-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathcopilot_request_cancel_error.e2e.test.ts
More file actions
184 lines (167 loc) · 6.66 KB
/
Copy pathcopilot_request_cancel_error.e2e.test.ts
File metadata and controls
184 lines (167 loc) · 6.66 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
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, it } from "vitest";
import { approveAll, CopilotRequestHandler, type CopilotRequestContext } from "../../src/index.js";
import { createSdkTestContext } from "./harness/sdkTestContext.js";
/**
* Cancellation and error coverage for {@link CopilotRequestHandler}. These two
* scenarios exercise the handler's terminal paths that the happy-path session-id
* and HTTP/WebSocket tests never reach:
*
* - **Error** — the handler throws from {@link CopilotRequestHandler.sendRequest}
* for an inference request. The base adapter reports a transport error back to
* the runtime (`errorResponse`) rather than hanging.
* - **Runtime cancel** — the handler blocks an inference request indefinitely;
* when the consumer aborts the turn the runtime cancels the in-flight request,
* firing `ctx.signal`. The handler observes the abort (the `cancel`-frame
* path) instead of leaking a stuck request.
*
* Non-inference model-layer requests (catalog, policy, model session) are served
* with minimal stubs so the turn reaches the inference step. The success-path
* SSE body is intentionally omitted — neither scenario completes a turn.
*/
function isInferenceUrl(url: string): boolean {
const u = url.toLowerCase();
return (
u.endsWith("/chat/completions") ||
u.endsWith("/responses") ||
u.endsWith("/v1/messages") ||
u.endsWith("/messages")
);
}
function json(body: string): Response {
return new Response(body, { status: 200, headers: { "content-type": "application/json" } });
}
/** Serve the non-inference GETs/POSTs (catalog, policy, model session). */
function serveNonInference(url: string): Response {
const u = url.toLowerCase();
if (u.endsWith("/models")) {
return json(MODEL_CATALOG_JSON);
}
if (u.includes("/models/session")) {
return json("{}");
}
if (u.includes("/policy")) {
return json(JSON.stringify({ state: "enabled" }));
}
return json("{}");
}
const MODEL_CATALOG_JSON = JSON.stringify({
data: [
{
id: "claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
object: "model",
vendor: "Anthropic",
version: "1",
preview: false,
model_picker_enabled: true,
capabilities: {
type: "chat",
family: "claude-sonnet-4.5",
tokenizer: "o200k_base",
limits: { max_context_window_tokens: 200000, max_output_tokens: 8192 },
supports: {
streaming: true,
tool_calls: true,
parallel_tool_calls: true,
vision: true,
},
},
},
],
});
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error("waitFor timed out");
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/** Throws from every inference request to exercise the error-reporting path. */
class ThrowingRequestHandler extends CopilotRequestHandler {
inferenceAttempts = 0;
protected override async sendRequest(
request: Request,
_ctx: CopilotRequestContext
): Promise<Response> {
if (!isInferenceUrl(request.url)) {
return serveNonInference(request.url);
}
this.inferenceAttempts++;
throw new Error("synthetic-callback-transport-failure");
}
}
/** Blocks every inference request until the runtime cancels it. */
class CancellingRequestHandler extends CopilotRequestHandler {
inferenceEntered = false;
sawAbort = false;
protected override async sendRequest(
request: Request,
ctx: CopilotRequestContext
): Promise<Response> {
if (!isInferenceUrl(request.url)) {
return serveNonInference(request.url);
}
this.inferenceEntered = true;
await new Promise<void>((resolve) => {
if (ctx.signal.aborted) {
resolve();
return;
}
ctx.signal.addEventListener("abort", () => resolve(), { once: true });
});
this.sawAbort = true;
// The runtime already dropped the request; throwing simply propagates
// the abort out of the (here, simulated) upstream call.
throw new Error("cancelled by runtime");
}
}
describe("CopilotRequestHandler surfaces inference errors", async () => {
const handler = new ThrowingRequestHandler();
const { copilotClient: client } = await createSdkTestContext({
copilotClientOptions: { requestHandler: handler },
});
it("reports a thrown callback error instead of hanging the turn", async () => {
await client.start();
const session = await client.createSession({ onPermissionRequest: approveAll });
try {
// The callback throws on inference; the turn surfaces an error (or
// completes without an assistant message) rather than hanging.
await session.sendAndWait({ prompt: "Say OK." }).catch(() => undefined);
} finally {
await session.disconnect();
}
expect(
handler.inferenceAttempts,
"expected the inference callback to be reached and raise"
).toBeGreaterThan(0);
}, 90_000);
});
describe("CopilotRequestHandler observes runtime cancellation", async () => {
const handler = new CancellingRequestHandler();
const { copilotClient: client } = await createSdkTestContext({
copilotClientOptions: { requestHandler: handler },
});
it("fires ctx.signal when the consumer aborts an in-flight inference request", async () => {
await client.start();
const session = await client.createSession({ onPermissionRequest: approveAll });
try {
await session.send("Say OK.");
await waitFor(() => handler.inferenceEntered, 60_000);
await session.abort();
await waitFor(() => handler.sawAbort, 30_000);
} finally {
await session.disconnect();
}
expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe(
true
);
expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe(
true
);
}, 90_000);
});