Public: anyone can read this; the crew can change it.
/talk: one conversation with the forge, reached from any page, streamed, and usable at phone width #8225
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7652-talk-one-conversation-with-the-forge-rea → main 10 commits fork
19 files
changed +785
−10
docs/CLI.md +11
| @@ -329,6 +329,17 @@ $ forge tell "make the header smaller" | ||
| 329 | 329 | told alice/shop: issue #12 https://forge.example.com/alice/shop/issues/12 |
| 330 | 330 | |
| 331 | 331 | |
| 332 | ## forge talk | |
| 333 | ||
| 334 | Say something to the forge's own agent, as you, and print what it answers. | |
| 335 | The agent works through the forge's MCP with your permissions; a write it | |
| 336 | would make is held for your yes (Confirm or Cancel on `/talk`). Words are 1 to | |
| 337 | 20000 characters. It waits up to two minutes, then says where to read the rest. | |
| 338 | ||
| 339 | console | |
| 340 | $ forge talk "what is waiting on me?" | |
| 341 | ||
| 342 | ||
| 332 | 343 | ## forge new |
| 333 | 344 | |
| 334 | 345 | Makes an app from the shell, as `POST /api/v1/apps` does, then clones its |
docs/design/areas/talk.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # Talk | |
| 2 | ||
| 3 | `/talk` (#7652) is the person's one conversation with the forge: a log, a composer, and Confirm and Cancel for a held action. | |
| 4 | ||
| 5 | - `.talk` — the page's column: at most 760px, centred, `min-width: 0` so nothing pushes the page sideways at 375px. | |
| 6 | - `.talk-log` — the ordered list of turns; its `data-last` is the last turn id the stream resumes from. | |
| 7 | - `.talk-turn` — one turn; wraps anywhere, so a long word or URL never scrolls the page. | |
| 8 | - `.talk-person` — the person's words: a right-aligned bubble, pre-wrapped. | |
| 9 | - `.talk-agent` — the agent's reply, markdown, full width, no bubble. | |
| 10 | - `.talk-tool` — what the agent did, one muted small line. | |
| 11 | - `.talk-confirm` — a held action's card: the sentence, then Confirm and Cancel at 44px high. | |
| 12 | - `.talk-compose` — the composer, sticky to the bottom of the viewport (padded for the phone's safe area), so it stays reachable; its textarea is 16px so iOS does not zoom. | |
| 13 | - `.talk-status` — one polite line (sending, answering, an error); it always takes its line, so nothing shifts. | |
| 14 | - `.talk-more` — the "Earlier" link above the log. |
docs/design/audit.md +1
| @@ -60,6 +60,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 60 | 60 | - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen. |
| 61 | 61 | - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen. |
| 62 | 62 | - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here. |
| 63 | - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`. | |
| 63 | 64 | - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen. |
| 64 | 65 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 65 | 66 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
docs/features/talk.md +8
| @@ -9,3 +9,11 @@ One door, `talk()` in `src/actions/talk.ts`, three ways in. It sends the words a | ||
| 9 | 9 | It needs a granted Claude account (`no_account`), and says `resting` when none can serve it or your daily ceiling is spent. At most four turns run across the forge at once (`TURNS_LIVE_MAX`); a new one past that is `busy` (429, `retry-after: 60`), while a message into your running turn always goes through. A message that is refused is not kept in the conversation. |
| 10 | 10 | |
| 11 | 11 | `GET /api/v1/conversation/turns?limit=` takes 1 to 200 turns; absent, empty or not a number is 50. |
| 12 | ||
| 13 | ## On the web: `/talk` | |
| 14 | ||
| 15 | Reached from the Talk icon in every page's top bar, "Talk to the forge" in the palette (`Ctrl/Cmd+K`), `g t`, and the Talk button beside the tell box on home. Signed out, it sends you to /signin and back. It shows your own conversation (the last 50 turns, "Earlier" pages back) and a composer; Ctrl+Enter sends. | |
| 16 | ||
| 17 | A reply streams in over `GET /talk/stream?after=<turn id>` (server-sent events, one `turn` frame per turn, each carrying the server-rendered markup). The stream ends with the turn (`done`), or after 40 seconds (`more`), well inside the edge's timeout; the page then reconnects from the last turn id it saw (`Last-Event-ID` works too). Without the script the composer is a plain form and a reload shows the answer. | |
| 18 | ||
| 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. |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/pipeline", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/actions/conversation_confirms.ts +15
| @@ -92,3 +92,18 @@ export async function sayYes(ctx: Ctx, user: User, confirmId: string): Promise<Y | ||
| 92 | 92 | appendTurn(ctx.app.db, user.id, conversation.id, { role: "tool", text: JSON.stringify(turn) }); |
| 93 | 93 | return { ok: true, done: ran.ok, turn, ...(ran.ok ? { result: ran.value } : {}) }; |
| 94 | 94 | } |
| 95 | ||
| 96 | /** | |
| 97 | * The person's no: the confirm is claimed so it can never be said yes to | |
| 98 | * later, nothing is run, and a `tool` turn says it was cancelled. Same door | |
| 99 | * rules as a yes — the person's own session, decided by the caller. | |
| 100 | */ | |
| 101 | export function sayNo(ctx: Pick<Ctx, "app">, user: User, confirmId: string): { ok: true; turn: Record<string, unknown> } | { ok: false; error: YesRefusal } { | |
| 102 | if (!CONFIRM_ID.test(confirmId)) return { ok: false, error: "not_found" }; | |
| 103 | const answered = answerHeld(ctx.app.db, confirmId, user.id); | |
| 104 | if (!answered.ok) return answered; | |
| 105 | const turn = { confirm: answered.value.id, tool: answered.value.tool, done: false, cancelled: true }; | |
| 106 | const conversation = openConversation(ctx.app.db, user.id); | |
| 107 | appendTurn(ctx.app.db, user.id, conversation.id, { role: "tool", text: JSON.stringify(turn) }); | |
| 108 | return { ok: true, turn }; | |
| 109 | } |
src/core/conversations.ts +16
| @@ -136,6 +136,22 @@ export function listTurns( | ||
| 136 | 136 | return ok(rows.reverse().map(turnOf)); |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | /** | |
| 140 | * The turns after the turn id `after`, oldest first, at most `MAX_PAGE`; an | |
| 141 | * empty `after` is the whole conversation's start, an id this conversation | |
| 142 | * does not have is nothing. The stream reads this to follow an answer being | |
| 143 | * written and to resume from the last turn a client saw. | |
| 144 | */ | |
| 145 | export function turnsAfter(db: Database, userId: string, conversationId: string, after: string): Turn[] { | |
| 146 | if (!owned(db, userId, conversationId)) return []; | |
| 147 | const rows = db | |
| 148 | .query( | |
| 149 | "SELECT * FROM conversation_turn WHERE conversation_id = ?1 AND rowid > COALESCE(CASE WHEN ?2 = '' THEN 0 ELSE (SELECT rowid FROM conversation_turn WHERE id = ?2 AND conversation_id = ?1) END, 9223372036854775807) ORDER BY rowid LIMIT ?3", | |
| 150 | ) | |
| 151 | .all(conversationId, after, MAX_PAGE) as TurnRow[]; | |
| 152 | return rows.map(turnOf); | |
| 153 | } | |
| 154 | ||
| 139 | 155 | /** What a Claude session id may look like: short, and only id characters. */ |
| 140 | 156 | export const SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; |
| 141 | 157 |
src/core/reserved.ts +1
| @@ -108,6 +108,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 108 | 108 | "releases.json", |
| 109 | 109 | "sw.js", |
| 110 | 110 | "tell", |
| 111 | "talk", | |
| 111 | 112 | "theme", |
| 112 | 113 | "verify", |
| 113 | 114 | "recover", |
src/http/server.ts +5
| @@ -136,6 +136,7 @@ import { | ||
| 136 | 136 | themeSubmit, |
| 137 | 137 | } from "../web/pages/site.ts"; |
| 138 | 138 | import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; |
| 139 | import { talkConfirm, talkSend, talkStream, talkView } from "../web/pages/talk.ts"; | |
| 139 | 140 | import { |
| 140 | 141 | passkeyAdd, |
| 141 | 142 | passkeyDelete, |
| @@ -914,6 +915,10 @@ export function buildRouter(): Router<Handler> { | ||
| 914 | 915 | router.add("POST", "/settings/tokens", tokenCreate); |
| 915 | 916 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
| 916 | 917 | router.add("POST", "/settings/sessions/:id/delete", sessionDelete); |
| 918 | router.add("GET", "/talk", talkView); | |
| 919 | router.add("POST", "/talk", talkSend); | |
| 920 | router.add("GET", "/talk/stream", talkStream); | |
| 921 | router.add("POST", "/talk/confirm/:id", talkConfirm); | |
| 917 | 922 | router.add("GET", "/settings/confirms", confirmsList); |
| 918 | 923 | router.add("POST", "/settings/confirms/:id", confirmYes); |
| 919 | 924 | router.add("GET", "/settings/passkeys", passkeysPage); |
src/tui/screens/confirms.ts +22 −5
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | * sentence, through the same action the web and the API call |
| 6 | 6 | * (`actions/conversation_confirms.ts`): it does the stored call once. A |
| 7 | 7 | * result shown only once (a link, a credential) opens in a dialog and is not |
| 8 | * kept in the conversation. | |
| 8 | * kept in the conversation. `n` cancels the one under the cursor the same way | |
| 9 | * (`sayNo`): the confirm is spent, nothing runs, and the conversation says so. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | import { REFUSED, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts"; | |
| 12 | import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts"; | |
| 12 | 13 | import { ASKING } from "../../crew/conversation_confirm.ts"; |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 15 | import { binding } from "../app/keys.ts"; |
| @@ -28,7 +29,7 @@ export function confirmsScreen(f: Session, ctx: Ctx): Screen { | ||
| 28 | 29 | |
| 29 | 30 | const screen: Screen = { |
| 30 | 31 | title: "Confirms", |
| 31 | keys: [binding(["j", "k"], "move cursor"), binding(["y"], "yes")], | |
| 32 | keys: [binding(["j", "k"], "move cursor"), binding(["y"], "yes"), binding(["n"], "cancel")], | |
| 32 | 33 | render(area, s) { |
| 33 | 34 | const c = ctx.theme.c; |
| 34 | 35 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| @@ -37,16 +38,32 @@ export function confirmsScreen(f: Session, ctx: Ctx): Screen { | ||
| 37 | 38 | ? note |
| 38 | 39 | : items().length === 0 |
| 39 | 40 | ? "Nothing waiting for your yes." |
| 40 | : "What the forge's agent asked to do; y does it once, as you."; | |
| 41 | : "What the forge's agent asked to do; y does it once, as you; n cancels it."; | |
| 41 | 42 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 42 | 43 | drawList(s, body!, c, state, rows()); |
| 43 | 44 | }, |
| 44 | 45 | key(e) { |
| 45 | 46 | if (state.key(e, items().length)) return true; |
| 46 | if (e.ctrl || e.alt || e.key !== "y") return false; | |
| 47 | if (e.ctrl || e.alt || (e.key !== "y" && e.key !== "n")) return false; | |
| 47 | 48 | const w = items()[state.index]; |
| 48 | 49 | const user = f.user; |
| 49 | 50 | if (w === undefined || user === undefined) return false; |
| 51 | if (e.key === "n") { | |
| 52 | ctx.push( | |
| 53 | confirmScreen(ctx, screen, { | |
| 54 | title: "Cancel this", | |
| 55 | body: [w.does], | |
| 56 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Cancel it", variant: "default" }], | |
| 57 | onChoose: (i) => { | |
| 58 | if (i !== 1) return; | |
| 59 | const no = sayNo(f, user, w.id); | |
| 60 | note = no.ok ? `Cancelled: ${w.tool}.` : `Nothing was done: ${REFUSED[no.error][1]}.`; | |
| 61 | ctx.invalidate(); | |
| 62 | }, | |
| 63 | }), | |
| 64 | ); | |
| 65 | return true; | |
| 66 | } | |
| 50 | 67 | ctx.push( |
| 51 | 68 | confirmScreen(ctx, screen, { |
| 52 | 69 | title: "Say yes", |
src/web/layout.ts +5 −1
| @@ -237,6 +237,7 @@ function userMenu( | ||
| 237 | 237 | { label: "Your profile", href: `/${user.username}`, icon: "user" }, |
| 238 | 238 | { label: "Your repositories", href: "/", icon: "repo" }, |
| 239 | 239 | { label: "Apps", href: "/apps", icon: "rocket" }, |
| 240 | { label: "Talk", href: "/talk", icon: "comment" }, | |
| 240 | 241 | { label: "Explore", href: "/explore", icon: "search" }, |
| 241 | 242 | { label: "Map", href: "/map", icon: "map" }, |
| 242 | 243 | { separator: true, label: "" }, |
| @@ -314,6 +315,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 314 | 315 | { t: "New repository", h: "/new", c: "Create" }, |
| 315 | 316 | { t: "Migrate a repository", h: "/migrate", c: "Create" }, |
| 316 | 317 | { t: "New organization", h: "/organizations/new", c: "Create" }, |
| 318 | { t: "Talk to the forge", h: "/talk", c: "Go to" }, | |
| 317 | 319 | { t: "Search", h: "/search", c: "Go to" }, |
| 318 | 320 | { t: "Account settings", h: "/settings", c: "Settings" }, |
| 319 | 321 | { t: "Access tokens", h: "/settings/tokens", c: "Settings" }, |
| @@ -564,7 +566,9 @@ function topbar( | ||
| 564 | 566 | <span class="topbar-tools"> |
| 565 | 567 | ${searchBar()} |
| 566 | 568 | ${user |
| 567 | ? html`<a class="small" href="/asks" data-shortcut="g q">Asks</a | |
| 569 | ? html`<a class="btn btn-icon" href="/talk" aria-label="Talk to the forge" title="Talk to the forge" data-shortcut="g t" | |
| 570 | >${icon("comment")}</a | |
| 571 | ><a class="small" href="/asks" data-shortcut="g q">Asks</a | |
| 568 | 572 | >${newMenu(ctx)}${userMenu( |
| 569 | 573 | ctx, |
| 570 | 574 | user, |
src/web/pages/talk.ts added +227
| @@ -0,0 +1,227 @@ | ||
| 1 | /** | |
| 2 | * /talk (#7652): the person's one conversation with the forge, on a page. | |
| 3 | * The history and the composer are server-rendered; a reply streams in over | |
| 4 | * `http/sse.ts` as it is written, each turn drawn by the same function the | |
| 5 | * first paint uses. A stream ends with the turn (or after `STREAM_MS`, well | |
| 6 | * inside the edge's timeout) and the client reconnects from the last turn id | |
| 7 | * it saw. A held action draws Confirm and Cancel buttons; Confirm is the | |
| 8 | * existing yes (`actions/conversation_confirms.ts`), Cancel is the no. | |
| 9 | * | |
| 10 | * Everything is the signed-in person's own: the conversation is found by | |
| 11 | * their id, the agent acts with their permissions (`crew/conversation.ts`). | |
| 12 | */ | |
| 13 | ||
| 14 | import { TALK_WORDS, talk } from "../../actions/talk.ts"; | |
| 15 | import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts"; | |
| 16 | import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts"; | |
| 17 | import { WORDS_MAX } from "../../core/fence.ts"; | |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 19 | import { ASKING } from "../../crew/conversation_confirm.ts"; | |
| 20 | import { turnRunning } from "../../crew/conversation.ts"; | |
| 21 | import type { Ctx } from "../../http/context.ts"; | |
| 22 | import { json } from "../../http/respond.ts"; | |
| 23 | import { eventStream } from "../../http/sse.ts"; | |
| 24 | import { signInFirst } from "../chrome.ts"; | |
| 25 | import { readForm } from "../form.ts"; | |
| 26 | import { html, type Html } from "../html.ts"; | |
| 27 | import { flashTo, page } from "../layout.ts"; | |
| 28 | import { renderMarkdown } from "../markdown.ts"; | |
| 29 | import { btn } from "../ui.ts"; | |
| 30 | import { talkCss, talkJs } from "./talk_client.ts"; | |
| 31 | ||
| 32 | /** A stream lasts this long at most; the client reconnects from its last turn id. */ | |
| 33 | export const STREAM_MS = 40_000; | |
| 34 | /** How often a stream looks for a turn the agent has written. */ | |
| 35 | export const POLL_MS = 400; | |
| 36 | /** Streams one person may hold open at once; a fourth is told to wait. */ | |
| 37 | export const STREAMS_MAX = 3; | |
| 38 | /** What a stream consults: how long it lasts and whether a turn runs. Tests move it; nothing else does. */ | |
| 39 | export const clock = { streamMs: STREAM_MS, running: turnRunning }; | |
| 40 | const open = new Map<string, number>(); | |
| 41 | const TURN_ID = /^[0-9a-z]{0,40}$/; | |
| 42 | const HERE = "/talk"; | |
| 43 | ||
| 44 | const parse = (text: string): Record<string, unknown> | undefined => { | |
| 45 | try { | |
| 46 | const value: unknown = JSON.parse(text); | |
| 47 | return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined; | |
| 48 | } catch { | |
| 49 | return undefined; | |
| 50 | } | |
| 51 | }; | |
| 52 | ||
| 53 | /** Confirm and Cancel for a held action; each its own form, so each posts alone. */ | |
| 54 | function confirmButtons(id: string, tool: string): Html { | |
| 55 | // A result shown once is drawn by Settings → Confirms, never kept in a turn. | |
| 56 | const once = ASKING[tool]?.once !== undefined; | |
| 57 | return html`<div class="bar"> | |
| 58 | <form method="post" action="${once ? `/settings/confirms/${id}` : `${HERE}/confirm/${id}`}" ${once ? null : html`data-confirm`}> | |
| 59 | <input type="hidden" name="answer" value="yes" /> | |
| 60 | ${btn("Confirm", { variant: "primary", icon: "check", label: `Confirm: ${tool}`, name: "answer", value: "yes" })} | |
| 61 | </form> | |
| 62 | <form method="post" action="${HERE}/confirm/${id}" data-confirm> | |
| 63 | ${btn("Cancel", { icon: "x", label: `Cancel: ${tool}`, name: "answer", value: "no" })} | |
| 64 | </form> | |
| 65 | </div>`; | |
| 66 | } | |
| 67 | ||
| 68 | /** One turn as the page and the stream both draw it; `waiting` is the confirms still open. */ | |
| 69 | export function turnHtml(turn: Turn, waiting: ReadonlySet<string>): Html { | |
| 70 | const id = `t-${turn.id}`; | |
| 71 | if (turn.role === "person") return html`<li class="talk-turn talk-person" id="${id}">${turn.text}</li>`; | |
| 72 | if (turn.role === "agent") return html`<li class="talk-turn talk-agent" id="${id}">${renderMarkdown(turn.text)}</li>`; | |
| 73 | const data = parse(turn.text); | |
| 74 | if (turn.role === "confirm") { | |
| 75 | const confirm = String(data?.["confirm"] ?? ""); | |
| 76 | const tool = String(data?.["tool"] ?? ""); | |
| 77 | return html`<li class="talk-turn talk-confirm" id="${id}"> | |
| 78 | <p>${String(data?.["does"] ?? turn.text)}</p> | |
| 79 | ${waiting.has(confirm) ? confirmButtons(confirm, tool) : html`<p class="muted small">Answered or past its ten minutes.</p>`} | |
| 80 | </li>`; | |
| 81 | } | |
| 82 | // A tool turn: the confirm's outcome as JSON, or the call the agent made as text. | |
| 83 | if (data !== undefined && "confirm" in data) { | |
| 84 | const tool = String(data["tool"] ?? ""); | |
| 85 | const says = data["cancelled"] === true ? `Cancelled: ${tool}.` : data["done"] === true ? `Done: ${tool}.${typeof data["undo"] === "string" ? ` To undo: ${data["undo"]}.` : ""}` : `${tool} did not go through: ${String(data["message"] ?? data["error"] ?? "")}`; | |
| 86 | return html`<li class="talk-turn talk-tool" id="${id}">${says}</li>`; | |
| 87 | } | |
| 88 | return html`<li class="talk-turn talk-tool" id="${id}">${turn.text.slice(0, 300)}</li>`; | |
| 89 | } | |
| 90 | ||
| 91 | function talkPage(ctx: Ctx, before: string | undefined): Response { | |
| 92 | const user = ctx.auth.user!; | |
| 93 | const conversation = openConversation(ctx.app.db, user.id); | |
| 94 | const listed = listTurns(ctx.app.db, user.id, conversation.id, { | |
| 95 | limit: DEFAULT_PAGE, | |
| 96 | ...(before === undefined ? {} : { before }), | |
| 97 | }); | |
| 98 | const turns = listed.ok ? listed.value : []; | |
| 99 | const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id)); | |
| 100 | const running = clock.running(ctx.app, user.id); | |
| 101 | // Paging back, the log ends before the newest turn: it does not follow. | |
| 102 | const last = turns.at(-1)?.id ?? ""; | |
| 103 | const body = html`<div class="page-head"><h1>Talk</h1></div> | |
| 104 | <div class="talk" data-talk data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}"> | |
| 105 | <div> | |
| 106 | ${turns.length === DEFAULT_PAGE && turns[0] !== undefined | |
| 107 | ? html`<p class="talk-more small"><a href="${HERE}?before=${turns[0].id}">Earlier</a></p>` | |
| 108 | : null} | |
| 109 | ${before === undefined ? null : html`<p class="talk-more small"><a href="${HERE}">Latest</a></p>`} | |
| 110 | <ol class="talk-log" data-last="${last}" aria-label="Conversation with the forge"> | |
| 111 | ${turns.length === 0 | |
| 112 | ? 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.</li>` | |
| 113 | : turns.map((t) => turnHtml(t, waiting))} | |
| 114 | </ol> | |
| 115 | <div id="end"></div> | |
| 116 | </div> | |
| 117 | <form class="talk-compose" method="post" action="${HERE}"> | |
| 118 | <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p> | |
| 119 | <textarea class="input" name="text" rows="2" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge"></textarea> | |
| 120 | <div class="bar"> | |
| 121 | <span class="muted small">Ctrl+Enter sends</span> | |
| 122 | <span class="spacer"></span> | |
| 123 | ${btn("Send", { variant: "primary" })} | |
| 124 | </div> | |
| 125 | </form> | |
| 126 | </div>`; | |
| 127 | return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge." }); | |
| 128 | } | |
| 129 | ||
| 130 | // GET /talk | |
| 131 | export function talkView(ctx: Ctx): Response { | |
| 132 | if (ctx.auth.user === undefined) return signInFirst(ctx); | |
| 133 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 134 | const before = ctx.url.searchParams.get("before"); | |
| 135 | return timePhase(ctx.request, "render", () => talkPage(ctx, before !== null && TURN_ID.test(before) && before !== "" ? before : undefined)); | |
| 136 | } | |
| 137 | ||
| 138 | // POST /talk — the plain form, for a browser without the script. | |
| 139 | export async function talkSend(ctx: Ctx): Promise<Response> { | |
| 140 | const user = ctx.auth.user; | |
| 141 | if (user === undefined) return signInFirst(ctx); | |
| 142 | const form = await readForm(ctx, "rw"); | |
| 143 | if (form instanceof Response) return form; | |
| 144 | const text = form.get("text"); | |
| 145 | if (typeof text !== "string" || text.trim() === "" || text.length > WORDS_MAX) | |
| 146 | return flashTo(ctx, `${HERE}`, { tone: "danger", message: `Say something, up to ${WORDS_MAX} characters.` }); | |
| 147 | const said = talk(ctx.app, user, text); | |
| 148 | if (!said.ok) return flashTo(ctx, `${HERE}`, { tone: "danger", message: TALK_WORDS[said.error][1] }); | |
| 149 | return flashTo(ctx, `${HERE}`, { tone: "success", message: "Sent. Reload to see the answer." }); | |
| 150 | } | |
| 151 | ||
| 152 | // POST /talk/confirm/:id — the person's yes or no, from their own session. | |
| 153 | export async function talkConfirm(ctx: Ctx): Promise<Response> { | |
| 154 | const user = ctx.auth.user; | |
| 155 | if (user === undefined) return signInFirst(ctx); | |
| 156 | if (ctx.auth.via !== "session") return new Response("forbidden", { status: 403 }); | |
| 157 | const form = await readForm(ctx); | |
| 158 | if (form instanceof Response) return form; | |
| 159 | const answer = form.get("answer"); | |
| 160 | if (answer !== "yes" && answer !== "no") return new Response("bad request", { status: 400 }); | |
| 161 | const asJson = (ctx.request.headers.get("accept") ?? "").includes("application/json"); | |
| 162 | const id = ctx.params["id"] ?? ""; | |
| 163 | let refused: string | undefined; | |
| 164 | let status = 422; | |
| 165 | if (answer === "no") { | |
| 166 | const no = sayNo(ctx, user, id); | |
| 167 | if (!no.ok) [status, refused] = REFUSED[no.error]; | |
| 168 | } else if (waitingConfirms(ctx, user).some((w) => w.id === id && ASKING[w.tool]?.once !== undefined)) { | |
| 169 | // A result shown once is drawn by Settings → Confirms; here it would be thrown away. | |
| 170 | status = 409; | |
| 171 | refused = "this result is shown once: confirm it in Settings → Confirms"; | |
| 172 | } else { | |
| 173 | const yes = await sayYes(ctx, user, id); | |
| 174 | if (!yes.ok) [status, refused] = REFUSED[yes.error]; | |
| 175 | else if (!yes.done) refused = `${String(yes.turn["tool"])} did not go through: ${String(yes.turn["message"] ?? yes.turn["error"])}`; | |
| 176 | } | |
| 177 | if (asJson) return json(refused === undefined ? { ok: true } : { ok: false, message: refused }, refused === undefined ? 200 : status); | |
| 178 | return flashTo(ctx, `${HERE}`, refused === undefined ? { tone: "success", message: answer === "yes" ? "Confirmed." : "Cancelled." } : { tone: "danger", message: `Nothing was done: ${refused}` }); | |
| 179 | } | |
| 180 | ||
| 181 | // GET /talk/stream?after=<turn id> — turns as they are written, ended with the turn. | |
| 182 | export function talkStream(ctx: Ctx): Response { | |
| 183 | const user = ctx.auth.user; | |
| 184 | if (user === undefined) return new Response("sign in", { status: 401 }); | |
| 185 | if ((open.get(user.id) ?? 0) >= STREAMS_MAX) return new Response("too many streams", { status: 429, headers: { "retry-after": "5" } }); | |
| 186 | const after = ctx.url.searchParams.get("after") ?? ctx.request.headers.get("last-event-id") ?? ""; | |
| 187 | if (!TURN_ID.test(after)) return new Response("bad turn id", { status: 400 }); | |
| 188 | const { app } = ctx; | |
| 189 | const conversation = openConversation(app.db, user.id); | |
| 190 | let cursor = after; | |
| 191 | return eventStream(app, ctx.request, (emit, close) => { | |
| 192 | const started = Date.now(); | |
| 193 | let told = false; | |
| 194 | const tick = (): void => { | |
| 195 | // Asked before the read: a turn that ends after this look is read whole next time. | |
| 196 | const running = clock.running(app, user.id); | |
| 197 | const fresh = turnsAfter(app.db, user.id, conversation.id, cursor); | |
| 198 | if (fresh.length > 0) { | |
| 199 | const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id)); | |
| 200 | for (const turn of fresh) { | |
| 201 | emit("turn", { id: turn.id, html: turnHtml(turn, waiting).text }, turn.id); | |
| 202 | cursor = turn.id; | |
| 203 | } | |
| 204 | } | |
| 205 | if (fresh.length >= MAX_PAGE) return; | |
| 206 | if (!running) { | |
| 207 | emit("done", {}); | |
| 208 | close(); | |
| 209 | } else if (Date.now() - started >= clock.streamMs) { | |
| 210 | emit("more", {}); | |
| 211 | close(); | |
| 212 | } else if (!told) { | |
| 213 | told = true; | |
| 214 | emit("running", {}); | |
| 215 | } | |
| 216 | }; | |
| 217 | open.set(user.id, (open.get(user.id) ?? 0) + 1); | |
| 218 | const timer = setInterval(tick, POLL_MS); | |
| 219 | queueMicrotask(tick); | |
| 220 | return () => { | |
| 221 | clearInterval(timer); | |
| 222 | const left = (open.get(user.id) ?? 1) - 1; | |
| 223 | if (left <= 0) open.delete(user.id); | |
| 224 | else open.set(user.id, left); | |
| 225 | }; | |
| 226 | }); | |
| 227 | } |
src/web/pages/talk_client.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * The /talk page's own stylesheet and script (#7652), kept apart from the | |
| 3 | * page so each is one idea. The script is static code, never built from | |
| 4 | * user text; every turn it draws arrives as markup the server escaped. | |
| 5 | */ | |
| 6 | ||
| 7 | export const talkCss = /* css */ ` | |
| 8 | .talk { display: grid; grid-template-rows: auto 1fr auto; gap: 0.75rem; max-width: min(48rem, 100%); margin: 0 auto; min-width: 0; } | |
| 9 | .talk-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.625rem; min-width: 0; align-content: start; min-height: 40dvh; } | |
| 10 | .talk-turn { min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; } | |
| 11 | .talk-turn p { margin: 0 0 8px; } | |
| 12 | .talk-turn p:last-child { margin-bottom: 0; } | |
| 13 | .talk-turn pre { max-width: 100%; overflow-x: auto; } | |
| 14 | .talk-person { justify-self: end; max-width: 85%; background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.5rem 0.75rem; white-space: pre-wrap; } | |
| 15 | .talk-agent { justify-self: start; max-width: 100%; padding: 4px 0; } | |
| 16 | .talk-tool { color: var(--muted); font-size: var(--fs-min); padding: 0 4px; } | |
| 17 | .talk-confirm { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.75rem; background: var(--card); } | |
| 18 | .talk-confirm .bar { margin-top: 8px; gap: 8px; } | |
| 19 | .talk-confirm .btn { min-height: 2.75rem; flex: 1 1 8rem; } | |
| 20 | .talk-compose { position: sticky; bottom: 0; background: var(--bg); padding: 0.5rem 0 max(0.5rem, env(safe-area-inset-bottom)); border-top: 1px solid var(--border); z-index: 8; } | |
| 21 | .talk-compose textarea { width: 100%; min-height: 56px; max-height: 40dvh; font-size: 16px; resize: vertical; box-sizing: border-box; } | |
| 22 | .talk-compose .bar { margin-top: 6px; gap: 8px; align-items: center; } | |
| 23 | .talk-compose .btn { min-height: 2.75rem; } | |
| 24 | .talk-status { min-height: 1.5em; margin: 0; } | |
| 25 | .talk-more { text-align: center; } | |
| 26 | `; | |
| 27 | ||
| 28 | /** Runs after DOMContentLoaded (`pageScriptAsset`). Reads the page's own `data-talk-*`; sends with fetch, follows with EventSource. */ | |
| 29 | export const talkJs = ` | |
| 30 | const root = document.querySelector("[data-talk]"); | |
| 31 | if (root) { | |
| 32 | const log = root.querySelector(".talk-log"); | |
| 33 | const form = root.querySelector("form.talk-compose"); | |
| 34 | const box = form.querySelector("textarea"); | |
| 35 | const status = root.querySelector(".talk-status"); | |
| 36 | let last = log.dataset.last || ""; | |
| 37 | let source = null; | |
| 38 | let retry = 0; | |
| 39 | const paged = root.dataset.paged === "1"; | |
| 40 | const say = (words) => { status.textContent = words; }; | |
| 41 | const toEnd = () => { const end = document.getElementById("end"); if (end) end.scrollIntoView({ block: "end" }); }; | |
| 42 | const follow = () => { | |
| 43 | if (paged) return; | |
| 44 | if (source) source.close(); | |
| 45 | source = new EventSource("/talk/stream?after=" + encodeURIComponent(last)); | |
| 46 | source.addEventListener("turn", (e) => { | |
| 47 | const turn = JSON.parse(e.data); | |
| 48 | last = turn.id; | |
| 49 | if (!document.getElementById("t-" + turn.id)) log.insertAdjacentHTML("beforeend", turn.html); | |
| 50 | retry = 0; | |
| 51 | toEnd(); | |
| 52 | }); | |
| 53 | source.addEventListener("running", () => say("The forge is answering…")); | |
| 54 | source.addEventListener("done", () => { say(""); source.close(); source = null; }); | |
| 55 | source.addEventListener("more", () => { source.close(); follow(); }); | |
| 56 | source.addEventListener("drain", () => { source.close(); setTimeout(follow, 1000); }); | |
| 57 | source.onerror = () => { | |
| 58 | source.close(); | |
| 59 | source = null; | |
| 60 | if (++retry < 6) setTimeout(follow, Math.min(1000 * retry, 5000)); | |
| 61 | else say("Lost the connection. Reload to see the rest."); | |
| 62 | }; | |
| 63 | }; | |
| 64 | const send = async (e) => { | |
| 65 | e.preventDefault(); | |
| 66 | const text = box.value.trim(); | |
| 67 | if (!text) return; | |
| 68 | say("Sending…"); | |
| 69 | try { | |
| 70 | const res = await fetch("/api/v1/conversation/messages", { | |
| 71 | method: "POST", | |
| 72 | headers: { "content-type": "application/json" }, | |
| 73 | body: JSON.stringify({ text }), | |
| 74 | }); | |
| 75 | if (!res.ok) { | |
| 76 | const body = await res.json().catch(() => ({})); | |
| 77 | say(body.message || "That was not sent."); | |
| 78 | return; | |
| 79 | } | |
| 80 | box.value = ""; | |
| 81 | if (paged) { location.href = "/talk"; return; } | |
| 82 | follow(); | |
| 83 | } catch (_) { | |
| 84 | say("That was not sent: no connection."); | |
| 85 | } | |
| 86 | }; | |
| 87 | form.addEventListener("submit", send); | |
| 88 | box.addEventListener("keydown", (e) => { | |
| 89 | if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) form.requestSubmit(); | |
| 90 | }); | |
| 91 | log.addEventListener("submit", async (e) => { | |
| 92 | const f = e.target.closest("form[data-confirm]"); | |
| 93 | if (!f) return; | |
| 94 | e.preventDefault(); | |
| 95 | const answer = e.submitter ? e.submitter.value : "no"; | |
| 96 | const body = new URLSearchParams({ answer }); | |
| 97 | say(answer === "yes" ? "Confirming…" : "Cancelling…"); | |
| 98 | try { | |
| 99 | const res = await fetch(f.action, { method: "POST", headers: { accept: "application/json" }, body }); | |
| 100 | const out = await res.json().catch(() => ({})); | |
| 101 | say(res.ok ? "" : out.message || "Nothing was done."); | |
| 102 | f.replaceWith(Object.assign(document.createElement("p"), { className: "muted small", textContent: res.ok ? (answer === "yes" ? "Confirmed." : "Cancelled.") : "Not done." })); | |
| 103 | follow(); | |
| 104 | } catch (_) { | |
| 105 | say("That was not sent: no connection."); | |
| 106 | } | |
| 107 | }); | |
| 108 | toEnd(); | |
| 109 | if (root.dataset.running === "1" && !paged) follow(); | |
| 110 | } | |
| 111 | `; |
src/web/studio/living.ts +1
| @@ -112,6 +112,7 @@ export function tellBox(ctx: Ctx, user: User): Html { | ||
| 112 | 112 | <option value="${FORGE_TARGET}">the forge</option> |
| 113 | 113 | </select>`} |
| 114 | 114 | ${btn("Tell", { variant: "primary" })} |
| 115 | ${btn("Talk", { href: "/talk", icon: "comment", title: "Talk to the forge" })} | |
| 115 | 116 | </form>`; |
| 116 | 117 | } |
| 117 | 118 |
test/conversation-confirm.test.ts +32
| @@ -416,6 +416,38 @@ describe("the yes, reached by a person", () => { | ||
| 416 | 416 | expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull(); |
| 417 | 417 | }); |
| 418 | 418 | |
| 419 | test("the terminal's Confirms: n, then Cancel it, spends the confirm and runs nothing", async () => { | |
| 420 | const w = await world(alice); | |
| 421 | const asked = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text); | |
| 422 | const terminal = headlessTerminal(160, 32); | |
| 423 | const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }, { terminal, exitHooks: false }); | |
| 424 | tui.start(); | |
| 425 | const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 426 | try { | |
| 427 | key("k", true); | |
| 428 | await settleApp(tui); | |
| 429 | for (const ch of "Confirms") key(ch); | |
| 430 | await settleApp(tui); | |
| 431 | key("enter"); | |
| 432 | await settleApp(tui); | |
| 433 | for (const h of waitingFor(app.db, alice.id)) if (h.id !== asked.needs_confirm) key("j"); | |
| 434 | await settleApp(tui); | |
| 435 | key("n"); | |
| 436 | await settleApp(tui); | |
| 437 | expect(tui.screen.toText()).toContain("Cancel this"); | |
| 438 | key("right"); | |
| 439 | key("enter"); | |
| 440 | await settleApp(tui); | |
| 441 | expect(tui.screen.toText()).toContain("Cancelled: archive."); | |
| 442 | expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull(); | |
| 443 | expect(waitingFor(app.db, alice.id).some((h) => h.id === asked.needs_confirm)).toBe(false); | |
| 444 | const turns = listTurns(app.db, alice.id, openConversation(app.db, alice.id).id, { limit: 200 }); | |
| 445 | expect(JSON.stringify(turns)).toContain("cancelled"); | |
| 446 | } finally { | |
| 447 | tui.stop(); | |
| 448 | } | |
| 449 | }); | |
| 450 | ||
| 419 | 451 | test("the terminal's Confirms: y, then Yes, does it once", async () => { |
| 420 | 452 | const w = await world(alice); |
| 421 | 453 | const asked = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text); |
test/danger-buttons.test.ts +1 −2
| @@ -11,11 +11,10 @@ const web = join(root, "src", "web"); | ||
| 11 | 11 | |
| 12 | 12 | /** Today's danger POST buttons that do not ask first, one `file — reason` per line. */ |
| 13 | 13 | const allowed: readonly { file: string; button: string; reason: string }[] = [ |
| 14 | { file: "src/web/pages/boards-view.ts", button: `"Delete this board"`, reason: "board delete; waits for its confirm issue" }, | |
| 15 | { file: "src/web/pages/org.ts", button: `"Remove"`, reason: "team member remove; waits for its confirm issue" }, | |
| 16 | 14 | { file: "src/web/pages/edit.ts", button: `"Delete this file"`, reason: "the delete page is the confirmation, reached by a link" }, |
| 17 | 15 | { file: "src/web/pages/pull.ts", button: `"Land past the gate"`, reason: "asks for a typed reason of at least 8 characters" }, |
| 18 | 16 | { file: "src/web/studio/notes.ts", button: "label", reason: "owner note ban; undone by unban on the same row" }, |
| 17 | { file: "src/web/studio/change.ts", button: `"Send it back"`, reason: "a changes-requested review with an optional why; the next review replaces it" }, | |
| 19 | 18 | ]; |
| 20 | 19 | |
| 21 | 20 | type DangerPost = { file: string; line: number; button: string }; |
test/design-audit.test.ts +1 −1
| @@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | const NOT_PAGES = |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/no-polling.test.ts +1
| @@ -10,6 +10,7 @@ import { Glob } from "bun"; | ||
| 10 | 10 | /** file -> the patterns it may carry, and why */ |
| 11 | 11 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| 12 | 12 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 13 | "src/web/pages/talk.ts": { allow: ["setInterval"], why: "server side: the stream of one turn watches the run's row, which has no event to hear" }, | |
| 13 | 14 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 14 | 15 | }; |
| 15 | 16 |
test/talk-page.test.ts added +312
| @@ -0,0 +1,312 @@ | ||
| 1 | /** | |
| 2 | * /talk (#7652): the page renders the person's own history and a composer, | |
| 3 | * a reply streams over SSE and ends with the turn, resuming from a turn id; | |
| 4 | * a held action draws Confirm and Cancel that post the person's yes or no; | |
| 5 | * signed out goes to /signin; Talk is in the chrome, the palette and home; | |
| 6 | * a stream is capped per person; a once-result is never confirmed here. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { sayNo } from "../src/actions/conversation_confirms.ts"; | |
| 15 | import { clock, STREAMS_MAX } from "../src/web/pages/talk.ts"; | |
| 16 | import { appendTurn, listTurns, openConversation } from "../src/core/conversations.ts"; | |
| 17 | import { hold, waitingFor } from "../src/core/fence.ts"; | |
| 18 | import { getRepoById } from "../src/core/repos.ts"; | |
| 19 | import { createSession } from "../src/core/sessions.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | ||
| 33 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; | |
| 34 | const get = (path: string, u?: User, headers: Record<string, string> = {}) => | |
| 35 | fetch(`${servers.url}${path}`, { redirect: "manual", headers: { ...(u ? { cookie: cookie(u) } : {}), ...headers } }); | |
| 36 | const post = (path: string, u: User, form: Record<string, string>, headers: Record<string, string> = {}) => | |
| 37 | fetch(`${servers.url}${path}`, { | |
| 38 | method: "POST", | |
| 39 | redirect: "manual", | |
| 40 | headers: { origin: servers.url, cookie: cookie(u), ...headers }, | |
| 41 | body: new URLSearchParams(form), | |
| 42 | }); | |
| 43 | const say = (u: User, role: string, text: string) => | |
| 44 | appendTurn(app.db, u.id, openConversation(app.db, u.id).id, { role, text }); | |
| 45 | ||
| 46 | /** The frames of a stream that ended on its own. */ | |
| 47 | async function frames(path: string, u: User, headers: Record<string, string> = {}) { | |
| 48 | const res = await get(path, u, headers); | |
| 49 | const text = await res.text(); | |
| 50 | return text | |
| 51 | .split("\n\n") | |
| 52 | .map((f) => ({ | |
| 53 | event: /^event: (.*)$/m.exec(f)?.[1], | |
| 54 | id: /^id: (.*)$/m.exec(f)?.[1], | |
| 55 | data: /^data: (.*)$/m.exec(f)?.[1], | |
| 56 | })) | |
| 57 | .filter((f) => f.event !== undefined); | |
| 58 | } | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-talk-page-")); | |
| 62 | app = createApp({ dataDir: dir, port: 0 }); | |
| 63 | servers = await serve(app); | |
| 64 | const mk = async (username: string, first = false) => { | |
| 65 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 66 | if (!u.ok) throw new Error(u.error); | |
| 67 | promote(app.db, u.value); | |
| 68 | return u.value; | |
| 69 | }; | |
| 70 | alice = await mk("alice", true); | |
| 71 | bob = await mk("bob"); | |
| 72 | }, 30_000); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | await rm(dir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | describe("the page", () => { | |
| 81 | test("signed out goes to /signin and comes back", async () => { | |
| 82 | const res = await get("/talk"); | |
| 83 | expect(res.status).toBe(303); | |
| 84 | expect(res.headers.get("location")).toStartWith("/signin?next=%2Ftalk"); | |
| 85 | expect((await get("/talk/stream")).status).toBe(401); | |
| 86 | }); | |
| 87 | ||
| 88 | test("renders your history escaped, a composer, and keeps the log in order", async () => { | |
| 89 | say(alice, "person", "hello <b>there</b>"); | |
| 90 | say(alice, "agent", "Hi **alice**"); | |
| 91 | const res = await get("/talk", alice); | |
| 92 | expect(res.status).toBe(200); | |
| 93 | const page = await res.text(); | |
| 94 | expect(page).toContain("hello <b>there</b>"); | |
| 95 | expect(page).not.toContain("<b>there</b>"); | |
| 96 | expect(page).toContain("<strong>alice</strong>"); | |
| 97 | expect(page).toContain('action="/talk"'); | |
| 98 | expect(page).toContain('name="text"'); | |
| 99 | expect(page.indexOf("hello")).toBeLessThan(page.indexOf("Hi ")); | |
| 100 | expect(res.headers.get("content-security-policy")).toBeTruthy(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("each person has their own conversation", async () => { | |
| 104 | say(alice, "person", "alice-only secret"); | |
| 105 | const bobs = await (await get("/talk", bob)).text(); | |
| 106 | expect(bobs).not.toContain("alice-only secret"); | |
| 107 | expect(bobs).toContain("Ask the forge anything"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a message with no agent running says so and is not kept", async () => { | |
| 111 | const res = await post("/talk", bob, { text: "do a thing" }); | |
| 112 | expect(res.status).toBe(303); | |
| 113 | const after = await (await get("/talk", bob)).text(); | |
| 114 | expect(after).not.toContain("do a thing"); | |
| 115 | expect((await post("/talk", bob, { text: "x" }, { origin: "https://elsewhere.example" })).status).toBe(403); | |
| 116 | expect((await post("/talk", bob, { text: " " })).status).toBe(303); | |
| 117 | }); | |
| 118 | ||
| 119 | test("Talk is one tap away: the chrome, the palette and home", async () => { | |
| 120 | const home = await (await get("/explore", alice)).text(); | |
| 121 | expect(home).toContain('href="/talk"'); | |
| 122 | expect(home).toContain('aria-label="Talk to the forge"'); | |
| 123 | expect(home).toContain('"h":"/talk"'); | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("the stream", () => { | |
| 128 | test("replays turns after an id, then ends with the turn", async () => { | |
| 129 | const a = say(bob, "person", "first"); | |
| 130 | const b = say(bob, "agent", "second"); | |
| 131 | if (!a.ok || !b.ok) throw new Error("turns"); | |
| 132 | const all = await frames("/talk/stream?after=", bob); | |
| 133 | expect(all.map((f) => f.event)).toEqual(["turn", "turn", "done"]); | |
| 134 | expect(all[0]!.id).toBe(a.value.id); | |
| 135 | expect(JSON.parse(all[1]!.data!).html).toContain("second"); | |
| 136 | const rest = await frames(`/talk/stream?after=${a.value.id}`, bob); | |
| 137 | expect(rest.map((f) => f.id ?? f.event)).toEqual([b.value.id, "done"]); | |
| 138 | // A reconnect that names the last turn it saw gets only what is new. | |
| 139 | const resumed = await frames("/talk/stream", bob, { "last-event-id": b.value.id }); | |
| 140 | expect(resumed.map((f) => f.event)).toEqual(["done"]); | |
| 141 | }); | |
| 142 | ||
| 143 | test("never shows another person's turns, and refuses a bad id", async () => { | |
| 144 | const mine = say(bob, "agent", "bob private"); | |
| 145 | if (!mine.ok) throw new Error("turn"); | |
| 146 | const theirs = await frames(`/talk/stream?after=${mine.value.id}`, alice); | |
| 147 | expect(theirs.map((f) => f.event)).toEqual(["done"]); | |
| 148 | const seen = await frames("/talk/stream?after=", alice); | |
| 149 | expect(JSON.stringify(seen)).not.toContain("bob private"); | |
| 150 | expect((await get("/talk/stream?after=%3Cscript%3E", alice)).status).toBe(400); | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | describe("confirm and cancel", () => { | |
| 155 | async function held(u: User) { | |
| 156 | const made = await createRepo(app, u, { name: `r${Math.random().toString(36).slice(2, 8)}`, visibility: "public" }); | |
| 157 | if (!made.ok) throw new Error(String(made.error)); | |
| 158 | const id = hold(app.db, "conversation:talk", u.id, "archive", { repo: `${u.username}/${made.value.name}`, archived: true, dryRun: false }); | |
| 159 | if (id === null) throw new Error("hold"); | |
| 160 | say(u, "confirm", JSON.stringify({ confirm: id, tool: "archive", does: "This will archive the app." })); | |
| 161 | return { id, repoId: made.value.id }; | |
| 162 | } | |
| 163 | ||
| 164 | test("a waiting confirm draws Confirm and Cancel; Cancel does nothing and cannot be undone by a yes", async () => { | |
| 165 | const h = await held(alice); | |
| 166 | const page = await (await get("/talk", alice)).text(); | |
| 167 | expect(page).toContain("This will archive the app."); | |
| 168 | expect(page).toContain(`action="/talk/confirm/${h.id}"`); | |
| 169 | expect(page).toContain("Confirm"); | |
| 170 | expect(page).toContain("Cancel"); | |
| 171 | const no = await post(`/talk/confirm/${h.id}`, alice, { answer: "no" }, { accept: "application/json" }); | |
| 172 | expect(no.status).toBe(200); | |
| 173 | expect(getRepoById(app.db, h.repoId)!.archivedAt).toBeNull(); | |
| 174 | const yes = await post(`/talk/confirm/${h.id}`, alice, { answer: "yes" }, { accept: "application/json" }); | |
| 175 | expect(yes.status).toBe(409); | |
| 176 | expect(getRepoById(app.db, h.repoId)!.archivedAt).toBeNull(); | |
| 177 | const after = await (await get("/talk", alice)).text(); | |
| 178 | expect(after).toContain("Cancelled: archive."); | |
| 179 | expect(after).not.toContain(`action="/talk/confirm/${h.id}"`); | |
| 180 | }); | |
| 181 | ||
| 182 | test("Confirm does the stored call once", async () => { | |
| 183 | const h = await held(alice); | |
| 184 | const yes = await post(`/talk/confirm/${h.id}`, alice, { answer: "yes" }, { accept: "application/json" }); | |
| 185 | expect(yes.status).toBe(200); | |
| 186 | expect(getRepoById(app.db, h.repoId)!.archivedAt).not.toBeNull(); | |
| 187 | expect((await post(`/talk/confirm/${h.id}`, alice, { answer: "yes" }, { accept: "application/json" })).status).toBe(409); | |
| 188 | expect(await (await get("/talk", alice)).text()).toContain("Done: archive."); | |
| 189 | }); | |
| 190 | ||
| 191 | test("nobody else's yes or no counts, nor a cross-origin one, nor a bad answer", async () => { | |
| 192 | const h = await held(alice); | |
| 193 | expect((await post(`/talk/confirm/${h.id}`, bob, { answer: "yes" }, { accept: "application/json" })).status).toBe(404); | |
| 194 | expect((await post(`/talk/confirm/${h.id}`, bob, { answer: "no" }, { accept: "application/json" })).status).toBe(404); | |
| 195 | expect((await post(`/talk/confirm/${h.id}`, alice, { answer: "yes" }, { origin: "https://elsewhere.example" })).status).toBe(403); | |
| 196 | expect((await post(`/talk/confirm/${h.id}`, alice, { answer: "maybe" })).status).toBe(400); | |
| 197 | expect(getRepoById(app.db, h.repoId)!.archivedAt).toBeNull(); | |
| 198 | }); | |
| 199 | }); | |
| 200 | ||
| 201 | describe("a once-result", () => { | |
| 202 | test("a yes posted here is refused, runs nothing and keeps the confirm for Settings", async () => { | |
| 203 | const id = hold(app.db, "conversation:talk", alice.id, "mint_invite", {}); | |
| 204 | if (id === null) throw new Error("hold"); | |
| 205 | say(alice, "confirm", JSON.stringify({ confirm: id, tool: "mint_invite", does: "This will mint an invite." })); | |
| 206 | const page = await (await get("/talk", alice)).text(); | |
| 207 | expect(page).toContain(`action="/settings/confirms/${id}"`); | |
| 208 | const yes = await post(`/talk/confirm/${id}`, alice, { answer: "yes" }, { accept: "application/json" }); | |
| 209 | expect(yes.status).toBe(409); | |
| 210 | expect(((await yes.json()) as { message: string }).message).toContain("Settings"); | |
| 211 | expect(waitingFor(app.db, alice.id).some((h) => h.id === id)).toBe(true); | |
| 212 | // Cancel is still allowed here. | |
| 213 | expect((await post(`/talk/confirm/${id}`, alice, { answer: "no" }, { accept: "application/json" })).status).toBe(200); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("sayNo", () => { | |
| 218 | test("spends the confirm, writes a cancelled tool turn, and is not another person's to say", () => { | |
| 219 | const id = hold(app.db, "conversation:talk", bob.id, "archive", { repo: "bob/none", archived: true, dryRun: false }); | |
| 220 | if (id === null) throw new Error("hold"); | |
| 221 | expect(sayNo({ app }, alice, id)).toEqual({ ok: false, error: "not_found" }); | |
| 222 | const no = sayNo({ app }, bob, id); | |
| 223 | expect(no.ok).toBe(true); | |
| 224 | expect(sayNo({ app }, bob, id)).toEqual({ ok: false, error: "answered" }); | |
| 225 | const turns = listTurns(app.db, bob.id, openConversation(app.db, bob.id).id, { limit: 200 }); | |
| 226 | if (!turns.ok) throw new Error("turns"); | |
| 227 | const last = JSON.parse(turns.value.at(-1)!.text); | |
| 228 | expect(last).toMatchObject({ confirm: id, tool: "archive", done: false, cancelled: true }); | |
| 229 | expect(sayNo({ app }, bob, "../x")).toEqual({ ok: false, error: "not_found" }); | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 233 | describe("paging back", () => { | |
| 234 | test("?before= shows older turns, does not follow, and validates the id", async () => { | |
| 235 | const carol = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" }); | |
| 236 | if (!carol.ok) throw new Error("user"); | |
| 237 | promote(app.db, carol.value); | |
| 238 | let ids: string[] = []; | |
| 239 | for (let i = 0; i < 55; i++) { | |
| 240 | const t = say(carol.value, "person", `turn-${String(i).padStart(2, "0")}`); | |
| 241 | if (t.ok) ids.push(t.value.id); | |
| 242 | } | |
| 243 | const first = await (await get("/talk", carol.value)).text(); | |
| 244 | expect(first).toContain("turn-54"); | |
| 245 | expect(first).not.toContain("turn-04"); | |
| 246 | expect(first).toContain('data-paged="0"'); | |
| 247 | const link = /href="\/talk\?before=([0-9a-z]+)"/.exec(first); | |
| 248 | expect(link).not.toBeNull(); | |
| 249 | clock.running = () => true; | |
| 250 | try { | |
| 251 | const older = await (await get(`/talk?before=${link![1]}`, carol.value)).text(); | |
| 252 | expect(older).toContain("turn-04"); | |
| 253 | expect(older).not.toContain("turn-54"); | |
| 254 | expect(older).toContain('data-paged="1"'); | |
| 255 | expect(older).toContain('data-running="0"'); | |
| 256 | const latest = await (await get("/talk", carol.value)).text(); | |
| 257 | expect(latest).toContain('data-running="1"'); | |
| 258 | } finally { | |
| 259 | clock.running = (await import("../src/crew/conversation.ts")).turnRunning; | |
| 260 | } | |
| 261 | // A bad id is ignored, not echoed. | |
| 262 | const bad = await get("/talk?before=%3Cscript%3E", carol.value); | |
| 263 | expect(bad.status).toBe(200); | |
| 264 | expect(await bad.text()).not.toContain("<script>alert"); | |
| 265 | }); | |
| 266 | }); | |
| 267 | ||
| 268 | describe("a long stream", () => { | |
| 269 | test("while a turn runs it says running, then asks the client to reconnect", async () => { | |
| 270 | const { turnRunning } = await import("../src/crew/conversation.ts"); | |
| 271 | clock.running = () => true; | |
| 272 | clock.streamMs = 700; | |
| 273 | try { | |
| 274 | const seen = await frames("/talk/stream?after=", alice); | |
| 275 | const events = seen.map((f) => f.event); | |
| 276 | expect(events.at(-1)).toBe("more"); | |
| 277 | expect(events).toContain("running"); | |
| 278 | expect(events).not.toContain("done"); | |
| 279 | } finally { | |
| 280 | clock.running = turnRunning; | |
| 281 | clock.streamMs = 40_000; | |
| 282 | } | |
| 283 | }); | |
| 284 | ||
| 285 | test("a person holds at most STREAMS_MAX at once; the next waits, and a slot frees", async () => { | |
| 286 | const { turnRunning } = await import("../src/crew/conversation.ts"); | |
| 287 | clock.running = () => true; | |
| 288 | clock.streamMs = 60_000; | |
| 289 | const aborts = Array.from({ length: STREAMS_MAX }, () => new AbortController()); | |
| 290 | try { | |
| 291 | const open = await Promise.all(aborts.map((a) => fetch(`${servers.url}/talk/stream?after=`, { headers: { cookie: cookie(bob) }, signal: a.signal }))); | |
| 292 | expect(open.map((r) => r.status)).toEqual(Array(STREAMS_MAX).fill(200)); | |
| 293 | const extra = await get("/talk/stream?after=", bob); | |
| 294 | expect(extra.status).toBe(429); | |
| 295 | expect(extra.headers.get("retry-after")).toBeTruthy(); | |
| 296 | // Another person is not held back. | |
| 297 | const other = new AbortController(); | |
| 298 | expect((await fetch(`${servers.url}/talk/stream?after=`, { headers: { cookie: cookie(alice) }, signal: other.signal })).status).toBe(200); | |
| 299 | other.abort(); | |
| 300 | aborts[0]!.abort(); | |
| 301 | await new Promise((r) => setTimeout(r, 300)); | |
| 302 | const again = new AbortController(); | |
| 303 | expect((await fetch(`${servers.url}/talk/stream?after=`, { headers: { cookie: cookie(bob) }, signal: again.signal })).status).toBe(200); | |
| 304 | again.abort(); | |
| 305 | } finally { | |
| 306 | for (const a of aborts) a.abort(); | |
| 307 | clock.running = turnRunning; | |
| 308 | clock.streamMs = 40_000; | |
| 309 | await new Promise((r) => setTimeout(r, 300)); | |
| 310 | } | |
| 311 | }); | |
| 312 | }); |
new above
offline