@copilotkit/sdk-js 1.56.4 → 1.56.5
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.
- package/dist/langgraph/middleware.cjs +144 -11
- package/dist/langgraph/middleware.cjs.map +1 -1
- package/dist/langgraph/middleware.d.cts +68 -1
- package/dist/langgraph/middleware.d.cts.map +1 -1
- package/dist/langgraph/middleware.d.mts +68 -1
- package/dist/langgraph/middleware.d.mts.map +1 -1
- package/dist/langgraph/middleware.mjs +143 -12
- package/dist/langgraph/middleware.mjs.map +1 -1
- package/dist/langgraph/state-schema.d.cts +22 -16
- package/dist/langgraph/state-schema.d.cts.map +1 -1
- package/dist/langgraph/state-schema.d.mts +22 -16
- package/dist/langgraph/state-schema.d.mts.map +1 -1
- package/dist/langgraph/types.d.cts +107 -50
- package/dist/langgraph/types.d.cts.map +1 -1
- package/dist/langgraph/types.d.mts +107 -50
- package/dist/langgraph/types.d.mts.map +1 -1
- package/dist/langgraph.cjs +3 -1
- package/dist/langgraph.d.cts +2 -2
- package/dist/langgraph.d.mts +2 -2
- package/dist/langgraph.mjs +2 -2
- package/package.json +6 -6
- package/src/langgraph/__tests__/middleware.test.ts +611 -0
- package/src/langgraph/middleware.ts +210 -15
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior tests for the CopilotKit LangGraph middleware.
|
|
3
|
+
*
|
|
4
|
+
* The contract these tests pin down (independent of how the middleware is
|
|
5
|
+
* implemented internally — we only assert on what the model handler
|
|
6
|
+
* observes and what state updates the middleware emits):
|
|
7
|
+
*
|
|
8
|
+
* - Frontend tools listed in `state.copilotkit.actions` reach the model
|
|
9
|
+
* alongside the agent's own tools. Empty actions = no change.
|
|
10
|
+
* - App context from `state.copilotkit.context` (or runtime.context) becomes
|
|
11
|
+
* a SystemMessage `"App Context:\n<json>"`. Idempotent across re-runs.
|
|
12
|
+
* - `afterModel` peels frontend tool calls off the last AIMessage so the
|
|
13
|
+
* ToolNode does not execute them; `afterAgent` re-attaches them.
|
|
14
|
+
* - The opt-in `exposeState` knob surfaces user state into
|
|
15
|
+
* `request.systemPrompt` as a "Current agent state:" note. Default off;
|
|
16
|
+
* reserved internal keys / `_`-prefixed keys / empty values are filtered;
|
|
17
|
+
* allowlist forces an explicit subset; existing systemPrompt is kept.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from "vitest";
|
|
21
|
+
import * as z from "zod";
|
|
22
|
+
import {
|
|
23
|
+
AIMessage,
|
|
24
|
+
HumanMessage,
|
|
25
|
+
SystemMessage,
|
|
26
|
+
} from "@langchain/core/messages";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
copilotkitMiddleware,
|
|
30
|
+
createCopilotkitMiddleware,
|
|
31
|
+
zodState,
|
|
32
|
+
} from "../middleware";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function makeRequest(overrides: any = {}): any {
|
|
39
|
+
return {
|
|
40
|
+
model: { _modelType: () => "fake" },
|
|
41
|
+
messages: [],
|
|
42
|
+
systemPrompt: undefined,
|
|
43
|
+
tools: [],
|
|
44
|
+
state: { messages: [] },
|
|
45
|
+
runtime: {},
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runWrap(middleware: any, request: any) {
|
|
51
|
+
let received: any = null;
|
|
52
|
+
const handler = async (req: any) => {
|
|
53
|
+
received = req;
|
|
54
|
+
return { content: "ok" } as any;
|
|
55
|
+
};
|
|
56
|
+
const result = await middleware.wrapModelCall(request, handler);
|
|
57
|
+
expect(received).not.toBeNull();
|
|
58
|
+
return { received, result };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function systemContents(messages: any[]): string[] {
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
for (const m of messages) {
|
|
64
|
+
if (m._getType?.() === "system") {
|
|
65
|
+
out.push(typeof m.content === "string" ? m.content : String(m.content));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Frontend-tool injection
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe("frontend tool injection", () => {
|
|
76
|
+
it("passes the request through unchanged when there are no frontend tools", async () => {
|
|
77
|
+
const backendTool = { name: "backend" };
|
|
78
|
+
const request = makeRequest({
|
|
79
|
+
state: { messages: [] },
|
|
80
|
+
tools: [backendTool],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { received } = await runWrap(copilotkitMiddleware, request);
|
|
84
|
+
|
|
85
|
+
expect(received.tools).toEqual([backendTool]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("merges frontend tools from state.copilotkit.actions with the agent's own tools", async () => {
|
|
89
|
+
const backend = { name: "backend" };
|
|
90
|
+
const fe = [{ name: "fe_one" }, { name: "fe_two" }];
|
|
91
|
+
const request = makeRequest({
|
|
92
|
+
state: { messages: [], copilotkit: { actions: fe } },
|
|
93
|
+
tools: [backend],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const { received } = await runWrap(copilotkitMiddleware, request);
|
|
97
|
+
|
|
98
|
+
const names = received.tools.map((t: any) => t.name).sort();
|
|
99
|
+
expect(names).toEqual(["backend", "fe_one", "fe_two"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not mutate the input request when merging frontend tools", async () => {
|
|
103
|
+
const request = makeRequest({
|
|
104
|
+
state: {
|
|
105
|
+
messages: [],
|
|
106
|
+
copilotkit: { actions: [{ name: "fe" }] },
|
|
107
|
+
},
|
|
108
|
+
tools: [{ name: "backend" }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await runWrap(copilotkitMiddleware, request);
|
|
112
|
+
|
|
113
|
+
expect(request.tools.map((t: any) => t.name)).toEqual(["backend"]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// exposeState — opt-in state surfacing
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe("exposeState", () => {
|
|
122
|
+
it("is off by default — user state never lands in the system prompt", async () => {
|
|
123
|
+
const request = makeRequest({
|
|
124
|
+
state: { messages: [], liked: ["a", "b"] },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const { received } = await runWrap(copilotkitMiddleware, request);
|
|
128
|
+
|
|
129
|
+
expect(received.systemPrompt).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("surfaces user state into the system prompt when set to true", async () => {
|
|
133
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
134
|
+
const request = makeRequest({
|
|
135
|
+
state: { messages: [], liked: ["a", "b"] },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { received } = await runWrap(middleware, request);
|
|
139
|
+
|
|
140
|
+
expect(received.systemPrompt).toBeDefined();
|
|
141
|
+
const content =
|
|
142
|
+
typeof received.systemPrompt.content === "string"
|
|
143
|
+
? received.systemPrompt.content
|
|
144
|
+
: String(received.systemPrompt.content);
|
|
145
|
+
expect(content).toContain("Current agent state:");
|
|
146
|
+
expect(content).toContain('"liked"');
|
|
147
|
+
expect(content).toContain('"a"');
|
|
148
|
+
expect(content).toContain('"b"');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("skips reserved internal keys when exposing state", async () => {
|
|
152
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
153
|
+
const request = makeRequest({
|
|
154
|
+
state: {
|
|
155
|
+
messages: [new HumanMessage("hi")],
|
|
156
|
+
tools: [{ name: "x" }],
|
|
157
|
+
copilotkit: { actions: [] },
|
|
158
|
+
structured_response: { foo: "bar" },
|
|
159
|
+
thread_id: "t-1",
|
|
160
|
+
remaining_steps: 5,
|
|
161
|
+
"ag-ui": { context: [] },
|
|
162
|
+
liked: ["a"],
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const { received } = await runWrap(middleware, request);
|
|
167
|
+
|
|
168
|
+
const body =
|
|
169
|
+
typeof received.systemPrompt?.content === "string"
|
|
170
|
+
? received.systemPrompt.content
|
|
171
|
+
: "";
|
|
172
|
+
expect(body).toContain('"liked"');
|
|
173
|
+
for (const reserved of [
|
|
174
|
+
"messages",
|
|
175
|
+
"tools",
|
|
176
|
+
"copilotkit",
|
|
177
|
+
"structured_response",
|
|
178
|
+
"thread_id",
|
|
179
|
+
"remaining_steps",
|
|
180
|
+
"ag-ui",
|
|
181
|
+
]) {
|
|
182
|
+
expect(body).not.toContain(`"${reserved}"`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("skips underscore-prefixed keys when exposing state", async () => {
|
|
187
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
188
|
+
const request = makeRequest({
|
|
189
|
+
state: { messages: [], _internal: { secret: 1 }, visible: "ok" },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const { received } = await runWrap(middleware, request);
|
|
193
|
+
const body =
|
|
194
|
+
typeof received.systemPrompt?.content === "string"
|
|
195
|
+
? received.systemPrompt.content
|
|
196
|
+
: "";
|
|
197
|
+
|
|
198
|
+
expect(body).not.toContain('"_internal"');
|
|
199
|
+
expect(body).toContain('"visible"');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it.each([null, undefined, "", [], {}])(
|
|
203
|
+
"skips keys with empty value %p",
|
|
204
|
+
async (emptyValue) => {
|
|
205
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
206
|
+
const request = makeRequest({
|
|
207
|
+
state: { messages: [], filled: ["x"], blank: emptyValue },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { received } = await runWrap(middleware, request);
|
|
211
|
+
if (received.systemPrompt == null) return; // acceptable: nothing left
|
|
212
|
+
|
|
213
|
+
const body =
|
|
214
|
+
typeof received.systemPrompt.content === "string"
|
|
215
|
+
? received.systemPrompt.content
|
|
216
|
+
: "";
|
|
217
|
+
expect(body).toContain('"filled"');
|
|
218
|
+
expect(body).not.toContain('"blank"');
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
it("emits no system prompt when only reserved keys are present", async () => {
|
|
223
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
224
|
+
const request = makeRequest({
|
|
225
|
+
state: { messages: [new HumanMessage("hi")], tools: [] },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const { received } = await runWrap(middleware, request);
|
|
229
|
+
|
|
230
|
+
expect(received.systemPrompt).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("only includes named keys when given an allowlist", async () => {
|
|
234
|
+
const middleware = createCopilotkitMiddleware({ exposeState: ["liked"] });
|
|
235
|
+
const request = makeRequest({
|
|
236
|
+
state: {
|
|
237
|
+
messages: [],
|
|
238
|
+
liked: ["a"],
|
|
239
|
+
todos: [{ id: 1 }],
|
|
240
|
+
other: "x",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const { received } = await runWrap(middleware, request);
|
|
245
|
+
const body =
|
|
246
|
+
typeof received.systemPrompt?.content === "string"
|
|
247
|
+
? received.systemPrompt.content
|
|
248
|
+
: "";
|
|
249
|
+
|
|
250
|
+
expect(body).toContain('"liked"');
|
|
251
|
+
expect(body).not.toContain('"todos"');
|
|
252
|
+
expect(body).not.toContain('"other"');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("honors an allowlist that explicitly names a normally-reserved key", async () => {
|
|
256
|
+
const middleware = createCopilotkitMiddleware({
|
|
257
|
+
exposeState: ["thread_id"],
|
|
258
|
+
});
|
|
259
|
+
const request = makeRequest({
|
|
260
|
+
state: { messages: [], thread_id: "t-42" },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const { received } = await runWrap(middleware, request);
|
|
264
|
+
const body =
|
|
265
|
+
typeof received.systemPrompt?.content === "string"
|
|
266
|
+
? received.systemPrompt.content
|
|
267
|
+
: "";
|
|
268
|
+
|
|
269
|
+
expect(body).toContain("t-42");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("appends to an existing string systemPrompt without replacing it", async () => {
|
|
273
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
274
|
+
const request = makeRequest({
|
|
275
|
+
state: { messages: [], liked: ["a"] },
|
|
276
|
+
systemPrompt: "You are a helpful assistant.",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { received } = await runWrap(middleware, request);
|
|
280
|
+
const body =
|
|
281
|
+
typeof received.systemPrompt.content === "string"
|
|
282
|
+
? received.systemPrompt.content
|
|
283
|
+
: "";
|
|
284
|
+
|
|
285
|
+
expect(body).toContain("You are a helpful assistant.");
|
|
286
|
+
expect(body).toContain("Current agent state:");
|
|
287
|
+
expect(body.indexOf("You are a helpful assistant.")).toBeLessThan(
|
|
288
|
+
body.indexOf("Current agent state:"),
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("appends to an existing SystemMessage systemPrompt without replacing it", async () => {
|
|
293
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
294
|
+
const request = makeRequest({
|
|
295
|
+
state: { messages: [], liked: ["a"] },
|
|
296
|
+
systemPrompt: new SystemMessage({ content: "base" }),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const { received } = await runWrap(middleware, request);
|
|
300
|
+
const body =
|
|
301
|
+
typeof received.systemPrompt.content === "string"
|
|
302
|
+
? received.systemPrompt.content
|
|
303
|
+
: "";
|
|
304
|
+
|
|
305
|
+
expect(body).toContain("base");
|
|
306
|
+
expect(body).toContain("Current agent state:");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("keeps state hidden when explicitly disabled", async () => {
|
|
310
|
+
const middleware = createCopilotkitMiddleware({ exposeState: false });
|
|
311
|
+
const request = makeRequest({
|
|
312
|
+
state: { messages: [], liked: ["a"] },
|
|
313
|
+
systemPrompt: "base",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const { received } = await runWrap(middleware, request);
|
|
317
|
+
|
|
318
|
+
expect(received.systemPrompt).toBe("base");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("emits a parseable JSON snapshot in the note", async () => {
|
|
322
|
+
const middleware = createCopilotkitMiddleware({ exposeState: true });
|
|
323
|
+
const request = makeRequest({
|
|
324
|
+
state: {
|
|
325
|
+
messages: [],
|
|
326
|
+
liked: ["a", "b"],
|
|
327
|
+
count: 3,
|
|
328
|
+
nested: { k: "v" },
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const { received } = await runWrap(middleware, request);
|
|
333
|
+
const body =
|
|
334
|
+
typeof received.systemPrompt.content === "string"
|
|
335
|
+
? received.systemPrompt.content
|
|
336
|
+
: "";
|
|
337
|
+
|
|
338
|
+
const jsonPart = body.split("Current agent state:\n")[1];
|
|
339
|
+
expect(JSON.parse(jsonPart)).toEqual({
|
|
340
|
+
liked: ["a", "b"],
|
|
341
|
+
count: 3,
|
|
342
|
+
nested: { k: "v" },
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// beforeAgent — App Context injection
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
describe("beforeAgent", () => {
|
|
352
|
+
it("returns no update when context is empty", () => {
|
|
353
|
+
const state = {
|
|
354
|
+
messages: [new HumanMessage("hi")],
|
|
355
|
+
copilotkit: { context: [] },
|
|
356
|
+
};
|
|
357
|
+
const result = copilotkitMiddleware.beforeAgent(state, {} as any);
|
|
358
|
+
expect(result).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("injects an App Context SystemMessage into the message list", () => {
|
|
362
|
+
const state = {
|
|
363
|
+
messages: [new HumanMessage("hi")],
|
|
364
|
+
copilotkit: {
|
|
365
|
+
context: [{ description: "viewer role", value: "admin" }],
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const result = copilotkitMiddleware.beforeAgent(state, {} as any);
|
|
370
|
+
|
|
371
|
+
expect(result).toBeDefined();
|
|
372
|
+
const sys = systemContents(result!.messages);
|
|
373
|
+
expect(sys.some((s) => s.startsWith("App Context:"))).toBe(true);
|
|
374
|
+
expect(sys.some((s) => s.includes("admin"))).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("uses runtime.context when state.copilotkit.context is missing", () => {
|
|
378
|
+
const state = {
|
|
379
|
+
messages: [new HumanMessage("hi")],
|
|
380
|
+
copilotkit: {},
|
|
381
|
+
};
|
|
382
|
+
const runtime = { context: "route=/dashboard" };
|
|
383
|
+
|
|
384
|
+
const result = copilotkitMiddleware.beforeAgent(state, runtime as any);
|
|
385
|
+
|
|
386
|
+
const sys = systemContents(result!.messages);
|
|
387
|
+
expect(sys.some((s) => s.includes("/dashboard"))).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("does not duplicate the App Context message across re-runs", () => {
|
|
391
|
+
const state = {
|
|
392
|
+
messages: [new HumanMessage("hi")],
|
|
393
|
+
copilotkit: { context: [{ description: "k", value: "v" }] },
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const first = copilotkitMiddleware.beforeAgent(state, {} as any) ?? state;
|
|
397
|
+
const second = copilotkitMiddleware.beforeAgent(first, {} as any) ?? first;
|
|
398
|
+
|
|
399
|
+
const appContextMessages = systemContents(second.messages).filter((s) =>
|
|
400
|
+
s.startsWith("App Context:"),
|
|
401
|
+
);
|
|
402
|
+
expect(appContextMessages).toHaveLength(1);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// afterModel — frontend tool-call interception
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe("afterModel", () => {
|
|
411
|
+
it("is a no-op when there are no frontend tools", () => {
|
|
412
|
+
const state = {
|
|
413
|
+
messages: [
|
|
414
|
+
new HumanMessage("hi"),
|
|
415
|
+
new AIMessage({
|
|
416
|
+
content: "",
|
|
417
|
+
tool_calls: [{ id: "1", name: "backend_only", args: {} }],
|
|
418
|
+
}),
|
|
419
|
+
],
|
|
420
|
+
copilotkit: { actions: [] },
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
expect(copilotkitMiddleware.afterModel(state, {} as any)).toBeUndefined();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("peels frontend tool calls off the last AIMessage and stashes them", () => {
|
|
427
|
+
const fe = { function: { name: "navigate" } };
|
|
428
|
+
const ai = new AIMessage({
|
|
429
|
+
content: "",
|
|
430
|
+
tool_calls: [
|
|
431
|
+
{ id: "1", name: "backend_search", args: { q: "hi" } },
|
|
432
|
+
{ id: "2", name: "navigate", args: { path: "/x" } },
|
|
433
|
+
],
|
|
434
|
+
id: "ai-1",
|
|
435
|
+
});
|
|
436
|
+
const state = {
|
|
437
|
+
messages: [new HumanMessage("hi"), ai],
|
|
438
|
+
copilotkit: { actions: [fe] },
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const result = copilotkitMiddleware.afterModel(state, {} as any);
|
|
442
|
+
|
|
443
|
+
expect(result).toBeDefined();
|
|
444
|
+
const lastAI = result!.messages[result!.messages.length - 1] as AIMessage;
|
|
445
|
+
expect(lastAI.tool_calls?.map((tc: any) => tc.name)).toEqual([
|
|
446
|
+
"backend_search",
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
const intercepted = result!.copilotkit.interceptedToolCalls;
|
|
450
|
+
expect(intercepted).toHaveLength(1);
|
|
451
|
+
expect(intercepted[0].id).toBe("2");
|
|
452
|
+
expect(intercepted[0].name).toBe("navigate");
|
|
453
|
+
expect(result!.copilotkit.originalAIMessageId).toBe("ai-1");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// afterAgent — frontend tool-call restoration
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
describe("afterAgent", () => {
|
|
462
|
+
it("returns no update when there is nothing intercepted", () => {
|
|
463
|
+
const state = {
|
|
464
|
+
messages: [
|
|
465
|
+
new HumanMessage("hi"),
|
|
466
|
+
new AIMessage({ content: "ok", id: "ai-1" }),
|
|
467
|
+
],
|
|
468
|
+
copilotkit: {},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
expect(copilotkitMiddleware.afterAgent(state, {} as any)).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("restores intercepted tool calls onto the original AIMessage", () => {
|
|
475
|
+
const intercepted = [{ id: "2", name: "navigate", args: { path: "/x" } }];
|
|
476
|
+
const state = {
|
|
477
|
+
messages: [
|
|
478
|
+
new HumanMessage("hi"),
|
|
479
|
+
new AIMessage({ content: "", id: "ai-1" }),
|
|
480
|
+
],
|
|
481
|
+
copilotkit: {
|
|
482
|
+
interceptedToolCalls: intercepted,
|
|
483
|
+
originalAIMessageId: "ai-1",
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const result = copilotkitMiddleware.afterAgent(state, {} as any);
|
|
488
|
+
|
|
489
|
+
expect(result).toBeDefined();
|
|
490
|
+
const restored = result!.messages.find(
|
|
491
|
+
(m: any) => m.id === "ai-1" && m._getType?.() === "ai",
|
|
492
|
+
) as AIMessage;
|
|
493
|
+
expect(restored.tool_calls?.map((tc: any) => tc.name)).toEqual([
|
|
494
|
+
"navigate",
|
|
495
|
+
]);
|
|
496
|
+
expect(result!.copilotkit.interceptedToolCalls).toBeUndefined();
|
|
497
|
+
expect(result!.copilotkit.originalAIMessageId).toBeUndefined();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// zodState — Standard-Schema JSON-schema augmentation
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
//
|
|
505
|
+
// Contract: a Zod v4 schema only carries `~standard.validate` + `vendor`, so
|
|
506
|
+
// LangGraph's `isStandardJSONSchema()` returns false and the field is dropped
|
|
507
|
+
// from `output_schema`. `zodState` patches `~standard.jsonSchema.input` onto
|
|
508
|
+
// the schema so the field survives serialization and reaches the frontend
|
|
509
|
+
// via AG-UI `STATE_SNAPSHOT` events.
|
|
510
|
+
|
|
511
|
+
type JsonSchema = {
|
|
512
|
+
type?: string;
|
|
513
|
+
properties?: Record<string, unknown>;
|
|
514
|
+
};
|
|
515
|
+
type StdJson = { jsonSchema?: { input: () => JsonSchema } };
|
|
516
|
+
const standardOf = (s: object): StdJson | undefined =>
|
|
517
|
+
(s as { "~standard"?: StdJson })["~standard"];
|
|
518
|
+
const hasZodV4ToJsonSchema = (): boolean =>
|
|
519
|
+
typeof (z as { toJSONSchema?: unknown }).toJSONSchema === "function";
|
|
520
|
+
|
|
521
|
+
describe("zodState", () => {
|
|
522
|
+
it("attaches a ~standard.jsonSchema.input hook to a Zod schema", () => {
|
|
523
|
+
const schema = z.object({ name: z.string() });
|
|
524
|
+
expect(standardOf(schema)?.jsonSchema).toBeUndefined();
|
|
525
|
+
|
|
526
|
+
const wrapped = zodState(schema);
|
|
527
|
+
|
|
528
|
+
const std = standardOf(wrapped);
|
|
529
|
+
expect(std?.jsonSchema).toBeDefined();
|
|
530
|
+
expect(typeof std?.jsonSchema?.input).toBe("function");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("returns a plain object from input() (real JSON Schema when zod v4 is available, else `{}`)", () => {
|
|
534
|
+
// The contract langgraph cares about is just "input() returns an
|
|
535
|
+
// object" — `isStandardJSONSchema()` doesn't introspect the shape.
|
|
536
|
+
// When zod v4's `toJSONSchema` is present we get the real schema;
|
|
537
|
+
// otherwise we get `{}`, which is still enough for the field to
|
|
538
|
+
// appear in `output_schema` (langgraph-api treats it as opaque).
|
|
539
|
+
const schema = z.object({ todos: z.array(z.string()) });
|
|
540
|
+
const wrapped = zodState(schema);
|
|
541
|
+
|
|
542
|
+
const json = standardOf(wrapped)?.jsonSchema?.input();
|
|
543
|
+
|
|
544
|
+
expect(json).toBeTypeOf("object");
|
|
545
|
+
expect(json).not.toBeNull();
|
|
546
|
+
if (hasZodV4ToJsonSchema()) {
|
|
547
|
+
expect(json?.type).toBe("object");
|
|
548
|
+
expect(json?.properties?.todos).toBeDefined();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("caches the JSON Schema across calls (input() returns the same object)", () => {
|
|
553
|
+
const schema = z.object({ x: z.string() });
|
|
554
|
+
const wrapped = zodState(schema);
|
|
555
|
+
const std = standardOf(wrapped);
|
|
556
|
+
|
|
557
|
+
const a = std?.jsonSchema?.input();
|
|
558
|
+
const b = std?.jsonSchema?.input();
|
|
559
|
+
|
|
560
|
+
expect(a).toBe(b);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("does not overwrite an existing jsonSchema hook", () => {
|
|
564
|
+
const schema = z.object({ x: z.string() });
|
|
565
|
+
const preset: StdJson["jsonSchema"] = { input: () => ({}) };
|
|
566
|
+
const std = standardOf(schema);
|
|
567
|
+
expect(std).toBeDefined();
|
|
568
|
+
std!.jsonSchema = preset;
|
|
569
|
+
|
|
570
|
+
zodState(schema);
|
|
571
|
+
|
|
572
|
+
expect(standardOf(schema)?.jsonSchema).toBe(preset);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("returns the same reference (mutates rather than copies)", () => {
|
|
576
|
+
const schema = z.object({ x: z.string() });
|
|
577
|
+
expect(zodState(schema)).toBe(schema);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("is a no-op for a value without ~standard metadata", () => {
|
|
581
|
+
const plain = { foo: "bar" };
|
|
582
|
+
expect(() => zodState(plain)).not.toThrow();
|
|
583
|
+
expect(zodState(plain)).toBe(plain);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("works on optional / array / default-wrapped schemas (state-field shapes)", () => {
|
|
587
|
+
const todos = zodState(
|
|
588
|
+
z.array(z.object({ id: z.string(), text: z.string() })).default(() => []),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const std = standardOf(todos);
|
|
592
|
+
expect(std).toBeDefined();
|
|
593
|
+
expect(std?.jsonSchema).toBeDefined();
|
|
594
|
+
const json = std?.jsonSchema?.input();
|
|
595
|
+
expect(json).toBeTypeOf("object");
|
|
596
|
+
if (hasZodV4ToJsonSchema()) {
|
|
597
|
+
expect(json?.type).toBe("array");
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("makes the wrapped field pass a StandardJSONSchemaV1-style probe", () => {
|
|
602
|
+
// Mirrors what LangGraph's isStandardJSONSchema() looks for: an `input`
|
|
603
|
+
// function on `~standard.jsonSchema` that returns a plain object.
|
|
604
|
+
const schema = zodState(z.object({ liked: z.array(z.string()) }));
|
|
605
|
+
const std = standardOf(schema);
|
|
606
|
+
|
|
607
|
+
expect(std?.jsonSchema).toBeDefined();
|
|
608
|
+
expect(typeof std?.jsonSchema?.input).toBe("function");
|
|
609
|
+
expect(typeof std?.jsonSchema?.input()).toBe("object");
|
|
610
|
+
});
|
|
611
|
+
});
|