@librechat/agents 3.2.51 → 3.2.52

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.
@@ -180,10 +180,24 @@ export declare class Run<_T extends t.BaseGraphState> {
180
180
  * paths processed; returns 0 when checkpointing is disabled.
181
181
  */
182
182
  rewindFiles(): Promise<number>;
183
+ /**
184
+ * Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
185
+ * `Command` fields applied together with `resume` in one superstep:
186
+ * - `update`: channel updates committed at the resume point. On a *rebuilt*
187
+ * Run (new instance + durable checkpointer), `update.messages` are the first
188
+ * write the fresh wrapper sees, so they seed the `getRunMessages()` /
189
+ * `returnContent` baseline and are excluded from them (still committed to the
190
+ * checkpoint). Hosts that rebuild + inject messages should persist them
191
+ * directly or read from `getState`. Unreachable without a durable checkpointer.
192
+ * - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
193
+ * the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
194
+ * fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
195
+ * tool-node resume). Intended for custom, Command-routed graphs.
196
+ */
183
197
  resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(resumeValue: TResume, callerConfig: Partial<RunnableConfig> & {
184
198
  version: 'v1' | 'v2';
185
199
  run_id?: string;
186
- }, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
200
+ }, streamOptions?: t.EventStreamOptions, commandOptions?: Pick<ConstructorParameters<typeof Command>[0], 'update' | 'goto'>): Promise<MessageContentComplex[] | undefined>;
187
201
  private resolveInterruptResumeConfig;
188
202
  private createSystemCallback;
189
203
  getCallbacks(clientCallbacks: t.ClientCallbacks): t.SystemCallbacks;
@@ -11,7 +11,7 @@ import { RunnableCallable } from '@/utils';
11
11
  * batch-scoped value the method needs so the signature stays at
12
12
  * three positional parameters even as new context fields are added.
13
13
  */
14
- type RunToolBatchContext = {
14
+ type RunToolBatchContext<T = unknown> = {
15
15
  /** Position of this call within the parent ToolNode batch. */
16
16
  batchIndex?: number;
17
17
  /** Batch turn shared across every call in the batch. */
@@ -50,6 +50,13 @@ type RunToolBatchContext = {
50
50
  * contract for hosts relying on it for policy / recovery guidance.
51
51
  */
52
52
  additionalContextsSink?: string[];
53
+ /**
54
+ * Graph state the ToolNode was invoked with, threaded from `run()`
55
+ * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
56
+ * (the deprecation-free replacement for `getCurrentTaskInput()`,
57
+ * which relies on `node:async_hooks` and is browser-incompatible).
58
+ */
59
+ runInput?: T;
53
60
  };
54
61
  export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
55
62
  private toolMap;
@@ -203,7 +210,7 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
203
210
  * register the output for future `{{tool<idx>turn<turn>}}`
204
211
  * substitutions. Omit when no registration should occur.
205
212
  */
206
- protected runTool(call: ToolCall, config: RunnableConfig, batchContext?: RunToolBatchContext): Promise<BaseMessage | Command>;
213
+ protected runTool(call: ToolCall, config: RunnableConfig, batchContext?: RunToolBatchContext<T>): Promise<BaseMessage | Command>;
207
214
  /**
208
215
  * Runs a single in-process tool call with the same lifecycle hooks
209
216
  * the event-dispatch path fires (`PreToolUse`, `PermissionDenied`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.51",
3
+ "version": "3.2.52",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -12,12 +12,11 @@ import {
12
12
  Command,
13
13
  StateGraph,
14
14
  Annotation,
15
- getCurrentTaskInput,
16
15
  messagesStateReducer,
17
16
  } from '@langchain/langgraph';
18
17
  import type { BaseMessage, AIMessageChunk } from '@langchain/core/messages';
19
18
  import type { LangGraphRunnableConfig } from '@langchain/langgraph';
20
- import type { ToolRunnableConfig } from '@langchain/core/tools';
19
+ import type { ToolRuntime } from '@langchain/core/tools';
21
20
  import type * as t from '@/types';
22
21
  import { StandardGraph } from './Graph';
23
22
  import { Constants } from '@/common';
@@ -330,12 +329,10 @@ export class MultiAgentGraph extends StandardGraph {
330
329
 
331
330
  tools.push(
332
331
  tool(
333
- async (rawInput, config) => {
332
+ async (rawInput, runtime: ToolRuntime) => {
334
333
  const input = rawInput as Record<string, unknown>;
335
- const state = getCurrentTaskInput() as t.BaseGraphState;
336
- const toolCallId =
337
- (config as ToolRunnableConfig | undefined)?.toolCall?.id ??
338
- 'unknown';
334
+ const state = runtime.state as t.BaseGraphState;
335
+ const toolCallId = runtime.toolCall?.id ?? 'unknown';
339
336
 
340
337
  /** Evaluated condition */
341
338
  const result = edge.condition!(state);
@@ -414,11 +411,9 @@ export class MultiAgentGraph extends StandardGraph {
414
411
 
415
412
  tools.push(
416
413
  tool(
417
- async (rawInput, config) => {
414
+ async (rawInput, runtime: ToolRuntime) => {
418
415
  const input = rawInput as Record<string, unknown>;
419
- const toolCallId =
420
- (config as ToolRunnableConfig | undefined)?.toolCall?.id ??
421
- 'unknown';
416
+ const toolCallId = runtime.toolCall?.id ?? 'unknown';
422
417
 
423
418
  let content = `Successfully transferred to ${destination}`;
424
419
  if (
@@ -439,7 +434,7 @@ export class MultiAgentGraph extends StandardGraph {
439
434
  },
440
435
  });
441
436
 
442
- const state = getCurrentTaskInput() as t.BaseGraphState;
437
+ const state = runtime.state as t.BaseGraphState;
443
438
 
444
439
  /**
445
440
  * For parallel handoff support:
package/src/run.ts CHANGED
@@ -667,7 +667,7 @@ export class Run<_T extends t.BaseGraphState> {
667
667
  const graph = this.Graph;
668
668
 
669
669
  /**
670
- * `Command` inputs (currently only `Command({ resume })`) are
670
+ * `Command` inputs (`Command({ resume, update?, goto? })`) are
671
671
  * resume-mode invocations: LangGraph rebuilds graph state from the
672
672
  * checkpointer, so we skip RunStart / UserPromptSubmit hooks (no
673
673
  * new prompt to evaluate) and read run-state from the Graph wrapper
@@ -1139,13 +1139,31 @@ export class Run<_T extends t.BaseGraphState> {
1139
1139
  return cp == null ? 0 : cp.rewind();
1140
1140
  }
1141
1141
 
1142
+ /**
1143
+ * Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
1144
+ * `Command` fields applied together with `resume` in one superstep:
1145
+ * - `update`: channel updates committed at the resume point. On a *rebuilt*
1146
+ * Run (new instance + durable checkpointer), `update.messages` are the first
1147
+ * write the fresh wrapper sees, so they seed the `getRunMessages()` /
1148
+ * `returnContent` baseline and are excluded from them (still committed to the
1149
+ * checkpoint). Hosts that rebuild + inject messages should persist them
1150
+ * directly or read from `getState`. Unreachable without a durable checkpointer.
1151
+ * - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
1152
+ * the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
1153
+ * fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
1154
+ * tool-node resume). Intended for custom, Command-routed graphs.
1155
+ */
1142
1156
  async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
1143
1157
  resumeValue: TResume,
1144
1158
  callerConfig: Partial<RunnableConfig> & {
1145
1159
  version: 'v1' | 'v2';
1146
1160
  run_id?: string;
1147
1161
  },
1148
- streamOptions?: t.EventStreamOptions
1162
+ streamOptions?: t.EventStreamOptions,
1163
+ commandOptions?: Pick<
1164
+ ConstructorParameters<typeof Command>[0],
1165
+ 'update' | 'goto'
1166
+ >
1149
1167
  ): Promise<MessageContentComplex[] | undefined> {
1150
1168
  const interruptId = this._interrupt?.interruptId;
1151
1169
  const scopedResume =
@@ -1155,8 +1173,18 @@ export class Run<_T extends t.BaseGraphState> {
1155
1173
  ? { [interruptId]: resumeValue }
1156
1174
  : resumeValue;
1157
1175
  const resumeConfig = await this.resolveInterruptResumeConfig(callerConfig);
1176
+ // langgraph 1.4.5 applies resume + state update + reroute in one superstep
1177
+ // (single checkpoint). `update`/`goto` are omitted unless the caller sets them.
1158
1178
  return this.processStream(
1159
- new Command({ resume: scopedResume }),
1179
+ new Command({
1180
+ resume: scopedResume,
1181
+ ...(commandOptions?.update !== undefined
1182
+ ? { update: commandOptions.update }
1183
+ : {}),
1184
+ ...(commandOptions?.goto !== undefined
1185
+ ? { goto: commandOptions.goto }
1186
+ : {}),
1187
+ }),
1160
1188
  resumeConfig,
1161
1189
  streamOptions
1162
1190
  );
@@ -10,10 +10,10 @@ import {
10
10
  MessagesAnnotation,
11
11
  Command,
12
12
  START,
13
- getCurrentTaskInput,
14
13
  END,
15
14
  } from '@langchain/langgraph';
16
15
  import { ToolMessage } from '@langchain/core/messages';
16
+ import type { ToolRuntime } from '@langchain/core/tools';
17
17
 
18
18
  interface CreateHandoffToolParams {
19
19
  agentName: string;
@@ -28,16 +28,15 @@ const createHandoffTool = ({
28
28
  const toolDescription = description || `Ask agent '${agentName}' for help`;
29
29
 
30
30
  const handoffTool = tool(
31
- async (_, config) => {
31
+ async (_, runtime: ToolRuntime) => {
32
32
  const toolMessage = new ToolMessage({
33
33
  content: `Successfully transferred to ${agentName}`,
34
34
  name: toolName,
35
- tool_call_id: config.toolCall.id,
35
+ tool_call_id: runtime.toolCallId,
36
36
  });
37
37
 
38
- // inject the current agent state
39
- const state =
40
- getCurrentTaskInput() as (typeof MessagesAnnotation)['State'];
38
+ // inject the current agent state via langgraph 1.4 `runtime.state`
39
+ const state = runtime.state as (typeof MessagesAnnotation)['State'];
41
40
  return new Command({
42
41
  goto: agentName,
43
42
  update: { messages: state.messages.concat(toolMessage) },
@@ -19,8 +19,12 @@ import type {
19
19
  RunnableConfig,
20
20
  RunnableToolLike,
21
21
  } from '@langchain/core/runnables';
22
+ import type {
23
+ ToolRuntime,
24
+ StructuredToolInterface,
25
+ } from '@langchain/core/tools';
22
26
  import type { BaseMessage, AIMessage } from '@langchain/core/messages';
23
- import type { StructuredToolInterface } from '@langchain/core/tools';
27
+ import type { LangGraphRunnableConfig } from '@langchain/langgraph';
24
28
  import type {
25
29
  ToolOutputResolveView,
26
30
  PreResolvedArgsMap,
@@ -65,7 +69,7 @@ import { executeHooks } from '@/hooks';
65
69
  * batch-scoped value the method needs so the signature stays at
66
70
  * three positional parameters even as new context fields are added.
67
71
  */
68
- type RunToolBatchContext = {
72
+ type RunToolBatchContext<T = unknown> = {
69
73
  /** Position of this call within the parent ToolNode batch. */
70
74
  batchIndex?: number;
71
75
  /** Batch turn shared across every call in the batch. */
@@ -104,6 +108,13 @@ type RunToolBatchContext = {
104
108
  * contract for hosts relying on it for policy / recovery guidance.
105
109
  */
106
110
  additionalContextsSink?: string[];
111
+ /**
112
+ * Graph state the ToolNode was invoked with, threaded from `run()`
113
+ * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
114
+ * (the deprecation-free replacement for `getCurrentTaskInput()`,
115
+ * which relies on `node:async_hooks` and is browser-incompatible).
116
+ */
117
+ runInput?: T;
107
118
  };
108
119
 
109
120
  const TOOL_NODE_RUN_NAME = 'tool_batch';
@@ -798,7 +809,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
798
809
  protected async runTool(
799
810
  call: ToolCall,
800
811
  config: RunnableConfig,
801
- batchContext: RunToolBatchContext = {}
812
+ batchContext: RunToolBatchContext<T> = {}
802
813
  ): Promise<BaseMessage | Command> {
803
814
  const {
804
815
  batchIndex,
@@ -806,6 +817,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
806
817
  batchScopeId,
807
818
  resolvedArgsByCallId,
808
819
  preBatchSnapshot,
820
+ runInput,
809
821
  } = batchContext;
810
822
  const tool = this.toolMap.get(call.name);
811
823
  const registry = this.toolOutputRegistry;
@@ -980,7 +992,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
980
992
  }
981
993
  }
982
994
 
983
- const output = await tool.invoke(invokeParams, config);
995
+ /**
996
+ * Forward the graph state as langgraph 1.4's `runtime.state` so
997
+ * tools can read it off their second argument instead of the
998
+ * deprecated `getCurrentTaskInput()` (which relies on
999
+ * `node:async_hooks` and is browser-incompatible). Shape mirrors
1000
+ * langgraph's prebuilt ToolNode runtime exactly.
1001
+ */
1002
+ const lgConfig = config as LangGraphRunnableConfig;
1003
+ const runtime: ToolRuntime<T> = {
1004
+ ...config,
1005
+ state: runInput as ToolRuntime<T>['state'],
1006
+ toolCallId: call.id ?? '',
1007
+ config,
1008
+ context: lgConfig.context as ToolRuntime<T>['context'],
1009
+ store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
1010
+ writer:
1011
+ lgConfig.writer ??
1012
+ (config.configurable?.writer as ToolRuntime<T>['writer']) ??
1013
+ null,
1014
+ };
1015
+ const output = await tool.invoke(invokeParams, runtime);
984
1016
  if (isCommand(output)) {
985
1017
  return output;
986
1018
  }
@@ -1192,7 +1224,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1192
1224
  private async runDirectToolWithLifecycleHooks(
1193
1225
  call: ToolCall,
1194
1226
  config: RunnableConfig,
1195
- batchContext: RunToolBatchContext = {}
1227
+ batchContext: RunToolBatchContext<T> = {}
1196
1228
  ): Promise<BaseMessage | Command> {
1197
1229
  const runId = (config.configurable?.run_id as string | undefined) ?? '';
1198
1230
  const hookRegistry = this.hookRegistry;
@@ -3162,6 +3194,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3162
3194
  // branch, so direct tools dispatched via Send (a supported
3163
3195
  // input shape) still silently dropped hook context.
3164
3196
  const directAdditionalContexts: string[] = [];
3197
+ // Mirror langgraph's prebuilt ToolNode: the Send-input state is
3198
+ // the input minus the `lg_tool_call` envelope key.
3199
+ const { lg_tool_call: _sendToolCall, ...sendState } = input;
3165
3200
  const sendOutput = await this.runDirectToolWithLifecycleHooks(
3166
3201
  input.lg_tool_call,
3167
3202
  config,
@@ -3171,6 +3206,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3171
3206
  batchScopeId,
3172
3207
  resolvedArgsByCallId,
3173
3208
  additionalContextsSink: directAdditionalContexts,
3209
+ runInput: sendState as T,
3174
3210
  }
3175
3211
  );
3176
3212
  outputs =
@@ -3343,6 +3379,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3343
3379
  resolvedArgsByCallId,
3344
3380
  preBatchSnapshot,
3345
3381
  additionalContextsSink: directAdditionalContexts,
3382
+ runInput: input as T,
3346
3383
  })
3347
3384
  )
3348
3385
  )
@@ -3407,6 +3444,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3407
3444
  resolvedArgsByCallId,
3408
3445
  preBatchSnapshot,
3409
3446
  additionalContextsSink: directAdditionalContexts,
3447
+ runInput: input as T,
3410
3448
  })
3411
3449
  )
3412
3450
  );
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { describe, it, expect } from '@jest/globals';
4
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
5
+ import type { BaseMessage } from '@langchain/core/messages';
6
+ import type { ToolRuntime } from '@langchain/core/tools';
7
+ import { ToolNode } from '../ToolNode';
8
+
9
+ type CapturedRuntime = {
10
+ state: unknown;
11
+ toolCallId: string;
12
+ toolCallIdFromConfig: string | undefined;
13
+ };
14
+
15
+ /**
16
+ * Tool that records the langgraph runtime it received as its second
17
+ * argument. Proves our forked ToolNode forwards `runtime.state` (the
18
+ * deprecation-free replacement for `getCurrentTaskInput()`).
19
+ */
20
+ function createRuntimeProbeTool(captured: CapturedRuntime[]) {
21
+ return tool(
22
+ async (_input, runtime: ToolRuntime) => {
23
+ captured.push({
24
+ state: runtime.state,
25
+ toolCallId: runtime.toolCallId,
26
+ toolCallIdFromConfig: runtime.toolCall?.id,
27
+ });
28
+ return 'ok';
29
+ },
30
+ {
31
+ name: 'probe_runtime',
32
+ description: 'Records the runtime forwarded by the ToolNode',
33
+ schema: z.object({ value: z.string() }),
34
+ }
35
+ );
36
+ }
37
+
38
+ function aiMessageWithProbeCall(callId: string): AIMessage {
39
+ return new AIMessage({
40
+ content: '',
41
+ tool_calls: [
42
+ {
43
+ id: callId,
44
+ name: 'probe_runtime',
45
+ args: { value: 'hello' },
46
+ },
47
+ ],
48
+ });
49
+ }
50
+
51
+ describe('ToolNode runtime.state forwarding (langgraph 1.4)', () => {
52
+ it('forwards the message-state input as runtime.state', async () => {
53
+ const captured: CapturedRuntime[] = [];
54
+ const toolNode = new ToolNode({
55
+ tools: [createRuntimeProbeTool(captured)],
56
+ });
57
+
58
+ const messages: BaseMessage[] = [
59
+ new HumanMessage('start'),
60
+ aiMessageWithProbeCall('call_1'),
61
+ ];
62
+ const input = { messages };
63
+
64
+ await toolNode.invoke(input);
65
+
66
+ expect(captured).toHaveLength(1);
67
+ /* state is the exact run input object the ToolNode was invoked with */
68
+ expect(captured[0].state).toBe(input);
69
+ expect((captured[0].state as { messages: BaseMessage[] }).messages).toBe(
70
+ messages
71
+ );
72
+ /* toolCallId and the langchain-extracted toolCall both resolve */
73
+ expect(captured[0].toolCallId).toBe('call_1');
74
+ expect(captured[0].toolCallIdFromConfig).toBe('call_1');
75
+ });
76
+
77
+ it('forwards the array-shaped input as runtime.state', async () => {
78
+ const captured: CapturedRuntime[] = [];
79
+ const toolNode = new ToolNode({
80
+ tools: [createRuntimeProbeTool(captured)],
81
+ });
82
+
83
+ const input: BaseMessage[] = [
84
+ new HumanMessage('start'),
85
+ aiMessageWithProbeCall('call_2'),
86
+ ];
87
+
88
+ await toolNode.invoke(input);
89
+
90
+ expect(captured).toHaveLength(1);
91
+ expect(captured[0].state).toBe(input);
92
+ expect(captured[0].toolCallId).toBe('call_2');
93
+ });
94
+
95
+ it('forwards the de-enveloped state for Send-shaped input', async () => {
96
+ const captured: CapturedRuntime[] = [];
97
+ const toolNode = new ToolNode({
98
+ tools: [createRuntimeProbeTool(captured)],
99
+ });
100
+
101
+ const sidecar = { tenant: 'acme' };
102
+ await toolNode.invoke({
103
+ lg_tool_call: {
104
+ id: 'call_3',
105
+ name: 'probe_runtime',
106
+ args: { value: 'hi' },
107
+ type: 'tool_call',
108
+ },
109
+ ...sidecar,
110
+ });
111
+
112
+ expect(captured).toHaveLength(1);
113
+ /* The `lg_tool_call` envelope key is stripped, the rest is state */
114
+ expect(captured[0].state).toEqual(sidecar);
115
+ expect(
116
+ (captured[0].state as Record<string, unknown>).lg_tool_call
117
+ ).toBeUndefined();
118
+ expect(captured[0].toolCallId).toBe('call_3');
119
+ });
120
+ });
@@ -761,6 +761,54 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
761
761
  expect(run.Graph?.compileOptions?.checkpointer).toBe(hostCheckpointer);
762
762
  });
763
763
 
764
+ it('Run.resume forwards update + goto into the resume Command (langgraph 1.4.5)', async () => {
765
+ const { Run } = await import('@/run');
766
+ const { Providers } = await import('@/common');
767
+
768
+ const run = await Run.create<t.IState>({
769
+ runId: 'hitl-resume-update-goto',
770
+ graphConfig: {
771
+ type: 'standard',
772
+ agents: [
773
+ {
774
+ agentId: 'a',
775
+ provider: Providers.OPENAI,
776
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
777
+ instructions: 'noop',
778
+ maxContextTokens: 8000,
779
+ },
780
+ ],
781
+ },
782
+ });
783
+
784
+ const spy = jest.spyOn(run, 'processStream').mockResolvedValue(undefined);
785
+
786
+ const decision = [{ type: 'approve' as const }];
787
+ const update = { messages: [new AIMessage('host-edit')] };
788
+ await run.resume(
789
+ decision,
790
+ { version: 'v1', configurable: { thread_id: 't' } },
791
+ undefined,
792
+ { update, goto: 'agent' }
793
+ );
794
+
795
+ const cmd = spy.mock.calls[0]?.[0] as Command;
796
+ expect(cmd).toBeInstanceOf(Command);
797
+ // No interrupt was captured, so the resume value passes through unscoped.
798
+ expect(cmd.resume).toEqual(decision);
799
+ expect(cmd.update).toEqual(update);
800
+ expect(cmd.goto).toEqual(['agent']); // langgraph normalizes goto to an array
801
+
802
+ // Backward-compat: omitting commandOptions leaves update unset, goto empty.
803
+ await run.resume(decision, {
804
+ version: 'v1',
805
+ configurable: { thread_id: 't' },
806
+ });
807
+ const cmd2 = spy.mock.calls[1]?.[0] as Command;
808
+ expect(cmd2.update).toBeUndefined();
809
+ expect(cmd2.goto).toEqual([]);
810
+ });
811
+
764
812
  it('re-exports langgraph HITL primitives from the SDK barrel for host use', async () => {
765
813
  const indexExports = await import('@/index');
766
814
  expect(indexExports.MemorySaver).toBe(MemorySaver);
@@ -903,6 +951,120 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
903
951
  expect(run.getHaltReason()).toBeUndefined();
904
952
  });
905
953
 
954
+ it('Run.resume() forwards `update` so langgraph applies the channel edit through streamEvents', async () => {
955
+ /** Executing proof (not a spy): interrupt on a tool, resume with an
956
+ * injected message via `update`, then read the committed checkpoint.
957
+ * langgraph 1.4.5 maps an INPUT resume Command through `mapCommand`
958
+ * (pregel/io.js), which applies resume AND update AND goto, so the
959
+ * injected message must land in the messages channel. */
960
+ jest
961
+ .spyOn(events, 'safeDispatchCustomEvent')
962
+ .mockImplementation(async (event, data) => {
963
+ if (event !== 'on_tool_execute') {
964
+ return;
965
+ }
966
+ const request = data as {
967
+ toolCalls: t.ToolCallRequest[];
968
+ resolve: (r: t.ToolExecuteResult[]) => void;
969
+ };
970
+ request.resolve(
971
+ request.toolCalls.map((c) => ({
972
+ toolCallId: c.id,
973
+ content: 'host-result',
974
+ status: 'success' as const,
975
+ }))
976
+ );
977
+ });
978
+
979
+ const registry = new HookRegistry();
980
+ registry.register('PreToolUse', {
981
+ hooks: [
982
+ async (): Promise<PreToolUseHookOutput> => ({
983
+ decision: 'ask',
984
+ reason: 'review',
985
+ }),
986
+ ],
987
+ });
988
+
989
+ const hexToolCallId = '0123456789abcdef0123456789abcdef';
990
+ const node = new ToolNode({
991
+ tools: [createSchemaStub('echo')],
992
+ eventDrivenMode: true,
993
+ agentId: 'agent-x',
994
+ toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
995
+ hookRegistry: registry,
996
+ humanInTheLoop: { enabled: true },
997
+ });
998
+
999
+ const builder = new StateGraph(MessagesAnnotation)
1000
+ .addNode(
1001
+ 'agent',
1002
+ (): MessagesUpdate => ({
1003
+ messages: [
1004
+ new AIMessage({
1005
+ content: '',
1006
+ tool_calls: [
1007
+ { id: hexToolCallId, name: 'echo', args: { command: 'x' } },
1008
+ ],
1009
+ }),
1010
+ ],
1011
+ })
1012
+ )
1013
+ .addNode('tools', node)
1014
+ .addEdge(START, 'agent')
1015
+ .addEdge('agent', 'tools')
1016
+ .addEdge('tools', END);
1017
+ const graph = builder.compile({ checkpointer: new MemorySaver() });
1018
+
1019
+ const { Run } = await import('@/run');
1020
+ const { HumanMessage } = await import('@langchain/core/messages');
1021
+ const run = await Run.create<t.IState>({
1022
+ runId: 'run-resume-update',
1023
+ graphConfig: {
1024
+ type: 'standard',
1025
+ agents: [
1026
+ {
1027
+ agentId: 'a',
1028
+ provider: providers.OPENAI,
1029
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1030
+ instructions: 'noop',
1031
+ maxContextTokens: 8000,
1032
+ },
1033
+ ],
1034
+ },
1035
+ hooks: registry,
1036
+ humanInTheLoop: { enabled: true },
1037
+ });
1038
+ run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
1039
+
1040
+ const callerConfig = {
1041
+ configurable: { thread_id: 'run-resume-update-thread' },
1042
+ version: 'v2' as const,
1043
+ };
1044
+
1045
+ await run.processStream({ messages: [] }, callerConfig);
1046
+ expect(run.getInterrupt()).toBeDefined();
1047
+
1048
+ const injected = new HumanMessage({ content: 'human-injected-on-resume' });
1049
+ await run.resume(
1050
+ { [hexToolCallId]: { type: 'approve' } },
1051
+ callerConfig,
1052
+ undefined,
1053
+ { update: { messages: [injected] } }
1054
+ );
1055
+
1056
+ expect(run.getInterrupt()).toBeUndefined();
1057
+
1058
+ const state = await graph.getState(callerConfig);
1059
+ const contents = (state.values.messages as BaseMessage[]).map(
1060
+ (m) => m.content
1061
+ );
1062
+ /** Proves langgraph honored `update` on the INPUT resume Command. */
1063
+ expect(contents).toContain('human-injected-on-resume');
1064
+ /** Resume itself still completed: the approved tool produced its result. */
1065
+ expect(contents).toContain('host-result');
1066
+ });
1067
+
906
1068
  it('Run.getHaltReason() reports prompt_denied when UserPromptSubmit denies the prompt', async () => {
907
1069
  const registry = new HookRegistry();
908
1070
  registry.register('UserPromptSubmit', {