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

/talk lists conversations: new, rename, archive, each at its own URL #8887

Merged opened this

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

Merged

scout/forge:scout/8814-talk-lists-conversations-new-rename-arch → main 25 commits fork 3 threads · 3 unresolved

  • Merged. scout/8814-talk-lists-conversations-new-rename-arch was merged into main as 70ca242.

Review

Sign in to review this change.
30 files changed +1188 −105
docs/design/areas/talk.md +2
@@ -18,6 +18,8 @@
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-chats` — the conversations list above the chat (#8814): New, the person's conversations newest first, and the Archived filter link.
22 - `.talk-here` — the line naming the open conversation, with its Rename and Archive forms.
2123 - `.talk-steps` — the quiet steps line under a reply ("read 3 files, opened #12").
2224 - `.talk-links` — the links under a reply to what the turn opened or changed.
2325 - `.talk-replaced` — a turn the person edited, regenerated or retried past: kept in the log, dimmer, labelled "replaced".
docs/features/talk.md +12 −1
@@ -18,6 +18,16 @@ 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 ## Many conversations (#8814)
22
23 A person has any number. `/talk` is their latest, with the list above it (newest activity first), **New**, and **Archived (n)** as the one filter (`/talk?archived=1`). Each opens at `/talk/<id>`, stable across reloads; Rename and Archive/Restore are plain forms (`POST /talk/:id/rename`, `/talk/:id/archive`, `POST /talk/new`), so they work without the script. Another person's id is 404 everywhere. A message with no conversation named goes to the latest one that is not archived.
24
25 - **Cap:** a person holds at most 100 conversations, archived ones counted; past it New is refused (page flash, API 409 `too_many`, MCP `too_many`). Stop ends every reply the person has running, in any conversation.
26 - **API:** `GET /api/v1/conversations[?archived=1]`, `POST /api/v1/conversations {title?}`, `PATCH /api/v1/conversations/:id {title?, archived?}`; `conversation` on `POST …/messages` and `GET …/turns?conversation=` picks one.
27 - **MCP:** `say` with `action` list | new | rename | archive | restore, and `conversation` to speak in one.
28 - **Terminal:** Palette → Conversations: `n` new, `r` rename, `x` archive or restore, `a` the archived list, Enter opens one (its last 50 turns; `s` says something in it, `r` reloads); the same calls, so the web list and stream read what it writes.
29 - Sending to an archived conversation keeps it archived; the page says so above the composer.
30
2131 ## Edit, regenerate, retry (#8817)
2232
2333 Three actions on the last exchange, one door, `talkAgain()` in `src/actions/talk.ts` (the same 12-a-minute throttle and refusals as sending):
@@ -28,6 +38,7 @@ Three actions on the last exchange, one door, `talkAgain()` in `src/actions/talk
2838 - Refused while a turn runs (`running`, 409), or when there is nothing to act on (`nothing`, 409).
2939 - **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.
3040 - **Terminal:** `ssh <forge> talk-again edit <words>`, `ssh <forge> talk-again regenerate`, `ssh <forge> talk-again retry`.
41 - With several conversations they act on the one open: the forms post to `/talk/<id>/again/<how>` (`/talk/again/<how>` on the latest); the action takes the conversation as an optional argument.
3142 - There is no API or MCP door for these yet.
3243
3344 ## Stop
@@ -64,7 +75,7 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha
6475 | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` |
6576 | Stick to bottom, "Latest" jump button | this pull | AI Elements `Conversation` |
6677 | Thinking as one closed line; a steps line under a reply ("read 3 files, opened #12"); links to each issue, pull, file or app the turn opened or changed | this pull | AI Elements `Reasoning`, `ChainOfThought` |
67 | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person |
78 | Many conversations: new, list, rename, archive, URL each | have (#8814) | search is not added yet |
6879 | Edit and resend, regenerate, Retry (see below) | have (#8817) | replaced turns are kept and marked, not deleted |
6980 | Queue and Steer while an answer runs | #8819 | `Queue` / `PromptInput` |
7081 | 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 |
src/actions/talk.ts +10 −7
@@ -6,6 +6,7 @@
66 */
77
88 import type { App } from "../app.ts";
9 import { getConversation } from "../core/conversations.ts";
910 import type { ConversationFile } from "../core/conversation_files.ts";
1011 import { RateLimiter } from "../core/ratelimit.ts";
1112 import type { User } from "../core/users.ts";
@@ -19,6 +20,7 @@ export type TalkRefusal = SayRefusal | RedoRefusal | "off" | "rate_limited";
1920
2021 /** What each refusal says, with its status. */
2122 export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]>> = {
23 not_found: [404, "no such conversation"],
2224 unauthorized: [401, "sign in to talk to the forge"],
2325 empty: [422, "say something"],
2426 too_long: [422, "the message is too long"],
@@ -50,23 +52,24 @@ const watch = (app: App, said: Said): void => {
5052 said.done?.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) }));
5153 };
5254
53 export function talk(app: App, user: User | undefined, text: string, files: readonly ConversationFile[] = [], during: During = "inject"): Result<Said, TalkRefusal> {
55 export function talk(app: App, user: User | undefined, text: string, files: readonly ConversationFile[] = [], during: During = "inject", conversationId?: string): Result<Said, TalkRefusal> {
5456 if (user === undefined) return err("unauthorized");
57 if (conversationId !== undefined && getConversation(app.db, user.id, conversationId) === undefined) return err("not_found");
5558 const options = conversationOptions(app);
5659 if (options === undefined) return err("off");
5760 if (!take(app, user)) return err("rate_limited");
58 const said = say(app, options, user.id, text, files, during);
61 const said = say(app, options, user.id, text, files, during, conversationId);
5962 if (said.ok) watch(app, said.value);
6063 return said;
6164 }
6265
63 /** Edit the last message and resend it, regenerate the last reply, or retry a failed turn (#8817). */
64 export function talkAgain(app: App, user: User | undefined, how: RedoHow, text = ""): Result<Said, TalkRefusal> {
66 /** Edit the last message and resend it, regenerate the last reply, or retry a failed turn (#8817), in the named conversation or the latest. */
67 export function talkAgain(app: App, user: User | undefined, how: RedoHow, text = "", conversationId?: string): Result<Said, TalkRefusal> {
6568 if (user === undefined) return err("unauthorized");
6669 const options = conversationOptions(app);
6770 if (options === undefined) return err("off");
6871 if (!take(app, user)) return err("rate_limited");
69 const said = redo(app, options, user.id, how, text);
72 const said = redo(app, options, user.id, how, text, conversationId);
7073 if (said.ok) watch(app, said.value);
7174 return said;
7275 }
@@ -82,5 +85,5 @@ export function stopTalk(app: App, user: User | undefined): Result<true, "unauth
8285 export const cancelTalk = (app: App, user: User | undefined, queuedId: string): boolean => user !== undefined && cancelQueued(app, user.id, queuedId);
8386
8487 /** The person's waiting messages, oldest first. */
85 export const queuedTalk = (app: App, user: User | undefined): readonly { id: string; text: string }[] =>
86 user === undefined ? [] : queuedOf(app, user.id).map((q) => ({ id: q.id, text: q.text }));
88 export const queuedTalk = (app: App, user: User | undefined, conversationId?: string): readonly { id: string; text: string }[] =>
89 user === undefined ? [] : queuedOf(app, user.id, conversationId).map((q) => ({ id: q.id, text: q.text }));
src/contract/openapi/routes/getApiV1ConversationTurns.ts +1 −1
@@ -5,6 +5,6 @@ export const route: Route = {
55 path: "/api/v1/conversation/turns",
66 summary: "The signed-in person's conversation with the forge's agent: the latest turns, oldest first, and whether an answer is being written now",
77 auth: true,
8 query: { limit: "integer", before: "string" },
8 query: { limit: "integer", before: "string", conversation: "string" },
99 reply: { running: "boolean", turns: "array" },
1010 };
src/contract/openapi/routes/getApiV1Conversations.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/conversations",
6 summary: "The signed-in person's conversations with the forge's agent, newest activity first; archived: 1 lists the put-away ones",
7 auth: true,
8 query: { archived: "string" },
9 reply: { conversations: "array" },
10 };
src/contract/openapi/routes/index.ts +3
@@ -58,6 +58,7 @@ export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts";
5858 export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
5959 export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts";
6060 export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts";
61 export { route as getApiV1Conversations } from "./getApiV1Conversations.ts";
6162 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
6263 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
6364 export { route as getApiV1Explore } from "./getApiV1Explore.ts";
@@ -184,6 +185,7 @@ export { route as getContractOpenapiJson } from "./getContractOpenapiJson.ts";
184185 export { route as getMetrics } from "./getMetrics.ts";
185186 export { route as patchApiV1AdminSettings } from "./patchApiV1AdminSettings.ts";
186187 export { route as patchApiV1AgentsUsername } from "./patchApiV1AgentsUsername.ts";
188 export { route as patchApiV1ConversationsId } from "./patchApiV1ConversationsId.ts";
187189 export { route as patchApiV1ForgeFollowing } from "./patchApiV1ForgeFollowing.ts";
188190 export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts";
189191 export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts";
@@ -218,6 +220,7 @@ export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFile
218220 export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts";
219221 export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts";
220222 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
223 export { route as postApiV1Conversations } from "./postApiV1Conversations.ts";
221224 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
222225 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
223226 export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts";
src/contract/openapi/routes/patchApiV1ConversationsId.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "patch",
5 path: "/api/v1/conversations/{id}",
6 summary: "Rename a conversation (title) or put it away and bring it back (archived: true or false); another person's id is 404",
7 auth: true,
8 body: { title: "string", archived: "boolean" },
9 reply: { conversation: "object" },
10 refusals: { invalid_title: "title is over 80 characters or has a line break (422)", not_found: "no such conversation of yours (404)", forbidden: "the request is cross-origin, or the token is read-only" },
11 };
src/contract/openapi/routes/postApiV1ConversationMessages.ts +2 −1
@@ -6,9 +6,10 @@ export const route: Route = {
66 summary: "Say something to the forge's own agent, as the signed-in person: it answers through the forge's MCP with that person's permissions, resuming their conversation; a message sent while it answers is handed to the turn, or with `during` (optional) set to `queue` waits for it (at most 5), or set to `steer` stops it and goes at once; answers 202 at once, read the answer from the turns; at most 12 a minute per person",
77 auth: true,
88 status: 202,
9 body: { text: "string" },
9 body: { text: "string", conversation: "string" },
1010 reply: { how: "string" },
1111 refusals: {
12 not_found: "conversation names none of the person's (404)",
1213 invalid_text: "text is not a string of 1 to 20000 characters",
1314 invalid_during: "during is not queue or steer",
1415 file_taken: "an attached file already waits in a queued message (409)",
src/contract/openapi/routes/postApiV1Conversations.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversations",
6 summary: "Start a new, empty conversation with the forge's agent; answers it, and it opens at /talk/<id>",
7 auth: true,
8 status: 201,
9 body: { title: "string" },
10 reply: { conversation: "object" },
11 refusals: { invalid_title: "title is over 80 characters or has a line break (422)", forbidden: "the request is cross-origin, or the token is read-only" },
12 };
src/core/conversations.ts +91 −11
@@ -1,8 +1,9 @@
11 /**
2 * A person's conversation with the forge: one per person, stored turn by turn
3 * in the database so it reads back after a restart. Every call takes the
4 * person's id and answers only for their own conversation; another person's
5 * conversation is `not_found`. Plain data in, plain data out, no authz beyond
2 * A person's conversations with the forge, stored turn by turn in the
3 * database so they read back after a restart. A person has any number: each
4 * has an id (its URL), a title they may change, and may be archived. Every
5 * call takes the person's id and answers only for their own conversation;
6 * another person's conversation is `not_found`. Plain data in, plain data out, no authz beyond
67 * that ownership.
78 */
89 import type { Database } from "bun:sqlite";
@@ -25,8 +26,14 @@ export interface Conversation {
2526 readonly claudeSessionId: string;
2627 readonly createdAt: string;
2728 readonly updatedAt: string;
29 readonly title: string;
30 /** When it was put away; null while it is in the list. */
31 readonly archivedAt: string | null;
2832 }
2933
34 /** Longest title kept, in characters; longer is refused, not cut. */
35 export const TITLE_MAX = 80;
36
3037 export interface Turn {
3138 readonly id: string;
3239 readonly conversationId: string;
@@ -45,6 +52,8 @@ interface ConversationRow {
4552 claude_session_id: string;
4653 created_at: string;
4754 updated_at: string;
55 title: string;
56 archived_at: string | null;
4857 }
4958
5059 interface TurnRow {
@@ -63,6 +72,8 @@ const conversationOf = (r: ConversationRow): Conversation => ({
6372 claudeSessionId: r.claude_session_id,
6473 createdAt: r.created_at,
6574 updatedAt: r.updated_at,
75 title: r.title,
76 archivedAt: r.archived_at,
6677 });
6778
6879 const turnOf = (r: TurnRow): Turn => ({
@@ -75,18 +86,87 @@ const turnOf = (r: TurnRow): Turn => ({
7586 failed: r.failed === 1,
7687 });
7788
78 /** The person's conversation, made on first use. */
79 export function openConversation(db: Database, userId: string): Conversation {
89 /** Most conversations a person holds, archived ones included; past it, New is refused. */
90 export const CONVERSATIONS_MAX = 100;
91
92 /** What every door says when New is refused. */
93 export const TOO_MANY_WORDS = `you hold ${CONVERSATIONS_MAX} conversations, the most there can be; keep talking in one of them`;
94
95 /** Start a new conversation at a person's request: refused past `CONVERSATIONS_MAX`. */
96 export function createConversation(db: Database, userId: string): Result<Conversation, "too_many"> {

Count then insert outside a transaction: concurrent New can exceed the cap. Wrap in db.transaction.

97 return db.transaction((): Result<Conversation, "too_many"> => {
98 const held = db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(userId) as { n: number };
99 return held.n >= CONVERSATIONS_MAX ? err("too_many") : ok(newConversation(db, userId));
100 }).immediate();
101 }
102
103 /** Start a new, empty conversation. */
104 export function newConversation(db: Database, userId: string): Conversation {
80105 const now = new Date().toISOString();
106 const row: ConversationRow = { id: id(), user_id: userId, claude_session_id: "", created_at: now, updated_at: now, title: "", archived_at: null };
81107 db.query(
82 "INSERT OR IGNORE INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES (?1,?2,'',?3,?3)",
83 ).run(id(), userId, now);
84 const row = db
85 .query("SELECT * FROM conversation WHERE user_id = ?1")
86 .get(userId) as ConversationRow;
108 "INSERT INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES (?1,?2,'',?3,?3)",
109 ).run(row.id, userId, now);
87110 return conversationOf(row);
88111 }
89112
113 /**
114 * Where a message with no conversation named goes: the person's most recently
115 * active conversation that is not archived, made on first use.
116 */
117 export function openConversation(db: Database, userId: string): Conversation {
118 const row = db
119 .query("SELECT * FROM conversation WHERE user_id = ?1 AND archived_at IS NULL ORDER BY updated_at DESC, rowid DESC LIMIT 1")
120 .get(userId) as ConversationRow | null;
121 return row === null ? newConversation(db, userId) : conversationOf(row);
122 }
123
124 /** One of the person's conversations by id; another person's is nothing. */
125 export function getConversation(db: Database, userId: string, conversationId: string): Conversation | undefined {
126 const row = db
127 .query("SELECT * FROM conversation WHERE id = ?1 AND user_id = ?2")
128 .get(conversationId, userId) as ConversationRow | null;
129 return row === null ? undefined : conversationOf(row);
130 }
131
132 /** The person's conversations, newest activity first; `archived` picks the put-away ones instead. */
133 export function listConversations(db: Database, userId: string, opts: { readonly archived?: boolean } = {}): Conversation[] {
134 const rows = db
135 .query(
136 `SELECT * FROM conversation WHERE user_id = ?1 AND archived_at IS ${opts.archived === true ? "NOT" : ""} NULL ORDER BY updated_at DESC, rowid DESC LIMIT 200`,
137 )
138 .all(userId) as ConversationRow[];
139 return rows.map(conversationOf);
140 }
141
142 /** Give a conversation a title (1 to `TITLE_MAX` characters, no line breaks); an empty one clears it. */
143 export function renameConversation(
144 db: Database,
145 userId: string,
146 conversationId: string,
147 title: string,
148 ): Result<Conversation, "not_found" | "bad_title"> {
149 const clean = title.trim();
150 if (clean.length > TITLE_MAX || /[\u0000-\u001f\u007f]/.test(clean)) return err("bad_title");
151 const changed = db.query("UPDATE conversation SET title = ?3 WHERE id = ?1 AND user_id = ?2").run(conversationId, userId, clean).changes;
152 const found = changed === 0 ? undefined : getConversation(db, userId, conversationId);
153 return found === undefined ? err("not_found") : ok(found);
154 }
155
156 /** Put a conversation away, or bring it back (`archived` false). It keeps its turns and its URL. */
157 export function archiveConversation(
158 db: Database,
159 userId: string,
160 conversationId: string,
161 archived: boolean,
162 ): Result<Conversation, "not_found"> {
163 const changed = db
164 .query("UPDATE conversation SET archived_at = ?3 WHERE id = ?1 AND user_id = ?2")
165 .run(conversationId, userId, archived ? new Date().toISOString() : null).changes;
166 const found = changed === 0 ? undefined : getConversation(db, userId, conversationId);
167 return found === undefined ? err("not_found") : ok(found);
168 }
169
90170 const owned = (db: Database, userId: string, conversationId: string): boolean =>
91171 db
92172 .query("SELECT 1 FROM conversation WHERE id = ?1 AND user_id = ?2")
src/core/migration-pins/20261103120000-conversations-many.pin added +1
@@ -0,0 +1,1 @@
1 9377b1d4965fc75f3dc2cc16d200869bb905c2de6cef959967845ea8347d5f67
src/core/migrations/20261103120000-conversations-many.ts added +30
@@ -0,0 +1,30 @@
1 // Many conversations a person (#8814): the one-per-person UNIQUE goes, and a
2 // conversation gains a title and an archived mark. SQLite cannot drop a
3 // UNIQUE in place, so the table is copied; its turns and runs are held aside
4 // first (and the attachments, which hang off both) because dropping the parent
5 // would cascade them away.
6 export default `
7 CREATE TEMP TABLE keep_turn AS SELECT * FROM conversation_turn ORDER BY rowid;
8 CREATE TEMP TABLE keep_run AS SELECT * FROM conversation_run;
9 CREATE TEMP TABLE keep_file AS SELECT * FROM conversation_file;
10 CREATE TABLE conversation_next (
11 id TEXT PRIMARY KEY,
12 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
13 claude_session_id TEXT NOT NULL DEFAULT '',
outdated

HIGH: conversation_file (and anything else REFERENCES conversation ON DELETE CASCADE) is not held aside; DROP TABLE conversation cascades its rows away. Hold aside and restore all dependents, with a test.

14 created_at TEXT NOT NULL,
15 updated_at TEXT NOT NULL,
16 title TEXT NOT NULL DEFAULT '',
17 archived_at TEXT
18 );
19 INSERT INTO conversation_next (id, user_id, claude_session_id, created_at, updated_at)
20 SELECT id, user_id, claude_session_id, created_at, updated_at FROM conversation;
21 DROP TABLE conversation;
22 ALTER TABLE conversation_next RENAME TO conversation;
23 CREATE INDEX conversation_by_person ON conversation (user_id, updated_at);
24 INSERT INTO conversation_turn SELECT * FROM keep_turn ORDER BY rowid;
25 INSERT INTO conversation_run SELECT * FROM keep_run;
26 INSERT INTO conversation_file SELECT * FROM keep_file;
27 DROP TABLE keep_file;
28 DROP TABLE keep_turn;
29 DROP TABLE keep_run;
30 `;
src/crew/conversation.ts +39 −30
@@ -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, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts";
22 import { appendTurn, getConversation, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts";
2323 import { attach, deliver, fileOwnedBy, NOTHING, release, type ConversationFile, type Delivery } from "../core/conversation_files.ts";
2424 import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts";
2525 import { cancelHeld, WORDS_MAX } from "../core/fence.ts";
@@ -75,7 +75,8 @@ export const configureConversation = (app: App, options: ConversationOptions): v
7575 export const conversationOptions = (app: App): ConversationOptions | undefined => configured.get(app);
7676
7777 /** Whether a turn of this person's conversation is running now. */
78 export const turnRunning = (app: App, userId: string): boolean => live.has(openConversation(app.db, userId).id);
78 export const turnRunning = (app: App, userId: string, conversationId?: string): boolean =>
79 live.has(conversationId ?? openConversation(app.db, userId).id);
7980
8081 /** The one line the person sees when a restart cut their answer short. */
8182 export const RESUMED_LINE = "The forge restarted while I was answering; I am picking it up where I stopped.";
@@ -102,7 +103,7 @@ const RESUME_PROMPT =
102103 export const STOPPED_LINE = "Stopped.";
103104
104105 /** Stops for the turns running, by conversation id. */
105 const stoppers = new Map<string, { runId: string; stop: () => void }>();
106 const stoppers = new Map<string, { runId: string; userId: string; stop: () => void }>();
106107
107108 /**
108109 * Stop the person's running turn: the agent's process group ends (and every
@@ -110,15 +111,18 @@ const stoppers = new Map<string, { runId: string; stop: () => void }>();
110111 * Confirm is cancelled, never applied. False when no turn runs.
111112 */
112113 export function stopTurn(app: App, userId: string): boolean {
113 const conversation = openConversation(app.db, userId);
114 const stop = stoppers.get(conversation.id)?.stop;
115 if (stop === undefined) return false;
116 // Stop is the person calling it off: what waited behind the turn goes too (interrupting keeps it, `say`).
117 queues.delete(conversation.id);
118 // Cancelled first: no Confirm may land while the process is going down.
119 cancelHeld(app.db, `conversation:${conversation.id}`);
120 stop();
121 return true;
114 let stopped = false;
115 // Every turn the person has running, whichever conversation it is in.
116 for (const [conversationId, running] of stoppers) {
117 if (running.userId !== userId) continue;
118 // Cancelled first: no Confirm may land while the process is going down.
119 // Stop is the person calling it off: what waited behind the turn goes too (interrupting keeps it, `say`).
120 queues.delete(conversationId);
121 cancelHeld(app.db, `conversation:${conversationId}`);
122 running.stop();
123 stopped = true;
124 }
125 return stopped;
122126 }
123127
124128 /** Turns with a session listening, by conversation id: the run id a message is steered to. */
@@ -194,7 +198,7 @@ export function mcpConfig(localUrl: string): string {
194198 });
195199 }
196200
197 export type SayRefusal = "unauthorized" | "empty" | "too_long" | "no_account" | "resting" | "busy" | "answering" | "queue_full" | "file_taken";
201 export type SayRefusal = "not_found" | "unauthorized" | "empty" | "too_long" | "no_account" | "resting" | "busy" | "answering" | "queue_full" | "file_taken";
198202
199203 export interface Said {
200204 /** `started` a new turn, `steered` words into the one running, `queued` for after it, or `interrupted` it to send these at once. */
@@ -225,21 +229,23 @@ export const QUEUE_MAX = 5;
225229
226230 const queues = new Map<string, Queued[]>();
227231
228 /** The person's messages waiting for their running turn, oldest first. */
229 export const queuedOf = (app: App, userId: string): readonly Queued[] => queues.get(openConversation(app.db, userId).id) ?? [];
232 /** The person's messages waiting for their running turn in a conversation (the latest by default), oldest first. */
233 export const queuedOf = (app: App, userId: string, conversationId?: string): readonly Queued[] =>
234 queues.get(conversationId ?? openConversation(app.db, userId).id) ?? [];
230235
231236 /** Whether a message waiting in a queue holds this file: it cannot be removed or sent twice meanwhile. */
232237 export const fileQueued = (fileId: string): boolean => [...queues.values()].some((l) => l.some((q) => q.files.some((f) => f.id === fileId)));
233238
234239 /** Take back a waiting message; false when it is gone (already sent, or never theirs). */
235240 export function cancelQueued(app: App, userId: string, queuedId: string): boolean {
236 const key = openConversation(app.db, userId).id;
237 const list = queues.get(key) ?? [];
238 const at = list.findIndex((q) => q.id === queuedId);
239 if (at < 0) return false;
240 list.splice(at, 1);
241 if (list.length === 0) queues.delete(key);
242 return true;
241 for (const [key, list] of queues) {
242 const at = list.findIndex((q) => q.id === queuedId);
243 if (at < 0 || getConversation(app.db, userId, key) === undefined) continue;
244 list.splice(at, 1);
245 if (list.length === 0) queues.delete(key);
246 return true;
247 }
248 return false;
243249 }
244250
245251 /**
@@ -253,8 +259,8 @@ function drain(app: App, options: ConversationOptions, conversation: Conversatio
253259 if (list?.length === 0) queues.delete(conversation.id);
254260 if (next === undefined) return;
255261 const userId = conversation.userId;
256 const current = openConversation(app.db, userId);
257 if (current.id !== conversation.id) return void queues.delete(conversation.id);
262 const current = getConversation(app.db, userId, conversation.id);
263 if (current === undefined || current.archivedAt !== null) return void queues.delete(conversation.id);
258264 const stored = appendTurn(app.db, userId, current.id, { role: "person", text: next.text });
259265 if (!stored.ok) return drain(app, options, current);
260266 // A file taken back or sent some other way while this waited is left out, not sent twice.
@@ -281,12 +287,14 @@ export function say(
281287 text: string,
282288 files: readonly ConversationFile[] = [],
283289 during: During = "inject",
290 conversationId?: string,
284291 ): Result<Said, SayRefusal> {
285292 const user = getUserById(app.db, userId);
286293 if (user === undefined) return err("unauthorized");
287294 if (text.trim() === "") return err("empty");
288295 if (text.length > WORDS_MAX) return err("too_long");
289 const conversation = openConversation(app.db, userId);
296 const conversation = conversationId === undefined ? openConversation(app.db, userId) : getConversation(app.db, userId, conversationId);
297 if (conversation === undefined) return err("not_found");
290298 if (during !== "inject" && live.has(conversation.id)) {
291299 const list = queues.get(conversation.id) ?? [];
292300 const waiting: Queued = { id: id(), text, files };
@@ -335,10 +343,11 @@ export type RedoRefusal = SayRefusal | "running" | "nothing";
335343 * starts a fresh session whose context is the turns that still stand. Refused
336344 * while a turn runs, or when the last exchange is not what the action is for.
337345 */
338 export function redo(app: App, options: ConversationOptions, userId: string, how: RedoHow, text = ""): Result<Said, RedoRefusal> {
346 export function redo(app: App, options: ConversationOptions, userId: string, how: RedoHow, text = "", conversationId?: string): Result<Said, RedoRefusal> {
339347 const user = getUserById(app.db, userId);
340348 if (user === undefined) return err("unauthorized");
341 const conversation = openConversation(app.db, userId);
349 const conversation = conversationId === undefined ? openConversation(app.db, userId) : getConversation(app.db, userId, conversationId);
350 if (conversation === undefined) return err("not_found");
342351 if (live.has(conversation.id)) return err("running");
343352 const { person, after } = lastExchange(app.db, userId, conversation.id);
344353 if (person === undefined) return err("nothing");
@@ -389,8 +398,8 @@ export function resumeInterrupted(app: App, options: ConversationOptions): Promi
389398 carryWrites(app.db, `conversation:${row.conversation_id}`);
390399 app.db.query("UPDATE conversation_run SET state = 'failed', finished_at = ?2 WHERE id = ?1").run(row.id, new Date().toISOString());
391400 if (row.resumes >= 1) continue;
392 const conversation = openConversation(app.db, row.user_id);
393 if (conversation.id !== row.conversation_id) continue;
401 const conversation = getConversation(app.db, row.user_id, row.conversation_id);
402 if (conversation === undefined) continue;
394403 appendTurn(app.db, row.user_id, conversation.id, { role: "agent", text: RESUMED_LINE });
395404 // A write begun and not reported is shown, not retried: the person decides.
396405 if (unknown.length > 0) appendTurn(app.db, row.user_id, conversation.id, { role: "agent", text: unendedLine(unknown) });
@@ -460,7 +469,7 @@ function begin(
460469 .run(runId, conversation.id, userId, subscriptionId, prompt, resumes, resumeSession ?? "", now.toISOString());
461470 live.set(conversation.id, runId);
462471 const stopper = new AbortController();
463 stoppers.set(conversation.id, { runId, stop: () => stopper.abort() });
472 stoppers.set(conversation.id, { runId, userId, stop: () => stopper.abort() });
464473 const signal = AbortSignal.any([options.signal, stopper.signal]);
465474 const put = (role: "agent" | "tool", text: string): void => {
466475 const stored = appendTurn(app.db, userId, conversation.id, { role, text: text.slice(0, 20_000) });
src/http/api/conversation_talk.ts +70 −4
@@ -10,11 +10,24 @@
1010 import type { App } from "../../app.ts";
1111 import { RateLimiter } from "../../core/ratelimit.ts";
1212 import { cancelTalk, stopTalk, TALK_WORDS, talk } from "../../actions/talk.ts";
13 import {
14 archiveConversation,
15 createConversation,
16 DEFAULT_PAGE,
17 getConversation,
18 listConversations,
19 listTurns,
20 MAX_PAGE,
21 openConversation,
22 renameConversation,
23 TITLE_MAX,
24 TOO_MANY_WORDS,
25 } from "../../core/conversations.ts";
1326 import { FILE_WORDS, fileBytes, fileOwnedBy, FILES_PER_MESSAGE, IMAGE_MAX, pendingAmong, removePending, storeFile } from "../../core/conversation_files.ts";
14 import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation } from "../../core/conversations.ts";
1527 import { WORDS_MAX } from "../../core/fence.ts";
1628 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1729 import { scopeAllows } from "../../core/tokens.ts";
30 import type { User } from "../../core/users.ts";
1831 import { worksOf } from "../../core/talk_work.ts";
1932 import { fileQueued, queuedOf, turnRunning } from "../../crew/conversation.ts";
2033 import { crossOrigin, type Handler } from "../context.ts";
@@ -57,6 +70,8 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
5770 const text = input["text"];
5871 if (typeof text !== "string" || text.trim() === "" || text.length > WORDS_MAX)
5972 return apiError("invalid_text", 422, `text is a string of 1 to ${WORDS_MAX} characters`);
73 const named = input["conversation"];
74 if (named !== undefined && (typeof named !== "string" || !ID.test(named))) return apiError("not_found", 404, "no such conversation");
6075 const ids = input["files"] ?? [];
6176 if (!Array.isArray(ids) || ids.length > FILES_PER_MESSAGE || !ids.every((f) => typeof f === "string"))
6277 return apiError("invalid_files", 422, `files is a list of at most ${FILES_PER_MESSAGE} attachment ids`);
@@ -65,7 +80,7 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
6580 // What to do when a turn runs: absent hands the words to it; `queue` waits for it; `steer` stops it and sends these at once.
6681 const during = input["during"];
6782 if (during !== undefined && during !== "queue" && during !== "steer") return apiError("invalid_during", 422, "during is queue or steer");
68 const said = talk(ctx.app, user, text, files.value, during === "queue" ? "queue" : during === "steer" ? "interrupt" : "inject");
83 const said = talk(ctx.app, user, text, files.value, during === "queue" ? "queue" : during === "steer" ? "interrupt" : "inject", named);
6984 if (!said.ok) {
7085 const [status, words] = TALK_WORDS[said.error];
7186 return json({ error: said.error, message: words }, status, said.error === "rate_limited" || said.error === "busy" ? { "retry-after": "60" } : {});
@@ -146,7 +161,9 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
146161 const user = ctx.auth.user;
147162 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
148163 if (user === undefined) return unauthorized();
149 const conversation = openConversation(ctx.app.db, user.id);
164 const named = ctx.url.searchParams.get("conversation");
165 const conversation = named === null ? openConversation(ctx.app.db, user.id) : getConversation(ctx.app.db, user.id, named);
166 if (conversation === undefined) return apiError("not_found", 404, "no such conversation");
150167 const turns = listTurns(ctx.app.db, user.id, conversation.id, {
151168 limit: turnsLimit(ctx.url.searchParams.get("limit")),
152169 ...(ctx.url.searchParams.get("before") ? { before: ctx.url.searchParams.get("before")! } : {}),
@@ -155,6 +172,55 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
155172 const all = turns.ok ? turns.value : [];
156173 const works = worksOf(ctx.app.db, user.id, conversation.id, all);
157174 const shown = all.map((t) => (t.role === "agent" ? { ...t, work: works.get(t.id) ?? null } : t));
158 return json({ running: turnRunning(ctx.app, user.id), queued: queuedOf(ctx.app, user.id).map((q) => ({ id: q.id, text: q.text })), turns: shown });
175 return json({ running: turnRunning(ctx.app, user.id, conversation.id), conversation: conversation.id, queued: queuedOf(ctx.app, user.id, conversation.id).map((q) => ({ id: q.id, text: q.text })), turns: shown });
176 });
177
178 router.add("GET", "/api/v1/conversations", (ctx) => {
179 const user = ctx.auth.user;
180 if (user === undefined) return unauthorized();
181 return json({ conversations: listConversations(ctx.app.db, user.id, { archived: ctx.url.searchParams.get("archived") === "1" }) });
182 });
183
184 router.add("POST", "/api/v1/conversations", async (ctx) => {
185 const user = mayChange(ctx);
186 if (user instanceof Response) return user;
187 const input = await boundedBody(ctx.request, 4_000);
188 if (input === "too_large") return apiError("too_large", 413, "at most 4000 bytes");
189 const title = input["title"];
190 if (title !== undefined && typeof title !== "string") return apiError("invalid_title", 422, TITLE_WORDS);
191 const started = createConversation(ctx.app.db, user.id);
192 if (!started.ok) return apiError("too_many", 409, TOO_MANY_WORDS);
193 const made = started.value;
194 if (title === undefined || title.trim() === "") return json({ conversation: made }, 201);
195 const named = renameConversation(ctx.app.db, user.id, made.id, title);
196 return named.ok ? json({ conversation: named.value }, 201) : apiError("invalid_title", 422, TITLE_WORDS);
197 });
198
199 router.add("PATCH", "/api/v1/conversations/:id", async (ctx) => {
200 const user = mayChange(ctx);
201 if (user instanceof Response) return user;
202 const id = ctx.params["id"] ?? "";
203 if (!ID.test(id) || getConversation(ctx.app.db, user.id, id) === undefined) return apiError("not_found", 404, "no such conversation");
204 const input = await boundedBody(ctx.request, 4_000);
205 if (input === "too_large") return apiError("too_large", 413, "at most 4000 bytes");
206 const { title, archived } = input;
207 if (title !== undefined && typeof title !== "string") return apiError("invalid_title", 422, TITLE_WORDS);
208 if (archived !== undefined && typeof archived !== "boolean") return apiError("invalid", 422, "archived is true or false");
209 if (title === undefined && archived === undefined) return apiError("invalid", 422, "send title or archived");
210 if (title !== undefined && !renameConversation(ctx.app.db, user.id, id, title).ok) return apiError("invalid_title", 422, TITLE_WORDS);
211 if (archived !== undefined) archiveConversation(ctx.app.db, user.id, id, archived);
212 return json({ conversation: getConversation(ctx.app.db, user.id, id) });
159213 });
160214 }
215
216 const ID = /^[0-9a-z]{1,40}$/;
217 const TITLE_WORDS = `title is text of at most ${TITLE_MAX} characters, on one line`;
218
219 /** The person a change is for: signed in, same-origin, and not on a read-only token. */
220 function mayChange(ctx: Parameters<Handler>[0]): User | Response {
221 const user = ctx.auth.user;
222 if (user === undefined) return unauthorized();
223 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
224 if (ctx.auth.via === "token" && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) return forbidden();
225 return user;
226 }
src/http/mcp/tools/say.ts +34 −5
@@ -1,32 +1,61 @@
11 import { mayView } from "../../../core/sealed.ts";
22 import { addCommentThrough } from "../../../actions/issues.ts";
33 import { getIssue } from "../../../core/issues.ts";
4 import { archiveConversation, createConversation, getConversation, listConversations, renameConversation, TOO_MANY_WORDS } from "../../../core/conversations.ts";
45 import { talk, TALK_WORDS } from "../../../actions/talk.ts";
56 import { WORDS_MAX } from "../../../core/fence.ts";
67 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
78
89 export const tool: Tool = {
910 name: "say",
10 doc: "speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`",
11 doc: "speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`. `conversation` names one of your conversations (default: the latest); `action` list | new | rename | archive | restore manages them (`title` for rename and new)",
1112 description:
12 "Say something on an issue or pull; with no repo, to the forge's own agent.",
13 "Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action).",
1314 inputSchema: {
1415 type: "object",
1516 properties: {
1617 repo: REPO_ARG,
1718 number: { type: "integer", minimum: 1 },
1819 text: { type: "string", maxLength: 8000 },

required: ["text"] was removed; a call with neither text nor action must be refused explicitly.

20 conversation: { type: "string" },
21 action: { type: "string", enum: ["list", "new", "rename", "archive", "restore"] },
22 title: { type: "string", maxLength: 80 },
23 archived: { type: "boolean" },
1924 },
20 required: ["text"],
2125 },
2226 writes: true,
2327 async run(ctx, user, args) {
2428 if (args["repo"] === undefined && args["number"] === undefined) {
2529 // The forge's own agent never wakes itself: its via token is refused.
2630 if (ctx.auth.through !== undefined) return bad("forbidden", "the forge's agent cannot start its own turn");
27 const text = str(args["text"]);
31 const db = ctx.app.db;
32 const action = args["action"];
33 if (action === "list") return good({ conversations: listConversations(db, user.id, { archived: args["archived"] === true }).map(({ id, title, updatedAt }) => ({ id, title, updatedAt })) });
34 if (action === "new") {
35 const started = createConversation(db, user.id);
36 if (!started.ok) return bad("too_many", TOO_MANY_WORDS);
37 const made = started.value;
38 const title = str(args["title"]);
39 if (title !== "" && !renameConversation(db, user.id, made.id, title).ok) return bad("invalid_title", "title is up to 80 characters, on one line");
40 return good({ conversation: made.id, says: "Started; send text with this conversation to speak in it. It opens at /talk/" + made.id + "." });
41 }
42 if (action !== undefined) {
43 const target = getConversation(db, user.id, str(args["conversation"]));
44 if (target === undefined) return bad("not_found", "no such conversation of yours");
45 if (action === "rename") {
46 if (typeof args["title"] !== "string") return bad("invalid_title", "rename needs a title");
47 const named = renameConversation(db, user.id, target.id, args["title"]);
48 return named.ok ? good({ says: "Renamed." }) : bad("invalid_title", "title is up to 80 characters, on one line");
49 }
50 if (action !== "archive" && action !== "restore") return bad("invalid", "action is list, new, rename, archive or restore");
51 archiveConversation(db, user.id, target.id, action === "archive");
52 return good({ says: action === "archive" ? "Archived." : "Restored." });
53 }
54 if (typeof args["text"] !== "string") return bad("invalid_text", "say needs `text` (or an `action` to manage conversations)");
55 const text = args["text"];
2856 if (text.trim() === "" || text.length > WORDS_MAX) return bad("invalid_text", `text is 1 to ${WORDS_MAX} characters`);
29 const talked = talk(ctx.app, user, text);
57 const named = args["conversation"] === undefined ? undefined : str(args["conversation"]);
58 const talked = talk(ctx.app, user, text, [], "inject", named);
3059 if (!talked.ok) return bad(talked.error, TALK_WORDS[talked.error][1]);
3160 return good({ says: talked.value.how === "steered" ? "Said to the forge's agent, mid-answer." : "The forge's agent is answering; read it in GET /api/v1/conversation/turns." });
3261 }
src/http/server.ts +7 −1
@@ -124,7 +124,7 @@ import {
124124 verifyPage,
125125 themeSubmit,
126126 } from "../web/pages/site.ts";
127 import { talkAgainSend, talkConfirm, talkQueueCancel, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
127 import { talkAgainSend, talkArchive, talkConfirm, talkNew, talkQueueCancel, talkRename, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
128128 import {
129129 recoverFinish,
130130 recoverOptions,
@@ -844,6 +844,12 @@ export function buildRouter(): Router<Handler> {
844844 router.add("POST", "/talk/again/:how", talkAgainSend);
845845 router.add("POST", "/talk/queue/:id/cancel", talkQueueCancel);
846846 router.add("POST", "/talk/confirm/:id", talkConfirm);
847 router.add("POST", "/talk/new", talkNew);
848 router.add("GET", "/talk/:id", talkView);
849 router.add("POST", "/talk/:id", talkSend);
850 router.add("POST", "/talk/:id/rename", talkRename);
851 router.add("POST", "/talk/:id/archive", talkArchive);
852 router.add("POST", "/talk/:id/again/:how", talkAgainSend);
847853 router.add("POST", "/signin/passkey/options", signinOptions);
848854 router.add("POST", "/signin/passkey", signinPasskey);
849855 router.add("GET", "/recover", recoverPage);
src/tui/forge.ts +9
@@ -50,6 +50,7 @@ import { usageScreen } from "./screens/usage.ts";
5050 import { canScreen } from "./screens/can.ts";
5151 import { modelScreen } from "./screens/model.ts";
5252 import { notifyScreen } from "./screens/notify.ts";
53 import { talkChatsScreen } from "./screens/talk_chats.ts";
5354 import { sshKeysScreen } from "./screens/sshkeys.ts";
5455 import { passkeysScreen } from "./screens/passkeys.ts";
5556 import { confirmsScreen } from "./screens/confirms.ts";
@@ -307,6 +308,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
307308 hint: "what the forge's agent asked to do; y does it once",
308309 run: () => ctx.push(confirmsScreen(session, ctx)),
309310 });
311 // The forge's agent keeps many conversations, as /talk lists them (#8814).
312 if (session.signedIn && session.user?.kind !== "agent")
313 commands.push({
314 id: "conversations",
315 title: "Conversations",
316 hint: "your talks with the forge's agent; n starts one, r renames, x archives",
317 run: () => ctx.push(talkChatsScreen(session, ctx)),
318 });
310319 if (session.signedIn)
311320 commands.push({
312321 id: "ssh-keys",
src/tui/screens/talk_chats.ts added +199
@@ -0,0 +1,199 @@
1 /**
2 * Talk conversations in the terminal (#8814): the same list, New, Rename and
3 * Archive/Restore /talk has, through the same `core/conversations.ts` calls,
4 * so a change here is the next thing a web viewer's list or stream reads.
5 * `a` flips between the live list and the archived one; Enter opens one,
6 * to read it and, with `s`, say something in it through `talk()`.
7 */
8
9 import { TALK_WORDS, talk } from "../../actions/talk.ts";
10 import {
11 archiveConversation,
12 createConversation,
13 listConversations,
14 listTurns,
15 renameConversation,
16 TITLE_MAX,
17 TOO_MANY_WORDS,
18 type Conversation,
19 } from "../../core/conversations.ts";
20 import type { Ctx, Screen } from "../app/app.ts";
21 import { binding } from "../app/keys.ts";
22 import { style } from "../term/style.ts";
23 import { truncate, wrap } from "../term/width.ts";
24 import type { Session } from "../session.ts";
25 import { fill, len, split } from "../ui/layout.ts";
26 import { ListState, Scroll, ago, drawLines, drawList, promptScreen, type ListRow } from "./common.ts";
27
28 const BAD_TITLE = `A title is up to ${TITLE_MAX} characters, on one line.`;
29
30 const WHO: Readonly<Record<string, string>> = { person: "You", agent: "Forge", tool: "Tool", confirm: "Asks" };
31
32 /** One conversation, newest turns last: j/k scroll, s says something in it, r reloads. */
33 function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen {
34 const scroll = new Scroll();
35 let note = "";
36 let width = 80;
37 // Opened, and after r, the view sits on the newest turns.
38 let follow = true;
39 const lines = (): { text: string; style: ReturnType<typeof style> }[][] => {
40 const c = ctx.theme.c;
41 const listed = f.user === undefined ? undefined : listTurns(f.app.db, f.user.id, chat.id, { limit: 50 });
42 const turns = listed?.ok ? listed.value : [];
43 if (turns.length === 0) return [[{ text: "Nothing said yet; s says something.", style: style(c.mutedFg, c.bg) }]];
44 return turns.flatMap((t) => [
45 [{ text: `${WHO[t.role] ?? t.role}${t.replaced ? " · replaced" : ""} · ${ago(t.createdAt)}`, style: style(c.mutedFg, c.bg) }],
46 ...t.text.split("\n").flatMap((l) => wrap(l, Math.max(10, width - 2))).map((l) => [{ text: l, style: style(c.fg, c.bg) }]),
47 [],
48 ]);
49 };
50 const screen: Screen = {
51 get title() {
52 return chat.title === "" ? "Untitled conversation" : chat.title;
53 },
54 keys: [binding(["j", "k"], "scroll"), binding(["s"], "say"), binding(["r"], "reload"), binding(["esc"], "back")],
55 render(area, s) {
56 const c = ctx.theme.c;
57 width = area.w;
58 const [head, body] = split(area, "v", [len(1), fill(1)]);
59 const text = note !== "" ? note : `/talk/${chat.id}${chat.archivedAt === null ? "" : " · archived: sending does not bring it back"}`;
60 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
61 const all = lines();
62 if (follow) scroll.to(all.length - body!.h, all.length);
63 drawLines(s, body!, c, scroll, all);
64 },
65 key(e) {
66 if (scroll.key(e, lines().length)) {
67 follow = false;
68 return true;
69 }
70 const user = f.user;
71 if (user === undefined || e.ctrl || e.alt) return false;
72 if (e.key === "esc" || e.key === "q") {
73 ctx.pop();
74 return true;
75 }
76 if (e.key === "r") {
77 note = "";
78 follow = true;
79 ctx.invalidate();
80 return true;
81 }
82 if (e.key === "s") {
83 ctx.push(
84 promptScreen(ctx, screen, {
85 title: "Say",
86 label: "to the forge, in this conversation",
87 onSubmit: (words) => {
88 const said = talk(f.app, user, words, [], "inject", chat.id);
89 if (!said.ok) return TALK_WORDS[said.error][1];
90 note = "Sent; r reloads to see the answer.";
91 ctx.invalidate();
92 return undefined;
93 },
94 }),
95 );
96 return true;
97 }
98 return false;
99 },
100 };
101 return screen;
102 }
103
104 export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
105 const state = new ListState();
106 let archived = false;
107 let note = "";
108 const items = (): Conversation[] => (f.user === undefined ? [] : listConversations(f.app.db, f.user.id, { archived }));
109 const rows = (): ListRow[] =>
110 items().map((c) => ({ title: c.title === "" ? "Untitled" : c.title, secondary: `/talk/${c.id}`, meta: `active ${ago(c.updatedAt)}` }));
111 const say = (text: string): void => {
112 note = text;
113 ctx.invalidate();
114 };
115
116 const screen: Screen = {
117 get title() {
118 return archived ? "Archived conversations" : "Conversations";
119 },
120 keys: [
121 binding(["j", "k"], "move cursor"),
122 binding(["enter"], "open"),
123 binding(["n"], "new"),
124 binding(["r"], "rename"),
125 binding(["x"], "archive or restore"),
126 binding(["a"], "archived"),
127 ],
128 render(area, s) {
129 const c = ctx.theme.c;
130 const [head, body] = split(area, "v", [len(1), fill(1)]);
131 const text =
132 note !== ""
133 ? note
134 : items().length === 0
135 ? archived ? "Nothing archived." : "No conversations yet; n starts one."
136 : archived ? "Archived; x brings one back." : "Your talks with the forge's agent; each opens at its /talk address.";
137 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
138 drawList(s, body!, c, state, rows());
139 },
140 key(e) {
141 if (state.key(e, items().length)) return true;
142 const user = f.user;
143 if (user === undefined || e.ctrl || e.alt) return false;
144 const here = items()[state.index];
145 if (e.key === "a") {
146 archived = !archived;
147 state.index = 0;
148 note = "";
149 ctx.invalidate();
150 return true;
151 }
152 if (e.key === "n") {
153 ctx.push(
154 promptScreen(ctx, screen, {
155 title: "New conversation",
156 label: "a title (optional)",
157 onSubmit: (title) => {
158 const made = createConversation(f.app.db, user.id);
159 if (!made.ok) return TOO_MANY_WORDS;
160 if (title.trim() !== "" && !renameConversation(f.app.db, user.id, made.value.id, title).ok) return BAD_TITLE;
161 archived = false;
162 state.index = 0;
163 say(`Started /talk/${made.value.id}.`);
164 return undefined;
165 },
166 }),
167 );
168 return true;
169 }
170 if (here === undefined) return false;
171 if (e.key === "enter") {
172 ctx.push(chatScreen(f, ctx, here));
173 return true;
174 }
175 if (e.key === "r") {
176 ctx.push(
177 promptScreen(ctx, screen, {
178 title: "Rename conversation",
179 label: "a title (empty clears it)",
180 value: here.title,
181 onSubmit: (title) => {
182 if (!renameConversation(f.app.db, user.id, here.id, title).ok) return BAD_TITLE;
183 say("Renamed.");
184 return undefined;
185 },
186 }),
187 );
188 return true;
189 }
190 if (e.key === "x") {
191 archiveConversation(f.app.db, user.id, here.id, !archived);
192 say(archived ? "Restored." : "Archived.");
193 return true;
194 }
195 return false;
196 },
197 };
198 return screen;
199 }
src/web/pages/talk.ts +155 −35
@@ -18,7 +18,23 @@ import type { Database } from "bun:sqlite";
1818 import { cancelTalk, queuedTalk, TALK_WORDS, talk, talkAgain } from "../../actions/talk.ts";
1919 import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
2020 import { filesOfTurns, type ConversationFile } from "../../core/conversation_files.ts";
21 import { DEFAULT_PAGE, lastExchange, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts";
21 import {
22 archiveConversation,
23 type Conversation,
24 DEFAULT_PAGE,
25 lastExchange,
26 getConversation,
27 listConversations,
28 listTurns,
29 MAX_PAGE,
30 createConversation,
31 TOO_MANY_WORDS,
32 openConversation,
33 renameConversation,
34 TITLE_MAX,
35 turnsAfter,
36 type Turn,
37 } from "../../core/conversations.ts";
2238 import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts";
2339 import { WORDS_MAX } from "../../core/fence.ts";
2440 import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts";
@@ -28,10 +44,10 @@ import { turnRunning, type RedoHow } from "../../crew/conversation.ts";
2844 import type { Ctx } from "../../http/context.ts";
2945 import { json } from "../../http/respond.ts";
3046 import { eventStream } from "../../http/sse.ts";
31 import { signInFirst, forbiddenPage } from "../chrome.ts";
47 import { signInFirst, forbiddenPage, notFoundPage } from "../chrome.ts";
3248 import { readForm } from "../form.ts";
3349 import { html, type Html } from "../html.ts";
34 import { flashTo, page } from "../layout.ts";
50 import { flashTo, page, redirect } from "../layout.ts";
3551 import { renderMarkdown } from "../markdown.ts";
3652 import { btn, time } from "../ui.ts";
3753 import { talkCss, talkJs } from "./talk_client.ts";
@@ -47,6 +63,12 @@ export const clock = { streamMs: STREAM_MS, running: turnRunning };
4763 const open = new Map<string, number>();
4864 const TURN_ID = /^[0-9a-z]{0,40}$/;
4965 const HERE = "/talk";
66 const CONVERSATION_ID = /^[0-9a-z]{1,40}$/;
67
68 /** What a conversation is called in a list: its title, else the day it began. */
69 const nameOf = (c: Conversation): string => (c.title === "" ? `Chat of ${c.createdAt.slice(0, 10)}` : c.title);
70 /** Where a conversation lives. */
71 const urlOf = (c: Conversation): string => `${HERE}/${c.id}`;
5072
5173 const parse = (text: string): Record<string, unknown> | undefined => {
5274 try {
@@ -88,23 +110,23 @@ const workHtml = (work: Work): Html =>
88110 const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const;
89111
90112 /** Ask again as a form, so it works without the script; the script only enhances it. */
91 const againForm = (how: "regenerate" | "retry", label: string, words: string): Html =>
92 html`<form method="post" action="${HERE}/again/${how}" data-again="${how}">${btn(label, { icon: "sync", label: words })}</form>`;
113 const againForm = (base: string, how: "regenerate" | "retry", label: string, words: string): Html =>
114 html`<form method="post" action="${base}/again/${how}" data-again="${how}">${btn(label, { icon: "sync", label: words })}</form>`;
93115
94116 /**
95117 * What the last exchange offers, by the turn it sits under: Edit under the
96118 * person's last message, and under the last turn Retry when it failed or
97119 * Regenerate when it is a reply. Nothing while a turn runs.
98120 */
99 export function exchangeActs(db: Database, userId: string, conversationId: string, running: boolean): Map<string, Html> {
121 export function exchangeActs(db: Database, userId: string, conversationId: string, running: boolean, base: string = HERE): Map<string, Html> {
100122 const acts = new Map<string, Html>();
101123 if (running) return acts;
102124 const { person, after } = lastExchange(db, userId, conversationId);
103125 if (person === undefined) return acts;
104 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>`);
126 acts.set(person.id, html`<a class="btn talk-act" href="${base}?edit=1#compose" data-act="edit" aria-label="Edit your last message">Edit</a>`);
105127 const last = after.at(-1);
106 if (last?.failed === true) acts.set(last.id, againForm("retry", "Retry", "Retry the failed turn"));
107 else if (last?.role === "agent") acts.set(last.id, againForm("regenerate", "Regenerate", "Regenerate the last reply"));
128 if (last?.failed === true) acts.set(last.id, againForm(base, "retry", "Retry", "Retry the failed turn"));
129 else if (last?.role === "agent") acts.set(last.id, againForm(base, "regenerate", "Regenerate", "Regenerate the last reply"));
108130 return acts;
109131 }
110132
@@ -166,9 +188,48 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number
166188 return toolCard(id, call, call.split(/[\s(:{]/, 1)[0] ?? "tool", turn.createdAt, set);
167189 }
168190
169 function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: boolean): Response {
191 /** The conversations list, then this one's name with Rename and Archive: each a plain form, so none needs the script. */
192 function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Html {
170193 const user = ctx.auth.user!;
171 const conversation = openConversation(ctx.app.db, user.id);
194 const shown = listConversations(ctx.app.db, user.id, { archived: showArchived });
195 const put = listConversations(ctx.app.db, user.id, { archived: !showArchived }).length;
196 const archived = conversation.archivedAt !== null;
197 return html`<nav class="talk-chats" aria-label="Conversations">
198 <div class="bar">
199 <form method="post" action="${HERE}/new">${btn("New", { icon: "plus", label: "New conversation" })}</form>
200 <span class="spacer"></span>
201 ${showArchived
202 ? html`<a class="small" href="${HERE}">Back to conversations</a>`
203 : put > 0
204 ? html`<a class="small" href="${HERE}?archived=1">Archived (${put})</a>`
205 : null}
206 </div>
207 <ul>
208 ${shown.length === 0
209 ? html`<li class="muted small">${showArchived ? "Nothing archived." : "No conversations yet."}</li>`
210 : shown.map((c) => html`<li ${c.id === conversation.id ? html`aria-current="page"` : null}><a href="${urlOf(c)}"><span>${nameOf(c)}</span><span class="muted small">${time(c.updatedAt)}</span></a></li>`)}
211 </ul>
212 </nav>
213 <div class="talk-here bar">
214 <strong>${nameOf(conversation)}${archived ? " (archived)" : ""}</strong>
215 <details>
216 <summary class="small">Rename</summary>
217 <form method="post" action="${urlOf(conversation)}/rename">
218 <input class="input" name="title" value="${conversation.title}" maxlength="${TITLE_MAX}" aria-label="Title" placeholder="Title" />
219 ${btn("Save")}
220 </form>
221 </details>
222 <form method="post" action="${urlOf(conversation)}/archive">
223 <input type="hidden" name="archived" value="${archived ? "0" : "1"}" />
224 ${btn(archived ? "Restore" : "Archive")}
225 </form>
226 </div>`;
227 }
228
229 function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefined, prefill: string, latest: boolean, edit: boolean): Response {
230 const user = ctx.auth.user!;
231 const here = latest ? HERE : urlOf(conversation);
232 const showArchived = latest && ctx.url.searchParams.get("archived") === "1";
172233 const listed = listTurns(ctx.app.db, user.id, conversation.id, {
173234 limit: DEFAULT_PAGE,
174235 ...(before === undefined ? {} : { before }),
@@ -176,33 +237,35 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b
176237 const turns = listed.ok ? listed.value : [];
177238 const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id));
178239 const sent = filesOfTurns(ctx.app.db, user.id, turns.filter((t) => t.role === "person").map((t) => t.id));
179 const running = clock.running(ctx.app, user.id);
240 const running = clock.running(ctx.app, user.id, conversation.id);
180241 const costs = turnCosts(ctx.app.db, conversation.id);
181242 const context = contextOf(ctx.app.db, conversation.id);
182243 const works = worksOf(ctx.app.db, user.id, conversation.id, turns);
183244 // Paging back, the log ends before the newest turn: it does not follow.
184245 const last = turns.at(-1)?.id ?? "";
185 const acts = before === undefined ? exchangeActs(ctx.app.db, user.id, conversation.id, running) : new Map<string, Html>();
246 const acts = before === undefined ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>();
186247 // Edit shows the composer holding the last message, posting to Edit instead of Send.
187248 const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined;
188249 const body = html`<div class="page-head"><h1>Talk</h1></div>
189 <div class="talk" data-talk data-user="${user.id}" data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}">
250 ${chats(ctx, conversation, showArchived)}
251 <div class="talk" data-talk data-user="${user.id}" data-c="${conversation.id}" data-here="${here}" data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}">
190252 <div>
191253 ${contextLine(context)}
192254 ${turns.length === DEFAULT_PAGE && turns[0] !== undefined
193 ? html`<p class="talk-more small"><a href="${HERE}?before=${turns[0].id}">Earlier</a></p>`
255 ? html`<p class="talk-more small"><a href="${here}?before=${turns[0].id}">Earlier</a></p>`
194256 : null}
195 ${before === undefined ? null : html`<p class="talk-more small"><a href="${HERE}">Latest</a></p>`}
257 ${before === undefined ? null : html`<p class="talk-more small"><a href="${here}">Latest</a></p>`}
196258 <ol class="talk-log" role="log" data-last="${last}" aria-label="Conversation with the forge">
197259 ${turns.length === 0
198260 ? 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.
199 <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${HERE}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>`
261 <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${here}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>`
200262 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id), acts.get(t.id), works.get(t.id), sent.get(t.id)))}
201 ${before === undefined && running ? queuedTalk(ctx.app, user).map(queuedHtml) : null}
263 ${before === undefined && running ? queuedTalk(ctx.app, user, conversation.id).map(queuedHtml) : null}
202264 </ol>
203265 <div id="end"></div>
204266 </div>
205 <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? HERE : `${HERE}/again/edit`}" ${editing === undefined ? null : html`data-edit`}>
267 <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? here : `${here}/again/edit`}" ${editing === undefined ? null : html`data-edit`}>
268 ${conversation.archivedAt === null ? null : html`<p class="small muted" data-archived-note>Archived: sending here keeps it archived; Restore puts it back in the list.</p>`}
206269 <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p>
207270 <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div>
208271 <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>
@@ -210,7 +273,7 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b
210273 <span class="muted small" data-hint></span>
211274 <label class="btn talk-attach" data-attach hidden>Attach<input type="file" multiple hidden data-pick /></label>
212275 <span class="spacer"></span>
213 ${editing === undefined ? null : html`<a class="btn" href="${HERE}">Keep as it was</a>`}
276 ${editing === undefined ? null : html`<a class="btn" href="${here}">Keep as it was</a>`}
214277 <button type="button" class="btn" data-stop hidden>Stop</button>
215278 <button type="button" class="btn" data-steer hidden title="Stop this answer and send now">Steer</button>
216279 ${btn(editing === undefined ? "Send" : "Resend", { variant: "primary" })}
@@ -220,32 +283,83 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b
220283 return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge." });
221284 }
222285
223 // GET /talk
286 /** The conversation a request names (`:id`), or the person's latest when it names none; undefined when it is not theirs. */
287 const conversationOf = (ctx: Ctx, user: { readonly id: string }): Conversation | undefined => {
288 const named = ctx.params["id"];
289 if (named === undefined) return openConversation(ctx.app.db, user.id);
290 return CONVERSATION_ID.test(named) ? getConversation(ctx.app.db, user.id, named) : undefined;
291 };
292
293 // GET /talk and GET /talk/:id
224294 export function talkView(ctx: Ctx): Response {
225295 if (ctx.auth.user === undefined) return signInFirst(ctx);
226296 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
227297 const before = ctx.url.searchParams.get("before");
228298 const q = ctx.url.searchParams.get("q");
229 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));
299 const conversation = conversationOf(ctx, ctx.auth.user);
300 if (conversation === undefined) return notFoundPage(ctx);
301 return timePhase(ctx.request, "render", () =>
302 talkPage(ctx, conversation, before !== null && TURN_ID.test(before) && before !== "" ? before : undefined, q !== null && SUGGESTIONS.some((s) => s === q) ? q : "", ctx.params["id"] === undefined, ctx.url.searchParams.get("edit") === "1" && before === null),
303 );
230304 }
231305
232 // POST /talk — the plain form, for a browser without the script.
306 // POST /talk and POST /talk/:id — the plain form, for a browser without the script.
233307 export async function talkSend(ctx: Ctx): Promise<Response> {
234308 const user = ctx.auth.user;
235309 if (user === undefined) return signInFirst(ctx);
236310 const form = await readForm(ctx, "rw");
237311 if (form instanceof Response) return form;
312 const conversation = conversationOf(ctx, user);
313 if (conversation === undefined) return notFoundPage(ctx);
314 const here = ctx.params["id"] === undefined ? HERE : urlOf(conversation);
238315 const text = form.get("text");
239316 if (typeof text !== "string" || text.trim() === "" || text.length > WORDS_MAX)
240 return flashTo(ctx, `${HERE}#end`, { tone: "danger", message: `Say something, up to ${WORDS_MAX} characters.` });
241 const said = talk(ctx.app, user, text, [], "queue");
242 if (!said.ok) return flashTo(ctx, `${HERE}#end`, { tone: "danger", message: TALK_WORDS[said.error][1] });
243 return flashTo(ctx, `${HERE}#end`, { tone: "success", message: "Sent. Reload to see the answer." });
317 return flashTo(ctx, `${here}#end`, { tone: "danger", message: `Say something, up to ${WORDS_MAX} characters.` });
318 const said = talk(ctx.app, user, text, [], "queue", conversation.id);
319 if (!said.ok) return flashTo(ctx, `${here}#end`, { tone: "danger", message: TALK_WORDS[said.error][1] });
320 return flashTo(ctx, `${here}#end`, { tone: "success", message: "Sent. Reload to see the answer." });
321 }
322
323 // POST /talk/new — an empty conversation, opened at its own URL.
324 export async function talkNew(ctx: Ctx): Promise<Response> {
325 const user = ctx.auth.user;
326 if (user === undefined) return signInFirst(ctx);
327 const form = await readForm(ctx, "rw");
328 if (form instanceof Response) return form;
329 const made = createConversation(ctx.app.db, user.id);
330 if (!made.ok) return flashTo(ctx, HERE, { tone: "danger", message: `Not started: ${TOO_MANY_WORDS}.` });
331 return redirect(urlOf(made.value));
332 }
333
334 // POST /talk/:id/rename — the title from the form; an empty one clears it.
335 export async function talkRename(ctx: Ctx): Promise<Response> {
336 const user = ctx.auth.user;
337 if (user === undefined) return signInFirst(ctx);
338 const form = await readForm(ctx, "rw");
339 if (form instanceof Response) return form;
340 const conversation = conversationOf(ctx, user);
341 if (conversation === undefined) return notFoundPage(ctx);
342 const title = form.get("title");
343 const renamed = renameConversation(ctx.app.db, user.id, conversation.id, typeof title === "string" ? title : "\n");
344 return flashTo(ctx, urlOf(conversation), renamed.ok ? { tone: "success", message: "Renamed." } : { tone: "danger", message: `A title is up to ${TITLE_MAX} characters, on one line.` });
345 }
346
347 // POST /talk/:id/archive — archived=1 puts it away, 0 brings it back.
348 export async function talkArchive(ctx: Ctx): Promise<Response> {
349 const user = ctx.auth.user;
350 if (user === undefined) return signInFirst(ctx);
351 const form = await readForm(ctx, "rw");
352 if (form instanceof Response) return form;
353 const conversation = conversationOf(ctx, user);
354 if (conversation === undefined) return notFoundPage(ctx);
355 const archive = form.get("archived") !== "0";
356 archiveConversation(ctx.app.db, user.id, conversation.id, archive);
357 return flashTo(ctx, archive ? HERE : urlOf(conversation), { tone: "success", message: archive ? "Archived. It is under Archived." : "Restored." });
244358 }
245359
246360 const AGAIN: readonly RedoHow[] = ["edit", "regenerate", "retry"];
247361
248 // POST /talk/again/:how — edit and resend the last message, regenerate the last reply, retry a failed turn.
362 // POST /talk/again/:how and POST /talk/:id/again/:how — edit and resend the last message, regenerate the last reply, retry a failed turn.
249363 export async function talkAgainSend(ctx: Ctx): Promise<Response> {
250364 const user = ctx.auth.user;
251365 if (user === undefined) return signInFirst(ctx);
@@ -254,16 +368,19 @@ export async function talkAgainSend(ctx: Ctx): Promise<Response> {
254368 if (form instanceof Response) return form;
255369 const how = AGAIN.find((h) => h === ctx.params["how"]);
256370 if (how === undefined) return new Response("not found", { status: 404 });
371 const conversation = conversationOf(ctx, user);
372 if (conversation === undefined) return notFoundPage(ctx);
373 const here = ctx.params["id"] === undefined ? HERE : urlOf(conversation);
257374 const text = form.get("text");
258375 const asJson = (ctx.request.headers.get("accept") ?? "").includes("application/json");
259376 let status = 422;
260377 let message = `Say something, up to ${WORDS_MAX} characters.`;
261378 if (how !== "edit" || (typeof text === "string" && text.trim() !== "" && text.length <= WORDS_MAX)) {
262 const said = talkAgain(ctx.app, user, how, typeof text === "string" ? text : "");
263 if (said.ok) return asJson ? json({ ok: true }, 202) : flashTo(ctx, `${HERE}#end`, { tone: "success", message: "Asked again. Reload to see the answer." });
379 const said = talkAgain(ctx.app, user, how, typeof text === "string" ? text : "", conversation.id);
380 if (said.ok) return asJson ? json({ ok: true }, 202) : flashTo(ctx, `${here}#end`, { tone: "success", message: "Asked again. Reload to see the answer." });
264381 [status, message] = TALK_WORDS[said.error];
265382 }
266 return asJson ? json({ ok: false, message }, status) : flashTo(ctx, `${HERE}#end`, { tone: "danger", message });
383 return asJson ? json({ ok: false, message }, status) : flashTo(ctx, `${here}#end`, { tone: "danger", message });
267384 }
268385
269386 // POST /talk/queue/:id/cancel — take back a message waiting for the running turn.
@@ -315,7 +432,10 @@ export function talkStream(ctx: Ctx): Response {
315432 const after = ctx.url.searchParams.get("after") ?? ctx.request.headers.get("last-event-id") ?? "";
316433 if (!TURN_ID.test(after)) return new Response("bad turn id", { status: 400 });
317434 const { app } = ctx;
318 const conversation = openConversation(app.db, user.id);
435 const named = ctx.url.searchParams.get("c");
436 if (named !== null && named !== "" && !CONVERSATION_ID.test(named)) return new Response("bad conversation id", { status: 400 });
437 const conversation = named === null || named === "" ? openConversation(app.db, user.id) : getConversation(app.db, user.id, named);
438 if (conversation === undefined) return new Response("no such conversation", { status: 404 });
319439 let cursor = after;
320440 return eventStream(app, ctx.request, (emit, close) => {
321441 const started = Date.now();
@@ -323,7 +443,7 @@ export function talkStream(ctx: Ctx): Response {
323443 let shown = "";
324444 const tick = (): void => {
325445 // Asked before the read: a turn that ends after this look is read whole next time.
326 const running = clock.running(app, user.id);
446 const running = clock.running(app, user.id, conversation.id);
327447 const fresh = turnsAfter(app.db, user.id, conversation.id, cursor);
328448 if (fresh.length > 0) {
329449 const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id));
@@ -335,7 +455,7 @@ export function talkStream(ctx: Ctx): Response {
335455 }
336456 }
337457 // Every tab and viewer sees what waits: sent whenever the list changes.
338 const waits = queuedTalk(app, user);
458 const waits = queuedTalk(app, user, conversation.id);
339459 const key = waits.map((q) => q.id).join();
340460 if (key !== shown && (running || shown !== "")) {
341461 shown = key;
@@ -349,7 +469,7 @@ export function talkStream(ctx: Ctx): Response {
349469 emit("done", {
350470 costs,
351471 context: context === undefined ? "" : contextText(context),
352 acts: [...exchangeActs(app.db, user.id, conversation.id, false)].map(([id, act]) => ({ id: `t-${id}`, html: act.text })),
472 acts: [...exchangeActs(app.db, user.id, conversation.id, false, ((c) => (c.id === conversation.id ? HERE : urlOf(conversation)))(openConversation(app.db, user.id)))].map(([id, act]) => ({ id: `t-${id}`, html: act.text })),
353473 });
354474 close();
355475 } else if (Date.now() - started >= clock.streamMs) {
src/web/pages/talk_client.ts +8 −4
@@ -47,6 +47,9 @@ export const talkCss = /* css */ `
4747 .talk-chips:empty { display: none; }
4848 .talk-files { justify-content: flex-end; }
4949 .talk-more { text-align: center; }
50 .talk-chats, .talk-here { margin-bottom: 12px; }
51 .talk-chats li a { display: flex; justify-content: space-between; }
52 .talk-chats [aria-current] { font-weight: 600; }
5053 .talk-replaced { opacity: 0.55; }
5154 .talk-failed form { margin-top: 6px; }
5255 .talk-failed .btn, .talk-act { min-height: 2.75rem; }
@@ -67,6 +70,7 @@ if (root) {
6770 let source = null;
6871 let retry = 0;
6972 const paged = root.dataset.paged === "1";
73 const { c: cid, here } = root.dataset;
7074 const say = (words) => { status.textContent = words; };
7175 const hint = root.querySelector("[data-hint]");
7276 if (hint) hint.textContent = "Enter sends, Shift+Enter adds a line";
@@ -134,7 +138,7 @@ if (root) {
134138 const again = async (how, body) => {
135139 say(how === "edit" ? "Sending…" : "Asking again…");
136140 try {
137 const res = await fetch("/talk/again/" + how, { method: "POST", headers: { accept: "application/json" }, body });
141 const res = await fetch(here + "/again/" + how, { method: "POST", headers: { accept: "application/json" }, body });
138142 const out = await res.json().catch(() => ({}));
139143 if (!res.ok) { say(out.message || "That was not done."); return false; }
140144 stuck = true;
@@ -188,7 +192,7 @@ if (root) {
188192 if (paged) return;
189193 if (source) source.close();
190194 dropActs();
191 source = new EventSource("/talk/stream?after=" + encodeURIComponent(last));
195 source = new EventSource("/talk/stream?c=" + encodeURIComponent(cid) + "&after=" + encodeURIComponent(last));
192196 source.addEventListener("turn", (e) => {
193197 const turn = JSON.parse(e.data);
194198 last = turn.id;
@@ -283,7 +287,7 @@ if (root) {
283287 const res = await fetch("/api/v1/conversation/messages", {
284288 method: "POST",
285289 headers: { "content-type": "application/json" },
286 body: JSON.stringify({ text, files: attached.map((f) => f.id), during: steer && answering ? "steer" : "queue" }),
290 body: JSON.stringify({ text, conversation: cid, files: attached.map((f) => f.id), during: steer && answering ? "steer" : "queue" }),
287291 });
288292 if (!res.ok) {
289293 const body = await res.json().catch(() => ({}));
@@ -299,7 +303,7 @@ if (root) {
299303 const out = await res.json().catch(() => ({}));
300304 if (out.how === "queued") { say("Queued: it sends after this answer."); if (!source) follow(); return; }
301305 if (out.how === "interrupted") { say("Stopping, then sending…"); return; }
302 if (paged) { location.href = "/talk"; return; }
306 if (paged) { location.href = here; return; }
303307 follow();
304308 } catch (_) {
305309 say("That was not sent: no connection.");
test/conversation-talk.test.ts +19 −1
@@ -19,7 +19,7 @@ import { mintViaToken } from "../src/core/via_token.ts";
1919 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";
22 import { appendTurn, openConversation } from "../src/core/conversations.ts";
22 import { appendTurn, newConversation, openConversation } from "../src/core/conversations.ts";
2323 import { forgeSshSession } from "../src/tui/ssh.ts";
2424 import { isStop, parseTalkAgain, parseTalkCommand } from "../src/tui/ssh-talk.ts";
2525 import { listTurns } from "../src/core/conversations.ts";
@@ -342,6 +342,24 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t
342342 expect(await standing()).toEqual(plain);
343343 expect(talkAgain(app, undefined, "retry").ok).toBe(false);
344344 });
345 test("regenerate acts on the conversation it names, not the latest", async () => {
346 const older = newConversation(app.db, alice());
347 appendTurn(app.db, alice(), older.id, { role: "person", text: "older question" });
348 appendTurn(app.db, alice(), older.id, { role: "agent", text: "older answer" });
349 const newer = newConversation(app.db, alice());
350 appendTurn(app.db, alice(), newer.id, { role: "person", text: "newer question" });
351 appendTurn(app.db, alice(), newer.id, { role: "agent", text: "newer answer" });
352 withBinary(answers("Fresh older answer."));
353 await settled(redo(app, conversationOptions(app)!, alice(), "regenerate", "", older.id));
354 await settle();
355 const text = (id: string) => (listTurns(app.db, alice(), id, { limit: 50 }) as { ok: true; value: { text: string; replaced: boolean }[] }).value;
356 expect(text(older.id).filter((t) => !t.replaced).map((t) => t.text)).toEqual(["older question", "Fresh older answer."]);
357 expect(text(newer.id).map((t) => t.text)).toEqual(["newer question", "newer answer"]);
358 const stranger = redo(app, conversationOptions(app)!, alice(), "regenerate", "", "01zzzzzzzzzzzzzzzzzzzzzzzz");
359 expect(!stranger.ok && stranger.error).toBe("not_found");
360 expect(exchangeActs(app.db, alice(), older.id, false, `/talk/${older.id}`).size).toBe(2);
361 });
362
345363 const post = (path: string, h: Record<string, string>, body = "") =>
346364 fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...h }, body, redirect: "manual" });
347365 const cookie = () => ({ cookie: `forge_session=${createSession(app.db, alice()).token}` });
test/conversation-turn.test.ts +16 −1
@@ -13,7 +13,7 @@ import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { clearAccountCeiling, daySpent, setAccountCeiling } from "../src/core/account_ceiling.ts";
1515 import { claimDone, completeWrite, recordWrite } from "../src/core/conversation_writes.ts";
16 import { listTurns, openConversation } from "../src/core/conversations.ts";
16 import { listTurns, newConversation, openConversation } from "../src/core/conversations.ts";
1717 import { monthSpent } from "../src/core/model_grant.ts";
1818 import { addSubscription, getSubscription, PLATFORM, setGrants, wakeSubscription } from "../src/core/subscriptions.ts";
1919 import { verifyToken } from "../src/core/tokens.ts";
@@ -524,6 +524,21 @@ sleep 300
524524 expect(alive).toBe(false);
525525 expect(stopTurn(app, u.value.id)).toBe(false);
526526 }, 20_000);
527
528 test("Stop ends the replies running in two conversations, the older one included", async () => {
529 const older = newConversation(app.db, alice.id);
530 const newer = newConversation(app.db, alice.id);
531 const one = say(app, options(stub("two-a", "sess-two-a", "hang")), alice.id, "first", [], "inject", older.id);
532 const two = say(app, options(stub("two-b", "sess-two-b", "hang")), alice.id, "second", [], "inject", newer.id);
533 if (!one.ok || !two.ok || one.value.done === undefined || two.value.done === undefined) throw new Error("not started");
534 expect(turnRunning(app, alice.id, older.id)).toBe(true);
535 expect(turnRunning(app, alice.id, newer.id)).toBe(true);
536 expect(stopTurn(app, alice.id)).toBe(true);
537 await Promise.all([one.value.done, two.value.done]);
538 expect(turnRunning(app, alice.id, older.id)).toBe(false);
539 expect(turnRunning(app, alice.id, newer.id)).toBe(false);
540 expect(stopTurn(app, alice.id)).toBe(false);
541 }, 20_000);
527542 });
528543
529544 describe("talk turn counts (#8860)", () => {
test/conversations.test.ts +55
@@ -5,6 +5,12 @@ import { join } from "node:path";
55 import { migrations } from "../src/core/schema.ts";
66 import {
77 appendTurn,
8 archiveConversation,
9 getConversation,
10 listConversations,
11 newConversation,
12 renameConversation,
13 TITLE_MAX,
814 listTurns,
915 MAX_TURN_CHARS,
1016 openConversation,
@@ -130,3 +136,52 @@ describe("conversations", () => {
130136 db.close();
131137 });
132138 });
139
140 describe("many conversations (#8814)", () => {
141 test("new starts another; the list is newest activity first and the latest is where messages go", () => {
142 const db = open("many.db");
143 const a = openConversation(db, "u1");
144 const b = newConversation(db, "u1");
145 expect(b.id).not.toBe(a.id);
146 expect(listConversations(db, "u1").map((c) => c.id)).toEqual([b.id, a.id]);
147 Bun.sleepSync(3);
148 appendTurn(db, "u1", a.id, { role: "person", text: "hi" });
149 expect(listConversations(db, "u1").map((c) => c.id)).toEqual([a.id, b.id]);
150 expect(openConversation(db, "u1").id).toBe(a.id);
151 });
152
153 test("rename keeps a clean title and refuses a long or multi-line one", () => {
154 const db = open("rename.db");
155 const c = openConversation(db, "u1");
156 const named = renameConversation(db, "u1", c.id, " Plan the week ");
157 expect(named.ok && named.value.title).toBe("Plan the week");
158 expect(renameConversation(db, "u1", c.id, "x".repeat(TITLE_MAX + 1))).toEqual({ ok: false, error: "bad_title" });
159 expect(renameConversation(db, "u1", c.id, "a\nb")).toEqual({ ok: false, error: "bad_title" });
160 expect(getConversation(db, "u1", c.id)?.title).toBe("Plan the week");
161 });
162
163 test("archive puts one behind the filter, restore brings it back, turns survive", () => {
164 const db = open("archive.db");
165 const a = openConversation(db, "u1");
166 appendTurn(db, "u1", a.id, { role: "person", text: "keep me" });
167 const b = newConversation(db, "u1");
168 expect(archiveConversation(db, "u1", b.id, true).ok).toBe(true);
169 expect(listConversations(db, "u1").map((c) => c.id)).toEqual([a.id]);
170 expect(listConversations(db, "u1", { archived: true }).map((c) => c.id)).toEqual([b.id]);
171 expect(openConversation(db, "u1").id).toBe(a.id);
172 archiveConversation(db, "u1", a.id, true);
173 expect(openConversation(db, "u1").id).not.toBe(a.id);
174 archiveConversation(db, "u1", a.id, false);
175 expect(listTurns(db, "u1", a.id)).toMatchObject({ ok: true, value: [{ text: "keep me" }] });
176 });
177
178 test("another person's id is not found for every call", () => {
179 const db = open("other.db");
180 const mine = openConversation(db, "u1");
181 expect(getConversation(db, "u2", mine.id)).toBeUndefined();
182 expect(renameConversation(db, "u2", mine.id, "x")).toEqual({ ok: false, error: "not_found" });
183 expect(archiveConversation(db, "u2", mine.id, true)).toEqual({ ok: false, error: "not_found" });
184 expect(listConversations(db, "u2")).toEqual([]);
185 expect(listTurns(db, "u2", mine.id)).toEqual({ ok: false, error: "not_found" });
186 });
187 });
test/palette-roster.test.ts +2
@@ -70,6 +70,7 @@ test("a site-admin person is offered the whole roster", () => {
7070 ["decider", "Decider", "what it closed, deferred and asked you about today"],
7171 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
7272 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
73 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
7374 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
7475 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
7576 ["account", "Account settings", undefined],
@@ -222,6 +223,7 @@ test("a member person is offered the signed-in roster without the admin entries"
222223 ["decider", "Decider", "what it closed, deferred and asked you about today"],
223224 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
224225 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
226 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
225227 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
226228 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
227229 ["account", "Account settings", undefined],
test/route-timing-coverage.test.ts +1
@@ -19,6 +19,7 @@ import { seedAdmin } from "./helpers/admin.ts";
1919
2020 /** Routes that answer 200 without a `session` phase → why. */
2121 const UNTIMED: Record<string, string> = {
22 "/api/v1/conversations": "a person's conversation list, one indexed query; no page to phase",
2223 "/metrics": "Prometheus text exposition, scraped by token; not a page read",
2324 };
2425
test/schema-additive.test.ts +11 −1
@@ -71,7 +71,7 @@ describe("migrations are additive across a roll", () => {
7171 expect(inPlay.length).toBeGreaterThan(0);
7272 const offenders = migrationFiles
7373 .filter((m) => inPlay.some((ids) => !ids.has(m.name)))
74 .filter((m) => !(m.name in RETIRED))
74 .filter((m) => !(m.name in RETIRED) && !(m.name in REBUILT))
7575 .map((m) => ({ name: m.name, problems: notAdditive(m.sql) }))
7676 .filter((m) => m.problems.length > 0);
7777 expect(offenders).toEqual([]);
@@ -152,6 +152,16 @@ const RETIRED: Record<string, { readonly drops: readonly string[]; readonly sinc
152152 },
153153 };
154154
155 /**
156 * Migrations that rebuild a table under its own name, in one transaction, with
157 * every column the old release reads kept and only columns added: an old
158 * process sees the same table before and after. SQLite cannot drop a UNIQUE
159 * constraint in place, so a rebuild is the only way to lift one.
160 */
161 const REBUILT: Record<string, string> = {
162 "20261103120000-conversations-many": "conversation loses UNIQUE(user_id) (#8814); same columns, plus title and archived_at",
163 };
164
155165 const src = join(import.meta.dir, "../src");
156166
157167 interface Release {
test/security-floor-routes.test.ts +2
@@ -45,11 +45,13 @@ const ARCH = "needs an archived-home operation id; the seed archives nothing";
4545 const NOW = "the stage is one of check, review, land or live; the seed fills no word with a name it cannot guess";
4646 const GATE = "needs a kept gate log, written only when a full gate runs on land";
4747 const BET = "needs a bet, which the seed does not propose";
48 const TALK = "needs a conversation of the signed-in person; the seed starts none";
4849 const HOOK = "needs a webhook, which the seed does not register";
4950
5051 /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */
5152 const UNSEEDED: Record<string, string> = {
5253 "/:owner/-/teams/:team": ORG,
54 "/talk/:id": TALK,
5355 "/:owner/:name/bets/:id": BET,
5456 "/:owner/:name/looks/:env/:commit/:kind.png": LOOK,
5557 "/:owner/:name/milestones/:number/limb/:op": LIMB,
test/talk-conversations.test.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * Many conversations (#8814): /talk lists them with New, each opens at
3 * /talk/<id>, rename and archive are plain forms, the API and the MCP reach
4 * the same calls, another person's id is a 404, and the migration that
5 * lifted one-per-person keeps the turns it found.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { appendTurn, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, newConversation, openConversation, renameConversation } from "../src/core/conversations.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { migrations } from "../src/core/schema.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { migrationFiles } from "../src/core/schema.ts";
18 import { openDb } from "../src/kernel/db.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { tool as sayTool } from "../src/http/mcp/tools/say.ts";
21 import { promote } from "./helpers/members.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 let dir: string;
27 let app: App;
28 let servers: Servers;
29 let alice: User;
30 let bob: User;
31
32 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
33 const get = (path: string, u?: User) =>
34 fetch(`${servers.url}${path}`, { redirect: "manual", headers: u ? { cookie: cookie(u) } : {} });
35 const post = (path: string, u: User, form: Record<string, string>) =>
36 fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: servers.url, cookie: cookie(u) }, body: new URLSearchParams(form) });
37 const api = (method: string, path: string, u: User, body?: unknown) =>
38 fetch(`${servers.url}${path}`, {
39 method,
40 redirect: "manual",
41 headers: { origin: servers.url, cookie: cookie(u), "content-type": "application/json" },
42 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
43 });
44
45 beforeAll(async () => {
46 dir = await mkdtemp(join(tmpdir(), "forge-talk-many-"));
47 app = createApp({ dataDir: dir, port: 0 });
48 servers = await serve(app);
49 const mk = async (username: string, first = false) => {
50 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
51 if (!u.ok) throw new Error(u.error);
52 promote(app.db, u.value);
53 return u.value;
54 };
55 alice = await mk("alice", true);
56 bob = await mk("bob");
57 }, 30_000);
58
59 afterAll(async () => {
60 servers.stop();
61 app.close();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 describe("the page", () => {
66 test("New makes a conversation at its own URL that survives a reload, and the list shows it", async () => {
67 const made = await post("/talk/new", alice, {});
68 expect(made.status).toBe(303);
69 const url = made.headers.get("location")!;
70 expect(url).toMatch(/^\/talk\/[0-9a-z]+$/);
71 const first = await (await get(url, alice)).text();
72 const again = await get(url, alice);
73 expect(again.status).toBe(200);
74 expect(first).toContain(`data-c="${url.slice(6)}"`);
75 expect(await again.text()).toContain(`href="${url}"`);
76 expect((await get("/talk", alice)).status).toBe(200);
77 });
78
79 test("rename shows the title in the list; archive moves it behind the filter; restore brings it back", async () => {
80 const c = newConversation(app.db, alice.id);
81 const renamed = await post(`/talk/${c.id}/rename`, alice, { title: "Weekly <plan>" });
82 expect(renamed.status).toBe(303);
83 const page = await (await get(`/talk/${c.id}`, alice)).text();
84 expect(page).toContain("Weekly &lt;plan&gt;");
85 expect(page).not.toContain("Weekly <plan>");
86 await post(`/talk/${c.id}/archive`, alice, { archived: "1" });
87 const list = await (await get("/talk", alice)).text();
88 expect(list).not.toContain(`href="/talk/${c.id}"`);
89 expect(list).toContain("Archived (");
90 expect(await (await get("/talk?archived=1", alice)).text()).toContain(`href="/talk/${c.id}"`);
91 expect(await (await get(`/talk/${c.id}`, alice)).text()).toContain("sending here keeps it archived");
92 await post(`/talk/${c.id}/archive`, alice, { archived: "0" });
93 expect(await (await get("/talk", alice)).text()).toContain(`href="/talk/${c.id}"`);
94 expect(await (await get(`/talk/${c.id}`, alice)).text()).not.toContain("sending here keeps it archived");
95 });
96
97 test("another person's id answers 404 for view, send, rename, archive and stream", async () => {
98 const mine = newConversation(app.db, alice.id);
99 expect((await get(`/talk/${mine.id}`, bob)).status).toBe(404);
100 expect((await post(`/talk/${mine.id}`, bob, { text: "hi" })).status).toBe(404);
101 expect((await post(`/talk/${mine.id}/rename`, bob, { title: "mine now" })).status).toBe(404);
102 expect((await post(`/talk/${mine.id}/archive`, bob, { archived: "1" })).status).toBe(404);
103 expect((await get(`/talk/stream?c=${mine.id}`, bob)).status).toBe(404);
104 expect((await get("/talk/nope!", bob)).status).toBe(404);
105 expect(listConversations(app.db, alice.id).find((c) => c.id === mine.id)?.title).toBe("");
106 });
107
108 test("signed out is sent to sign in", async () => {
109 expect((await get("/talk/abc")).status).toBe(303);
110 expect((await post("/talk/new", alice, {})).status).toBe(303);
111 });
112 });
113
114 describe("the API", () => {
115 test("list, new, rename, archive; another person's id is 404", async () => {
116 const made = await api("POST", "/api/v1/conversations", alice, { title: "From the API" });
117 expect(made.status).toBe(201);
118 const { conversation } = (await made.json()) as { conversation: { id: string; title: string } };
119 expect(conversation.title).toBe("From the API");
120 const listed = (await (await api("GET", "/api/v1/conversations", alice)).json()) as { conversations: { id: string }[] };
121 expect(listed.conversations.some((c) => c.id === conversation.id)).toBe(true);
122 const patched = await api("PATCH", `/api/v1/conversations/${conversation.id}`, alice, { title: "Renamed", archived: true });
123 expect(patched.status).toBe(200);
124 const after = (await (await api("GET", "/api/v1/conversations?archived=1", alice)).json()) as { conversations: { id: string; title: string }[] };
125 expect(after.conversations.find((c) => c.id === conversation.id)?.title).toBe("Renamed");
126 expect((await api("PATCH", `/api/v1/conversations/${conversation.id}`, bob, { title: "x" })).status).toBe(404);
127 expect((await api("PATCH", `/api/v1/conversations/${conversation.id}`, alice, { title: "x".repeat(81) })).status).toBe(422);
128 expect((await api("GET", `/api/v1/conversation/turns?conversation=${conversation.id}`, bob)).status).toBe(404);
129 expect((await api("GET", `/api/v1/conversation/turns?conversation=${conversation.id}`, alice)).status).toBe(200);
130 expect((await api("POST", "/api/v1/conversation/messages", bob, { text: "hi", conversation: conversation.id })).status).toBe(404);
131 });
132 });
133
134 describe("the MCP", () => {
135 test("say with an action lists, starts, renames and archives, and refuses another person's id", async () => {
136 const ctx = { app, auth: { user: alice, via: "session" } } as never;
137 const run = async (u: User, args: Record<string, unknown>) => {
138 const answer = (await sayTool.run(ctx, u, args)) as { ok: boolean; value?: any; error?: string };
139 return answer.ok ? answer.value : { error: answer.error };
140 };
141 const made = await run(alice, { action: "new", title: "By agent" });
142 expect(made.conversation).toMatch(/^[0-9a-z]+$/);
143 expect((await run(alice, { action: "list" })).conversations.some((c: { id: string }) => c.id === made.conversation)).toBe(true);
144 expect((await run(alice, { action: "rename", conversation: made.conversation, title: "Better" })).says).toBe("Renamed.");
145 expect((await run(alice, { action: "archive", conversation: made.conversation })).says).toBe("Archived.");
146 expect((await run(bob, { action: "archive", conversation: made.conversation })).error).toBe("not_found");
147 expect((await run(alice, { action: "list" })).conversations.some((c: { id: string }) => c.id === made.conversation)).toBe(false);
148 expect((await run(alice, { action: "list", archived: true })).conversations.some((c: { id: string }) => c.id === made.conversation)).toBe(true);
149 expect((await run(alice, {})).error).toBe("invalid_text");
150 });
151 });
152
153 describe("the migration", () => {
154 test("a person's one conversation, its turns and runs come through the table rebuild", () => {
155 const db = openDb(join(dir, "before.db"), migrations.slice(0, -1));
156 db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u1','u1@x','x')").run();
157 db.query("INSERT INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES ('c1','u1','s','t','t')").run();
158 for (const n of [1, 2, 3]) appendTurn(db, "u1", "c1", { role: "person", text: `t${n}` });
159 db.query("INSERT INTO conversation_run (id, conversation_id, user_id, prompt, started_at) VALUES ('r1','c1','u1','p','t')").run();
160 db.close();
161 const after = openDb(join(dir, "before.db"), migrations);
162 expect(openConversation(after, "u1").id).toBe("c1");
163 const turns = listTurns(after, "u1", "c1");
164 expect(turns.ok && turns.value.map((t) => t.text)).toEqual(["t1", "t2", "t3"]);
165 expect(after.query("SELECT COUNT(*) AS n FROM conversation_run").get()).toEqual({ n: 1 });
166 expect(newConversation(after, "u1").id).not.toBe("c1");
167 after.close();
168 });
169 });
170
171 describe("the migration keeps attachments", () => {
172 test("a file row, hung off a conversation and a turn, survives the table rebuild", () => {
173 const at = migrationFiles.findIndex((m) => m.name === "20261103120000-conversations-many");
174 expect(at).toBeGreaterThan(0);
175 const path = join(dir, "files.db");
176 const db = openDb(path, migrationFiles.slice(0, at).map((m) => m.sql));
177 db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u1','u1@x','x')").run();
178 db.query("INSERT INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES ('c1','u1','s','t','t')").run();
179 const turn = appendTurn(db, "u1", "c1", { role: "person", text: "see" });
180 if (!turn.ok) throw new Error("no turn");
181 db.query("INSERT INTO conversation_file (id, conversation_id, user_id, turn_id, name, type, size, created_at) VALUES ('f1','c1','u1',?1,'a.png','image/png',3,'t')").run(turn.value.id);
182 db.close();
183 const after = openDb(path, migrations);
184 expect(after.query("SELECT conversation_id, turn_id, name FROM conversation_file").all()).toEqual([{ conversation_id: "c1", turn_id: turn.value.id, name: "a.png" }]);
185 after.close();
186 });
187 });
188
189 describe("the mutation checks", () => {
190 test("a cross-origin POST to New, Rename and Archive is refused and changes nothing", async () => {
191 const c = newConversation(app.db, alice.id);
192 const before = listConversations(app.db, alice.id).length;
193 const evil = (path: string, form: Record<string, string>) =>
194 fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: "https://evil.example", cookie: cookie(alice) }, body: new URLSearchParams(form) });
195 expect((await evil("/talk/new", {})).status).toBe(403);
196 expect((await evil(`/talk/${c.id}/rename`, { title: "hijack" })).status).toBe(403);
197 expect((await evil(`/talk/${c.id}/archive`, { archived: "1" })).status).toBe(403);
198 expect(listConversations(app.db, alice.id).length).toBe(before);
199 const kept = listConversations(app.db, alice.id).find((x) => x.id === c.id);
200 expect(kept?.title).toBe("");
201 });
202
203 test("a read-only token is refused on POST and PATCH /api/v1/conversations", async () => {
204 const ro = createToken(app.db, alice.id, "t", "ro").token;
205 const rw = createToken(app.db, alice.id, "t", "rw").token;
206 const c = newConversation(app.db, alice.id);
207 const call = (method: string, path: string, token: string, body: unknown) =>
208 fetch(`${servers.url}${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
209 const before = listConversations(app.db, alice.id).length;
210 expect((await call("POST", "/api/v1/conversations", ro, {})).status).toBe(403);
211 expect((await call("PATCH", `/api/v1/conversations/${c.id}`, ro, { archived: true })).status).toBe(403);
212 expect(listConversations(app.db, alice.id).length).toBe(before);
213 expect(listConversations(app.db, alice.id).find((x) => x.id === c.id)?.archivedAt).toBeNull();
214 expect((await call("PATCH", `/api/v1/conversations/${c.id}`, rw, { archived: true })).status).toBe(200);
215 expect((await fetch(`${servers.url}/api/v1/conversations`, { headers: { authorization: `Bearer ${ro}` } })).status).toBe(200);
216 });
217
218 test("GET /talk/stream is the stream, not a conversation called stream", async () => {
219 const answer = await get("/talk/stream", alice);
220 expect(answer.headers.get("content-type")).toContain("text/event-stream");
221 await answer.body?.cancel();
222 });
223 });
224
225 describe("the most a person holds", () => {
226 test("every door refuses past the cap, and the others are untouched", async () => {
227 const carol = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
228 if (!carol.ok) throw new Error(carol.error);
229 promote(app.db, carol.value);
230 const c = carol.value;
231 while (listConversations(app.db, c.id).length + listConversations(app.db, c.id, { archived: true }).length < CONVERSATIONS_MAX) {
232 const made = createConversation(app.db, c.id);
233 if (!made.ok) break;
234 }
235 const held = () => (app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(c.id) as { n: number }).n;
236 expect(held()).toBe(CONVERSATIONS_MAX);
237 const refused = createConversation(app.db, c.id);
238 expect(!refused.ok && refused.error).toBe("too_many");
239 const page = await post("/talk/new", c, {});
240 expect(page.status).toBe(303);
241 expect(page.headers.get("location")).not.toMatch(/^\/talk\/[0-9a-z]{6,}/);
242 const viaApi = await api("POST", "/api/v1/conversations", c, {});
243 expect(viaApi.status).toBe(409);
244 expect(((await viaApi.json()) as { error: string }).error).toBe("too_many");
245 const ctx = { app, auth: { user: c, via: "session" } } as never;
246 const mcp = (await sayTool.run(ctx, c, { action: "new" })) as { ok: boolean; error?: string };
247 expect(mcp.ok).toBe(false);
248 expect(mcp.error).toBe("too_many");
249 expect(held()).toBe(CONVERSATIONS_MAX);
250 // Archiving does not free a place; another person is unaffected.
251 expect(createConversation(app.db, alice.id).ok).toBe(true);
252 });
253 });
254
255 describe("the MCP rename", () => {
256 test("rename needs a title: without one the title is kept", async () => {
257 const ctx = { app, auth: { user: alice, via: "session" } } as never;
258 const made = newConversation(app.db, alice.id);
259 renameConversation(app.db, alice.id, made.id, "Keep me");
260 const answer = (await sayTool.run(ctx, alice, { action: "rename", conversation: made.id })) as { ok: boolean; error?: string };
261 expect(answer.ok).toBe(false);
262 expect(answer.error).toBe("invalid_title");
263 expect(listConversations(app.db, alice.id).find((x) => x.id === made.id)?.title).toBe("Keep me");
264 });
265 });
test/talk-page.test.ts +2 −2
@@ -191,8 +191,8 @@ describe("the page", () => {
191191
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");
194 expect(Buffer.byteLength(talkCss)).toBeLessThan(3_600);
195 expect(Buffer.byteLength(talkJs)).toBeLessThan(13_400);
194 expect(Buffer.byteLength(talkCss)).toBeLessThan(3_750);
195 expect(Buffer.byteLength(talkJs)).toBeLessThan(13_500);
196196 say(alice, "agent", "```ts\nlet x = 1;\n```");
197197 const page = await get("/talk", alice);
198198 const size = (await page.arrayBuffer()).byteLength;
test/tui-talk-chats.test.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The terminal's Conversations screen (#8814): list, New, Rename and
3 * Archive/Restore through the calls /talk uses, offered to a signed-in person
4 * and not to an agent, with the cap refusing past it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { appendTurn, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, renameConversation } from "../src/core/conversations.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { settleApp } from "./helpers/tui.ts";
16 import { proveAll } from "./helpers/verified.ts";
17
18 let dir: string;
19 let app: App;
20 let alice: User;
21 let bob: User;
22 const open = [":", "Conversations", "\r"];
23
24 async function drive(user: User, keys: string[]): Promise<string> {
25 const terminal = headlessTerminal(120, 30);
26 const { app: tui } = createForgeApp(
27 app,
28 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
29 { terminal, exitHooks: false },
30 );
31 tui.start();
32 await settleApp(tui);
33 for (const k of keys) {
34 terminal.feed(k);
35 await settleApp(tui);
36 }
37 const text = tui.screen.toText();
38 tui.stop?.();
39 return text;
40 }
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-tui-chats-"));
44 app = createApp({ dataDir: dir, port: 0 });
45 const mk = async (username: string, first = false) => {
46 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
47 if (!r.ok) throw new Error(r.error);
48 return r.value;
49 };
50 alice = await mk("alice", true);
51 bob = await mk("bob");
52 proveAll(app);
53 });
54 afterAll(async () => {
55 app.db.close();
56 await rm(dir, { recursive: true, force: true });
57 });
58
59 describe("the terminal's conversations screen", () => {
60 test("n starts one with a title, r renames it, x archives it and a brings it back", async () => {
61 const text = await drive(alice, [...open, "n", "Weekly plan", "\r"]);
62 const mine = listConversations(app.db, alice.id);
63 expect(mine.map((c) => c.title)).toEqual(["Weekly plan"]);
64 expect(text).toContain("Weekly plan");
65 expect(text).toContain(`/talk/${mine[0]!.id}`);
66 await drive(alice, [...open, "r", ..."\x7f".repeat(11).split(""), "Renamed", "\r"]);
67 expect(listConversations(app.db, alice.id)[0]!.title).toBe("Renamed");
68 await drive(alice, [...open, "x"]);
69 expect(listConversations(app.db, alice.id)).toEqual([]);
70 const archived = await drive(alice, [...open, "a"]);
71 expect(archived).toContain("Renamed");
72 expect(listConversations(app.db, alice.id, { archived: true }).length).toBe(1);
73 await drive(alice, [...open, "a", "x"]);
74 expect(listConversations(app.db, alice.id).length).toBe(1);
75 });
76
77 test("another person's conversations are not listed", async () => {
78 const text = await drive(bob, open);
79 expect(text).not.toContain("Renamed");
80 expect(text).toContain("No conversations yet");
81 });
82
83 test("Enter opens one: its turns, and s says something in it", async () => {
84 const made = createConversation(app.db, bob.id);
85 if (!made.ok) throw new Error(made.error);
86 renameConversation(app.db, bob.id, made.value.id, "Bob's chat");
87 appendTurn(app.db, bob.id, made.value.id, { role: "person", text: "how is the forge" });
88 appendTurn(app.db, bob.id, made.value.id, { role: "agent", text: "all green today" });
89 const text = await drive(bob, [...open, "\r"]);
90 expect(text).toContain("how is the forge");
91 expect(text).toContain("all green today");
92 expect(text).toContain(`/talk/${made.value.id}`);
93 // The crew is off in this app: talk() refuses, the prompt says why, and nothing is stored.
94 const said = await drive(bob, [...open, "\r", "s", "hello", "\r"]);
95 expect(said).toContain("not running here");
96 const turns = listTurns(app.db, bob.id, made.value.id, { limit: 50 });
97 expect(turns.ok && turns.value.length).toBe(2);
98 });
99
100 test("past the cap, n says so and starts nothing", async () => {
101 while (createConversation(app.db, alice.id).ok) { /* fill */ }
102 const held = (app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(alice.id) as { n: number }).n;
103 expect(held).toBe(CONVERSATIONS_MAX);
104 renameConversation(app.db, alice.id, listConversations(app.db, alice.id)[0]!.id, "top");
105 const text = await drive(alice, [...open, "n", "one too many", "\r"]);
106 expect(text).toContain("the most there can be");
107 expect((app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(alice.id) as { n: number }).n).toBe(CONVERSATIONS_MAX);
108 });
109 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs