Public: anyone can read this; the crew can change it.
/talk lists conversations: new, rename, archive, each at its own URL #8887
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8814-talk-lists-conversations-new-rename-arch → main 25 commits fork 3 threads · 3 unresolved
30 files
changed +1188
−105
docs/design/areas/talk.md +2
| @@ -18,6 +18,8 @@ | ||
| 18 | 18 | - `.talk-copy` — a quiet text button that copies a message or a code block. |
| 19 | 19 | - `.talk-suggest` — the starter questions on an empty chat; each fills the composer. |
| 20 | 20 | - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end. |
| 21 | - `.talk-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. | |
| 21 | 23 | - `.talk-steps` — the quiet steps line under a reply ("read 3 files, opened #12"). |
| 22 | 24 | - `.talk-links` — the links under a reply to what the turn opened or changed. |
| 23 | 25 | - `.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, | ||
| 18 | 18 | |
| 19 | 19 | When the agent asks first (`needs_confirm`), the turn draws **Confirm** (the existing yes) and **Cancel** (a no: the confirm is spent, nothing runs, a turn says it was cancelled). A result shown once (an invite link) is drawn by Settings → Confirms instead, where Confirm leads. |
| 20 | 20 | |
| 21 | ## 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 | ||
| 21 | 31 | ## Edit, regenerate, retry (#8817) |
| 22 | 32 | |
| 23 | 33 | 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 | ||
| 28 | 38 | - Refused while a turn runs (`running`, 409), or when there is nothing to act on (`nothing`, 409). |
| 29 | 39 | - **Web:** Edit under your last message (opens the composer holding it, `/talk?edit=1`; Send resends), Regenerate under the last reply, Retry under a failed one. All are plain forms or links, so they work without the script, which only does them in place. They post to `POST /talk/again/edit|regenerate|retry`, from your own signed-in browser only. |
| 30 | 40 | - **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. | |
| 31 | 42 | - There is no API or MCP door for these yet. |
| 32 | 43 | |
| 33 | 44 | ## Stop |
| @@ -64,7 +75,7 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha | ||
| 64 | 75 | | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` | |
| 65 | 76 | | Stick to bottom, "Latest" jump button | this pull | AI Elements `Conversation` | |
| 66 | 77 | | 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 | | |
| 68 | 79 | | Edit and resend, regenerate, Retry (see below) | have (#8817) | replaced turns are kept and marked, not deleted | |
| 69 | 80 | | Queue and Steer while an answer runs | #8819 | `Queue` / `PromptInput` | |
| 70 | 81 | | 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 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | import { getConversation } from "../core/conversations.ts"; | |
| 9 | 10 | import type { ConversationFile } from "../core/conversation_files.ts"; |
| 10 | 11 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 11 | 12 | import type { User } from "../core/users.ts"; |
| @@ -19,6 +20,7 @@ export type TalkRefusal = SayRefusal | RedoRefusal | "off" | "rate_limited"; | ||
| 19 | 20 | |
| 20 | 21 | /** What each refusal says, with its status. */ |
| 21 | 22 | export const TALK_WORDS: Readonly<Record<TalkRefusal, readonly [number, string]>> = { |
| 23 | not_found: [404, "no such conversation"], | |
| 22 | 24 | unauthorized: [401, "sign in to talk to the forge"], |
| 23 | 25 | empty: [422, "say something"], |
| 24 | 26 | too_long: [422, "the message is too long"], |
| @@ -50,23 +52,24 @@ const watch = (app: App, said: Said): void => { | ||
| 50 | 52 | said.done?.catch((error: unknown) => app.log.error("conversation turn crashed", { error: String(error) })); |
| 51 | 53 | }; |
| 52 | 54 | |
| 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> { | |
| 54 | 56 | if (user === undefined) return err("unauthorized"); |
| 57 | if (conversationId !== undefined && getConversation(app.db, user.id, conversationId) === undefined) return err("not_found"); | |
| 55 | 58 | const options = conversationOptions(app); |
| 56 | 59 | if (options === undefined) return err("off"); |
| 57 | 60 | 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); | |
| 59 | 62 | if (said.ok) watch(app, said.value); |
| 60 | 63 | return said; |
| 61 | 64 | } |
| 62 | 65 | |
| 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> { | |
| 65 | 68 | if (user === undefined) return err("unauthorized"); |
| 66 | 69 | const options = conversationOptions(app); |
| 67 | 70 | if (options === undefined) return err("off"); |
| 68 | 71 | 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); | |
| 70 | 73 | if (said.ok) watch(app, said.value); |
| 71 | 74 | return said; |
| 72 | 75 | } |
| @@ -82,5 +85,5 @@ export function stopTalk(app: App, user: User | undefined): Result<true, "unauth | ||
| 82 | 85 | export const cancelTalk = (app: App, user: User | undefined, queuedId: string): boolean => user !== undefined && cancelQueued(app, user.id, queuedId); |
| 83 | 86 | |
| 84 | 87 | /** 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 = { | ||
| 5 | 5 | path: "/api/v1/conversation/turns", |
| 6 | 6 | 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", |
| 7 | 7 | auth: true, |
| 8 | query: { limit: "integer", before: "string" }, | |
| 8 | query: { limit: "integer", before: "string", conversation: "string" }, | |
| 9 | 9 | reply: { running: "boolean", turns: "array" }, |
| 10 | 10 | }; |
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"; | ||
| 58 | 58 | export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; |
| 59 | 59 | export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts"; |
| 60 | 60 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
| 61 | export { route as getApiV1Conversations } from "./getApiV1Conversations.ts"; | |
| 61 | 62 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; |
| 62 | 63 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| 63 | 64 | export { route as getApiV1Explore } from "./getApiV1Explore.ts"; |
| @@ -184,6 +185,7 @@ export { route as getContractOpenapiJson } from "./getContractOpenapiJson.ts"; | ||
| 184 | 185 | export { route as getMetrics } from "./getMetrics.ts"; |
| 185 | 186 | export { route as patchApiV1AdminSettings } from "./patchApiV1AdminSettings.ts"; |
| 186 | 187 | export { route as patchApiV1AgentsUsername } from "./patchApiV1AgentsUsername.ts"; |
| 188 | export { route as patchApiV1ConversationsId } from "./patchApiV1ConversationsId.ts"; | |
| 187 | 189 | export { route as patchApiV1ForgeFollowing } from "./patchApiV1ForgeFollowing.ts"; |
| 188 | 190 | export { route as patchApiV1ReposOwnerName } from "./patchApiV1ReposOwnerName.ts"; |
| 189 | 191 | export { route as patchApiV1ReposOwnerNameApp } from "./patchApiV1ReposOwnerNameApp.ts"; |
| @@ -218,6 +220,7 @@ export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFile | ||
| 218 | 220 | export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts"; |
| 219 | 221 | export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts"; |
| 220 | 222 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; |
| 223 | export { route as postApiV1Conversations } from "./postApiV1Conversations.ts"; | |
| 221 | 224 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 222 | 225 | export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts"; |
| 223 | 226 | 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 = { | ||
| 6 | 6 | 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", |
| 7 | 7 | auth: true, |
| 8 | 8 | status: 202, |
| 9 | body: { text: "string" }, | |
| 9 | body: { text: "string", conversation: "string" }, | |
| 10 | 10 | reply: { how: "string" }, |
| 11 | 11 | refusals: { |
| 12 | not_found: "conversation names none of the person's (404)", | |
| 12 | 13 | invalid_text: "text is not a string of 1 to 20000 characters", |
| 13 | 14 | invalid_during: "during is not queue or steer", |
| 14 | 15 | 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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 6 | 7 | * that ownership. |
| 7 | 8 | */ |
| 8 | 9 | import type { Database } from "bun:sqlite"; |
| @@ -25,8 +26,14 @@ export interface Conversation { | ||
| 25 | 26 | readonly claudeSessionId: string; |
| 26 | 27 | readonly createdAt: string; |
| 27 | 28 | 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; | |
| 28 | 32 | } |
| 29 | 33 | |
| 34 | /** Longest title kept, in characters; longer is refused, not cut. */ | |
| 35 | export const TITLE_MAX = 80; | |
| 36 | ||
| 30 | 37 | export interface Turn { |
| 31 | 38 | readonly id: string; |
| 32 | 39 | readonly conversationId: string; |
| @@ -45,6 +52,8 @@ interface ConversationRow { | ||
| 45 | 52 | claude_session_id: string; |
| 46 | 53 | created_at: string; |
| 47 | 54 | updated_at: string; |
| 55 | title: string; | |
| 56 | archived_at: string | null; | |
| 48 | 57 | } |
| 49 | 58 | |
| 50 | 59 | interface TurnRow { |
| @@ -63,6 +72,8 @@ const conversationOf = (r: ConversationRow): Conversation => ({ | ||
| 63 | 72 | claudeSessionId: r.claude_session_id, |
| 64 | 73 | createdAt: r.created_at, |
| 65 | 74 | updatedAt: r.updated_at, |
| 75 | title: r.title, | |
| 76 | archivedAt: r.archived_at, | |
| 66 | 77 | }); |
| 67 | 78 | |
| 68 | 79 | const turnOf = (r: TurnRow): Turn => ({ |
| @@ -75,18 +86,87 @@ const turnOf = (r: TurnRow): Turn => ({ | ||
| 75 | 86 | failed: r.failed === 1, |
| 76 | 87 | }); |
| 77 | 88 | |
| 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"> { | |
| 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 { | |
| 80 | 105 | 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 }; | |
| 81 | 107 | 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); | |
| 87 | 110 | return conversationOf(row); |
| 88 | 111 | } |
| 89 | 112 | |
| 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 | ||
| 90 | 170 | const owned = (db: Database, userId: string, conversationId: string): boolean => |
| 91 | 171 | db |
| 92 | 172 | .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: |
||
| 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"; | ||
| 19 | 19 | import { join } from "node:path"; |
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { dailyLeft } from "../core/account_ceiling.ts"; |
| 22 | import { appendTurn, 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"; | |
| 23 | 23 | import { attach, deliver, fileOwnedBy, NOTHING, release, type ConversationFile, type Delivery } from "../core/conversation_files.ts"; |
| 24 | 24 | import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts"; |
| 25 | 25 | import { cancelHeld, WORDS_MAX } from "../core/fence.ts"; |
| @@ -75,7 +75,8 @@ export const configureConversation = (app: App, options: ConversationOptions): v | ||
| 75 | 75 | export const conversationOptions = (app: App): ConversationOptions | undefined => configured.get(app); |
| 76 | 76 | |
| 77 | 77 | /** 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); | |
| 79 | 80 | |
| 80 | 81 | /** The one line the person sees when a restart cut their answer short. */ |
| 81 | 82 | 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 = | ||
| 102 | 103 | export const STOPPED_LINE = "Stopped."; |
| 103 | 104 | |
| 104 | 105 | /** 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 }>(); | |
| 106 | 107 | |
| 107 | 108 | /** |
| 108 | 109 | * 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 }>(); | ||
| 110 | 111 | * Confirm is cancelled, never applied. False when no turn runs. |
| 111 | 112 | */ |
| 112 | 113 | 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; | |
| 122 | 126 | } |
| 123 | 127 | |
| 124 | 128 | /** 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 { | ||
| 194 | 198 | }); |
| 195 | 199 | } |
| 196 | 200 | |
| 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"; | |
| 198 | 202 | |
| 199 | 203 | export interface Said { |
| 200 | 204 | /** `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; | ||
| 225 | 229 | |
| 226 | 230 | const queues = new Map<string, Queued[]>(); |
| 227 | 231 | |
| 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) ?? []; | |
| 230 | 235 | |
| 231 | 236 | /** Whether a message waiting in a queue holds this file: it cannot be removed or sent twice meanwhile. */ |
| 232 | 237 | export const fileQueued = (fileId: string): boolean => [...queues.values()].some((l) => l.some((q) => q.files.some((f) => f.id === fileId))); |
| 233 | 238 | |
| 234 | 239 | /** Take back a waiting message; false when it is gone (already sent, or never theirs). */ |
| 235 | 240 | 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; | |
| 243 | 249 | } |
| 244 | 250 | |
| 245 | 251 | /** |
| @@ -253,8 +259,8 @@ function drain(app: App, options: ConversationOptions, conversation: Conversatio | ||
| 253 | 259 | if (list?.length === 0) queues.delete(conversation.id); |
| 254 | 260 | if (next === undefined) return; |
| 255 | 261 | 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); | |
| 258 | 264 | const stored = appendTurn(app.db, userId, current.id, { role: "person", text: next.text }); |
| 259 | 265 | if (!stored.ok) return drain(app, options, current); |
| 260 | 266 | // 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( | ||
| 281 | 287 | text: string, |
| 282 | 288 | files: readonly ConversationFile[] = [], |
| 283 | 289 | during: During = "inject", |
| 290 | conversationId?: string, | |
| 284 | 291 | ): Result<Said, SayRefusal> { |
| 285 | 292 | const user = getUserById(app.db, userId); |
| 286 | 293 | if (user === undefined) return err("unauthorized"); |
| 287 | 294 | if (text.trim() === "") return err("empty"); |
| 288 | 295 | 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"); | |
| 290 | 298 | if (during !== "inject" && live.has(conversation.id)) { |
| 291 | 299 | const list = queues.get(conversation.id) ?? []; |
| 292 | 300 | const waiting: Queued = { id: id(), text, files }; |
| @@ -335,10 +343,11 @@ export type RedoRefusal = SayRefusal | "running" | "nothing"; | ||
| 335 | 343 | * starts a fresh session whose context is the turns that still stand. Refused |
| 336 | 344 | * while a turn runs, or when the last exchange is not what the action is for. |
| 337 | 345 | */ |
| 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> { | |
| 339 | 347 | const user = getUserById(app.db, userId); |
| 340 | 348 | 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"); | |
| 342 | 351 | if (live.has(conversation.id)) return err("running"); |
| 343 | 352 | const { person, after } = lastExchange(app.db, userId, conversation.id); |
| 344 | 353 | if (person === undefined) return err("nothing"); |
| @@ -389,8 +398,8 @@ export function resumeInterrupted(app: App, options: ConversationOptions): Promi | ||
| 389 | 398 | carryWrites(app.db, `conversation:${row.conversation_id}`); |
| 390 | 399 | app.db.query("UPDATE conversation_run SET state = 'failed', finished_at = ?2 WHERE id = ?1").run(row.id, new Date().toISOString()); |
| 391 | 400 | 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; | |
| 394 | 403 | appendTurn(app.db, row.user_id, conversation.id, { role: "agent", text: RESUMED_LINE }); |
| 395 | 404 | // A write begun and not reported is shown, not retried: the person decides. |
| 396 | 405 | if (unknown.length > 0) appendTurn(app.db, row.user_id, conversation.id, { role: "agent", text: unendedLine(unknown) }); |
| @@ -460,7 +469,7 @@ function begin( | ||
| 460 | 469 | .run(runId, conversation.id, userId, subscriptionId, prompt, resumes, resumeSession ?? "", now.toISOString()); |
| 461 | 470 | live.set(conversation.id, runId); |
| 462 | 471 | const stopper = new AbortController(); |
| 463 | stoppers.set(conversation.id, { runId, stop: () => stopper.abort() }); | |
| 472 | stoppers.set(conversation.id, { runId, userId, stop: () => stopper.abort() }); | |
| 464 | 473 | const signal = AbortSignal.any([options.signal, stopper.signal]); |
| 465 | 474 | const put = (role: "agent" | "tool", text: string): void => { |
| 466 | 475 | 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 @@ | ||
| 10 | 10 | import type { App } from "../../app.ts"; |
| 11 | 11 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 12 | 12 | 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"; | |
| 13 | 26 | 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"; | |
| 15 | 27 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 16 | 28 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 17 | 29 | import { scopeAllows } from "../../core/tokens.ts"; |
| 30 | import type { User } from "../../core/users.ts"; | |
| 18 | 31 | import { worksOf } from "../../core/talk_work.ts"; |
| 19 | 32 | import { fileQueued, queuedOf, turnRunning } from "../../crew/conversation.ts"; |
| 20 | 33 | import { crossOrigin, type Handler } from "../context.ts"; |
| @@ -57,6 +70,8 @@ export function conversationTalkRoutes(router: Router<Handler>): void { | ||
| 57 | 70 | const text = input["text"]; |
| 58 | 71 | if (typeof text !== "string" || text.trim() === "" || text.length > WORDS_MAX) |
| 59 | 72 | 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"); | |
| 60 | 75 | const ids = input["files"] ?? []; |
| 61 | 76 | if (!Array.isArray(ids) || ids.length > FILES_PER_MESSAGE || !ids.every((f) => typeof f === "string")) |
| 62 | 77 | 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 { | ||
| 65 | 80 | // 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. |
| 66 | 81 | const during = input["during"]; |
| 67 | 82 | 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); | |
| 69 | 84 | if (!said.ok) { |
| 70 | 85 | const [status, words] = TALK_WORDS[said.error]; |
| 71 | 86 | 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 { | ||
| 146 | 161 | const user = ctx.auth.user; |
| 147 | 162 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 148 | 163 | 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"); | |
| 150 | 167 | const turns = listTurns(ctx.app.db, user.id, conversation.id, { |
| 151 | 168 | limit: turnsLimit(ctx.url.searchParams.get("limit")), |
| 152 | 169 | ...(ctx.url.searchParams.get("before") ? { before: ctx.url.searchParams.get("before")! } : {}), |
| @@ -155,6 +172,55 @@ export function conversationTalkRoutes(router: Router<Handler>): void { | ||
| 155 | 172 | const all = turns.ok ? turns.value : []; |
| 156 | 173 | const works = worksOf(ctx.app.db, user.id, conversation.id, all); |
| 157 | 174 | 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) }); | |
| 159 | 213 | }); |
| 160 | 214 | } |
| 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 @@ | ||
| 1 | 1 | import { mayView } from "../../../core/sealed.ts"; |
| 2 | 2 | import { addCommentThrough } from "../../../actions/issues.ts"; |
| 3 | 3 | import { getIssue } from "../../../core/issues.ts"; |
| 4 | import { archiveConversation, createConversation, getConversation, listConversations, renameConversation, TOO_MANY_WORDS } from "../../../core/conversations.ts"; | |
| 4 | 5 | import { talk, TALK_WORDS } from "../../../actions/talk.ts"; |
| 5 | 6 | import { WORDS_MAX } from "../../../core/fence.ts"; |
| 6 | 7 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 7 | 8 | |
| 8 | 9 | export const tool: Tool = { |
| 9 | 10 | 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)", | |
| 11 | 12 | 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).", | |
| 13 | 14 | inputSchema: { |
| 14 | 15 | type: "object", |
| 15 | 16 | properties: { |
| 16 | 17 | repo: REPO_ARG, |
| 17 | 18 | number: { type: "integer", minimum: 1 }, |
| 18 | 19 | text: { type: "string", maxLength: 8000 }, |
|
||
| 20 | conversation: { type: "string" }, | |
| 21 | action: { type: "string", enum: ["list", "new", "rename", "archive", "restore"] }, | |
| 22 | title: { type: "string", maxLength: 80 }, | |
| 23 | archived: { type: "boolean" }, | |
| 19 | 24 | }, |
| 20 | required: ["text"], | |
| 21 | 25 | }, |
| 22 | 26 | writes: true, |
| 23 | 27 | async run(ctx, user, args) { |
| 24 | 28 | if (args["repo"] === undefined && args["number"] === undefined) { |
| 25 | 29 | // The forge's own agent never wakes itself: its via token is refused. |
| 26 | 30 | 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"]; | |
| 28 | 56 | 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); | |
| 30 | 59 | if (!talked.ok) return bad(talked.error, TALK_WORDS[talked.error][1]); |
| 31 | 60 | 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." }); |
| 32 | 61 | } |
src/http/server.ts +7 −1
| @@ -124,7 +124,7 @@ import { | ||
| 124 | 124 | verifyPage, |
| 125 | 125 | themeSubmit, |
| 126 | 126 | } 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"; | |
| 128 | 128 | import { |
| 129 | 129 | recoverFinish, |
| 130 | 130 | recoverOptions, |
| @@ -844,6 +844,12 @@ export function buildRouter(): Router<Handler> { | ||
| 844 | 844 | router.add("POST", "/talk/again/:how", talkAgainSend); |
| 845 | 845 | router.add("POST", "/talk/queue/:id/cancel", talkQueueCancel); |
| 846 | 846 | 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); | |
| 847 | 853 | router.add("POST", "/signin/passkey/options", signinOptions); |
| 848 | 854 | router.add("POST", "/signin/passkey", signinPasskey); |
| 849 | 855 | router.add("GET", "/recover", recoverPage); |
src/tui/forge.ts +9
| @@ -50,6 +50,7 @@ import { usageScreen } from "./screens/usage.ts"; | ||
| 50 | 50 | import { canScreen } from "./screens/can.ts"; |
| 51 | 51 | import { modelScreen } from "./screens/model.ts"; |
| 52 | 52 | import { notifyScreen } from "./screens/notify.ts"; |
| 53 | import { talkChatsScreen } from "./screens/talk_chats.ts"; | |
| 53 | 54 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 54 | 55 | import { passkeysScreen } from "./screens/passkeys.ts"; |
| 55 | 56 | import { confirmsScreen } from "./screens/confirms.ts"; |
| @@ -307,6 +308,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 307 | 308 | hint: "what the forge's agent asked to do; y does it once", |
| 308 | 309 | run: () => ctx.push(confirmsScreen(session, ctx)), |
| 309 | 310 | }); |
| 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 | }); | |
| 310 | 319 | if (session.signedIn) |
| 311 | 320 | commands.push({ |
| 312 | 321 | 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"; | ||
| 18 | 18 | import { cancelTalk, queuedTalk, TALK_WORDS, talk, talkAgain } from "../../actions/talk.ts"; |
| 19 | 19 | import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts"; |
| 20 | 20 | 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"; | |
| 22 | 38 | import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts"; |
| 23 | 39 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 24 | 40 | import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts"; |
| @@ -28,10 +44,10 @@ import { turnRunning, type RedoHow } from "../../crew/conversation.ts"; | ||
| 28 | 44 | import type { Ctx } from "../../http/context.ts"; |
| 29 | 45 | import { json } from "../../http/respond.ts"; |
| 30 | 46 | import { eventStream } from "../../http/sse.ts"; |
| 31 | import { signInFirst, forbiddenPage } from "../chrome.ts"; | |
| 47 | import { signInFirst, forbiddenPage, notFoundPage } from "../chrome.ts"; | |
| 32 | 48 | import { readForm } from "../form.ts"; |
| 33 | 49 | import { html, type Html } from "../html.ts"; |
| 34 | import { flashTo, page } from "../layout.ts"; | |
| 50 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 35 | 51 | import { renderMarkdown } from "../markdown.ts"; |
| 36 | 52 | import { btn, time } from "../ui.ts"; |
| 37 | 53 | import { talkCss, talkJs } from "./talk_client.ts"; |
| @@ -47,6 +63,12 @@ export const clock = { streamMs: STREAM_MS, running: turnRunning }; | ||
| 47 | 63 | const open = new Map<string, number>(); |
| 48 | 64 | const TURN_ID = /^[0-9a-z]{0,40}$/; |
| 49 | 65 | 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}`; | |
| 50 | 72 | |
| 51 | 73 | const parse = (text: string): Record<string, unknown> | undefined => { |
| 52 | 74 | try { |
| @@ -88,23 +110,23 @@ const workHtml = (work: Work): Html => | ||
| 88 | 110 | const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const; |
| 89 | 111 | |
| 90 | 112 | /** 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>`; | |
| 93 | 115 | |
| 94 | 116 | /** |
| 95 | 117 | * What the last exchange offers, by the turn it sits under: Edit under the |
| 96 | 118 | * person's last message, and under the last turn Retry when it failed or |
| 97 | 119 | * Regenerate when it is a reply. Nothing while a turn runs. |
| 98 | 120 | */ |
| 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> { | |
| 100 | 122 | const acts = new Map<string, Html>(); |
| 101 | 123 | if (running) return acts; |
| 102 | 124 | const { person, after } = lastExchange(db, userId, conversationId); |
| 103 | 125 | 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>`); | |
| 105 | 127 | 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")); | |
| 108 | 130 | return acts; |
| 109 | 131 | } |
| 110 | 132 | |
| @@ -166,9 +188,48 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number | ||
| 166 | 188 | return toolCard(id, call, call.split(/[\s(:{]/, 1)[0] ?? "tool", turn.createdAt, set); |
| 167 | 189 | } |
| 168 | 190 | |
| 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 { | |
| 170 | 193 | 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"; | |
| 172 | 233 | const listed = listTurns(ctx.app.db, user.id, conversation.id, { |
| 173 | 234 | limit: DEFAULT_PAGE, |
| 174 | 235 | ...(before === undefined ? {} : { before }), |
| @@ -176,33 +237,35 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b | ||
| 176 | 237 | const turns = listed.ok ? listed.value : []; |
| 177 | 238 | const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id)); |
| 178 | 239 | 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); | |
| 180 | 241 | const costs = turnCosts(ctx.app.db, conversation.id); |
| 181 | 242 | const context = contextOf(ctx.app.db, conversation.id); |
| 182 | 243 | const works = worksOf(ctx.app.db, user.id, conversation.id, turns); |
| 183 | 244 | // Paging back, the log ends before the newest turn: it does not follow. |
| 184 | 245 | 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>(); | |
| 186 | 247 | // Edit shows the composer holding the last message, posting to Edit instead of Send. |
| 187 | 248 | const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined; |
| 188 | 249 | 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"}"> | |
| 190 | 252 | <div> |
| 191 | 253 | ${contextLine(context)} |
| 192 | 254 | ${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>` | |
| 194 | 256 | : 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>`} | |
| 196 | 258 | <ol class="talk-log" role="log" data-last="${last}" aria-label="Conversation with the forge"> |
| 197 | 259 | ${turns.length === 0 |
| 198 | 260 | ? 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>` | |
| 200 | 262 | : 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} | |
| 202 | 264 | </ol> |
| 203 | 265 | <div id="end"></div> |
| 204 | 266 | </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>`} | |
| 206 | 269 | <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p> |
| 207 | 270 | <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div> |
| 208 | 271 | <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 | ||
| 210 | 273 | <span class="muted small" data-hint></span> |
| 211 | 274 | <label class="btn talk-attach" data-attach hidden>Attach<input type="file" multiple hidden data-pick /></label> |
| 212 | 275 | <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>`} | |
| 214 | 277 | <button type="button" class="btn" data-stop hidden>Stop</button> |
| 215 | 278 | <button type="button" class="btn" data-steer hidden title="Stop this answer and send now">Steer</button> |
| 216 | 279 | ${btn(editing === undefined ? "Send" : "Resend", { variant: "primary" })} |
| @@ -220,32 +283,83 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b | ||
| 220 | 283 | return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge." }); |
| 221 | 284 | } |
| 222 | 285 | |
| 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 | |
| 224 | 294 | export function talkView(ctx: Ctx): Response { |
| 225 | 295 | if (ctx.auth.user === undefined) return signInFirst(ctx); |
| 226 | 296 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 227 | 297 | const before = ctx.url.searchParams.get("before"); |
| 228 | 298 | 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 | ); | |
| 230 | 304 | } |
| 231 | 305 | |
| 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. | |
| 233 | 307 | export async function talkSend(ctx: Ctx): Promise<Response> { |
| 234 | 308 | const user = ctx.auth.user; |
| 235 | 309 | if (user === undefined) return signInFirst(ctx); |
| 236 | 310 | const form = await readForm(ctx, "rw"); |
| 237 | 311 | 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); | |
| 238 | 315 | const text = form.get("text"); |
| 239 | 316 | if (typeof text !== "string" || text.trim() === "" || text.length > WORDS_MAX) |
| 240 | return flashTo(ctx, `${HERE}`, { 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}`, { tone: "danger", message: TALK_WORDS[said.error][1] }); | |
| 243 | return flashTo(ctx, `${HERE}`, { tone: "success", message: "Sent. Reload to see the answer." }); | |
| 317 | return flashTo(ctx, `${here}`, { 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}`, { tone: "danger", message: TALK_WORDS[said.error][1] }); | |
| 320 | return flashTo(ctx, `${here}`, { 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." }); | |
| 244 | 358 | } |
| 245 | 359 | |
| 246 | 360 | const AGAIN: readonly RedoHow[] = ["edit", "regenerate", "retry"]; |
| 247 | 361 | |
| 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. | |
| 249 | 363 | export async function talkAgainSend(ctx: Ctx): Promise<Response> { |
| 250 | 364 | const user = ctx.auth.user; |
| 251 | 365 | if (user === undefined) return signInFirst(ctx); |
| @@ -254,16 +368,19 @@ export async function talkAgainSend(ctx: Ctx): Promise<Response> { | ||
| 254 | 368 | if (form instanceof Response) return form; |
| 255 | 369 | const how = AGAIN.find((h) => h === ctx.params["how"]); |
| 256 | 370 | 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); | |
| 257 | 374 | const text = form.get("text"); |
| 258 | 375 | const asJson = (ctx.request.headers.get("accept") ?? "").includes("application/json"); |
| 259 | 376 | let status = 422; |
| 260 | 377 | let message = `Say something, up to ${WORDS_MAX} characters.`; |
| 261 | 378 | 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}`, { 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}`, { tone: "success", message: "Asked again. Reload to see the answer." }); | |
| 264 | 381 | [status, message] = TALK_WORDS[said.error]; |
| 265 | 382 | } |
| 266 | return asJson ? json({ ok: false, message }, status) : flashTo(ctx, `${HERE}`, { tone: "danger", message }); | |
| 383 | return asJson ? json({ ok: false, message }, status) : flashTo(ctx, `${here}`, { tone: "danger", message }); | |
| 267 | 384 | } |
| 268 | 385 | |
| 269 | 386 | // POST /talk/queue/:id/cancel — take back a message waiting for the running turn. |
| @@ -315,7 +432,10 @@ export function talkStream(ctx: Ctx): Response { | ||
| 315 | 432 | const after = ctx.url.searchParams.get("after") ?? ctx.request.headers.get("last-event-id") ?? ""; |
| 316 | 433 | if (!TURN_ID.test(after)) return new Response("bad turn id", { status: 400 }); |
| 317 | 434 | 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 }); | |
| 319 | 439 | let cursor = after; |
| 320 | 440 | return eventStream(app, ctx.request, (emit, close) => { |
| 321 | 441 | const started = Date.now(); |
| @@ -323,7 +443,7 @@ export function talkStream(ctx: Ctx): Response { | ||
| 323 | 443 | let shown = ""; |
| 324 | 444 | const tick = (): void => { |
| 325 | 445 | // 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); | |
| 327 | 447 | const fresh = turnsAfter(app.db, user.id, conversation.id, cursor); |
| 328 | 448 | if (fresh.length > 0) { |
| 329 | 449 | const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id)); |
| @@ -335,7 +455,7 @@ export function talkStream(ctx: Ctx): Response { | ||
| 335 | 455 | } |
| 336 | 456 | } |
| 337 | 457 | // 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); | |
| 339 | 459 | const key = waits.map((q) => q.id).join(); |
| 340 | 460 | if (key !== shown && (running || shown !== "")) { |
| 341 | 461 | shown = key; |
| @@ -349,7 +469,7 @@ export function talkStream(ctx: Ctx): Response { | ||
| 349 | 469 | emit("done", { |
| 350 | 470 | costs, |
| 351 | 471 | 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 })), | |
| 353 | 473 | }); |
| 354 | 474 | close(); |
| 355 | 475 | } else if (Date.now() - started >= clock.streamMs) { |
src/web/pages/talk_client.ts +8 −4
| @@ -47,6 +47,9 @@ export const talkCss = /* css */ ` | ||
| 47 | 47 | .talk-chips:empty { display: none; } |
| 48 | 48 | .talk-files { justify-content: flex-end; } |
| 49 | 49 | .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; } | |
| 50 | 53 | .talk-replaced { opacity: 0.55; } |
| 51 | 54 | .talk-failed form { margin-top: 6px; } |
| 52 | 55 | .talk-failed .btn, .talk-act { min-height: 2.75rem; } |
| @@ -67,6 +70,7 @@ if (root) { | ||
| 67 | 70 | let source = null; |
| 68 | 71 | let retry = 0; |
| 69 | 72 | const paged = root.dataset.paged === "1"; |
| 73 | const { c: cid, here } = root.dataset; | |
| 70 | 74 | const say = (words) => { status.textContent = words; }; |
| 71 | 75 | const hint = root.querySelector("[data-hint]"); |
| 72 | 76 | if (hint) hint.textContent = "Enter sends, Shift+Enter adds a line"; |
| @@ -134,7 +138,7 @@ if (root) { | ||
| 134 | 138 | const again = async (how, body) => { |
| 135 | 139 | say(how === "edit" ? "Sending…" : "Asking again…"); |
| 136 | 140 | 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 }); | |
| 138 | 142 | const out = await res.json().catch(() => ({})); |
| 139 | 143 | if (!res.ok) { say(out.message || "That was not done."); return false; } |
| 140 | 144 | stuck = true; |
| @@ -188,7 +192,7 @@ if (root) { | ||
| 188 | 192 | if (paged) return; |
| 189 | 193 | if (source) source.close(); |
| 190 | 194 | dropActs(); |
| 191 | source = new EventSource("/talk/stream?after=" + encodeURIComponent(last)); | |
| 195 | source = new EventSource("/talk/stream?c=" + encodeURIComponent(cid) + "&after=" + encodeURIComponent(last)); | |
| 192 | 196 | source.addEventListener("turn", (e) => { |
| 193 | 197 | const turn = JSON.parse(e.data); |
| 194 | 198 | last = turn.id; |
| @@ -283,7 +287,7 @@ if (root) { | ||
| 283 | 287 | const res = await fetch("/api/v1/conversation/messages", { |
| 284 | 288 | method: "POST", |
| 285 | 289 | 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" }), | |
| 287 | 291 | }); |
| 288 | 292 | if (!res.ok) { |
| 289 | 293 | const body = await res.json().catch(() => ({})); |
| @@ -299,7 +303,7 @@ if (root) { | ||
| 299 | 303 | const out = await res.json().catch(() => ({})); |
| 300 | 304 | if (out.how === "queued") { say("Queued: it sends after this answer."); if (!source) follow(); return; } |
| 301 | 305 | if (out.how === "interrupted") { say("Stopping, then sending…"); return; } |
| 302 | if (paged) { location.href = "/talk"; return; } | |
| 306 | if (paged) { location.href = here; return; } | |
| 303 | 307 | follow(); |
| 304 | 308 | } catch (_) { |
| 305 | 309 | 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"; | ||
| 19 | 19 | import { TALK_PER_MINUTE, talk, talkAgain } from "../src/actions/talk.ts"; |
| 20 | 20 | import { turnsLimit } from "../src/http/api/conversation_talk.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { appendTurn, openConversation } from "../src/core/conversations.ts"; | |
| 22 | import { appendTurn, newConversation, openConversation } from "../src/core/conversations.ts"; | |
| 23 | 23 | import { forgeSshSession } from "../src/tui/ssh.ts"; |
| 24 | 24 | import { isStop, parseTalkAgain, parseTalkCommand } from "../src/tui/ssh-talk.ts"; |
| 25 | 25 | import { listTurns } from "../src/core/conversations.ts"; |
| @@ -342,6 +342,24 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Heard.","t | ||
| 342 | 342 | expect(await standing()).toEqual(plain); |
| 343 | 343 | expect(talkAgain(app, undefined, "retry").ok).toBe(false); |
| 344 | 344 | }); |
| 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 | ||
| 345 | 363 | const post = (path: string, h: Record<string, string>, body = "") => |
| 346 | 364 | fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...h }, body, redirect: "manual" }); |
| 347 | 365 | 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"; | ||
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { clearAccountCeiling, daySpent, setAccountCeiling } from "../src/core/account_ceiling.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { monthSpent } from "../src/core/model_grant.ts"; |
| 18 | 18 | import { addSubscription, getSubscription, PLATFORM, setGrants, wakeSubscription } from "../src/core/subscriptions.ts"; |
| 19 | 19 | import { verifyToken } from "../src/core/tokens.ts"; |
| @@ -524,6 +524,21 @@ sleep 300 | ||
| 524 | 524 | expect(alive).toBe(false); |
| 525 | 525 | expect(stopTurn(app, u.value.id)).toBe(false); |
| 526 | 526 | }, 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); | |
| 527 | 542 | }); |
| 528 | 543 | |
| 529 | 544 | describe("talk turn counts (#8860)", () => { |
test/conversations.test.ts +55
| @@ -5,6 +5,12 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { migrations } from "../src/core/schema.ts"; |
| 6 | 6 | import { |
| 7 | 7 | appendTurn, |
| 8 | archiveConversation, | |
| 9 | getConversation, | |
| 10 | listConversations, | |
| 11 | newConversation, | |
| 12 | renameConversation, | |
| 13 | TITLE_MAX, | |
| 8 | 14 | listTurns, |
| 9 | 15 | MAX_TURN_CHARS, |
| 10 | 16 | openConversation, |
| @@ -130,3 +136,52 @@ describe("conversations", () => { | ||
| 130 | 136 | db.close(); |
| 131 | 137 | }); |
| 132 | 138 | }); |
| 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", () => { | ||
| 70 | 70 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 71 | 71 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 72 | 72 | ["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"], | |
| 73 | 74 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 74 | 75 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], |
| 75 | 76 | ["account", "Account settings", undefined], |
| @@ -222,6 +223,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 222 | 223 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 223 | 224 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 224 | 225 | ["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"], | |
| 225 | 227 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 226 | 228 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], |
| 227 | 229 | ["account", "Account settings", undefined], |
test/route-timing-coverage.test.ts +1
| @@ -19,6 +19,7 @@ import { seedAdmin } from "./helpers/admin.ts"; | ||
| 19 | 19 | |
| 20 | 20 | /** Routes that answer 200 without a `session` phase → why. */ |
| 21 | 21 | const UNTIMED: Record<string, string> = { |
| 22 | "/api/v1/conversations": "a person's conversation list, one indexed query; no page to phase", | |
| 22 | 23 | "/metrics": "Prometheus text exposition, scraped by token; not a page read", |
| 23 | 24 | }; |
| 24 | 25 |
test/schema-additive.test.ts +11 −1
| @@ -71,7 +71,7 @@ describe("migrations are additive across a roll", () => { | ||
| 71 | 71 | expect(inPlay.length).toBeGreaterThan(0); |
| 72 | 72 | const offenders = migrationFiles |
| 73 | 73 | .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)) | |
| 75 | 75 | .map((m) => ({ name: m.name, problems: notAdditive(m.sql) })) |
| 76 | 76 | .filter((m) => m.problems.length > 0); |
| 77 | 77 | expect(offenders).toEqual([]); |
| @@ -152,6 +152,16 @@ const RETIRED: Record<string, { readonly drops: readonly string[]; readonly sinc | ||
| 152 | 152 | }, |
| 153 | 153 | }; |
| 154 | 154 | |
| 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 | ||
| 155 | 165 | const src = join(import.meta.dir, "../src"); |
| 156 | 166 | |
| 157 | 167 | 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"; | ||
| 45 | 45 | const NOW = "the stage is one of check, review, land or live; the seed fills no word with a name it cannot guess"; |
| 46 | 46 | const GATE = "needs a kept gate log, written only when a full gate runs on land"; |
| 47 | 47 | 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"; | |
| 48 | 49 | const HOOK = "needs a webhook, which the seed does not register"; |
| 49 | 50 | |
| 50 | 51 | /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */ |
| 51 | 52 | const UNSEEDED: Record<string, string> = { |
| 52 | 53 | "/:owner/-/teams/:team": ORG, |
| 54 | "/talk/:id": TALK, | |
| 53 | 55 | "/:owner/:name/bets/:id": BET, |
| 54 | 56 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, |
| 55 | 57 | "/: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 <plan>"); | |
| 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", () => { | ||
| 191 | 191 | |
| 192 | 192 | test("the page's own bytes are held: stylesheet and script stay small", async () => { |
| 193 | 193 | const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts"); |
| 194 | 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); | |
| 196 | 196 | say(alice, "agent", "```ts\nlet x = 1;\n```"); |
| 197 | 197 | const page = await get("/talk", alice); |
| 198 | 198 | const size = (await page.arrayBuffer()).byteLength; |
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 | }); |
new above
offline
Count then insert outside a transaction: concurrent New can exceed the cap. Wrap in db.transaction.