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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8818-talk-has-stop-a-turn-can-be-ended-mid-re → main 2 commits fork 3 threads · 3 unresolved
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, | ||
| 18 | 18 | |
| 19 | 19 | 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. |
| 20 | 20 | |
| 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". | |
| 29 | ||
| 30 | It checks identity, same origin and token scope. Viewers of `/talk` see the `Stopped.` turn arrive over the stream. | |
| 31 | ||
| 21 | 32 | ## Chat features: have, adding, not adding (#8752) |
| 22 | 33 | |
| 23 | 34 | 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 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 10 | 10 | 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"; | |
| 13 | 13 | |
| 14 | 14 | /** Messages a person may send in a minute. */ |
| 15 | 15 | export const TALK_PER_MINUTE = 12; |
| @@ -45,3 +45,10 @@ export function talk(app: App, user: User | undefined, text: string): Result<Sai | ||
| 45 | 45 | said.value.done.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) })); |
| 46 | 46 | return said; |
| 47 | 47 | } |
| 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"; | ||
| 212 | 212 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 213 | 213 | export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts"; |
| 214 | 214 | export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts"; |
| 215 | export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts"; | |
| 215 | 216 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; |
| 216 | 217 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 217 | 218 | 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( | ||
| 275 | 275 | })(); |
| 276 | 276 | } |
| 277 | 277 | |
| 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 | ||
| 278 | 289 | /** A conversation's held writes, oldest first. */ |
| 279 | 290 | export function heldIn(db: Database, via: string): Held[] { |
| 280 | 291 | return db |
src/crew/conversation.ts +38 −3
| @@ -21,7 +21,7 @@ import type { App } from "../app.ts"; | ||
| 21 | 21 | import { dailyLeft } from "../core/account_ceiling.ts"; |
| 22 | 22 | import { appendTurn, listTurns, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts"; |
| 23 | 23 | 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"; | |
| 25 | 25 | import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts"; |
| 26 | 26 | import { getUserById } from "../core/users.ts"; |
| 27 | 27 | import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts"; |
| @@ -96,6 +96,27 @@ export const unendedLine = (tools: readonly string[]): string => { | ||
| 96 | 96 | const RESUME_PROMPT = |
| 97 | 97 | "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."; |
| 98 | 98 | |
| 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 | ||
| 99 | 120 | /** Turns with a session listening, by conversation id: the run id a message is steered to. */ |
| 100 | 121 | const live = new Map<string, string>(); |
| 101 | 122 | |
| @@ -277,6 +298,9 @@ function begin( | ||
| 277 | 298 | ) |
| 278 | 299 | .run(runId, conversation.id, userId, subscriptionId, prompt, resumes, resumeSession ?? "", now.toISOString()); |
| 279 | 300 | 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]); | |
| 280 | 304 | const put = (role: "agent" | "tool", text: string): void => { |
| 281 | 305 | const stored = appendTurn(app.db, userId, conversation.id, { role, text: text.slice(0, 20_000) }); |
| 282 | 306 | if (stored.ok && role === "agent") streamed.push({ id: stored.value.id, text }); |
| @@ -319,7 +343,7 @@ function begin( | ||
| 319 | 343 | label: "conversation", |
| 320 | 344 | timeoutMs: options.timeoutMs, |
| 321 | 345 | log: (line) => appendRunLog(app.config.dataDir, runId, line), |
| 322 | signal: options.signal, | |
| 346 | signal, | |
| 323 | 347 | mcp: { config: mcpConfig(options.localUrl), allow: [`mcp__${MCP_SERVER}`] }, |
| 324 | 348 | onSteer: (send) => { |
| 325 | 349 | unlisten = listenForSteer(runId, send); |
| @@ -340,7 +364,7 @@ function begin( | ||
| 340 | 364 | app.db |
| 341 | 365 | .query("UPDATE conversation_run SET resume_outcome = ?2, version = ?3 WHERE id = ?1") |
| 342 | 366 | .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; | |
| 344 | 368 | appendRunLog(app.config.dataDir, runId, "no session to resume; starting this message fresh"); |
| 345 | 369 | // What the failed attempt showed (its error may be printed as text) is taken back. |
| 346 | 370 | 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( | ||
| 351 | 375 | } finally { |
| 352 | 376 | unlisten(); |
| 353 | 377 | 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 |
||
| 354 | 379 | } |
| 355 | 380 | const cost = outcome?.costUsd ?? 0; |
| 356 | 381 | // Stopped for a restart: the row stays `running` for the next boot to |
| @@ -363,6 +388,16 @@ function begin( | ||
| 363 | 388 | return; |
| 364 | 389 | } |
| 365 | 390 | 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 | } | |
| 366 | 401 | const answer = outcome?.result ?? ""; |
| 367 | 402 | if (outcome !== undefined && isRateLimited(outcome)) { |
| 368 | 403 | // 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 @@ | ||
| 7 | 7 | * request is same-origin, and sending is throttled per person (`actions/talk.ts`). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { TALK_WORDS, talk } from "../../actions/talk.ts"; | |
| 10 | import { stopTalk, TALK_WORDS, talk } from "../../actions/talk.ts"; | |
| 11 | 11 | import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation } from "../../core/conversations.ts"; |
| 12 | 12 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 13 | 13 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| @@ -48,6 +48,16 @@ export function conversationTalkRoutes(router: Router<Handler>): void { | ||
| 48 | 48 | return json({ how: said.value.how }, 202); |
| 49 | 49 | }); |
| 50 | 50 | |
| 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 | ||
| 51 | 61 | router.add("GET", "/api/v1/conversation/turns", (ctx) => { |
| 52 | 62 | const user = ctx.auth.user; |
| 53 | 63 | 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"; | |
| 1 | 2 | import { cancelRepoRun } from "../../../actions/runs.ts"; |
| 2 | 3 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| 5 | 6 | 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", | |
| 7 | 8 | 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.", | |
| 9 | 10 | inputSchema: { |
| 10 | 11 | type: "object", |
| 11 | 12 | properties: { repo: REPO_ARG, run: { type: "string" } }, |
| 12 | required: ["repo", "run"], | |
| 13 | required: [], | |
| 13 | 14 | }, |
| 14 | 15 | writes: true, |
| 15 | 16 | run(ctx, user, args) { |
With |
||
| 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"); | |
| 16 | 24 | const repo = repoArg(ctx, user, args); |
| 17 | 25 | if (isAnswer(repo)) return repo; |
| 18 | 26 | const stopped = cancelRepoRun(ctx.app, user, repo, str(args["run"])); |
src/tui/ssh-talk.ts +18 −2
| @@ -4,10 +4,12 @@ | ||
| 4 | 4 | * as the account the presented key belongs to, and prints that the agent is |
| 5 | 5 | * answering; the answer is read back with `forge talk` or on the feed. The |
| 6 | 6 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | import { TALK_WORDS, talk } from "../actions/talk.ts"; | |
| 12 | import { TALK_WORDS, stopTalk, talk } from "../actions/talk.ts"; | |
| 11 | 13 | import type { App } from "../app.ts"; |
| 12 | 14 | import type { User } from "../core/users.ts"; |
| 13 | 15 | import { CONTROL_WORDS } from "./ssh-tell.ts"; |
| @@ -22,6 +24,11 @@ export function parseTalkCommand(command: string | undefined): string | undefine | ||
| 22 | 24 | return m === null ? undefined : (m[1] ?? ""); |
| 23 | 25 | } |
| 24 | 26 | |
| 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 | ||
| 25 | 32 | /** Run one `talk` exec for `user` (undefined: an unknown key). */ |
| 26 | 33 | export function serveTalk(app: App, session: SshSession, user: User | undefined, words: string): void { |
| 27 | 34 | const refuse = (text: string): void => { |
| @@ -29,6 +36,15 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined, | ||
| 29 | 36 | session.exit(1); |
| 30 | 37 | }; |
| 31 | 38 | 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 | } | |
| 32 | 48 | const said = talk(app, user, words); |
| 33 | 49 | if (!said.ok) return refuse(TALK_WORDS[said.error][1]); |
| 34 | 50 | session.write( |
src/web/pages/talk_client.ts +22 −2
| @@ -99,6 +99,26 @@ if (root) { | ||
| 99 | 99 | }); |
| 100 | 100 | }; |
| 101 | 101 | 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(); } }); | |
| 102 | 122 | const follow = () => { |
| 103 | 123 | if (paged) return; |
| 104 | 124 | if (source) source.close(); |
| @@ -110,8 +130,8 @@ if (root) { | ||
| 110 | 130 | retry = 0; |
| 111 | 131 | toEnd(); |
| 112 | 132 | }); |
| 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; }); | |
| 115 | 135 | source.addEventListener("more", () => { source.close(); follow(); }); |
| 116 | 136 | source.addEventListener("drain", () => { source.close(); setTimeout(follow, 1000); }); |
| 117 | 137 | source.onerror = () => { |
test/conversation-talk.test.ts +42 −1
| @@ -20,7 +20,7 @@ import { TALK_PER_MINUTE } from "../src/actions/talk.ts"; | ||
| 20 | 20 | import { turnsLimit } from "../src/http/api/conversation_talk.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 22 | 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"; | |
| 24 | 24 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | 25 | |
| 26 | 26 | quietLogs(); |
| @@ -203,6 +203,23 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t | ||
| 203 | 203 | expect(res.status).toBe(403); |
| 204 | 204 | }); |
| 205 | 205 | |
| 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 | ||
| 206 | 223 | test("the MCP say with no repo speaks to the agent; the agent's own token is refused", async () => { |
| 207 | 224 | const said = await mcp(token, { text: "by mcp" }); |
| 208 | 225 | expect(said.error).toBe(false); |
| @@ -226,4 +243,28 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t | ||
| 226 | 243 | expect((await exec("talk ")).code).toBe(1); |
| 227 | 244 | expect(await persons()).toEqual(["by api", "by mcp", "by ssh $HOME"]); |
| 228 | 245 | }); |
| 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 | }); | |
| 229 | 270 | }); |
test/conversation-turn.test.ts +43 −1
| @@ -19,7 +19,8 @@ import { addSubscription, getSubscription, PLATFORM, setGrants, wakeSubscription | ||
| 19 | 19 | import { verifyToken } from "../src/core/tokens.ts"; |
| 20 | 20 | import { createUser, type User } from "../src/core/users.ts"; |
| 21 | 21 | 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"; | |
| 23 | 24 | |
| 24 | 25 | let dir: string; |
| 25 | 26 | let app: App; |
| @@ -413,3 +414,44 @@ echo '{"type":"result","subtype":"success","is_error":true,"result":"You have hi | ||
| 413 | 414 | if (next.ok) await next.value.done; |
| 414 | 415 | }); |
| 415 | 416 | }); |
| 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", () => { | ||
| 146 | 146 | }); |
| 147 | 147 | |
| 148 | 148 | 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 | ||
| 149 | 164 | const boot = async (user = "u1", store: Record<string, string> = {}) => { |
| 150 | 165 | const sources: Array<{ on: Record<string, (e: { data: string }) => void> }> = []; |
| 151 | 166 | const { Window } = await import("happy-dom"); |
| @@ -154,10 +169,11 @@ describe("the page", () => { | ||
| 154 | 169 | const doc = window.document as any; |
| 155 | 170 | doc.body.innerHTML = `<div class="talk" data-talk data-user="${user}" data-paged="0" data-running="1"><div> |
| 156 | 171 | <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>`; | |
| 158 | 173 | const sent: string[] = []; |
| 174 | const urls: string[] = []; | |
| 159 | 175 | 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 () => ({}) }; }; | |
| 161 | 177 | const local = { getItem: (k: string) => store[k] ?? null, setItem: (k: string, v: string) => { store[k] = v; }, removeItem: (k: string) => { delete store[k]; } }; |
| 162 | 178 | const nav = { clipboard: { writeText: async (t: string) => { copied.push(t); } } }; |
| 163 | 179 | 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", () => { | ||
| 166 | 182 | const box = doc.querySelector("textarea"); |
| 167 | 183 | const key = (init: object) => box.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...init })); |
| 168 | 184 | 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") }; | |
| 170 | 186 | }; |
| 171 | 187 | const tick = () => new Promise((r) => setTimeout(r, 5)); |
| 172 | 188 |
new above
offline
Non-blocking: the route answers 202, not 200.