@librechat/agents 3.3.13 → 3.4.1

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 (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +121 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/google/index.cjs +19 -12
  15. package/dist/cjs/llm/google/index.cjs.map +1 -1
  16. package/dist/cjs/llm/init.cjs +3 -3
  17. package/dist/cjs/llm/invoke.cjs +2 -2
  18. package/dist/cjs/llm/mistral/index.cjs +26 -0
  19. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  20. package/dist/cjs/llm/openai/index.cjs +82 -80
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/llm/providers.cjs +19 -19
  24. package/dist/cjs/llm/providers.cjs.map +1 -1
  25. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  26. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  27. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  28. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  29. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  30. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  31. package/dist/cjs/main.cjs +25 -16
  32. package/dist/cjs/messages/format.cjs +40 -23
  33. package/dist/cjs/messages/format.cjs.map +1 -1
  34. package/dist/cjs/run.cjs +82 -15
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/AgentSession.cjs +37 -2
  37. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  38. package/dist/cjs/stream.cjs +1 -1
  39. package/dist/cjs/summarization/node.cjs +6 -3
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  42. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  43. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  44. package/dist/cjs/tools/ToolNode.cjs +316 -94
  45. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  46. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  50. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  51. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  52. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  53. package/dist/cjs/utils/index.cjs +2 -2
  54. package/dist/cjs/utils/tokens.cjs +76 -29
  55. package/dist/cjs/utils/tokens.cjs.map +1 -1
  56. package/dist/esm/graphs/Graph.mjs +121 -27
  57. package/dist/esm/graphs/Graph.mjs.map +1 -1
  58. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  59. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  60. package/dist/esm/hooks/executeHooks.mjs +60 -14
  61. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  62. package/dist/esm/hooks/index.mjs.map +1 -1
  63. package/dist/esm/hooks/types.mjs +2 -1
  64. package/dist/esm/hooks/types.mjs.map +1 -1
  65. package/dist/esm/llm/anthropic/index.mjs +34 -205
  66. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  67. package/dist/esm/llm/bedrock/index.mjs +120 -240
  68. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  69. package/dist/esm/llm/google/index.mjs +19 -12
  70. package/dist/esm/llm/google/index.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +1 -1
  72. package/dist/esm/llm/invoke.mjs +2 -2
  73. package/dist/esm/llm/mistral/index.mjs +26 -0
  74. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  75. package/dist/esm/llm/openai/index.mjs +82 -80
  76. package/dist/esm/llm/openai/index.mjs.map +1 -1
  77. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  78. package/dist/esm/llm/providers.mjs +3 -3
  79. package/dist/esm/llm/providers.mjs.map +1 -1
  80. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  81. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  82. package/dist/esm/llm/stream/smoother.mjs +365 -0
  83. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  84. package/dist/esm/llm/vertexai/index.mjs +13 -1
  85. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  86. package/dist/esm/main.mjs +13 -11
  87. package/dist/esm/messages/format.mjs +40 -23
  88. package/dist/esm/messages/format.mjs.map +1 -1
  89. package/dist/esm/run.mjs +83 -16
  90. package/dist/esm/run.mjs.map +1 -1
  91. package/dist/esm/session/AgentSession.mjs +37 -2
  92. package/dist/esm/session/AgentSession.mjs.map +1 -1
  93. package/dist/esm/stream.mjs +1 -1
  94. package/dist/esm/summarization/node.mjs +6 -3
  95. package/dist/esm/summarization/node.mjs.map +1 -1
  96. package/dist/esm/tools/BashExecutor.mjs +1 -1
  97. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  98. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  99. package/dist/esm/tools/ToolNode.mjs +317 -95
  100. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  101. package/dist/esm/tools/ToolSearch.mjs +1 -1
  102. package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
  103. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  104. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  105. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  106. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  107. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  108. package/dist/esm/utils/index.mjs +2 -2
  109. package/dist/esm/utils/tokens.mjs +76 -30
  110. package/dist/esm/utils/tokens.mjs.map +1 -1
  111. package/dist/types/graphs/Graph.d.ts +19 -1
  112. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  113. package/dist/types/hooks/executeHooks.d.ts +5 -1
  114. package/dist/types/hooks/index.d.ts +2 -2
  115. package/dist/types/hooks/types.d.ts +12 -0
  116. package/dist/types/index.d.ts +3 -0
  117. package/dist/types/llm/google/index.d.ts +2 -0
  118. package/dist/types/llm/mistral/index.d.ts +11 -0
  119. package/dist/types/llm/openai/index.d.ts +20 -4
  120. package/dist/types/llm/openrouter/index.d.ts +4 -1
  121. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  122. package/dist/types/llm/stream/smoother.d.ts +95 -0
  123. package/dist/types/llm/vertexai/index.d.ts +2 -0
  124. package/dist/types/run.d.ts +4 -1
  125. package/dist/types/session/AgentSession.d.ts +1 -0
  126. package/dist/types/tools/ToolNode.d.ts +23 -15
  127. package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
  128. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  129. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  130. package/dist/types/types/graph.d.ts +5 -5
  131. package/dist/types/types/hitl.d.ts +15 -0
  132. package/dist/types/types/llm.d.ts +21 -14
  133. package/dist/types/utils/tokens.d.ts +10 -0
  134. package/package.json +6 -2
  135. package/src/graphs/Graph.ts +257 -52
  136. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  137. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  138. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  139. package/src/hooks/HookRegistry.ts +151 -1
  140. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  141. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  142. package/src/hooks/executeHooks.ts +133 -15
  143. package/src/hooks/index.ts +3 -1
  144. package/src/hooks/types.ts +16 -0
  145. package/src/index.ts +8 -0
  146. package/src/llm/anthropic/index.ts +85 -354
  147. package/src/llm/bedrock/index.ts +174 -390
  148. package/src/llm/bedrock/llm.spec.ts +2 -0
  149. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  150. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  151. package/src/llm/google/index.ts +17 -18
  152. package/src/llm/google/streamSmoothing.test.ts +121 -0
  153. package/src/llm/mistral/index.ts +33 -0
  154. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  155. package/src/llm/openai/deepseek.test.ts +56 -0
  156. package/src/llm/openai/index.ts +119 -126
  157. package/src/llm/openrouter/index.ts +4 -1
  158. package/src/llm/providers.ts +3 -3
  159. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  160. package/src/llm/stream/chunkAdapters.ts +317 -0
  161. package/src/llm/stream/reassembly.test.ts +241 -0
  162. package/src/llm/stream/smoother.bench.test.ts +155 -0
  163. package/src/llm/stream/smoother.test.ts +519 -0
  164. package/src/llm/stream/smoother.ts +574 -0
  165. package/src/llm/vertexai/index.ts +17 -1
  166. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +164 -18
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/subagent.test.ts +44 -0
  175. package/src/specs/tokens.test.ts +159 -14
  176. package/src/summarization/__tests__/node.test.ts +32 -14
  177. package/src/summarization/node.ts +15 -11
  178. package/src/tools/ToolNode.ts +631 -157
  179. package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
  180. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  181. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  182. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  183. package/src/tools/__tests__/hitl.test.ts +234 -5
  184. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  185. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  186. package/src/tools/subagent/SubagentExecutor.ts +1438 -136
  187. package/src/tools/subagent/SubagentReplay.ts +575 -0
  188. package/src/tools/toolOutputReferences.ts +40 -1
  189. package/src/types/graph.ts +5 -5
  190. package/src/types/hitl.ts +16 -0
  191. package/src/types/llm.ts +53 -36
  192. package/src/utils/tokens.ts +115 -30
@@ -1,9 +1,9 @@
1
- import { describe, it, expect } from '@jest/globals';
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
2
  import type { RunnableConfig } from '@langchain/core/runnables';
3
3
  import type * as t from '@/types';
4
4
  import { StreamLimitExceededError } from '@/llm/streamLimits';
5
- import { Providers } from '@/common';
6
5
  import { StandardGraph } from '../Graph';
6
+ import { Providers } from '@/common';
7
7
 
8
8
  const makeAgent = (agentId: string): t.AgentInputs => ({
9
9
  agentId,
@@ -25,7 +25,51 @@ const makeTrip = (): StreamLimitExceededError =>
25
25
  toolName: 'db_query',
26
26
  });
27
27
 
28
+ type CompiledRegistrationInternals = {
29
+ _compiledToolNodes: Set<{ clearDirectPathTurns(): void }>;
30
+ _subagentExecutors: Set<{
31
+ clearHeavyState(): void;
32
+ resetCheckpointThreadIds(): void;
33
+ }>;
34
+ };
35
+
28
36
  describe('run breaker lifecycle', () => {
37
+ it('retains compiled cleanup registrations across graph reuse', () => {
38
+ const graph = makeGraph();
39
+ const internals = graph as unknown as CompiledRegistrationInternals;
40
+ const clearDirectPathTurns = jest.fn();
41
+ const clearSubagentState = jest.fn();
42
+ const resetCheckpointThreadIds = jest.fn();
43
+ internals._compiledToolNodes.add({ clearDirectPathTurns });
44
+ internals._subagentExecutors.add({
45
+ clearHeavyState: clearSubagentState,
46
+ resetCheckpointThreadIds,
47
+ });
48
+
49
+ graph.clearHeavyState();
50
+ graph.clearHeavyState();
51
+
52
+ expect(clearDirectPathTurns).toHaveBeenCalledTimes(2);
53
+ expect(clearSubagentState).toHaveBeenCalledTimes(2);
54
+ expect(internals._compiledToolNodes.size).toBe(1);
55
+ expect(internals._subagentExecutors.size).toBe(1);
56
+ });
57
+
58
+ it('resets subagent checkpoint ownership at each fresh run start', () => {
59
+ const graph = makeGraph();
60
+ const internals = graph as unknown as CompiledRegistrationInternals;
61
+ const resetCheckpointThreadIds = jest.fn();
62
+ internals._subagentExecutors.add({
63
+ clearHeavyState: jest.fn(),
64
+ resetCheckpointThreadIds,
65
+ });
66
+
67
+ graph.resetValues();
68
+ graph.resetValues();
69
+
70
+ expect(resetCheckpointThreadIds).toHaveBeenCalledTimes(2);
71
+ });
72
+
29
73
  it('replaces the breaker at every run start, even when un-aborted', () => {
30
74
  const graph = makeGraph();
31
75
  const previous = graph.breakerAbort;
@@ -115,9 +159,7 @@ describe('run breaker lifecycle', () => {
115
159
  model: model as unknown as t.ChatModel,
116
160
  messages: [new HumanMessage('hi')],
117
161
  provider: Providers.OPENAI,
118
- context: graph as Parameters<
119
- typeof attemptInvoke
120
- >[0]['context'],
162
+ context: graph as Parameters<typeof attemptInvoke>[0]['context'],
121
163
  onChunk: () => {
122
164
  /* producer loop enforcement is under test */
123
165
  },
@@ -225,10 +267,7 @@ describe('run breaker lifecycle', () => {
225
267
  /** With a live breaker the entry guard must not fire; the node then
226
268
  * fails later, on the missing config, proving it got past the guard. */
227
269
  await expect(
228
- node(
229
- { messages: [] } as unknown as t.AgentSubgraphState,
230
- undefined
231
- )
270
+ node({ messages: [] } as unknown as t.AgentSubgraphState, undefined)
232
271
  ).rejects.toThrow('No config provided');
233
272
  });
234
273
  });
@@ -1338,7 +1338,7 @@ describe('context overflow recovery', () => {
1338
1338
  if (agentContext == null) {
1339
1339
  throw new Error('Expected default agent context');
1340
1340
  }
1341
- expect(run.Graph.compileOptions?.checkpointer).toBeUndefined();
1341
+ expect(run.Graph.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
1342
1342
  expect(run.Graph.hasCompiledCheckpointer).toBe(true);
1343
1343
  run.Graph.resetValues(undefined, 'checkpoint-scope');
1344
1344
  agentContext.preserveOriginalToolContent(new Map([[2, 'full output']]));
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type { SubagentToolNodeResumeState } from '@/tools/subagent/SubagentReplay';
3
+ import { StandardGraph } from '../Graph';
4
+ import { Providers } from '@/common';
5
+
6
+ type ResumeToolNode = {
7
+ createSubagentResumeState(): SubagentToolNodeResumeState;
8
+ restoreSubagentResumeState(state: SubagentToolNodeResumeState): void;
9
+ };
10
+
11
+ const makeGraph = (runId: string): StandardGraph =>
12
+ new StandardGraph({
13
+ runId,
14
+ agents: [
15
+ {
16
+ agentId: 'child-agent',
17
+ provider: Providers.OPENAI,
18
+ instructions: 'Test child state restoration.',
19
+ },
20
+ ],
21
+ });
22
+
23
+ const initializeToolNode = (graph: StandardGraph): ResumeToolNode => {
24
+ const agentContext = graph.agentContexts.get('child-agent');
25
+ return graph.initializeTools({
26
+ currentTools: [],
27
+ agentContext,
28
+ }) as unknown as ResumeToolNode;
29
+ };
30
+
31
+ describe('subagent graph resume state', () => {
32
+ it('restores tool identity, turns, references, and sessions into a branch', () => {
33
+ const source = makeGraph('source');
34
+ source.toolOutputReferences = { enabled: true };
35
+ const sourceNode = initializeToolNode(source);
36
+ source.toolCallStepIds.set('call_tool', 'step_tool');
37
+ source.sessions.set('execute_code', {
38
+ session_id: 'sandbox-session',
39
+ lastUpdated: 42,
40
+ });
41
+ source
42
+ .getOrCreateToolOutputRegistry()
43
+ ?.set('source-scope', 'tool0turn0', 'raw output');
44
+ source.getEagerEventToolUsageCount('child-agent').set('calculator', 3);
45
+ source.eagerEventToolSuppressions.add('unstable_search');
46
+ const sourceNodeState = sourceNode.createSubagentResumeState();
47
+ sourceNode.restoreSubagentResumeState({
48
+ ...sourceNodeState,
49
+ toolUsageCounts: [{ toolName: 'calculator', count: 3 }],
50
+ directPathTurns: [{ toolCallId: 'call_tool', turn: 2 }],
51
+ });
52
+
53
+ const target = makeGraph('target');
54
+ target.toolOutputReferences = { enabled: true };
55
+ const targetNode = initializeToolNode(target);
56
+ target.restoreSubagentResumeState(
57
+ source.createSubagentResumeState('source-scope'),
58
+ 'target-scope'
59
+ );
60
+
61
+ expect(target.toolCallStepIds.get('call_tool')).toBe('step_tool');
62
+ expect(target.sessions.get('execute_code')).toMatchObject({
63
+ session_id: 'sandbox-session',
64
+ lastUpdated: 42,
65
+ });
66
+ expect(
67
+ target.getOrCreateToolOutputRegistry()?.get('target-scope', 'tool0turn0')
68
+ ).toBe('raw output');
69
+ expect(targetNode.createSubagentResumeState()).toMatchObject({
70
+ toolUsageCounts: [{ toolName: 'calculator', count: 3 }],
71
+ directPathTurns: [{ toolCallId: 'call_tool', turn: 2 }],
72
+ });
73
+ expect(
74
+ target.getEagerEventToolUsageCount('child-agent').get('calculator')
75
+ ).toBe(3);
76
+ expect(target.eagerEventToolSuppressions).toEqual(
77
+ new Set(['unstable_search'])
78
+ );
79
+ });
80
+ });
@@ -1,5 +1,31 @@
1
1
  // src/hooks/HookRegistry.ts
2
- import type { HookEvent, HookMatcher } from './types';
2
+ import type {
3
+ HookEvent,
4
+ HookMatcher,
5
+ ToolApprovalReplayKey,
6
+ ToolApprovalReplaySnapshot,
7
+ AggregatedHookResult,
8
+ } from './types';
9
+
10
+ function serializeApprovalKey(key: ToolApprovalReplayKey): string {
11
+ return JSON.stringify([key.executionScope, key.agentId, key.toolUseId]);
12
+ }
13
+
14
+ function deserializeApprovalKey(value: string): ToolApprovalReplayKey | null {
15
+ const parsed: unknown = JSON.parse(value);
16
+ if (
17
+ !Array.isArray(parsed) ||
18
+ parsed.length !== 3 ||
19
+ parsed.some((part) => typeof part !== 'string')
20
+ ) {
21
+ return null;
22
+ }
23
+ return {
24
+ executionScope: parsed[0],
25
+ agentId: parsed[1],
26
+ toolUseId: parsed[2],
27
+ };
28
+ }
3
29
 
4
30
  /**
5
31
  * Internal matcher storage type.
@@ -71,6 +97,11 @@ export class HookRegistry {
71
97
  * O(1) insertion in hot paths, no spread-on-write.
72
98
  */
73
99
  private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
100
+ /** One-shot hook contributions retained until approval is consumed. */
101
+ private readonly pendingToolApprovals = new Map<
102
+ string,
103
+ Map<string, AggregatedHookResult>
104
+ >();
74
105
 
75
106
  /**
76
107
  * Register a matcher for the lifetime of this registry (= one Run).
@@ -160,6 +191,125 @@ export class HookRegistry {
160
191
  */
161
192
  clearSession(sessionId: string): void {
162
193
  this.sessions.delete(sessionId);
194
+ this.pendingToolApprovals.delete(sessionId);
195
+ }
196
+
197
+ /** Copies session-scoped policy into a rebuilt or branched Run. */
198
+ copySession(sourceSessionId: string, targetSessionId: string): void {
199
+ if (sourceSessionId === targetSessionId) {
200
+ return;
201
+ }
202
+ const source = this.sessions.get(sourceSessionId);
203
+ if (source != null) {
204
+ const target = this.ensureSessionBucket(targetSessionId);
205
+ for (const event of Object.keys(source) as HookEvent[]) {
206
+ const targetList = ensureList(target, event);
207
+ for (const matcher of readList(source, event)) {
208
+ if (!targetList.includes(matcher)) {
209
+ targetList.push(matcher);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ const pending = this.pendingToolApprovals.get(sourceSessionId);
215
+ if (pending == null) {
216
+ return;
217
+ }
218
+ let targetPending = this.pendingToolApprovals.get(targetSessionId);
219
+ if (targetPending == null) {
220
+ targetPending = new Map();
221
+ this.pendingToolApprovals.set(targetSessionId, targetPending);
222
+ }
223
+ for (const [toolUseId, result] of pending) {
224
+ if (!targetPending.has(toolUseId)) {
225
+ targetPending.set(toolUseId, result);
226
+ }
227
+ }
228
+ }
229
+
230
+ getPendingToolApproval(
231
+ sessionId: string,
232
+ key: ToolApprovalReplayKey
233
+ ): AggregatedHookResult | undefined {
234
+ return this.pendingToolApprovals
235
+ .get(sessionId)
236
+ ?.get(serializeApprovalKey(key));
237
+ }
238
+
239
+ setPendingToolApproval(
240
+ sessionId: string,
241
+ key: ToolApprovalReplayKey,
242
+ result: AggregatedHookResult
243
+ ): void {
244
+ let pending = this.pendingToolApprovals.get(sessionId);
245
+ if (pending == null) {
246
+ pending = new Map();
247
+ this.pendingToolApprovals.set(sessionId, pending);
248
+ }
249
+ pending.set(serializeApprovalKey(key), result);
250
+ }
251
+
252
+ clearPendingToolApproval(
253
+ sessionId: string,
254
+ key: ToolApprovalReplayKey
255
+ ): void {
256
+ const pending = this.pendingToolApprovals.get(sessionId);
257
+ if (pending == null) {
258
+ return;
259
+ }
260
+ pending.delete(serializeApprovalKey(key));
261
+ if (pending.size === 0) {
262
+ this.pendingToolApprovals.delete(sessionId);
263
+ }
264
+ }
265
+
266
+ snapshotPendingToolApprovals(
267
+ sessionId: string,
268
+ executionScope: string
269
+ ): ToolApprovalReplaySnapshot[] {
270
+ const pending = this.pendingToolApprovals.get(sessionId);
271
+ if (pending == null) {
272
+ return [];
273
+ }
274
+ const snapshots: ToolApprovalReplaySnapshot[] = [];
275
+ for (const [serializedKey, result] of pending) {
276
+ const key = deserializeApprovalKey(serializedKey);
277
+ if (key == null || key.executionScope !== executionScope) {
278
+ continue;
279
+ }
280
+ snapshots.push({ key, result });
281
+ }
282
+ return snapshots;
283
+ }
284
+
285
+ restorePendingToolApprovals(
286
+ sessionId: string,
287
+ targetExecutionScope: string,
288
+ snapshots: ReadonlyArray<ToolApprovalReplaySnapshot>
289
+ ): void {
290
+ const restored = new Map<string, AggregatedHookResult>();
291
+ for (const [serializedKey, result] of this.pendingToolApprovals.get(
292
+ sessionId
293
+ ) ?? []) {
294
+ const key = deserializeApprovalKey(serializedKey);
295
+ if (key?.executionScope !== targetExecutionScope) {
296
+ restored.set(serializedKey, result);
297
+ }
298
+ }
299
+ for (const snapshot of snapshots) {
300
+ restored.set(
301
+ serializeApprovalKey({
302
+ ...snapshot.key,
303
+ executionScope: targetExecutionScope,
304
+ }),
305
+ snapshot.result
306
+ );
307
+ }
308
+ if (restored.size === 0) {
309
+ this.pendingToolApprovals.delete(sessionId);
310
+ return;
311
+ }
312
+ this.pendingToolApprovals.set(sessionId, restored);
163
313
  }
164
314
 
165
315
  /**
@@ -164,6 +164,102 @@ describe('HookRegistry', () => {
164
164
  expect(registry.hasHookFor('PreToolUse', 'run-a')).toBe(true);
165
165
  expect(registry.hasHookFor('PreToolUse', 'run-b')).toBe(false);
166
166
  });
167
+
168
+ it('copies session policy without consuming the source or sibling branches', () => {
169
+ const registry = new HookRegistry();
170
+ const matcher = makePreToolUseMatcher('subagent');
171
+ registry.registerSession('source', 'PreToolUse', matcher);
172
+
173
+ registry.copySession('source', 'branch-a');
174
+ registry.copySession('source', 'branch-b');
175
+
176
+ expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
177
+ expect(registry.getMatchers('PreToolUse', 'branch-a')).toEqual([matcher]);
178
+ expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
179
+
180
+ registry.clearSession('branch-a');
181
+ expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
182
+ expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
183
+ });
184
+
185
+ it('copies pending one-shot approvals and clears them with the target session', () => {
186
+ const registry = new HookRegistry();
187
+ const approval = {
188
+ decision: 'ask' as const,
189
+ reason: 'review tool',
190
+ additionalContexts: [],
191
+ injectedMessages: [],
192
+ errors: [],
193
+ };
194
+ const key = {
195
+ executionScope: 'child-a',
196
+ agentId: 'researcher',
197
+ toolUseId: 'call_1',
198
+ };
199
+ const siblingKey = { ...key, executionScope: 'child-b' };
200
+ registry.setPendingToolApproval('source', key, approval);
201
+ registry.setPendingToolApproval('source', siblingKey, {
202
+ ...approval,
203
+ reason: 'review sibling',
204
+ });
205
+
206
+ registry.copySession('source', 'branch');
207
+
208
+ expect(registry.getPendingToolApproval('source', key)).toBe(approval);
209
+ expect(registry.getPendingToolApproval('branch', key)).toBe(approval);
210
+ expect(
211
+ registry.getPendingToolApproval('branch', siblingKey)?.reason
212
+ ).toBe('review sibling');
213
+ registry.clearPendingToolApproval('branch', key);
214
+ expect(registry.getPendingToolApproval('branch', key)).toBeUndefined();
215
+ expect(
216
+ registry.getPendingToolApproval('branch', siblingKey)?.reason
217
+ ).toBe('review sibling');
218
+ const checkpointSnapshot = registry.snapshotPendingToolApprovals(
219
+ 'source',
220
+ 'child-a'
221
+ );
222
+ expect(checkpointSnapshot).toEqual([{ key, result: approval }]);
223
+ registry.clearSession('branch');
224
+ expect(
225
+ registry.getPendingToolApproval('branch', siblingKey)
226
+ ).toBeUndefined();
227
+ expect(registry.getPendingToolApproval('source', key)).toBe(approval);
228
+ registry.setPendingToolApproval('restored', key, {
229
+ ...approval,
230
+ reason: 'stale approval',
231
+ });
232
+ registry.setPendingToolApproval('restored', siblingKey, {
233
+ ...approval,
234
+ reason: 'preserved sibling',
235
+ });
236
+ registry.restorePendingToolApprovals(
237
+ 'restored',
238
+ 'child-a',
239
+ checkpointSnapshot
240
+ );
241
+ expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
242
+ expect(
243
+ registry.getPendingToolApproval('restored', siblingKey)?.reason
244
+ ).toBe('preserved sibling');
245
+ registry.restorePendingToolApprovals('restored', 'child-b', []);
246
+ expect(
247
+ registry.getPendingToolApproval('restored', siblingKey)
248
+ ).toBeUndefined();
249
+
250
+ const branchKey = { ...key, executionScope: 'child-a-attempt-2' };
251
+ registry.restorePendingToolApprovals(
252
+ 'restored',
253
+ branchKey.executionScope,
254
+ checkpointSnapshot
255
+ );
256
+ expect(registry.getPendingToolApproval('restored', branchKey)).toBe(
257
+ approval
258
+ );
259
+ expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
260
+ registry.clearPendingToolApproval('restored', branchKey);
261
+ expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
262
+ });
167
263
  });
168
264
 
169
265
  describe('session isolation under parallel registration', () => {
@@ -764,6 +764,166 @@ describe('executeHooks', () => {
764
764
  });
765
765
 
766
766
  describe('once: true self-removal', () => {
767
+ it('replays a one-shot approval until the tool decision is consumed', async () => {
768
+ const registry = new HookRegistry();
769
+ let calls = 0;
770
+ registry.registerSession('run-1', 'PreToolUse', {
771
+ once: true,
772
+ hooks: [
773
+ async (): Promise<PreToolUseHookOutput> => {
774
+ calls += 1;
775
+ return { decision: 'ask', reason: 'review once' };
776
+ },
777
+ ],
778
+ });
779
+ const options = {
780
+ registry,
781
+ input: preToolUseInput('Bash'),
782
+ matchQuery: 'Bash',
783
+ sessionId: 'run-1',
784
+ onceReplayKey: {
785
+ executionScope: 'child-a',
786
+ agentId: 'researcher',
787
+ toolUseId: 'call_1',
788
+ },
789
+ };
790
+
791
+ await executeHooks(options);
792
+ const replay = await executeHooks(options);
793
+
794
+ expect(replay).toMatchObject({
795
+ decision: 'ask',
796
+ reason: 'review once',
797
+ });
798
+ expect(calls).toBe(1);
799
+ expect(registry.getMatchers('PreToolUse', 'run-1')).toHaveLength(0);
800
+
801
+ const sibling = await executeHooks({
802
+ ...options,
803
+ onceReplayKey: {
804
+ ...options.onceReplayKey,
805
+ executionScope: 'child-b',
806
+ },
807
+ });
808
+ expect(sibling.decision).toBeUndefined();
809
+
810
+ registry.clearPendingToolApproval('run-1', options.onceReplayKey);
811
+ const afterDecision = await executeHooks(options);
812
+ expect(afterDecision.decision).toBeUndefined();
813
+ expect(calls).toBe(1);
814
+ });
815
+
816
+ it('replays only one-shot contributions while rerunning ordinary hooks', async () => {
817
+ const registry = new HookRegistry();
818
+ let onceCalls = 0;
819
+ let ordinaryCalls = 0;
820
+ let ordinaryDecision: 'allow' | 'deny' = 'allow';
821
+ registry.registerSession('run-1', 'PreToolUse', {
822
+ once: true,
823
+ hooks: [
824
+ async (): Promise<PreToolUseHookOutput> => {
825
+ onceCalls += 1;
826
+ return {
827
+ decision: 'ask',
828
+ reason: 'review once',
829
+ additionalContext: 'once-context',
830
+ };
831
+ },
832
+ ],
833
+ });
834
+ registry.registerSession('run-1', 'PreToolUse', {
835
+ hooks: [
836
+ async (): Promise<PreToolUseHookOutput> => {
837
+ ordinaryCalls += 1;
838
+ return {
839
+ decision: ordinaryDecision,
840
+ reason: `ordinary-${ordinaryDecision}`,
841
+ additionalContext: `ordinary-${ordinaryCalls}`,
842
+ };
843
+ },
844
+ ],
845
+ });
846
+ const options = {
847
+ registry,
848
+ input: preToolUseInput('Bash'),
849
+ matchQuery: 'Bash',
850
+ sessionId: 'run-1',
851
+ onceReplayKey: {
852
+ executionScope: 'child-a',
853
+ agentId: 'researcher',
854
+ toolUseId: 'call_1',
855
+ },
856
+ };
857
+
858
+ const first = await executeHooks(options);
859
+ ordinaryDecision = 'deny';
860
+ const replay = await executeHooks(options);
861
+
862
+ expect(first.decision).toBe('ask');
863
+ expect(replay).toMatchObject({
864
+ decision: 'deny',
865
+ reason: 'ordinary-deny',
866
+ additionalContexts: ['once-context', 'ordinary-2'],
867
+ });
868
+ expect(onceCalls).toBe(1);
869
+ expect(ordinaryCalls).toBe(2);
870
+ expect(
871
+ registry.getPendingToolApproval('run-1', options.onceReplayKey)
872
+ ).toBeUndefined();
873
+ });
874
+
875
+ it('retains a consumed one-shot rewrite when an ordinary hook asks', async () => {
876
+ const registry = new HookRegistry();
877
+ let onceCalls = 0;
878
+ let ordinaryCalls = 0;
879
+ registry.registerSession('run-1', 'PreToolUse', {
880
+ once: true,
881
+ hooks: [
882
+ async (): Promise<PreToolUseHookOutput> => {
883
+ onceCalls += 1;
884
+ return {
885
+ decision: 'allow',
886
+ updatedInput: { command: 'sanitized' },
887
+ };
888
+ },
889
+ ],
890
+ });
891
+ registry.registerSession('run-1', 'PreToolUse', {
892
+ hooks: [
893
+ async (): Promise<PreToolUseHookOutput> => {
894
+ ordinaryCalls += 1;
895
+ return { decision: 'ask', reason: 'ordinary approval' };
896
+ },
897
+ ],
898
+ });
899
+ const options = {
900
+ registry,
901
+ input: preToolUseInput('Bash'),
902
+ matchQuery: 'Bash',
903
+ sessionId: 'run-1',
904
+ onceReplayKey: {
905
+ executionScope: 'child-a',
906
+ agentId: 'researcher',
907
+ toolUseId: 'call_1',
908
+ },
909
+ };
910
+
911
+ const first = await executeHooks(options);
912
+ const replay = await executeHooks(options);
913
+
914
+ expect(first).toMatchObject({
915
+ decision: 'ask',
916
+ updatedInput: { command: 'sanitized' },
917
+ });
918
+ expect(replay).toMatchObject({
919
+ decision: 'ask',
920
+ reason: 'ordinary approval',
921
+ updatedInput: { command: 'sanitized' },
922
+ });
923
+ expect(onceCalls).toBe(1);
924
+ expect(ordinaryCalls).toBe(2);
925
+ });
926
+
767
927
  it('removes the matcher after a successful fire', async () => {
768
928
  const registry = new HookRegistry();
769
929
  let calls = 0;