-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathabort.e2e.test.ts
More file actions
242 lines (204 loc) · 9.15 KB
/
Copy pathabort.e2e.test.ts
File metadata and controls
242 lines (204 loc) · 9.15 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
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { approveAll, defineTool } from "../../src/index.js";
import { createSdkTestContext } from "./harness/sdkTestContext.js";
describe("Abort", async () => {
const { copilotClient: client } = await createSdkTestContext();
const TEST_TIMEOUT_MS = 120_000;
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
it("should abort during active streaming", { timeout: TEST_TIMEOUT_MS }, async () => {
const session = await client.createSession({
onPermissionRequest: approveAll,
streaming: true,
});
let firstDeltaResolve!: (value: void) => void;
const firstDeltaReceived = new Promise<void>((resolve) => {
firstDeltaResolve = resolve;
});
const events: { type: string }[] = [];
session.on((event) => {
events.push({ type: event.type });
if (event.type === "assistant.message_delta") {
firstDeltaResolve();
}
});
// Fire-and-forget — we'll abort before it finishes
void session.send({
prompt: "Write a very long essay about the history of computing, covering every decade from the 1940s to the 2020s in great detail.",
});
// Wait for at least one delta to arrive (proves streaming started)
await withTimeout(firstDeltaReceived, 60_000, "first assistant.message_delta");
const deltaEvents = events.filter((e) => e.type === "assistant.message_delta");
expect(deltaEvents.length).toBeGreaterThanOrEqual(1);
// Abort mid-stream
await session.abort();
// Session should be usable after abort. Wait for the specific recovery
// message rather than racing against a late idle from the aborted turn.
let recoveryResolve!: (content: string) => void;
const recoveryReceived = new Promise<string>((resolve) => {
recoveryResolve = resolve;
});
const unsubscribeRecovery = session.on((event) => {
if (event.type === "assistant.message") {
const content = event.data.content ?? "";
if (content.toLowerCase().includes("abort_recovery_ok")) {
recoveryResolve(content);
}
}
});
try {
await session.send({ prompt: "Say 'abort_recovery_ok'." });
const recoveryContent = await withTimeout(
recoveryReceived,
60_000,
"assistant.message containing abort_recovery_ok"
);
expect(recoveryContent.toLowerCase()).toContain("abort_recovery_ok");
} finally {
unsubscribeRecovery();
}
await session.disconnect();
});
it("should abort during active tool execution", { timeout: TEST_TIMEOUT_MS }, async () => {
let toolStartedResolve!: (value: string) => void;
const toolStarted = new Promise<string>((resolve) => {
toolStartedResolve = resolve;
});
let releaseToolResolve!: (value: string) => void;
const releaseTool = new Promise<string>((resolve) => {
releaseToolResolve = resolve;
});
let signalAbortedResolve!: () => void;
const signalAborted = new Promise<void>((resolve) => {
signalAbortedResolve = resolve;
});
const session = await client.createSession({
onPermissionRequest: approveAll,
tools: [
defineTool("slow_analysis", {
description: "A slow analysis tool that blocks until released",
parameters: z.object({
value: z.string().describe("Value to analyze"),
}),
handler: async ({ value }, { signal }) => {
toolStartedResolve(value);
if (signal.aborted) {
signalAbortedResolve();
} else {
signal.addEventListener("abort", () => signalAbortedResolve(), {
once: true,
});
}
return await releaseTool;
},
}),
],
});
// Fire-and-forget
void session.send({
prompt: "Use slow_analysis with value 'test_abort'. Wait for the result.",
});
// Wait for the tool to start executing
const toolValue = await withTimeout(toolStarted, 60_000, "slow_analysis start");
expect(toolValue).toBe("test_abort");
// Abort while the tool is running
await session.abort();
// The handler's AbortSignal should fire as a result of session.abort()
await withTimeout(signalAborted, 10_000, "tool handler AbortSignal");
// Release the tool so its task doesn't leak
releaseToolResolve("RELEASED_AFTER_ABORT");
// Session should be usable after abort — verify with a follow-up
let recoveryResolve!: (value: void) => void;
const recoveryReceived = new Promise<void>((resolve) => {
recoveryResolve = resolve;
});
session.on((event) => {
if (
event.type === "assistant.message" &&
event.data.content?.includes("tool_abort_recovery_ok")
) {
recoveryResolve();
}
});
void session.send({
prompt: "Say 'tool_abort_recovery_ok'.",
});
await withTimeout(recoveryReceived, 60_000, "tool abort recovery message");
await session.disconnect();
});
it(
"should cancel a single tool call via cancelToolCall",
{ timeout: TEST_TIMEOUT_MS },
async () => {
let toolCallIdResolve!: (value: string) => void;
const toolCallIdReady = new Promise<string>((resolve) => {
toolCallIdResolve = resolve;
});
let releaseToolResolve!: (value: string) => void;
const releaseTool = new Promise<string>((resolve) => {
releaseToolResolve = resolve;
});
let signalAbortedResolve!: () => void;
const signalAborted = new Promise<void>((resolve) => {
signalAbortedResolve = resolve;
});
const session = await client.createSession({
onPermissionRequest: approveAll,
tools: [
defineTool("slow_analysis", {
description: "A slow analysis tool that blocks until released",
parameters: z.object({
value: z.string().describe("Value to analyze"),
}),
handler: async ({ value: _value }, { signal, toolCallId }) => {
toolCallIdResolve(toolCallId);
if (signal.aborted) {
signalAbortedResolve();
} else {
signal.addEventListener("abort", () => signalAbortedResolve(), {
once: true,
});
}
return await releaseTool;
},
}),
],
});
// Fire-and-forget
void session.send({
prompt: "Use slow_analysis with value 'test_cancel'. Wait for the result.",
});
// Wait for the tool to start executing and capture its toolCallId
const toolCallId = await withTimeout(
toolCallIdReady,
60_000,
"slow_analysis toolCallId"
);
// Unknown toolCallIds return false
expect(session.cancelToolCall("nonexistent-tool-call-id")).toBe(false);
// Cancelling the in-flight tool call returns true and fires its signal
expect(session.cancelToolCall(toolCallId)).toBe(true);
await withTimeout(signalAborted, 10_000, "tool handler AbortSignal via cancelToolCall");
// A second cancel of the same (now-removed) call returns false
expect(session.cancelToolCall(toolCallId)).toBe(false);
// Release the tool so its task doesn't leak
releaseToolResolve("RELEASED_AFTER_CANCEL");
await session.disconnect();
}
);
});