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

Merged opened this

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

Merged

torque/forge:torque/7652-talk-one-conversation-with-the-forge-rea → main 10 commits fork

  • Merged. torque/7652-talk-one-conversation-with-the-forge-rea was merged into main as 4d772bd.

Review

Sign in to review this change.
19 files changed +785 −10
docs/CLI.md +11
@@ -329,6 +329,17 @@ $ forge tell "make the header smaller"
329329 told alice/shop: issue #12 https://forge.example.com/alice/shop/issues/12
330330 ```
331331
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
332343 ## forge new
333344
334345 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`,
6060 - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen.
6161 - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen.
6262 - `/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`.
6364 - `/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.
6465 - `/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.
6566 - `/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
99 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.
1010
1111 `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 @@
33 "api.anthropic.com"
44 ],
55 "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"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/actions/conversation_confirms.ts +15
@@ -92,3 +92,18 @@ export async function sayYes(ctx: Ctx, user: User, confirmId: string): Promise<Y
9292 appendTurn(ctx.app.db, user.id, conversation.id, { role: "tool", text: JSON.stringify(turn) });
9393 return { ok: true, done: ran.ok, turn, ...(ran.ok ? { result: ran.value } : {}) };
9494 }
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(
136136 return ok(rows.reverse().map(turnOf));
137137 }
138138
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
139155 /** What a Claude session id may look like: short, and only id characters. */
140156 export const SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
141157
src/core/reserved.ts +1
@@ -108,6 +108,7 @@ export const SYSTEM_NAMES: readonly string[] = [
108108 "releases.json",
109109 "sw.js",
110110 "tell",
111 "talk",
111112 "theme",
112113 "verify",
113114 "recover",
src/http/server.ts +5
@@ -136,6 +136,7 @@ import {
136136 themeSubmit,
137137 } from "../web/pages/site.ts";
138138 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
139 import { talkConfirm, talkSend, talkStream, talkView } from "../web/pages/talk.ts";
139140 import {
140141 passkeyAdd,
141142 passkeyDelete,
@@ -914,6 +915,10 @@ export function buildRouter(): Router<Handler> {
914915 router.add("POST", "/settings/tokens", tokenCreate);
915916 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
916917 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);
917922 router.add("GET", "/settings/confirms", confirmsList);
918923 router.add("POST", "/settings/confirms/:id", confirmYes);
919924 router.add("GET", "/settings/passkeys", passkeysPage);
src/tui/screens/confirms.ts +22 −5
@@ -5,10 +5,11 @@
55 * sentence, through the same action the web and the API call
66 * (`actions/conversation_confirms.ts`): it does the stored call once. A
77 * 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.
910 */
1011
11 import { REFUSED, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
12 import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
1213 import { ASKING } from "../../crew/conversation_confirm.ts";
1314 import type { Ctx, Screen } from "../app/app.ts";
1415 import { binding } from "../app/keys.ts";
@@ -28,7 +29,7 @@ export function confirmsScreen(f: Session, ctx: Ctx): Screen {
2829
2930 const screen: Screen = {
3031 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")],
3233 render(area, s) {
3334 const c = ctx.theme.c;
3435 const [head, body] = split(area, "v", [len(1), fill(1)]);
@@ -37,16 +38,32 @@ export function confirmsScreen(f: Session, ctx: Ctx): Screen {
3738 ? note
3839 : items().length === 0
3940 ? "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.";
4142 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
4243 drawList(s, body!, c, state, rows());
4344 },
4445 key(e) {
4546 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;
4748 const w = items()[state.index];
4849 const user = f.user;
4950 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 }
5067 ctx.push(
5168 confirmScreen(ctx, screen, {
5269 title: "Say yes",
src/web/layout.ts +5 −1
@@ -237,6 +237,7 @@ function userMenu(
237237 { label: "Your profile", href: `/${user.username}`, icon: "user" },
238238 { label: "Your repositories", href: "/", icon: "repo" },
239239 { label: "Apps", href: "/apps", icon: "rocket" },
240 { label: "Talk", href: "/talk", icon: "comment" },
240241 { label: "Explore", href: "/explore", icon: "search" },
241242 { label: "Map", href: "/map", icon: "map" },
242243 { separator: true, label: "" },
@@ -314,6 +315,7 @@ const STATIC_PALETTE: PaletteItem[] = [
314315 { t: "New repository", h: "/new", c: "Create" },
315316 { t: "Migrate a repository", h: "/migrate", c: "Create" },
316317 { t: "New organization", h: "/organizations/new", c: "Create" },
318 { t: "Talk to the forge", h: "/talk", c: "Go to" },
317319 { t: "Search", h: "/search", c: "Go to" },
318320 { t: "Account settings", h: "/settings", c: "Settings" },
319321 { t: "Access tokens", h: "/settings/tokens", c: "Settings" },
@@ -564,7 +566,9 @@ function topbar(
564566 <span class="topbar-tools">
565567 ${searchBar()}
566568 ${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
568572 >${newMenu(ctx)}${userMenu(
569573 ctx,
570574 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}#end`, { 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}#end`, { tone: "danger", message: TALK_WORDS[said.error][1] });
149 return flashTo(ctx, `${HERE}#end`, { 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}#end`, 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 {
112112 <option value="${FORGE_TARGET}">the forge</option>
113113 </select>`}
114114 ${btn("Tell", { variant: "primary" })}
115 ${btn("Talk", { href: "/talk", icon: "comment", title: "Talk to the forge" })}
115116 </form>`;
116117 }
117118
test/conversation-confirm.test.ts +32
@@ -416,6 +416,38 @@ describe("the yes, reached by a person", () => {
416416 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
417417 });
418418
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
419451 test("the terminal's Confirms: y, then Yes, does it once", async () => {
420452 const w = await world(alice);
421453 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");
1111
1212 /** Today's danger POST buttons that do not ask first, one `file — reason` per line. */
1313 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" },
1614 { file: "src/web/pages/edit.ts", button: `"Delete this file"`, reason: "the delete page is the confirmation, reached by a link" },
1715 { file: "src/web/pages/pull.ts", button: `"Land past the gate"`, reason: "asks for a typed reason of at least 8 characters" },
1816 { 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" },
1918 ];
2019
2120 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");
1111
1212 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1313 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$)/;
1515
1616 const getRoutes = [
1717 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/no-polling.test.ts +1
@@ -10,6 +10,7 @@ import { Glob } from "bun";
1010 /** file -> the patterns it may carry, and why */
1111 const ALLOWED: Record<string, { allow: string[]; why: string }> = {
1212 "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" },
1314 "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" },
1415 };
1516
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 &lt;b&gt;there&lt;/b&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs