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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8817-talk-can-edit-and-resend-a-message-regen → main 5 commits fork 1 thread · 1 unresolved
14 files
changed +508
−56
docs/design/areas/talk.md +4
| @@ -18,5 +18,9 @@ | ||
| 18 | 18 | - `.talk-copy` — a quiet text button that copies a message or a code block. |
| 19 | 19 | - `.talk-suggest` — the starter questions on an empty chat; each fills the composer. |
| 20 | 20 | - `.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. | |
| 21 | 25 | |
| 22 | 26 | 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, | ||
| 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 | ## 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 | ||
| 21 | 33 | ## Stop |
| 22 | 34 | |
| 23 | 35 | 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 | ||
| 44 | 56 | | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` | |
| 45 | 57 | | Stick to bottom, "Latest" jump button | this pull | AI Elements `Conversation` | |
| 46 | 58 | | 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` | | |
| 48 | 61 | | 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 | |
| 49 | 62 | | Attachments, tables, thinking, plan line | later | need storage / token data / markdown tables | |
| 50 | 63 | | 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 @@ | ||
| 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, 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"; | |
| 12 | 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; |
| 16 | 16 | |
| 17 | export type TalkRefusal = SayRefusal | "off" | "rate_limited"; | |
| 17 | export type TalkRefusal = SayRefusal | RedoRefusal | "off" | "rate_limited"; | |
| 18 | 18 | |
| 19 | 19 | /** What each refusal says, with its status. */ |
| 20 | 20 | export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]>> = { |
| @@ -25,24 +25,45 @@ export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]> | ||
| 25 | 25 | resting: [429, "the account that serves you is resting or out of budget; try again later"], |
| 26 | 26 | busy: [429, "the forge's agent is answering as many people as it can at once; try again in a minute"], |
| 27 | 27 | 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"], | |
| 28 | 30 | rate_limited: [429, `at most ${TALK_PER_MINUTE} messages a minute`], |
| 29 | 31 | }; |
| 30 | 32 | |
| 31 | 33 | const limiters = new WeakMap<App, RateLimiter>(); |
| 32 | 34 | |
| 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 { | |
| 37 | 37 | let limiter = limiters.get(app); |
| 38 | 38 | if (limiter === undefined) { |
| 39 | 39 | limiter = new RateLimiter({ perMinute: TALK_PER_MINUTE, adminPerMinute: TALK_PER_MINUTE, enabled: true }); |
| 40 | 40 | limiters.set(app, limiter); |
| 41 | 41 | } |
| 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"); | |
| 43 | 54 | 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); | |
| 46 | 67 | return said; |
| 47 | 68 | } |
| 48 | 69 |
src/core/conversations.ts +53 −3
| @@ -33,6 +33,10 @@ export interface Turn { | ||
| 33 | 33 | readonly role: TurnRole; |
| 34 | 34 | readonly text: string; |
| 35 | 35 | 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; | |
| 36 | 40 | } |
| 37 | 41 | |
| 38 | 42 | interface ConversationRow { |
| @@ -49,6 +53,8 @@ interface TurnRow { | ||
| 49 | 53 | role: TurnRole; |
| 50 | 54 | text: string; |
| 51 | 55 | created_at: string; |
| 56 | replaced_at: string | null; | |
| 57 | failed: number; | |
| 52 | 58 | } |
| 53 | 59 | |
| 54 | 60 | const conversationOf = (r: ConversationRow): Conversation => ({ |
| @@ -65,6 +71,8 @@ const turnOf = (r: TurnRow): Turn => ({ | ||
| 65 | 71 | role: r.role, |
| 66 | 72 | text: r.text, |
| 67 | 73 | createdAt: r.created_at, |
| 74 | replaced: r.replaced_at !== null, | |
| 75 | failed: r.failed === 1, | |
| 68 | 76 | }); |
| 69 | 77 | |
| 70 | 78 | /** The person's conversation, made on first use. */ |
| @@ -89,7 +97,7 @@ export function appendTurn( | ||
| 89 | 97 | db: Database, |
| 90 | 98 | userId: string, |
| 91 | 99 | conversationId: string, |
| 92 | input: { readonly role: string; readonly text: string }, | |
| 100 | input: { readonly role: string; readonly text: string; readonly failed?: boolean }, | |
| 93 | 101 | ): Result<Turn, "not_found" | "bad_role" | "empty_text" | "text_too_long"> { |
| 94 | 102 | if (!owned(db, userId, conversationId)) return err("not_found"); |
| 95 | 103 | if (!(TURN_ROLES as readonly string[]).includes(input.role)) return err("bad_role"); |
| @@ -102,10 +110,12 @@ export function appendTurn( | ||
| 102 | 110 | role: input.role as TurnRole, |
| 103 | 111 | text: input.text, |
| 104 | 112 | created_at: now, |
| 113 | replaced_at: null, | |
| 114 | failed: input.failed === true ? 1 : 0, | |
| 105 | 115 | }; |
| 106 | 116 | 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); | |
| 109 | 119 | db.query("UPDATE conversation SET updated_at = ?2 WHERE id = ?1").run(conversationId, now); |
| 110 | 120 | db.query( |
| 111 | 121 | "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, | ||
| 119 | 129 | db.query("DELETE FROM conversation_turn WHERE id = ?1 AND conversation_id = ?2").run(turnId, conversationId); |
| 120 | 130 | } |
| 121 | 131 | |
| 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 | ||
| 122 | 172 | /** |
| 123 | 173 | * A page of turns, newest last: the latest `limit` turns, or the `limit` |
| 124 | 174 | * 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"; | ||
| 19 | 19 | import { join } from "node:path"; |
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | 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"; | |
| 23 | 23 | import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts"; |
| 24 | 24 | import { cancelHeld, WORDS_MAX } from "../core/fence.ts"; |
| 25 | 25 | import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts"; |
| @@ -163,7 +163,7 @@ export function withHistory(app: App, conversation: Conversation, prompt: string | ||
| 163 | 163 | const before = last !== undefined && last.role === "person" && last.text === prompt ? stored.slice(0, -1) : stored; |
| 164 | 164 | const lines: string[] = []; |
| 165 | 165 | 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()) { | |
| 167 | 167 | const line = `${t.role === "person" ? "Person" : t.role === "agent" ? "You" : "Tool"}: ${t.text.slice(0, HISTORY_TURN_CHARS)}`; |
| 168 | 168 | if (size + line.length > HISTORY_CHARS) break; |
| 169 | 169 | size += line.length; |
| @@ -217,6 +217,52 @@ export function say(app: App, options: ConversationOptions, userId: string, text | ||
| 217 | 217 | return ok({ how: "started", done: started.value }); |
| 218 | 218 | } |
| 219 | 219 | |
| 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 | ||
| 220 | 266 | /** |
| 221 | 267 | * Turns the last run of the forge left `running` are resumed once, from their |
| 222 | 268 | * 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 { | ||
| 262 | 308 | : `${account.name} is resting until ${whenText(until)}.`; |
| 263 | 309 | } |
| 264 | 310 | |
| 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 | ||
| 265 | 318 | function begin( |
| 266 | 319 | app: App, |
| 267 | 320 | options: ConversationOptions, |
| @@ -399,15 +452,19 @@ function begin( | ||
| 399 | 452 | return; |
| 400 | 453 | } |
| 401 | 454 | 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)) { | |
| 404 | 463 | 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); | |
| 409 | 466 | 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); | |
| 411 | 468 | app.db |
| 412 | 469 | .query( |
| 413 | 470 | "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 { | ||
| 124 | 124 | verifyPage, |
| 125 | 125 | themeSubmit, |
| 126 | 126 | } 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"; | |
| 128 | 128 | import { |
| 129 | 129 | recoverFinish, |
| 130 | 130 | recoverOptions, |
| @@ -841,6 +841,7 @@ export function buildRouter(): Router<Handler> { | ||
| 841 | 841 | router.add("GET", "/talk", talkView); |
| 842 | 842 | router.add("POST", "/talk", talkSend); |
| 843 | 843 | router.add("GET", "/talk/stream", talkStream); |
| 844 | router.add("POST", "/talk/again/:how", talkAgainSend); | |
| 844 | 845 | router.add("POST", "/talk/confirm/:id", talkConfirm); |
| 845 | 846 | router.add("POST", "/signin/passkey/options", signinOptions); |
| 846 | 847 | router.add("POST", "/signin/passkey", signinPasskey); |
src/tui/ssh-talk.ts +25 −1
| @@ -7,12 +7,16 @@ | ||
| 7 | 7 | * control character is refused before anything runs. `talk /stop` (or |
| 8 | 8 | * `--stop`) ends the running answer, as Stop does on /talk: what the agent |
| 9 | 9 | * 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`. | |
| 10 | 13 | */ |
| 11 | 14 | |
| 12 | import { TALK_WORDS, stopTalk, talk } from "../actions/talk.ts"; | |
| 15 | import { TALK_WORDS, stopTalk, talk, talkAgain } from "../actions/talk.ts"; | |
| 13 | 16 | import { contextOf, contextText, dollars, turnCosts } from "../core/conversation_cost.ts"; |
| 14 | 17 | import { openConversation } from "../core/conversations.ts"; |
| 15 | 18 | import type { App } from "../app.ts"; |
| 19 | import type { RedoHow } from "../crew/conversation.ts"; | |
| 16 | 20 | import type { User } from "../core/users.ts"; |
| 17 | 21 | import { CONTROL_WORDS } from "./ssh-tell.ts"; |
| 18 | 22 | import type { SshSession } from "../ssh/server.ts"; |
| @@ -62,3 +66,23 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined, | ||
| 62 | 66 | } |
| 63 | 67 | session.exit(0); |
| 64 | 68 | } |
| 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"; | ||
| 29 | 29 | import { servePulls } from "../apps/tty_pulls.ts"; |
| 30 | 30 | import type { Terminal } from "./term/terminal.ts"; |
| 31 | 31 | import type { TuiIdentity } from "./session.ts"; |
| 32 | import { parseTalkCommand, serveTalk } from "./ssh-talk.ts"; | |
| 32 | import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts"; | |
| 33 | 33 | import { parseTellCommand, serveTell } from "./ssh-tell.ts"; |
| 34 | 34 | |
| 35 | 35 | const CRLF = "\r\n"; |
| @@ -245,6 +245,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 245 | 245 | serveTalk(forge, session, identity.user, talked); |
| 246 | 246 | return; |
| 247 | 247 | } |
| 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 | } | |
| 248 | 253 | void serveGitCommand(forge, session, identity.user).catch( |
| 249 | 254 | (error: unknown) => { |
| 250 | 255 | forge.log.error("git over ssh failed", { error: String(error) }); |
src/web/pages/talk.ts +86 −22
| @@ -6,19 +6,23 @@ | ||
| 6 | 6 | * inside the edge's timeout) and the client reconnects from the last turn id |
| 7 | 7 | * it saw. A held action draws Confirm and Cancel buttons; Confirm is the |
| 8 | 8 | * 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; | |
| 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). | |
| 9 | 12 | * |
| 10 | 13 | * Everything is the signed-in person's own: the conversation is found by |
| 11 | 14 | * their id, the agent acts with their permissions (`crew/conversation.ts`). |
| 12 | 15 | */ |
| 13 | 16 | |
| 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"; | |
| 15 | 19 | 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"; | |
| 17 | 21 | import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts"; |
| 18 | 22 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 19 | 23 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 20 | 24 | import { ASKING } from "../../crew/conversation_confirm.ts"; |
| 21 | import { turnRunning } from "../../crew/conversation.ts"; | |
| 25 | import { turnRunning, type RedoHow } from "../../crew/conversation.ts"; | |
| 22 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 23 | 27 | import { json } from "../../http/respond.ts"; |
| 24 | 28 | import { eventStream } from "../../http/sse.ts"; |
| @@ -71,12 +75,33 @@ const meta = (who: string, at: string): Html => | ||
| 71 | 75 | html`<p class="talk-meta small muted">${who} · ${time(at)}</p>`; |
| 72 | 76 | |
| 73 | 77 | /** 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>`; | |
| 76 | 80 | |
| 77 | 81 | /** What an empty chat offers to start with; each is a link that fills the composer (`?q=`), so it works without the script. */ |
| 78 | 82 | const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const; |
| 79 | 83 | |
| 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 | ||
| 80 | 105 | /** How full the context is, one quiet line above the log; always drawn so a finished run can fill it. */ |
| 81 | 106 | const contextLine = (c: Context | undefined): Html => |
| 82 | 107 | 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 => | ||
| 85 | 110 | const costLine = (usd: number): Html => html`<p class="talk-cost small muted">${dollars(usd)} this turn</p>`; |
| 86 | 111 | |
| 87 | 112 | /** 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 { | |
| 89 | 114 | 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); | |
| 90 | 118 | 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}"> | |
| 92 | 120 | <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} | |
| 94 | 127 | </li>`; |
| 95 | 128 | 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}"> | |
| 97 | 130 | <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} | |
| 100 | 133 | </li>`; |
| 101 | 134 | const data = parse(turn.text); |
| 102 | 135 | if (turn.role === "confirm") { |
| 103 | 136 | const confirm = String(data?.["confirm"] ?? ""); |
| 104 | 137 | 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}"> | |
| 106 | 139 | <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>`} | |
| 108 | 141 | </li>`; |
| 109 | 142 | } |
| 110 | 143 | // A tool turn: the confirm's outcome as JSON, or the call the agent made as text. |
| 111 | 144 | if (data !== undefined && "confirm" in data) { |
| 112 | 145 | const tool = String(data["tool"] ?? ""); |
| 113 | 146 | 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); | |
| 115 | 148 | } |
| 116 | 149 | 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); | |
| 118 | 151 | } |
| 119 | 152 | |
| 120 | function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Response { | |
| 153 | function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: boolean): Response { | |
| 121 | 154 | const user = ctx.auth.user!; |
| 122 | 155 | const conversation = openConversation(ctx.app.db, user.id); |
| 123 | 156 | const listed = listTurns(ctx.app.db, user.id, conversation.id, { |
| @@ -131,6 +164,9 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Respon | ||
| 131 | 164 | const context = contextOf(ctx.app.db, conversation.id); |
| 132 | 165 | // Paging back, the log ends before the newest turn: it does not follow. |
| 133 | 166 | 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; | |
| 134 | 170 | const body = html`<div class="page-head"><h1>Talk</h1></div> |
| 135 | 171 | <div class="talk" data-talk data-user="${user.id}" data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}"> |
| 136 | 172 | <div> |
| @@ -143,17 +179,18 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string): Respon | ||
| 143 | 179 | ${turns.length === 0 |
| 144 | 180 | ? 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. |
| 145 | 181 | <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)))} | |
| 147 | 183 | </ol> |
| 148 | 184 | <div id="end"></div> |
| 149 | 185 | </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`}> | |
| 151 | 187 | <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> | |
| 153 | 189 | <div class="bar"> |
| 154 | 190 | <span class="muted small" data-hint></span> |
| 155 | 191 | <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" })} | |
| 157 | 194 | </div> |
| 158 | 195 | </form> |
| 159 | 196 | </div>`; |
| @@ -166,7 +203,7 @@ export function talkView(ctx: Ctx): Response { | ||
| 166 | 203 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 167 | 204 | const before = ctx.url.searchParams.get("before"); |
| 168 | 205 | 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)); | |
| 170 | 207 | } |
| 171 | 208 | |
| 172 | 209 | // POST /talk — the plain form, for a browser without the script. |
| @@ -183,6 +220,29 @@ export async function talkSend(ctx: Ctx): Promise<Response> { | ||
| 183 | 220 | return flashTo(ctx, `${HERE}`, { tone: "success", message: "Sent. Reload to see the answer." }); |
| 184 | 221 | } |
| 185 | 222 | |
| 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}`, { 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}`, { tone: "danger", message }); | |
| 244 | } | |
| 245 | ||
| 186 | 246 | // POST /talk/confirm/:id — the person's yes or no, from their own session. |
| 187 | 247 | export async function talkConfirm(ctx: Ctx): Promise<Response> { |
| 188 | 248 | const user = ctx.auth.user; |
| @@ -241,7 +301,11 @@ export function talkStream(ctx: Ctx): Response { | ||
| 241 | 301 | // The run stored its figures before it stopped running: send them with the end. |
| 242 | 302 | const costs = [...turnCosts(app.db, conversation.id)].slice(-5).map(([id, usd]) => ({ id: `t-${id}`, html: costLine(usd).text })); |
| 243 | 303 | 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 | }); | |
| 245 | 309 | close(); |
| 246 | 310 | } else if (Date.now() - started >= clock.streamMs) { |
| 247 | 311 | emit("more", {}); |
src/web/pages/talk_client.ts +50 −4
| @@ -42,6 +42,11 @@ export const talkCss = /* css */ ` | ||
| 42 | 42 | .talk-body pre { position: relative; } |
| 43 | 43 | .talk-body pre .talk-copy { position: absolute; top: 2px; right: 2px; } |
| 44 | 44 | .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; } | |
| 45 | 50 | `; |
| 46 | 51 | |
| 47 | 52 | /** Runs after DOMContentLoaded (`pageScriptAsset`). Reads the page's own `data-talk-*`; sends with fetch, follows with EventSource. */ |
| @@ -86,6 +91,16 @@ if (root) { | ||
| 86 | 91 | root.addEventListener("click", async (e) => { |
| 87 | 92 | const s = e.target.closest("[data-suggest]"); |
| 88 | 93 | 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 | } | |
| 89 | 104 | const c = e.target.closest("[data-copy]"); |
| 90 | 105 | if (!c) return; |
| 91 | 106 | const pre = c.closest("pre"); |
| @@ -105,8 +120,27 @@ if (root) { | ||
| 105 | 120 | }); |
| 106 | 121 | }; |
| 107 | 122 | 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 | }; | |
| 108 | 142 | // 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"); | |
| 110 | 144 | const sendLabel = action.textContent; |
| 111 | 145 | let answering = false; |
| 112 | 146 | const mode = (on) => { |
| @@ -128,6 +162,7 @@ if (root) { | ||
| 128 | 162 | const follow = () => { |
| 129 | 163 | if (paged) return; |
| 130 | 164 | if (source) source.close(); |
| 165 | dropActs(); | |
| 131 | 166 | source = new EventSource("/talk/stream?after=" + encodeURIComponent(last)); |
| 132 | 167 | source.addEventListener("turn", (e) => { |
| 133 | 168 | const turn = JSON.parse(e.data); |
| @@ -136,12 +171,16 @@ if (root) { | ||
| 136 | 171 | retry = 0; |
| 137 | 172 | toEnd(); |
| 138 | 173 | }); |
| 139 | source.addEventListener("running", () => { mode(true); say("The forge is answering…"); }); | |
| 174 | source.addEventListener("running", () => { dropActs(); mode(true); say("The forge is answering…"); }); | |
| 140 | 175 | source.addEventListener("done", (e) => { |
| 141 | mode(false); say(""); source.close(); source = null; | |
| 176 | mode(false); dropActs(); say(""); source.close(); source = null; | |
| 142 | 177 | const o = JSON.parse(e.data), c = root.querySelector("[data-context]"); |
| 143 | 178 | 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 | } | |
| 145 | 184 | }); |
| 146 | 185 | source.addEventListener("more", () => { source.close(); follow(); }); |
| 147 | 186 | source.addEventListener("drain", () => { source.close(); setTimeout(follow, 1000); }); |
| @@ -156,6 +195,10 @@ if (root) { | ||
| 156 | 195 | e.preventDefault(); |
| 157 | 196 | const text = box.value.trim(); |
| 158 | 197 | if (!text) return; |
| 198 | if (editing) { | |
| 199 | if (await again("edit", new URLSearchParams({ text }))) { try { localStorage.removeItem(draftKey); } catch (_) {} } | |
| 200 | return; | |
| 201 | } | |
| 159 | 202 | say("Sending…"); |
| 160 | 203 | try { |
| 161 | 204 | const res = await fetch("/api/v1/conversation/messages", { |
| @@ -180,9 +223,12 @@ if (root) { | ||
| 180 | 223 | }; |
| 181 | 224 | form.addEventListener("submit", send); |
| 182 | 225 | box.addEventListener("keydown", (e) => { |
| 226 | if (e.key === "Escape" && editing) { editing = false; box.value = ""; grow(); say(""); return; } | |
| 183 | 227 | if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } |
| 184 | 228 | }); |
| 185 | 229 | 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; } | |
| 186 | 232 | const f = e.target.closest("form[data-confirm]"); |
| 187 | 233 | if (!f) return; |
| 188 | 234 | e.preventDefault(); |
test/conversation-talk.test.ts +163 −4
| @@ -16,12 +16,16 @@ import { WORDS_MAX } from "../src/core/fence.ts"; | ||
| 16 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 17 | 17 | import { createUser } from "../src/core/users.ts"; |
| 18 | 18 | 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"; | |
| 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 { appendTurn, openConversation } from "../src/core/conversations.ts"; |
| 23 | 23 | 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"; | |
| 25 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| 26 | 30 | |
| 27 | 31 | quietLogs(); |
| @@ -36,7 +40,7 @@ describe("talking to the forge's agent", () => { | ||
| 36 | 40 | let via: string; |
| 37 | 41 | const headers = (t: string) => ({ "content-type": "application/json", authorization: `Bearer ${t}` }); |
| 38 | 42 | |
| 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 }[] }> => | |
| 40 | 44 | (await fetch(`${servers.url}/api/v1/conversation/turns`, { headers: headers(token) })).json() as never; |
| 41 | 45 | const settle = async () => { |
| 42 | 46 | for (let i = 0; i < 200; i++) { |
| @@ -244,7 +248,6 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t | ||
| 244 | 248 | expect((await exec("talk ")).code).toBe(1); |
| 245 | 249 | expect(await persons()).toEqual(["by api", "by mcp", "by ssh $HOME"]); |
| 246 | 250 | }); |
| 247 | ||
| 248 | 251 | test("ssh talk prints the last turn's cost and the context share from the run's own record", async () => { |
| 249 | 252 | const id = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id; |
| 250 | 253 | const conversation = openConversation(app.db, id); |
| @@ -258,8 +261,164 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t | ||
| 258 | 261 | expect(r.out).toContain("last turn $0.50"); |
| 259 | 262 | expect(r.out).toContain("Context 50% full (500k of 1M tokens)"); |
| 260 | 263 | 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); | |
| 261 | 266 | }); |
| 262 | 267 | |
| 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 | }); | |
| 263 | 422 | |
| 264 | 423 | test("ssh talk /stop stops instead of speaking; with nothing running it says so (#8818)", async () => { |
| 265 | 424 | expect(isStop("/stop")).toBe(true); |
test/talk-page.test.ts +1 −1
| @@ -192,7 +192,7 @@ describe("the page", () => { | ||
| 192 | 192 | test("the page's own bytes are held: stylesheet and script stay small", async () => { |
| 193 | 193 | const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts"); |
| 194 | 194 | expect(Buffer.byteLength(talkCss)).toBeLessThan(3_500); |
| 195 | expect(Buffer.byteLength(talkJs)).toBeLessThan(7_400); | |
| 195 | expect(Buffer.byteLength(talkJs)).toBeLessThan(9_500); | |
| 196 | 196 | say(alice, "agent", "```ts\nlet x = 1;\n```"); |
| 197 | 197 | const page = await get("/talk", alice); |
| 198 | 198 | const size = (await page.arrayBuffer()).byteLength; |
new above
offline
Non-blocking: stale. Edit and Regenerate are server-drawn by exchangeActs, not by the script.