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

/talk can edit and resend a message, regenerate a reply, and Retry a failed turn #8844

Merged opened this

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

Merged

mason/forge:mason/8817-talk-can-edit-and-resend-a-message-regen → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/8817-talk-can-edit-and-resend-a-message-regen was merged into main as 8fb7ab4.

Review

Sign in to review this change.
14 files changed +508 −56
docs/design/areas/talk.md +4
@@ -18,5 +18,9 @@
1818 - `.talk-copy` — a quiet text button that copies a message or a code block.
1919 - `.talk-suggest` — the starter questions on an empty chat; each fills the composer.
2020 - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end.
21 - `.talk-replaced` — a turn the person edited, regenerated or retried past: kept in the log, dimmer, labelled "replaced".
22 - `.talk-failed` — the plain-words line for a run that could not answer; Retry sits under it.
23 - `.talk-acts` — the row under the last message and last reply holding Edit, Regenerate or Retry; server-drawn, so it works without the script.
24 - `.talk-act` — one of those controls, at 44px high.
2125
2226 Under a reply, `.talk-cost` is one quiet line with the turn's dollars; above the log, `.talk-context` says how full the context is and, from 80 %, suggests a new conversation. Both are `small muted` text, no new colour.
docs/features/talk.md +14 −1
@@ -18,6 +18,18 @@ 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 ## Edit, regenerate, retry (#8817)
22
23 Three actions on the last exchange, one door, `talkAgain()` in `src/actions/talk.ts` (the same 12-a-minute throttle and refusals as sending):
24
25 - **Edit** replaces your last message with new words and asks again. **Regenerate** asks the same message again for a new reply. **Retry** asks again after a failed turn.
26 - What is replaced stays in the conversation, marked replaced (`replaced: true` in `GET /api/v1/conversation/turns`), dimmed on the page; the agent no longer reads it, and the new turn starts a fresh session from the turns that still stand.
27 - A turn that could not answer (a failed run, a resting account) says what happened in plain words, never the raw error, and shows **Retry**.
28 - Refused while a turn runs (`running`, 409), or when there is nothing to act on (`nothing`, 409).
29 - **Web:** Edit under your last message (opens the composer holding it, `/talk?edit=1`; Send resends), Regenerate under the last reply, Retry under a failed one. All are plain forms or links, so they work without the script, which only does them in place. They post to `POST /talk/again/edit|regenerate|retry`, from your own signed-in browser only.
30 - **Terminal:** `ssh <forge> talk-again edit <words>`, `ssh <forge> talk-again regenerate`, `ssh <forge> talk-again retry`.
31 - There is no API or MCP door for these yet.
32
2133 ## Stop
2234
2335 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.
@@ -44,7 +56,8 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha
4456 | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` |
4557 | Stick to bottom, "Latest" jump button | this pull | AI Elements `Conversation` |
4658 | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person |
47 | Edit and resend, regenerate, Stop, queue | next pulls | need turn deletion and a cancel on `crew/conversation.ts` |
59 | Edit and resend, regenerate, Retry (see below) | have (#8817) | replaced turns are kept and marked, not deleted |
60 | Queue | next pull | needs a queue on `crew/conversation.ts` |
4861 | Cost under each reply and how full the context is (header, and in the terminal) | this pull | the run's own usage record, never an estimate |
4962 | Attachments, tables, thinking, plan line | later | need storage / token data / markdown tables |
5063 | Code highlighting and tables in replies | this pull (#8816) | a reply is stored whole and drawn whole, so no half-open fence is ever tokenised |
src/actions/talk.ts +30 −9
@@ -8,13 +8,13 @@
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, stopTurn, type SayRefusal, type Said } from "../crew/conversation.ts";
11 import { conversationOptions, redo, say, stopTurn, type RedoHow, type RedoRefusal, type SayRefusal, type Said } from "../crew/conversation.ts";
1212 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;
1616
17 export type TalkRefusal = SayRefusal | "off" | "rate_limited";
17 export type TalkRefusal = SayRefusal | RedoRefusal | "off" | "rate_limited";
1818
1919 /** What each refusal says, with its status. */
2020 export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]>> = {
@@ -25,24 +25,45 @@ export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]>
2525 resting: [429, "the account that serves you is resting or out of budget; try again later"],
2626 busy: [429, "the forge's agent is answering as many people as it can at once; try again in a minute"],
2727 off: [503, "the forge's agent is not running here"],
28 running: [409, "the forge is still answering; wait for it to finish"],
29 nothing: [409, "there is nothing here to do that to"],
2830 rate_limited: [429, `at most ${TALK_PER_MINUTE} messages a minute`],
2931 };
3032
3133 const limiters = new WeakMap<App, RateLimiter>();
3234
33 export function talk(app: App, user: User | undefined, text: string): Result<Said, TalkRefusal> {
34 if (user === undefined) return err("unauthorized");
35 const options = conversationOptions(app);
36 if (options === undefined) return err("off");
35 /** Throttle by identity: false past `TALK_PER_MINUTE` a minute. */
36 function take(app: App, user: User): boolean {
3737 let limiter = limiters.get(app);
3838 if (limiter === undefined) {
3939 limiter = new RateLimiter({ perMinute: TALK_PER_MINUTE, adminPerMinute: TALK_PER_MINUTE, enabled: true });
4040 limiters.set(app, limiter);
4141 }
42 if (!limiter.take(`talk:${user.id}`, "rw").allowed) return err("rate_limited");
42 return limiter.take(`talk:${user.id}`, "rw").allowed;
43 }
44
45 const watch = (app: App, said: Said): void => {
46 said.done?.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) }));
47 };
48
49 export function talk(app: App, user: User | undefined, text: string): Result<Said, TalkRefusal> {
50 if (user === undefined) return err("unauthorized");
51 const options = conversationOptions(app);
52 if (options === undefined) return err("off");
53 if (!take(app, user)) return err("rate_limited");
4354 const said = say(app, options, user.id, text);
44 if (said.ok && said.value.done !== undefined)
45 said.value.done.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) }));
55 if (said.ok) watch(app, said.value);
56 return said;
57 }
58
59 /** Edit the last message and resend it, regenerate the last reply, or retry a failed turn (#8817). */
60 export function talkAgain(app: App, user: User | undefined, how: RedoHow, text = ""): Result<Said, TalkRefusal> {
61 if (user === undefined) return err("unauthorized");
62 const options = conversationOptions(app);
63 if (options === undefined) return err("off");
64 if (!take(app, user)) return err("rate_limited");
65 const said = redo(app, options, user.id, how, text);
66 if (said.ok) watch(app, said.value);
4667 return said;
4768 }
4869
src/core/conversations.ts +53 −3
@@ -33,6 +33,10 @@ export interface Turn {
3333 readonly role: TurnRole;
3434 readonly text: string;
3535 readonly createdAt: string;
36 /** Set aside by an edit, a regenerate or a retry: kept in history, no longer part of the conversation. */
37 readonly replaced: boolean;
38 /** The line saying a run could not answer; the page draws Retry beside it. */
39 readonly failed: boolean;
3640 }
3741
3842 interface ConversationRow {
@@ -49,6 +53,8 @@ interface TurnRow {
4953 role: TurnRole;
5054 text: string;
5155 created_at: string;
56 replaced_at: string | null;
57 failed: number;
5258 }
5359
5460 const conversationOf = (r: ConversationRow): Conversation => ({
@@ -65,6 +71,8 @@ const turnOf = (r: TurnRow): Turn => ({
6571 role: r.role,
6672 text: r.text,
6773 createdAt: r.created_at,
74 replaced: r.replaced_at !== null,
75 failed: r.failed === 1,
6876 });
6977
7078 /** The person's conversation, made on first use. */
@@ -89,7 +97,7 @@ export function appendTurn(
8997 db: Database,
9098 userId: string,
9199 conversationId: string,
92 input: { readonly role: string; readonly text: string },
100 input: { readonly role: string; readonly text: string; readonly failed?: boolean },
93101 ): Result<Turn, "not_found" | "bad_role" | "empty_text" | "text_too_long"> {
94102 if (!owned(db, userId, conversationId)) return err("not_found");
95103 if (!(TURN_ROLES as readonly string[]).includes(input.role)) return err("bad_role");
@@ -102,10 +110,12 @@ export function appendTurn(
102110 role: input.role as TurnRole,
103111 text: input.text,
104112 created_at: now,
113 replaced_at: null,
114 failed: input.failed === true ? 1 : 0,
105115 };
106116 db.query(
107 "INSERT INTO conversation_turn (id, conversation_id, role, text, created_at) VALUES (?1,?2,?3,?4,?5)",
108 ).run(row.id, row.conversation_id, row.role, row.text, row.created_at);
117 "INSERT INTO conversation_turn (id, conversation_id, role, text, created_at, failed) VALUES (?1,?2,?3,?4,?5,?6)",
118 ).run(row.id, row.conversation_id, row.role, row.text, row.created_at, row.failed);
109119 db.query("UPDATE conversation SET updated_at = ?2 WHERE id = ?1").run(conversationId, now);
110120 db.query(
111121 "DELETE FROM conversation_turn WHERE conversation_id = ?1 AND rowid < (SELECT rowid FROM conversation_turn WHERE conversation_id = ?1 ORDER BY rowid DESC LIMIT 1 OFFSET ?2)",
@@ -119,6 +129,46 @@ export function removeTurn(db: Database, userId: string, conversationId: string,
119129 db.query("DELETE FROM conversation_turn WHERE id = ?1 AND conversation_id = ?2").run(turnId, conversationId);
120130 }
121131
132 /**
133 * Set aside the turn `fromId` and every turn after it that still stands: they
134 * stay in history, marked replaced. Returns the ids set aside, so a refusal
135 * can put them back (`restoreTurns`).
136 */
137 export function setAsideFrom(db: Database, userId: string, conversationId: string, fromId: string): string[] {
138 if (!owned(db, userId, conversationId)) return [];
139 const now = new Date().toISOString();
140 const rows = db
141 .query(
142 "UPDATE conversation_turn SET replaced_at = ?3 WHERE conversation_id = ?1 AND replaced_at IS NULL AND rowid >= (SELECT rowid FROM conversation_turn WHERE id = ?2 AND conversation_id = ?1) RETURNING id",
143 )
144 .all(conversationId, fromId, now) as { id: string }[];
145 return rows.map((r) => r.id);
146 }
147
148 /** Put back turns just set aside, as when the message that replaced them could not be answered. */
149 export function restoreTurns(db: Database, userId: string, conversationId: string, ids: readonly string[]): void {
150 if (!owned(db, userId, conversationId)) return;
151 for (const turnId of ids)
152 db.query("UPDATE conversation_turn SET replaced_at = NULL WHERE id = ?1 AND conversation_id = ?2").run(turnId, conversationId);
153 }
154
155 /**
156 * The person's last message that still stands and the turns after it, oldest
157 * first; `person` is undefined when none stands. Edit, Regenerate and Retry
158 * each look at this and at nothing older.
159 */
160 export function lastExchange(db: Database, userId: string, conversationId: string): { person: Turn | undefined; after: Turn[] } {
161 if (!owned(db, userId, conversationId)) return { person: undefined, after: [] };
162 const row = db
163 .query("SELECT rowid AS at, * FROM conversation_turn WHERE conversation_id = ?1 AND role = 'person' AND replaced_at IS NULL ORDER BY rowid DESC LIMIT 1")
164 .get(conversationId) as (TurnRow & { at: number }) | null;
165 if (row === null) return { person: undefined, after: [] };
166 const after = db
167 .query("SELECT * FROM conversation_turn WHERE conversation_id = ?1 AND replaced_at IS NULL AND rowid > ?2 ORDER BY rowid LIMIT ?3")
168 .all(conversationId, row.at, MAX_PAGE) as TurnRow[];
169 return { person: turnOf(row), after: after.map(turnOf) };
170 }
171
122172 /**
123173 * A page of turns, newest last: the latest `limit` turns, or the `limit`
124174 * before the turn id `before`, oldest first. Order is insertion order
src/core/migration-pins/20261103120000-conversation-turn-replaced.pin added +1
@@ -0,0 +1,1 @@
1 1242865cab98a16af1fac1be2a9465c994f0ddffee0c3404e156f1fd8d215992
src/core/migrations/20261103120000-conversation-turn-replaced.ts added +7
@@ -0,0 +1,7 @@
1 // A /talk turn the person edited, regenerated or retried is kept and marked,
2 // not deleted (#8817): `replaced_at` is when it was set aside, and `failed`
3 // marks the line that says a run could not answer, which Retry answers.
4 export default `
5 ALTER TABLE conversation_turn ADD COLUMN replaced_at TEXT;
6 ALTER TABLE conversation_turn ADD COLUMN failed INTEGER NOT NULL DEFAULT 0;
7 `;
src/crew/conversation.ts +66 −9
@@ -19,7 +19,7 @@ import { chmodSync, mkdirSync } from "node:fs";
1919 import { join } from "node:path";
2020 import type { App } from "../app.ts";
2121 import { dailyLeft } from "../core/account_ceiling.ts";
22 import { appendTurn, listTurns, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts";
22 import { appendTurn, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts";
2323 import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts";
2424 import { cancelHeld, WORDS_MAX } from "../core/fence.ts";
2525 import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts";
@@ -163,7 +163,7 @@ export function withHistory(app: App, conversation: Conversation, prompt: string
163163 const before = last !== undefined && last.role === "person" && last.text === prompt ? stored.slice(0, -1) : stored;
164164 const lines: string[] = [];
165165 let size = 0;
166 for (const t of before.filter((t) => t.role !== "confirm").reverse()) {
166 for (const t of before.filter((t) => t.role !== "confirm" && !t.replaced && !t.failed).reverse()) {
167167 const line = `${t.role === "person" ? "Person" : t.role === "agent" ? "You" : "Tool"}: ${t.text.slice(0, HISTORY_TURN_CHARS)}`;
168168 if (size + line.length > HISTORY_CHARS) break;
169169 size += line.length;
@@ -217,6 +217,52 @@ export function say(app: App, options: ConversationOptions, userId: string, text
217217 return ok({ how: "started", done: started.value });
218218 }
219219
220 export type RedoHow = "edit" | "regenerate" | "retry";
221 export type RedoRefusal = SayRefusal | "running" | "nothing";
222
223 /**
224 * Ask again, in place of the last exchange (#8817). `edit` replaces the
225 * person's last message with `text`; `regenerate` asks the same message again
226 * for a new reply; `retry` asks it again after a failed run. What is replaced
227 * is kept in history, marked, and the agent no longer reads it: the turn
228 * starts a fresh session whose context is the turns that still stand. Refused
229 * while a turn runs, or when the last exchange is not what the action is for.
230 */
231 export function redo(app: App, options: ConversationOptions, userId: string, how: RedoHow, text = ""): Result<Said, RedoRefusal> {
232 const user = getUserById(app.db, userId);
233 if (user === undefined) return err("unauthorized");
234 const conversation = openConversation(app.db, userId);
235 if (live.has(conversation.id)) return err("running");
236 const { person, after } = lastExchange(app.db, userId, conversation.id);
237 if (person === undefined) return err("nothing");
238 const last = after.at(-1);
239 if (how === "retry" && last?.failed !== true) return err("nothing");
240 if (how === "regenerate" && (last === undefined || last.role !== "agent" || last.failed)) return err("nothing");
241 if (how === "edit") {
242 if (text.trim() === "") return err("empty");
243 if (text.length > WORDS_MAX) return err("too_long");
244 }
245 if (live.size >= (options.liveMax ?? TURNS_LIVE_MAX)) return err("busy");
246 const words = how === "edit" ? text : person.text;
247 // Edit sets the message itself aside; the others keep it and set aside what answered it.
248 const first = how === "edit" ? person.id : (after[0]?.id ?? "");
249 const aside = first === "" ? [] : setAsideFrom(app.db, userId, conversation.id, first);
250 const refuse = (why: RedoRefusal, added?: string): Result<Said, RedoRefusal> => {
251 if (added !== undefined) removeTurn(app.db, userId, conversation.id, added);
252 restoreTurns(app.db, userId, conversation.id, aside);
253 return err(why);
254 };
255 let added: string | undefined;
256 if (how === "edit") {
257 const stored = appendTurn(app.db, userId, conversation.id, { role: "person", text });
258 if (!stored.ok) return refuse("too_long");
259 added = stored.value.id;
260 }
261 const started = begin(app, options, conversation, withHistory(app, conversation, words), 0, undefined);
262 if (!started.ok) return refuse(started.error, added);
263 return ok({ how: "started", done: started.value });
264 }
265
220266 /**
221267 * Turns the last run of the forge left `running` are resumed once, from their
222268 * stored session, with one line to the person. A turn already resumed once
@@ -262,6 +308,13 @@ function limitLine(app: App, picked: Pick, message: string): string {
262308 : `${account.name} is resting until ${whenText(until)}.`;
263309 }
264310
311 /** What a run that did not end well says to the person: what happened, never the raw error. */
312 export function failureLine(reason: string): string {
313 if (reason === "timeout") return "That took longer than a turn may, so I stopped.";
314 if (reason === "max_budget_reached") return "That would have cost more than one turn may spend, so I stopped. Ask for less, or try again.";
315 return "I could not answer that just now.";
316 }
317
265318 function begin(
266319 app: App,
267320 options: ConversationOptions,
@@ -399,15 +452,19 @@ function begin(
399452 return;
400453 }
401454 const answer = outcome?.result ?? "";
402 if (outcome !== undefined && isRateLimited(outcome)) {
403 // The provider's words are not shown: the streamed copy of them goes.
455 // A turn that could not answer says so in plain words, flagged so the page draws Retry;
456 // whatever the provider or process printed, streamed as text, is taken back first.
457 const couldNot = (text: string, raw: (said: string) => boolean): void => {
458 for (const turn of streamed) if (raw(turn.text)) removeTurn(app.db, userId, conversation.id, turn.id);
459 appendTurn(app.db, userId, conversation.id, { role: "agent", text, failed: true });
460 };
461 if (outcome === undefined) couldNot(failureLine("error"), () => false);
462 else if (isRateLimited(outcome)) {
404463 const first = outcome.result.split("\n").find((l) => l.trim() !== "")?.trim() ?? "";
405 for (const turn of streamed)
406 if (turn.text === outcome.result || (first !== "" && turn.text.includes(first))) removeTurn(app.db, userId, conversation.id, turn.id);
407 put("agent", limitLine(app, picked, outcome.result));
408 }
464 couldNot(limitLine(app, picked, outcome.result), (said) => said === outcome!.result || (first !== "" && said.includes(first)));
465 } else if (!outcome.ok) couldNot(failureLine(outcome.reason), (said) => said === outcome!.result);
409466 else if (answer.trim() !== "" && answer !== lastText) put("agent", answer);
410 else if (answer.trim() === "" && lastText === "") put("agent", "I could not answer that just now.");
467 else if (answer.trim() === "" && lastText === "") couldNot(failureLine("error"), () => false);
411468 app.db
412469 .query(
413470 "UPDATE conversation_run SET state = ?2, tokens_in = ?3, tokens_out = ?4, cost_usd = ?5, finished_at = ?6, context_tokens = ?7, context_window = ?8 WHERE id = ?1",
src/http/server.ts +2 −1
@@ -124,7 +124,7 @@ import {
124124 verifyPage,
125125 themeSubmit,
126126 } from "../web/pages/site.ts";
127 import { talkConfirm, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
127 import { talkAgainSend, talkConfirm, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
128128 import {
129129 recoverFinish,
130130 recoverOptions,
@@ -841,6 +841,7 @@ export function buildRouter(): Router<Handler> {
841841 router.add("GET", "/talk", talkView);
842842 router.add("POST", "/talk", talkSend);
843843 router.add("GET", "/talk/stream", talkStream);
844 router.add("POST", "/talk/again/:how", talkAgainSend);
844845 router.add("POST", "/talk/confirm/:id", talkConfirm);
845846 router.add("POST", "/signin/passkey/options", signinOptions);
846847 router.add("POST", "/signin/passkey", signinPasskey);
src/tui/ssh-talk.ts +25 −1
@@ -7,12 +7,16 @@
77 * control character is refused before anything runs. `talk /stop` (or
88 * `--stop`) ends the running answer, as Stop does on /talk: what the agent
99 * had said stays, its processes end, a held write is cancelled.
10 *
11 * `talk-again edit <words>`, `talk-again regenerate` and `talk-again retry`
12 * are the same three actions the page has (#8817), through `talkAgain`.
1013 */
1114
12 import { TALK_WORDS, stopTalk, talk } from "../actions/talk.ts";
15 import { TALK_WORDS, stopTalk, talk, talkAgain } from "../actions/talk.ts";
1316 import { contextOf, contextText, dollars, turnCosts } from "../core/conversation_cost.ts";
1417 import { openConversation } from "../core/conversations.ts";
1518 import type { App } from "../app.ts";
19 import type { RedoHow } from "../crew/conversation.ts";
1620 import type { User } from "../core/users.ts";
1721 import { CONTROL_WORDS } from "./ssh-tell.ts";
1822 import type { SshSession } from "../ssh/server.ts";
@@ -62,3 +66,23 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined,
6266 }
6367 session.exit(0);
6468 }
69
70 /** `talk-again <edit words | regenerate | retry>`; undefined when the command is not one. */
71 export function parseTalkAgain(command: string | undefined): { how: RedoHow; words: string } | undefined {
72 if (command === undefined) return undefined;
73 const m = /^talk-again[ \t]+(edit|regenerate|retry)(?:[ \t]+([\s\S]*))?$/.exec(command.trim());
74 return m === null ? undefined : { how: m[1] as RedoHow, words: m[2] ?? "" };
75 }
76
77 /** Run one `talk-again` exec for `user` (undefined: an unknown key). */
78 export function serveTalkAgain(app: App, session: SshSession, user: User | undefined, how: RedoHow, words: string): void {
79 const refuse = (text: string): void => {
80 session.writeStderr(`forge: ${text}${CRLF}`);
81 session.exit(1);
82 };
83 if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(CONTROL_WORDS);
84 const said = talkAgain(app, user, how, words);
85 if (!said.ok) return refuse(TALK_WORDS[said.error][1]);
86 session.write(`the forge's agent is answering again${CRLF}`);
87 session.exit(0);
88 }
src/tui/ssh.ts +6 −1
@@ -29,7 +29,7 @@ import { appForLogin, attachAppTty } from "../apps/tty.ts";
2929 import { servePulls } from "../apps/tty_pulls.ts";
3030 import type { Terminal } from "./term/terminal.ts";
3131 import type { TuiIdentity } from "./session.ts";
32 import { parseTalkCommand, serveTalk } from "./ssh-talk.ts";
32 import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts";
3333 import { parseTellCommand, serveTell } from "./ssh-tell.ts";
3434
3535 const CRLF = "\r\n";
@@ -245,6 +245,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
245245 serveTalk(forge, session, identity.user, talked);
246246 return;
247247 }
248 const again = parseTalkAgain(session.command);
249 if (again !== undefined && door === undefined) {
250 serveTalkAgain(forge, session, identity.user, again.how, again.words);
251 return;
252 }
248253 void serveGitCommand(forge, session, identity.user).catch(
249254 (error: unknown) => {
250255 forge.log.error("git over ssh failed", { error: String(error) });
src/web/pages/talk.ts +86 −22
@@ -6,19 +6,23 @@
66 * inside the edge's timeout) and the client reconnects from the last turn id
77 * it saw. A held action draws Confirm and Cancel buttons; Confirm is the
88 * existing yes (`actions/conversation_confirms.ts`), Cancel is the no.
9 * Edit and Regenerate are drawn by the script on the last message and reply;

Non-blocking: stale. Edit and Regenerate are server-drawn by exchangeActs, not by the script.

10 * a failed turn says in plain words what happened and carries Retry, a plain
11 * form. What they replace stays in the log, marked (#8817).
912 *
1013 * Everything is the signed-in person's own: the conversation is found by
1114 * their id, the agent acts with their permissions (`crew/conversation.ts`).
1215 */
1316
14 import { TALK_WORDS, talk } from "../../actions/talk.ts";
17 import type { Database } from "bun:sqlite";
18 import { TALK_WORDS, talk, talkAgain } from "../../actions/talk.ts";
1519 import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
16 import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts";
20 import { DEFAULT_PAGE, lastExchange, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts";
1721 import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts";
1822 import { WORDS_MAX } from "../../core/fence.ts";
1923 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2024 import { ASKING } from "../../crew/conversation_confirm.ts";
21 import { turnRunning } from "../../crew/conversation.ts";
25 import { turnRunning, type RedoHow } from "../../crew/conversation.ts";
2226 import type { Ctx } from "../../http/context.ts";
2327 import { json } from "../../http/respond.ts";
2428 import { eventStream } from "../../http/sse.ts";
@@ -71,12 +75,33 @@ const meta = (who: string, at: string): Html =>
7175 html`<p class="talk-meta small muted">${who} · ${time(at)}</p>`;
7276
7377 /** A tool call or its outcome as a quiet card: closed, the name alone; open, the whole line. */
74 const toolCard = (id: string, says: string, name: string, at: string): Html =>
75 html`<li class="talk-turn talk-tool" id="${id}"><details><summary>${name.slice(0, 60)} <span class="talk-when">${time(at)}</span></summary><p>${says}</p></details></li>`;
78 const toolCard = (id: string, says: string, name: string, at: string, set = ""): Html =>
79 html`<li class="talk-turn talk-tool${set}" id="${id}"><details><summary>${name.slice(0, 60)} <span class="talk-when">${time(at)}</span></summary><p>${says}</p></details></li>`;
7680
7781 /** What an empty chat offers to start with; each is a link that fills the composer (`?q=`), so it works without the script. */
7882 const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const;
7983
84 /** Ask again as a form, so it works without the script; the script only enhances it. */
85 const againForm = (how: "regenerate" | "retry", label: string, words: string): Html =>
86 html`<form method="post" action="${HERE}/again/${how}" data-again="${how}">${btn(label, { icon: "sync", label: words })}</form>`;
87
88 /**
89 * What the last exchange offers, by the turn it sits under: Edit under the
90 * person's last message, and under the last turn Retry when it failed or
91 * Regenerate when it is a reply. Nothing while a turn runs.
92 */
93 export function exchangeActs(db: Database, userId: string, conversationId: string, running: boolean): Map<string, Html> {
94 const acts = new Map<string, Html>();
95 if (running) return acts;
96 const { person, after } = lastExchange(db, userId, conversationId);
97 if (person === undefined) return acts;
98 acts.set(person.id, html`<a class="btn talk-act" href="${HERE}?edit=1#compose" data-act="edit" aria-label="Edit your last message">Edit</a>`);
99 const last = after.at(-1);
100 if (last?.failed === true) acts.set(last.id, againForm("retry", "Retry", "Retry the failed turn"));
101 else if (last?.role === "agent") acts.set(last.id, againForm("regenerate", "Regenerate", "Regenerate the last reply"));
102 return acts;
103 }
104
80105 /** How full the context is, one quiet line above the log; always drawn so a finished run can fill it. */
81106 const contextLine = (c: Context | undefined): Html =>
82107 html`<p class="talk-context small muted" data-context ${c === undefined ? html`hidden` : null}>${c === undefined ? "" : contextText(c)}</p>`;
@@ -85,39 +110,47 @@ const contextLine = (c: Context | undefined): Html =>
85110 const costLine = (usd: number): Html => html`<p class="talk-cost small muted">${dollars(usd)} this turn</p>`;
86111
87112 /** One turn as the page and the stream both draw it; `waiting` is the confirms still open. */
88 export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number): Html {
113 export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number, act?: Html): Html {
89114 const id = `t-${turn.id}`;
115 const set = turn.replaced ? " talk-replaced" : "";
116 const acts = act === undefined ? null : html`<div class="talk-acts">${act}</div>`;
117 const label = (who: string): Html => meta(turn.replaced ? `${who} · replaced` : who, turn.createdAt);
90118 if (turn.role === "person")
91 return html`<li class="talk-turn talk-person" id="${id}">
119 return html`<li class="talk-turn talk-person${set}" id="${id}">
92120 <div class="talk-body talk-plain">${turn.text}</div>
93 ${meta("You", turn.createdAt)}
121 ${label("You")}${acts}
122 </li>`;
123 if (turn.role === "agent" && turn.failed)
124 return html`<li class="talk-turn talk-agent talk-failed${set}" id="${id}" role="alert">
125 <div class="talk-body">${turn.text}</div>
126 ${acts}
94127 </li>`;
95128 if (turn.role === "agent")
96 return html`<li class="talk-turn talk-agent" id="${id}">
129 return html`<li class="talk-turn talk-agent${set}" id="${id}">
97130 <div class="talk-body prose">${renderMarkdown(turn.text)}</div>
98 ${meta("Forge", turn.createdAt)}
99 ${cost === undefined ? null : costLine(cost)}
131 ${label("Forge")}
132 ${cost === undefined ? null : costLine(cost)}${acts}
100133 </li>`;
101134 const data = parse(turn.text);
102135 if (turn.role === "confirm") {
103136 const confirm = String(data?.["confirm"] ?? "");
104137 const tool = String(data?.["tool"] ?? "");
105 return html`<li class="talk-turn talk-confirm" id="${id}">
138 return html`<li class="talk-turn talk-confirm${set}" id="${id}">
106139 <p>${String(data?.["does"] ?? turn.text)}</p>
107 ${waiting.has(confirm) ? confirmButtons(confirm, tool) : html`<p class="muted small">Answered or past its ten minutes.</p>`}
140 ${waiting.has(confirm) && !turn.replaced ? confirmButtons(confirm, tool) : html`<p class="muted small">Answered or past its ten minutes.</p>`}
108141 </li>`;
109142 }
110143 // A tool turn: the confirm's outcome as JSON, or the call the agent made as text.
111144 if (data !== undefined && "confirm" in data) {
112145 const tool = String(data["tool"] ?? "");
113146 const says = data["cancelled"] === true ? `Cancelled: ${tool}.` : data["done"] === true ? `Done: ${tool}.${typeof data["undo"] === "string" ? ` To undo: ${data["undo"]}.` : ""}` : `${tool} did not go through: ${String(data["message"] ?? data["error"] ?? "")}`;
114 return toolCard(id, says, tool, turn.createdAt);
147 return toolCard(id, says, tool, turn.createdAt, set);
115148 }
116149 const call = turn.text.slice(0, 300);
117 return toolCard(id, call, call.split(/[\s(:{]/, 1)[0] ?? "tool", turn.createdAt);
150 return toolCard(id, call, call.split(/[\s(:{]/, 1)[0] ?? "tool", turn.createdAt, set);
118151 }
119152
120 function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Response {
153 function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: boolean): Response {
121154 const user = ctx.auth.user!;
122155 const conversation = openConversation(ctx.app.db, user.id);
123156 const listed = listTurns(ctx.app.db, user.id, conversation.id, {
@@ -131,6 +164,9 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Respon
131164 const context = contextOf(ctx.app.db, conversation.id);
132165 // Paging back, the log ends before the newest turn: it does not follow.
133166 const last = turns.at(-1)?.id ?? "";
167 const acts = before === undefined ? exchangeActs(ctx.app.db, user.id, conversation.id, running) : new Map<string, Html>();
168 // Edit shows the composer holding the last message, posting to Edit instead of Send.
169 const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined;
134170 const body = html`<div class="page-head"><h1>Talk</h1></div>
135171 <div class="talk" data-talk data-user="${user.id}" data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}">
136172 <div>
@@ -143,17 +179,18 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Respon
143179 ${turns.length === 0
144180 ? html`<li class="muted talk-turn">Ask the forge anything, or tell it what to change. It acts as you, with your permissions, and asks before anything hard to undo.
145181 <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${HERE}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>`
146 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id)))}
182 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id), acts.get(t.id)))}
147183 </ol>
148184 <div id="end"></div>
149185 </div>
150 <form class="talk-compose" method="post" action="${HERE}">
186 <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? HERE : `${HERE}/again/edit`}" ${editing === undefined ? null : html`data-edit`}>
151187 <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p>
152 <textarea class="input" name="text" rows="1" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge">${prefill}</textarea>
188 <textarea class="input" name="text" rows="1" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge">${editing === undefined ? prefill : editing.text}</textarea>
153189 <div class="bar">
154190 <span class="muted small" data-hint></span>
155191 <span class="spacer"></span>
156 ${btn("Send", { variant: "primary" })}
192 ${editing === undefined ? null : html`<a class="btn" href="${HERE}">Keep as it was</a>`}
193 ${btn(editing === undefined ? "Send" : "Resend", { variant: "primary" })}
157194 </div>
158195 </form>
159196 </div>`;
@@ -166,7 +203,7 @@ export function talkView(ctx: Ctx): Response {
166203 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
167204 const before = ctx.url.searchParams.get("before");
168205 const q = ctx.url.searchParams.get("q");
169 return timePhase(ctx.request, "render", () => talkPage(ctx, before !== null && TURN_ID.test(before) && before !== "" ? before : undefined, q !== null && SUGGESTIONS.some((s) => s === q) ? q : ""));
206 return timePhase(ctx.request, "render", () => talkPage(ctx, before !== null && TURN_ID.test(before) && before !== "" ? before : undefined, q !== null && SUGGESTIONS.some((s) => s === q) ? q : "", ctx.url.searchParams.get("edit") === "1" && before === null));
170207 }
171208
172209 // POST /talk — the plain form, for a browser without the script.
@@ -183,6 +220,29 @@ export async function talkSend(ctx: Ctx): Promise<Response> {
183220 return flashTo(ctx, `${HERE}#end`, { tone: "success", message: "Sent. Reload to see the answer." });
184221 }
185222
223 const AGAIN: readonly RedoHow[] = ["edit", "regenerate", "retry"];
224
225 // POST /talk/again/:how — edit and resend the last message, regenerate the last reply, retry a failed turn.
226 export async function talkAgainSend(ctx: Ctx): Promise<Response> {
227 const user = ctx.auth.user;
228 if (user === undefined) return signInFirst(ctx);
229 if (ctx.auth.via !== "session") return forbiddenPage(ctx, "Do this from your own signed-in browser.");
230 const form = await readForm(ctx, "rw");
231 if (form instanceof Response) return form;
232 const how = AGAIN.find((h) => h === ctx.params["how"]);
233 if (how === undefined) return new Response("not found", { status: 404 });
234 const text = form.get("text");
235 const asJson = (ctx.request.headers.get("accept") ?? "").includes("application/json");
236 let status = 422;
237 let message = `Say something, up to ${WORDS_MAX} characters.`;
238 if (how !== "edit" || (typeof text === "string" && text.trim() !== "" && text.length <= WORDS_MAX)) {
239 const said = talkAgain(ctx.app, user, how, typeof text === "string" ? text : "");
240 if (said.ok) return asJson ? json({ ok: true }, 202) : flashTo(ctx, `${HERE}#end`, { tone: "success", message: "Asked again. Reload to see the answer." });
241 [status, message] = TALK_WORDS[said.error];
242 }
243 return asJson ? json({ ok: false, message }, status) : flashTo(ctx, `${HERE}#end`, { tone: "danger", message });
244 }
245
186246 // POST /talk/confirm/:id — the person's yes or no, from their own session.
187247 export async function talkConfirm(ctx: Ctx): Promise<Response> {
188248 const user = ctx.auth.user;
@@ -241,7 +301,11 @@ export function talkStream(ctx: Ctx): Response {
241301 // The run stored its figures before it stopped running: send them with the end.
242302 const costs = [...turnCosts(app.db, conversation.id)].slice(-5).map(([id, usd]) => ({ id: `t-${id}`, html: costLine(usd).text }));
243303 const context = contextOf(app.db, conversation.id);
244 emit("done", { costs, context: context === undefined ? "" : contextText(context) });
304 emit("done", {
305 costs,
306 context: context === undefined ? "" : contextText(context),
307 acts: [...exchangeActs(app.db, user.id, conversation.id, false)].map(([id, act]) => ({ id: `t-${id}`, html: act.text })),
308 });
245309 close();
246310 } else if (Date.now() - started >= clock.streamMs) {
247311 emit("more", {});
src/web/pages/talk_client.ts +50 −4
@@ -42,6 +42,11 @@ export const talkCss = /* css */ `
4242 .talk-body pre { position: relative; }
4343 .talk-body pre .talk-copy { position: absolute; top: 2px; right: 2px; }
4444 .talk-more { text-align: center; }
45 .talk-replaced { opacity: 0.55; }
46 .talk-failed form { margin-top: 6px; }
47 .talk-failed .btn, .talk-act { min-height: 2.75rem; }
48 .talk-acts { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; }
49 .talk-agent .talk-acts { justify-content: flex-start; }
4550 `;
4651
4752 /** Runs after DOMContentLoaded (`pageScriptAsset`). Reads the page's own `data-talk-*`; sends with fetch, follows with EventSource. */
@@ -86,6 +91,16 @@ if (root) {
8691 root.addEventListener("click", async (e) => {
8792 const s = e.target.closest("[data-suggest]");
8893 if (s) { e.preventDefault(); box.value = s.dataset.suggest; grow(); box.focus(); return; }
94 const a = e.target.closest("[data-act]");
95 if (a) {
96 e.preventDefault();
97 editing = true;
98 box.value = a.closest(".talk-turn").querySelector(".talk-body").textContent;
99 grow();
100 box.focus();
101 say("Editing your last message. Send resends it; Escape keeps it as it was.");
102 return;
103 }
89104 const c = e.target.closest("[data-copy]");
90105 if (!c) return;
91106 const pre = c.closest("pre");
@@ -105,8 +120,27 @@ if (root) {
105120 });
106121 };
107122 decorate(log);
123 // Edit, Regenerate and Retry are drawn by the server (forms and a link that work without
124 // this script); here they only act in place. They are taken away while a turn runs and
125 // the server sends the new ones with done.
126 let editing = form.hasAttribute("data-edit");
127 const dropActs = () => log.querySelectorAll(".talk-acts").forEach((a) => a.remove());
128 const again = async (how, body) => {
129 say(how === "edit" ? "Sending…" : "Asking again…");
130 try {
131 const res = await fetch("/talk/again/" + how, { method: "POST", headers: { accept: "application/json" }, body });
132 const out = await res.json().catch(() => ({}));
133 if (!res.ok) { say(out.message || "That was not done."); return false; }
134 stuck = true;
135 location.reload();
136 return true;
137 } catch (_) {
138 say("That was not sent: no connection.");
139 return false;
140 }
141 };
108142 // While a reply streams, Send is Stop; Escape stops too.
109 const action = form.querySelector(".bar .btn");
143 const action = form.querySelector(".bar button.btn");
110144 const sendLabel = action.textContent;
111145 let answering = false;
112146 const mode = (on) => {
@@ -128,6 +162,7 @@ if (root) {
128162 const follow = () => {
129163 if (paged) return;
130164 if (source) source.close();
165 dropActs();
131166 source = new EventSource("/talk/stream?after=" + encodeURIComponent(last));
132167 source.addEventListener("turn", (e) => {
133168 const turn = JSON.parse(e.data);
@@ -136,12 +171,16 @@ if (root) {
136171 retry = 0;
137172 toEnd();
138173 });
139 source.addEventListener("running", () => { mode(true); say("The forge is answering…"); });
174 source.addEventListener("running", () => { dropActs(); mode(true); say("The forge is answering…"); });
140175 source.addEventListener("done", (e) => {
141 mode(false); say(""); source.close(); source = null;
176 mode(false); dropActs(); say(""); source.close(); source = null;
142177 const o = JSON.parse(e.data), c = root.querySelector("[data-context]");
143178 for (const k of o.costs || []) { const li = document.getElementById(k.id); if (li && !li.querySelector(".talk-cost")) li.insertAdjacentHTML("beforeend", k.html); }
144 if (o.context && c) { c.textContent = o.context; c.hidden = false; }
179 if (o.context && c) { c.hidden = false; c.textContent = o.context; }
180 for (const a of o.acts || []) {
181 const li = document.getElementById(a.id);
182 if (li) li.insertAdjacentHTML("beforeend", '<div class="talk-acts">' + a.html + "</div>");
183 }
145184 });
146185 source.addEventListener("more", () => { source.close(); follow(); });
147186 source.addEventListener("drain", () => { source.close(); setTimeout(follow, 1000); });
@@ -156,6 +195,10 @@ if (root) {
156195 e.preventDefault();
157196 const text = box.value.trim();
158197 if (!text) return;
198 if (editing) {
199 if (await again("edit", new URLSearchParams({ text }))) { try { localStorage.removeItem(draftKey); } catch (_) {} }
200 return;
201 }
159202 say("Sending…");
160203 try {
161204 const res = await fetch("/api/v1/conversation/messages", {
@@ -180,9 +223,12 @@ if (root) {
180223 };
181224 form.addEventListener("submit", send);
182225 box.addEventListener("keydown", (e) => {
226 if (e.key === "Escape" && editing) { editing = false; box.value = ""; grow(); say(""); return; }
183227 if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
184228 });
185229 log.addEventListener("submit", async (e) => {
230 const r = e.target.closest("form[data-again]");
231 if (r) { e.preventDefault(); again(r.dataset.again, new URLSearchParams()); return; }
186232 const f = e.target.closest("form[data-confirm]");
187233 if (!f) return;
188234 e.preventDefault();
test/conversation-talk.test.ts +163 −4
@@ -16,12 +16,16 @@ import { WORDS_MAX } from "../src/core/fence.ts";
1616 import { createToken } from "../src/core/tokens.ts";
1717 import { createUser } from "../src/core/users.ts";
1818 import { mintViaToken } from "../src/core/via_token.ts";
19 import { TALK_PER_MINUTE } from "../src/actions/talk.ts";
19 import { TALK_PER_MINUTE, talk, talkAgain } 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 { appendTurn, openConversation } from "../src/core/conversations.ts";
2323 import { forgeSshSession } from "../src/tui/ssh.ts";
24 import { isStop, parseTalkCommand } from "../src/tui/ssh-talk.ts";
24 import { isStop, parseTalkAgain, parseTalkCommand } from "../src/tui/ssh-talk.ts";
25 import { listTurns } from "../src/core/conversations.ts";
26 import { createSession } from "../src/core/sessions.ts";
27 import { conversationOptions, redo, withHistory } from "../src/crew/conversation.ts";
28 import { exchangeActs, turnHtml } from "../src/web/pages/talk.ts";
2529 import { quietLogs } from "./helpers/globals.ts";
2630
2731 quietLogs();
@@ -36,7 +40,7 @@ describe("talking to the forge's agent", () => {
3640 let via: string;
3741 const headers = (t: string) => ({ "content-type": "application/json", authorization: `Bearer ${t}` });
3842
39 const turns = async (): Promise<{ running: boolean; turns: { role: string; text: string }[] }> =>
43 const turns = async (): Promise<{ running: boolean; turns: { id: string; role: string; text: string; replaced: boolean; failed: boolean }[] }> =>
4044 (await fetch(`${servers.url}/api/v1/conversation/turns`, { headers: headers(token) })).json() as never;
4145 const settle = async () => {
4246 for (let i = 0; i < 200; i++) {
@@ -244,7 +248,6 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t
244248 expect((await exec("talk ")).code).toBe(1);
245249 expect(await persons()).toEqual(["by api", "by mcp", "by ssh $HOME"]);
246250 });
247
248251 test("ssh talk prints the last turn's cost and the context share from the run's own record", async () => {
249252 const id = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id;
250253 const conversation = openConversation(app.db, id);
@@ -258,8 +261,164 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t
258261 expect(r.out).toContain("last turn $0.50");
259262 expect(r.out).toContain("Context 50% full (500k of 1M tokens)");
260263 app.db.query("DELETE FROM conversation_run WHERE id = 'rc1'").run();
264 await settle();
265 app.db.query("DELETE FROM conversation_turn WHERE id IN (SELECT id FROM conversation_turn WHERE conversation_id = ?1 AND id >= ?2)").run(conversation.id, reply.value.id);
261266 });
262267
268 const alice = () => app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id;
269 const again = (how: "edit" | "regenerate" | "retry", text = "") => redo(app, conversationOptions(app)!, alice(), how, text);
270 const as = () => ({ id: alice() }) as never;
271 const settled = async (r: ReturnType<typeof redo>) => {
272 if (!r.ok) throw new Error(r.error);
273 await r.value.done;
274 };
275 const withBinary = (script: string) => {
276 const bin = join(dir, `bin-${Math.random().toString(36).slice(2)}`);
277 writeFileSync(bin, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi\nIFS= read -r first\n${script}\n`);
278 chmodSync(bin, 0o755);
279 configureConversation(app, { binary: bin, localUrl: servers.url, budgetUsd: 1, timeoutMs: 30_000, signal: new AbortController().signal });
280 };
281 const answers = (words: string) =>
282 `echo '{"type":"system","subtype":"init","session_id":"s2","model":"fake"}'\necho '{"type":"result","subtype":"success","is_error":false,"result":"${words}","total_cost_usd":0.01,"num_turns":1,"session_id":"s2","terminal_reason":"completed","usage":{"input_tokens":1,"output_tokens":1}}'`;
283 const standing = async () => (await turns()).turns.filter((t) => !t.replaced).map((t) => `${t.role}:${t.text}`);
284
285 test("editing the last message resends it; the old message and its reply stay, marked replaced", async () => {
286 withBinary(answers("Second answer."));
287 const before = (await turns()).turns.length;
288 const last = (await persons()).at(-1)!;
289 await settled(again("edit", "edited words"));
290 await settle();
291 const now = await turns();
292 expect(now.turns.length).toBe(before + 2);
293 const old = now.turns.find((t) => t.text === last)!;
294 expect(old.replaced).toBe(true);
295 expect((await standing()).slice(-2)).toEqual(["person:edited words", "agent:Second answer."]);
296 expect(now.turns.filter((t) => t.replaced).every((t) => t.role === "person" || t.role === "agent")).toBe(true);
297 const drawn = turnHtml({ ...old, conversationId: "c", createdAt: "2026-01-01T00:00:00Z" } as never, new Set()).text;
298 expect(drawn).toContain("talk-replaced");
299 expect(drawn).toContain("replaced");
300 });
301
302 test("regenerate asks the last message again and replaces only the reply", async () => {
303 withBinary(answers("A new reply."));
304 await settled(again("regenerate"));
305 await settle();
306 const tail = (await standing()).slice(-2);
307 expect(tail).toEqual(["person:edited words", "agent:A new reply."]);
308 expect((await turns()).turns.some((t) => t.text === "Second answer." && t.replaced)).toBe(true);
309 });
310
311 test("a failed run says so in plain words with Retry, and never the raw error", async () => {
312 withBinary(`echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"RAW stack: ECONNRESET at 0xdead","total_cost_usd":0,"num_turns":0,"session_id":"s3"}'\nexit 1`);
313 await settled(again("regenerate"));
314 await settle();
315 const all = (await turns()).turns;
316 const failed = all.at(-1)!;
317 expect(failed.failed).toBe(true);
318 expect(all.some((t) => t.text.includes("RAW stack") || t.text.includes("ECONNRESET"))).toBe(false);
319 const conv = openConversation(app.db, alice());
320 const drawn = turnHtml(failed as never, new Set(), undefined, exchangeActs(app.db, alice(), conv.id, false).get(failed.id)).text;
321 expect(drawn).toContain("Retry");
322 expect(drawn).toContain("/talk/again/retry");
323 expect(drawn).not.toContain("RAW");
324 expect(exchangeActs(app.db, alice(), conv.id, true).size).toBe(0);
325 // Retry on a good answer, or regenerate on a failure, has nothing to act on.
326 withBinary(answers("Fine now."));
327 const bad = again("regenerate");
328 expect(!bad.ok && bad.error).toBe("nothing");
329 await settled(again("retry"));
330 await settle();
331 expect((await standing()).slice(-2)).toEqual(["person:edited words", "agent:Fine now."]);
332 expect((await turns()).turns.find((t) => t.id === failed.id)!.replaced).toBe(true);
333 const retried = again("retry");
334 expect(!retried.ok && retried.error).toBe("nothing");
335 });
336
337 test("a refused redo puts the turns back as they were", async () => {
338 const plain = await standing();
339 const refused = talkAgain(app, { id: alice() } as never, "edit", " ");
340 expect(!refused.ok && refused.error).toBe("empty");
341 expect(await standing()).toEqual(plain);
342 expect(talkAgain(app, undefined, "retry").ok).toBe(false);
343 });
344 const post = (path: string, h: Record<string, string>, body = "") =>
345 fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...h }, body, redirect: "manual" });
346 const cookie = () => ({ cookie: `forge_session=${createSession(app.db, alice()).token}` });
347
348 test("POST /talk/again/:how takes only the signed-in person's own browser", async () => {
349 withBinary(answers("Again."));
350 expect((await post("/talk/again/regenerate", { ...cookie(), origin: "https://elsewhere.example" })).status).toBe(403);
351 expect((await post("/talk/again/regenerate", { authorization: `Bearer ${token}` })).status).toBe(403);
352 expect((await post("/talk/again/sideways", cookie())).status).toBe(404);
353 for (const body of ["text=", `text=${"x".repeat(WORDS_MAX + 1)}`, ""]) {
354 const res = await post("/talk/again/edit", { ...cookie(), accept: "application/json" }, body);
355 expect(res.status).toBe(422);
356 expect(((await res.json()) as { ok: boolean }).ok).toBe(false);
357 }
358 const plain = await post("/talk/again/regenerate", cookie());
359 expect(plain.status).toBe(303);
360 await settle();
361 const json = await post("/talk/again/regenerate", { ...cookie(), accept: "application/json" });
362 expect(json.status).toBe(202);
363 await settle();
364 const edit = await post("/talk/again/edit", { ...cookie(), accept: "application/json" }, "text=via+the+form");
365 expect(edit.status).toBe(202);
366 await settle();
367 expect((await standing()).slice(-2)).toEqual(["person:via the form", "agent:Again."]);
368 const nothing = await post("/talk/again/retry", { ...cookie(), accept: "application/json" });
369 expect(nothing.status).toBe(409);
370 });
371
372 test("a redo is refused with running while a turn is live", async () => {
373 withBinary(`sleep 1\n${answers("Slow.")}`);
374 const sent = talk(app, as(), "slow one");
375 expect(sent.ok).toBe(true);
376 const refused = again("regenerate");
377 expect(!refused.ok && refused.error).toBe("running");
378 const edit = again("edit", "meanwhile");
379 expect(!edit.ok && edit.error).toBe("running");
380 await settle();
381 });
382
383 test("the agent's history leaves out replaced and failed turns", async () => {
384 const conv = openConversation(app.db, alice());
385 const stored = listTurns(app.db, alice(), conv.id, { limit: 200 });
386 if (!stored.ok) throw new Error("turns");
387 const out = withHistory(app, conv, "next question");
388 for (const t of stored.value.filter((t) => t.replaced || t.failed)) {
389 if (!stored.value.some((o) => !o.replaced && !o.failed && o.text === t.text)) expect(out).not.toContain(t.text);
390 }
391 expect(out).not.toContain("Second answer.");
392 expect(stored.value.some((t) => t.replaced)).toBe(true);
393 expect(out).toContain("via the form");
394 });
395
396 test("a turn the account was too resting to answer shows its own words with Retry", async () => {
397 withBinary(
398 `echo '{"type":"system","subtype":"init","session_id":"s-limit","model":"fake"}'\necho '{"type":"result","subtype":"success","is_error":true,"result":"You have hit your session limit · resets 7:20am (UTC)","total_cost_usd":0,"num_turns":1,"session_id":"s-limit","terminal_reason":"api_error","usage":{"input_tokens":1,"output_tokens":1}}'`,
399 );
400 await settled(again("regenerate"));
401 await settle();
402 const last = (await turns()).turns.at(-1)!;
403 expect(last.failed).toBe(true);
404 expect(last.text).not.toContain("session limit");
405 expect(last.text).toMatch(/window is full|resting/);
406 const conv = openConversation(app.db, alice());
407 expect(turnHtml(last as never, new Set(), undefined, exchangeActs(app.db, alice(), conv.id, false).get(last.id)).text).toContain("Retry");
408 });
409
410 test("ssh talk-again reaches the same three actions", async () => {
411 expect(parseTalkAgain("talk-again retry")).toEqual({ how: "retry", words: "" });
412 expect(parseTalkAgain("talk-again edit new words")).toEqual({ how: "edit", words: "new words" });
413 expect(parseTalkAgain("talk-again sideways")).toBeUndefined();
414 expect(parseTalkAgain("talk retry")).toBeUndefined();
415 withBinary(answers("By ssh again."));
416 expect((await exec("talk-again edit by terminal")).code).toBe(0);
417 await settle();
418 expect((await standing()).slice(-2)).toEqual(["person:by terminal", "agent:By ssh again."]);
419 expect((await exec("talk-again retry")).code).toBe(1);
420 expect((await exec("talk-again edit bad\u0007words")).code).toBe(1);
421 });
263422
264423 test("ssh talk /stop stops instead of speaking; with nothing running it says so (#8818)", async () => {
265424 expect(isStop("/stop")).toBe(true);
test/talk-page.test.ts +1 −1
@@ -192,7 +192,7 @@ describe("the page", () => {
192192 test("the page's own bytes are held: stylesheet and script stay small", async () => {
193193 const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts");
194194 expect(Buffer.byteLength(talkCss)).toBeLessThan(3_500);
195 expect(Buffer.byteLength(talkJs)).toBeLessThan(7_400);
195 expect(Buffer.byteLength(talkJs)).toBeLessThan(9_500);
196196 say(alice, "agent", "```ts\nlet x = 1;\n```");
197197 const page = await get("/talk", alice);
198198 const size = (await page.arrayBuffer()).byteLength;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs