@copilotkit/runtime 0.0.0-0.0.0-max-changeset-10101010101010-20260109191632

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 (122) hide show
  1. package/.eslintrc.js +7 -0
  2. package/CHANGELOG.md +2905 -0
  3. package/LICENSE +21 -0
  4. package/README.md +76 -0
  5. package/__snapshots__/schema/schema.graphql +371 -0
  6. package/dist/index.d.ts +1495 -0
  7. package/dist/index.js +5644 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/index.mjs +5601 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/langgraph.d.ts +284 -0
  12. package/dist/langgraph.js +211 -0
  13. package/dist/langgraph.js.map +1 -0
  14. package/dist/langgraph.mjs +206 -0
  15. package/dist/langgraph.mjs.map +1 -0
  16. package/dist/v2/index.d.ts +2 -0
  17. package/dist/v2/index.js +22 -0
  18. package/dist/v2/index.js.map +1 -0
  19. package/dist/v2/index.mjs +5 -0
  20. package/dist/v2/index.mjs.map +1 -0
  21. package/jest.config.js +10 -0
  22. package/package.json +143 -0
  23. package/scripts/generate-gql-schema.ts +13 -0
  24. package/src/agents/langgraph/event-source.ts +329 -0
  25. package/src/agents/langgraph/events.ts +377 -0
  26. package/src/graphql/inputs/action.input.ts +16 -0
  27. package/src/graphql/inputs/agent-session.input.ts +13 -0
  28. package/src/graphql/inputs/agent-state.input.ts +13 -0
  29. package/src/graphql/inputs/cloud-guardrails.input.ts +16 -0
  30. package/src/graphql/inputs/cloud.input.ts +8 -0
  31. package/src/graphql/inputs/context-property.input.ts +10 -0
  32. package/src/graphql/inputs/copilot-context.input.ts +10 -0
  33. package/src/graphql/inputs/custom-property.input.ts +15 -0
  34. package/src/graphql/inputs/extensions.input.ts +21 -0
  35. package/src/graphql/inputs/forwarded-parameters.input.ts +22 -0
  36. package/src/graphql/inputs/frontend.input.ts +14 -0
  37. package/src/graphql/inputs/generate-copilot-response.input.ts +59 -0
  38. package/src/graphql/inputs/load-agent-state.input.ts +10 -0
  39. package/src/graphql/inputs/message.input.ts +110 -0
  40. package/src/graphql/inputs/meta-event.input.ts +18 -0
  41. package/src/graphql/message-conversion/agui-to-gql.test.ts +1263 -0
  42. package/src/graphql/message-conversion/agui-to-gql.ts +333 -0
  43. package/src/graphql/message-conversion/gql-to-agui.test.ts +1580 -0
  44. package/src/graphql/message-conversion/gql-to-agui.ts +278 -0
  45. package/src/graphql/message-conversion/index.ts +2 -0
  46. package/src/graphql/message-conversion/roundtrip-conversion.test.ts +526 -0
  47. package/src/graphql/resolvers/copilot.resolver.ts +708 -0
  48. package/src/graphql/resolvers/state.resolver.ts +27 -0
  49. package/src/graphql/types/agents-response.type.ts +19 -0
  50. package/src/graphql/types/base/index.ts +10 -0
  51. package/src/graphql/types/converted/index.ts +176 -0
  52. package/src/graphql/types/copilot-response.type.ts +138 -0
  53. package/src/graphql/types/enums.ts +38 -0
  54. package/src/graphql/types/extensions-response.type.ts +23 -0
  55. package/src/graphql/types/guardrails-result.type.ts +20 -0
  56. package/src/graphql/types/load-agent-state-response.type.ts +17 -0
  57. package/src/graphql/types/message-status.type.ts +42 -0
  58. package/src/graphql/types/meta-events.type.ts +71 -0
  59. package/src/graphql/types/response-status.type.ts +66 -0
  60. package/src/index.ts +4 -0
  61. package/src/langgraph.ts +1 -0
  62. package/src/lib/cloud/index.ts +4 -0
  63. package/src/lib/error-messages.ts +200 -0
  64. package/src/lib/index.ts +52 -0
  65. package/src/lib/integrations/index.ts +6 -0
  66. package/src/lib/integrations/nest/index.ts +14 -0
  67. package/src/lib/integrations/nextjs/app-router.ts +38 -0
  68. package/src/lib/integrations/nextjs/pages-router.ts +39 -0
  69. package/src/lib/integrations/node-express/index.ts +14 -0
  70. package/src/lib/integrations/node-http/index.ts +143 -0
  71. package/src/lib/integrations/node-http/request-handler.ts +111 -0
  72. package/src/lib/integrations/shared.ts +161 -0
  73. package/src/lib/logger.ts +28 -0
  74. package/src/lib/observability.ts +160 -0
  75. package/src/lib/runtime/__tests__/copilot-runtime-error.test.ts +169 -0
  76. package/src/lib/runtime/__tests__/mcp-tools-utils.test.ts +464 -0
  77. package/src/lib/runtime/agent-integrations/langgraph/agent.ts +209 -0
  78. package/src/lib/runtime/agent-integrations/langgraph/consts.ts +34 -0
  79. package/src/lib/runtime/agent-integrations/langgraph/index.ts +2 -0
  80. package/src/lib/runtime/copilot-runtime.ts +710 -0
  81. package/src/lib/runtime/mcp-tools-utils.ts +254 -0
  82. package/src/lib/runtime/retry-utils.ts +96 -0
  83. package/src/lib/runtime/telemetry-agent-runner.ts +139 -0
  84. package/src/lib/runtime/types.ts +49 -0
  85. package/src/lib/runtime/utils.ts +87 -0
  86. package/src/lib/streaming.ts +202 -0
  87. package/src/lib/telemetry-client.ts +64 -0
  88. package/src/service-adapters/anthropic/anthropic-adapter.ts +452 -0
  89. package/src/service-adapters/anthropic/utils.ts +152 -0
  90. package/src/service-adapters/bedrock/bedrock-adapter.ts +73 -0
  91. package/src/service-adapters/conversion.ts +67 -0
  92. package/src/service-adapters/empty/empty-adapter.ts +38 -0
  93. package/src/service-adapters/events.ts +294 -0
  94. package/src/service-adapters/experimental/ollama/ollama-adapter.ts +84 -0
  95. package/src/service-adapters/google/google-genai-adapter.test.ts +104 -0
  96. package/src/service-adapters/google/google-genai-adapter.ts +88 -0
  97. package/src/service-adapters/groq/groq-adapter.ts +203 -0
  98. package/src/service-adapters/index.ts +18 -0
  99. package/src/service-adapters/langchain/langchain-adapter.ts +111 -0
  100. package/src/service-adapters/langchain/langserve.ts +88 -0
  101. package/src/service-adapters/langchain/types.ts +14 -0
  102. package/src/service-adapters/langchain/utils.ts +313 -0
  103. package/src/service-adapters/openai/openai-adapter.ts +283 -0
  104. package/src/service-adapters/openai/openai-assistant-adapter.ts +344 -0
  105. package/src/service-adapters/openai/utils.ts +199 -0
  106. package/src/service-adapters/service-adapter.ts +41 -0
  107. package/src/service-adapters/shared/error-utils.ts +61 -0
  108. package/src/service-adapters/shared/index.ts +1 -0
  109. package/src/service-adapters/unify/unify-adapter.ts +151 -0
  110. package/src/utils/failed-response-status-reasons.ts +70 -0
  111. package/src/utils/index.ts +1 -0
  112. package/src/v2/index.ts +3 -0
  113. package/tests/global.d.ts +13 -0
  114. package/tests/service-adapters/anthropic/allowlist-approach.test.ts +226 -0
  115. package/tests/service-adapters/anthropic/anthropic-adapter.test.ts +389 -0
  116. package/tests/service-adapters/openai/allowlist-approach.test.ts +238 -0
  117. package/tests/service-adapters/openai/openai-adapter.test.ts +301 -0
  118. package/tests/setup.jest.ts +21 -0
  119. package/tests/tsconfig.json +10 -0
  120. package/tsconfig.json +13 -0
  121. package/tsup.config.ts +20 -0
  122. package/typedoc.json +4 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter";
6
+
7
+ // Mock only the Anthropic SDK, not our adapter
8
+ jest.mock("@anthropic-ai/sdk", () => {
9
+ return {
10
+ default: jest.fn().mockImplementation(() => ({
11
+ messages: {
12
+ create: jest.fn(),
13
+ },
14
+ })),
15
+ };
16
+ });
17
+
18
+ // Mock the message classes
19
+ jest.mock("../../../src/graphql/types/converted", () => {
20
+ class MockTextMessage {
21
+ content: string;
22
+ role: string;
23
+ id: string;
24
+
25
+ constructor(role: string, content: string) {
26
+ this.role = role;
27
+ this.content = content;
28
+ this.id = "mock-text-" + Math.random().toString(36).substring(7);
29
+ }
30
+
31
+ isTextMessage() {
32
+ return true;
33
+ }
34
+ isImageMessage() {
35
+ return false;
36
+ }
37
+ isActionExecutionMessage() {
38
+ return false;
39
+ }
40
+ isResultMessage() {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ class MockActionExecutionMessage {
46
+ id: string;
47
+ name: string;
48
+ arguments: string;
49
+
50
+ constructor(params: { id: string; name: string; arguments: string }) {
51
+ this.id = params.id;
52
+ this.name = params.name;
53
+ this.arguments = params.arguments;
54
+ }
55
+
56
+ isTextMessage() {
57
+ return false;
58
+ }
59
+ isImageMessage() {
60
+ return false;
61
+ }
62
+ isActionExecutionMessage() {
63
+ return true;
64
+ }
65
+ isResultMessage() {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ class MockResultMessage {
71
+ actionExecutionId: string;
72
+ result: string;
73
+ id: string;
74
+
75
+ constructor(params: { actionExecutionId: string; result: string }) {
76
+ this.actionExecutionId = params.actionExecutionId;
77
+ this.result = params.result;
78
+ this.id = "mock-result-" + Math.random().toString(36).substring(7);
79
+ }
80
+
81
+ isTextMessage() {
82
+ return false;
83
+ }
84
+ isImageMessage() {
85
+ return false;
86
+ }
87
+ isActionExecutionMessage() {
88
+ return false;
89
+ }
90
+ isResultMessage() {
91
+ return true;
92
+ }
93
+ }
94
+
95
+ return {
96
+ TextMessage: MockTextMessage,
97
+ ActionExecutionMessage: MockActionExecutionMessage,
98
+ ResultMessage: MockResultMessage,
99
+ };
100
+ });
101
+
102
+ describe("AnthropicAdapter", () => {
103
+ let adapter: AnthropicAdapter;
104
+ let mockEventSource: any;
105
+ let mockAnthropicCreate: jest.Mock;
106
+
107
+ beforeEach(() => {
108
+ jest.clearAllMocks();
109
+
110
+ // Create a mock Anthropic instance
111
+ const mockAnthropic = {
112
+ messages: {
113
+ create: jest.fn(),
114
+ },
115
+ };
116
+
117
+ // Create adapter with the mocked instance
118
+ adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
119
+
120
+ // Mock the create method to capture what's being sent
121
+ mockAnthropicCreate = mockAnthropic.messages.create;
122
+
123
+ mockEventSource = {
124
+ stream: jest.fn((callback) => {
125
+ const mockStream = {
126
+ sendTextMessageStart: jest.fn(),
127
+ sendTextMessageContent: jest.fn(),
128
+ sendTextMessageEnd: jest.fn(),
129
+ sendActionExecutionStart: jest.fn(),
130
+ sendActionExecutionArgs: jest.fn(),
131
+ sendActionExecutionEnd: jest.fn(),
132
+ complete: jest.fn(),
133
+ };
134
+ callback(mockStream);
135
+ return Promise.resolve();
136
+ }),
137
+ };
138
+ });
139
+
140
+ describe("Deduplication Logic", () => {
141
+ it("should filter out duplicate result messages", async () => {
142
+ const {
143
+ TextMessage,
144
+ ActionExecutionMessage,
145
+ ResultMessage,
146
+ } = require("../../../src/graphql/types/converted");
147
+
148
+ const systemMessage = new TextMessage("system", "System message");
149
+ const userMessage = new TextMessage("user", "Set theme to orange");
150
+
151
+ // Tool execution
152
+ const toolExecution = new ActionExecutionMessage({
153
+ id: "tool-123",
154
+ name: "setThemeColor",
155
+ arguments: '{"themeColor": "orange"}',
156
+ });
157
+
158
+ // Multiple duplicate results (this was causing the infinite loop)
159
+ const result1 = new ResultMessage({
160
+ actionExecutionId: "tool-123",
161
+ result: "Theme color set to orange",
162
+ });
163
+ const result2 = new ResultMessage({
164
+ actionExecutionId: "tool-123",
165
+ result: "Theme color set to orange",
166
+ });
167
+ const result3 = new ResultMessage({
168
+ actionExecutionId: "tool-123",
169
+ result: "Theme color set to orange",
170
+ });
171
+
172
+ // Mock Anthropic to return empty stream (simulating the original problem)
173
+ mockAnthropicCreate.mockResolvedValue({
174
+ [Symbol.asyncIterator]: async function* () {
175
+ // Empty stream - no content from Anthropic
176
+ },
177
+ });
178
+
179
+ await adapter.process({
180
+ threadId: "test-thread",
181
+ model: "claude-3-5-sonnet-latest",
182
+ messages: [systemMessage, userMessage, toolExecution, result1, result2, result3],
183
+ actions: [],
184
+ eventSource: mockEventSource,
185
+ forwardedParameters: {},
186
+ });
187
+
188
+ // Check that only one result message was sent to Anthropic
189
+ expect(mockAnthropicCreate).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ messages: expect.arrayContaining([
192
+ expect.objectContaining({
193
+ role: "user",
194
+ content: [{ type: "text", text: "Set theme to orange" }],
195
+ }),
196
+ expect.objectContaining({
197
+ role: "assistant",
198
+ content: [
199
+ expect.objectContaining({
200
+ type: "tool_use",
201
+ id: "tool-123",
202
+ name: "setThemeColor",
203
+ }),
204
+ ],
205
+ }),
206
+ expect.objectContaining({
207
+ role: "user",
208
+ content: [
209
+ expect.objectContaining({
210
+ type: "tool_result",
211
+ content: "Theme color set to orange",
212
+ tool_use_id: "tool-123",
213
+ }),
214
+ ],
215
+ }),
216
+ ]),
217
+ }),
218
+ );
219
+
220
+ // Verify only 3 messages sent (user, assistant tool_use, user tool_result) - no duplicates
221
+ const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
222
+ expect(sentMessages).toHaveLength(3);
223
+
224
+ // Count tool_result messages - should be only 1
225
+ const toolResults = sentMessages.filter(
226
+ (msg: any) => msg.role === "user" && msg.content.some((c: any) => c.type === "tool_result"),
227
+ );
228
+ expect(toolResults).toHaveLength(1);
229
+ });
230
+
231
+ it("should filter out invalid result messages without corresponding tool_use", async () => {
232
+ const {
233
+ TextMessage,
234
+ ActionExecutionMessage,
235
+ ResultMessage,
236
+ } = require("../../../src/graphql/types/converted");
237
+
238
+ const systemMessage = new TextMessage("system", "System message");
239
+
240
+ // Valid tool execution
241
+ const validTool = new ActionExecutionMessage({
242
+ id: "valid-tool",
243
+ name: "validAction",
244
+ arguments: "{}",
245
+ });
246
+ const validResult = new ResultMessage({
247
+ actionExecutionId: "valid-tool",
248
+ result: "Valid result",
249
+ });
250
+
251
+ // Invalid result with no corresponding tool_use
252
+ const invalidResult = new ResultMessage({
253
+ actionExecutionId: "nonexistent-tool",
254
+ result: "Invalid result",
255
+ });
256
+
257
+ mockAnthropicCreate.mockResolvedValue({
258
+ [Symbol.asyncIterator]: async function* () {},
259
+ });
260
+
261
+ await adapter.process({
262
+ threadId: "test-thread",
263
+ messages: [systemMessage, validTool, validResult, invalidResult],
264
+ actions: [],
265
+ eventSource: mockEventSource,
266
+ forwardedParameters: {},
267
+ });
268
+
269
+ const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
270
+
271
+ // Should only include the valid tool result
272
+ const toolResults = sentMessages.filter(
273
+ (msg: any) => msg.role === "user" && msg.content.some((c: any) => c.type === "tool_result"),
274
+ );
275
+ expect(toolResults).toHaveLength(1);
276
+ expect(toolResults[0].content[0].tool_use_id).toBe("valid-tool");
277
+ });
278
+ });
279
+
280
+ describe("Fallback Response Logic", () => {
281
+ it("should generate contextual fallback when Anthropic returns no content", async () => {
282
+ const {
283
+ TextMessage,
284
+ ActionExecutionMessage,
285
+ ResultMessage,
286
+ } = require("../../../src/graphql/types/converted");
287
+
288
+ const systemMessage = new TextMessage("system", "System message");
289
+ const userMessage = new TextMessage("user", "Set theme to orange");
290
+
291
+ const toolExecution = new ActionExecutionMessage({
292
+ id: "tool-123",
293
+ name: "setThemeColor",
294
+ arguments: '{"themeColor": "orange"}',
295
+ });
296
+
297
+ const toolResult = new ResultMessage({
298
+ actionExecutionId: "tool-123",
299
+ result: "Theme color set to orange",
300
+ });
301
+
302
+ // Mock Anthropic to return empty stream
303
+ mockAnthropicCreate.mockResolvedValue({
304
+ [Symbol.asyncIterator]: async function* () {
305
+ // No content blocks - simulates Anthropic not responding
306
+ },
307
+ });
308
+
309
+ const mockStream = {
310
+ sendTextMessageStart: jest.fn(),
311
+ sendTextMessageContent: jest.fn(),
312
+ sendTextMessageEnd: jest.fn(),
313
+ complete: jest.fn(),
314
+ };
315
+
316
+ mockEventSource.stream.mockImplementation((callback) => {
317
+ callback(mockStream);
318
+ return Promise.resolve();
319
+ });
320
+
321
+ await adapter.process({
322
+ threadId: "test-thread",
323
+ messages: [systemMessage, userMessage, toolExecution, toolResult],
324
+ actions: [],
325
+ eventSource: mockEventSource,
326
+ forwardedParameters: {},
327
+ });
328
+
329
+ // Should generate fallback response with the tool result content
330
+ expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
331
+ expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
332
+ messageId: expect.any(String),
333
+ content: "Theme color set to orange", // Uses the actual result content
334
+ });
335
+ expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
336
+ });
337
+
338
+ it("should use generic fallback when no tool result content available", async () => {
339
+ const {
340
+ TextMessage,
341
+ ActionExecutionMessage,
342
+ ResultMessage,
343
+ } = require("../../../src/graphql/types/converted");
344
+
345
+ const systemMessage = new TextMessage("system", "System message");
346
+
347
+ const toolExecution = new ActionExecutionMessage({
348
+ id: "tool-123",
349
+ name: "someAction",
350
+ arguments: "{}",
351
+ });
352
+
353
+ const toolResult = new ResultMessage({
354
+ actionExecutionId: "tool-123",
355
+ result: "", // Empty result
356
+ });
357
+
358
+ mockAnthropicCreate.mockResolvedValue({
359
+ [Symbol.asyncIterator]: async function* () {},
360
+ });
361
+
362
+ const mockStream = {
363
+ sendTextMessageStart: jest.fn(),
364
+ sendTextMessageContent: jest.fn(),
365
+ sendTextMessageEnd: jest.fn(),
366
+ complete: jest.fn(),
367
+ };
368
+
369
+ mockEventSource.stream.mockImplementation((callback) => {
370
+ callback(mockStream);
371
+ return Promise.resolve();
372
+ });
373
+
374
+ await adapter.process({
375
+ threadId: "test-thread",
376
+ messages: [systemMessage, toolExecution, toolResult],
377
+ actions: [],
378
+ eventSource: mockEventSource,
379
+ forwardedParameters: {},
380
+ });
381
+
382
+ // Should use generic fallback
383
+ expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
384
+ messageId: expect.any(String),
385
+ content: "Task completed successfully.",
386
+ });
387
+ });
388
+ });
389
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { describe, it, expect } from "@jest/globals";
6
+
7
+ describe("OpenAI Adapter - Allowlist Approach", () => {
8
+ it("should filter out tool_result messages with no corresponding tool_call ID", () => {
9
+ // Setup test data
10
+ const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
11
+
12
+ // Messages to filter - valid and invalid ones
13
+ const messages = [
14
+ { type: "text", role: "user", content: "Hello" },
15
+ { type: "tool_result", actionExecutionId: "valid-id-1", result: "result1" },
16
+ { type: "tool_result", actionExecutionId: "invalid-id", result: "invalid" },
17
+ { type: "tool_result", actionExecutionId: "valid-id-2", result: "result2" },
18
+ { type: "tool_result", actionExecutionId: "valid-id-1", result: "duplicate" }, // Duplicate ID
19
+ ];
20
+
21
+ // Implement the filtering logic, similar to the adapter
22
+ const filteredMessages = messages.filter((message) => {
23
+ if (message.type === "tool_result") {
24
+ // Skip if there's no corresponding tool_call
25
+ if (!validToolCallIds.has(message.actionExecutionId)) {
26
+ return false;
27
+ }
28
+
29
+ // Remove this ID from valid IDs so we don't process duplicates
30
+ validToolCallIds.delete(message.actionExecutionId);
31
+ }
32
+
33
+ // Keep all non-tool-result messages
34
+ return true;
35
+ });
36
+
37
+ // Verify results
38
+ expect(filteredMessages.length).toBe(3); // text + 2 valid tool results (no duplicates or invalid)
39
+
40
+ // Valid results should be included
41
+ expect(
42
+ filteredMessages.some(
43
+ (m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
44
+ ),
45
+ ).toBe(true);
46
+
47
+ expect(
48
+ filteredMessages.some(
49
+ (m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-2",
50
+ ),
51
+ ).toBe(true);
52
+
53
+ // Invalid result should be excluded
54
+ expect(
55
+ filteredMessages.some(
56
+ (m) => m.type === "tool_result" && m.actionExecutionId === "invalid-id",
57
+ ),
58
+ ).toBe(false);
59
+
60
+ // Duplicate should be excluded - we used a different approach than Anthropic
61
+ const validId1Count = filteredMessages.filter(
62
+ (m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
63
+ ).length;
64
+
65
+ expect(validId1Count).toBe(1);
66
+ });
67
+
68
+ it("should maintain correct order of messages when filtering", () => {
69
+ // Setup test data
70
+ const validToolCallIds = new Set<string>(["tool-1", "tool-2", "tool-3"]);
71
+
72
+ // Test with a complex conversation pattern
73
+ const messages = [
74
+ { type: "text", role: "user", content: "Initial message" },
75
+ { type: "text", role: "assistant", content: "I'll help with that" },
76
+ { type: "tool_call", id: "tool-1", name: "firstTool" },
77
+ { type: "tool_result", actionExecutionId: "tool-1", result: "result1" },
78
+ { type: "text", role: "assistant", content: "Got the first result" },
79
+ { type: "tool_call", id: "tool-2", name: "secondTool" },
80
+ { type: "tool_result", actionExecutionId: "tool-2", result: "result2" },
81
+ { type: "tool_result", actionExecutionId: "invalid-id", result: "invalid-result" },
82
+ { type: "tool_call", id: "tool-3", name: "thirdTool" },
83
+ { type: "tool_result", actionExecutionId: "tool-1", result: "duplicate-result" }, // Duplicate
84
+ { type: "tool_result", actionExecutionId: "tool-3", result: "result3" },
85
+ { type: "text", role: "user", content: "Final message" },
86
+ ];
87
+
88
+ // Apply OpenAI's filter approach (using filter instead of loop)
89
+ const filteredMessages = messages.filter((message) => {
90
+ if (message.type === "tool_result") {
91
+ // Skip if there's no corresponding tool_call
92
+ if (!validToolCallIds.has(message.actionExecutionId)) {
93
+ return false;
94
+ }
95
+
96
+ // Remove this ID from valid IDs so we don't process duplicates
97
+ validToolCallIds.delete(message.actionExecutionId);
98
+ }
99
+
100
+ // Keep all non-tool-result messages
101
+ return true;
102
+ });
103
+
104
+ // Verify results
105
+ expect(filteredMessages.length).toBe(10); // 12 original - 2 filtered out
106
+
107
+ // Check that the message order is preserved
108
+ expect(filteredMessages[0].type).toBe("text"); // Initial user message
109
+ expect(filteredMessages[0].content).toBe("Initial message");
110
+ expect(filteredMessages[1].type).toBe("text"); // Assistant response
111
+ expect(filteredMessages[2].type).toBe("tool_call"); // First tool
112
+ expect(filteredMessages[3].type).toBe("tool_result"); // First result
113
+ expect(filteredMessages[3].actionExecutionId).toBe("tool-1");
114
+ expect(filteredMessages[4].type).toBe("text"); // Assistant comment
115
+ expect(filteredMessages[5].type).toBe("tool_call"); // Second tool
116
+ expect(filteredMessages[6].type).toBe("tool_result"); // Second result
117
+ expect(filteredMessages[6].actionExecutionId).toBe("tool-2");
118
+ expect(filteredMessages[7].type).toBe("tool_call"); // Third tool
119
+ expect(filteredMessages[8].type).toBe("tool_result"); // Third result
120
+ expect(filteredMessages[8].actionExecutionId).toBe("tool-3");
121
+ expect(filteredMessages[9].type).toBe("text"); // Final user message
122
+
123
+ // Each valid tool result should appear exactly once
124
+ const toolResultCounts = new Map();
125
+ filteredMessages.forEach((message) => {
126
+ if (message.type === "tool_result") {
127
+ const id = message.actionExecutionId;
128
+ toolResultCounts.set(id, (toolResultCounts.get(id) || 0) + 1);
129
+ }
130
+ });
131
+
132
+ expect(toolResultCounts.size).toBe(3); // Should have 3 different tool results
133
+ expect(toolResultCounts.get("tool-1")).toBe(1);
134
+ expect(toolResultCounts.get("tool-2")).toBe(1);
135
+ expect(toolResultCounts.get("tool-3")).toBe(1);
136
+ expect(toolResultCounts.has("invalid-id")).toBe(false);
137
+ });
138
+
139
+ it("should handle empty message array", () => {
140
+ const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
141
+ const messages = [];
142
+
143
+ // Apply OpenAI's filter approach
144
+ const filteredMessages = messages.filter((message) => {
145
+ if (message.type === "tool_result") {
146
+ if (!validToolCallIds.has(message.actionExecutionId)) {
147
+ return false;
148
+ }
149
+ validToolCallIds.delete(message.actionExecutionId);
150
+ }
151
+ return true;
152
+ });
153
+
154
+ expect(filteredMessages.length).toBe(0);
155
+ });
156
+
157
+ it("should handle edge cases with mixed message types", () => {
158
+ // Setup test data with various message types
159
+ const validToolCallIds = new Set<string>(["valid-id-1"]);
160
+
161
+ const messages = [
162
+ { type: "text", role: "user", content: "Hello" },
163
+ { type: "image", url: "https://example.com/image.jpg" }, // Non-tool message type
164
+ { type: "tool_result", actionExecutionId: "valid-id-1", result: "result1" },
165
+ { type: "custom", data: { key: "value" } }, // Another custom type
166
+ { type: "tool_result", actionExecutionId: "valid-id-1", result: "duplicate" }, // Duplicate
167
+ { type: "null", value: null }, // Edge case
168
+ { type: "undefined" }, // Edge case
169
+ ];
170
+
171
+ // Apply OpenAI's filter approach
172
+ const filteredMessages = messages.filter((message) => {
173
+ if (message.type === "tool_result") {
174
+ if (!validToolCallIds.has(message.actionExecutionId)) {
175
+ return false;
176
+ }
177
+ validToolCallIds.delete(message.actionExecutionId);
178
+ }
179
+ return true;
180
+ });
181
+
182
+ // Should have all non-tool_result messages + 1 valid tool_result
183
+ expect(filteredMessages.length).toBe(6); // 7 original - 1 duplicate
184
+
185
+ // Valid tool_result should be included exactly once
186
+ const toolResults = filteredMessages.filter((m) => m.type === "tool_result");
187
+ expect(toolResults.length).toBe(1);
188
+ expect(toolResults[0].actionExecutionId).toBe("valid-id-1");
189
+
190
+ // All non-tool_result messages should be preserved
191
+ expect(filteredMessages.filter((m) => m.type === "text").length).toBe(1);
192
+ expect(filteredMessages.filter((m) => m.type === "image").length).toBe(1);
193
+ expect(filteredMessages.filter((m) => m.type === "custom").length).toBe(1);
194
+ expect(filteredMessages.filter((m) => m.type === "null").length).toBe(1);
195
+ expect(filteredMessages.filter((m) => m.type === "undefined").length).toBe(1);
196
+ });
197
+
198
+ it("should properly handle multiple duplicate tool results", () => {
199
+ // Setup test data with multiple duplicates
200
+ const validToolCallIds = new Set<string>(["tool-1", "tool-2"]);
201
+
202
+ const messages = [
203
+ { type: "text", role: "user", content: "Initial prompt" },
204
+ { type: "tool_call", id: "tool-1", name: "firstTool" },
205
+ { type: "tool_result", actionExecutionId: "tool-1", result: "first-result" },
206
+ { type: "tool_result", actionExecutionId: "tool-1", result: "duplicate-1" }, // Duplicate 1
207
+ { type: "tool_call", id: "tool-2", name: "secondTool" },
208
+ { type: "tool_result", actionExecutionId: "tool-1", result: "duplicate-2" }, // Duplicate 2
209
+ { type: "tool_result", actionExecutionId: "tool-2", result: "second-result" },
210
+ { type: "tool_result", actionExecutionId: "tool-2", result: "duplicate-3" }, // Duplicate 3
211
+ { type: "tool_result", actionExecutionId: "tool-1", result: "duplicate-4" }, // Duplicate 4
212
+ ];
213
+
214
+ // Apply OpenAI's filter approach
215
+ const filteredMessages = messages.filter((message) => {
216
+ if (message.type === "tool_result") {
217
+ if (!validToolCallIds.has(message.actionExecutionId)) {
218
+ return false;
219
+ }
220
+ validToolCallIds.delete(message.actionExecutionId);
221
+ }
222
+ return true;
223
+ });
224
+
225
+ // Should have text + tool calls + only the first occurrence of each tool result
226
+ expect(filteredMessages.length).toBe(5);
227
+
228
+ // Check that only the first occurrence of each tool result is kept
229
+ const toolResults = filteredMessages.filter((m) => m.type === "tool_result");
230
+ expect(toolResults.length).toBe(2);
231
+
232
+ expect(toolResults[0].actionExecutionId).toBe("tool-1");
233
+ expect(toolResults[0].result).toBe("first-result"); // First occurrence should be kept
234
+
235
+ expect(toolResults[1].actionExecutionId).toBe("tool-2");
236
+ expect(toolResults[1].result).toBe("second-result"); // First occurrence should be kept
237
+ });
238
+ });