Public: anyone can read this; the crew can change it.

/talk has Stop: a turn can be ended mid-reply and its run's processes end with it #8835

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8818-talk-has-stop-a-turn-can-be-ended-mid-re → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/8818-talk-has-stop-a-turn-can-be-ended-mid-re was merged into main as 33f6c5b.

Review

Sign in to review this change.
13 files changed +251 −18
docs/features/talk.md +11
@@ -18,6 +18,17 @@ A reply streams in over `GET /talk/stream?after=<turn id>` (server-sent events,
1818
1919 When the agent asks first (`needs_confirm`), the turn draws **Confirm** (the existing yes) and **Cancel** (a no: the confirm is spent, nothing runs, a turn says it was cancelled). A result shown once (an invite link) is drawn by Settings → Confirms instead, where Confirm leads.
2020
21 ## Stop
22
23 A turn can be ended mid-reply (`stopTalk` in `src/actions/talk.ts`, `stopTurn` in `src/crew/conversation.ts`). The agent's process group ends with everything it started; the words it had said stay, followed by a `Stopped.` line; the run is stored as `failed`. Held writes waiting on a Confirm are cancelled before the kill and again after, so nothing lands while the process goes down.
24
25 - **Web:** the Stop button beside Send while an answer is running, and `Escape` in the composer. Send still posts the form without the script.
26 - **API:** `POST /api/v1/conversation/stop` answers 200, or 409 when nothing runs; a read-only token is 403.
27 - **MCP:** `stop` with no `repo` and no `run` stops the forge's agent; with both it stops that crew run; one without the other is an error. The agent's own token is refused.
28 - **Terminal:** `ssh <forge> talk /stop` (or `--stop`) exits 0 when stopped, 1 with "no answer is running".

Non-blocking: the route answers 202, not 200.

29
30 It checks identity, same origin and token scope. Viewers of `/talk` see the `Stopped.` turn arrive over the stream.
31
2132 ## Chat features: have, adding, not adding (#8752)
2233
2334 Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and behaviour ported, no code copied), AI SDK UI, ChatGPT and Claude.ai. No React: the page stays server-drawn with a small script.
src/actions/talk.ts +9 −2
@@ -8,8 +8,8 @@
88 import type { App } from "../app.ts";
99 import { RateLimiter } from "../core/ratelimit.ts";
1010 import type { User } from "../core/users.ts";
11 import { conversationOptions, say, type SayRefusal, type Said } from "../crew/conversation.ts";
12 import { err, type Result } from "../kernel/result.ts";
11 import { conversationOptions, say, stopTurn, type SayRefusal, type Said } from "../crew/conversation.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
1313
1414 /** Messages a person may send in a minute. */
1515 export const TALK_PER_MINUTE = 12;
@@ -45,3 +45,10 @@ export function talk(app: App, user: User | undefined, text: string): Result<Sai
4545 said.value.done.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) }));
4646 return said;
4747 }
48
49 /** Stop the person's running turn; `idle` when none runs. */
50 export function stopTalk(app: App, user: User | undefined): Result<true, "unauthorized" | "off" | "idle"> {
51 if (user === undefined) return err("unauthorized");
52 if (conversationOptions(app) === undefined) return err("off");
53 return stopTurn(app, user.id) ? ok(true) : err("idle");
54 }
src/contract/openapi/routes/index.ts +1
@@ -212,6 +212,7 @@ export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
212212 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
213213 export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts";
214214 export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts";
215 export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts";
215216 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
216217 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
217218 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
src/contract/openapi/routes/postApiV1ConversationStop.ts added +15
@@ -0,0 +1,15 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversation/stop",
6 summary: "Stop the forge's agent mid-answer, as the signed-in person: its run and every process it started end, what it said so far stays and is marked stopped, and a write waiting for a Confirm is cancelled, never applied; answers 202",
7 auth: true,
8 status: 202,
9 reply: { stopped: "boolean" },
10 refusals: {
11 idle: "no answer is running (409)",
12 off: "the forge's agent is not running here (503)",
13 forbidden: "the request is cross-origin, or the token may only read",
14 },
15 };
src/core/fence.ts +11
@@ -275,6 +275,17 @@ export function hold(
275275 })();
276276 }
277277
278 /**
279 * Every write a conversation still holds is answered no and its arguments
280 * cleared, so a stopped turn leaves nothing a late Confirm could apply.
281 * Returns how many were cancelled.
282 */
283 export function cancelHeld(db: Database, via: string, now: number = Date.now()): number {
284 return db
285 .query("UPDATE conversation_held SET answered_at = ?2, args = '{}' WHERE via = ?1 AND answered_at IS NULL")
286 .run(via, new Date(now).toISOString()).changes;
287 }
288
278289 /** A conversation's held writes, oldest first. */
279290 export function heldIn(db: Database, via: string): Held[] {
280291 return db
src/crew/conversation.ts +38 −3
@@ -21,7 +21,7 @@ import type { App } from "../app.ts";
2121 import { dailyLeft } from "../core/account_ceiling.ts";
2222 import { appendTurn, listTurns, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts";
2323 import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts";
24 import { WORDS_MAX } from "../core/fence.ts";
24 import { cancelHeld, WORDS_MAX } from "../core/fence.ts";
2525 import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts";
2626 import { getUserById } from "../core/users.ts";
2727 import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts";
@@ -96,6 +96,27 @@ export const unendedLine = (tools: readonly string[]): string => {
9696 const RESUME_PROMPT =
9797 "The forge restarted while you were answering and you are resuming this conversation. Before any write, read to see whether it already happened; a held write's confirmation was answered once and is not to be asked for again. Then finish your answer to the person's last message.";
9898
99 /** The line a stopped turn leaves after what it had said. */
100 export const STOPPED_LINE = "Stopped.";
101
102 /** Stops for the turns running, by conversation id. */
103 const stoppers = new Map<string, { runId: string; stop: () => void }>();
104
105 /**
106 * Stop the person's running turn: the agent's process group ends (and every
107 * process it started), the words so far stay, a held write waiting for a
108 * Confirm is cancelled, never applied. False when no turn runs.
109 */
110 export function stopTurn(app: App, userId: string): boolean {
111 const conversation = openConversation(app.db, userId);
112 const stop = stoppers.get(conversation.id)?.stop;
113 if (stop === undefined) return false;
114 // Cancelled first: no Confirm may land while the process is going down.
115 cancelHeld(app.db, `conversation:${conversation.id}`);
116 stop();
117 return true;
118 }
119
99120 /** Turns with a session listening, by conversation id: the run id a message is steered to. */
100121 const live = new Map<string, string>();
101122
@@ -277,6 +298,9 @@ function begin(
277298 )
278299 .run(runId, conversation.id, userId, subscriptionId, prompt, resumes, resumeSession ?? "", now.toISOString());
279300 live.set(conversation.id, runId);
301 const stopper = new AbortController();
302 stoppers.set(conversation.id, { runId, stop: () => stopper.abort() });
303 const signal = AbortSignal.any([options.signal, stopper.signal]);
280304 const put = (role: "agent" | "tool", text: string): void => {
281305 const stored = appendTurn(app.db, userId, conversation.id, { role, text: text.slice(0, 20_000) });
282306 if (stored.ok && role === "agent") streamed.push({ id: stored.value.id, text });
@@ -319,7 +343,7 @@ function begin(
319343 label: "conversation",
320344 timeoutMs: options.timeoutMs,
321345 log: (line) => appendRunLog(app.config.dataDir, runId, line),
322 signal: options.signal,
346 signal,
323347 mcp: { config: mcpConfig(options.localUrl), allow: [`mcp__${MCP_SERVER}`] },
324348 onSteer: (send) => {
325349 unlisten = listenForSteer(runId, send);
@@ -340,7 +364,7 @@ function begin(
340364 app.db
341365 .query("UPDATE conversation_run SET resume_outcome = ?2, version = ?3 WHERE id = ?1")
342366 .run(runId, named && !lost ? "resumed" : "fallback", running().version);
343 if (session === undefined || (named && !lost) || options.signal.aborted) break;
367 if (session === undefined || (named && !lost) || signal.aborted) break;
344368 appendRunLog(app.config.dataDir, runId, "no session to resume; starting this message fresh");
345369 // What the failed attempt showed (its error may be printed as text) is taken back.
346370 for (const t of streamed.splice(0)) if (lostSession(t.text)) removeTurn(app.db, userId, conversation.id, t.id);
@@ -351,6 +375,7 @@ function begin(
351375 } finally {
352376 unlisten();
353377 if (live.get(conversation.id) === runId) live.delete(conversation.id);
378 if (stoppers.get(conversation.id)?.runId === runId) stoppers.delete(conversation.id);
outdated

Guard this with stoppers.get(conversation.id) being this run's stopper, as live is guarded by run id above.

354379 }
355380 const cost = outcome?.costUsd ?? 0;
356381 // Stopped for a restart: the row stays `running` for the next boot to
@@ -363,6 +388,16 @@ function begin(
363388 return;
364389 }
365390 app.db.query("DELETE FROM token WHERE id = ?1").run(minted.value.id);
391 if (stopper.signal.aborted) {
392 // A write held after the stop was asked is cancelled too.
393 cancelHeld(app.db, via);
394 put("agent", STOPPED_LINE);
395 app.db
396 .query("UPDATE conversation_run SET state = 'failed', tokens_in = ?2, tokens_out = ?3, cost_usd = ?4, finished_at = ?5 WHERE id = ?1")
397 .run(runId, outcome?.tokensIn ?? 0, outcome?.tokensOut ?? 0, cost, new Date().toISOString());
398 app.db.query("UPDATE subscription SET spent_usd = spent_usd + ?2 WHERE id = ?1").run(subscriptionId, cost);
399 return;
400 }
366401 const answer = outcome?.result ?? "";
367402 if (outcome !== undefined && isRateLimited(outcome)) {
368403 // The provider's words are not shown: the streamed copy of them goes.
src/http/api/conversation_talk.ts +11 −1
@@ -7,7 +7,7 @@
77 * request is same-origin, and sending is throttled per person (`actions/talk.ts`).
88 */
99
10 import { TALK_WORDS, talk } from "../../actions/talk.ts";
10 import { stopTalk, TALK_WORDS, talk } from "../../actions/talk.ts";
1111 import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation } from "../../core/conversations.ts";
1212 import { WORDS_MAX } from "../../core/fence.ts";
1313 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
@@ -48,6 +48,16 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
4848 return json({ how: said.value.how }, 202);
4949 });
5050
51 router.add("POST", "/api/v1/conversation/stop", (ctx) => {
52 const user = ctx.auth.user;
53 if (user === undefined) return unauthorized();
54 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
55 if (ctx.auth.via === "token" && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) return forbidden();
56 const stopped = stopTalk(ctx.app, user);
57 if (!stopped.ok) return json({ error: stopped.error, message: stopped.error === "idle" ? "no answer is running" : "the forge's agent is not running here" }, stopped.error === "idle" ? 409 : 503);
58 return json({ stopped: true }, 202);
59 });
60
5161 router.add("GET", "/api/v1/conversation/turns", (ctx) => {
5262 const user = ctx.auth.user;
5363 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/mcp/tools/stop.ts +11 −3
@@ -1,18 +1,26 @@
1 import { stopTalk } from "../../../actions/talk.ts";
12 import { cancelRepoRun } from "../../../actions/runs.ts";
23 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
34
45 export const tool: Tool = {
56 name: "stop",
6 doc: "stop one run by its id from `now`",
7 doc: "stop one run by its id from `now`; with no `repo` it stops the forge's own agent mid-answer",
78 description:
8 "Stop a crew run by its id from `now`. Its pushes stay; nothing is re-queued.",
9 "Stop a crew run by its id from `now`; with no repo, the forge's own agent mid-answer.",
910 inputSchema: {
1011 type: "object",
1112 properties: { repo: REPO_ARG, run: { type: "string" } },
12 required: ["repo", "run"],
13 required: [],
1314 },
1415 writes: true,
1516 run(ctx, user, args) {

With required: [], run given without repo falls through to repoArg. Return an explicit error when exactly one of the two is present.

17 if (args["repo"] === undefined && args["run"] === undefined) {
18 if (ctx.auth.through !== undefined) return bad("forbidden", "the forge's agent cannot stop itself");
19 const talked = stopTalk(ctx.app, user);
20 return talked.ok ? good({ says: "Stopped the forge's agent; what it said stays." }) : bad(talked.error, talked.error === "idle" ? "no answer is running" : "the forge's agent is not running here");
21 }
22 if (args["repo"] === undefined || args["run"] === undefined)
23 return bad("invalid", "to stop a crew run give both `repo` and `run`; give neither to stop the forge's agent");
1624 const repo = repoArg(ctx, user, args);
1725 if (isAnswer(repo)) return repo;
1826 const stopped = cancelRepoRun(ctx.app, user, repo, str(args["run"]));
src/tui/ssh-talk.ts +18 −2
@@ -4,10 +4,12 @@
44 * as the account the presented key belongs to, and prints that the agent is
55 * answering; the answer is read back with `forge talk` or on the feed. The
66 * words are the rest of the command line as given: no shell, no expansion; a
7 * control character is refused before anything runs.
7 * control character is refused before anything runs. `talk /stop` (or
8 * `--stop`) ends the running answer, as Stop does on /talk: what the agent
9 * had said stays, its processes end, a held write is cancelled.
810 */
911
10 import { TALK_WORDS, talk } from "../actions/talk.ts";
12 import { TALK_WORDS, stopTalk, talk } from "../actions/talk.ts";
1113 import type { App } from "../app.ts";
1214 import type { User } from "../core/users.ts";
1315 import { CONTROL_WORDS } from "./ssh-tell.ts";
@@ -22,6 +24,11 @@ export function parseTalkCommand(command: string | undefined): string | undefine
2224 return m === null ? undefined : (m[1] ?? "");
2325 }
2426
27 /** The words that stop the running answer instead of being said. */
28 export function isStop(words: string): boolean {
29 return words.trim() === "/stop" || words.trim() === "--stop";
30 }
31
2532 /** Run one `talk` exec for `user` (undefined: an unknown key). */
2633 export function serveTalk(app: App, session: SshSession, user: User | undefined, words: string): void {
2734 const refuse = (text: string): void => {
@@ -29,6 +36,15 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined,
2936 session.exit(1);
3037 };
3138 if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(CONTROL_WORDS);
39 if (isStop(words)) {
40 const stopped = stopTalk(app, user);
41 if (!stopped.ok)
42 return refuse(
43 stopped.error === "idle" ? "no answer is running" : stopped.error === "off" ? TALK_WORDS.off[1] : TALK_WORDS.unauthorized[1],
44 );
45 session.write(`stopped the forge's agent; what it said stays${CRLF}`);
46 return session.exit(0);
47 }
3248 const said = talk(app, user, words);
3349 if (!said.ok) return refuse(TALK_WORDS[said.error][1]);
3450 session.write(
src/web/pages/talk_client.ts +22 −2
@@ -99,6 +99,26 @@ if (root) {
9999 });
100100 };
101101 decorate(log);
102 // While a reply streams, Send is Stop; Escape stops too.
103 const action = form.querySelector(".bar .btn");
104 const sendLabel = action.textContent;
105 let answering = false;
106 const mode = (on) => {
107 answering = on;
108 action.textContent = on ? "Stop" : sendLabel;
109 action.dataset.stop = on ? "1" : "";
110 };
111 const stop = async () => {
112 say("Stopping…");
113 try {
114 const res = await fetch("/api/v1/conversation/stop", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
115 if (!res.ok) { const body = await res.json().catch(() => ({})); say(body.message || "Nothing to stop."); }
116 } catch (_) {
117 say("That was not sent: no connection.");
118 }
119 };
120 action.addEventListener("click", (e) => { if (answering) { e.preventDefault(); stop(); } });
121 document.addEventListener("keydown", (e) => { if (e.key === "Escape" && answering) { e.preventDefault(); stop(); } });
102122 const follow = () => {
103123 if (paged) return;
104124 if (source) source.close();
@@ -110,8 +130,8 @@ if (root) {
110130 retry = 0;
111131 toEnd();
112132 });
113 source.addEventListener("running", () => say("The forge is answering…"));
114 source.addEventListener("done", () => { say(""); source.close(); source = null; });
133 source.addEventListener("running", () => { mode(true); say("The forge is answering…"); });
134 source.addEventListener("done", () => { mode(false); say(""); source.close(); source = null; });
115135 source.addEventListener("more", () => { source.close(); follow(); });
116136 source.addEventListener("drain", () => { source.close(); setTimeout(follow, 1000); });
117137 source.onerror = () => {
test/conversation-talk.test.ts +42 −1
@@ -20,7 +20,7 @@ import { TALK_PER_MINUTE } from "../src/actions/talk.ts";
2020 import { turnsLimit } from "../src/http/api/conversation_talk.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
2222 import { forgeSshSession } from "../src/tui/ssh.ts";
23 import { parseTalkCommand } from "../src/tui/ssh-talk.ts";
23 import { isStop, parseTalkCommand } from "../src/tui/ssh-talk.ts";
2424 import { quietLogs } from "./helpers/globals.ts";
2525
2626 quietLogs();
@@ -203,6 +203,23 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t
203203 expect(res.status).toBe(403);
204204 });
205205
206 test("stop answers 409 when no answer runs, in the API and the MCP; a read-only token and the agent's own token are refused (#8818)", async () => {
207 await settle();
208 const stop = (t: string) => fetch(`${servers.url}/api/v1/conversation/stop`, { method: "POST", headers: headers(t), body: "{}" });
209 expect((await stop(token)).status).toBe(409);
210 const id = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id;
211 expect((await stop(createToken(app.db, id, "reader2", "ro").token)).status).toBe(403);
212 expect((await stop(via)).status).toBe(403);
213 const res = await fetch(`${servers.url}/mcp`, {
214 method: "POST",
215 headers: { ...headers(token), accept: "application/json, text/event-stream" },
216 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "stop", arguments: {} } }),
217 });
218 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
219 expect(body.result.isError).toBe(true);
220 expect(body.result.content[0]!.text).toContain("no answer is running");
221 });
222
206223 test("the MCP say with no repo speaks to the agent; the agent's own token is refused", async () => {
207224 const said = await mcp(token, { text: "by mcp" });
208225 expect(said.error).toBe(false);
@@ -226,4 +243,28 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t
226243 expect((await exec("talk ")).code).toBe(1);
227244 expect(await persons()).toEqual(["by api", "by mcp", "by ssh $HOME"]);
228245 });
246
247 test("ssh talk /stop stops instead of speaking; with nothing running it says so (#8818)", async () => {
248 expect(isStop("/stop")).toBe(true);
249 expect(isStop(" --stop ")).toBe(true);
250 expect(isStop("please /stop")).toBe(false);
251 await settle();
252 const before = await persons();
253 const r = await exec("talk /stop");
254 expect(r.code).toBe(1);
255 expect(await persons()).toEqual(before);
256 });
257
258 test("the MCP stop with a run and no repo, or a repo and no run, is an error", async () => {
259 for (const args of [{ run: "x" }, { repo: "alice/x" }]) {
260 const res = await fetch(`${servers.url}/mcp`, {
261 method: "POST",
262 headers: { ...headers(token), accept: "application/json, text/event-stream" },
263 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "stop", arguments: args } }),
264 });
265 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
266 expect(body.result.isError).toBe(true);
267 expect(body.result.content[0]!.text).toContain("give both");
268 }
269 });
229270 });
test/conversation-turn.test.ts +43 −1
@@ -19,7 +19,8 @@ import { addSubscription, getSubscription, PLATFORM, setGrants, wakeSubscription
1919 import { verifyToken } from "../src/core/tokens.ts";
2020 import { createUser, type User } from "../src/core/users.ts";
2121 import { TOOLS } from "../src/crew/claude.ts";
22 import { MCP_SERVER, mcpConfig, RESUMED_LINE, resumeInterrupted, say, unendedLine, TURNS_LIVE_MAX, type ConversationOptions } from "../src/crew/conversation.ts";
22 import { answerHeld, hold, waitingFor } from "../src/core/fence.ts";
23 import { MCP_SERVER, mcpConfig, RESUMED_LINE, resumeInterrupted, say, STOPPED_LINE, stopTurn, turnRunning, unendedLine, TURNS_LIVE_MAX, type ConversationOptions } from "../src/crew/conversation.ts";
2324
2425 let dir: string;
2526 let app: App;
@@ -413,3 +414,44 @@ echo '{"type":"result","subtype":"success","is_error":true,"result":"You have hi
413414 if (next.ok) await next.value.done;
414415 });
415416 });
417
418 describe("stopping a turn (#8818)", () => {
419 test("a stopped turn leaves no process, keeps what was said, marked stopped, and cancels a pending confirm", async () => {
420 const u = { value: alice };
421 const pidFile = join(dir, "stop.pid");
422 const path = join(dir, "stop-claude");
423 writeFileSync(
424 path,
425 `#!/bin/sh
426 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
427 IFS= read -r first
428 sleep 300 &
429 echo $! > "${pidFile}"
430 echo '{"type":"system","subtype":"init","session_id":"sess-stop","model":"fake"}'
431 echo '{"type":"assistant","session_id":"sess-stop","message":{"id":"m1","content":[{"type":"text","text":"Looking into it."}],"usage":{"input_tokens":1,"output_tokens":1}}}'
432 sleep 300
433 `,
434 );
435 chmodSync(path, 0o755);
436 const said = say(app, options(path), u.value.id, "do a slow thing");
437 if (!said.ok || said.value.done === undefined) throw new Error("not started");
438 for (let i = 0; i < 100 && !turns(u.value).some((t) => t === "agent:Looking into it."); i++) await Bun.sleep(50);
439 expect(turns(u.value)).toContain("agent:Looking into it.");
440 const pid = Number(readFileSync(pidFile, "utf8").trim());
441 const via = `conversation:${openConversation(app.db, u.value.id).id}`;
442 const held = hold(app.db, via, u.value.id, "create_issue", { title: "x" });
443 expect(held).not.toBeNull();
444 expect(stopTurn(app, u.value.id)).toBe(true);
445 await said.value.done;
446 expect(turnRunning(app, u.value.id)).toBe(false);
447 expect(turns(u.value).slice(-3)).toEqual(["person:do a slow thing", "agent:Looking into it.", `agent:${STOPPED_LINE}`]);
448 expect(waitingFor(app.db, u.value.id)).toEqual([]);
449 expect(answerHeld(app.db, held!, u.value.id).ok).toBe(false);
450 let alive = true;
451 for (let i = 0; i < 60 && alive; i++) {
452 try { process.kill(pid, 0); await Bun.sleep(50); } catch { alive = false; }
453 }
454 expect(alive).toBe(false);
455 expect(stopTurn(app, u.value.id)).toBe(false);
456 }, 20_000);
457 });
test/talk-page.test.ts +19 −3
@@ -146,6 +146,21 @@ describe("the page", () => {
146146 });
147147
148148 describe("the script, driven against a DOM", () => {
149 test("while a reply streams Send is Stop, Escape stops too, and it is Send again when the turn ends", async () => {
150 const t = await boot();
151 const button = t.doc.querySelector(".bar .btn");
152 expect(button.textContent).toBe("Send");
153 t.sources.at(-1)!.on["running"]!({ data: "{}" });
154 expect(button.textContent).toBe("Stop");
155 button.dispatchEvent(new t.window.MouseEvent("click", { bubbles: true, cancelable: true }));
156 t.doc.dispatchEvent(new t.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
157 expect(t.urls).toEqual(["/api/v1/conversation/stop", "/api/v1/conversation/stop"]);
158 t.sources.at(-1)!.on["done"]!({ data: "{}" });
159 expect(button.textContent).toBe("Send");
160 t.doc.dispatchEvent(new t.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
161 expect(t.urls).toHaveLength(2);
162 });
163
149164 const boot = async (user = "u1", store: Record<string, string> = {}) => {
150165 const sources: Array<{ on: Record<string, (e: { data: string }) => void> }> = [];
151166 const { Window } = await import("happy-dom");
@@ -154,10 +169,11 @@ describe("the page", () => {
154169 const doc = window.document as any;
155170 doc.body.innerHTML = `<div class="talk" data-talk data-user="${user}" data-paged="0" data-running="1"><div>
156171 <ol class="talk-log" role="log" data-last=""><li class="talk-turn talk-agent" id="t-1"><div class="talk-body"><pre><code>let a = 1;</code></pre></div><p class="talk-meta">Forge · now</p></li></ol><div id="end"></div></div>
157 <form class="talk-compose"><p class="talk-status" role="status"></p><textarea name="text"></textarea><span data-hint></span></form></div>`;
172 <form class="talk-compose"><p class="talk-status" role="status"></p><textarea name="text"></textarea><div class="bar"><span data-hint></span><button class="btn" type="submit">Send</button></div></form></div>`;
158173 const sent: string[] = [];
174 const urls: string[] = [];
159175 const copied: string[] = [];
160 const fetchStub = async (_u: string, init: { body: string }) => { sent.push(JSON.parse(init.body).text); return { ok: true, json: async () => ({}) }; };
176 const fetchStub = async (u: string, init: { body: string }) => { urls.push(u); sent.push(JSON.parse(init.body).text); return { ok: true, json: async () => ({}) }; };
161177 const local = { getItem: (k: string) => store[k] ?? null, setItem: (k: string, v: string) => { store[k] = v; }, removeItem: (k: string) => { delete store[k]; } };
162178 const nav = { clipboard: { writeText: async (t: string) => { copied.push(t); } } };
163179 class ES { on: Record<string, (e: { data: string }) => void> = {}; constructor() { sources.push(this); } addEventListener(n: string, f: (e: { data: string }) => void) { this.on[n] = f; } close() {} }
@@ -166,7 +182,7 @@ describe("the page", () => {
166182 const box = doc.querySelector("textarea");
167183 const key = (init: object) => box.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...init }));
168184 const emit = (id: string, html: string) => sources.at(-1)!.on["turn"]!({ data: JSON.stringify({ id, html }) });
169 return { emit, window, doc, box, sent, copied, store, key, status: doc.querySelector(".talk-status") };
185 return { emit, sources, urls, window, doc, box, sent, copied, store, key, status: doc.querySelector(".talk-status") };
170186 };
171187 const tick = () => new Promise((r) => setTimeout(r, 5));
172188

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs