running-task-cache-safety.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. /**
  2. * Regression coverage for running background-task tool-result byte stability.
  3. *
  4. * While a background `task` lane runs, the runtime may stream live child
  5. * progress into the parent's task tool part (`state.output`). A still-running
  6. * task result sits mid-history, so any per-request change to it invalidates
  7. * the provider prompt cache from that byte onward, re-writing the entire tail
  8. * every request (a write-never-read loop).
  9. *
  10. * The transform hook must:
  11. * (a) keep a running task part byte-identical across consecutive requests
  12. * while the child progresses,
  13. * (b) materialize the terminal result exactly once and keep it byte-stable
  14. * afterwards, and
  15. * (c) never produce duplicate completed results.
  16. */
  17. import { describe, expect, mock, test } from 'bun:test';
  18. import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
  19. import { BackgroundJobBoard } from '../../utils';
  20. import { createTaskSessionManagerHook } from './index';
  21. const SESSION = 'ses_orchestrator_1114';
  22. const CHILD = 'ses_child_0771';
  23. function createHook(board: BackgroundJobBoard) {
  24. return createTaskSessionManagerHook(
  25. {
  26. client: { session: { status: mock(async () => ({ data: {} })) } },
  27. directory: '/tmp',
  28. worktree: '/tmp',
  29. } as never,
  30. {
  31. maxSessionsPerAgent: 4,
  32. maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
  33. backgroundJobBoard: board,
  34. shouldManageSession: () => true,
  35. },
  36. );
  37. }
  38. /** A task tool call on an assistant message, mirroring the SDK part shape. */
  39. function taskToolMessage(callID: string, output: string) {
  40. return {
  41. info: {
  42. role: 'assistant',
  43. agent: 'orchestrator',
  44. sessionID: SESSION,
  45. id: callID,
  46. },
  47. parts: [
  48. { type: 'text', text: ' ' },
  49. {
  50. type: 'tool',
  51. tool: 'task',
  52. callID,
  53. state: { status: 'running', input: { background: true }, output },
  54. },
  55. ],
  56. };
  57. }
  58. function userMessage(id: string, text: string) {
  59. return {
  60. info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
  61. parts: [{ type: 'text', text }],
  62. };
  63. }
  64. /** Grab the task tool part's rendered output from a transformed history. */
  65. function taskOutput(messages: unknown[], callID: string): string | undefined {
  66. for (const message of messages as any[]) {
  67. for (const part of message?.parts ?? []) {
  68. if (
  69. part?.type === 'tool' &&
  70. part?.tool === 'task' &&
  71. part?.callID === callID
  72. ) {
  73. return part.state?.output as string | undefined;
  74. }
  75. }
  76. }
  77. return undefined;
  78. }
  79. async function transform(
  80. hook: ReturnType<typeof createTaskSessionManagerHook>,
  81. history: unknown[],
  82. ): Promise<unknown[]> {
  83. // prompt.ts rebuilds msgs from storage every request, so each transform
  84. // starts from a fresh clone of the real history.
  85. const request = { messages: structuredClone(history) };
  86. await hook['experimental.chat.messages.transform']({}, request as never);
  87. return request.messages;
  88. }
  89. // Core's running placeholder that grows with live child progress. In this
  90. // runtime it is static, but the transform must be robust to a runtime that
  91. // streams progress into it.
  92. function runningOutput(snapshot: string): string {
  93. return [
  94. `<task id="${CHILD}" state="running">`,
  95. '<summary>Background task started</summary>',
  96. '<task_result>',
  97. snapshot,
  98. '</task_result>',
  99. '</task>',
  100. ].join('\n');
  101. }
  102. describe('running task tool-result cache safety', () => {
  103. test('(a) running task part is byte-identical across consecutive requests while the child progresses', async () => {
  104. const board = new BackgroundJobBoard();
  105. const hook = createHook(board);
  106. // Two consecutive requests where the runtime streamed different live
  107. // progress snapshots into the same running task part.
  108. const history1 = [
  109. userMessage('u1', 'Coordinate the work'),
  110. taskToolMessage(
  111. 'call-1',
  112. runningOutput('The task is working in the background... (711 bytes)'),
  113. ),
  114. ];
  115. const history2 = [
  116. userMessage('u1', 'Coordinate the work'),
  117. taskToolMessage(
  118. 'call-1',
  119. runningOutput(
  120. 'Progress snapshot: found 4 files, still working... (4132 bytes)',
  121. ),
  122. ),
  123. ];
  124. const out1 = await transform(hook, history1);
  125. const out2 = await transform(hook, history2);
  126. const o1 = taskOutput(out1, 'call-1');
  127. const o2 = taskOutput(out2, 'call-1');
  128. expect(o1).toBeDefined();
  129. expect(o2).toBeDefined();
  130. // Byte-identical despite different live snapshots — cache prefix preserved.
  131. expect(o2).toBe(o1 as string);
  132. // Deterministic placeholder keyed on the task ID, still parseable as running.
  133. expect(o1).toContain(`<task id="${CHILD}" state="running">`);
  134. expect(o1).not.toContain('4132 bytes');
  135. expect(o1).not.toContain('711 bytes');
  136. });
  137. test('(b) terminal result materializes once and then stays byte-stable', async () => {
  138. const board = new BackgroundJobBoard();
  139. const hook = createHook(board);
  140. const completedOutput = [
  141. `<task id="${CHILD}" state="completed">`,
  142. '<summary>Background task completed: research grok models</summary>',
  143. '<task_result>',
  144. 'Full research findings: repo uses xai/grok-imagine-image, latest is quality mode.',
  145. '</task_result>',
  146. '</task>',
  147. ].join('\n');
  148. // The completed tool part carries a real terminal result.
  149. const completedMessage = {
  150. info: {
  151. role: 'assistant',
  152. agent: 'orchestrator',
  153. sessionID: SESSION,
  154. id: 'call-1',
  155. },
  156. parts: [
  157. {
  158. type: 'tool',
  159. tool: 'task',
  160. callID: 'call-1',
  161. state: {
  162. status: 'completed',
  163. input: { background: true },
  164. output: completedOutput,
  165. },
  166. },
  167. ],
  168. };
  169. const history = [
  170. userMessage('u1', 'Coordinate the work'),
  171. completedMessage,
  172. ];
  173. const out1 = await transform(hook, history);
  174. const out2 = await transform(hook, history);
  175. const o1 = taskOutput(out1, 'call-1');
  176. const o2 = taskOutput(out2, 'call-1');
  177. // The terminal result must reach the orchestrator intact and unchanged.
  178. expect(o1).toBe(completedOutput);
  179. expect(o2).toBe(completedOutput);
  180. expect(o1).toContain('Full research findings');
  181. });
  182. test('(c) running → terminal transition mutates the part exactly once, no duplicate completed results', async () => {
  183. const board = new BackgroundJobBoard();
  184. const hook = createHook(board);
  185. const completedOutput = [
  186. `<task id="${CHILD}" state="completed">`,
  187. '<summary>Background task completed: research grok models</summary>',
  188. '<task_result>',
  189. 'Final result body.',
  190. '</task_result>',
  191. '</task>',
  192. ].join('\n');
  193. // Turn 1 & 2: running (byte-stable). Turn 3: completed.
  194. const runningHistory = [
  195. userMessage('u1', 'Coordinate the work'),
  196. taskToolMessage('call-1', runningOutput('snapshot A')),
  197. ];
  198. const runningHistory2 = [
  199. userMessage('u1', 'Coordinate the work'),
  200. taskToolMessage('call-1', runningOutput('snapshot B — bigger')),
  201. ];
  202. const terminalHistory = [
  203. userMessage('u1', 'Coordinate the work'),
  204. {
  205. info: {
  206. role: 'assistant',
  207. agent: 'orchestrator',
  208. sessionID: SESSION,
  209. id: 'call-1',
  210. },
  211. parts: [
  212. {
  213. type: 'tool',
  214. tool: 'task',
  215. callID: 'call-1',
  216. state: {
  217. status: 'completed',
  218. input: { background: true },
  219. output: completedOutput,
  220. },
  221. },
  222. ],
  223. },
  224. ];
  225. const r1 = taskOutput(await transform(hook, runningHistory), 'call-1');
  226. const r2 = taskOutput(await transform(hook, runningHistory2), 'call-1');
  227. const outTerminal = await transform(hook, terminalHistory);
  228. const t3 = taskOutput(outTerminal, 'call-1');
  229. const t4 = taskOutput(await transform(hook, terminalHistory), 'call-1');
  230. // Running requests are byte-identical; the single mutation is running→terminal.
  231. expect(r2).toBe(r1 as string);
  232. expect(r1).not.toBe(t3);
  233. // Terminal stays stable afterwards (no further mutation).
  234. expect(t4).toBe(t3 as string);
  235. expect(t3).toBe(completedOutput);
  236. // No duplicate completed results anywhere in the payload.
  237. const completedCount = (outTerminal as any[])
  238. .flatMap((m) => m?.parts ?? [])
  239. .filter(
  240. (p: any) =>
  241. p?.type === 'tool' &&
  242. p?.tool === 'task' &&
  243. typeof p?.state?.output === 'string' &&
  244. p.state.output.includes('state="completed"'),
  245. ).length;
  246. expect(completedCount).toBe(1);
  247. });
  248. test('foreground (non-background) running task parts are also stabilized deterministically', async () => {
  249. // Defensive: a running task part with no background flag still normalizes
  250. // to the deterministic placeholder (only terminal results are preserved).
  251. const board = new BackgroundJobBoard();
  252. const hook = createHook(board);
  253. const message = {
  254. info: {
  255. role: 'assistant',
  256. agent: 'orchestrator',
  257. sessionID: SESSION,
  258. id: 'call-1',
  259. },
  260. parts: [
  261. {
  262. type: 'tool',
  263. tool: 'task',
  264. callID: 'call-1',
  265. state: {
  266. status: 'running',
  267. input: {},
  268. output: runningOutput('live snapshot'),
  269. },
  270. },
  271. ],
  272. };
  273. const out = await transform(hook, [userMessage('u1', 'go'), message]);
  274. const o = taskOutput(out, 'call-1');
  275. expect(o).toContain(`<task id="${CHILD}" state="running">`);
  276. expect(o).not.toContain('live snapshot');
  277. });
  278. });