@librechat/agents 3.1.86 → 3.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +69 -0
  2. package/dist/cjs/events.cjs +23 -0
  3. package/dist/cjs/events.cjs.map +1 -1
  4. package/dist/cjs/graphs/Graph.cjs +133 -18
  5. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  8. package/dist/cjs/llm/anthropic/index.cjs +251 -53
  9. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  10. package/dist/cjs/llm/init.cjs +1 -5
  11. package/dist/cjs/llm/init.cjs.map +1 -1
  12. package/dist/cjs/llm/openai/index.cjs +113 -24
  13. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  14. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  15. package/dist/cjs/llm/openrouter/index.cjs +3 -1
  16. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  17. package/dist/cjs/main.cjs +18 -5
  18. package/dist/cjs/main.cjs.map +1 -1
  19. package/dist/cjs/openai/index.cjs +253 -0
  20. package/dist/cjs/openai/index.cjs.map +1 -0
  21. package/dist/cjs/responses/index.cjs +448 -0
  22. package/dist/cjs/responses/index.cjs.map +1 -0
  23. package/dist/cjs/run.cjs +108 -7
  24. package/dist/cjs/run.cjs.map +1 -1
  25. package/dist/cjs/session/AgentSession.cjs +1057 -0
  26. package/dist/cjs/session/AgentSession.cjs.map +1 -0
  27. package/dist/cjs/session/JsonlSessionStore.cjs +425 -0
  28. package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -0
  29. package/dist/cjs/session/handlers.cjs +221 -0
  30. package/dist/cjs/session/handlers.cjs.map +1 -0
  31. package/dist/cjs/session/ids.cjs +22 -0
  32. package/dist/cjs/session/ids.cjs.map +1 -0
  33. package/dist/cjs/session/messageSerialization.cjs +179 -0
  34. package/dist/cjs/session/messageSerialization.cjs.map +1 -0
  35. package/dist/cjs/stream.cjs +475 -11
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/summarization/node.cjs +1 -1
  38. package/dist/cjs/summarization/node.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolNode.cjs +177 -59
  40. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  41. package/dist/cjs/tools/eagerEventExecution.cjs +113 -0
  42. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -0
  43. package/dist/cjs/tools/handlers.cjs +1 -1
  44. package/dist/cjs/tools/handlers.cjs.map +1 -1
  45. package/dist/cjs/tools/streamedToolCallSeals.cjs +42 -0
  46. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -0
  47. package/dist/esm/events.mjs +23 -1
  48. package/dist/esm/events.mjs.map +1 -1
  49. package/dist/esm/graphs/Graph.mjs +133 -18
  50. package/dist/esm/graphs/Graph.mjs.map +1 -1
  51. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  52. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  53. package/dist/esm/llm/anthropic/index.mjs +251 -53
  54. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  55. package/dist/esm/llm/init.mjs +1 -5
  56. package/dist/esm/llm/init.mjs.map +1 -1
  57. package/dist/esm/llm/openai/index.mjs +113 -25
  58. package/dist/esm/llm/openai/index.mjs.map +1 -1
  59. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  60. package/dist/esm/llm/openrouter/index.mjs +4 -2
  61. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  62. package/dist/esm/main.mjs +5 -1
  63. package/dist/esm/main.mjs.map +1 -1
  64. package/dist/esm/openai/index.mjs +246 -0
  65. package/dist/esm/openai/index.mjs.map +1 -0
  66. package/dist/esm/responses/index.mjs +440 -0
  67. package/dist/esm/responses/index.mjs.map +1 -0
  68. package/dist/esm/run.mjs +108 -7
  69. package/dist/esm/run.mjs.map +1 -1
  70. package/dist/esm/session/AgentSession.mjs +1054 -0
  71. package/dist/esm/session/AgentSession.mjs.map +1 -0
  72. package/dist/esm/session/JsonlSessionStore.mjs +422 -0
  73. package/dist/esm/session/JsonlSessionStore.mjs.map +1 -0
  74. package/dist/esm/session/handlers.mjs +219 -0
  75. package/dist/esm/session/handlers.mjs.map +1 -0
  76. package/dist/esm/session/ids.mjs +17 -0
  77. package/dist/esm/session/ids.mjs.map +1 -0
  78. package/dist/esm/session/messageSerialization.mjs +173 -0
  79. package/dist/esm/session/messageSerialization.mjs.map +1 -0
  80. package/dist/esm/stream.mjs +476 -12
  81. package/dist/esm/stream.mjs.map +1 -1
  82. package/dist/esm/summarization/node.mjs +1 -1
  83. package/dist/esm/summarization/node.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +177 -59
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/eagerEventExecution.mjs +107 -0
  87. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -0
  88. package/dist/esm/tools/handlers.mjs +1 -1
  89. package/dist/esm/tools/handlers.mjs.map +1 -1
  90. package/dist/esm/tools/streamedToolCallSeals.mjs +36 -0
  91. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -0
  92. package/dist/types/events.d.ts +1 -0
  93. package/dist/types/graphs/Graph.d.ts +24 -9
  94. package/dist/types/index.d.ts +1 -0
  95. package/dist/types/llm/openai/index.d.ts +1 -0
  96. package/dist/types/openai/index.d.ts +75 -0
  97. package/dist/types/responses/index.d.ts +97 -0
  98. package/dist/types/run.d.ts +2 -0
  99. package/dist/types/session/AgentSession.d.ts +32 -0
  100. package/dist/types/session/JsonlSessionStore.d.ts +67 -0
  101. package/dist/types/session/handlers.d.ts +8 -0
  102. package/dist/types/session/ids.d.ts +4 -0
  103. package/dist/types/session/index.d.ts +5 -0
  104. package/dist/types/session/messageSerialization.d.ts +7 -0
  105. package/dist/types/session/types.d.ts +191 -0
  106. package/dist/types/tools/ToolNode.d.ts +12 -1
  107. package/dist/types/tools/eagerEventExecution.d.ts +23 -0
  108. package/dist/types/tools/streamedToolCallSeals.d.ts +13 -0
  109. package/dist/types/types/hitl.d.ts +4 -0
  110. package/dist/types/types/run.d.ts +11 -1
  111. package/dist/types/types/tools.d.ts +36 -0
  112. package/package.json +19 -2
  113. package/src/__tests__/stream.eagerEventExecution.test.ts +2571 -0
  114. package/src/events.ts +29 -0
  115. package/src/graphs/Graph.ts +224 -50
  116. package/src/graphs/MultiAgentGraph.ts +1 -1
  117. package/src/graphs/__tests__/composition.smoke.test.ts +30 -0
  118. package/src/index.ts +3 -0
  119. package/src/llm/anthropic/index.ts +356 -84
  120. package/src/llm/anthropic/llm.spec.ts +64 -0
  121. package/src/llm/custom-chat-models.smoke.test.ts +175 -4
  122. package/src/llm/openai/contentBlocks.test.ts +35 -0
  123. package/src/llm/openai/deepseek.test.ts +201 -2
  124. package/src/llm/openai/index.ts +171 -26
  125. package/src/llm/openai/utils/index.ts +22 -0
  126. package/src/llm/openrouter/index.ts +4 -2
  127. package/src/openai/__tests__/openai.test.ts +337 -0
  128. package/src/openai/index.ts +404 -0
  129. package/src/responses/__tests__/responses.test.ts +652 -0
  130. package/src/responses/index.ts +677 -0
  131. package/src/run.ts +158 -8
  132. package/src/scripts/compare_pi_vs_ours.ts +592 -173
  133. package/src/scripts/session_live.ts +548 -0
  134. package/src/session/AgentSession.ts +1432 -0
  135. package/src/session/JsonlSessionStore.ts +572 -0
  136. package/src/session/__tests__/JsonlSessionStore.test.ts +1410 -0
  137. package/src/session/__tests__/handlers.test.ts +161 -0
  138. package/src/session/handlers.ts +272 -0
  139. package/src/session/ids.ts +17 -0
  140. package/src/session/index.ts +44 -0
  141. package/src/session/messageSerialization.ts +207 -0
  142. package/src/session/types.ts +275 -0
  143. package/src/specs/custom-event-await.test.ts +89 -0
  144. package/src/specs/summarization.test.ts +1 -1
  145. package/src/stream.ts +756 -48
  146. package/src/summarization/node.ts +1 -1
  147. package/src/tools/ToolNode.ts +299 -126
  148. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +373 -0
  149. package/src/tools/__tests__/handlers.test.ts +2 -1
  150. package/src/tools/__tests__/hitl.test.ts +206 -110
  151. package/src/tools/eagerEventExecution.ts +153 -0
  152. package/src/tools/handlers.ts +8 -4
  153. package/src/tools/streamedToolCallSeals.ts +57 -0
  154. package/src/types/hitl.ts +4 -0
  155. package/src/types/run.ts +11 -0
  156. package/src/types/tools.ts +36 -0
  157. package/dist/cjs/llm/text.cjs +0 -69
  158. package/dist/cjs/llm/text.cjs.map +0 -1
  159. package/dist/esm/llm/text.mjs +0 -67
  160. package/dist/esm/llm/text.mjs.map +0 -1
@@ -37,6 +37,20 @@ import { HookRegistry } from '@/hooks';
37
37
  import { Providers as providers, GraphEvents } from '@/common';
38
38
  import { ToolNode } from '../ToolNode';
39
39
 
40
+ async function flushAsyncWork(): Promise<void> {
41
+ await Promise.resolve();
42
+ await new Promise<void>((resolve) => setImmediate(resolve));
43
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
44
+ await new Promise<void>((resolve) => setImmediate(resolve));
45
+ await Promise.resolve();
46
+ }
47
+
48
+ afterEach(async () => {
49
+ await flushAsyncWork();
50
+ jest.restoreAllMocks();
51
+ await flushAsyncWork();
52
+ });
53
+
40
54
  /**
41
55
  * Schema-only tool stub. ToolNode in event-driven mode uses the schema
42
56
  * for binding/discovery but routes execution through the host via
@@ -71,8 +85,20 @@ function mockEventDispatch(mockResults: t.ToolExecuteResult[]): void {
71
85
  }
72
86
 
73
87
  type MessagesUpdate = { messages: BaseMessage[] };
88
+ type InterruptStateSnapshot = {
89
+ config?: RunnableConfig;
90
+ tasks?: Array<{
91
+ interrupts?: Array<{ id?: string }>;
92
+ }>;
93
+ };
74
94
  type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
75
95
  invoke(input: unknown, config?: RunnableConfig): Promise<unknown>;
96
+ getState?(
97
+ config: RunnableConfig
98
+ ): Promise<{ config?: RunnableConfig } | undefined>;
99
+ getStateHistory?(
100
+ config: RunnableConfig
101
+ ): AsyncIterableIterator<InterruptStateSnapshot>;
76
102
  };
77
103
 
78
104
  /** Factory for a minimal `agent → tools → END` graph wrapping the ToolNode. */
@@ -80,18 +106,26 @@ function buildHITLGraph(
80
106
  toolNode: ToolNode,
81
107
  toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>
82
108
  ): CompiledMessagesGraph {
83
- let agentInvocations = 0;
109
+ const toolCallIds = new Set(toolCalls.map((call) => call.id));
84
110
  const builder = new StateGraph(MessagesAnnotation)
85
- .addNode('agent', (): MessagesUpdate => {
86
- agentInvocations += 1;
111
+ .addNode('agent', (state: { messages?: BaseMessage[] }): MessagesUpdate => {
87
112
  /**
88
- * First entry → emit the AIMessage carrying tool_calls so the
89
- * ToolNode actually has work. After resume the agent re-enters
90
- * once more (a normal LangGraph loop), but at that point any
91
- * approved tool already has a ToolMessage in state, so we emit
92
- * an empty AIMessage to satisfy the loop and end the run.
113
+ * Emit the AIMessage carrying tool_calls until this test graph
114
+ * actually has a matching ToolMessage in state. LangGraph usually
115
+ * resumes at the interrupted `tools` node, but under full-suite
116
+ * async callback pressure it can re-enter this tiny test graph from
117
+ * START while still carrying the resume value. A call-count based
118
+ * fake agent then returned "done" too early and made HITL resume
119
+ * assertions order-dependent. State is the stable contract here:
120
+ * no tool result means the tool node still needs work.
93
121
  */
94
- if (agentInvocations === 1) {
122
+ const hasMatchingToolResult =
123
+ state.messages?.some(
124
+ (message): boolean =>
125
+ message._getType() === 'tool' &&
126
+ toolCallIds.has((message as ToolMessage).tool_call_id)
127
+ ) === true;
128
+ if (!hasMatchingToolResult) {
95
129
  return {
96
130
  messages: [new AIMessage({ content: '', tool_calls: toolCalls })],
97
131
  };
@@ -123,6 +157,52 @@ function makeHookRegistry(
123
157
  return registry;
124
158
  }
125
159
 
160
+ function resumeFromInterrupt<TResume>(
161
+ interrupted: unknown,
162
+ resume: TResume
163
+ ): Command {
164
+ if (isInterrupted<unknown>(interrupted)) {
165
+ const interruptId = interrupted.__interrupt__[0]?.id;
166
+ if (typeof interruptId === 'string' && interruptId.length > 0) {
167
+ return new Command({ resume: { [interruptId]: resume } });
168
+ }
169
+ }
170
+ return new Command({ resume });
171
+ }
172
+
173
+ async function resumeGraph<TResume>(
174
+ graph: CompiledMessagesGraph,
175
+ interrupted: unknown,
176
+ resume: TResume,
177
+ config: RunnableConfig
178
+ ): Promise<unknown> {
179
+ const interruptId = isInterrupted<unknown>(interrupted)
180
+ ? interrupted.__interrupt__[0]?.id
181
+ : undefined;
182
+ let checkpointConfig = config;
183
+ if (typeof interruptId === 'string' && graph.getStateHistory != null) {
184
+ for await (const snapshot of graph.getStateHistory(config)) {
185
+ const hasMatchingInterrupt =
186
+ snapshot.tasks?.some(
187
+ (task) =>
188
+ task.interrupts?.some(
189
+ (interrupt) => interrupt.id === interruptId
190
+ ) === true
191
+ ) === true;
192
+ if (hasMatchingInterrupt && snapshot.config != null) {
193
+ checkpointConfig = snapshot.config;
194
+ break;
195
+ }
196
+ }
197
+ } else {
198
+ checkpointConfig = (await graph.getState?.(config))?.config ?? config;
199
+ }
200
+ return graph.invoke(
201
+ resumeFromInterrupt(interrupted, resume),
202
+ checkpointConfig
203
+ );
204
+ }
205
+
126
206
  describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoop is enabled', () => {
127
207
  afterEach(() => {
128
208
  jest.restoreAllMocks();
@@ -196,10 +276,14 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
196
276
  const interrupted = await graph.invoke({ messages: [] }, config);
197
277
  expect(isInterrupted(interrupted)).toBe(true);
198
278
 
199
- const resumed = (await graph.invoke(
200
- new Command({ resume: [{ type: 'approve' }] }),
279
+ const resumed = (await resumeGraph(
280
+ graph,
281
+ interrupted,
282
+ [{ type: 'approve' }],
201
283
  config
202
- )) as { messages: BaseMessage[] };
284
+ )) as {
285
+ messages: BaseMessage[];
286
+ };
203
287
 
204
288
  const toolMessages = resumed.messages.filter(
205
289
  (m): m is ToolMessage => m._getType() === 'tool'
@@ -226,12 +310,12 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
226
310
  ]);
227
311
  const config = { configurable: { thread_id: 'thread-hitl-reject' } };
228
312
 
229
- await graph.invoke({ messages: [] }, config);
313
+ const interrupted = await graph.invoke({ messages: [] }, config);
230
314
 
231
- const resumed = (await graph.invoke(
232
- new Command({
233
- resume: [{ type: 'reject', reason: 'destructive command' }],
234
- }),
315
+ const resumed = (await resumeGraph(
316
+ graph,
317
+ interrupted,
318
+ [{ type: 'reject', reason: 'destructive command' }],
235
319
  config
236
320
  )) as { messages: BaseMessage[] };
237
321
 
@@ -279,12 +363,12 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
279
363
  ]);
280
364
  const config = { configurable: { thread_id: 'thread-hitl-edit' } };
281
365
 
282
- await graph.invoke({ messages: [] }, config);
366
+ const interrupted = await graph.invoke({ messages: [] }, config);
283
367
 
284
- await graph.invoke(
285
- new Command({
286
- resume: [{ type: 'edit', updatedInput: { command: 'patched' } }],
287
- }),
368
+ await resumeGraph(
369
+ graph,
370
+ interrupted,
371
+ [{ type: 'edit', updatedInput: { command: 'patched' } }],
288
372
  config
289
373
  );
290
374
 
@@ -320,16 +404,16 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
320
404
  ]);
321
405
  const config = { configurable: { thread_id: 'thread-hitl-respond' } };
322
406
 
323
- await graph.invoke({ messages: [] }, config);
407
+ const interrupted = await graph.invoke({ messages: [] }, config);
324
408
 
325
409
  const dispatchCallsBefore = dispatchSpy.mock.calls.filter(
326
410
  ([event]) => event === 'on_tool_execute'
327
411
  ).length;
328
412
 
329
- const resumed = (await graph.invoke(
330
- new Command({
331
- resume: [{ type: 'respond', responseText: 'no relevant results' }],
332
- }),
413
+ const resumed = (await resumeGraph(
414
+ graph,
415
+ interrupted,
416
+ [{ type: 'respond', responseText: 'no relevant results' }],
333
417
  config
334
418
  )) as { messages: BaseMessage[] };
335
419
 
@@ -399,10 +483,12 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
399
483
  ]);
400
484
  const config = { configurable: { thread_id: 'thread-hitl-map' } };
401
485
 
402
- await graph.invoke({ messages: [] }, config);
486
+ const interrupted = await graph.invoke({ messages: [] }, config);
403
487
 
404
- const resumed = (await graph.invoke(
405
- new Command({ resume: { call_1: { type: 'approve' } } }),
488
+ const resumed = (await resumeGraph(
489
+ graph,
490
+ interrupted,
491
+ { call_1: { type: 'approve' } },
406
492
  config
407
493
  )) as { messages: BaseMessage[] };
408
494
 
@@ -561,10 +647,10 @@ describe('ToolNode HITL — multi-tool batches', () => {
561
647
  'call_2',
562
648
  ]);
563
649
 
564
- const resumed = (await graph.invoke(
565
- new Command({
566
- resume: [{ type: 'approve' }, { type: 'reject', reason: 'too risky' }],
567
- }),
650
+ const resumed = (await resumeGraph(
651
+ graph,
652
+ interrupted,
653
+ [{ type: 'approve' }, { type: 'reject', reason: 'too risky' }],
568
654
  config
569
655
  )) as { messages: BaseMessage[] };
570
656
 
@@ -745,11 +831,12 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
745
831
  ],
746
832
  });
747
833
 
834
+ const hexToolCallId = '0123456789abcdef0123456789abcdef';
748
835
  const node = new ToolNode({
749
836
  tools: [createSchemaStub('echo')],
750
837
  eventDrivenMode: true,
751
838
  agentId: 'agent-x',
752
- toolCallStepIds: new Map([['call_1', 'step_1']]),
839
+ toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
753
840
  hookRegistry: registry,
754
841
  humanInTheLoop: { enabled: true },
755
842
  });
@@ -762,7 +849,7 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
762
849
  new AIMessage({
763
850
  content: '',
764
851
  tool_calls: [
765
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
852
+ { id: hexToolCallId, name: 'echo', args: { command: 'x' } },
766
853
  ],
767
854
  }),
768
855
  ],
@@ -804,8 +891,10 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
804
891
  expect(dispatchCount).toBe(0);
805
892
 
806
893
  /** This is the API contract under test: Run.resume() with a
807
- * decision array (not graph.invoke + Command). */
808
- await run.resume([{ type: 'approve' }], callerConfig);
894
+ * tool_call_id-keyed decision map (not graph.invoke + Command).
895
+ * The tool_call_id intentionally looks like a LangGraph interrupt
896
+ * id; Run.resume must still wrap it under the real interrupt id. */
897
+ await run.resume({ [hexToolCallId]: { type: 'approve' } }, callerConfig);
809
898
 
810
899
  expect(dispatchCount).toBe(1);
811
900
  /** Resume completed naturally: interrupt cleared, no halt
@@ -1732,13 +1821,13 @@ describe('Codex review fixes', () => {
1732
1821
  ]);
1733
1822
  const config = { configurable: { thread_id: 'dedup-thread' } };
1734
1823
 
1735
- await graph.invoke({ messages: [] }, config);
1824
+ const interrupted = await graph.invoke({ messages: [] }, config);
1736
1825
  /** First pass: interrupt() threw, so the deferred denial side
1737
1826
  * effects were not flushed. Zero step-completed events for the
1738
1827
  * denied tool yet. */
1739
1828
  expect(stepCompletedDispatches.filter((id) => id === 'call_a')).toEqual([]);
1740
1829
 
1741
- await graph.invoke(new Command({ resume: [{ type: 'approve' }] }), config);
1830
+ await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
1742
1831
 
1743
1832
  /** After resume: the denied tool dispatches exactly once (deferred
1744
1833
  * flush on the resume re-execution); the approved tool dispatches
@@ -1803,13 +1892,13 @@ describe('Codex review fixes', () => {
1803
1892
  ]);
1804
1893
  const config = { configurable: { thread_id: 'allowed-enforce' } };
1805
1894
 
1806
- await graph.invoke({ messages: [] }, config);
1895
+ const interrupted = await graph.invoke({ messages: [] }, config);
1807
1896
 
1808
1897
  /** Submit `edit` — outside the advertised allowlist. */
1809
- const resumed = (await graph.invoke(
1810
- new Command({
1811
- resume: [{ type: 'edit', updatedInput: { command: 'malicious' } }],
1812
- }),
1898
+ const resumed = (await resumeGraph(
1899
+ graph,
1900
+ interrupted,
1901
+ [{ type: 'edit', updatedInput: { command: 'malicious' } }],
1813
1902
  config
1814
1903
  )) as { messages: BaseMessage[] };
1815
1904
 
@@ -1875,10 +1964,10 @@ describe('Codex review fixes', () => {
1875
1964
  ]);
1876
1965
  const config = { configurable: { thread_id: 'allowed-pass' } };
1877
1966
 
1878
- await graph.invoke({ messages: [] }, config);
1967
+ const interrupted = await graph.invoke({ messages: [] }, config);
1879
1968
 
1880
1969
  /** Submit `approve` — explicitly in the allowlist. */
1881
- await graph.invoke(new Command({ resume: [{ type: 'approve' }] }), config);
1970
+ await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
1882
1971
 
1883
1972
  expect(dispatchedArgs).toEqual([{ command: 'original' }]);
1884
1973
  });
@@ -2048,7 +2137,7 @@ describe('Codex review fixes', () => {
2048
2137
  command: 'redacted-command',
2049
2138
  });
2050
2139
 
2051
- await graph.invoke(new Command({ resume: [{ type: 'approve' }] }), config);
2140
+ await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
2052
2141
 
2053
2142
  /** And the host execution dispatches the rewritten args, not
2054
2143
  * the original. Without the fix, the policy redaction would be
@@ -2307,15 +2396,15 @@ describe('Codex review fixes', () => {
2307
2396
  ]);
2308
2397
  const config = { configurable: { thread_id: 'edit-malformed' } };
2309
2398
 
2310
- await graph.invoke({ messages: [] }, config);
2399
+ const interrupted = await graph.invoke({ messages: [] }, config);
2311
2400
 
2312
2401
  /** `{ type: 'edit' }` with no updatedInput — same trust-boundary
2313
2402
  * issue as malformed respond. Must fail closed, NOT pass undefined
2314
2403
  * into applyInputOverride and approve a tool with garbage args. */
2315
- const resumed = (await graph.invoke(
2316
- new Command({
2317
- resume: [{ type: 'edit' } as unknown as t.ToolApprovalDecision],
2318
- }),
2404
+ const resumed = (await resumeGraph(
2405
+ graph,
2406
+ interrupted,
2407
+ [{ type: 'edit' } as unknown as t.ToolApprovalDecision],
2319
2408
  config
2320
2409
  )) as { messages: BaseMessage[] };
2321
2410
 
@@ -2361,19 +2450,19 @@ describe('Codex review fixes', () => {
2361
2450
  ]);
2362
2451
  const config = { configurable: { thread_id: 'edit-nonobject' } };
2363
2452
 
2364
- await graph.invoke({ messages: [] }, config);
2453
+ const interrupted = await graph.invoke({ messages: [] }, config);
2365
2454
 
2366
2455
  /** `updatedInput: 'string'` — wire deserializer didn't enforce
2367
2456
  * object shape; SDK must reject. */
2368
- const resumed = (await graph.invoke(
2369
- new Command({
2370
- resume: [
2371
- {
2372
- type: 'edit',
2373
- updatedInput: 'not-an-object' as unknown as Record<string, unknown>,
2374
- },
2375
- ],
2376
- }),
2457
+ const resumed = (await resumeGraph(
2458
+ graph,
2459
+ interrupted,
2460
+ [
2461
+ {
2462
+ type: 'edit',
2463
+ updatedInput: 'not-an-object' as unknown as Record<string, unknown>,
2464
+ },
2465
+ ],
2377
2466
  config
2378
2467
  )) as { messages: BaseMessage[] };
2379
2468
 
@@ -2410,17 +2499,17 @@ describe('Codex review fixes', () => {
2410
2499
  ]);
2411
2500
  const config = { configurable: { thread_id: 'edit-array' } };
2412
2501
 
2413
- await graph.invoke({ messages: [] }, config);
2502
+ const interrupted = await graph.invoke({ messages: [] }, config);
2414
2503
 
2415
- const resumed = (await graph.invoke(
2416
- new Command({
2417
- resume: [
2418
- {
2419
- type: 'edit',
2420
- updatedInput: [1, 2, 3] as unknown as Record<string, unknown>,
2421
- },
2422
- ],
2423
- }),
2504
+ const resumed = (await resumeGraph(
2505
+ graph,
2506
+ interrupted,
2507
+ [
2508
+ {
2509
+ type: 'edit',
2510
+ updatedInput: [1, 2, 3] as unknown as Record<string, unknown>,
2511
+ },
2512
+ ],
2424
2513
  config
2425
2514
  )) as { messages: BaseMessage[] };
2426
2515
 
@@ -2462,15 +2551,15 @@ describe('Codex review fixes', () => {
2462
2551
  ]);
2463
2552
  const config = { configurable: { thread_id: 'respond-malformed' } };
2464
2553
 
2465
- await graph.invoke({ messages: [] }, config);
2554
+ const interrupted = await graph.invoke({ messages: [] }, config);
2466
2555
 
2467
2556
  /** Submit a `respond` decision with NO responseText — wire shape
2468
2557
  * the SDK can't honor. Must fail closed (blockEntry path), NOT
2469
2558
  * crash truncateToolResultContent on `undefined.length`. */
2470
- const resumed = (await graph.invoke(
2471
- new Command({
2472
- resume: [{ type: 'respond' } as unknown as t.ToolApprovalDecision],
2473
- }),
2559
+ const resumed = (await resumeGraph(
2560
+ graph,
2561
+ interrupted,
2562
+ [{ type: 'respond' } as unknown as t.ToolApprovalDecision],
2474
2563
  config
2475
2564
  )) as { messages: BaseMessage[] };
2476
2565
 
@@ -2508,19 +2597,19 @@ describe('Codex review fixes', () => {
2508
2597
  ]);
2509
2598
  const config = { configurable: { thread_id: 'respond-nonstring' } };
2510
2599
 
2511
- await graph.invoke({ messages: [] }, config);
2600
+ const interrupted = await graph.invoke({ messages: [] }, config);
2512
2601
 
2513
2602
  /** `responseText: 42` — wire deserializer didn't enforce string;
2514
2603
  * SDK must reject without crashing. */
2515
- const resumed = (await graph.invoke(
2516
- new Command({
2517
- resume: [
2518
- {
2519
- type: 'respond',
2520
- responseText: 42 as unknown as string,
2521
- },
2522
- ],
2523
- }),
2604
+ const resumed = (await resumeGraph(
2605
+ graph,
2606
+ interrupted,
2607
+ [
2608
+ {
2609
+ type: 'respond',
2610
+ responseText: 42 as unknown as string,
2611
+ },
2612
+ ],
2524
2613
  config
2525
2614
  )) as { messages: BaseMessage[] };
2526
2615
 
@@ -2571,14 +2660,14 @@ describe('Codex review fixes', () => {
2571
2660
  ]);
2572
2661
  const config = { configurable: { thread_id: 'respond-truncate' } };
2573
2662
 
2574
- await graph.invoke({ messages: [] }, config);
2663
+ const interrupted = await graph.invoke({ messages: [] }, config);
2575
2664
 
2576
2665
  /** 200-char response — well over the 50-char cap. */
2577
2666
  const oversized = 'A'.repeat(200);
2578
- const resumed = (await graph.invoke(
2579
- new Command({
2580
- resume: [{ type: 'respond', responseText: oversized }],
2581
- }),
2667
+ const resumed = (await resumeGraph(
2668
+ graph,
2669
+ interrupted,
2670
+ [{ type: 'respond', responseText: oversized }],
2582
2671
  config
2583
2672
  )) as { messages: BaseMessage[] };
2584
2673
 
@@ -3091,8 +3180,10 @@ describe('Codex review fixes', () => {
3091
3180
  expect(payload.action_requests[0].tool_call_id).toBe('call_b');
3092
3181
  expect(dispatchedToolNames).toEqual([]);
3093
3182
 
3094
- const resumed = (await graph.invoke(
3095
- new Command({ resume: [{ type: 'approve' }] }),
3183
+ const resumed = (await resumeGraph(
3184
+ graph,
3185
+ interrupted,
3186
+ [{ type: 'approve' }],
3096
3187
  config
3097
3188
  )) as { messages: BaseMessage[] };
3098
3189
 
@@ -3222,17 +3313,17 @@ describe('Codex review fixes', () => {
3222
3313
  ]);
3223
3314
  const config = { configurable: { thread_id: 'mixed-respond-reject' } };
3224
3315
 
3225
- await graph.invoke({ messages: [] }, config);
3316
+ const interrupted = await graph.invoke({ messages: [] }, config);
3226
3317
  /** First pass: interrupt fires before either dispatch path runs. */
3227
3318
  expect(stepCompletedDispatches).toEqual([]);
3228
3319
 
3229
- const resumed = (await graph.invoke(
3230
- new Command({
3231
- resume: [
3232
- { type: 'respond', responseText: 'fake answer' },
3233
- { type: 'reject', reason: 'no thanks' },
3234
- ],
3235
- }),
3320
+ const resumed = (await resumeGraph(
3321
+ graph,
3322
+ interrupted,
3323
+ [
3324
+ { type: 'respond', responseText: 'fake answer' },
3325
+ { type: 'reject', reason: 'no thanks' },
3326
+ ],
3236
3327
  config
3237
3328
  )) as { messages: BaseMessage[] };
3238
3329
 
@@ -3394,13 +3485,13 @@ describe('Codex review fixes', () => {
3394
3485
  ]);
3395
3486
  const config = { configurable: { thread_id: 'unknown-decision' } };
3396
3487
 
3397
- await graph.invoke({ messages: [] }, config);
3488
+ const interrupted = await graph.invoke({ messages: [] }, config);
3398
3489
 
3399
3490
  /** Host sends a typo'd / malformed decision. Must NOT silently approve. */
3400
- const resumed = (await graph.invoke(
3401
- new Command({
3402
- resume: [{ type: 'aproved' as 'approve' }],
3403
- }),
3491
+ const resumed = (await resumeGraph(
3492
+ graph,
3493
+ interrupted,
3494
+ [{ type: 'aproved' as 'approve' }],
3404
3495
  config
3405
3496
  )) as { messages: BaseMessage[] };
3406
3497
 
@@ -3555,7 +3646,7 @@ describe('AskUserQuestion — interrupt + resume', () => {
3555
3646
  const config = { configurable: { thread_id: 'ask-q-thread' } };
3556
3647
 
3557
3648
  const interrupted = (await graph.invoke({ messages: [] }, config)) as {
3558
- __interrupt__?: Array<{ value?: t.HumanInterruptPayload }>;
3649
+ __interrupt__?: Array<{ id?: string; value?: t.HumanInterruptPayload }>;
3559
3650
  };
3560
3651
  expect(interrupted.__interrupt__).toBeDefined();
3561
3652
  const payload = interrupted.__interrupt__![0].value!;
@@ -3566,7 +3657,12 @@ describe('AskUserQuestion — interrupt + resume', () => {
3566
3657
  expect(payload.question.options).toHaveLength(2);
3567
3658
 
3568
3659
  const resolution: t.AskUserQuestionResolution = { answer: 'production' };
3569
- await graph.invoke(new Command({ resume: resolution }), config);
3660
+ await resumeGraph(
3661
+ graph as unknown as CompiledMessagesGraph,
3662
+ interrupted,
3663
+ resolution,
3664
+ config
3665
+ );
3570
3666
 
3571
3667
  expect(resumedAnswer).toBe('production');
3572
3668
  });