doer-agent 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { test } from "node:test";
4
+ import { startCodexChatBridge } from "./codex-chat-bridge.js";
5
+ async function startBridgeFixture(handler) {
6
+ const capturedBodies = [];
7
+ const logs = [];
8
+ const upstream = createServer(async (req, res) => {
9
+ const chunks = [];
10
+ for await (const chunk of req) {
11
+ chunks.push(Buffer.from(chunk));
12
+ }
13
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
14
+ const body = raw ? JSON.parse(raw) : {};
15
+ capturedBodies.push(body);
16
+ await handler({ body, req, res });
17
+ });
18
+ await listen(upstream);
19
+ const address = upstream.address();
20
+ assert(address && typeof address !== "string");
21
+ const bridge = await startCodexChatBridge({
22
+ providerApiKey: "upstream-test-key",
23
+ providerId: "openai-compatible",
24
+ providerName: "test upstream",
25
+ targetBaseUrl: `http://127.0.0.1:${address.port}`,
26
+ onLog: (message) => logs.push(message),
27
+ });
28
+ return {
29
+ bridge,
30
+ capturedBodies,
31
+ logs,
32
+ close: async () => {
33
+ await bridge.stop();
34
+ await closeServer(upstream);
35
+ },
36
+ };
37
+ }
38
+ function listen(server) {
39
+ return new Promise((resolve, reject) => {
40
+ server.once("error", reject);
41
+ server.listen(0, "127.0.0.1", () => {
42
+ server.off("error", reject);
43
+ resolve();
44
+ });
45
+ });
46
+ }
47
+ function closeServer(server) {
48
+ return new Promise((resolve, reject) => {
49
+ server.close((error) => {
50
+ if (error) {
51
+ reject(error);
52
+ return;
53
+ }
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+ async function responsesRequest(args) {
59
+ return await fetch(`${args.bridge.baseUrl}/responses`, {
60
+ method: "POST",
61
+ headers: {
62
+ authorization: `Bearer ${args.bridge.apiKey}`,
63
+ "content-type": "application/json",
64
+ },
65
+ body: JSON.stringify(args.body),
66
+ });
67
+ }
68
+ function writeChatJson(res, body, status = 200) {
69
+ res.writeHead(status, { "content-type": "application/json" });
70
+ res.end(JSON.stringify(body));
71
+ }
72
+ function writeChatSse(res, values, options) {
73
+ res.writeHead(200, {
74
+ "content-type": "text/event-stream; charset=utf-8",
75
+ "cache-control": "no-cache",
76
+ });
77
+ for (const value of values) {
78
+ res.write(`data: ${typeof value === "string" ? value : JSON.stringify(value)}\n\n`);
79
+ }
80
+ if (!options?.keepOpen) {
81
+ res.end();
82
+ }
83
+ }
84
+ function parseSse(raw) {
85
+ return raw
86
+ .split(/\n\n+/)
87
+ .map((block) => block.trim())
88
+ .filter(Boolean)
89
+ .map((block) => {
90
+ const event = block.match(/^event: (.+)$/m)?.[1];
91
+ const data = block.match(/^data: (.+)$/m)?.[1];
92
+ assert(event, `missing event in block: ${block}`);
93
+ assert(data, `missing data in block: ${block}`);
94
+ return { event, data: JSON.parse(data) };
95
+ });
96
+ }
97
+ test("maps Responses create request fields to Chat Completions", async () => {
98
+ const fixture = await startBridgeFixture(({ res }) => {
99
+ writeChatJson(res, {
100
+ choices: [{ message: { role: "assistant", content: "mapped" } }],
101
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
102
+ });
103
+ });
104
+ try {
105
+ const response = await responsesRequest({
106
+ bridge: fixture.bridge,
107
+ body: {
108
+ model: "glm-5.2",
109
+ instructions: "system instruction",
110
+ input: [
111
+ {
112
+ role: "developer",
113
+ content: [{ type: "input_text", text: "developer note" }],
114
+ },
115
+ {
116
+ role: "user",
117
+ content: [
118
+ { type: "input_text", text: "look at this" },
119
+ { type: "input_image", image_url: "data:image/png;base64,AA==", detail: "high" },
120
+ { type: "input_file", filename: "notes.txt", file_id: "file_123" },
121
+ ],
122
+ },
123
+ {
124
+ type: "function_call",
125
+ call_id: "call_123",
126
+ name: "lookup",
127
+ arguments: "{\"q\":\"doer\"}",
128
+ },
129
+ {
130
+ type: "function_call_output",
131
+ call_id: "call_123",
132
+ output: { ok: true },
133
+ },
134
+ ],
135
+ tools: [{
136
+ type: "function",
137
+ name: "lookup",
138
+ description: "Lookup a value",
139
+ parameters: { type: "object", properties: { q: { type: "string" } } },
140
+ }],
141
+ tool_choice: { type: "function", name: "lookup" },
142
+ text: { format: { type: "json_schema", name: "result", strict: true, schema: { type: "object" } } },
143
+ max_output_tokens: 64,
144
+ temperature: 0.2,
145
+ top_p: 0.8,
146
+ presence_penalty: 0.1,
147
+ frequency_penalty: 0.2,
148
+ seed: 42,
149
+ stop: ["END"],
150
+ parallel_tool_calls: false,
151
+ user: "user_123",
152
+ },
153
+ });
154
+ assert.equal(response.status, 200);
155
+ const chat = fixture.capturedBodies[0];
156
+ assert.equal(chat.model, "glm-5.2");
157
+ assert.equal(chat.stream, false);
158
+ assert.equal(chat.max_tokens, 64);
159
+ assert.equal(chat.temperature, 0.2);
160
+ assert.equal(chat.top_p, 0.8);
161
+ assert.equal(chat.presence_penalty, 0.1);
162
+ assert.equal(chat.frequency_penalty, 0.2);
163
+ assert.equal(chat.seed, 42);
164
+ assert.deepEqual(chat.stop, ["END"]);
165
+ assert.equal(chat.parallel_tool_calls, false);
166
+ assert.equal(chat.user, "user_123");
167
+ assert.deepEqual(chat.tool_choice, { type: "function", function: { name: "lookup" } });
168
+ assert.deepEqual(chat.response_format, {
169
+ type: "json_schema",
170
+ json_schema: { name: "result", schema: { type: "object" }, strict: true },
171
+ });
172
+ assert.deepEqual(chat.tools, [{
173
+ type: "function",
174
+ function: {
175
+ name: "lookup",
176
+ description: "Lookup a value",
177
+ parameters: { type: "object", properties: { q: { type: "string" } } },
178
+ },
179
+ }]);
180
+ const messages = chat.messages;
181
+ assert.deepEqual(messages[0], { role: "system", content: "system instruction" });
182
+ assert.deepEqual(messages[1], { role: "system", content: "developer note" });
183
+ assert.equal(messages[2]?.role, "user");
184
+ assert.deepEqual(messages[2]?.content, [
185
+ { type: "text", text: "look at this" },
186
+ { type: "image_url", image_url: { url: "data:image/png;base64,AA==", detail: "high" } },
187
+ { type: "text", text: "[attached file: notes.txt file_id=file_123]" },
188
+ ]);
189
+ assert.deepEqual(messages[3], {
190
+ role: "assistant",
191
+ content: null,
192
+ tool_calls: [{
193
+ id: "call_123",
194
+ type: "function",
195
+ function: { name: "lookup", arguments: "{\"q\":\"doer\"}" },
196
+ }],
197
+ });
198
+ assert.deepEqual(messages[4], { role: "tool", tool_call_id: "call_123", content: "{\"ok\":true}" });
199
+ }
200
+ finally {
201
+ await fixture.close();
202
+ }
203
+ });
204
+ test("returns a Responses object for non-streaming assistant text and usage", async () => {
205
+ const fixture = await startBridgeFixture(({ res }) => {
206
+ writeChatJson(res, {
207
+ choices: [{ message: { role: "assistant", content: "안녕 하세요" } }],
208
+ usage: {
209
+ prompt_tokens: 11,
210
+ completion_tokens: 5,
211
+ total_tokens: 16,
212
+ prompt_tokens_details: { cached_tokens: 3 },
213
+ completion_tokens_details: { reasoning_tokens: 2 },
214
+ },
215
+ });
216
+ });
217
+ try {
218
+ const response = await responsesRequest({
219
+ bridge: fixture.bridge,
220
+ body: {
221
+ model: "glm-5.2",
222
+ stream: false,
223
+ input: "hello",
224
+ metadata: { smoke: "non-stream" },
225
+ previous_response_id: "resp_prev",
226
+ truncation: "auto",
227
+ },
228
+ });
229
+ assert.equal(response.status, 200);
230
+ const body = await response.json();
231
+ assert.equal(body.object, "response");
232
+ assert.equal(body.status, "completed");
233
+ assert.equal(body.model, "glm-5.2");
234
+ assert.deepEqual(body.metadata, { smoke: "non-stream" });
235
+ assert.equal(body.previous_response_id, "resp_prev");
236
+ assert.equal(body.truncation, "auto");
237
+ assert.deepEqual(body.usage, {
238
+ input_tokens: 11,
239
+ input_tokens_details: { cached_tokens: 3 },
240
+ output_tokens: 5,
241
+ output_tokens_details: { reasoning_tokens: 2 },
242
+ total_tokens: 16,
243
+ });
244
+ const output = body.output;
245
+ assert.equal(output[0]?.type, "message");
246
+ assert.deepEqual(output[0]?.content, [{ type: "output_text", text: "안녕 하세요", annotations: [] }]);
247
+ }
248
+ finally {
249
+ await fixture.close();
250
+ }
251
+ });
252
+ test("drops unsupported Responses hosted tools before forwarding to Chat Completions", async () => {
253
+ const fixture = await startBridgeFixture(({ res }) => {
254
+ writeChatJson(res, {
255
+ choices: [{ message: { role: "assistant", content: "ok" } }],
256
+ });
257
+ });
258
+ try {
259
+ const response = await responsesRequest({
260
+ bridge: fixture.bridge,
261
+ body: {
262
+ model: "glm-5.2",
263
+ input: "hello",
264
+ tools: [
265
+ { type: "function", name: "lookup", parameters: { type: "object", properties: {} } },
266
+ {
267
+ type: "namespace",
268
+ name: "doer_daemon",
269
+ description: "Manage daemons",
270
+ tools: [{
271
+ name: "daemon_list",
272
+ description: "List daemons",
273
+ input_schema: { type: "object", properties: { includeStopped: { type: "boolean" } } },
274
+ }],
275
+ },
276
+ { type: "web_search_preview" },
277
+ { type: "file_search", vector_store_ids: ["vs_123"] },
278
+ { type: "local_shell" },
279
+ { type: "computer_use_preview" },
280
+ ],
281
+ tool_choice: { type: "web_search_preview" },
282
+ },
283
+ });
284
+ assert.equal(response.status, 200);
285
+ const chat = fixture.capturedBodies[0];
286
+ assert.deepEqual(chat.tools, [{
287
+ type: "function",
288
+ function: {
289
+ name: "lookup",
290
+ description: "",
291
+ parameters: { type: "object", properties: {} },
292
+ },
293
+ }, {
294
+ type: "function",
295
+ function: {
296
+ name: "mcp__doer_daemon__daemon_list",
297
+ description: "Namespace: mcp__doer_daemon__. Original tool: daemon_list. List daemons",
298
+ parameters: { type: "object", properties: { includeStopped: { type: "boolean" } } },
299
+ },
300
+ }]);
301
+ assert.equal(chat.tool_choice, undefined);
302
+ assert.deepEqual(fixture.logs.filter((line) => line.includes("dropped unsupported Responses tool")), [
303
+ "Codex chat bridge dropped unsupported Responses tool type=web_search_preview",
304
+ "Codex chat bridge dropped unsupported Responses tool type=file_search",
305
+ "Codex chat bridge dropped unsupported Responses tool type=local_shell",
306
+ "Codex chat bridge dropped unsupported Responses tool type=computer_use_preview",
307
+ ]);
308
+ }
309
+ finally {
310
+ await fixture.close();
311
+ }
312
+ });
313
+ test("returns function_call output for non-streaming Chat tool calls", async () => {
314
+ const fixture = await startBridgeFixture(({ res }) => {
315
+ writeChatJson(res, {
316
+ choices: [{
317
+ message: {
318
+ role: "assistant",
319
+ content: null,
320
+ tool_calls: [{
321
+ id: "call_abc",
322
+ type: "function",
323
+ function: { name: "mcp__doer_daemon__daemon_list", arguments: "{}" },
324
+ }],
325
+ },
326
+ }],
327
+ });
328
+ });
329
+ try {
330
+ const response = await responsesRequest({ bridge: fixture.bridge, body: { stream: false, input: "call tool" } });
331
+ assert.equal(response.status, 200);
332
+ const body = await response.json();
333
+ assert.deepEqual(body.output, [{
334
+ type: "function_call",
335
+ id: "call_abc",
336
+ call_id: "call_abc",
337
+ namespace: "mcp__doer_daemon",
338
+ name: "daemon_list",
339
+ arguments: "{}",
340
+ status: "completed",
341
+ }]);
342
+ }
343
+ finally {
344
+ await fixture.close();
345
+ }
346
+ });
347
+ test("normalizes upstream errors for non-streaming requests", async () => {
348
+ const fixture = await startBridgeFixture(({ res }) => {
349
+ writeChatJson(res, { error: { message: "bad upstream", code: "bad_request" } }, 400);
350
+ });
351
+ try {
352
+ const response = await responsesRequest({ bridge: fixture.bridge, body: { stream: false, input: "fail" } });
353
+ assert.equal(response.status, 400);
354
+ const body = await response.json();
355
+ assert.equal(body.error?.message, "bad upstream");
356
+ assert.equal(body.error?.type, "server_error");
357
+ assert.equal(body.error?.status, 400);
358
+ assert.deepEqual(body.error?.upstream, { message: "bad upstream", code: "bad_request" });
359
+ }
360
+ finally {
361
+ await fixture.close();
362
+ }
363
+ });
364
+ test("streams Responses text lifecycle events, preserves whitespace, and stops at DONE", async () => {
365
+ const fixture = await startBridgeFixture(({ res }) => {
366
+ writeChatSse(res, [
367
+ { choices: [{ delta: { content: "안녕" } }] },
368
+ { choices: [{ delta: { content: " 하세요" } }] },
369
+ { choices: [], usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 } },
370
+ "[DONE]",
371
+ ], { keepOpen: true });
372
+ });
373
+ try {
374
+ const controller = new AbortController();
375
+ const timeout = setTimeout(() => controller.abort(), 3_000);
376
+ const response = await fetch(`${fixture.bridge.baseUrl}/responses`, {
377
+ method: "POST",
378
+ signal: controller.signal,
379
+ headers: {
380
+ authorization: `Bearer ${fixture.bridge.apiKey}`,
381
+ "content-type": "application/json",
382
+ },
383
+ body: JSON.stringify({ model: "glm-5.2", stream: true, input: "hello" }),
384
+ });
385
+ const raw = await response.text();
386
+ clearTimeout(timeout);
387
+ assert.equal(response.status, 200);
388
+ const chat = fixture.capturedBodies[0];
389
+ assert.equal(chat.stream, true);
390
+ assert.deepEqual(chat.stream_options, { include_usage: true });
391
+ const events = parseSse(raw);
392
+ assert.deepEqual(events.map((event) => event.event), [
393
+ "response.created",
394
+ "response.in_progress",
395
+ "response.output_item.added",
396
+ "response.content_part.added",
397
+ "response.output_text.delta",
398
+ "response.output_text.delta",
399
+ "response.output_text.done",
400
+ "response.content_part.done",
401
+ "response.output_item.done",
402
+ "response.completed",
403
+ ]);
404
+ assert.equal(events[4]?.data.delta, "안녕");
405
+ assert.equal(events[5]?.data.delta, " 하세요");
406
+ assert.equal(events[6]?.data.text, "안녕 하세요");
407
+ const completed = events.at(-1)?.data.response;
408
+ assert.equal(completed.status, "completed");
409
+ assert.deepEqual(completed.usage, { input_tokens: 4, output_tokens: 2, total_tokens: 6 });
410
+ }
411
+ finally {
412
+ await fixture.close();
413
+ }
414
+ });
415
+ test("streams reasoning text as fallback assistant text", async () => {
416
+ const fixture = await startBridgeFixture(({ res }) => {
417
+ writeChatSse(res, [
418
+ { choices: [{ delta: { reasoning_content: "생각 결과" } }] },
419
+ "[DONE]",
420
+ ]);
421
+ });
422
+ try {
423
+ const response = await responsesRequest({ bridge: fixture.bridge, body: { stream: true, input: "reason" } });
424
+ const events = parseSse(await response.text());
425
+ assert.equal(response.status, 200);
426
+ assert.equal(events.find((event) => event.event === "response.output_text.delta")?.data.delta, "생각 결과");
427
+ const done = events.find((event) => event.event === "response.output_text.done");
428
+ assert.equal(done?.data.text, "생각 결과");
429
+ }
430
+ finally {
431
+ await fixture.close();
432
+ }
433
+ });
434
+ test("streams function_call argument events from Chat tool call deltas", async () => {
435
+ const fixture = await startBridgeFixture(({ res }) => {
436
+ writeChatSse(res, [
437
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_xyz", function: { name: "lookup", arguments: "{\"q\"" } }] } }] },
438
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_xyz", function: { arguments: ":\"doer\"}" } }] } }] },
439
+ "[DONE]",
440
+ ]);
441
+ });
442
+ try {
443
+ const response = await responsesRequest({ bridge: fixture.bridge, body: { stream: true, input: "tool" } });
444
+ const events = parseSse(await response.text());
445
+ assert.equal(response.status, 200);
446
+ const added = events.find((event) => event.event === "response.output_item.added");
447
+ assert.deepEqual(added?.data.item, {
448
+ id: "fc_call_xyz",
449
+ type: "function_call",
450
+ status: "in_progress",
451
+ call_id: "call_xyz",
452
+ name: "lookup",
453
+ arguments: "",
454
+ });
455
+ assert.deepEqual(events.filter((event) => event.event === "response.function_call_arguments.delta").map((event) => event.data.delta), ["{\"q\"", ":\"doer\"}"]);
456
+ const done = events.find((event) => event.event === "response.function_call_arguments.done");
457
+ assert.equal(done?.data.arguments, "{\"q\":\"doer\"}");
458
+ const completed = events.at(-1)?.data.response;
459
+ assert.deepEqual(completed.output, [{
460
+ id: "fc_call_xyz",
461
+ type: "function_call",
462
+ status: "completed",
463
+ call_id: "call_xyz",
464
+ name: "lookup",
465
+ arguments: "{\"q\":\"doer\"}",
466
+ }]);
467
+ }
468
+ finally {
469
+ await fixture.close();
470
+ }
471
+ });
472
+ test("emits response.failed for upstream streaming errors and malformed chunks", async () => {
473
+ const upstreamFailure = await startBridgeFixture(({ res }) => {
474
+ writeChatJson(res, { error: { message: "stream bad" } }, 429);
475
+ });
476
+ try {
477
+ const response = await responsesRequest({ bridge: upstreamFailure.bridge, body: { stream: true, input: "fail" } });
478
+ const events = parseSse(await response.text());
479
+ assert.equal(response.status, 200);
480
+ assert.equal(events.at(-1)?.event, "response.failed");
481
+ const failed = events.at(-1)?.data.response;
482
+ assert.equal(failed.status, "failed");
483
+ assert.match(failed.error.message, /429/);
484
+ }
485
+ finally {
486
+ await upstreamFailure.close();
487
+ }
488
+ const malformed = await startBridgeFixture(({ res }) => {
489
+ writeChatSse(res, ["{not json"]);
490
+ });
491
+ try {
492
+ const response = await responsesRequest({ bridge: malformed.bridge, body: { stream: true, input: "bad json" } });
493
+ const events = parseSse(await response.text());
494
+ assert.equal(response.status, 200);
495
+ assert.equal(events.at(-1)?.event, "response.failed");
496
+ const failed = events.at(-1)?.data.response;
497
+ assert.equal(failed.status, "failed");
498
+ assert.match(failed.error.message, /parse/);
499
+ }
500
+ finally {
501
+ await malformed.close();
502
+ }
503
+ });
504
+ test("rejects missing or invalid bridge authorization", async () => {
505
+ const fixture = await startBridgeFixture(({ res }) => {
506
+ writeChatJson(res, { choices: [{ message: { content: "unused" } }] });
507
+ });
508
+ try {
509
+ const response = await fetch(`${fixture.bridge.baseUrl}/responses`, {
510
+ method: "POST",
511
+ headers: { "content-type": "application/json" },
512
+ body: JSON.stringify({ input: "hello" }),
513
+ });
514
+ assert.equal(response.status, 401);
515
+ assert.deepEqual(await response.json(), { error: { message: "Invalid Authorization header" } });
516
+ }
517
+ finally {
518
+ await fixture.close();
519
+ }
520
+ });