Skip to content

Commit f812ac2

Browse files
Add SDK e2e asserting sessionId reaches the LLM callback (CAPI + BYOK)
Drives a CAPI session and a BYOK (openai/responses) session entirely through the LLM inference callback — the consumer fabricates every model-layer response, so the CAPI record/replay proxy is never the inference endpoint. Asserts each in-session inference request carries req.sessionId === session.sessionId and that the two session ids differ. The mock branches /responses on the request stream flag: BYOK turns whose config-derived model does not advertise streaming issue a buffered (non-streaming) /responses request expecting a single JSON response object, whereas the CAPI turn streams via SSE. This mirrors real upstream behaviour and confirms the callback transport faithfully delivers both shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b21e42b commit f812ac2

1 file changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import { describe, expect, it } from "vitest";
6+
import { approveAll, type LlmInferenceRequest } from "../../src/index.js";
7+
import { createSdkTestContext } from "./harness/sdkTestContext.js";
8+
9+
const SYNTHETIC_TEXT = "OK from the synthetic stream.";
10+
11+
async function drainRequest(req: LlmInferenceRequest): Promise<string> {
12+
const parts: Buffer[] = [];
13+
for await (const chunk of req.requestBody) {
14+
parts.push(Buffer.from(chunk));
15+
}
16+
return Buffer.concat(parts).toString("utf-8");
17+
}
18+
19+
async function respondBuffered(
20+
req: LlmInferenceRequest,
21+
init: { status: number; headers?: Record<string, string[]> },
22+
body: string
23+
): Promise<void> {
24+
await drainRequest(req);
25+
await req.responseBody.start(init);
26+
if (body.length > 0) {
27+
await req.responseBody.write(body);
28+
}
29+
await req.responseBody.end();
30+
}
31+
32+
/**
33+
* Serve the model-layer GETs/POSTs the runtime issues that are not
34+
* inference (catalog, model session, policy). These flow through the same
35+
* callback but carry no session id (they happen outside an agent turn).
36+
*/
37+
async function handleNonInferenceModelTraffic(req: LlmInferenceRequest): Promise<void> {
38+
const url = req.url.toLowerCase();
39+
if (url.endsWith("/models")) {
40+
await respondBuffered(
41+
req,
42+
{ status: 200, headers: { "content-type": ["application/json"] } },
43+
JSON.stringify({
44+
data: [
45+
{
46+
id: "claude-sonnet-4.5",
47+
name: "Claude Sonnet 4.5",
48+
object: "model",
49+
vendor: "Anthropic",
50+
version: "1",
51+
preview: false,
52+
model_picker_enabled: true,
53+
capabilities: {
54+
type: "chat",
55+
family: "claude-sonnet-4.5",
56+
tokenizer: "o200k_base",
57+
limits: { max_context_window_tokens: 200000, max_output_tokens: 8192 },
58+
supports: {
59+
streaming: true,
60+
tool_calls: true,
61+
parallel_tool_calls: true,
62+
vision: true,
63+
},
64+
},
65+
},
66+
],
67+
})
68+
);
69+
return;
70+
}
71+
if (url.includes("/models/session")) {
72+
await respondBuffered(req, { status: 200, headers: {} }, "{}");
73+
return;
74+
}
75+
if (url.includes("/policy")) {
76+
await respondBuffered(req, { status: 200, headers: {} }, JSON.stringify({ state: "enabled" }));
77+
return;
78+
}
79+
await respondBuffered(req, { status: 200, headers: { "content-type": ["application/json"] } }, "{}");
80+
}
81+
82+
/**
83+
* Synthesize a well-formed inference response so the agent turn completes.
84+
* The runtime selects `/responses` for both the CAPI and BYOK sessions
85+
* here; `/chat/completions` is handled too for robustness. The consumer
86+
* fabricates the response directly — there is no upstream server and the
87+
* CAPI record/replay proxy is never the inference endpoint.
88+
*/
89+
async function handleInference(req: LlmInferenceRequest): Promise<void> {
90+
const bodyText = await drainRequest(req);
91+
const wantsStream = /"stream"\s*:\s*true/.test(bodyText);
92+
const url = req.url.toLowerCase();
93+
94+
// `/responses` streams via SSE only when the request asked for it
95+
// (`stream: true`). BYOK turns whose config-derived model doesn't
96+
// advertise streaming issue a buffered request expecting a single
97+
// JSON `response` object, so branch on the flag exactly as a real
98+
// upstream would.
99+
if (url.includes("/responses")) {
100+
if (!wantsStream) {
101+
await req.responseBody.start({
102+
status: 200,
103+
headers: { "content-type": ["application/json"] },
104+
});
105+
await req.responseBody.write(
106+
JSON.stringify({
107+
id: "resp_stub_1",
108+
object: "response",
109+
status: "completed",
110+
output: [
111+
{
112+
id: "msg_1",
113+
type: "message",
114+
role: "assistant",
115+
content: [{ type: "output_text", text: SYNTHETIC_TEXT }],
116+
},
117+
],
118+
usage: { input_tokens: 5, output_tokens: 7, total_tokens: 12 },
119+
})
120+
);
121+
await req.responseBody.end();
122+
return;
123+
}
124+
await req.responseBody.start({
125+
status: 200,
126+
headers: { "content-type": ["text/event-stream"] },
127+
});
128+
const id = "resp_stub_1";
129+
const events: string[] = [
130+
`event: response.created\ndata: ${JSON.stringify({
131+
type: "response.created",
132+
response: { id, object: "response", status: "in_progress", output: [] },
133+
})}\n\n`,
134+
`event: response.output_item.added\ndata: ${JSON.stringify({
135+
type: "response.output_item.added",
136+
output_index: 0,
137+
item: { id: "msg_1", type: "message", role: "assistant", content: [] },
138+
})}\n\n`,
139+
`event: response.content_part.added\ndata: ${JSON.stringify({
140+
type: "response.content_part.added",
141+
output_index: 0,
142+
content_index: 0,
143+
part: { type: "output_text", text: "" },
144+
})}\n\n`,
145+
`event: response.output_text.delta\ndata: ${JSON.stringify({
146+
type: "response.output_text.delta",
147+
output_index: 0,
148+
content_index: 0,
149+
delta: SYNTHETIC_TEXT,
150+
})}\n\n`,
151+
`event: response.output_text.done\ndata: ${JSON.stringify({
152+
type: "response.output_text.done",
153+
output_index: 0,
154+
content_index: 0,
155+
text: SYNTHETIC_TEXT,
156+
})}\n\n`,
157+
`event: response.completed\ndata: ${JSON.stringify({
158+
type: "response.completed",
159+
response: {
160+
id,
161+
object: "response",
162+
status: "completed",
163+
output: [
164+
{
165+
id: "msg_1",
166+
type: "message",
167+
role: "assistant",
168+
content: [{ type: "output_text", text: SYNTHETIC_TEXT }],
169+
},
170+
],
171+
usage: { input_tokens: 5, output_tokens: 7, total_tokens: 12 },
172+
},
173+
})}\n\n`,
174+
];
175+
for (const event of events) {
176+
await req.responseBody.write(event);
177+
}
178+
await req.responseBody.end();
179+
return;
180+
}
181+
182+
if (url.includes("/chat/completions") && wantsStream) {
183+
await req.responseBody.start({
184+
status: 200,
185+
headers: { "content-type": ["text/event-stream"] },
186+
});
187+
const base = { id: "chatcmpl-stub-1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5" };
188+
const events: string[] = [
189+
`data: ${JSON.stringify({
190+
...base,
191+
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
192+
})}\n\n`,
193+
`data: ${JSON.stringify({
194+
...base,
195+
choices: [{ index: 0, delta: { content: SYNTHETIC_TEXT }, finish_reason: null }],
196+
})}\n\n`,
197+
`data: ${JSON.stringify({
198+
...base,
199+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
200+
usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 },
201+
})}\n\n`,
202+
`data: [DONE]\n\n`,
203+
];
204+
for (const event of events) {
205+
await req.responseBody.write(event);
206+
}
207+
await req.responseBody.end();
208+
return;
209+
}
210+
211+
// /chat/completions non-streaming — buffered JSON.
212+
await req.responseBody.start({ status: 200, headers: { "content-type": ["application/json"] } });
213+
await req.responseBody.write(
214+
JSON.stringify({
215+
id: "chatcmpl-stub-1",
216+
object: "chat.completion",
217+
created: 1,
218+
model: "claude-sonnet-4.5",
219+
choices: [
220+
{ index: 0, message: { role: "assistant", content: SYNTHETIC_TEXT }, finish_reason: "stop" },
221+
],
222+
usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 },
223+
})
224+
);
225+
await req.responseBody.end();
226+
}
227+
228+
interface InterceptedRequest {
229+
url: string;
230+
sessionId?: string;
231+
}
232+
233+
function isInferenceUrl(url: string): boolean {
234+
const u = url.toLowerCase();
235+
return (
236+
u.endsWith("/chat/completions") ||
237+
u.endsWith("/responses") ||
238+
u.endsWith("/v1/messages") ||
239+
u.endsWith("/messages")
240+
);
241+
}
242+
243+
/**
244+
* Asserts the runtime threads its session id into the LLM inference
245+
* callback for BOTH a CAPI session and a BYOK session. The callback alone
246+
* services every model-layer request — no upstream server, no CAPI proxy
247+
* acting as the inference endpoint — so the only source of `req.sessionId`
248+
* is the runtime's own per-client threading.
249+
*/
250+
describe("LLM inference callback threads the runtime session id (CAPI + BYOK)", async () => {
251+
const records: InterceptedRequest[] = [];
252+
253+
const { copilotClient: client } = await createSdkTestContext({
254+
copilotClientOptions: {
255+
llmInference: {
256+
createLlmInferenceProvider: () => ({
257+
async onLlmRequest(req: LlmInferenceRequest): Promise<void> {
258+
records.push({ url: req.url, sessionId: req.sessionId });
259+
if (isInferenceUrl(req.url)) {
260+
await handleInference(req);
261+
} else {
262+
await handleNonInferenceModelTraffic(req);
263+
}
264+
},
265+
}),
266+
},
267+
},
268+
});
269+
270+
let capiSessionId: string | undefined;
271+
272+
it("threads the session id into a CAPI session's inference request", async () => {
273+
await client.start();
274+
const baseline = records.length;
275+
const session = await client.createSession({ onPermissionRequest: approveAll });
276+
capiSessionId = session.sessionId;
277+
let resultJson = "";
278+
try {
279+
const result = await session.sendAndWait({ prompt: "Say OK." });
280+
resultJson = JSON.stringify(result);
281+
} finally {
282+
await session.disconnect();
283+
}
284+
285+
const inference = records.slice(baseline).filter((r) => isInferenceUrl(r.url));
286+
expect(inference.length, "expected at least one intercepted inference request").toBeGreaterThan(0);
287+
for (const r of inference) {
288+
expect(r.sessionId, "CAPI inference request must carry the runtime session id").toBe(
289+
session.sessionId
290+
);
291+
}
292+
293+
// Validate the final assistant response arrived (guards against truncated captures)
294+
expect(resultJson).toMatch(/OK from the synthetic/);
295+
}, 90_000);
296+
297+
it("threads the session id into a BYOK session's inference request", async () => {
298+
await client.start();
299+
const baseline = records.length;
300+
const session = await client.createSession({
301+
onPermissionRequest: approveAll,
302+
// BYOK providers require an explicit model id.
303+
model: "claude-sonnet-4.5",
304+
provider: {
305+
type: "openai",
306+
wireApi: "responses",
307+
baseUrl: "https://byok.invalid/v1",
308+
apiKey: "byok-secret",
309+
modelId: "claude-sonnet-4.5",
310+
wireModel: "claude-sonnet-4.5",
311+
},
312+
});
313+
const byokSessionId = session.sessionId;
314+
let resultJson = "";
315+
try {
316+
const result = await session.sendAndWait({ prompt: "Say OK." });
317+
resultJson = JSON.stringify(result);
318+
} finally {
319+
await session.disconnect();
320+
}
321+
322+
const inference = records.slice(baseline).filter((r) => isInferenceUrl(r.url));
323+
expect(inference.length, "expected at least one intercepted BYOK inference request").toBeGreaterThan(0);
324+
for (const r of inference) {
325+
expect(r.sessionId, "BYOK inference request must carry the runtime session id").toBe(byokSessionId);
326+
}
327+
328+
// Session ids are per-session, so the two turns must differ — proves
329+
// we assert against a real, request-specific id, not a constant.
330+
expect(byokSessionId).not.toBe(capiSessionId);
331+
332+
// Validate the final assistant response arrived (guards against truncated captures)
333+
expect(resultJson).toMatch(/OK from the synthetic/);
334+
}, 90_000);
335+
});

0 commit comments

Comments
 (0)