Browse Source

Merge PR #889: preserve board cache breakpoints

Alvin Unreal 1 week ago
parent
commit
be46f39dbb

+ 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,

+ 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,
@@ -225,15 +225,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);
   });

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

@@ -0,0 +1,433 @@
+/**
+ * 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('an already-sent tail board is not stripped when the tail advances (dumps 000086->000087)', async () => {
+    // Faithful reconstruction of the live cache bust (ses_11145863, dumps
+    // 000086 A -> 000087 B). In A the tail was a user tool_result message that
+    // carried the board as an appended trailing part; that request was SENT to
+    // the provider and cached with the board on that message. B then advanced
+    // by two new messages (assistant + user tool_result). The provider caches a
+    // byte prefix, so every message it already received in A must be byte-
+    // identical in B — INCLUDING the board bytes on the old tail. The #889
+    // append-on-tail placement dropped that board when the tail advanced,
+    // rewriting the already-sent old-tail message (A: 1376B -> B: 652B in the
+    // field dump) and busting the cache prefix from that message onward.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'grok research',
+    });
+    const hook = createHook(board);
+
+    // FULL serialization including the board part — a byte-exact fingerprint of
+    // what the provider actually received for each message.
+    const fullSerialize = (messages: unknown[]): string[] =>
+      (messages as Msg[]).map((m) => JSON.stringify(m));
+
+    // Request A: tail is a user tool_result turn; board rides on it as a
+    // trailing part (the #889 "tail is user" branch, matching dump 000086).
+    const historyA = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
+    const outA = await inject(hook, historyA);
+    const serA = fullSerialize(outA);
+
+    // The old tail carried the board (as sent to the provider in request A).
+    const oldTailA = outA.at(-1) as Msg;
+    expect(oldTailA.info.role).toBe('user');
+    expect(
+      oldTailA.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    // Request B: the loop advanced by exactly two new messages (assistant +
+    // user tool_result), matching dump 000087's two extra tail messages.
+    const historyB = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outB = await inject(hook, historyB);
+    const serB = fullSerialize(outB);
+
+    // Every message the provider received in request A must be byte-identical
+    // in request B, board bytes included. In particular the old tail (index
+    // serA.length - 1) must still carry its board — it must NOT be stripped.
+    expect(serB.slice(0, serA.length)).toEqual(serA);
+
+    // Explicit guard on the exact failure the field dump showed: the old-tail
+    // message keeps its board trailing part in B.
+    const oldTailB = outB[serA.length - 1] as Msg;
+    expect(
+      oldTailB.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+  });
+
+  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);
+  });
+});

+ 411 - 48
src/hooks/task-session-manager/board-injection.ts

@@ -22,9 +22,12 @@ import {
 import { isRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import {
+  appendTaggedSyntheticPart,
   appendTrailingVolatileMessage,
   createTaggedSyntheticPart,
+  hasTaggedPart,
   isTaggedPart,
+  isVolatileTaggedMessage,
   stripTaggedContent,
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
@@ -60,6 +63,27 @@ export type RetainedBoardSnapshotState = {
   firstRealMessageAnchorKey?: string;
 };
 
+/**
+ * A board the `latest` strategy has already placed (and therefore already sent
+ * to the provider) on a specific anchor message. Replayed byte-identically on
+ * every later request once that anchor is no longer the tail, so a board that
+ * was sent on a message the provider has cached never disappears.
+ *
+ * Only ONE placement is ever retained: a board that rode as a trailing PART on
+ * a USER anchor (`anchorRole: 'user'`). That is the only shape that can be
+ * reproduced later without inserting a message mid-array (A1) or grafting board
+ * text onto a non-user message (A3). `anchorRole` stays a plain string so
+ * legacy in-memory entries recorded by an earlier build (notably `'assistant'`,
+ * which was replayed by splicing a synthetic message directly after the anchor
+ * and could orphan a tool call from its result) are recognized and dropped
+ * instead of replayed.
+ */
+type RetainedTailBoard = {
+  anchorId: string;
+  anchorRole: string;
+  text: string;
+};
+
 // ── State shape ────────────────────────────────────────────────────────
 
 export type InjectedTerminalJobs = {
@@ -88,6 +112,15 @@ export interface InjectionState {
     prune(board: { taskIDs(): Set<string> }): void;
   };
   retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+  /**
+   * Per-session log of boards the `latest` strategy has placed on real anchor
+   * messages, keyed by anchor message id. Once an anchor is no longer the tail
+   * its board is replayed byte-identically every later request, so a board sent
+   * on a now-mid-history message is never stripped (which would rewrite already
+   * cached bytes and bust the provider prompt-cache prefix from that message
+   * onward - the field bust in dumps 000086->000087).
+   */
+  retainedTailBoards: Map<string, Map<string, RetainedTailBoard>>;
 }
 
 // ── Helpers ────────────────────────────────────────────────────────────
@@ -104,6 +137,34 @@ function sha256Hash(str: string): string {
   return createHash('sha256').update(str).digest('hex');
 }
 
+/**
+ * True when board text may ride on this message as a trailing PART.
+ *
+ * Board text may ONLY ever be appended to a `user` message. This is the single
+ * hard requirement behind the `AI_InvalidPromptError` this guard exists to
+ * prevent, and it is a property of the host's message conversion:
+ *
+ * - the USER branch of `MessageV2.toModelMessagesEffect` copies text parts as
+ *   `{ type: 'text', text }` and DISCARDS `part.metadata`;
+ * - the ASSISTANT branch copies it as
+ *   `{ type: 'text', text, providerMetadata: part.metadata }`, which
+ *   `convertToModelMessages` then forwards as `providerOptions`.
+ *
+ * `providerOptions` is validated as `Record<string, Record<string, JSONValue>>`.
+ * A board part's metadata is `{ '<metadataKey>': true }` — a boolean, not a
+ * nested record — so any board text landing on an assistant-role message fails
+ * `ModelMessage[]` validation and aborts the request before it is sent.
+ *
+ * Tool parts do not disqualify a user message: the user branch of the converter
+ * only emits text/file/compaction/subtask parts, so a user message's tool parts
+ * never become tool-call or tool-result content and cannot be separated from a
+ * pairing by appended text. Keeping such anchors eligible is what preserves the
+ * #889 tail-breakpoint placement (A4).
+ */
+function canCarryBoardPart(message: MessageWithParts): boolean {
+  return message.info.role === 'user';
+}
+
 function createOccurrenceId(
   part: MessagePart,
   message: MessageWithParts,
@@ -442,74 +503,376 @@ export async function injectBackgroundJobBoard(
 ): Promise<void> {
   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);
-  }
-
   if (state.strategy === 'checkpoint-compatible') {
     injectCheckpointBoard(state, messages);
     return;
   }
 
+  injectLatestBoard(state, messages);
+}
+
+/**
+ * `latest` strategy: keep exactly one FRESH board on the current tail while
+ * every board already sent on an earlier (now mid-history) message stays put,
+ * byte-identical.
+ *
+ * The board is never persisted, so opencode rebuilds real history board-free
+ * each request. That means the plugin — not storage — must reproduce every
+ * board it previously placed. The prior implementation instead STRIPPED the
+ * old tail's board and re-appended a fresh board on the new tail
+ * (`stripTailBoardContent` + append). Because the old tail was already sent to
+ * the provider WITH its board, dropping it rewrote an already-cached message
+ * and invalidated the provider prompt-cache prefix from that message onward —
+ * the whole tail re-cached every turn (field bust: ses_11145863 dumps
+ * 000086→000087, old-tail user message 1376B→652B as its board vanished).
+ *
+ * Fix (append-only w.r.t. already-sent messages):
+ *   1. Strip the board ONLY from the current tail zone (the tail message's
+ *      trailing board part + whole synthetic board messages trailing it). That
+ *      zone re-caches every turn, so rewriting it is byte-safe.
+ *   2. Replay every FROZEN board (one placed on a message that is no longer the
+ *      tail) byte-identically on its original anchor, so an already-sent board
+ *      never disappears.
+ *   3. Add ONE fresh board to the current tail, preserving the #889 placement
+ *      (trailing PART on a user tail; separate trailing message on an assistant
+ *      tail) so the tail breakpoint still lands on stable content, and record
+ *      it so the NEXT request can freeze/replay it once the tail advances.
+ * A board on any earlier message is never mutated or stripped.
+ */
+function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
+  // The current tail anchor: the last real (non-fully-tagged) message. It is
+  // the ONLY message whose board is volatile — the tail re-caches anyway, so
+  // freshening its board is free. Every earlier message was already sent, so
+  // its board must never change.
+  const anchor = findBoardAnchor(messages, state.metadataKey);
+  const anchorId = anchor ? boardAnchorId(anchor) : undefined;
+
+  // Strip the board from the current tail zone only (byte-safe volatile zone).
+  stripCurrentTailBoard(messages, state.metadataKey, anchor);
+
+  // Eligibility is driven by the most recent orchestrator user message (the
+  // triggering turn), which also guards against specialist/internal turns.
+  const trigger = findTriggeringUserMessage(messages, state.metadataKey);
+  const sessionID = trigger?.info.sessionID;
+
+  // Replay frozen boards even when this turn is ineligible for a fresh board
+  // (internal-initiator turn, empty board): an already-sent board must stay put
+  // regardless of the current turn. The current tail anchor is skipped — its
+  // board is (re)placed fresh below.
+  if (sessionID !== undefined) {
+    replayRetainedTailBoards(state, sessionID, messages, anchorId);
+  }
+
+  if (!trigger) return;
+  if (trigger.info.agent && trigger.info.agent !== 'orchestrator') return;
+  if (!sessionID || !state.shouldManageSession(sessionID)) return;
+  if (!anchor) return;
+
+  const shapeKey = promptShapeKey(realMessages(messages, state.metadataKey));
+  reconcileConsumedTerminalJobs(state, sessionID, shapeKey);
+
+  const boardMeta =
+    state.backgroundJobBoard.formatForPromptWithMetadata(sessionID);
+  const reminder = boardMeta?.text;
+  if (!reminder) return;
+
+  const textPart = trigger.parts.find(
+    (part) => part.type === 'text' && typeof part.text === 'string',
+  );
+  if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+  rememberInjectedTerminalJobs(
+    state,
+    sessionID,
+    boardMeta.terminalUnreconciledTaskIDs,
+    shapeKey,
+  );
+
+  // Placement rules — correctness first, then prompt-cache safety.
+  //
+  // Correctness (invariants A1-A3): the transformed array is converted to
+  // `ModelMessage[]` and schema-validated before the request is sent, and a
+  // violation raises `AI_InvalidPromptError` ("The messages do not match the
+  // ModelMessage[] schema") before the HTTP call — a hard, unrecoverable turn
+  // failure. Two rules keep the array valid:
+  //
+  //   * board text only ever rides on a `user` message (A3). The assistant
+  //     branch of the host's converter forwards `part.metadata` as
+  //     `providerMetadata`/`providerOptions`, which must be a nested record; a
+  //     board part's `{ '<key>': true }` is a boolean and fails validation. The
+  //     user branch drops metadata entirely, so it is safe.
+  //   * a synthetic board MESSAGE is only ever appended at the very END of the
+  //     array (A1). Inserting one mid-array can land between an assistant
+  //     `task` tool_call and its tool_result and break the pairing the schema
+  //     requires (A2); appending at the end cannot (A2 holds by construction).
+  //
+  // Cache safety (within the above):
+  //
+  // 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 text 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. This is
+  //   also the only placement replayable later without inserting a message
+  //   mid-array, so it is the only one recorded for replay.
+  // - If the tail is an assistant message, a separate trailing USER board
+  //   message is appended at the very end of the array. It does not merge
+  //   (different role), so the assistant message keeps its own readable
+  //   breakpoint, and it uses the USER `trigger.info` — never `anchor.info` —
+  //   so the message carrying board text is genuinely user-role (A3).
+  const recordId = anchorId ?? boardAnchorFallbackId(anchor);
+  if (canCarryBoardPart(anchor)) {
+    appendTaggedSyntheticPart(anchor, {
+      text: reminder,
+      metadataKey: state.metadataKey,
+    });
+    // Recording the placement under the tail's anchor id lets the NEXT request
+    // (once the tail advances) replay this exact board on this exact message,
+    // so the bytes the provider just cached for this message never change.
+    rememberTailBoard(state, sessionID, {
+      anchorId: recordId,
+      anchorRole: 'user',
+      text: reminder,
+    });
+  } else {
+    appendTrailingVolatileMessage(
+      messages,
+      {
+        ...trigger.info,
+        id: `${trigger.info.id ?? 'board'}-background-job-board`,
+      },
+      {
+        text: reminder,
+        metadataKey: state.metadataKey,
+      },
+    );
+    // A5: this placement is deliberately NOT retained for replay. Reproducing
+    // it once the tail advances would require splicing a message back into the
+    // middle of the array, which is exactly what orphaned an assistant
+    // tool_call from its tool_result and made the whole request invalid. A
+    // cache bust (the board's bytes move to the new tail) is strictly
+    // preferable to a hard `AI_InvalidPromptError`, so the board is simply
+    // re-rendered on the new tail instead. Any stale entry for this anchor is
+    // dropped so the retained map cannot grow or retry the unsafe placement.
+    forgetTailBoard(state, sessionID, recordId);
+  }
+}
+
+/** The last real (non-fully-tagged) message — the current tail anchor. */
+function findBoardAnchor(
+  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 (
-      isMessageWithParts(message) &&
       message.parts.length > 0 &&
-      message.parts.every((part) => isTaggedPart(part, state.metadataKey))
+      message.parts.every((part) => isTaggedPart(part, metadataKey))
     ) {
       continue;
     }
-    if (!isUserMessageWithParts(message)) continue;
-    if (message.info.agent && message.info.agent !== 'orchestrator') return;
+    return message;
+  }
+  return undefined;
+}
+
+/**
+ * Strip the board ONLY from the current tail zone: whole synthetic board
+ * messages trailing the payload, plus a trailing board part on the current
+ * tail anchor. This is the volatile zone (the tail re-caches every turn), so
+ * rewriting it is byte-safe. A board on any earlier message is untouched —
+ * removing it would rewrite already-sent, already-cached bytes.
+ */
+function stripCurrentTailBoard(
+  messages: unknown[],
+  metadataKey: string,
+  anchor: MessageWithParts | undefined,
+): void {
+  // Drop whole synthetic board messages trailing the payload.
+  let i = messages.length - 1;
+  while (i >= 0) {
+    const message = messages[i];
+    if (!isVolatileTaggedMessage(message, metadataKey)) break;
+    messages.splice(i, 1);
+    i -= 1;
+  }
+
+  // Strip a trailing board part from the current tail anchor only.
+  if (anchor) {
+    anchor.parts = anchor.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+  }
+}
+
+/**
+ * A stable id for an anchor message that lacks an `info.id` (test fixtures,
+ * legacy shapes). Derived from role + concatenated REAL text (tagged board
+ * parts excluded) so the id is identical whether or not a board currently
+ * rides on the message — otherwise appending a board would change the id and
+ * defeat cross-request anchor matching.
+ */
+function boardAnchorFallbackId(message: MessageWithParts): string {
+  const text = message.parts
+    .filter(
+      (part) =>
+        !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY) &&
+        part.type === 'text' &&
+        typeof part.text === 'string',
+    )
+    .map((part) => part.text)
+    .join('\u0000');
+  return `anon:${djb2Hash(`${message.info.role}:${text}`)}`;
+}
+
+/** The id used to key a message in the retained-tail-board log. */
+function boardAnchorId(message: MessageWithParts): string {
+  return message.info.id ?? boardAnchorFallbackId(message);
+}
+
+/** Record (or refresh) a board placed on an anchor for later replay. */
+function rememberTailBoard(
+  state: InjectionState,
+  sessionID: string,
+  board: RetainedTailBoard,
+): void {
+  const perSession =
+    state.retainedTailBoards.get(sessionID) ??
+    new Map<string, RetainedTailBoard>();
+  perSession.set(board.anchorId, board);
+  state.retainedTailBoards.set(sessionID, perSession);
+}
+
+/**
+ * Stop tracking a retained board for an anchor (A5). Used when the placement
+ * cannot be safely reproduced, so the map neither grows without bound nor
+ * retries an unsafe replay on every later request.
+ */
+function forgetTailBoard(
+  state: InjectionState,
+  sessionID: string,
+  anchorId: string,
+): void {
+  const perSession = state.retainedTailBoards.get(sessionID);
+  if (!perSession) return;
+  perSession.delete(anchorId);
+  if (perSession.size === 0) state.retainedTailBoards.delete(sessionID);
+}
+
+/**
+ * Re-append every FROZEN retained board onto its original anchor message,
+ * exactly as first sent, so a board that was sent on a message which is no
+ * longer the tail never disappears (its bytes are already in the provider's
+ * cached prefix).
+ *
+ * Replay is strictly append-a-PART-to-an-existing-message. It never inserts a
+ * message (A1) and therefore can never come between a tool_call and its
+ * tool_result (A2), and it only ever targets a `user` message (A3).
+ *
+ * A retained board whose anchor cannot satisfy those invariants is DROPPED
+ * (A5) rather than reproduced: losing a stale board costs one cache bust,
+ * whereas an invalid message array raises `AI_InvalidPromptError` during
+ * request validation and fails the turn outright.
+ *
+ * The current tail anchor (`currentAnchorId`) is skipped: its board is volatile
+ * and is (re)placed fresh by the caller. Anchors no longer present in history
+ * (compaction, revert) are pruned — their bytes are gone from the provider's
+ * view too. Replay is skipped when the anchor already carries a board, keeping
+ * the operation idempotent under repeated transforms on a shared array.
+ */
+function replayRetainedTailBoards(
+  state: InjectionState,
+  sessionID: string,
+  messages: unknown[],
+  currentAnchorId: string | undefined,
+): void {
+  const perSession = state.retainedTailBoards.get(sessionID);
+  if (!perSession || perSession.size === 0) return;
+
+  const anchorById = new Map<string, MessageWithParts>();
+  for (const message of messages) {
+    if (!isMessageWithParts(message)) continue;
     if (
-      !message.info.sessionID ||
-      !state.shouldManageSession(message.info.sessionID)
+      message.parts.length > 0 &&
+      message.parts.every((part) => isTaggedPart(part, state.metadataKey))
     ) {
-      return;
+      continue;
     }
+    anchorById.set(boardAnchorId(message), message);
+  }
 
-    const textPart = message.parts.find(
-      (part) => part.type === 'text' && typeof part.text === 'string',
-    );
-    if (!textPart || isInternalInitiatorPart(textPart)) return;
+  for (const [anchorId, board] of [...perSession.entries()]) {
+    // The current tail's board is volatile — the caller strips and re-appends
+    // it. Never freeze/replay it here.
+    if (anchorId === currentAnchorId) continue;
 
-    const shapeKey = promptShapeKey(realMessages(messages, state.metadataKey));
-    reconcileConsumedTerminalJobs(state, message.info.sessionID, shapeKey);
+    const anchor = anchorById.get(anchorId);
+    if (!anchor) {
+      // Anchor gone from history (compaction/revert): its bytes are no longer
+      // in the provider's view, so stop tracking it.
+      perSession.delete(anchorId);
+      continue;
+    }
+    if (hasTaggedPart(anchor, state.metadataKey)) continue;
+
+    // A5: only the trailing-PART-on-a-user-anchor placement is replayable. A
+    // board recorded against an assistant anchor (legacy state from an earlier
+    // build) was reproduced by splicing a synthetic message after the anchor —
+    // which lands between an assistant `task` tool_call and its tool_result and
+    // invalidates the whole request. A board whose anchor is no longer a user
+    // message cannot take the part path either. Both are dropped: one lost
+    // board (a bounded cache bust on that message) is preferable to a hard
+    // AI_InvalidPromptError on every request.
+    if (board.anchorRole !== 'user' || !canCarryBoardPart(anchor)) {
+      perSession.delete(anchorId);
+      continue;
+    }
 
-    const boardMeta = state.backgroundJobBoard.formatForPromptWithMetadata(
-      message.info.sessionID,
-    );
-    const reminder = boardMeta?.text;
-    if (!reminder) return;
-
-    rememberInjectedTerminalJobs(
-      state,
-      message.info.sessionID,
-      boardMeta.terminalUnreconciledTaskIDs,
-      shapeKey,
-    );
-    // 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.
-    appendTrailingVolatileMessage(
-      messages,
-      {
-        ...message.info,
-        id: `${message.info.id}-background-job-board`,
-      },
-      {
-        text: reminder,
-        metadataKey: state.metadataKey,
-      },
+    appendTaggedSyntheticPart(anchor, {
+      text: board.text,
+      metadataKey: state.metadataKey,
+    });
+  }
+
+  if (perSession.size === 0) state.retainedTailBoards.delete(sessionID);
+}
+
+/**
+ * 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',
     );
-    return;
+    if (!hasText) continue;
+    return message;
   }
+  return undefined;
 }
 
 function injectCheckpointBoard(

+ 748 - 0
src/hooks/task-session-manager/board-tool-pairing.test.ts

@@ -0,0 +1,748 @@
+/**
+ * Regression coverage for the retained-board replay bug that produced
+ * `AI_InvalidPromptError: Invalid prompt: The messages do not match the
+ * ModelMessage[] schema.` in a long-running session driving background
+ * subagents (commit 208b656, also present in upstream PR #889).
+ *
+ * ── What went wrong ──────────────────────────────────────────────────────
+ *
+ * `replayRetainedTailBoards` reproduced a board that had been placed on an
+ * ASSISTANT tail by splicing a synthetic message directly after that anchor:
+ *
+ *     const index = messages.indexOf(anchor);
+ *     const boardMessage = { info: { ...anchor.info, id: ... }, parts: [...] };
+ *     messages.splice(index + 1, 0, boardMessage);
+ *
+ * `anchor.info` is an ASSISTANT message info, so the synthetic board message
+ * inherited `role: 'assistant'`. The host's conversion pipeline treats the two
+ * roles asymmetrically (verified against the shipped opencode binary,
+ * `MessageV2.toModelMessagesEffect`):
+ *
+ *   - the USER branch emits `{ type: 'text', text }` and DISCARDS `metadata`;
+ *   - the ASSISTANT branch emits
+ *     `{ type: 'text', text, providerMetadata: part.metadata }`, which
+ *     `convertToModelMessages` forwards as `providerOptions`.
+ *
+ * `providerOptions` is validated as `Record<string, Record<string, JSONValue>>`.
+ * A board part's metadata is `{ 'oh-my-opencode-slim.backgroundJobBoard': true }`
+ * — a boolean where a nested record is required — so the request failed schema
+ * validation before the HTTP call. This is why the failure only appeared in
+ * sessions with assistant tails (a finishing background `task` turn), only
+ * after 208b656 introduced `retainedTailBoards`, and never showed up in
+ * storage: the malformed message is produced in-memory by the transform.
+ *
+ * ── Invariants now enforced ──────────────────────────────────────────────
+ *
+ *  A1  a synthetic board MESSAGE is only ever appended at the END of the array,
+ *      never spliced into the middle.
+ *  A2  no injected message separates a tool_call from its matching tool_result.
+ *  A3  board text only ever rides on a `user`-role message (the rule that
+ *      actually prevents the error above).
+ *  A4  a board on a still-present user anchor is replayed byte-identically, so
+ *      already-cached bytes never change.
+ *  A5  a retained board that cannot be safely replayed is DROPPED from the
+ *      retained map rather than reproduced.
+ *
+ * The reproduction test validates against a transcription of the REAL
+ * `ModelMessage[]` zod schema together with a port of the host's conversion
+ * pipeline, both extracted from the installed opencode binary. The `ai` package
+ * is not a dependency of this repo and none was added, so the schema is
+ * reproduced rather than imported; the structural invariant assertions below
+ * stand on their own.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { z } from 'zod';
+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_invalid_prompt';
+const CHILD = 'ses_child_background';
+const PROVIDER = 'anthropic';
+const MODEL = 'claude-opus-4';
+
+// ── Real ModelMessage[] schema (transcribed from the opencode binary) ──────
+
+const jsonValue: z.ZodType = z.lazy(() =>
+  z.union([
+    z.null(),
+    z.string(),
+    z.number(),
+    z.boolean(),
+    z.record(z.string(), jsonValue.optional()),
+    z.array(jsonValue),
+  ]),
+);
+
+/** `providerOptions`: Record<string, Record<string, JSONValue>>. */
+const providerOptions = z.record(
+  z.string(),
+  z.record(z.string(), jsonValue.optional()),
+);
+
+const textPart = z.object({
+  type: z.literal('text'),
+  text: z.string(),
+  providerOptions: providerOptions.optional(),
+});
+
+const filePart = z.object({
+  type: z.literal('file'),
+  mediaType: z.string(),
+  filename: z.string().optional(),
+  data: z.unknown(),
+  providerOptions: providerOptions.optional(),
+});
+
+const reasoningPart = z.object({
+  type: z.literal('reasoning'),
+  text: z.string(),
+  providerOptions: providerOptions.optional(),
+});
+
+const toolCallPart = z.object({
+  type: z.literal('tool-call'),
+  toolCallId: z.string(),
+  toolName: z.string(),
+  input: z.unknown(),
+  providerExecuted: z.boolean().optional(),
+  providerOptions: providerOptions.optional(),
+});
+
+const toolResultOutput = z.discriminatedUnion('type', [
+  z.object({ type: z.literal('text'), value: z.string() }),
+  z.object({ type: z.literal('json'), value: jsonValue }),
+  z.object({ type: z.literal('error-text'), value: z.string() }),
+  z.object({ type: z.literal('error-json'), value: jsonValue }),
+  z.object({
+    type: z.literal('content'),
+    value: z.array(z.unknown()),
+  }),
+]);
+
+const toolResultPart = z.object({
+  type: z.literal('tool-result'),
+  toolCallId: z.string(),
+  toolName: z.string(),
+  output: toolResultOutput,
+  providerOptions: providerOptions.optional(),
+});
+
+const modelMessage = z.union([
+  z.object({
+    role: z.literal('system'),
+    content: z.string(),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('user'),
+    content: z.union([z.string(), z.array(z.union([textPart, filePart]))]),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('assistant'),
+    content: z.union([
+      z.string(),
+      z.array(
+        z.union([
+          textPart,
+          filePart,
+          reasoningPart,
+          toolCallPart,
+          toolResultPart,
+        ]),
+      ),
+    ]),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('tool'),
+    content: z.array(toolResultPart),
+    providerOptions: providerOptions.optional(),
+  }),
+]);
+
+const modelMessages = z.array(modelMessage);
+
+// ── Host conversion pipeline (ported from the opencode binary) ────────────
+
+type AnyPart = Record<string, any>;
+type AnyMessage = { info: Record<string, any>; parts: AnyPart[] };
+
+/**
+ * Port of `MessageV2.toModelMessagesEffect` (user + assistant branches) —
+ * the step that turns the transform hook's array into UIMessages. The
+ * metadata asymmetry between the two role branches is reproduced verbatim: it
+ * is the mechanism behind the failure under test.
+ */
+function toUIMessages(messages: unknown[]): AnyMessage[] {
+  const result: any[] = [];
+  for (const message of messages as AnyMessage[]) {
+    if (!message?.info || !Array.isArray(message.parts)) continue;
+    if (message.parts.length === 0) continue;
+
+    if (message.info.role === 'user') {
+      const parts: any[] = [];
+      for (const part of message.parts) {
+        // NOTE: no metadata is forwarded on the user path.
+        if (part.type === 'text' && !part.ignored && part.text !== '') {
+          parts.push({ type: 'text', text: part.text });
+        }
+      }
+      if (parts.length > 0) {
+        result.push({ id: message.info.id, role: 'user', parts });
+      }
+    }
+
+    if (message.info.role === 'assistant') {
+      if (message.info.error) continue;
+      // Model-match gate: when the message's model equals the request model,
+      // part metadata IS forwarded as providerMetadata.
+      const differentModel =
+        `${PROVIDER}/${MODEL}` !==
+        `${message.info.providerID}/${message.info.modelID}`;
+      const parts: any[] = [];
+      for (const part of message.parts) {
+        if (part.type === 'text') {
+          parts.push({
+            type: 'text',
+            text: part.text,
+            ...(differentModel ? {} : { providerMetadata: part.metadata }),
+          });
+        }
+        if (part.type === 'step-start') parts.push({ type: 'step-start' });
+        if (part.type === 'tool' && part.state?.status === 'completed') {
+          parts.push({
+            type: `tool-${part.tool}`,
+            state: 'output-available',
+            toolCallId: part.callID,
+            input: part.state.input,
+            output: part.state.output,
+          });
+        }
+      }
+      if (parts.length > 0)
+        result.push({ id: message.info.id, role: 'assistant', parts });
+    }
+  }
+  return result.filter((m) =>
+    m.parts.some((p: AnyPart) => p.type !== 'step-start'),
+  );
+}
+
+/**
+ * Port of the AI SDK's `convertToModelMessages` for the part kinds this suite
+ * produces. Note that a single assistant `tool-*` part expands into an
+ * assistant `tool-call` plus an immediately following `role: 'tool'` message —
+ * so the pairing is emitted adjacently by construction.
+ */
+function convertToModelMessages(uiMessages: AnyMessage[]): any[] {
+  const out: any[] = [];
+  for (const message of uiMessages as any[]) {
+    if (message.role === 'user') {
+      out.push({
+        role: 'user',
+        content: message.parts
+          .filter((p: AnyPart) => p.type === 'text')
+          .map((p: AnyPart) => ({
+            type: 'text',
+            text: p.text,
+            ...(p.providerMetadata != null
+              ? { providerOptions: p.providerMetadata }
+              : {}),
+          })),
+      });
+      continue;
+    }
+
+    if (message.role !== 'assistant') continue;
+
+    const content: any[] = [];
+    const toolResults: any[] = [];
+    for (const part of message.parts as AnyPart[]) {
+      if (part.type === 'text') {
+        content.push({
+          type: 'text',
+          text: part.text,
+          // providerMetadata → providerOptions: the exact key whose shape the
+          // ModelMessage[] schema validates.
+          ...(part.providerMetadata != null
+            ? { providerOptions: part.providerMetadata }
+            : {}),
+        });
+        continue;
+      }
+      if (typeof part.type === 'string' && part.type.startsWith('tool-')) {
+        const toolName = part.type.slice('tool-'.length);
+        content.push({
+          type: 'tool-call',
+          toolCallId: part.toolCallId,
+          toolName,
+          input: part.input,
+        });
+        toolResults.push({
+          type: 'tool-result',
+          toolCallId: part.toolCallId,
+          toolName,
+          output: { type: 'text', value: String(part.output) },
+        });
+      }
+    }
+    if (content.length > 0) out.push({ role: 'assistant', content });
+    if (toolResults.length > 0)
+      out.push({ role: 'tool', content: toolResults });
+  }
+  return out;
+}
+
+/** Mirrors the host's validation step; returns the zod error when invalid. */
+function validateModelMessages(messages: unknown[]) {
+  return modelMessages.safeParse(
+    convertToModelMessages(toUIMessages(messages)),
+  );
+}
+
+// ── Fixtures ──────────────────────────────────────────────────────────────
+
+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 userTextTurn(id: string, text: string) {
+  return {
+    info: { id, role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [{ id: `prt_${id}`, type: 'text', text }],
+  };
+}
+
+const TASK_OUTPUT = [
+  `<task id="${CHILD}" state="completed">`,
+  '<summary>Background task completed: research the scheduler</summary>',
+  '<task_result>',
+  'Findings: the scheduler batches on idle.',
+  '</task_result>',
+  '</task>',
+].join('\n');
+
+/**
+ * The assistant turn a FINISHED background subagent produces: a `task` tool
+ * part whose terminal result is materialized on the same message. The host
+ * converter expands this single part into an assistant tool_call plus its
+ * matching tool_result.
+ */
+function finishedTaskAssistantTurn(id: string, callID: string) {
+  return {
+    info: {
+      id,
+      role: 'assistant',
+      sessionID: SESSION,
+      providerID: PROVIDER,
+      modelID: MODEL,
+    },
+    parts: [
+      { id: `prt_${id}_s`, type: 'step-start' },
+      {
+        id: `prt_${id}_t`,
+        type: 'text',
+        text: 'The background task finished.',
+      },
+      {
+        id: `prt_${id}_c`,
+        type: 'tool',
+        tool: 'task',
+        callID,
+        state: {
+          status: 'completed',
+          input: { background: true, description: 'research the scheduler' },
+          output: TASK_OUTPUT,
+          time: { start: 1, end: 2 },
+        },
+      },
+    ],
+  };
+}
+
+/** A user turn carrying only a tool result (the tool-loop shape). */
+function toolResultUserTurn(id: string, callID: string, output: string) {
+  return {
+    info: { id, role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [
+      {
+        id: `prt_${id}`,
+        type: 'tool',
+        tool: 'read',
+        callID,
+        state: {
+          status: 'completed',
+          input: {},
+          output,
+          time: { start: 1, end: 2 },
+        },
+      },
+    ],
+  };
+}
+
+async function request(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // opencode rebuilds the array from storage every request; synthetic board
+  // content is never persisted, so each request starts from real history only.
+  const output = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, output as never);
+  await hook.injectBackgroundJobBoard({}, output as never);
+  return output.messages;
+}
+
+// ── Invariant helpers ─────────────────────────────────────────────────────
+
+function isBoardPart(part: AnyPart): boolean {
+  return part?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true;
+}
+
+function isBoardMessage(message: AnyMessage): boolean {
+  return (
+    message.parts.length > 0 && message.parts.every((part) => isBoardPart(part))
+  );
+}
+
+/** A3: board text may only ride on a `user`-role message. */
+function assertBoardTextOnlyOnUserMessages(messages: unknown[]): void {
+  for (const message of messages as AnyMessage[]) {
+    if (!message.parts?.some(isBoardPart)) continue;
+    expect(
+      message.info.role,
+      `board text landed on a ${message.info.role} message; the assistant ` +
+        'branch of the host converter would forward its metadata as ' +
+        'providerOptions and fail ModelMessage[] validation',
+    ).toBe('user');
+  }
+}
+
+/**
+ * A2: every assistant `tool_call` stays immediately followed by its matching
+ * `tool_result`, measured on the CONVERTED model messages.
+ */
+function assertToolPairingIntact(messages: unknown[]): void {
+  const converted = convertToModelMessages(toUIMessages(messages));
+  for (const [index, message] of converted.entries()) {
+    if (message.role !== 'assistant') continue;
+    const callIds = (message.content as AnyPart[])
+      .filter((part) => part.type === 'tool-call')
+      .map((part) => part.toolCallId);
+    if (callIds.length === 0) continue;
+
+    const next = converted[index + 1];
+    expect(
+      next?.role,
+      `assistant tool_call(s) ${callIds.join(', ')} are not followed by a ` +
+        'tool-role message — the pairing was orphaned',
+    ).toBe('tool');
+    const resultIds = (next.content as AnyPart[]).map(
+      (part) => part.toolCallId,
+    );
+    for (const callId of callIds) {
+      expect(resultIds).toContain(callId);
+    }
+  }
+}
+
+/**
+ * A1: no synthetic board message may sit anywhere but the very end of the
+ * array — i.e. nothing was spliced into the middle of already-sent history.
+ */
+function assertNoMidArrayBoardMessage(messages: unknown[]): void {
+  const list = messages as AnyMessage[];
+  for (const [index, message] of list.entries()) {
+    if (!isBoardMessage(message)) continue;
+    expect(
+      index,
+      'a synthetic board message was inserted mid-array instead of appended',
+    ).toBe(list.length - 1);
+  }
+}
+
+/**
+ * A1/A2 combined, stated positionally: a synthetic board message may follow an
+ * assistant `task` tool_call message ONLY when it is the final element of the
+ * array. Appending after the last real message is safe — the host emits the
+ * tool_call and its tool_result adjacently from that one message, so nothing
+ * comes between them. Splicing the board after a task message that still has
+ * successors is the bug: it lands inside already-sent history.
+ */
+function assertNothingBetweenTaskCallAndResult(messages: unknown[]): void {
+  const list = messages as AnyMessage[];
+  for (const [index, message] of list.entries()) {
+    const hasTaskCall = message.parts?.some(
+      (part) => part.type === 'tool' && part.tool === 'task',
+    );
+    if (!hasTaskCall) continue;
+    const next = list[index + 1];
+    if (!next || !isBoardMessage(next)) continue;
+    expect(
+      index + 1,
+      'a synthetic board message was spliced in immediately after an ' +
+        'assistant task tool_call message that is not the tail — it sits ' +
+        'between the call and its tool_result',
+    ).toBe(list.length - 1);
+  }
+}
+
+function assertAllInvariants(messages: unknown[]): void {
+  assertNoMidArrayBoardMessage(messages);
+  assertNothingBetweenTaskCallAndResult(messages);
+  assertToolPairingIntact(messages);
+  assertBoardTextOnlyOnUserMessages(messages);
+}
+
+function boardTexts(messages: unknown[]): string[] {
+  return (messages as AnyMessage[]).flatMap((message) =>
+    (message.parts ?? []).filter(isBoardPart).map((part) => String(part.text)),
+  );
+}
+
+function runningBoard(): BackgroundJobBoard {
+  const board = new BackgroundJobBoard();
+  board.registerLaunch({
+    taskID: CHILD,
+    parentSessionID: SESSION,
+    agent: 'librarian',
+    description: 'research the scheduler',
+  });
+  return board;
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────
+
+describe('board injection keeps the ModelMessage[] array valid', () => {
+  test('reproduction: a board retained on an assistant anchor never corrupts the prompt when the background task finishes', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request 1 — the tail is the ASSISTANT turn of a just-finished background
+    // task. This is the request that makes the buggy build retain a board
+    // against an ASSISTANT anchor.
+    const historyA = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ];
+    const outA = await request(hook, historyA);
+
+    const validA = validateModelMessages(outA);
+    expect(
+      validA.success,
+      `request A failed ModelMessage[] validation: ${JSON.stringify(
+        validA.error?.issues?.slice(0, 3),
+        null,
+        2,
+      )}`,
+    ).toBe(true);
+    assertAllInvariants(outA);
+
+    // Request 2 — the loop advanced: the assistant task turn is now
+    // mid-history, followed by the user tool_result turn. The buggy build
+    // replayed the retained board by splicing an ASSISTANT-role synthetic
+    // message directly after the anchor, which both landed mid-array and
+    // carried board metadata on an assistant message.
+    board.updateStatus({
+      taskID: CHILD,
+      state: 'completed',
+      resultSummary: 'scheduler batches on idle',
+    });
+    const historyB = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'file contents'),
+    ];
+    const outB = await request(hook, historyB);
+
+    const validB = validateModelMessages(outB);
+    expect(
+      validB.success,
+      `request B failed ModelMessage[] validation (AI_InvalidPromptError): ` +
+        `${JSON.stringify(validB.error?.issues?.slice(0, 3), null, 2)}`,
+    ).toBe(true);
+    assertAllInvariants(outB);
+
+    // Request 3 — a consecutive request over the same history must stay valid
+    // and must not accumulate boards.
+    const outC = await request(hook, historyB);
+    expect(validateModelMessages(outC).success).toBe(true);
+    assertAllInvariants(outC);
+    expect(boardTexts(outC)).toHaveLength(1);
+  });
+
+  test('A1/A2: no synthetic message is ever placed between a tool_call and its tool_result across a growing tool loop', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    const history: unknown[] = [
+      userTextTurn('u1', 'Coordinate the background research'),
+    ];
+
+    // Grow the conversation the way the agent loop does: alternating assistant
+    // task turns and user tool_result turns, re-rendering every step.
+    for (let turn = 0; turn < 4; turn += 1) {
+      history.push(finishedTaskAssistantTurn(`a${turn}`, `call-task-${turn}`));
+      const mid = await request(hook, history);
+      assertAllInvariants(mid);
+      expect(validateModelMessages(mid).success).toBe(true);
+
+      history.push(
+        toolResultUserTurn(`r${turn}`, `call-read-${turn}`, `result-${turn}`),
+      );
+      const after = await request(hook, history);
+      assertAllInvariants(after);
+      expect(validateModelMessages(after).success).toBe(true);
+    }
+  });
+
+  test('A3: board text never rides on an assistant message even when the tail is an assistant turn', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    const out = await request(hook, [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ]);
+
+    // The board is present…
+    expect(boardTexts(out)).toHaveLength(1);
+    // …and it is carried by a user-role message appended at the very end.
+    assertBoardTextOnlyOnUserMessages(out);
+    const tail = (out as AnyMessage[]).at(-1);
+    expect(tail?.info.role).toBe('user');
+    expect(tail?.parts.every(isBoardPart)).toBe(true);
+    // The assistant anchor itself is untouched by the board.
+    const assistant = (out as AnyMessage[]).find(
+      (message) => message.info.role === 'assistant',
+    );
+    expect(assistant?.parts.some(isBoardPart)).toBe(false);
+  });
+
+  test('A4: a board on a still-present text-only user anchor is replayed byte-identically', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request A: the tail is a plain user turn, so the board rides on it as a
+    // trailing PART and is recorded for replay.
+    const historyA = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      toolResultUserTurn('r0', 'call-read-0', 'first read'),
+      userTextTurn('u2', 'Now summarize the findings'),
+    ];
+    const outA = await request(hook, historyA);
+    const anchorA = (outA as AnyMessage[]).find(
+      (message) => message.info.id === 'u2',
+    );
+    const retainedBoard = anchorA?.parts.at(-1);
+    expect(isBoardPart(retainedBoard as AnyPart)).toBe(true);
+    const retainedBytes = JSON.stringify(retainedBoard);
+
+    // Request B: the tail advanced past that anchor. The already-sent board
+    // bytes on `u2` must be reproduced exactly — that is the cache guarantee
+    // 208b656 introduced and this fix preserves.
+    board.updateStatus({
+      taskID: CHILD,
+      state: 'completed',
+      resultSummary: 'scheduler batches on idle',
+    });
+    const historyB = [
+      ...historyA,
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'second read'),
+    ];
+    const outB = await request(hook, historyB);
+
+    const anchorB = (outB as AnyMessage[]).find(
+      (message) => message.info.id === 'u2',
+    );
+    const replayed = anchorB?.parts.at(-1);
+    expect(isBoardPart(replayed as AnyPart)).toBe(true);
+    // Byte-identical replay: the provider's cached prefix stays valid.
+    expect(JSON.stringify(replayed)).toBe(retainedBytes);
+
+    // And the array is still valid and invariant-clean.
+    expect(validateModelMessages(outB).success).toBe(true);
+    assertAllInvariants(outB);
+  });
+
+  test('A5: an unreplayable retained board is dropped instead of retried every request', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request A: assistant tail → the board is appended as a trailing message.
+    // That placement is deliberately NOT retained, because reproducing it later
+    // would require a mid-array insertion.
+    await request(hook, [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ]);
+
+    const historyB = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'file contents'),
+    ];
+
+    // Repeated consecutive requests must each carry exactly ONE board: no
+    // resurrection of the dropped placement, no unbounded accumulation, and no
+    // repeated attempt at the unsafe replay.
+    for (let attempt = 0; attempt < 3; attempt += 1) {
+      const out = await request(hook, historyB);
+      expect(boardTexts(out)).toHaveLength(1);
+      // The dropped board is not reproduced on the assistant anchor.
+      const assistant = (out as AnyMessage[]).find(
+        (message) => message.info.role === 'assistant',
+      );
+      expect(assistant?.parts.some(isBoardPart)).toBe(false);
+      assertAllInvariants(out);
+      expect(validateModelMessages(out).success).toBe(true);
+    }
+  });
+
+  test('a board part on an assistant message is exactly what the real schema rejects', async () => {
+    // Guards the schema port itself: if this stopped failing, the reproduction
+    // test above would pass for the wrong reason.
+    const corrupted = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      {
+        info: {
+          id: 'a1',
+          role: 'assistant',
+          sessionID: SESSION,
+          providerID: PROVIDER,
+          modelID: MODEL,
+        },
+        parts: [
+          {
+            type: 'text',
+            synthetic: true,
+            text: '<system-reminder>board</system-reminder>',
+            metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+          },
+        ],
+      },
+    ];
+
+    const result = validateModelMessages(corrupted);
+    expect(result.success).toBe(false);
+    expect(JSON.stringify(result.error?.issues)).toContain('providerOptions');
+  });
+});

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

@@ -165,6 +165,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?: {
@@ -173,7 +176,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;
@@ -251,7 +254,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',
@@ -298,17 +303,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;
     };
@@ -352,7 +360,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',
     );
@@ -382,7 +392,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',
@@ -393,11 +406,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',
@@ -411,11 +423,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 () => {
@@ -433,16 +486,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);
@@ -453,10 +504,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',
@@ -469,8 +520,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' },
@@ -484,9 +537,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,
     });
   });
@@ -886,30 +943,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' },
     ]);
   });
@@ -941,19 +1004,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 () => {
@@ -985,7 +1052,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,
     );

+ 2 - 0
src/hooks/task-session-manager/index.ts

@@ -191,6 +191,7 @@ export function createTaskSessionManagerHook(
       terminalJobsInjectedByParent.delete(sessionId);
       pendingInjectedTerminalJobsByParent.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
+      injectionState.retainedTailBoards.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
@@ -210,6 +211,7 @@ export function createTaskSessionManagerHook(
     shouldManageSession: options.shouldManageSession,
     taskContextTracker,
     retainedBoardSnapshots: new Map(),
+    retainedTailBoards: new Map(),
   };
 
   return {