@librechat/agents 3.1.57 → 3.1.61

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 (214) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +326 -62
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +13 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +7 -27
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +303 -222
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +4 -4
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +6 -2
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/init.cjs +60 -0
  14. package/dist/cjs/llm/init.cjs.map +1 -0
  15. package/dist/cjs/llm/invoke.cjs +90 -0
  16. package/dist/cjs/llm/invoke.cjs.map +1 -0
  17. package/dist/cjs/llm/openai/index.cjs +2 -0
  18. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  19. package/dist/cjs/llm/request.cjs +41 -0
  20. package/dist/cjs/llm/request.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +40 -0
  22. package/dist/cjs/main.cjs.map +1 -1
  23. package/dist/cjs/messages/cache.cjs +76 -89
  24. package/dist/cjs/messages/cache.cjs.map +1 -1
  25. package/dist/cjs/messages/contextPruning.cjs +156 -0
  26. package/dist/cjs/messages/contextPruning.cjs.map +1 -0
  27. package/dist/cjs/messages/contextPruningSettings.cjs +53 -0
  28. package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -0
  29. package/dist/cjs/messages/core.cjs +23 -37
  30. package/dist/cjs/messages/core.cjs.map +1 -1
  31. package/dist/cjs/messages/format.cjs +156 -11
  32. package/dist/cjs/messages/format.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +1161 -49
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/messages/reducer.cjs +87 -0
  36. package/dist/cjs/messages/reducer.cjs.map +1 -0
  37. package/dist/cjs/run.cjs +81 -42
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +54 -7
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/summarization/index.cjs +75 -0
  42. package/dist/cjs/summarization/index.cjs.map +1 -0
  43. package/dist/cjs/summarization/node.cjs +663 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -0
  45. package/dist/cjs/tools/ToolNode.cjs +16 -8
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +2 -0
  48. package/dist/cjs/tools/handlers.cjs.map +1 -1
  49. package/dist/cjs/utils/errors.cjs +115 -0
  50. package/dist/cjs/utils/errors.cjs.map +1 -0
  51. package/dist/cjs/utils/events.cjs +17 -0
  52. package/dist/cjs/utils/events.cjs.map +1 -1
  53. package/dist/cjs/utils/handlers.cjs +16 -0
  54. package/dist/cjs/utils/handlers.cjs.map +1 -1
  55. package/dist/cjs/utils/llm.cjs +10 -0
  56. package/dist/cjs/utils/llm.cjs.map +1 -1
  57. package/dist/cjs/utils/tokens.cjs +247 -14
  58. package/dist/cjs/utils/tokens.cjs.map +1 -1
  59. package/dist/cjs/utils/truncation.cjs +107 -0
  60. package/dist/cjs/utils/truncation.cjs.map +1 -0
  61. package/dist/esm/agents/AgentContext.mjs +325 -61
  62. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  63. package/dist/esm/common/enum.mjs +13 -0
  64. package/dist/esm/common/enum.mjs.map +1 -1
  65. package/dist/esm/events.mjs +8 -28
  66. package/dist/esm/events.mjs.map +1 -1
  67. package/dist/esm/graphs/Graph.mjs +307 -226
  68. package/dist/esm/graphs/Graph.mjs.map +1 -1
  69. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +4 -4
  70. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  71. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +6 -2
  72. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/init.mjs +58 -0
  74. package/dist/esm/llm/init.mjs.map +1 -0
  75. package/dist/esm/llm/invoke.mjs +87 -0
  76. package/dist/esm/llm/invoke.mjs.map +1 -0
  77. package/dist/esm/llm/openai/index.mjs +2 -0
  78. package/dist/esm/llm/openai/index.mjs.map +1 -1
  79. package/dist/esm/llm/request.mjs +38 -0
  80. package/dist/esm/llm/request.mjs.map +1 -0
  81. package/dist/esm/main.mjs +13 -3
  82. package/dist/esm/main.mjs.map +1 -1
  83. package/dist/esm/messages/cache.mjs +76 -89
  84. package/dist/esm/messages/cache.mjs.map +1 -1
  85. package/dist/esm/messages/contextPruning.mjs +154 -0
  86. package/dist/esm/messages/contextPruning.mjs.map +1 -0
  87. package/dist/esm/messages/contextPruningSettings.mjs +50 -0
  88. package/dist/esm/messages/contextPruningSettings.mjs.map +1 -0
  89. package/dist/esm/messages/core.mjs +23 -37
  90. package/dist/esm/messages/core.mjs.map +1 -1
  91. package/dist/esm/messages/format.mjs +156 -11
  92. package/dist/esm/messages/format.mjs.map +1 -1
  93. package/dist/esm/messages/prune.mjs +1158 -52
  94. package/dist/esm/messages/prune.mjs.map +1 -1
  95. package/dist/esm/messages/reducer.mjs +83 -0
  96. package/dist/esm/messages/reducer.mjs.map +1 -0
  97. package/dist/esm/run.mjs +82 -43
  98. package/dist/esm/run.mjs.map +1 -1
  99. package/dist/esm/stream.mjs +54 -7
  100. package/dist/esm/stream.mjs.map +1 -1
  101. package/dist/esm/summarization/index.mjs +73 -0
  102. package/dist/esm/summarization/index.mjs.map +1 -0
  103. package/dist/esm/summarization/node.mjs +659 -0
  104. package/dist/esm/summarization/node.mjs.map +1 -0
  105. package/dist/esm/tools/ToolNode.mjs +16 -8
  106. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  107. package/dist/esm/tools/handlers.mjs +2 -0
  108. package/dist/esm/tools/handlers.mjs.map +1 -1
  109. package/dist/esm/utils/errors.mjs +111 -0
  110. package/dist/esm/utils/errors.mjs.map +1 -0
  111. package/dist/esm/utils/events.mjs +17 -1
  112. package/dist/esm/utils/events.mjs.map +1 -1
  113. package/dist/esm/utils/handlers.mjs +16 -0
  114. package/dist/esm/utils/handlers.mjs.map +1 -1
  115. package/dist/esm/utils/llm.mjs +10 -1
  116. package/dist/esm/utils/llm.mjs.map +1 -1
  117. package/dist/esm/utils/tokens.mjs +245 -15
  118. package/dist/esm/utils/tokens.mjs.map +1 -1
  119. package/dist/esm/utils/truncation.mjs +102 -0
  120. package/dist/esm/utils/truncation.mjs.map +1 -0
  121. package/dist/types/agents/AgentContext.d.ts +124 -6
  122. package/dist/types/common/enum.d.ts +14 -1
  123. package/dist/types/graphs/Graph.d.ts +22 -27
  124. package/dist/types/index.d.ts +5 -0
  125. package/dist/types/llm/init.d.ts +18 -0
  126. package/dist/types/llm/invoke.d.ts +48 -0
  127. package/dist/types/llm/request.d.ts +14 -0
  128. package/dist/types/messages/contextPruning.d.ts +42 -0
  129. package/dist/types/messages/contextPruningSettings.d.ts +44 -0
  130. package/dist/types/messages/core.d.ts +1 -1
  131. package/dist/types/messages/format.d.ts +17 -1
  132. package/dist/types/messages/index.d.ts +3 -0
  133. package/dist/types/messages/prune.d.ts +162 -1
  134. package/dist/types/messages/reducer.d.ts +18 -0
  135. package/dist/types/run.d.ts +12 -1
  136. package/dist/types/summarization/index.d.ts +20 -0
  137. package/dist/types/summarization/node.d.ts +29 -0
  138. package/dist/types/tools/ToolNode.d.ts +3 -1
  139. package/dist/types/types/graph.d.ts +44 -6
  140. package/dist/types/types/index.d.ts +1 -0
  141. package/dist/types/types/run.d.ts +30 -0
  142. package/dist/types/types/stream.d.ts +31 -4
  143. package/dist/types/types/summarize.d.ts +47 -0
  144. package/dist/types/types/tools.d.ts +7 -0
  145. package/dist/types/utils/errors.d.ts +28 -0
  146. package/dist/types/utils/events.d.ts +13 -0
  147. package/dist/types/utils/index.d.ts +2 -0
  148. package/dist/types/utils/llm.d.ts +4 -0
  149. package/dist/types/utils/tokens.d.ts +14 -1
  150. package/dist/types/utils/truncation.d.ts +49 -0
  151. package/package.json +3 -3
  152. package/src/agents/AgentContext.ts +388 -58
  153. package/src/agents/__tests__/AgentContext.test.ts +265 -5
  154. package/src/common/enum.ts +13 -0
  155. package/src/events.ts +9 -39
  156. package/src/graphs/Graph.ts +468 -331
  157. package/src/index.ts +7 -0
  158. package/src/llm/anthropic/llm.spec.ts +3 -3
  159. package/src/llm/anthropic/utils/message_inputs.ts +6 -4
  160. package/src/llm/bedrock/llm.spec.ts +1 -1
  161. package/src/llm/bedrock/utils/message_inputs.ts +6 -2
  162. package/src/llm/init.ts +63 -0
  163. package/src/llm/invoke.ts +144 -0
  164. package/src/llm/request.ts +55 -0
  165. package/src/messages/__tests__/observationMasking.test.ts +221 -0
  166. package/src/messages/cache.ts +77 -102
  167. package/src/messages/contextPruning.ts +191 -0
  168. package/src/messages/contextPruningSettings.ts +90 -0
  169. package/src/messages/core.ts +32 -53
  170. package/src/messages/ensureThinkingBlock.test.ts +39 -39
  171. package/src/messages/format.ts +227 -15
  172. package/src/messages/formatAgentMessages.test.ts +511 -1
  173. package/src/messages/index.ts +3 -0
  174. package/src/messages/prune.ts +1548 -62
  175. package/src/messages/reducer.ts +22 -0
  176. package/src/run.ts +104 -51
  177. package/src/scripts/bedrock-merge-test.ts +1 -1
  178. package/src/scripts/test-thinking-handoff-bedrock.ts +1 -1
  179. package/src/scripts/test-thinking-handoff.ts +1 -1
  180. package/src/scripts/thinking-bedrock.ts +1 -1
  181. package/src/scripts/thinking.ts +1 -1
  182. package/src/specs/anthropic.simple.test.ts +1 -1
  183. package/src/specs/multi-agent-summarization.test.ts +396 -0
  184. package/src/specs/prune.test.ts +1196 -23
  185. package/src/specs/summarization-unit.test.ts +868 -0
  186. package/src/specs/summarization.test.ts +3827 -0
  187. package/src/specs/summarize-prune.test.ts +376 -0
  188. package/src/specs/thinking-handoff.test.ts +10 -10
  189. package/src/specs/thinking-prune.test.ts +7 -4
  190. package/src/specs/token-accounting-e2e.test.ts +1034 -0
  191. package/src/specs/token-accounting-pipeline.test.ts +882 -0
  192. package/src/specs/token-distribution-edge-case.test.ts +25 -26
  193. package/src/splitStream.test.ts +42 -33
  194. package/src/stream.ts +64 -11
  195. package/src/summarization/__tests__/aggregator.test.ts +153 -0
  196. package/src/summarization/__tests__/node.test.ts +708 -0
  197. package/src/summarization/__tests__/trigger.test.ts +50 -0
  198. package/src/summarization/index.ts +102 -0
  199. package/src/summarization/node.ts +982 -0
  200. package/src/tools/ToolNode.ts +25 -3
  201. package/src/types/graph.ts +62 -7
  202. package/src/types/index.ts +1 -0
  203. package/src/types/run.ts +32 -0
  204. package/src/types/stream.ts +45 -5
  205. package/src/types/summarize.ts +58 -0
  206. package/src/types/tools.ts +7 -0
  207. package/src/utils/errors.ts +117 -0
  208. package/src/utils/events.ts +31 -0
  209. package/src/utils/handlers.ts +18 -0
  210. package/src/utils/index.ts +2 -0
  211. package/src/utils/llm.ts +12 -0
  212. package/src/utils/tokens.ts +336 -18
  213. package/src/utils/truncation.ts +124 -0
  214. package/src/scripts/image.ts +0 -180
@@ -0,0 +1,708 @@
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import type { RunnableConfig } from '@langchain/core/runnables';
3
+ import type * as t from '@/types';
4
+ import { GraphEvents, Providers } from '@/common';
5
+ import {
6
+ createSummarizeNode,
7
+ DEFAULT_SUMMARIZATION_PROMPT,
8
+ DEFAULT_UPDATE_SUMMARIZATION_PROMPT,
9
+ } from '@/summarization/node';
10
+ import * as providers from '@/llm/providers';
11
+ import * as eventUtils from '@/utils/events';
12
+ import { AgentContext } from '@/agents/AgentContext';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Creates a real AgentContext via fromConfig with sensible defaults.
19
+ * Extra properties are assigned directly for test-specific overrides. */
20
+ function createAgentContext(
21
+ overrides: Record<string, unknown> = {}
22
+ ): AgentContext {
23
+ const {
24
+ // AgentInputs fields
25
+ agentId = 'agent_0',
26
+ provider = Providers.OPENAI,
27
+ instructions = 'Test agent',
28
+ summarizationEnabled = true,
29
+ summarizationConfig,
30
+ maxContextTokens,
31
+ tools,
32
+ ...extra
33
+ } = overrides;
34
+
35
+ const ctx = AgentContext.fromConfig({
36
+ agentId: agentId as string,
37
+ provider: provider as Providers,
38
+ instructions: instructions as string,
39
+ summarizationEnabled: summarizationEnabled as boolean,
40
+ ...(summarizationConfig != null ? { summarizationConfig } : {}),
41
+ ...(maxContextTokens != null ? { maxContextTokens } : {}),
42
+ ...(tools != null ? { tools } : {}),
43
+ } as import('@/types').AgentInputs);
44
+
45
+ // Apply direct property overrides for test-specific internal state
46
+ for (const [key, value] of Object.entries(extra)) {
47
+ (ctx as unknown as Record<string, unknown>)[key] = value;
48
+ }
49
+
50
+ return ctx;
51
+ }
52
+
53
+ /** Creates a mock graph container for createSummarizeNode. */
54
+ function mockGraph(
55
+ onStepCompleted?: (stepId: string, result: t.StepCompleted) => void
56
+ ): {
57
+ contentData: t.RunStep[];
58
+ contentIndexMap: Map<string, number>;
59
+ config: RunnableConfig;
60
+ runId: string;
61
+ isMultiAgent: boolean;
62
+ dispatchRunStep: (
63
+ runStep: t.RunStep,
64
+ config?: RunnableConfig
65
+ ) => Promise<void>;
66
+ dispatchRunStepCompleted: (
67
+ stepId: string,
68
+ result: t.StepCompleted,
69
+ config?: RunnableConfig
70
+ ) => Promise<void>;
71
+ } {
72
+ const contentData: t.RunStep[] = [];
73
+ const contentIndexMap = new Map<string, number>();
74
+ return {
75
+ contentData,
76
+ contentIndexMap,
77
+ config: {} as RunnableConfig,
78
+ runId: 'run_1',
79
+ isMultiAgent: false,
80
+ dispatchRunStep: async (runStep: t.RunStep): Promise<void> => {
81
+ contentData.push(runStep);
82
+ contentIndexMap.set(runStep.id, runStep.index);
83
+ },
84
+ dispatchRunStepCompleted: async (
85
+ stepId: string,
86
+ result: t.StepCompleted
87
+ ): Promise<void> => {
88
+ onStepCompleted?.(stepId, result);
89
+ },
90
+ };
91
+ }
92
+
93
+ let stepCounter = 0;
94
+ function generateStepId(_stepKey: string): [string, number] {
95
+ const id = `step_test_${stepCounter++}`;
96
+ return [id, 0];
97
+ }
98
+
99
+ /** Collects custom events dispatched during the node execution. */
100
+ function captureEvents(): Array<{ event: string; data: unknown }> {
101
+ const events: Array<{ event: string; data: unknown }> = [];
102
+ jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
103
+ ...args: unknown[]
104
+ ) => {
105
+ events.push({ event: args[0] as string, data: args[1] });
106
+ }) as never);
107
+ return events;
108
+ }
109
+
110
+ /** Creates a mock model that returns a canned response via invoke(). */
111
+ function mockInvokeModel(response: string): { invoke: jest.Mock } {
112
+ return {
113
+ invoke: jest.fn().mockResolvedValue({ content: response }),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Creates a mock model that streams text chunk-by-chunk.
119
+ * invoke() returns the full text; stream() yields one chunk per word.
120
+ */
121
+ function mockStreamingModel(response: string): {
122
+ invoke: jest.Mock;
123
+ stream: jest.Mock;
124
+ } {
125
+ const words = response.split(' ');
126
+ return {
127
+ invoke: jest.fn().mockResolvedValue({ content: response }),
128
+ stream: jest.fn().mockImplementation(async () => {
129
+ return (async function* (): AsyncGenerator<{ content: string }> {
130
+ for (const word of words) {
131
+ // Add space back except for first word
132
+ yield { content: word + ' ' };
133
+ }
134
+ })();
135
+ }),
136
+ };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Tests
141
+ // ---------------------------------------------------------------------------
142
+
143
+ beforeEach(() => {
144
+ stepCounter = 0;
145
+ jest.restoreAllMocks();
146
+ });
147
+
148
+ describe('createSummarizeNode', () => {
149
+ it('emits ON_SUMMARIZE_START and ON_SUMMARIZE_COMPLETE on success', async () => {
150
+ const events = captureEvents();
151
+
152
+ // Mock getChatModelClass to return our mock model
153
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
154
+ class {
155
+ constructor() {
156
+ return mockInvokeModel('Test summary output');
157
+ }
158
+ } as never
159
+ );
160
+
161
+ const agentContext = createAgentContext();
162
+ const graph = mockGraph((_stepId, result) => {
163
+ if (result.type === 'summary') {
164
+ events.push({
165
+ event: GraphEvents.ON_SUMMARIZE_COMPLETE,
166
+ data: { summary: result.summary },
167
+ });
168
+ }
169
+ });
170
+ const node = createSummarizeNode({
171
+ agentContext,
172
+ graph,
173
+ generateStepId,
174
+ });
175
+
176
+ await node(
177
+ {
178
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
179
+ summarizationRequest: {
180
+ remainingContextTokens: 1000,
181
+ agentId: 'agent_0',
182
+ },
183
+ },
184
+ {} as RunnableConfig
185
+ );
186
+
187
+ const eventNames = events.map((e) => e.event);
188
+ // ON_RUN_STEP now goes through graph.dispatchRunStep, not safeDispatchCustomEvent
189
+ expect(graph.contentData.length).toBeGreaterThan(0);
190
+ expect(eventNames).toContain(GraphEvents.ON_SUMMARIZE_START);
191
+ expect(eventNames).toContain(GraphEvents.ON_SUMMARIZE_COMPLETE);
192
+
193
+ // Complete event should have the summary text
194
+ const completeEvent = events.find(
195
+ (e) => e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
196
+ );
197
+ expect(
198
+ (
199
+ (completeEvent?.data as t.SummarizeCompleteEvent).summary!
200
+ .content?.[0] as { text: string } | undefined
201
+ )?.text
202
+ ).toBe('Test summary output');
203
+ expect(
204
+ (completeEvent?.data as t.SummarizeCompleteEvent).error
205
+ ).toBeUndefined();
206
+ });
207
+
208
+ it('collects streamed text when model supports stream()', async () => {
209
+ captureEvents();
210
+
211
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
212
+ class {
213
+ constructor() {
214
+ return mockStreamingModel('one two three');
215
+ }
216
+ } as never
217
+ );
218
+
219
+ const setSummary = jest.fn();
220
+ const agentContext = createAgentContext({ setSummary } as never);
221
+ const graph = mockGraph();
222
+ const node = createSummarizeNode({
223
+ agentContext,
224
+ graph,
225
+ generateStepId,
226
+ });
227
+
228
+ await node(
229
+ {
230
+ messages: [new HumanMessage('Test message')],
231
+ summarizationRequest: {
232
+ remainingContextTokens: 1000,
233
+ agentId: 'agent_0',
234
+ },
235
+ },
236
+ {} as RunnableConfig
237
+ );
238
+
239
+ // Node collects the full streamed text and calls setSummary.
240
+ // Delta events are dispatched by ChatModelStreamHandler, not the node.
241
+ expect(setSummary).toHaveBeenCalledWith(
242
+ 'one two three',
243
+ expect.any(Number)
244
+ );
245
+ });
246
+
247
+ it('falls back to invoke when model has no stream()', async () => {
248
+ captureEvents();
249
+
250
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
251
+ class {
252
+ constructor() {
253
+ return mockInvokeModel('Full summary text');
254
+ }
255
+ } as never
256
+ );
257
+
258
+ const setSummary = jest.fn();
259
+ const agentContext = createAgentContext({ setSummary } as never);
260
+ const graph = mockGraph();
261
+ const node = createSummarizeNode({
262
+ agentContext,
263
+ graph,
264
+ generateStepId,
265
+ });
266
+
267
+ await node(
268
+ {
269
+ messages: [new HumanMessage('Test message')],
270
+ summarizationRequest: {
271
+ remainingContextTokens: 1000,
272
+ agentId: 'agent_0',
273
+ },
274
+ },
275
+ {} as RunnableConfig
276
+ );
277
+
278
+ // Falls back to invoke and still collects the text
279
+ expect(setSummary).toHaveBeenCalledWith(
280
+ 'Full summary text',
281
+ expect.any(Number)
282
+ );
283
+ });
284
+
285
+ it('produces metadata stub when all LLM attempts fail', async () => {
286
+ const events = captureEvents();
287
+
288
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
289
+ class {
290
+ constructor() {
291
+ return {
292
+ invoke: jest.fn().mockRejectedValue(new Error('Model error')),
293
+ };
294
+ }
295
+ } as never
296
+ );
297
+
298
+ const setSummary = jest.fn();
299
+ const agentContext = createAgentContext({ setSummary } as never);
300
+ const graph = mockGraph((_stepId, result) => {
301
+ if (result.type === 'summary') {
302
+ events.push({
303
+ event: GraphEvents.ON_SUMMARIZE_COMPLETE,
304
+ data: { summary: result.summary },
305
+ });
306
+ }
307
+ });
308
+ const node = createSummarizeNode({
309
+ agentContext,
310
+ graph,
311
+ generateStepId,
312
+ });
313
+
314
+ const result = await node(
315
+ {
316
+ messages: [new HumanMessage('Test')],
317
+ summarizationRequest: {
318
+ remainingContextTokens: 1000,
319
+ agentId: 'agent_0',
320
+ },
321
+ },
322
+ {} as RunnableConfig
323
+ );
324
+
325
+ expect(result.summarizationRequest).toBeUndefined();
326
+ // After summarization, REMOVE_ALL + surviving context is returned
327
+ expect(result.messages).toBeDefined();
328
+ expect(result.messages!.length).toBeGreaterThanOrEqual(1);
329
+ expect(result.messages![0]._getType()).toBe('remove');
330
+
331
+ // Tier 3 fallback: metadata stub is used as summary text
332
+ const completeEvent = events.find(
333
+ (e) => e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
334
+ );
335
+ expect(
336
+ (
337
+ (completeEvent?.data as t.SummarizeCompleteEvent).summary!
338
+ .content?.[0] as { text: string } | undefined
339
+ )?.text
340
+ ).toMatch(/^\[Metadata summary:/);
341
+ expect(
342
+ (completeEvent?.data as t.SummarizeCompleteEvent).error
343
+ ).toBeUndefined();
344
+ });
345
+
346
+ it('falls back to metadata stub when primary LLM call fails', async () => {
347
+ captureEvents();
348
+
349
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
350
+ class {
351
+ constructor() {
352
+ return {
353
+ invoke: jest.fn().mockRejectedValue(new Error('LLM unavailable')),
354
+ };
355
+ }
356
+ } as never
357
+ );
358
+
359
+ const setSummary = jest.fn();
360
+ const agentContext = createAgentContext({ setSummary } as never);
361
+ const graph = mockGraph();
362
+ const node = createSummarizeNode({
363
+ agentContext,
364
+ graph,
365
+ generateStepId,
366
+ });
367
+
368
+ await node(
369
+ {
370
+ messages: [new HumanMessage('Test message')],
371
+ summarizationRequest: {
372
+ remainingContextTokens: 1000,
373
+ agentId: 'agent_0',
374
+ },
375
+ },
376
+ {} as RunnableConfig
377
+ );
378
+
379
+ expect(setSummary).toHaveBeenCalledWith(
380
+ expect.stringContaining('[Metadata summary:'),
381
+ expect.any(Number)
382
+ );
383
+ });
384
+
385
+ it('calls setSummary with the final text', async () => {
386
+ captureEvents();
387
+
388
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
389
+ class {
390
+ constructor() {
391
+ return mockInvokeModel('Final summary');
392
+ }
393
+ } as never
394
+ );
395
+
396
+ const setSummary = jest.fn();
397
+ const agentContext = createAgentContext({ setSummary } as never);
398
+ const graph = mockGraph();
399
+ const node = createSummarizeNode({
400
+ agentContext,
401
+ graph,
402
+ generateStepId,
403
+ });
404
+
405
+ await node(
406
+ {
407
+ messages: [new HumanMessage('Test')],
408
+ summarizationRequest: {
409
+ remainingContextTokens: 1000,
410
+ agentId: 'agent_0',
411
+ },
412
+ },
413
+ {} as RunnableConfig
414
+ );
415
+
416
+ expect(setSummary).toHaveBeenCalledWith(
417
+ 'Final summary',
418
+ expect.any(Number)
419
+ );
420
+ });
421
+
422
+ it('cache-hit path sends raw messages with instruction appended as final HumanMessage', async () => {
423
+ captureEvents();
424
+
425
+ const capturedMessages: Array<{ type: string; content: string }> = [];
426
+
427
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
428
+ class {
429
+ constructor() {
430
+ return {
431
+ invoke: jest
432
+ .fn()
433
+ .mockImplementation(async (messages: unknown[]) => {
434
+ for (const msg of messages as {
435
+ getType: () => string;
436
+ content: string | unknown[];
437
+ }[]) {
438
+ capturedMessages.push({
439
+ type: msg.getType(),
440
+ content:
441
+ typeof msg.content === 'string'
442
+ ? msg.content
443
+ : JSON.stringify(msg.content),
444
+ });
445
+ }
446
+ return {
447
+ content:
448
+ '## Goal\nTest goal\n\n<events>\n<event key="test" turn="0">value</event>\n</events>',
449
+ };
450
+ }),
451
+ };
452
+ }
453
+ } as never
454
+ );
455
+
456
+ const agentContext = createAgentContext();
457
+ const graph = mockGraph();
458
+ const node = createSummarizeNode({
459
+ agentContext,
460
+ graph: graph as never,
461
+ generateStepId,
462
+ });
463
+
464
+ await node(
465
+ {
466
+ messages: [
467
+ new HumanMessage('Message 1'),
468
+ new HumanMessage('Message 2'),
469
+ new HumanMessage('Message 3'),
470
+ ],
471
+ summarizationRequest: {
472
+ remainingContextTokens: 1000,
473
+ agentId: 'agent_0',
474
+ },
475
+ },
476
+ {} as RunnableConfig
477
+ );
478
+
479
+ // The raw messages should be sent + instruction appended as the last HumanMessage
480
+ // messagesToRefine has 3 HumanMessages, instruction adds 1 more
481
+ expect(capturedMessages.length).toBe(4);
482
+ expect(capturedMessages[0].type).toBe('human');
483
+ expect(capturedMessages[0].content).toBe('Message 1');
484
+ expect(capturedMessages[3].type).toBe('human');
485
+ // The last message should contain the summarization prompt
486
+ expect(capturedMessages[3].content).toContain(
487
+ 'context window is filling up'
488
+ );
489
+ });
490
+
491
+ it('cache-hit path includes prior summary in the instruction message', async () => {
492
+ captureEvents();
493
+
494
+ const capturedMessages: Array<{ type: string; content: string }> = [];
495
+
496
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
497
+ class {
498
+ constructor() {
499
+ return {
500
+ invoke: jest
501
+ .fn()
502
+ .mockImplementation(async (messages: unknown[]) => {
503
+ for (const msg of messages as {
504
+ getType: () => string;
505
+ content: string | unknown[];
506
+ }[]) {
507
+ capturedMessages.push({
508
+ type: msg.getType(),
509
+ content:
510
+ typeof msg.content === 'string'
511
+ ? msg.content
512
+ : JSON.stringify(msg.content),
513
+ });
514
+ }
515
+ return { content: '## Goal\nUpdated summary' };
516
+ }),
517
+ };
518
+ }
519
+ } as never
520
+ );
521
+
522
+ // Create context with a prior summary
523
+ const agentContext = createAgentContext();
524
+ agentContext.setSummary('## Goal\nPrior summary content.', 50);
525
+
526
+ const graph = mockGraph();
527
+ const node = createSummarizeNode({
528
+ agentContext,
529
+ graph: graph as never,
530
+ generateStepId,
531
+ });
532
+
533
+ await node(
534
+ {
535
+ messages: [new HumanMessage('New message')],
536
+ summarizationRequest: {
537
+ remainingContextTokens: 1000,
538
+ agentId: 'agent_0',
539
+ },
540
+ },
541
+ {} as RunnableConfig
542
+ );
543
+
544
+ // The last message should contain the update prompt (prior summary exists)
545
+ const lastMsg = capturedMessages[capturedMessages.length - 1];
546
+ expect(lastMsg.type).toBe('human');
547
+ expect(lastMsg.content).toContain('Merge the new messages');
548
+ // Should include the prior summary
549
+ expect(lastMsg.content).toContain('<previous-summary>');
550
+ expect(lastMsg.content).toContain('Prior summary content');
551
+ });
552
+ });
553
+
554
+ describe('DEFAULT_SUMMARIZATION_PROMPT', () => {
555
+ it('is exported and non-empty', () => {
556
+ expect(typeof DEFAULT_SUMMARIZATION_PROMPT).toBe('string');
557
+ expect(DEFAULT_SUMMARIZATION_PROMPT.length).toBeGreaterThan(0);
558
+ });
559
+
560
+ it('contains structured checkpoint sections', () => {
561
+ expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Goal');
562
+ expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Progress');
563
+ expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Key Decisions');
564
+ expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Next Steps');
565
+ });
566
+ });
567
+
568
+ describe('DEFAULT_UPDATE_SUMMARIZATION_PROMPT', () => {
569
+ it('is exported and non-empty', () => {
570
+ expect(typeof DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toBe('string');
571
+ expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT.length).toBeGreaterThan(0);
572
+ });
573
+
574
+ it('instructs merging new content', () => {
575
+ expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(
576
+ /Merge the new messages/i
577
+ );
578
+ });
579
+
580
+ it('instructs updating progress tracking', () => {
581
+ expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(/Done/);
582
+ expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(/In Progress/);
583
+ });
584
+ });
585
+
586
+ describe('budget check — instructions exceed context', () => {
587
+ it('skips summarization when instructionTokens >= maxContextTokens', async () => {
588
+ const events = captureEvents();
589
+ const agentContext = createAgentContext({
590
+ maxContextTokens: 4000,
591
+ systemMessageTokens: 5000,
592
+ formatTokenBudgetBreakdown: () => 'mock breakdown',
593
+ });
594
+
595
+ const graph = mockGraph();
596
+ const summarizeNode = createSummarizeNode({
597
+ agentContext,
598
+ graph: graph as never,
599
+ generateStepId,
600
+ });
601
+
602
+ const result = await summarizeNode(
603
+ {
604
+ messages: [new HumanMessage('test')],
605
+ summarizationRequest: {
606
+ remainingContextTokens: -1000,
607
+ agentId: 'agent_0',
608
+ },
609
+ },
610
+ {} as RunnableConfig
611
+ );
612
+
613
+ expect(result.summarizationRequest).toBeUndefined();
614
+ expect(result.messages).toBeUndefined();
615
+
616
+ // No summarization events should have fired
617
+ const summarizeEvents = events.filter(
618
+ (e) =>
619
+ e.event === GraphEvents.ON_SUMMARIZE_START ||
620
+ e.event === GraphEvents.ON_SUMMARIZE_DELTA ||
621
+ e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
622
+ );
623
+ expect(summarizeEvents).toHaveLength(0);
624
+ });
625
+
626
+ it('proceeds normally when instructionTokens < maxContextTokens', async () => {
627
+ captureEvents();
628
+
629
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
630
+ class {
631
+ constructor() {
632
+ return mockInvokeModel('Budget is fine summary');
633
+ }
634
+ } as never
635
+ );
636
+
637
+ const agentContext = createAgentContext({
638
+ maxContextTokens: 8000,
639
+ systemMessageTokens: 2000,
640
+ formatTokenBudgetBreakdown: () => 'mock breakdown',
641
+ });
642
+
643
+ const graph = mockGraph();
644
+ const summarizeNode = createSummarizeNode({
645
+ agentContext,
646
+ graph: graph as never,
647
+ generateStepId,
648
+ });
649
+
650
+ const result = await summarizeNode(
651
+ {
652
+ messages: [new HumanMessage('hello')],
653
+ summarizationRequest: {
654
+ remainingContextTokens: 500,
655
+ agentId: 'agent_0',
656
+ },
657
+ },
658
+ {} as RunnableConfig
659
+ );
660
+
661
+ // Should have summarized — messages returned for state replacement
662
+ expect(result.messages).toBeDefined();
663
+ expect(result.messages!.length).toBeGreaterThan(0);
664
+ });
665
+ });
666
+
667
+ describe('emoji-heavy content does not break summarization', () => {
668
+ it('summarization completes without JSON errors on emoji-heavy messages', async () => {
669
+ captureEvents();
670
+
671
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
672
+ class {
673
+ constructor() {
674
+ return mockInvokeModel('Summary of emoji conversation');
675
+ }
676
+ } as never
677
+ );
678
+
679
+ const emojiContent = '👨‍💻 coding 🎉 party 🌍 world 🚀 rocket '.repeat(30);
680
+ const agentContext = createAgentContext({
681
+ maxContextTokens: 8000,
682
+ systemMessageTokens: 100,
683
+ formatTokenBudgetBreakdown: () => 'mock breakdown',
684
+ });
685
+
686
+ const graph = mockGraph();
687
+ const summarizeNode = createSummarizeNode({
688
+ agentContext,
689
+ graph: graph as never,
690
+ generateStepId,
691
+ });
692
+
693
+ const result = await summarizeNode(
694
+ {
695
+ messages: [new HumanMessage(emojiContent)],
696
+ summarizationRequest: {
697
+ remainingContextTokens: 500,
698
+ agentId: 'agent_0',
699
+ },
700
+ },
701
+ {} as RunnableConfig
702
+ );
703
+
704
+ // Should complete without throwing JSON serialization errors
705
+ expect(result.messages).toBeDefined();
706
+ expect(result.messages!.length).toBeGreaterThan(0);
707
+ });
708
+ });