Browse Source

Merge pull request #804 from DanielMaly/fix/job-board-cache-safe

Make background job board injection cache-safe
Alvin 1 month ago
parent
commit
9e5a8e4d5a
3 changed files with 327 additions and 144 deletions
  1. 253 99
      src/hooks/task-session-manager/index.test.ts
  2. 73 45
      src/hooks/task-session-manager/index.ts
  3. 1 0
      src/index.ts

+ 253 - 99
src/hooks/task-session-manager/index.test.ts

@@ -75,6 +75,29 @@ function createMessages(sessionID: string, text = 'user message') {
   };
 }
 
+function boardText(messages: { messages: unknown[] }): string | undefined {
+  const last = messages.messages.at(-1) as
+    | {
+        parts?: {
+          text?: string;
+          metadata?: Record<string, unknown>;
+        }[];
+      }
+    | undefined;
+  const part = last?.parts?.[0];
+  return part?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true
+    ? part.text
+    : undefined;
+}
+
+async function transformMessages(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  messages: { messages: unknown[] },
+) {
+  await hook['experimental.chat.messages.transform']({}, messages as never);
+  await hook.injectBackgroundJobBoard({}, messages as never);
+}
+
 function setupCompletedJob(
   board: BackgroundJobBoard,
   opts?: { taskID?: string; parentSessionID?: string },
@@ -118,13 +141,11 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages as never);
+    await transformMessages(hook, messages as never);
 
-    expect(messages.messages).toHaveLength(5);
-    expect(messages.messages[4].parts[0].text).toContain(
-      '### Background Job Board',
-    );
-    expect(messages.messages[4].parts[0].text).toContain(
+    expect(messages.messages).toHaveLength(6);
+    expect(boardText(messages)).toContain('### Background Job Board');
+    expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
   });
@@ -167,10 +188,19 @@ describe('task-session-manager hook', () => {
     );
 
     const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await hook.injectBackgroundJobBoard({}, messages);
 
     const userMessage = messages.messages[0];
-    const boardPart = userMessage.parts[0] as {
+    expect(userMessage.parts).toHaveLength(1);
+    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 {
       text?: string;
       synthetic?: boolean;
     };
@@ -183,7 +213,6 @@ describe('task-session-manager hook', () => {
     expect(boardPart.text).toEndWith('</system-reminder>');
     expect(boardPart.text).toContain('exp-1 / child-1 / explorer / running');
     expect(boardPart.text).toContain('Objective: map scheduler hooks');
-    expect(userMessage.parts[1].text).toBe('do something');
   });
 
   test('does not let user-visible sentinel text suppress board injection', async () => {
@@ -210,16 +239,13 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await hook.injectBackgroundJobBoard({}, messages);
 
-    expect(messages.messages[0].parts[0]).toMatchObject({
-      type: 'text',
-      synthetic: true,
-    });
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
-    expect(messages.messages[0].parts[1].text).toBe(
+    expect(messages.messages[0].parts).toHaveLength(1);
+    expect(messages.messages[0].parts[0].text).toBe(
       'SENTINEL: background-job-board-v2',
     );
   });
@@ -235,17 +261,158 @@ describe('task-session-manager hook', () => {
     const { hook } = createHook({ backgroundJobBoard: board });
     const messages = createMessages('parent-1', 'continue');
 
-    await hook['experimental.chat.messages.transform']({}, messages);
-    messages.messages[0].parts = JSON.parse(
-      JSON.stringify(messages.messages[0].parts),
+    await hook.injectBackgroundJobBoard({}, messages);
+    messages.messages = JSON.parse(JSON.stringify(messages.messages));
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    const boardMessages = messages.messages.filter((message) =>
+      message.parts.some((part) =>
+        part.text?.includes('### Background Job Board'),
+      ),
+    );
+    expect(boardMessages).toHaveLength(1);
+    expect(messages.messages.at(-1)).toBe(boardMessages[0]);
+  });
+
+  test('strips stale board parts from history before injecting the latest state', async () => {
+    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);
+    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);
+
+    const boardParts = messages.messages.flatMap((message) =>
+      message.parts.filter(
+        (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+      ),
+    );
+    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');
+  });
+
+  test('strips JSON-persisted board parts from earlier messages', async () => {
+    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', 'earlier turn');
+
+    await hook.injectBackgroundJobBoard({}, messages);
+    const persistedBoard = JSON.parse(
+      JSON.stringify(messages.messages.at(-1)?.parts[0]),
     );
-    await hook['experimental.chat.messages.transform']({}, messages);
+    messages.messages = [
+      {
+        info: { role: 'assistant' },
+        parts: [persistedBoard],
+      },
+      {
+        info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+        parts: [{ type: 'text', text: 'current turn' }],
+      },
+    ];
+
+    await hook.injectBackgroundJobBoard({}, messages);
 
+    expect(messages.messages).toHaveLength(2);
+    expect(messages.messages[0].parts[0].text).toBe('current turn');
+    expect(messages.messages[1].parts[0].metadata).toEqual({
+      [BACKGROUND_JOB_BOARD_METADATA_KEY]: true,
+    });
+  });
+
+  test('strips existing board parts when no jobs produce a prompt', async () => {
+    const { hook } = createHook({
+      backgroundJobBoard: new BackgroundJobBoard(),
+    });
+    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: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [{ type: 'text', text: 'current turn' }, staleBoard],
+        },
+      ],
+    };
+
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    expect(messages.messages).toHaveLength(1);
+    expect(messages.messages[0].parts).toEqual([
+      { type: 'text', text: 'current turn' },
+    ]);
+  });
+
+  test('appends one board after a phase reminder on repeated transforms', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({ backgroundJobBoard: board });
+    const phaseReminder = createPhaseReminderHook({
+      shouldInject: () => true,
+    });
+    const messages = createMessages('parent-1', 'current turn');
+
+    await phaseReminder['experimental.chat.messages.transform']({}, messages);
+    await hook.injectBackgroundJobBoard({}, messages);
+
+    // Next request: opencode rebuilds the array from storage. Transient
+    // board messages are gone, but parts pushed onto shared message
+    // objects (phase reminder) may linger.
+    const nextRequest = { messages: [messages.messages[0]] };
+    await phaseReminder['experimental.chat.messages.transform'](
+      {},
+      nextRequest,
+    );
+    await hook.injectBackgroundJobBoard({}, nextRequest);
+
+    const parts = nextRequest.messages[0].parts;
     expect(
-      messages.messages[0].parts.filter((part) =>
-        part.text?.includes('### Background Job Board'),
+      parts.filter(
+        (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
       ),
-    ).toHaveLength(1);
+    ).toHaveLength(0);
+    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,
+    });
   });
 
   test('does not let user-visible internal marker suppress board injection', async () => {
@@ -272,16 +439,13 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await hook.injectBackgroundJobBoard({}, messages);
 
-    expect(messages.messages[0].parts[0]).toMatchObject({
-      type: 'text',
-      synthetic: true,
-    });
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
-    expect(messages.messages[0].parts[1].text).toBe(
+    expect(messages.messages[0].parts).toHaveLength(1);
+    expect(messages.messages[0].parts[0].text).toBe(
       SLIM_INTERNAL_INITIATOR_MARKER,
     );
   });
@@ -311,7 +475,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(messages.messages[0].parts).toHaveLength(1);
     expect(
@@ -368,14 +532,12 @@ describe('task-session-manager hook', () => {
     });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(boardText(messages)).toContain(
       'ora-1 / child-1 / oracle / completed, unreconciled',
     );
-    expect(messages.messages[0].parts[0].text).toContain(
-      'Result: plan is sound',
-    );
+    expect(boardText(messages)).toContain('Result: plan is sound');
   });
 
   test('keeps task timeout as a running timed-out job', async () => {
@@ -428,9 +590,9 @@ describe('task-session-manager hook', () => {
     });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(boardText(messages)).toContain(
       'fix-1 / child-1 / fixer / running, timed out',
     );
   });
@@ -582,10 +744,8 @@ describe('task-session-manager hook', () => {
     );
 
     const beforeMessages = createMessages('parent-1', 'before busy');
-    await hook['experimental.chat.messages.transform']({}, beforeMessages);
-    expect(beforeMessages.messages[0].parts[0].text).toContain(
-      'running, timed out',
-    );
+    await transformMessages(hook, beforeMessages);
+    expect(boardText(beforeMessages)).toContain('running, timed out');
 
     await hook.event({
       event: {
@@ -605,7 +765,7 @@ describe('task-session-manager hook', () => {
     });
 
     const afterMessages = createMessages('parent-1', 'after busy');
-    await hook['experimental.chat.messages.transform']({}, afterMessages);
+    await transformMessages(hook, afterMessages);
     expect(afterMessages.messages[0].parts[0].text).not.toContain(
       'running, timed out',
     );
@@ -654,14 +814,14 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'completed',
       terminalUnreconciled: true,
       resultSummary: 'found hook flow',
     });
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / completed, unreconciled',
     );
   });
@@ -689,7 +849,7 @@ describe('task-session-manager hook', () => {
       ].join('\n'),
     );
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -732,7 +892,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
     expect(board.get('child-1')).toMatchObject({
       state: 'completed',
       terminalUnreconciled: true,
@@ -746,7 +906,7 @@ describe('task-session-manager hook', () => {
       description: 'map hooks again',
     });
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -795,7 +955,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, firstMessages);
+    await transformMessages(hook, firstMessages);
     expect(board.get('child-1')).toMatchObject({
       state: 'completed',
       terminalUnreconciled: true,
@@ -844,7 +1004,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, secondMessages);
+    await transformMessages(hook, secondMessages);
 
     // Should be terminal again because this is a new message occurrence
     expect(board.get('child-1')).toMatchObject({
@@ -889,7 +1049,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, firstMessages);
+    await transformMessages(hook, firstMessages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'completed',
@@ -929,7 +1089,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, secondMessages);
+    await transformMessages(hook, secondMessages);
 
     // Should still be running because the same anonymous completion was deduped
     // (not re-processed just because message index changed)
@@ -974,7 +1134,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -1016,7 +1176,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -1058,7 +1218,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -1100,7 +1260,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
@@ -1141,7 +1301,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'completed',
@@ -1183,7 +1343,7 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     expect(board.get('child-1')).toMatchObject({
       state: 'error',
@@ -1227,10 +1387,12 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
-    expect(messages.messages[0].parts[0].text).toContain('state: cancelled');
-    expect(messages.messages[0].parts[0].text).toContain(
+    expect(messages.messages[0].parts.at(-1)?.text).toContain(
+      'state: cancelled',
+    );
+    expect(messages.messages[0].parts.at(-1)?.text).toContain(
       'cancelled: user requested',
     );
     expect(messages.messages[0].parts[0].text).not.toContain(
@@ -1305,8 +1467,8 @@ describe('task-session-manager hook', () => {
     });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
-    expect(messages.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, messages);
+    expect(boardText(messages)).toContain(
       'ora-1 / child-1 / oracle / completed, unreconciled',
     );
 
@@ -1326,10 +1488,8 @@ describe('task-session-manager hook', () => {
     });
 
     const nextMessages = createMessages('parent-1', 'continue again');
-    await hook['experimental.chat.messages.transform']({}, nextMessages);
-    expect(nextMessages.messages[0].parts[0].text).toContain(
-      'Reusable Sessions',
-    );
+    await transformMessages(hook, nextMessages);
+    expect(boardText(nextMessages)).toContain('Reusable Sessions');
   });
 
   test('does not reopen stale cancelled child job when child session becomes busy', async () => {
@@ -1366,7 +1526,7 @@ describe('task-session-manager hook', () => {
     setupCompletedJob(board);
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     // Fire idle event (starts 2s reconciliation timer)
     await hook.event({
@@ -1431,7 +1591,7 @@ describe('task-session-manager hook', () => {
     board.updateStatus({ taskID: 'child-1', state: 'completed' });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     await hook.event({
       event: {
@@ -1468,7 +1628,7 @@ describe('task-session-manager hook', () => {
     board.updateStatus({ taskID: 'child-1', state: 'completed' });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     await hook.event({
       event: {
@@ -1511,7 +1671,7 @@ describe('task-session-manager hook', () => {
     });
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
     await hook.event({
       event: {
         type: 'session.status',
@@ -1523,11 +1683,9 @@ describe('task-session-manager hook', () => {
     await flushIdleReconcileDelay();
 
     const nextMessages = createMessages('parent-1', 'reuse');
-    await hook['experimental.chat.messages.transform']({}, nextMessages);
-    expect(nextMessages.messages[0].parts[0].text).toContain(
-      '#### Reusable Sessions',
-    );
-    expect(nextMessages.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, nextMessages);
+    expect(boardText(nextMessages)).toContain('#### Reusable Sessions');
+    expect(boardText(nextMessages)).toContain(
       'exp-1 / child-1 / explorer / completed, reconciled',
     );
     expect(nextMessages.messages[0].parts[0].text).not.toContain(
@@ -1597,8 +1755,8 @@ describe('task-session-manager hook', () => {
     expect(completed.args.task_id).toBe('done-1');
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
-    expect(messages.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, messages);
+    expect(boardText(messages)).toContain(
       'ora-1 / done-1 / oracle / completed, reconciled',
     );
     expect(messages.messages[0].parts[0].text).not.toContain('err-1');
@@ -1742,13 +1900,11 @@ describe('task-session-manager hook', () => {
     );
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
-    expect(messages.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, messages);
+    expect(boardText(messages)).toContain(
       'exp-1 / child-1 / explorer / running',
     );
-    expect(messages.messages[0].parts[0].text).toContain(
-      '#### Reusable Sessions\n- none',
-    );
+    expect(boardText(messages)).toContain('#### Reusable Sessions\n- none');
   });
 
   test('bare task id output without state does not create reusable job', async () => {
@@ -1763,7 +1919,7 @@ describe('task-session-manager hook', () => {
     );
 
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
     expect(messages.messages[0].parts[0].text).toBe('continue');
   });
 
@@ -1787,8 +1943,8 @@ describe('task-session-manager hook', () => {
     );
 
     const unreconciled = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, unreconciled);
-    expect(unreconciled.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, unreconciled);
+    expect(boardText(unreconciled)).toContain(
       'fix-1 / ses_child / fixer / completed, unreconciled',
     );
 
@@ -1803,8 +1959,8 @@ describe('task-session-manager hook', () => {
     await flushIdleReconcileDelay();
 
     const reusable = createMessages('parent-1', 'reuse');
-    await hook['experimental.chat.messages.transform']({}, reusable);
-    expect(reusable.messages[0].parts[0].text).toContain(
+    await transformMessages(hook, reusable);
+    expect(boardText(reusable)).toContain(
       'fix-1 / ses_child / fixer / completed, reconciled',
     );
 
@@ -1925,7 +2081,7 @@ describe('task-session-manager hook', () => {
       { output: ['task_id: child-1', 'state: completed'].join('\n') },
     );
     const messages = createMessages('parent-1', 'continue');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
     await hook.event({
       event: {
         type: 'session.status',
@@ -1937,8 +2093,8 @@ describe('task-session-manager hook', () => {
     await flushIdleReconcileDelay();
 
     const next = createMessages('parent-1', 'reuse');
-    await hook['experimental.chat.messages.transform']({}, next);
-    const prompt = next.messages[0].parts[0].text;
+    await transformMessages(hook, next);
+    const prompt = boardText(next);
     expect(prompt).not.toContain('small.ts');
     expect(prompt).toContain('src/large.ts (12 lines)');
     expect(prompt).not.toContain('src/large.ts (18 lines)');
@@ -2010,7 +2166,7 @@ describe('task-session-manager hook', () => {
     );
 
     const messages = createMessages('manual-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     // Message should remain unchanged
     expect(messages.messages[0].parts[0].text).toBe('do something');
@@ -2048,7 +2204,7 @@ describe('task-session-manager hook', () => {
     coordinator.dispatchSessionDeleted('child-1');
 
     const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
     // Message should remain unchanged since session was deleted
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
@@ -2086,7 +2242,7 @@ describe('task-session-manager hook', () => {
     );
 
     const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
+    await transformMessages(hook, messages);
 
     // Message should remain unchanged since session was deleted
     expect(messages.messages[0].parts[0].text).toBe('do something');
@@ -2505,14 +2661,12 @@ describe('task-session-manager hook', () => {
       ],
     };
 
-    await hook['experimental.chat.messages.transform']({}, messages as never);
+    await transformMessages(hook, messages as never);
 
     // After recovery: agentMap corrected, board reminders injected
     expect(agentMap.get('orchestrator-1')).toBe('orchestrator');
-    expect(messages.messages[0].parts[0].text).toContain(
-      '### Background Job Board',
-    );
-    expect(messages.messages[0].parts[0].text).toContain('child-transform-1');
+    expect(boardText(messages)).toContain('### Background Job Board');
+    expect(boardText(messages)).toContain('child-transform-1');
   });
 
   test('repairs session mapping before composed reminder transforms', async () => {

+ 73 - 45
src/hooks/task-session-manager/index.ts

@@ -15,6 +15,7 @@ import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
 import type { SessionLifecycle } from '../session-lifecycle';
 import {
+  isMessageWithParts,
   isUserMessageWithParts,
   type MessagePart,
   type MessageWithParts,
@@ -571,6 +572,76 @@ export function createTaskSessionManagerHook(
     terminalJobsInjectedByParent.delete(parentSessionID);
   }
 
+  function isBoardPart(part: MessagePart): boolean {
+    return (
+      part.synthetic === true &&
+      isObjectRecord(part.metadata) &&
+      part.metadata[BACKGROUND_JOB_BOARD_METADATA_KEY] === true
+    );
+  }
+
+  async function injectBackgroundJobBoard(
+    _input: Record<string, never>,
+    output: { messages?: unknown },
+  ): Promise<void> {
+    const messages = Array.isArray(output.messages) ? output.messages : [];
+
+    // Strip previously injected board content: parts attached to real
+    // messages (legacy placement) and whole synthetic board messages.
+    for (let i = messages.length - 1; i >= 0; i -= 1) {
+      const message = messages[i];
+      if (!isMessageWithParts(message)) continue;
+      const hadParts = message.parts.length > 0;
+      message.parts = message.parts.filter((part) => !isBoardPart(part));
+      if (hadParts && message.parts.length === 0) messages.splice(i, 1);
+    }
+
+    for (let i = messages.length - 1; i >= 0; i -= 1) {
+      const message = messages[i];
+      if (!isUserMessageWithParts(message)) continue;
+      if (message.info.agent && message.info.agent !== 'orchestrator') return;
+      if (
+        !message.info.sessionID ||
+        !options.shouldManageSession(message.info.sessionID)
+      ) {
+        return;
+      }
+
+      const reminder = backgroundJobBoard.formatForPrompt(
+        message.info.sessionID,
+      );
+      if (!reminder) return;
+
+      const textPart = message.parts.find(
+        (part) => part.type === 'text' && typeof part.text === 'string',
+      );
+      if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+      rememberInjectedTerminalJobs(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.
+      messages.push({
+        info: {
+          ...message.info,
+          id: `${message.info.id}-background-job-board`,
+        },
+        parts: [
+          {
+            type: 'text',
+            synthetic: true,
+            text: reminder,
+            metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+          },
+        ],
+      });
+      return;
+    }
+  }
+
   return {
     observeChatMessage: (input: unknown, output: unknown): void => {
       const inputMessage = isObjectRecord(input) ? input : undefined;
@@ -857,53 +928,10 @@ export function createTaskSessionManagerHook(
           updateFromInjectedCompletion(part, message, messageIndex, partIndex);
         }
       }
-
-      for (let i = messages.length - 1; i >= 0; i -= 1) {
-        const message = messages[i];
-        if (!isUserMessageWithParts(message)) continue;
-        if (message.info.agent && message.info.agent !== 'orchestrator') return;
-        if (
-          !message.info.sessionID ||
-          !options.shouldManageSession(message.info.sessionID)
-        ) {
-          return;
-        }
-
-        const reminders = [
-          backgroundJobBoard.formatForPrompt(message.info.sessionID),
-        ].filter((item): item is string => Boolean(item));
-        if (reminders.length === 0) return;
-
-        const textPart = message.parts.find(
-          (part) => part.type === 'text' && typeof part.text === 'string',
-        );
-        if (!textPart) return;
-        if (isInternalInitiatorPart(textPart)) {
-          return;
-        }
-        if (
-          message.parts.some(
-            (part) =>
-              part.synthetic === true &&
-              isObjectRecord(part.metadata) &&
-              part.metadata[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
-          )
-        ) {
-          return;
-        }
-
-        rememberInjectedTerminalJobs(message.info.sessionID);
-        const boardPart = {
-          type: 'text',
-          synthetic: true,
-          text: reminders.join('\n\n'),
-          metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
-        };
-        message.parts.unshift(boardPart);
-        return;
-      }
     },
 
+    injectBackgroundJobBoard,
+
     event: async (input: {
       event: {
         type: string;

+ 1 - 0
src/index.ts

@@ -1210,6 +1210,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         input as never,
         typedOutput as never,
       );
+      await taskSessionManagerHook.injectBackgroundJobBoard(input, typedOutput);
     },
 
     'tool.execute.after': async (input, output) => {