| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { EMPTY_ASK, type AskState, askPage, askReducer, cutOff, history, readLines, restorable } from "./ask.ts"; |
| 5 | |
| 6 | test("the page says which repository, issue or pull request the person is on", () => { |
| 7 | assert.deepEqual(askPage("/Acme/web/issues/12"), { path: "/Acme/web/issues/12", repo: { namespace: "acme", name: "web" }, issue: 12 }); |
| 8 | assert.deepEqual(askPage("/acme/web/pull/3/files"), { path: "/acme/web/pull/3/files", repo: { namespace: "acme", name: "web" }, pull: 3 }); |
| 9 | assert.deepEqual(askPage("/acme/web/tree/main"), { path: "/acme/web/tree/main", repo: { namespace: "acme", name: "web" } }); |
| 10 | assert.deepEqual(askPage("/acme/web/issues"), { path: "/acme/web/issues", repo: { namespace: "acme", name: "web" } }); |
| 11 | // A workspace's own pages, and g1t's, name no repository. |
| 12 | assert.deepEqual(askPage("/acme/-/chat/general"), { path: "/acme/-/chat/general" }); |
| 13 | assert.deepEqual(askPage("/settings/profile"), { path: "/settings/profile" }); |
| 14 | assert.deepEqual(askPage("/acme"), { path: "/acme" }); |
| 15 | assert.deepEqual(askPage(""), { path: "/" }); |
| 16 | }); |
| 17 | |
| 18 | function run(...actions: Parameters<typeof askReducer>[1][]): AskState { |
| 19 | return actions.reduce(askReducer, EMPTY_ASK); |
| 20 | } |
| 21 | |
| 22 | test("an answer streams into the turn after the question", () => { |
| 23 | const state = run( |
| 24 | { type: "ask", question: " What broke? ", id: "1" }, |
| 25 | { type: "event", event: { type: "meta", provider: "anthropic", provider_label: "Anthropic", model: "claude-x" } }, |
| 26 | { type: "event", event: { type: "text", text: "The " } }, |
| 27 | { type: "event", event: { type: "text", text: "login." } }, |
| 28 | ); |
| 29 | assert.equal(state.busy, true); |
| 30 | assert.equal(state.turns[0]!.content, "What broke?"); |
| 31 | assert.deepEqual( |
| 32 | { content: state.turns[1]!.content, provider: state.turns[1]!.provider, model: state.turns[1]!.model, status: state.turns[1]!.status }, |
| 33 | { content: "The login.", provider: "Anthropic", model: "claude-x", status: "streaming" }, |
| 34 | ); |
| 35 | const done = askReducer(state, { type: "event", event: { type: "done", stop_reason: "max_tokens" } }); |
| 36 | assert.equal(done.busy, false); |
| 37 | assert.equal(done.turns[1]!.status, "done"); |
| 38 | assert.ok(cutOff(done.turns[1]!)); |
| 39 | }); |
| 40 | |
| 41 | test("one question at a time; empty ones are not asked", () => { |
| 42 | const asking = run({ type: "ask", question: "a", id: "1" }); |
| 43 | assert.equal(askReducer(asking, { type: "ask", question: "b", id: "2" }), asking); |
| 44 | assert.equal(askReducer(EMPTY_ASK, { type: "ask", question: " ", id: "1" }), EMPTY_ASK); |
| 45 | }); |
| 46 | |
| 47 | test("stopping keeps what came; an error says what went wrong; new conversation starts over", () => { |
| 48 | const stopped = run({ type: "ask", question: "a", id: "1" }, { type: "event", event: { type: "text", text: "par" } }, { type: "stop" }); |
| 49 | assert.equal(stopped.busy, false); |
| 50 | assert.deepEqual([stopped.turns[1]!.status, stopped.turns[1]!.content], ["stopped", "par"]); |
| 51 | // Nothing after the stop lands on the answer. |
| 52 | assert.equal(askReducer(stopped, { type: "event", event: { type: "text", text: "tial" } }).turns[1]!.content, "par"); |
| 53 | |
| 54 | const failed = run({ type: "ask", question: "a", id: "1" }, { type: "event", event: { type: "error", message: "Anthropic did not accept the credentials." } }); |
| 55 | assert.equal(failed.turns[1]!.error, "Anthropic did not accept the credentials."); |
| 56 | assert.equal(failed.busy, false); |
| 57 | assert.equal(run({ type: "ask", question: "a", id: "1" }, { type: "failed", message: "Too many questions." }).turns[1]!.status, "error"); |
| 58 | assert.equal(askReducer(failed, { type: "reset" }), EMPTY_ASK); |
| 59 | }); |
| 60 | |
| 61 | test("what is sent alternates, leaving out questions whose answer failed", () => { |
| 62 | const state = run( |
| 63 | { type: "ask", question: "one", id: "1" }, |
| 64 | { type: "event", event: { type: "text", text: "first" } }, |
| 65 | { type: "event", event: { type: "done", stop_reason: "end_turn" } }, |
| 66 | { type: "ask", question: "two", id: "2" }, |
| 67 | { type: "event", event: { type: "error", message: "no" } }, |
| 68 | { type: "ask", question: "three", id: "3" }, |
| 69 | { type: "event", event: { type: "text", text: "cut" } }, |
| 70 | { type: "stop" }, |
| 71 | ); |
| 72 | assert.deepEqual(history(state.turns, " four "), [ |
| 73 | { role: "user", content: "one" }, |
| 74 | { role: "assistant", content: "first" }, |
| 75 | { role: "user", content: "three" }, |
| 76 | { role: "assistant", content: "cut" }, |
| 77 | { role: "user", content: "four" }, |
| 78 | ]); |
| 79 | }); |
| 80 | |
| 81 | test("lines are read as they arrive, across chunks", () => { |
| 82 | const first = readLines("", '{"type":"meta","provider":"openai","provider_label":"OpenAI","model":"gpt-5"}\n{"type":"te'); |
| 83 | assert.equal(first.events.length, 1); |
| 84 | assert.equal(first.rest, '{"type":"te'); |
| 85 | const second = readLines(first.rest, 'xt","text":"Hi"}\nnot json\n{"type":"other"}\n'); |
| 86 | assert.deepEqual(second.events, [{ type: "text", text: "Hi" }]); |
| 87 | assert.equal(second.rest, ""); |
| 88 | }); |
| 89 | |
| 90 | test("a conversation kept in the tab comes back stopped where it was", () => { |
| 91 | const kept = restorable({ turns: [{ id: "1-q", role: "user", content: "a", status: "done" }, { id: "1-a", role: "assistant", content: "b", status: "streaming" }], busy: true }); |
| 92 | assert.equal(kept.busy, false); |
| 93 | assert.equal(kept.turns[1]!.status, "stopped"); |
| 94 | assert.deepEqual(restorable("junk"), EMPTY_ASK); |
| 95 | assert.deepEqual(restorable({ turns: [{ role: "system", content: "x" }] }).turns, []); |
| 96 | }); |