返回 CodeWhale
api.test.ts
根目录 / extensions / vscode / src / test / api.test.ts
1 import assert from "node:assert/strict";
2 import * as http from "node:http";
3 import type { AddressInfo } from "node:net";
4 import { after, before, describe, it } from "node:test";
5 import {
6 answerUserInput,
7 ApiError,
8 checkConnection,
9 ConflictError,
10 getThreadDetail,
11 interruptTurn,
12 listThreadSummaries,
13 openEventStream,
14 resolveApproval,
15 startTurn,
16 steerTurn,
17 type ApiConfig,
18 type RuntimeEvent,
19 } from "../api";
20
21 describe("api client", () => {
22 let server: http.Server;
23 let baseUrl: string;
24 const seen: { path?: string; auth?: string; method?: string; body?: string } = {};
25
26 before(async () => {
27 server = http.createServer((request, response) => {
28 let body = "";
29 request.on("data", (chunk: Buffer) => {
30 body += chunk.toString("utf8");
31 });
32 request.on("end", () => {
33 seen.path = request.url;
34 seen.auth = request.headers.authorization;
35 seen.method = request.method;
36 seen.body = body;
37
38 if (request.url === "/health") {
39 response.writeHead(200, { "Content-Type": "application/json" });
40 response.end("{}");
41 return;
42 }
43 if (request.url === "/v1/runtime/info") {
44 response.writeHead(200, { "Content-Type": "application/json" });
45 response.end(JSON.stringify({ version: "0.9.12", auth_required: true }));
46 return;
47 }
48 if (request.url?.startsWith("/v1/threads/summary")) {
49 if (seen.auth !== "Bearer sekrit") {
50 response.writeHead(401, { "Content-Type": "application/json" });
51 response.end("{}");
52 return;
53 }
54 response.writeHead(200, { "Content-Type": "application/json" });
55 response.end(
56 JSON.stringify([
57 {
58 id: "thr_1",
59 title: "Implement chat",
60 preview: "Let me start…",
61 model: "deepseek-v4-pro",
62 mode: "agent",
63 branch: "main",
64 head: "abc1234",
65 dirty: true,
66 archived: false,
67 updated_at: "2026-06-06T05:43:00Z",
68 latest_turn_status: "completed",
69 },
70 ]),
71 );
72 return;
73 }
74 if (request.url === "/v1/threads/thr_1" && request.method === "GET") {
75 response.writeHead(200, { "Content-Type": "application/json" });
76 response.end(
77 JSON.stringify({
78 thread: { id: "thr_1", model: "deepseek-v4-pro", updated_at: "2026-06-06T05:43:00Z" },
79 turns: [{ id: "turn_1", status: "completed" }],
80 items: [
81 { id: "item_1", turn_id: "turn_1", kind: "user_message", status: "completed", summary: "hi" },
82 {
83 id: "item_2",
84 turn_id: "turn_1",
85 kind: "agent_message",
86 status: "completed",
87 summary: "**done**",
88 },
89 ],
90 latest_seq: 12,
91 pending_approvals: [
92 { id: "ap_1", turn_id: "turn_1", tool_name: "shell", description: "rm -rf /" },
93 ],
94 pending_user_inputs: [
95 {
96 id: "ui_1",
97 turn_id: "turn_1",
98 request: {
99 questions: [
100 {
101 header: "Approach",
102 id: "q1",
103 question: "Which way?",
104 options: [{ label: "Fast", description: "quick" }],
105 allow_free_text: true,
106 },
107 ],
108 },
109 },
110 ],
111 }),
112 );
113 return;
114 }
115 if (request.url === "/v1/threads/thr_1/turns" && request.method === "POST") {
116 if (!body.includes("operation_key")) {
117 response.writeHead(400, { "Content-Type": "application/json" });
118 response.end("{}");
119 return;
120 }
121 // The real contract: `start_thread_turn` answers 201 CREATED
122 // (crates/tui/src/runtime_api.rs:4613-4631).
123 response.writeHead(201, { "Content-Type": "application/json" });
124 response.end(
125 JSON.stringify({
126 thread: { id: "thr_1" },
127 turn: { id: "turn_2", status: "queued" },
128 }),
129 );
130 return;
131 }
132 // Every 2xx is a success, so pin the edges of the range as well.
133 const rangeMatch = /^\/v1\/threads\/thr_(200|201|202)\/turns$/.exec(request.url ?? "");
134 if (rangeMatch && request.method === "POST") {
135 response.writeHead(Number(rangeMatch[1]), { "Content-Type": "application/json" });
136 response.end(
137 JSON.stringify({
138 thread: { id: `thr_${rangeMatch[1]}` },
139 turn: { id: "turn_range", status: "queued" },
140 }),
141 );
142 return;
143 }
144 if (request.url === "/v1/threads/thr_500/turns") {
145 response.writeHead(500, { "Content-Type": "application/json" });
146 response.end(JSON.stringify({ error: { code: "internal", message: "engine exploded" } }));
147 return;
148 }
149 if (request.url === "/v1/threads/thr_401/turns") {
150 response.writeHead(401, { "Content-Type": "application/json" });
151 response.end(JSON.stringify({ error: { message: "missing bearer token" } }));
152 return;
153 }
154 if (request.url === "/v1/threads/thr_1/turns/turn_idle/interrupt") {
155 response.writeHead(409, { "Content-Type": "application/json" });
156 response.end(JSON.stringify({ error: { message: "No active turn for thread 'thr_1'" } }));
157 return;
158 }
159 if (request.url === "/v1/threads/thr_1/turns/turn_2/steer") {
160 response.writeHead(202, { "Content-Type": "application/json" });
161 response.end("{}");
162 return;
163 }
164 if (request.url === "/v1/threads/thr_1/turns/turn_2/interrupt") {
165 response.writeHead(202, { "Content-Type": "application/json" });
166 response.end("{}");
167 return;
168 }
169 if (request.url === "/v1/approvals/ap_1") {
170 response.writeHead(200, { "Content-Type": "application/json" });
171 response.end(JSON.stringify({ decision: JSON.parse(body).decision }));
172 return;
173 }
174 if (request.url === "/v1/user-input/thr_1/ui_1") {
175 response.writeHead(200, { "Content-Type": "application/json" });
176 response.end("{}");
177 return;
178 }
179 if (request.url === "/v1/threads/thr_conflict/turns") {
180 response.writeHead(409, { "Content-Type": "application/json" });
181 response.end(JSON.stringify({ error: { code: "operation_key_conflict", message: "key reuse" } }));
182 return;
183 }
184 response.writeHead(404, { "Content-Type": "application/json" });
185 response.end("{}");
186 });
187 });
188 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
189 baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
190 });
191
192 after(() => {
193 server.close();
194 });
195
196 const config = (token?: string): ApiConfig => ({ baseUrl, token });
197
198 it("reports connected with version", async () => {
199 const info = await checkConnection(config("sekrit"));
200 assert.equal(info.kind, "connected");
201 assert.equal(info.version, "0.9.12");
202 });
203
204 it("reports auth-required when info demands a token and none is set", async () => {
205 const strict = http.createServer((request, response) => {
206 response.writeHead(200, { "Content-Type": "application/json" });
207 response.end(JSON.stringify({ auth_required: true }));
208 request.on("data", () => undefined);
209 });
210 await new Promise<void>((resolve) => strict.listen(0, "127.0.0.1", resolve));
211 const strictBase = `http://127.0.0.1:${(strict.address() as AddressInfo).port}`;
212 const info = await checkConnection({ baseUrl: strictBase });
213 assert.equal(info.kind, "auth-required");
214 strict.close();
215 });
216
217 it("lists thread summaries with the bearer token", async () => {
218 const threads = await listThreadSummaries(config("sekrit"));
219 assert.equal(seen.auth, "Bearer sekrit");
220 assert.equal(threads.length, 1);
221 assert.equal(threads[0].id, "thr_1");
222 assert.equal(threads[0].dirty, true);
223 });
224
225 it("hydrates thread detail with pending approvals and inputs", async () => {
226 const detail = await getThreadDetail(config(), "thr_1");
227 assert.equal(detail.latestSeq, 12);
228 assert.equal(detail.items.length, 2);
229 assert.equal(detail.pendingApprovals[0].toolName, "shell");
230 assert.equal(detail.pendingUserInputs[0].questions[0].options[0].label, "Fast");
231 assert.equal(detail.pendingUserInputs[0].questions[0].allowFreeText, true);
232 });
233
234 it("starts a turn with an idempotency key and accepts the runtime's 201", async () => {
235 const result = await startTurn(config(), "thr_1", {
236 prompt: "do the thing",
237 operationKey: "op-123",
238 });
239 assert.ok(seen.body?.includes("operation_key"));
240 assert.ok(seen.body?.includes("do the thing"));
241 assert.equal(result.turn.id, "turn_2");
242 });
243
244 it("accepts every 2xx, not a hand-picked list of codes", async () => {
245 for (const status of ["200", "201", "202"]) {
246 const result = await startTurn(config(), `thr_${status}`, { prompt: "x", operationKey: "k" });
247 assert.equal(result.turn.id, "turn_range", `HTTP ${status} should be a success`);
248 }
249 });
250
251 it("surfaces the runtime's message on a 5xx", async () => {
252 await assert.rejects(
253 startTurn(config(), "thr_500", { prompt: "x", operationKey: "k" }),
254 (error: unknown) => {
255 assert.ok(error instanceof ApiError);
256 assert.equal((error as ApiError).statusCode, 500);
257 assert.ok((error as ApiError).message.includes("engine exploded"));
258 return true;
259 },
260 );
261 });
262
263 it("signals auth-required on a mutating 401", async () => {
264 await assert.rejects(
265 startTurn(config(), "thr_401", { prompt: "x", operationKey: "k" }),
266 (error: unknown) => {
267 assert.ok(error instanceof ApiError);
268 assert.equal((error as ApiError).statusCode, 401);
269 assert.ok((error as ApiError).message.includes("requires the runtime token"));
270 return true;
271 },
272 );
273 });
274
275 it("steers and interrupts", async () => {
276 await steerTurn(config(), "thr_1", "turn_2", "focus on tests");
277 assert.ok(seen.path?.includes("/steer"));
278 assert.ok(seen.body?.includes("focus on tests"));
279 const result = await interruptTurn(config(), "thr_1", "turn_2");
280 assert.ok(seen.path?.endsWith("/interrupt"));
281 assert.equal(result, "interrupted");
282 });
283
284 it("treats a 409 interrupt as nothing to stop, not a failure", async () => {
285 const result = await interruptTurn(config(), "thr_1", "turn_idle");
286 assert.equal(result, "not-running");
287 });
288
289 it("resolves approvals and answers user input", async () => {
290 await resolveApproval(config(), "ap_1", "deny", true);
291 assert.ok(seen.body?.includes('"deny"'));
292 assert.ok(seen.body?.includes('"remember":true'));
293 await answerUserInput(config(), "thr_1", "ui_1", [{ id: "q1", label: "Fast", value: "Fast" }]);
294 assert.ok(seen.body?.includes("ui_1") || seen.path?.includes("ui_1"));
295 assert.ok(seen.body?.includes("Fast"));
296 });
297
298 it("surfaces 409 as a typed conflict with server detail", async () => {
299 const failing: ApiConfig = { baseUrl };
300 const promise = startTurn(failing, "thr_conflict", { prompt: "x", operationKey: "k" });
301 await assert.rejects(promise, (error: unknown) => {
302 assert.ok(error instanceof ApiError);
303 // A conflict is its own signal so callers can say "a turn is already
304 // running" rather than reporting a generic HTTP failure.
305 assert.ok(error instanceof ConflictError);
306 assert.equal((error as ApiError).statusCode, 409);
307 assert.equal((error as ApiError).detail, "key reuse");
308 assert.ok((error as ApiError).message.includes("key reuse"));
309 return true;
310 });
311 });
312
313 it("streams and parses SSE events", async () => {
314 const received: RuntimeEvent[] = [];
315 let finish!: () => void;
316 const done = new Promise<void>((resolve) => {
317 finish = resolve;
318 });
319
320 const sseServer = http.createServer((request, response) => {
321 response.writeHead(200, { "Content-Type": "text/event-stream" });
322 response.write('data: {"seq":1,"event":"item.started","item_id":"i1","payload":{"kind":"agent_message"}}\n\n');
323 response.write('data: {"seq":2,"event":"item.delta","item_id":"i1","payload":{"delta":"hel"}}\n\n');
324 setTimeout(() => {
325 response.write('data: {"seq":3,"event":"item.completed","item_id":"i1","payload":{"summary":"hello"}}\n\n');
326 response.end();
327 }, 20);
328 });
329 await new Promise<void>((resolve) => sseServer.listen(0, "127.0.0.1", resolve));
330 const sseBase = `http://127.0.0.1:${(sseServer.address() as AddressInfo).port}`;
331
332 const stream = openEventStream({ baseUrl: sseBase }, "thr_9", 0);
333 let streamError: Error | undefined;
334 stream.onEvent = (event) => {
335 received.push(event);
336 if (received.length === 3) {
337 finish();
338 }
339 };
340 // The server ends the response after the third event, so a trailing
341 // "stream closed" error is expected and must not loop or throw.
342 stream.onError = (error) => {
343 streamError = error;
344 };
345 await done;
346 assert.deepEqual(
347 received.map((event) => [event.seq, event.event]),
348 [
349 [1, "item.started"],
350 [2, "item.delta"],
351 [3, "item.completed"],
352 ],
353 );
354 assert.ok(streamError);
355 stream.close();
356 sseServer.close();
357 });
358 });
359
359 lines TYPESCRIPT