board-cache-breakpoint.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. /**
  2. * Regression coverage for the Background Job Board prompt-cache breakpoint bug.
  3. *
  4. * Real same-session dumps (2026-07-23, ses_11145863…, dumps 000164–000169)
  5. * showed the same failure on every consecutive request pair: the board sat at
  6. * the very tail, but the conversation advanced by ~2 messages per turn, so the
  7. * first byte divergence landed exactly at the board position and ~243 KB of
  8. * tail was re-written as cache on every call. The frozen cache-read at the
  9. * system boundary is the field signature.
  10. *
  11. * Root cause: the provider caches only the last TWO messages (Anthropic:
  12. * `provider/transform.ts applyCaching → final.slice(-2)`), and the provider
  13. * SDK coalesces adjacent same-role `user` messages. A board injected as its
  14. * OWN trailing `user` message merges into the preceding user tool_result
  15. * message and collapses both tail breakpoints onto the single merged block —
  16. * so the only readable breakpoint sits on the volatile board, which moves to a
  17. * new tail every request. The deepest reusable breakpoint therefore regresses
  18. * to the stable system boundary.
  19. *
  20. * Fix: inject the board as a trailing PART on the last real message. The
  21. * message COUNT stays identical to a board-free render, so the provider's
  22. * second tail breakpoint lands on the previous (byte-stable, real) message,
  23. * which the next request reproduces exactly and can read from cache.
  24. *
  25. * This suite models core's caching + SDK merge to prove the readable
  26. * breakpoint now falls on stable real content.
  27. */
  28. import { describe, expect, mock, test } from 'bun:test';
  29. import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
  30. import { BackgroundJobBoard } from '../../utils';
  31. import {
  32. BACKGROUND_JOB_BOARD_METADATA_KEY,
  33. createTaskSessionManagerHook,
  34. } from './index';
  35. const SESSION = 'ses_orchestrator_1114';
  36. function createHook(board: BackgroundJobBoard) {
  37. return createTaskSessionManagerHook(
  38. {
  39. client: { session: { status: mock(async () => ({ data: {} })) } },
  40. directory: '/tmp',
  41. worktree: '/tmp',
  42. } as never,
  43. {
  44. maxSessionsPerAgent: 4,
  45. maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
  46. backgroundJobBoard: board,
  47. shouldManageSession: () => true,
  48. },
  49. );
  50. }
  51. function userMsg(id: string, text: string) {
  52. return {
  53. info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
  54. parts: [{ type: 'text', text }],
  55. };
  56. }
  57. function anonymousUserMsg(text: string) {
  58. return {
  59. info: { role: 'user', agent: 'orchestrator', sessionID: SESSION },
  60. parts: [{ type: 'text', text }],
  61. };
  62. }
  63. /** An assistant turn issuing a tool call, followed by its user tool_result. */
  64. function toolTurn(id: string, output: string) {
  65. return [
  66. {
  67. info: {
  68. role: 'assistant',
  69. agent: 'orchestrator',
  70. sessionID: SESSION,
  71. id: `${id}-a`,
  72. },
  73. parts: [
  74. { type: 'text', text: ' ' },
  75. {
  76. type: 'tool',
  77. tool: 'read',
  78. callID: `${id}-call`,
  79. state: { status: 'completed', input: {}, output: 'x' },
  80. },
  81. ],
  82. },
  83. {
  84. info: {
  85. role: 'user',
  86. agent: 'orchestrator',
  87. sessionID: SESSION,
  88. id: `${id}-r`,
  89. },
  90. parts: [
  91. {
  92. type: 'tool',
  93. tool: 'read',
  94. callID: `${id}-call`,
  95. state: { status: 'completed', input: {}, output },
  96. },
  97. ],
  98. },
  99. ];
  100. }
  101. async function inject(
  102. hook: ReturnType<typeof createTaskSessionManagerHook>,
  103. history: unknown[],
  104. ): Promise<unknown[]> {
  105. // opencode rebuilds msgs from storage every request; the board is never
  106. // persisted, so each request starts from real history only.
  107. const request = { messages: structuredClone(history) };
  108. await hook['experimental.chat.messages.transform']({}, request as never);
  109. await hook.injectBackgroundJobBoard({}, request as never);
  110. return request.messages;
  111. }
  112. type Msg = {
  113. info: { role: string; id?: string };
  114. parts: { metadata?: Record<string, unknown> }[];
  115. };
  116. /**
  117. * Faithful model of the provider cache pipeline that produced the field bug,
  118. * in the exact order opencode runs it (`provider/transform.ts`):
  119. *
  120. * 1. `applyCaching` selects the breakpoint messages as `msgs.slice(-2)` over
  121. * the message array BEFORE the SDK coalesces roles. This ordering is why
  122. * the bug exists: a separate trailing board `user` message makes the last
  123. * two messages [tool_result(user), board(user)], so NEITHER breakpoint
  124. * lands on the preceding assistant turn.
  125. * 2. the provider SDK then coalesces adjacent same-role messages, so the two
  126. * selected user messages merge and only the final block (the board) keeps
  127. * an effective cache_control.
  128. *
  129. * A breakpoint is READABLE next request only if the exact byte prefix ending
  130. * at that breakpoint message reproduces. Returns the readable byte-prefixes
  131. * this request establishes (one per breakpoint message, measured over the full
  132. * ordered block stream).
  133. */
  134. function readableCachePrefixes(messages: unknown[]): string[] {
  135. const msgs = messages as Msg[];
  136. // Assign each message to its post-merge coalesced-turn index.
  137. const turnOfMessage: number[] = [];
  138. const turnEndPrefix: string[] = [];
  139. let acc = '';
  140. let turnIndex = -1;
  141. let prevRole: string | undefined;
  142. for (const message of msgs) {
  143. if (message.info.role !== prevRole) {
  144. turnIndex += 1;
  145. prevRole = message.info.role;
  146. }
  147. for (const part of message.parts) acc += JSON.stringify(part);
  148. turnOfMessage.push(turnIndex);
  149. turnEndPrefix[turnIndex] = acc; // running end-of-turn prefix
  150. }
  151. // applyCaching selects the last two MESSAGES (pre-merge). Each realizes its
  152. // cache_control on the LAST block of the coalesced turn it merges into, so
  153. // the readable prefix ends at that turn's end — not the message's own end.
  154. const breakpointMessages = [msgs.length - 2, msgs.length - 1].filter(
  155. (i) => i >= 0,
  156. );
  157. const prefixes = new Set<string>();
  158. for (const mi of breakpointMessages) {
  159. prefixes.add(turnEndPrefix[turnOfMessage[mi]]);
  160. }
  161. return [...prefixes];
  162. }
  163. /** Simulate the OLD placement: board as its own trailing user message. */
  164. function withSeparateBoardMessage(
  165. messages: unknown[],
  166. reminderText: string,
  167. ): unknown[] {
  168. return [
  169. ...(messages as unknown[]),
  170. {
  171. info: {
  172. role: 'user',
  173. agent: 'orchestrator',
  174. sessionID: SESSION,
  175. id: 'board-msg',
  176. },
  177. parts: [
  178. {
  179. type: 'text',
  180. synthetic: true,
  181. text: reminderText,
  182. metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
  183. },
  184. ],
  185. },
  186. ];
  187. }
  188. describe('background job board cache breakpoint stability', () => {
  189. test('a readable cache breakpoint falls on byte-stable real content across turns', async () => {
  190. const board = new BackgroundJobBoard();
  191. board.registerLaunch({
  192. taskID: 'child-1',
  193. parentSessionID: SESSION,
  194. agent: 'librarian',
  195. description: 'research',
  196. });
  197. const hook = createHook(board);
  198. // Request N: history ends with a tool_result turn; board injected at tail.
  199. const historyN = [
  200. userMsg('u1', 'Coordinate'),
  201. ...toolTurn('t1', 'result-1'),
  202. ];
  203. const outN = await inject(hook, historyN);
  204. // Request N+1: the agent loop advanced by another tool turn.
  205. const historyN1 = [
  206. userMsg('u1', 'Coordinate'),
  207. ...toolTurn('t1', 'result-1'),
  208. ...toolTurn('t2', 'result-2'),
  209. ];
  210. const outN1 = await inject(hook, historyN1);
  211. // NEW placement: at least one readable byte-prefix from request N is a
  212. // prefix of request N+1's full byte stream — the provider can resume the
  213. // cache there instead of re-writing the whole tail.
  214. const prefixesN = readableCachePrefixes(outN);
  215. const streamN1 = readableCachePrefixes(outN1).at(-1) ?? '';
  216. const readable = prefixesN.filter((p) => streamN1.startsWith(p));
  217. expect(readable.length).toBeGreaterThan(0);
  218. // CONTRAST: the OLD separate-message placement establishes no readable
  219. // prefix — its only breakpoints sit on the merged tool_result+board turn
  220. // and the board turn, both of which N+1 does not reproduce at that offset.
  221. const oldReminder = board.formatForPrompt(SESSION) ?? '';
  222. const oldN = withSeparateBoardMessage(
  223. [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'result-1')],
  224. oldReminder,
  225. );
  226. const oldN1 = withSeparateBoardMessage(
  227. [
  228. userMsg('u1', 'Coordinate'),
  229. ...toolTurn('t1', 'result-1'),
  230. ...toolTurn('t2', 'result-2'),
  231. ],
  232. oldReminder,
  233. );
  234. const oldPrefixesN = readableCachePrefixes(oldN);
  235. const oldStreamN1 = readableCachePrefixes(oldN1).at(-1) ?? '';
  236. const oldReadable = oldPrefixesN.filter((p) => oldStreamN1.startsWith(p));
  237. expect(oldReadable.length).toBe(0);
  238. });
  239. test('board is a trailing part on the last message, keeping message count board-free-equal', async () => {
  240. const board = new BackgroundJobBoard();
  241. board.registerLaunch({
  242. taskID: 'child-1',
  243. parentSessionID: SESSION,
  244. agent: 'librarian',
  245. description: 'research',
  246. });
  247. const hook = createHook(board);
  248. const history = [
  249. userMsg('u1', 'Coordinate'),
  250. ...toolTurn('t1', 'result-1'),
  251. ];
  252. const emptyHook = createHook(new BackgroundJobBoard());
  253. const boardFree = await inject(emptyHook, history);
  254. const withBoard = await inject(hook, history);
  255. // No new message is created for the board.
  256. expect((withBoard as unknown[]).length).toBe(
  257. (boardFree as unknown[]).length,
  258. );
  259. // The single board part is the last part of the last message.
  260. const boardParts = (withBoard as Msg[]).flatMap((m, i) =>
  261. m.parts
  262. .map((p, pi) => ({ i, pi, p }))
  263. .filter(
  264. ({ p }) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
  265. ),
  266. );
  267. expect(boardParts).toHaveLength(1);
  268. const last = withBoard.at(-1) as Msg;
  269. expect(boardParts[0].i).toBe(withBoard.length - 1);
  270. expect(boardParts[0].pi).toBe(last.parts.length - 1);
  271. });
  272. test('previously-sent history bytes never change across a growing conversation', async () => {
  273. const board = new BackgroundJobBoard();
  274. board.registerLaunch({
  275. taskID: 'child-1',
  276. parentSessionID: SESSION,
  277. agent: 'librarian',
  278. description: 'research',
  279. });
  280. const hook = createHook(board);
  281. // Fingerprint of the stable (non-board) content of every message.
  282. const stableSerialize = (messages: unknown[]): string[] =>
  283. (messages as Msg[]).map((m) =>
  284. JSON.stringify({
  285. info: m.info,
  286. parts: m.parts.filter(
  287. (p) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] !== true,
  288. ),
  289. }),
  290. );
  291. const historyN = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
  292. const outN = stableSerialize(await inject(hook, historyN));
  293. board.updateStatus({
  294. taskID: 'child-1',
  295. state: 'completed',
  296. resultSummary: 'done',
  297. });
  298. const historyN1 = [
  299. userMsg('u1', 'Coordinate'),
  300. ...toolTurn('t1', 'r1'),
  301. ...toolTurn('t2', 'r2'),
  302. ];
  303. const outN1 = stableSerialize(await inject(hook, historyN1));
  304. // Every message present in request N must be byte-identical in N+1: the
  305. // board (excluded here) is the only thing that ever changes, and it rides
  306. // on the last message's trailing part, so real history is untouched.
  307. expect(outN1.slice(0, outN.length)).toEqual(outN);
  308. });
  309. test('an already-sent tail board is not stripped when the tail advances (dumps 000086->000087)', async () => {
  310. // Faithful reconstruction of the live cache bust (ses_11145863, dumps
  311. // 000086 A -> 000087 B). In A the tail was a user tool_result message that
  312. // carried the board as an appended trailing part; that request was SENT to
  313. // the provider and cached with the board on that message. B then advanced
  314. // by two new messages (assistant + user tool_result). The provider caches a
  315. // byte prefix, so every message it already received in A must be byte-
  316. // identical in B — INCLUDING the board bytes on the old tail. The #889
  317. // append-on-tail placement dropped that board when the tail advanced,
  318. // rewriting the already-sent old-tail message (A: 1376B -> B: 652B in the
  319. // field dump) and busting the cache prefix from that message onward.
  320. const board = new BackgroundJobBoard();
  321. board.registerLaunch({
  322. taskID: 'ses_child',
  323. parentSessionID: SESSION,
  324. agent: 'librarian',
  325. description: 'grok research',
  326. });
  327. const hook = createHook(board);
  328. // FULL serialization including the board part — a byte-exact fingerprint of
  329. // what the provider actually received for each message.
  330. const fullSerialize = (messages: unknown[]): string[] =>
  331. (messages as Msg[]).map((m) => JSON.stringify(m));
  332. // Request A: tail is a user tool_result turn; board rides on it as a
  333. // trailing part (the #889 "tail is user" branch, matching dump 000086).
  334. const historyA = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
  335. const outA = await inject(hook, historyA);
  336. const serA = fullSerialize(outA);
  337. // The old tail carried the board (as sent to the provider in request A).
  338. const oldTailA = outA.at(-1) as Msg;
  339. expect(oldTailA.info.role).toBe('user');
  340. expect(
  341. oldTailA.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
  342. ).toBe(true);
  343. // Request B: the loop advanced by exactly two new messages (assistant +
  344. // user tool_result), matching dump 000087's two extra tail messages.
  345. const historyB = [
  346. userMsg('u1', 'Coordinate'),
  347. ...toolTurn('t1', 'r1'),
  348. ...toolTurn('t2', 'r2'),
  349. ];
  350. const outB = await inject(hook, historyB);
  351. const serB = fullSerialize(outB);
  352. // Every message the provider received in request A must be byte-identical
  353. // in request B, board bytes included. In particular the old tail (index
  354. // serA.length - 1) must still carry its board — it must NOT be stripped.
  355. expect(serB.slice(0, serA.length)).toEqual(serA);
  356. // Explicit guard on the exact failure the field dump showed: the old-tail
  357. // message keeps its board trailing part in B.
  358. const oldTailB = outB[serA.length - 1] as Msg;
  359. expect(
  360. oldTailB.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
  361. ).toBe(true);
  362. });
  363. test('duplicate anonymous user turns preserve the first board on append', async () => {
  364. const board = new BackgroundJobBoard();
  365. board.registerLaunch({
  366. taskID: 'child-1',
  367. parentSessionID: SESSION,
  368. agent: 'librarian',
  369. description: 'research',
  370. });
  371. const hook = createHook(board);
  372. const firstRequest = await inject(hook, [anonymousUserMsg('continue')]);
  373. const firstMessage = firstRequest[0] as Msg;
  374. expect(
  375. firstMessage.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
  376. ).toBe(true);
  377. const secondRequest = await inject(hook, [
  378. anonymousUserMsg('continue'),
  379. anonymousUserMsg('continue'),
  380. ]);
  381. // The first anonymous message is the same append-stable anchor, while the
  382. // second occurrence receives the fresh tail board.
  383. expect(JSON.stringify(secondRequest[0])).toBe(
  384. JSON.stringify(firstRequest[0]),
  385. );
  386. expect(
  387. (secondRequest as Msg[]).flatMap((message) =>
  388. message.parts.filter(
  389. (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
  390. ),
  391. ),
  392. ).toHaveLength(2);
  393. });
  394. test('field-dump scenario: tail is a tool_result user turn preceded by an assistant turn', async () => {
  395. // Reconstructs the real bust (2026-07-23 dumps 000166→000167): the tail was
  396. // a user tool_result message preceded by an assistant tool-call message,
  397. // and the conversation advanced by one more tool turn between requests. The
  398. // board must attach to the tool_result tail so the preceding assistant turn
  399. // keeps a readable breakpoint that the next request reproduces.
  400. const board = new BackgroundJobBoard();
  401. board.registerLaunch({
  402. taskID: 'ses_child',
  403. parentSessionID: SESSION,
  404. agent: 'librarian',
  405. description: 'grok research',
  406. });
  407. const hook = createHook(board);
  408. const base = [
  409. userMsg('u1', 'Coordinate the work'),
  410. ...toolTurn('t1', 'r1'),
  411. ];
  412. const outN = await inject(hook, base);
  413. // The board rode on the tail user (tool_result) message, not a new message.
  414. expect((outN as unknown[]).length).toBe(base.length);
  415. const tail = outN.at(-1) as Msg;
  416. expect(tail.info.role).toBe('user');
  417. expect(
  418. tail.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
  419. ).toBe(true);
  420. // Advance by one more tool turn (as the loop did between dumps).
  421. const advanced = [
  422. userMsg('u1', 'Coordinate the work'),
  423. ...toolTurn('t1', 'r1'),
  424. ...toolTurn('t2', 'r2'),
  425. ];
  426. const outN1 = await inject(hook, advanced);
  427. // The assistant turn that preceded the board tail in request N is present
  428. // and byte-identical in request N+1 — the readable cache boundary.
  429. const prefixesN = readableCachePrefixes(outN);
  430. const fullN1 = readableCachePrefixes(outN1).at(-1) ?? '';
  431. expect(prefixesN.some((p) => fullN1.startsWith(p))).toBe(true);
  432. });
  433. });