-
Notifications
You must be signed in to change notification settings - Fork 125
Expand file tree
/
Copy pathinterrupt.test.ts
More file actions
332 lines (302 loc) · 10.3 KB
/
Copy pathinterrupt.test.ts
File metadata and controls
332 lines (302 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
import { EventType } from "@ag-ui/client";
import { createRunRenderer } from "@copilotkit/channels/slack/render";
import { describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import { parseInterrupt } from "./interrupt.js";
const realEnvelope = {
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: {
action: "Create Linear issue",
detail: "CPK-9: Checkout 500s",
},
},
__copilotkit_messages__: [
{
content: "",
type: "ai",
tool_calls: [
{
id: "tool-confirm-write",
name: "confirm_write",
args: {
action: "Create Linear issue",
detail: "CPK-9: Checkout 500s",
},
type: "tool_call",
},
],
},
],
};
/**
* Parse one payload and insist it was the approval card's own interrupt.
*
* `parseInterrupt` answers with a kind, because a graph pausing for something
* else is a different request rather than a broken approval. Everything below
* that is about the card asserts the kind first, so a test cannot go on reading
* `args` off a result that never was one.
*/
function confirmWrite(payload: unknown) {
const parsed = parseInterrupt(payload);
if (parsed.kind !== "confirm_write") {
throw new Error(
`expected a confirm_write interrupt, got "${parsed.action}"`,
);
}
return parsed;
}
describe("parseInterrupt", () => {
it("parses ag_ui_langgraph's JSON-stringified interrupt envelope", () => {
expect(confirmWrite(JSON.stringify(realEnvelope))).toEqual({
kind: "confirm_write",
args: realEnvelope.__copilotkit_interrupt_value__.args,
});
});
it("parses the object produced by the canary Slack renderer", () => {
const renderer = createRunRenderer({
transport: {
setStatus: vi.fn(async () => undefined),
postMessage: vi.fn(async () => ({ ts: "1.0" })),
updateMessage: vi.fn(async () => undefined),
},
target: { channel: "C1", threadTs: "1.0" },
});
renderer.subscriber.onCustomEvent?.({
event: {
type: EventType.CUSTOM,
name: "on_interrupt",
value: JSON.stringify(realEnvelope),
},
} as never);
const renderedPayload = renderer.getPendingInterrupt()?.value;
expect(renderedPayload).toEqual(realEnvelope);
expect(confirmWrite(renderedPayload)).toEqual({
kind: "confirm_write",
args: realEnvelope.__copilotkit_interrupt_value__.args,
});
});
it("parses the fields the agent sends for the confirmation table", () => {
const fields = [
{ label: "Name", value: "OpenTag" },
{ label: "Description", value: "Project for OpenTag work." },
];
expect(
confirmWrite(
JSON.stringify({
...realEnvelope,
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: { action: "Save project", fields },
},
}),
).args.fields,
).toEqual(fields);
});
it("rejects fields that are not label/value pairs", () => {
expect(() =>
confirmWrite(
JSON.stringify({
...realEnvelope,
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: { action: "Save project", fields: [{ label: "Name" }] },
},
}),
),
).toThrow();
});
it("parses the retry context the agent adds to a re-asked write", () => {
const args = confirmWrite(
JSON.stringify({
...realEnvelope,
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: {
action: "Save project",
fields: [{ label: "Name", value: "OpenTag" }],
attempt: 2,
previous_error: 'Team "Growth" not found',
},
},
}),
).args;
expect(args.attempt).toBe(2);
expect(args.previous_error).toBe('Team "Growth" not found');
});
it("accepts a first attempt with no retry context", () => {
const args = confirmWrite(
JSON.stringify(realEnvelope),
).args;
expect(args.attempt).toBeUndefined();
expect(args.previous_error).toBeUndefined();
});
it("rejects a nonsensical attempt number", () => {
for (const attempt of [0, -1, 1.5]) {
expect(() =>
confirmWrite(
JSON.stringify({
...realEnvelope,
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: { action: "Save project", attempt },
},
}),
),
).toThrow();
}
});
it("reads an interrupt that is not an approval as unsupported, not as broken", () => {
// The card is not the only thing a graph can pause for. Reported as a
// failed approval, a request this surface has no handler for sends the
// reader looking for a card that was never asked for — and for the write
// they think it was gating.
expect(
parseInterrupt(
JSON.stringify({
...realEnvelope,
__copilotkit_interrupt_value__: {
action: "delete_without_confirmation",
args: { action: "Delete everything" },
},
}),
),
).toEqual({ kind: "unsupported", action: "delete_without_confirmation" });
});
it("does not read the arguments of a request it has no handler for", () => {
// Nothing here can say what those arguments mean, and the caller quotes
// only what this returns. An `args` shape nobody validated is not a thing
// to hand a thread.
expect(
parseInterrupt({
__copilotkit_interrupt_value__: {
action: "connect_account",
args: { anything: ["at", "all"] },
},
}),
).toEqual({ kind: "unsupported", action: "connect_account" });
});
it("still throws on an envelope it cannot read at all", () => {
expect(() => parseInterrupt("{broken")).toThrow();
expect(() => parseInterrupt({ nothing: "here" })).toThrow();
});
});
function interruptPayload(action: string, args: unknown) {
return {
__copilotkit_interrupt_value__: { action, args },
__copilotkit_messages__: [],
};
}
describe("parseInterrupt approver", () => {
it("carries the approver through when one is named", () => {
const { args } = confirmWrite(
interruptPayload("confirm_write", {
action: "Gmail send email",
approver: "slack:U1",
effect: "write",
}),
);
expect(args.approver).toBe("slack:U1");
});
it("accepts a null approver, which is how a workspace action arrives", () => {
const { args } = confirmWrite(
interruptPayload("confirm_write", {
action: "Create issue",
approver: null,
}),
);
expect(args.approver ?? undefined).toBeUndefined();
});
it("accepts an explicitly null fields, which is how the agent says none", () => {
// Every other optional key on this card is nullish, and the producer sends
// explicit nulls. A schema that only tolerates `undefined` throws inside
// the interrupt handler, and the card is never posted at all — the graph
// waits for an answer to a question nobody was ever asked.
const { args } = confirmWrite(
interruptPayload("confirm_write", {
action: "Save project",
fields: null,
attempt: null,
previous_error: null,
}),
);
expect(args.fields ?? undefined).toBeUndefined();
expect(args.attempt ?? undefined).toBeUndefined();
});
it("carries the classified effect through to the card", () => {
const { args } = confirmWrite(
interruptPayload("confirm_write", {
action: "Gmail delete draft",
effect: "destructive",
}),
);
expect(args.effect).toBe("destructive");
});
it("still accepts a payload from an agent revision predating the approver", () => {
const { args } = confirmWrite(
interruptPayload("confirm_write", { action: "Create issue" }),
);
expect(args.action).toBe("Create issue");
});
});
describe("parseInterrupt fail-safe", () => {
it("reads the three effects the agent classifies", () => {
for (const effect of ["read", "write", "destructive"] as const) {
expect(
confirmWrite(
interruptPayload("confirm_write", { action: "Do it", effect }),
).args.effect,
).toBe(effect);
}
});
it("reads an effect outside that vocabulary as destructive", () => {
// `EffectMap.effect_for` answers `destructive` for anything it cannot
// classify. A word this schema does not know is the same situation one hop
// later, and the card must not be handed a value it will render neutral.
expect(
confirmWrite(
interruptPayload("confirm_write", {
action: "Do it",
effect: "purge",
}),
).args.effect,
).toBe("destructive");
});
it("throws one shape for bad JSON, not two for the same contract", () => {
// The renderer's contract is a ZodError. A raw SyntaxError from an
// unguarded `JSON.parse` is a second throw shape for the same failure, and
// the handler that has to tell them apart cannot.
let thrown: unknown;
try {
parseInterrupt("{broken");
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ZodError);
expect(thrown).not.toBeInstanceOf(SyntaxError);
});
it("posts the card when the agent sends no message history", () => {
// `__copilotkit_messages__` is never read here. Requiring it means a
// producer that omits it kills the card, and the graph waits on a question
// nobody was asked.
const { args } = confirmWrite({
__copilotkit_interrupt_value__: {
action: "confirm_write",
args: { action: "Create issue" },
},
});
expect(args.action).toBe("Create issue");
});
});
describe("interrupt correlation", () => {
it("preserves the trusted outer LangGraph ID", () => {
const id = "0123456789abcdef0123456789abcdef";
expect(confirmWrite({ ...realEnvelope, __opentag_interrupt_id__: id }).interruptId).toBe(id);
});
it("leaves an older agent's missing ID absent for the Channel to refuse", () => {
expect(confirmWrite(realEnvelope).interruptId).toBeUndefined();
});
it.each([null, "", "abc", "0123456789ABCDEF0123456789ABCDEF", "confirmed", 5])("rejects malformed ID %s", (id) => {
expect(() => confirmWrite({ ...realEnvelope, __opentag_interrupt_id__: id })).toThrow(ZodError);
});
});