Browse Source

fix: keep background job board cache-breakpoint-stable by anchoring to tail message

The Background Job Board was injected as its own trailing user message. The
provider caches only the last two messages (Anthropic applyCaching →
final.slice(-2)) and the SDK coalesces adjacent same-role messages, so the
separate board user-message merged into the preceding user tool_result turn and
collapsed both tail breakpoints onto the single merged block. The only readable
breakpoint then sat on the volatile board, which moves to a new tail every
request — so the deepest reusable breakpoint regressed to the system boundary
and the entire ~200k+ tail was re-written as cache on every call (verified in
same-session dumps 000164-000169: first byte divergence at the board position,
~243KB tail rewritten each request).

Fix: inject the board as a trailing PART on the tail message when it is a user
message (keeping the message COUNT identical to a board-free render so the
second tail breakpoint lands on the previous byte-stable real message), or as a
separate trailing user message when the tail is an assistant message (no merge,
so the assistant turn keeps its own readable breakpoint). The board is never
persisted, so historical bytes never change. Strip only the previous request's
TAIL board; a board found genuinely mid-history is left untouched (removing it
would rewrite already-sent bytes).

Adds board-cache-breakpoint.test.ts modeling the provider merge + slice(-2)
breakpoint placement, contrasting old vs new placement, and a field-dump
scenario. Updates the cache-safety property/harness fingerprints and the
canonical payload snapshot for the new trailing-part placement.
Tsanko Tsanev 3 weeks ago
parent
commit
5216a16be3

+ 0 - 10
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -455,16 +455,6 @@ exports[`cache-impact snapshots (update deliberately — see file header) transf
 ,
         "type": "text",
       },
-    ],
-  },
-  {
-    "info": {
-      "agent": "orchestrator",
-      "id": "m09-background-job-board",
-      "role": "user",
-      "sessionID": "ses_cache_safety_fixture",
-    },
-    "parts": [
       {
         "metadata": {
           "oh-my-opencode-slim.backgroundJobBoard": true,

+ 39 - 0
src/hooks/cache-safe-injection.ts

@@ -106,6 +106,45 @@ export function stripTaggedContent(
   }
 }
 
+/**
+ * Remove tagged parts only from the trailing zone of the payload — the
+ * contiguous run of whole synthetic tagged messages at the end plus the
+ * single real message immediately before them (where per-request volatile
+ * content is appended as a trailing part).
+ *
+ * A tagged part found genuinely mid-history (e.g. a legacy or accidentally
+ * persisted board) is LEFT in place: removing it would rewrite the bytes of
+ * an already-sent message and invalidate the provider cache for the entire
+ * tail after it. Leaving it costs nothing (it is already part of the cached
+ * prefix), while removing it costs a full tail re-cache.
+ */
+export function stripTailBoardContent(
+  messages: unknown[],
+  metadataKey: string,
+): void {
+  let i = messages.length - 1;
+
+  // Drop whole trailing synthetic tagged messages.
+  while (i >= 0) {
+    const message = messages[i];
+    if (!isVolatileTaggedMessage(message, metadataKey)) break;
+    messages.splice(i, 1);
+    i -= 1;
+  }
+
+  // Strip a tagged trailing part from the now-last real message only.
+  if (i >= 0) {
+    const message = messages[i];
+    if (isMessageWithParts(message)) {
+      const hadParts = message.parts.length > 0;
+      message.parts = message.parts.filter(
+        (part) => !isTaggedPart(part, metadataKey),
+      );
+      if (hadParts && message.parts.length === 0) messages.splice(i, 1);
+    }
+  }
+}
+
 /**
  * Append volatile content as its own synthetic message at the very end of
  * the payload. Call `stripTaggedContent` first so at most one instance

+ 14 - 5
src/hooks/cache-safety-harness.test.ts

@@ -14,7 +14,7 @@ import {
 } from '../config/constants';
 import { BackgroundJobBoard, createInternalAgentTextPart } from '../utils';
 import { createDisplayNameMentionRewriter } from '../utils/agent-variant';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { isTaggedPart } from './cache-safe-injection';
 import { createFilterAvailableSkillsHook } from './filter-available-skills';
 import { processImageAttachments } from './image-hook';
 import { createPhaseReminderHook } from './phase-reminder';
@@ -25,6 +25,7 @@ import {
   createTaskSessionManagerHook,
 } from './task-session-manager';
 import type { MessageWithParts } from './types';
+import { isMessageWithParts } from './types';
 
 export const SESSION_ID = 'ses_cache_safety_fixture';
 export const FIXTURE_NOW = 1_700_000_000_000;
@@ -229,11 +230,19 @@ export function turnEndIndices(history: unknown[]): number[] {
 }
 
 export function stableFingerprints(messages: unknown[]): string[] {
+  // The volatile board is a tagged part appended to the last real message (or,
+  // for legacy paths, a whole tagged trailing message). Both must be excluded
+  // from the stable fingerprint: strip tagged parts from every message and
+  // drop any message that was wholly volatile, then fingerprint what remains.
   return messages
-    .filter(
-      (message) =>
-        !isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
-    )
+    .flatMap((message) => {
+      if (!isMessageWithParts(message)) return [message];
+      const stableParts = message.parts.filter(
+        (part) => !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+      );
+      if (stableParts.length === 0) return [];
+      return [{ ...message, parts: stableParts }];
+    })
     .map((message) => JSON.stringify(message));
 }
 

+ 26 - 7
src/hooks/cache-safety.property.test.ts

@@ -22,7 +22,7 @@ import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
 import { readFileSync } from 'node:fs';
 import path from 'node:path';
 import { BackgroundJobsConfigSchema } from '../config';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { isTaggedPart, isVolatileTaggedMessage } from './cache-safe-injection';
 import {
   assistantTurn,
   type BoardStrategy,
@@ -227,15 +227,34 @@ describe('cache-safety: volatile content isolation', () => {
       stableFingerprints(withoutJobs.messages),
     );
 
-    // The volatile zone is exactly one tagged message, strictly trailing.
-    const volatile = withJobs.messages.filter((message) =>
-      isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    // The board is a single tagged part appended to the very end of the last
+    // message (never a separate trailing message that the provider SDK would
+    // coalesce into the last real message and rob of its cache breakpoint).
+    // Keeping the message COUNT identical to the no-board render lets the
+    // provider's last-two-messages breakpoint land on stable real content.
+    expect(withJobs.messages).toHaveLength(withoutJobs.messages.length);
+
+    const allTaggedParts = withJobs.messages.flatMap((message, index) =>
+      (message as MessageWithParts).parts
+        .map((part, partIndex) => ({ index, partIndex, part }))
+        .filter(({ part }) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
     );
-    expect(volatile).toHaveLength(1);
-    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(allTaggedParts).toHaveLength(1);
+
+    const lastMessage = withJobs.messages.at(-1) as MessageWithParts;
+    const boardHit = allTaggedParts[0];
+    // The one board part lives on the last message and is its last part.
+    expect(boardHit.index).toBe(withJobs.messages.length - 1);
+    expect(boardHit.partIndex).toBe(lastMessage.parts.length - 1);
+
+    // The no-board render carries no board part anywhere.
     expect(
       withoutJobs.messages.some((message) =>
-        isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        (message as MessageWithParts).parts.some((part) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
       ),
     ).toBe(false);
   });

+ 372 - 0
src/hooks/task-session-manager/board-cache-breakpoint.test.ts

@@ -0,0 +1,372 @@
+/**
+ * Regression coverage for the Background Job Board prompt-cache breakpoint bug.
+ *
+ * Real same-session dumps (2026-07-23, ses_11145863…, dumps 000164–000169)
+ * showed the same failure on every consecutive request pair: the board sat at
+ * the very tail, but the conversation advanced by ~2 messages per turn, so the
+ * first byte divergence landed exactly at the board position and ~243 KB of
+ * tail was re-written as cache on every call. The frozen cache-read at the
+ * system boundary is the field signature.
+ *
+ * Root cause: the provider caches only the last TWO messages (Anthropic:
+ * `provider/transform.ts applyCaching → final.slice(-2)`), and the provider
+ * SDK coalesces adjacent same-role `user` messages. A board injected as its
+ * OWN trailing `user` message merges into the preceding user tool_result
+ * message and collapses both tail breakpoints onto the single merged block —
+ * so the only readable breakpoint sits on the volatile board, which moves to a
+ * new tail every request. The deepest reusable breakpoint therefore regresses
+ * to the stable system boundary.
+ *
+ * Fix: inject the board as a trailing PART on the last real message. The
+ * message COUNT stays identical to a board-free render, so the provider's
+ * second tail breakpoint lands on the previous (byte-stable, real) message,
+ * which the next request reproduces exactly and can read from cache.
+ *
+ * This suite models core's caching + SDK merge to prove the readable
+ * breakpoint now falls on stable real content.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard } from '../../utils';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './index';
+
+const SESSION = 'ses_orchestrator_1114';
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 4,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+function userMsg(id: string, text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+/** An assistant turn issuing a tool call, followed by its user tool_result. */
+function toolTurn(id: string, output: string) {
+  return [
+    {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: `${id}-a`,
+      },
+      parts: [
+        { type: 'text', text: ' ' },
+        {
+          type: 'tool',
+          tool: 'read',
+          callID: `${id}-call`,
+          state: { status: 'completed', input: {}, output: 'x' },
+        },
+      ],
+    },
+    {
+      info: {
+        role: 'user',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: `${id}-r`,
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'read',
+          callID: `${id}-call`,
+          state: { status: 'completed', input: {}, output },
+        },
+      ],
+    },
+  ];
+}
+
+async function inject(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // opencode rebuilds msgs from storage every request; the board is never
+  // persisted, so each request starts from real history only.
+  const request = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, request as never);
+  await hook.injectBackgroundJobBoard({}, request as never);
+  return request.messages;
+}
+
+type Msg = {
+  info: { role: string; id?: string };
+  parts: { metadata?: Record<string, unknown> }[];
+};
+
+/**
+ * Faithful model of the provider cache pipeline that produced the field bug,
+ * in the exact order opencode runs it (`provider/transform.ts`):
+ *
+ *   1. `applyCaching` selects the breakpoint messages as `msgs.slice(-2)` over
+ *      the message array BEFORE the SDK coalesces roles. This ordering is why
+ *      the bug exists: a separate trailing board `user` message makes the last
+ *      two messages [tool_result(user), board(user)], so NEITHER breakpoint
+ *      lands on the preceding assistant turn.
+ *   2. the provider SDK then coalesces adjacent same-role messages, so the two
+ *      selected user messages merge and only the final block (the board) keeps
+ *      an effective cache_control.
+ *
+ * A breakpoint is READABLE next request only if the exact byte prefix ending
+ * at that breakpoint message reproduces. Returns the readable byte-prefixes
+ * this request establishes (one per breakpoint message, measured over the full
+ * ordered block stream).
+ */
+function readableCachePrefixes(messages: unknown[]): string[] {
+  const msgs = messages as Msg[];
+
+  // Assign each message to its post-merge coalesced-turn index.
+  const turnOfMessage: number[] = [];
+  const turnEndPrefix: string[] = [];
+  let acc = '';
+  let turnIndex = -1;
+  let prevRole: string | undefined;
+  for (const message of msgs) {
+    if (message.info.role !== prevRole) {
+      turnIndex += 1;
+      prevRole = message.info.role;
+    }
+    for (const part of message.parts) acc += JSON.stringify(part);
+    turnOfMessage.push(turnIndex);
+    turnEndPrefix[turnIndex] = acc; // running end-of-turn prefix
+  }
+
+  // applyCaching selects the last two MESSAGES (pre-merge). Each realizes its
+  // cache_control on the LAST block of the coalesced turn it merges into, so
+  // the readable prefix ends at that turn's end — not the message's own end.
+  const breakpointMessages = [msgs.length - 2, msgs.length - 1].filter(
+    (i) => i >= 0,
+  );
+  const prefixes = new Set<string>();
+  for (const mi of breakpointMessages) {
+    prefixes.add(turnEndPrefix[turnOfMessage[mi]]);
+  }
+  return [...prefixes];
+}
+
+/** Simulate the OLD placement: board as its own trailing user message. */
+function withSeparateBoardMessage(
+  messages: unknown[],
+  reminderText: string,
+): unknown[] {
+  return [
+    ...(messages as unknown[]),
+    {
+      info: {
+        role: 'user',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'board-msg',
+      },
+      parts: [
+        {
+          type: 'text',
+          synthetic: true,
+          text: reminderText,
+          metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+        },
+      ],
+    },
+  ];
+}
+
+describe('background job board cache breakpoint stability', () => {
+  test('a readable cache breakpoint falls on byte-stable real content across turns', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    // Request N: history ends with a tool_result turn; board injected at tail.
+    const historyN = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+    ];
+    const outN = await inject(hook, historyN);
+
+    // Request N+1: the agent loop advanced by another tool turn.
+    const historyN1 = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+      ...toolTurn('t2', 'result-2'),
+    ];
+    const outN1 = await inject(hook, historyN1);
+
+    // NEW placement: at least one readable byte-prefix from request N is a
+    // prefix of request N+1's full byte stream — the provider can resume the
+    // cache there instead of re-writing the whole tail.
+    const prefixesN = readableCachePrefixes(outN);
+    const streamN1 = readableCachePrefixes(outN1).at(-1) ?? '';
+    const readable = prefixesN.filter((p) => streamN1.startsWith(p));
+    expect(readable.length).toBeGreaterThan(0);
+
+    // CONTRAST: the OLD separate-message placement establishes no readable
+    // prefix — its only breakpoints sit on the merged tool_result+board turn
+    // and the board turn, both of which N+1 does not reproduce at that offset.
+    const oldReminder = board.formatForPrompt(SESSION) ?? '';
+    const oldN = withSeparateBoardMessage(
+      [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'result-1')],
+      oldReminder,
+    );
+    const oldN1 = withSeparateBoardMessage(
+      [
+        userMsg('u1', 'Coordinate'),
+        ...toolTurn('t1', 'result-1'),
+        ...toolTurn('t2', 'result-2'),
+      ],
+      oldReminder,
+    );
+    const oldPrefixesN = readableCachePrefixes(oldN);
+    const oldStreamN1 = readableCachePrefixes(oldN1).at(-1) ?? '';
+    const oldReadable = oldPrefixesN.filter((p) => oldStreamN1.startsWith(p));
+    expect(oldReadable.length).toBe(0);
+  });
+
+  test('board is a trailing part on the last message, keeping message count board-free-equal', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    const history = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+    ];
+
+    const emptyHook = createHook(new BackgroundJobBoard());
+    const boardFree = await inject(emptyHook, history);
+    const withBoard = await inject(hook, history);
+
+    // No new message is created for the board.
+    expect((withBoard as unknown[]).length).toBe(
+      (boardFree as unknown[]).length,
+    );
+
+    // The single board part is the last part of the last message.
+    const boardParts = (withBoard as Msg[]).flatMap((m, i) =>
+      m.parts
+        .map((p, pi) => ({ i, pi, p }))
+        .filter(
+          ({ p }) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+        ),
+    );
+    expect(boardParts).toHaveLength(1);
+    const last = withBoard.at(-1) as Msg;
+    expect(boardParts[0].i).toBe(withBoard.length - 1);
+    expect(boardParts[0].pi).toBe(last.parts.length - 1);
+  });
+
+  test('previously-sent history bytes never change across a growing conversation', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    // Fingerprint of the stable (non-board) content of every message.
+    const stableSerialize = (messages: unknown[]): string[] =>
+      (messages as Msg[]).map((m) =>
+        JSON.stringify({
+          info: m.info,
+          parts: m.parts.filter(
+            (p) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] !== true,
+          ),
+        }),
+      );
+
+    const historyN = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
+    const outN = stableSerialize(await inject(hook, historyN));
+
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    const historyN1 = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outN1 = stableSerialize(await inject(hook, historyN1));
+
+    // Every message present in request N must be byte-identical in N+1: the
+    // board (excluded here) is the only thing that ever changes, and it rides
+    // on the last message's trailing part, so real history is untouched.
+    expect(outN1.slice(0, outN.length)).toEqual(outN);
+  });
+
+  test('field-dump scenario: tail is a tool_result user turn preceded by an assistant turn', async () => {
+    // Reconstructs the real bust (2026-07-23 dumps 000166→000167): the tail was
+    // a user tool_result message preceded by an assistant tool-call message,
+    // and the conversation advanced by one more tool turn between requests. The
+    // board must attach to the tool_result tail so the preceding assistant turn
+    // keeps a readable breakpoint that the next request reproduces.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'grok research',
+    });
+    const hook = createHook(board);
+
+    const base = [
+      userMsg('u1', 'Coordinate the work'),
+      ...toolTurn('t1', 'r1'),
+    ];
+    const outN = await inject(hook, base);
+
+    // The board rode on the tail user (tool_result) message, not a new message.
+    expect((outN as unknown[]).length).toBe(base.length);
+    const tail = outN.at(-1) as Msg;
+    expect(tail.info.role).toBe('user');
+    expect(
+      tail.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    // Advance by one more tool turn (as the loop did between dumps).
+    const advanced = [
+      userMsg('u1', 'Coordinate the work'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outN1 = await inject(hook, advanced);
+
+    // The assistant turn that preceded the board tail in request N is present
+    // and byte-identical in request N+1 — the readable cache boundary.
+    const prefixesN = readableCachePrefixes(outN);
+    const fullN1 = readableCachePrefixes(outN1).at(-1) ?? '';
+    expect(prefixesN.some((p) => fullN1.startsWith(p))).toBe(true);
+  });
+});

+ 98 - 31
src/hooks/task-session-manager/board-injection.ts

@@ -20,10 +20,12 @@ import {
 import { isRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import {
+  appendTaggedSyntheticPart,
   appendTrailingVolatileMessage,
   createTaggedSyntheticPart,
   isTaggedPart,
   stripTaggedContent,
+  stripTailBoardContent,
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
 import { isMessageWithParts, isUserMessageWithParts } from '../types';
@@ -316,9 +318,11 @@ export async function injectBackgroundJobBoard(
   const messages = Array.isArray(output.messages) ? output.messages : [];
 
   if (state.strategy === 'latest') {
-    // Strip previously injected board content: parts attached to real
-    // messages (legacy placement) and whole synthetic board messages.
-    stripTaggedContent(messages, state.metadataKey);
+    // Strip only the previous request's TAIL board (a trailing tagged part on
+    // the last real message, plus any whole synthetic trailing board
+    // messages). A tagged board found genuinely mid-history is left untouched:
+    // removing it would rewrite already-sent bytes and bust the whole tail.
+    stripTailBoardContent(messages, state.metadataKey);
   }
 
   if (state.strategy === 'checkpoint-compatible') {
@@ -326,56 +330,119 @@ export async function injectBackgroundJobBoard(
     return;
   }
 
+  // Find the anchor: the last real (non-fully-tagged) message. It decides
+  // orchestrator/session eligibility and is where the board attaches so it
+  // stays strictly at the tail.
+  let anchor: MessageWithParts | undefined;
   for (let i = messages.length - 1; i >= 0; i -= 1) {
     const message = messages[i];
+    if (!isMessageWithParts(message)) continue;
     if (
-      isMessageWithParts(message) &&
       message.parts.length > 0 &&
       message.parts.every((part) => isTaggedPart(part, state.metadataKey))
     ) {
       continue;
     }
-    if (!isUserMessageWithParts(message)) continue;
-    if (message.info.agent && message.info.agent !== 'orchestrator') return;
-    if (
-      !message.info.sessionID ||
-      !state.shouldManageSession(message.info.sessionID)
-    ) {
-      return;
-    }
+    anchor = message;
+    break;
+  }
+  if (!anchor) return;
+
+  // Eligibility is driven by the most recent orchestrator user message (the
+  // triggering turn), which also guards against injecting on specialist
+  // sessions or internal-initiator turns.
+  const trigger = findTriggeringUserMessage(messages, state.metadataKey);
+  if (!trigger) return;
+  if (trigger.info.agent && trigger.info.agent !== 'orchestrator') return;
+  if (
+    !trigger.info.sessionID ||
+    !state.shouldManageSession(trigger.info.sessionID)
+  ) {
+    return;
+  }
 
-    const reminder = state.backgroundJobBoard.formatForPrompt(
-      message.info.sessionID,
-    );
-    if (!reminder) return;
+  const reminder = state.backgroundJobBoard.formatForPrompt(
+    trigger.info.sessionID,
+  );
+  if (!reminder) return;
 
-    const textPart = message.parts.find(
-      (part) => part.type === 'text' && typeof part.text === 'string',
-    );
-    if (!textPart || isInternalInitiatorPart(textPart)) return;
-
-    rememberInjectedTerminalJobs(state, message.info.sessionID);
-    // Append the board as its own trailing message rather than mutating
-    // an existing user message. In long tool loops the latest user
-    // message becomes deep history; rewriting it on board state changes
-    // would invalidate the provider prompt cache for everything after
-    // it. A trailing message keeps board churn at the end of the
-    // prompt, where it only costs itself.
+  const textPart = trigger.parts.find(
+    (part) => part.type === 'text' && typeof part.text === 'string',
+  );
+  if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+  rememberInjectedTerminalJobs(state, trigger.info.sessionID);
+
+  // Placement rules (prompt-cache safety):
+  //
+  // Provider caches read from the last two messages (Anthropic:
+  // provider/transform.ts applyCaching → final.slice(-2)), and the provider
+  // SDK coalesces adjacent same-role messages. A board injected as its own
+  // trailing `user` message merges into a preceding user tool_result message,
+  // collapsing both tail breakpoints onto the merged block — so the only
+  // readable breakpoint sits on the volatile board. Because the board moves to
+  // a new tail every request, the deepest reusable breakpoint regresses to the
+  // stable system boundary and the entire tail re-writes as cache every call.
+  //
+  // - If the tail is a user message, append the board as its trailing PART:
+  //   the message COUNT stays identical to a board-free render, so the second
+  //   tail breakpoint lands on the previous (byte-stable, real) message.
+  // - If the tail is an assistant message, a separate trailing user board
+  //   message does NOT merge (different role), so the assistant message keeps
+  //   its own readable breakpoint.
+  //
+  // Either way the board never invalidates already-sent bytes: it is never
+  // persisted, so the next request rebuilds real history board-free.
+  if (anchor.info.role === 'user') {
+    appendTaggedSyntheticPart(anchor, {
+      text: reminder,
+      metadataKey: state.metadataKey,
+    });
+  } else {
     appendTrailingVolatileMessage(
       messages,
       {
-        ...message.info,
-        id: `${message.info.id}-background-job-board`,
+        ...trigger.info,
+        id: `${trigger.info.id ?? 'board'}-background-job-board`,
       },
       {
         text: reminder,
         metadataKey: state.metadataKey,
       },
     );
-    return;
   }
 }
 
+/**
+ * The most recent real (non-board) user message that carries a text part —
+ * used only to validate injection eligibility and derive session/text context.
+ * Tool-result-only user turns (no text part) are skipped so a long tool loop
+ * still resolves the triggering orchestrator turn. Board placement targets the
+ * tail (see injectBackgroundJobBoard).
+ */
+function findTriggeringUserMessage(
+  messages: unknown[],
+  metadataKey: string,
+): MessageWithParts | undefined {
+  for (let i = messages.length - 1; i >= 0; i -= 1) {
+    const message = messages[i];
+    if (!isMessageWithParts(message)) continue;
+    if (
+      message.parts.length > 0 &&
+      message.parts.every((part) => isTaggedPart(part, metadataKey))
+    ) {
+      continue;
+    }
+    if (!isUserMessageWithParts(message)) continue;
+    const hasText = message.parts.some(
+      (part) => part.type === 'text' && typeof part.text === 'string',
+    );
+    if (!hasText) continue;
+    return message;
+  }
+  return undefined;
+}
+
 function injectCheckpointBoard(
   state: InjectionState,
   messages: unknown[],

+ 105 - 36
src/hooks/task-session-manager/index.test.ts

@@ -116,6 +116,9 @@ function createAnchoredMessages(sessionID: string, texts = ['R1']) {
 }
 
 function boardText(messages: { messages: unknown[] }): string | undefined {
+  // The board is injected as a trailing tagged PART on the last message
+  // (keeping the message count stable so the provider's tail cache
+  // breakpoint lands on stable real content). It is always the last part.
   const last = messages.messages.at(-1) as
     | {
         parts?: {
@@ -124,7 +127,7 @@ function boardText(messages: { messages: unknown[] }): string | undefined {
         }[];
       }
     | undefined;
-  const part = last?.parts?.[0];
+  const part = last?.parts?.at(-1);
   return part?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true
     ? part.text
     : undefined;
@@ -202,7 +205,9 @@ describe('task-session-manager hook', () => {
 
     await transformMessages(hook, messages as never);
 
-    expect(messages.messages).toHaveLength(6);
+    // Board is appended as a trailing part on the last user message, not as a
+    // new message, so the message count is unchanged.
+    expect(messages.messages).toHaveLength(5);
     expect(boardText(messages)).toContain('### Background Job Board');
     expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
@@ -249,17 +254,20 @@ describe('task-session-manager hook', () => {
     const messages = createMessages('parent-1', 'do something');
     await hook.injectBackgroundJobBoard({}, messages);
 
+    // Board is appended as a trailing part on the last (only) user message.
+    // The message count is unchanged; the real text part is preserved and the
+    // board part follows it.
     const userMessage = messages.messages[0];
-    expect(userMessage.parts).toHaveLength(1);
+    expect(messages.messages).toHaveLength(1);
+    expect(userMessage.parts).toHaveLength(2);
     expect(userMessage.parts[0].text).toBe('do something');
     const boardMessage = messages.messages.at(-1) as {
       info: { role?: string; sessionID?: string };
       parts: { text?: string; synthetic?: boolean }[];
     };
-    expect(messages.messages).toHaveLength(2);
     expect(boardMessage.info.role).toBe('user');
     expect(boardMessage.info.sessionID).toBe('parent-1');
-    const boardPart = boardMessage.parts[0] as {
+    const boardPart = boardMessage.parts.at(-1) as {
       text?: string;
       synthetic?: boolean;
     };
@@ -303,7 +311,9 @@ describe('task-session-manager hook', () => {
     expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
-    expect(messages.messages[0].parts).toHaveLength(1);
+    // The real sentinel-bearing part is preserved; the board is appended after
+    // it as a trailing part on the same (last) message.
+    expect(messages.messages[0].parts).toHaveLength(2);
     expect(messages.messages[0].parts[0].text).toBe(
       'SENTINEL: background-job-board-v2',
     );
@@ -333,7 +343,10 @@ describe('task-session-manager hook', () => {
     expect(messages.messages.at(-1)).toBe(boardMessages[0]);
   });
 
-  test('strips stale board parts from history before injecting the latest state', async () => {
+  test('strips the tail board and re-appends the latest state on the new tail', async () => {
+    // Production never sees a board in storage (synthetic parts are not
+    // persisted), so the tail board from the previous request is the only one
+    // present and is stripped in place before re-injection.
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'child-1',
@@ -344,11 +357,10 @@ describe('task-session-manager hook', () => {
     const { hook } = createHook({ backgroundJobBoard: board });
     const messages = createMessages('parent-1', 'first turn');
 
+    // Simulate the realistic path: the board is transient, so a fresh request
+    // rebuilds real messages only and the tail (now "second turn") carries the
+    // previous board as its trailing part before re-injection.
     await hook.injectBackgroundJobBoard({}, messages);
-    messages.messages.push({
-      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
-      parts: [{ type: 'text', text: 'second turn' }],
-    });
     board.updateStatus({
       taskID: 'child-1',
       state: 'completed',
@@ -362,11 +374,52 @@ describe('task-session-manager hook', () => {
         (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
       ),
     );
+    // The stale tail board is stripped and exactly one fresh board remains.
     expect(boardParts).toHaveLength(1);
     expect(boardParts[0].text).toContain('completed, unreconciled');
-    expect(messages.messages[0].parts).toHaveLength(1);
-    expect(messages.messages.at(-1)?.parts[0]).toBe(boardParts[0]);
-    expect(messages.messages.at(-2)?.parts[0].text).toBe('second turn');
+    // Board is the last part of the last (real) message; the real text part is
+    // preserved before it.
+    expect(messages.messages).toHaveLength(1);
+    expect(messages.messages[0].parts).toHaveLength(2);
+    expect(messages.messages[0].parts[0].text).toBe('first turn');
+    expect(messages.messages.at(-1)?.parts.at(-1)).toBe(boardParts[0]);
+  });
+
+  test('leaves a genuinely mid-history stale board untouched (cache invariant)', async () => {
+    // If a board is found mid-history (e.g. a legacy/persisted block), removing
+    // it would rewrite already-sent bytes and bust the whole tail. It is left
+    // in place; the fresh board is appended to the current tail.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({ backgroundJobBoard: board });
+    const messages = createMessages('parent-1', 'first turn');
+
+    await hook.injectBackgroundJobBoard({}, messages);
+    // A NEW real message arrives after the previous board, pushing it
+    // mid-history (this only happens if a board was persisted into storage).
+    messages.messages.push({
+      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+      parts: [{ type: 'text', text: 'second turn' }],
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished mapping',
+    });
+
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    // The mid-history board (on message[0]) is preserved; a fresh board is
+    // appended to the current tail (message[1]).
+    expect(messages.messages[0].parts.at(-1)?.text).toContain('running');
+    const tailBoard = messages.messages.at(-1)?.parts.at(-1);
+    expect(tailBoard?.text).toContain('completed, unreconciled');
+    expect(messages.messages.at(-1)?.parts[0].text).toBe('second turn');
   });
 
   test('latest mode ignores maxRetainedSnapshots and replaces the board', async () => {
@@ -384,16 +437,14 @@ describe('task-session-manager hook', () => {
     const messages = createMessages('parent-1', 'first turn');
 
     await hook.injectBackgroundJobBoard({}, messages);
-    messages.messages.push({
-      info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
-      parts: [{ type: 'text', text: 'second turn' }],
-    });
     board.updateStatus({
       taskID: 'child-1',
       state: 'completed',
       resultSummary: 'finished mapping',
     });
 
+    // Re-injecting on the same tail strips the previous tail board and
+    // re-appends the updated state — no retained snapshots in latest mode.
     await hook.injectBackgroundJobBoard({}, messages);
 
     expect(boardSnapshotIDs(messages)).toHaveLength(0);
@@ -404,10 +455,10 @@ describe('task-session-manager hook', () => {
     );
     expect(boardParts).toHaveLength(1);
     expect(boardParts[0].text).toContain('completed, unreconciled');
-    expect(messages.messages.at(-1)?.parts[0]).toBe(boardParts[0]);
+    expect(messages.messages.at(-1)?.parts.at(-1)).toBe(boardParts[0]);
   });
 
-  test('strips JSON-persisted board parts from earlier messages', async () => {
+  test('leaves a JSON-persisted mid-history board message untouched (cache invariant)', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'child-1',
@@ -420,8 +471,10 @@ describe('task-session-manager hook', () => {
 
     await hook.injectBackgroundJobBoard({}, messages);
     const persistedBoard = JSON.parse(
-      JSON.stringify(messages.messages.at(-1)?.parts[0]),
+      JSON.stringify(messages.messages.at(-1)?.parts.at(-1)),
     );
+    // A board persisted mid-history (not at the tail): stripping it would
+    // rewrite already-sent bytes, so it must be left in place.
     messages.messages = [
       {
         info: { role: 'assistant' },
@@ -435,9 +488,13 @@ describe('task-session-manager hook', () => {
 
     await hook.injectBackgroundJobBoard({}, messages);
 
+    // Mid-history board message preserved; fresh board appended to the tail.
     expect(messages.messages).toHaveLength(2);
-    expect(messages.messages[0].parts[0].text).toBe('current turn');
-    expect(messages.messages[1].parts[0].metadata).toEqual({
+    expect(messages.messages[0].parts[0].metadata).toEqual({
+      [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
+    });
+    expect(messages.messages[1].parts[0].text).toBe('current turn');
+    expect(messages.messages[1].parts.at(-1)?.metadata).toEqual({
       [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
     });
   });
@@ -820,30 +877,36 @@ describe('task-session-manager hook', () => {
     expect(boardSnapshotIDs(secondEpochRequest)[1]).toEndWith(':21');
   });
 
-  test('strips existing board parts when no jobs produce a prompt', async () => {
+  test('strips the tail board when no jobs produce a prompt, leaving mid-history', async () => {
     const { hook } = createHook({
       backgroundJobBoard: new BackgroundJobBoard(),
     });
-    const staleBoard = {
+    const staleBoard = () => ({
       type: 'text',
       synthetic: true,
       text: '<system-reminder>stale</system-reminder>',
       metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
-    };
+    });
     const messages = {
       messages: [
-        { info: { role: 'assistant' }, parts: [staleBoard] },
+        { info: { role: 'assistant' }, parts: [staleBoard()] },
         {
           info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
-          parts: [{ type: 'text', text: 'current turn' }, staleBoard],
+          parts: [{ type: 'text', text: 'current turn' }, staleBoard()],
         },
       ],
     };
 
     await hook.injectBackgroundJobBoard({}, messages);
 
-    expect(messages.messages).toHaveLength(1);
-    expect(messages.messages[0].parts).toEqual([
+    // With no jobs there is nothing to inject. The tail board part is stripped
+    // from the last message; the mid-history board message is left untouched
+    // (removing it would rewrite already-sent bytes).
+    expect(messages.messages).toHaveLength(2);
+    expect(messages.messages[0].parts[0].metadata).toEqual({
+      [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
+    });
+    expect(messages.messages[1].parts).toEqual([
       { type: 'text', text: 'current turn' },
     ]);
   });
@@ -875,19 +938,23 @@ describe('task-session-manager hook', () => {
     );
     await hook.injectBackgroundJobBoard({}, nextRequest);
 
+    // The board is a trailing PART on the last (only) message, so the message
+    // count stays 1. The previous tail board part is stripped and re-appended,
+    // leaving exactly one board — the last part of the message.
     const parts = nextRequest.messages[0].parts;
     expect(
       parts.filter(
         (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
       ),
-    ).toHaveLength(0);
+    ).toHaveLength(1);
+    expect(nextRequest.messages).toHaveLength(1);
     expect(parts.at(-1)?.metadata).toEqual({
-      [PHASE_REMINDER_METADATA_KEY]: true,
-    });
-    expect(nextRequest.messages).toHaveLength(2);
-    expect(nextRequest.messages.at(-1)?.parts[0].metadata).toEqual({
       [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
     });
+    // The phase reminder is preserved (immediately before the board).
+    expect(parts.at(-2)?.metadata).toEqual({
+      [PHASE_REMINDER_METADATA_KEY]: true,
+    });
   });
 
   test('does not let user-visible internal marker suppress board injection', async () => {
@@ -919,7 +986,9 @@ describe('task-session-manager hook', () => {
     expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
-    expect(messages.messages[0].parts).toHaveLength(1);
+    // The original marker-bearing part is preserved; the board is appended
+    // after it as a trailing part on the same message.
+    expect(messages.messages[0].parts).toHaveLength(2);
     expect(messages.messages[0].parts[0].text).toBe(
       SLIM_INTERNAL_INITIATOR_MARKER,
     );