| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { AskSetup, ChatAnswer } from "@g1t/contracts"; |
| 5 | |
| 6 | import { runAnswer, type AnswerDeps } from "./answer.ts"; |
| 7 | |
| 8 | const ready: AskSetup = { connection_id: "int_1", connection_name: "Anthropic", provider: "anthropic", model: "m", problem: null }; |
| 9 | |
| 10 | function ndjson(lines: object[], { split = false } = {}): Response { |
| 11 | const text = lines.map((line) => `${JSON.stringify(line)}\n`).join(""); |
| 12 | const parts = split ? text.match(/[\s\S]{1,7}/g)! : [text]; |
| 13 | return new Response( |
| 14 | new ReadableStream({ |
| 15 | start(controller) { |
| 16 | for (const part of parts) controller.enqueue(new TextEncoder().encode(part)); |
| 17 | controller.close(); |
| 18 | }, |
| 19 | }), |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | function harness(setup: AskSetup | null, answer: () => Response | Promise<Response>) { |
| 24 | const seen = { typing: 0, shown: [] as string[], finished: null as null | { body: string; answer: ChatAnswer }, asked: 0 }; |
| 25 | let clock = 0; |
| 26 | const deps: AnswerDeps = { |
| 27 | slug: "acme", |
| 28 | setup: async () => setup, |
| 29 | ask: async () => { |
| 30 | seen.asked++; |
| 31 | return answer(); |
| 32 | }, |
| 33 | typing: () => void seen.typing++, |
| 34 | show: (body) => void seen.shown.push(body), |
| 35 | save: async () => {}, |
| 36 | finish: async (body, answer) => void (seen.finished = { body, answer }), |
| 37 | now: () => (clock += 200), |
| 38 | }; |
| 39 | return { deps, seen }; |
| 40 | } |
| 41 | |
| 42 | test("an answer streams into the message and ends as a normal one", async () => { |
| 43 | const { deps, seen } = harness(ready, () => |
| 44 | ndjson( |
| 45 | [ |
| 46 | { type: "meta", provider: "anthropic", provider_label: "Anthropic", model: "m" }, |
| 47 | { type: "text", text: "Two pull " }, |
| 48 | { type: "tool", name: "list_pulls" }, |
| 49 | { type: "text", text: "requests merged." }, |
| 50 | { type: "done", stop_reason: "end_turn" }, |
| 51 | ], |
| 52 | { split: true }, |
| 53 | ), |
| 54 | ); |
| 55 | await runAnswer(deps, new AbortController().signal); |
| 56 | assert.equal(seen.typing, 1); |
| 57 | assert.ok(seen.shown.length > 0); |
| 58 | assert.equal(seen.finished!.body, "Two pull requests merged."); |
| 59 | assert.equal(seen.finished!.answer.state, "done"); |
| 60 | assert.equal(seen.finished!.answer.model, "m"); |
| 61 | assert.equal(seen.finished!.answer.steps!.length, 1); |
| 62 | }); |
| 63 | |
| 64 | test("with no provider chosen, g1t says so once, without asking", async () => { |
| 65 | const { deps, seen } = harness({ ...ready, connection_id: null, problem: "No AI provider is chosen for Ask in this workspace yet." }, () => ndjson([])); |
| 66 | await runAnswer(deps, new AbortController().signal); |
| 67 | assert.equal(seen.asked, 0); |
| 68 | assert.equal(seen.finished!.answer.setup, "missing"); |
| 69 | assert.match(seen.finished!.body, /Integrations → Your AI providers → Ask g1t/); |
| 70 | }); |
| 71 | |
| 72 | test("a chosen provider that cannot answer is a short message, not silence", async () => { |
| 73 | const { deps, seen } = harness({ ...ready, problem: "The key for Anthropic cannot be read. Replace it." }, () => ndjson([])); |
| 74 | await runAnswer(deps, new AbortController().signal); |
| 75 | assert.equal(seen.finished!.answer.state, "error"); |
| 76 | assert.equal(seen.finished!.body, "I couldn't answer that: The key for Anthropic cannot be read. Replace it."); |
| 77 | }); |
| 78 | |
| 79 | test("a refusal or an unreachable service ends as an error message", async () => { |
| 80 | const refused = harness(ready, () => ndjson([{ type: "error", message: "Write a question to ask." }])); |
| 81 | await runAnswer(refused.deps, new AbortController().signal); |
| 82 | assert.equal(refused.seen.finished!.body, "I couldn't answer that: Write a question to ask."); |
| 83 | const down = harness(ready, () => { |
| 84 | throw new Error("binding down"); |
| 85 | }); |
| 86 | await runAnswer(down.deps, new AbortController().signal); |
| 87 | assert.equal(down.seen.finished!.answer.state, "error"); |
| 88 | assert.match(down.seen.finished!.body, /broke off/); |
| 89 | }); |
| 90 | |
| 91 | test("Stop keeps what came, marked stopped", async () => { |
| 92 | const stop = new AbortController(); |
| 93 | let push!: (text: string) => void; |
| 94 | const { deps, seen } = harness(ready, () => { |
| 95 | const stream = new ReadableStream<Uint8Array>({ |
| 96 | start(controller) { |
| 97 | push = (text) => controller.enqueue(new TextEncoder().encode(text)); |
| 98 | }, |
| 99 | }); |
| 100 | return new Response(stream); |
| 101 | }); |
| 102 | const running = runAnswer(deps, stop.signal); |
| 103 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| 104 | push(`${JSON.stringify({ type: "text", text: "Partly" })}\n`); |
| 105 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| 106 | stop.abort(); |
| 107 | await running; |
| 108 | assert.equal(seen.finished!.answer.state, "stopped"); |
| 109 | assert.equal(seen.finished!.body, "Partly"); |
| 110 | }); |