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

/talk shows the agent's thinking collapsed, a steps line, and links to what it opened or changed #8845

Merged opened this

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

Merged

scout/forge:scout/8821-talk-shows-the-agent-s-thinking-collapse → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/8821-talk-shows-the-agent-s-thinking-collapse was merged into main as 37c7bdd.

Review

Sign in to review this change.
12 files changed +550 −12
docs/design/areas/talk.md +2
@@ -18,6 +18,8 @@
1818 - `.talk-copy` — a quiet text button that copies a message or a code block.
1919 - `.talk-suggest` — the starter questions on an empty chat; each fills the composer.
2020 - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end.
21 - `.talk-steps` — the quiet steps line under a reply ("read 3 files, opened #12").
22 - `.talk-links` — the links under a reply to what the turn opened or changed.
2123 - `.talk-replaced` — a turn the person edited, regenerated or retried past: kept in the log, dimmer, labelled "replaced".
2224 - `.talk-failed` — the plain-words line for a run that could not answer; Retry sits under it.
2325 - `.talk-acts` — the row under the last message and last reply holding Edit, Regenerate or Retry; server-drawn, so it works without the script.
docs/features/talk.md +4 −3
@@ -55,23 +55,24 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha
5555 | Tool calls as closed cards | this pull | AI Elements `Tool` |
5656 | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` |
5757 | Stick to bottom, "Latest" jump button | this pull | AI Elements `Conversation` |
58 | Thinking as one closed line; a steps line under a reply ("read 3 files, opened #12"); links to each issue, pull, file or app the turn opened or changed | this pull | AI Elements `Reasoning`, `ChainOfThought` |
5859 | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person |
5960 | Edit and resend, regenerate, Retry (see below) | have (#8817) | replaced turns are kept and marked, not deleted |
6061 | Queue | next pull | needs a queue on `crew/conversation.ts` |
6162 | Cost under each reply and how full the context is (header, and in the terminal) | this pull | the run's own usage record, never an estimate |
62 | Attachments, tables, thinking, plan line | later | need storage / token data / markdown tables |
63 | Attachments, tables, plan line | later | need storage / token data / markdown tables |
6364 | Code highlighting and tables in replies | this pull (#8816) | a reply is stored whole and drawn whole, so no half-open fence is ever tokenised |
6465
6566 **Without the script** the page is a plain form: Enter adds a line, starters are links that prefill the box, and Copy and Latest are not drawn (the script draws them, along with the "Enter sends" hint). The log is `role="log"`; with the script, a new message while you are scrolled up is announced in the status line.
6667
6768 **Cost and context.** Under a finished reply a quiet line gives the turn's dollars (`$0.12 this turn`); above the log, "Context 85% full (170k of 200k tokens)". Both are read from the run's record (`conversation_run`: `cost_usd`, and `context_tokens`/`context_window` from the last message's usage and the result's `modelUsage`), shown by `core/conversation_cost.ts`. When a run names no window, 200k is assumed and the line says so by showing it. From 80 % the line adds that a new conversation answers faster and costs less (information only: there is no way to start another yet, see "Many conversations"). A reply that streams in gets both when the stream ends; the terminal prints the last turn's cost and the share after its confirmation line.
6869
69 **The terminal** (`ssh <forge> talk <words>`, `forge talk`) sends one line and prints the answer; it has no log, so who/when, copy, tool cards, starters and the jump button do not apply to it. The conversation's whole history is read through `GET /api/v1/conversation/turns`, where every turn carries its role and time.
70 **The terminal** (`ssh <forge> talk <words>`, `forge talk`) sends one line and prints the answer; it has no log, so who/when, copy, tool cards, starters and the jump button do not apply to it. `forge talk` prints a thought as one `thinking:` line, then the reply, the steps line and the links as paths (the API's agent turns carry `work: {steps, links}`). The steps line says "opened" for a read issue or pull and "changed" for a tool that writes one (`CHANGES` in `core/talk_work.ts`, held to the MCP tools' `writes` by a test); numbers carry their repo when a turn touched more than one. A reply first on an older page, or first in a stream batch, reads its calls from the turns before the page (`worksOf`), so it says the same on every door. The conversation's whole history is read through `GET /api/v1/conversation/turns`, where every turn carries its role and time.
7071
7172 ## Code, tables and Copy in a reply (#8816)
7273
7374 An agent reply is Markdown, drawn on the server by the same renderer as a README: fenced code is highlighted, tables and links render, and everything else is escaped (a `<script>` in a reply shows as text). A table wider than the column scrolls inside it; the page does not. The reply sits in the column like a person's turn, with none of the README's padding or line measure.
7475
75 A reply arrives as one finished turn, never as growing text, and the page only appends a turn whose id it has not drawn (a repeat is ignored). Turns already on the page are not touched, so nothing above a new reply moves, and the page follows the end only if you were already there (otherwise "Latest" appears). An unfinished fence in a stored reply is closed for drawing. The page's own bytes are held by a test: the stylesheet under 3.5 KB, the script under 6.5 KB, and a page with a code reply under 40 KB.
76 A reply arrives as one finished turn, never as growing text, and the page only appends a turn whose id it has not drawn (a repeat is ignored). Turns already on the page are not touched, so nothing above a new reply moves, and the page follows the end only if you were already there (otherwise "Latest" appears). An unfinished fence in a stored reply is closed for drawing. The page's own bytes are held by a test: the stylesheet under 3.5 KB, the script under 7.4 KB, and a page with a code reply under 40 KB.
7677
7778 **Copy:** each code block and each message has a Copy button, which copies the text, not the markup. The buttons are drawn by the script: with JavaScript off there are none, and the code is still there to select.
src/cli/api.ts +1 −1
@@ -473,7 +473,7 @@ export class ForgeApi {
473473 }
474474
475475 /** The latest turns of the person's conversation (`GET /api/v1/conversation/turns`). */
476 async conversation(): Promise<ApiResult<{ running: boolean; turns: { id: string; role: string; text: string }[] }>> {
476 async conversation(): Promise<ApiResult<{ running: boolean; turns: { id: string; role: string; text: string; work?: { steps: string; links: { href: string; label: string }[] } | null }[] }>> {
477477 return this.call("/api/v1/conversation/turns?limit=20");
478478 }
479479
src/cli/commands/talk.ts +16 −1
@@ -1,4 +1,5 @@
11 import { WORDS_MAX } from "../../core/fence.ts";
2 import { thinkingOf } from "../../core/talk_work.ts";
23 import { ForgeApi } from "../api.ts";
34 import type { Args } from "../args.ts";
45 import { requireConfig } from "../context.ts";
@@ -9,6 +10,12 @@ import type { Command } from "./types.ts";
910 /** Seconds `forge talk` waits for the answer before it says where to read it. */
1011 const WAIT_S = 120;
1112
13 /** A thought on one line: its first words, ended with an ellipsis when cut. */
14 const oneLine = (text: string): string => {
15 const flat = text.replace(/\s+/g, " ").trim();
16 return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat;
17 };
18
1219 /**
1320 * `forge talk "words"`: say something to the forge's own agent, as you, and
1421 * print what it answers. The agent works through the forge's MCP with your
@@ -41,7 +48,15 @@ async function talk(out: Out, args: Args): Promise<number> {
4148 for (const turn of now.value.turns)
4249 if (!seen.has(turn.id)) {
4350 seen.add(turn.id);
44 if (turn.role === "agent") out.line(inert(turn.text));
51 // A thought is one folded line; the steps and links follow the reply, as on the page.
52 const thought = thinkingOf(turn);
53 if (thought !== undefined) out.line(inert(`thinking: ${oneLine(thought)}`));
54 if (turn.role !== "agent") continue;
55 out.line(inert(turn.text));
56 if (turn.work) {
57 out.line(inert(turn.work.steps));
58 for (const link of turn.work.links) out.line(inert(link.href));
59 }
4560 }
4661 if (!now.value.running) return EXIT.ok;
4762 }
src/core/talk_work.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * What a /talk turn did, read from the calls it stored (#8821): one quiet
3 * steps line ("read 3 files, opened #12") and the links to what it opened or
4 * changed. Plain data in, plain data out: the calls are tool turns written as
5 * `<tool> <json args>` (`crew/conversation.ts`), and only a well-formed
6 * repo, number and path ever becomes a link. Both doors read it: the web page
7 * draws it, and `GET /api/v1/conversation/turns` carries it for the terminal.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { getIssue } from "./issues.ts";
12 import { listTurns } from "./conversations.ts";
13 import { getRepo, type Repo } from "./repos.ts";
14
15 /** The part of a turn the reading needs: its role and its stored text. */
16 export type Said = { readonly role: string; readonly text: string };
17
18 export interface Link {
19 readonly href: string;
20 readonly label: string;
21 }
22
23 /**
24 * What the links need to know about the forge: a file link needs the default
25 * branch the blob page resolves, and a number link needs to know whether it is
26 * an issue or a pull (each answers only at its own path). Undefined is unknown.
27 */
28 export interface Lookup {
29 readonly branchOf: (repo: string) => string | undefined;
30 readonly kindOf: (repo: string, n: number) => "issue" | "pull" | undefined;
31 }
32
33 const NOTHING: Lookup = { branchOf: () => undefined, kindOf: () => undefined };
34
35 /** A thought as the agent's `thinking` turn stores it (`crew/conversation.ts`), else undefined. */
36 export function thinkingOf(turn: Said): string | undefined {
37 if (turn.role !== "tool" || !turn.text.startsWith('{"thinking"')) return undefined;
38 try {
39 const data: unknown = JSON.parse(turn.text);
40 const thought = typeof data === "object" && data !== null ? (data as Record<string, unknown>)["thinking"] : undefined;
41 return typeof thought === "string" ? thought : undefined;
42 } catch {
43 return undefined;
44 }
45 }
46
47 /** The lookup read from the database, each answer kept for the request, so a long chat asks once per repo and number. */
48 export function lookupFrom(db: Database): Lookup {
49 const repos = new Map<string, Repo | undefined>();
50 const kinds = new Map<string, "issue" | "pull" | undefined>();
51 const repoOf = (repo: string): Repo | undefined => {
52 if (!repos.has(repo)) {
53 const [owner, name] = repo.split("/");
54 repos.set(repo, owner === undefined || name === undefined ? undefined : getRepo(db, owner, name));
55 }
56 return repos.get(repo);
57 };
58 return {
59 branchOf: (repo) => repoOf(repo)?.defaultBranch,
60 kindOf: (repo, n) => {
61 const key = `${repo}#${String(n)}`;
62 if (!kinds.has(key)) {
63 const found = repoOf(repo);
64 kinds.set(key, found === undefined ? undefined : getIssue(db, found.id, n)?.kind);
65 }
66 return kinds.get(key);
67 },
68 };
69 }
70
71 export interface Work {
72 readonly steps: string;
73 readonly links: readonly Link[];
74 }
75
76 /** The most links drawn under one reply. */
77 export const LINKS_MAX = 8;
78 const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
79 const PREFIX = /^mcp__[a-z0-9-]+__/;
80 /** Tools that read one issue or pull: the line says "opened". */
81 const OPENS = new Set(["issue", "pull"]);
82 /** Tools that change one issue or pull by `repo` and `number` (`writes: true`, held by a test): the line says "changed". */
83 export const CHANGES = new Set(["assign", "close", "edit", "issue_teams", "keep_plan", "labels", "land_past_gate", "merge", "review", "say", "seal", "take_back", "threads", "variant"]);
84 /** Tools that act on a pull; the rest of the number tools name an issue unless the forge says otherwise. */
85 const PULL_TOOLS = new Set(["pull", "merge", "review", "land_past_gate"]);
86 const APPS = new Set(["new_app", "app_deploy", "make_it_yours", "fork", "variant"]);
87
88 interface Call {
89 readonly tool: string;
90 readonly args: Record<string, unknown>;
91 }
92
93 function callOf(turn: Said): Call | undefined {
94 if (turn.role !== "tool" || turn.text.startsWith("{")) return undefined;
95 const space = turn.text.indexOf(" ");
96 const tool = (space < 0 ? turn.text : turn.text.slice(0, space)).replace(PREFIX, "");
97 if (!/^[a-z0-9_]{1,60}$/.test(tool)) return undefined;
98 try {
99 const args: unknown = space < 0 ? {} : JSON.parse(turn.text.slice(space + 1));
100 return { tool, args: typeof args === "object" && args !== null && !Array.isArray(args) ? (args as Record<string, unknown>) : {} };
101 } catch {
102 // A call cut at the stored length still counts as a step, with no link.
103 return { tool, args: {} };
104 }
105 }
106
107 const str = (v: unknown): string => (typeof v === "string" ? v : "");
108 const plural = (n: number, one: string, many: string): string => `${String(n)} ${n === 1 ? one : many}`;
109
110 /** The steps line and links for the tool turns that came before one reply; undefined when there were none. */
111 export function workOf(turns: readonly Said[], look: Lookup = NOTHING): Work | undefined {
112 const calls = turns.map(callOf).filter((c): c is Call => c !== undefined);
113 if (calls.length === 0) return undefined;
114 const links = new Map<string, string>();
115 const read = new Set<string>();
116 const wrote = new Set<string>();
117 const opened = new Map<string, { repo: string; n: number }>();
118 const changed = new Map<string, { repo: string; n: number }>();
119 const other = new Map<string, number>();
120 const another = (tool: string): void => void other.set(tool, (other.get(tool) ?? 0) + 1);
121 calls.forEach(({ tool, args }, i) => {
122 const repo = str(args["repo"]);
123 const good = REPO.test(repo);
124 const n = args["number"];
125 const path = str(args["path"]);
126 const safePath = path !== "" && path.length <= 1024 && !path.split("/").some((s) => s === ".." || s === "");
127 const file = good && safePath;
128 if (tool === "read_file" || tool === "write_file") {
129 // A malformed call still counts as one file, with no link.
130 (tool === "read_file" ? read : wrote).add(file ? `${repo}:${path}` : `?${String(i)}`);
131 const ref = file ? str(args[tool === "read_file" ? "ref" : "branch"]) || look.branchOf(repo) : undefined;
132 if (ref !== undefined) links.set(blob(repo, ref, path), `${repo}/${path}`);
133 } else if ((OPENS.has(tool) || CHANGES.has(tool)) && good && typeof n === "number" && Number.isInteger(n) && n > 0) {
134 const kind = look.kindOf(repo, n) ?? (PULL_TOOLS.has(tool) ? "pull" : "issue");
135 (OPENS.has(tool) ? opened : changed).set(`${repo}#${String(n)}`, { repo, n });
136 links.set(`/${repo}/${kind}s/${String(n)}`, `${repo} #${String(n)}`);
137 } else if (APPS.has(tool) && good) {
138 another(tool);
139 links.set(`/${repo}`, repo);
140 } else another(tool);
141 });
142 // A number alone, unless the turn touched more than one repo: then each says whose.
143 const repos = new Set([...opened.values(), ...changed.values()].map((r) => r.repo));
144 const named = (refs: Map<string, { repo: string; n: number }>): string =>
145 [...refs.values()].map(({ repo, n }) => `${repos.size > 1 ? repo : ""}#${String(n)}`).join(", ");
146 const parts: string[] = [];
147 if (read.size > 0) parts.push(`read ${plural(read.size, "file", "files")}`);
148 if (wrote.size > 0) parts.push(`changed ${plural(wrote.size, "file", "files")}`);
149 if (opened.size > 0) parts.push(`opened ${named(opened)}`);
150 if (changed.size > 0) parts.push(`changed ${named(changed)}`);
151 const rest = [...other];
152 // One kind of other call is named; several are counted.
153 if (rest.length === 1) parts.push(`${rest[0]![0].replaceAll("_", " ")}${rest[0]![1] > 1 ? ` ×${String(rest[0]![1])}` : ""}`);
154 else if (rest.length > 1) parts.push(plural(rest.reduce((n, [, c]) => n + c, 0), "other step", "other steps"));
155 return { steps: parts.join(", "), links: [...links].slice(0, LINKS_MAX).map(([href, label]) => ({ href, label })) };
156 }
157
158 const blob = (repo: string, ref: string, path: string): string =>
159 `/${repo}/blob/${encodeURIComponent(ref)}/${path.split("/").map(encodeURIComponent).join("/")}`;
160
161 /** The tool turns just before position `i`: back to the last person or agent turn. */
162 export function workBefore(turns: readonly Said[], i: number, look?: Lookup): Work | undefined {
163 let from = i;
164 while (from > 0 && isCall(turns[from - 1])) from -= 1;
165 return workOf(turns.slice(from, i), look);
166 }
167
168 const isCall = (turn: Said | undefined): boolean => turn?.role === "tool" || turn?.role === "confirm";
169
170 /** How far back a reply's calls are read when they start before the page. */
171 const REACH = 60;
172
173 /**
174 * The work behind each agent turn of one page of a conversation, by turn id.
175 * When the page opens on calls or a reply, the calls before it sit on the
176 * page before, so they are read too: a reply reads the same on any page as
177 * on the stream.
178 */
179 export function worksOf(db: Database, userId: string, conversationId: string, page: readonly (Said & { readonly id: string })[]): Map<string, Work> {
180 const look = lookupFrom(db);
181 const first = page[0];
182 const earlier =
183 first !== undefined && (first.role === "agent" || isCall(first))
184 ? listTurns(db, userId, conversationId, { before: first.id, limit: REACH })
185 : undefined;
186 const all: readonly Said[] = earlier?.ok ? [...earlier.value, ...page] : page;
187 const offset = all.length - page.length;
188 const works = new Map<string, Work>();
189 page.forEach((turn, i) => {
190 if (turn.role !== "agent") return;
191 const work = workBefore(all, offset + i, look);
192 if (work !== undefined) works.set(turn.id, work);
193 });
194 return works;
195 }
src/crew/claude.ts +3 −1
@@ -57,7 +57,7 @@ export interface ClaudeInput {
5757 /** The session id, once the init record names it (a restart resumes it). */
5858 readonly onSession?: ((sessionId: string) => void) | undefined;
5959 /** What the agent said or called, as each assistant message arrives. */
60 readonly onSaid?: ((said: { readonly kind: "text" | "tool"; readonly text: string }) => void) | undefined;
60 readonly onSaid?: ((said: { readonly kind: "text" | "tool" | "thinking"; readonly text: string }) => void) | undefined;
6161 /**
6262 * How long a session may stay silent after a result while its input is
6363 * still open (a steer was pending) before it is closed and settled as
@@ -368,6 +368,8 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
368368 const b = (block ?? {}) as Record<string, unknown>;
369369 if (b["type"] === "text" && typeof b["text"] === "string")
370370 input.onSaid({ kind: "text", text: b["text"] });
371 else if (b["type"] === "thinking" && typeof b["thinking"] === "string")
372 input.onSaid({ kind: "thinking", text: b["thinking"] });
371373 else if (b["type"] === "tool_use" && typeof b["name"] === "string")
372374 input.onSaid({ kind: "tool", text: `${b["name"]} ${JSON.stringify(b["input"] ?? {})}` });
373375 }
src/crew/conversation.ts +10 −2
@@ -22,6 +22,7 @@ import { dailyLeft } from "../core/account_ceiling.ts";
2222 import { appendTurn, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts";
2323 import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts";
2424 import { cancelHeld, WORDS_MAX } from "../core/fence.ts";
25 import { thinkingOf } from "../core/talk_work.ts";
2526 import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts";
2627 import { getUserById } from "../core/users.ts";
2728 import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts";
@@ -146,6 +147,9 @@ export const sessionsDir = (dataDir: string, configured?: string): string => {
146147 /** Claude Code's refusal to resume a session whose file is gone. */
147148 export const lostSession = (result: string): boolean => /No conversation found with session ID/i.test(result);
148149
150 /** Most of a thought kept for the page; the rest is not shown. */
151 const THINKING_CHARS = 4_000;
152
149153 const HISTORY_TURNS = 40;
150154 const HISTORY_TURN_CHARS = 2_000;
151155 const HISTORY_CHARS = 20_000;
@@ -163,7 +167,8 @@ export function withHistory(app: App, conversation: Conversation, prompt: string
163167 const before = last !== undefined && last.role === "person" && last.text === prompt ? stored.slice(0, -1) : stored;
164168 const lines: string[] = [];
165169 let size = 0;
166 for (const t of before.filter((t) => t.role !== "confirm" && !t.replaced && !t.failed).reverse()) {
170 // A confirm is asked of the person, and a thought is the model's own: neither is replayed.
171 for (const t of before.filter((t) => t.role !== "confirm" && !t.replaced && !t.failed && thinkingOf(t) === undefined).reverse()) {
167172 const line = `${t.role === "person" ? "Person" : t.role === "agent" ? "You" : "Tool"}: ${t.text.slice(0, HISTORY_TURN_CHARS)}`;
168173 if (size + line.length > HISTORY_CHARS) break;
169174 size += line.length;
@@ -408,7 +413,10 @@ function begin(
408413 },
409414 onSaid: (said) => {
410415 if (said.kind === "text") lastText = said.text;
411 if (said.text.trim() !== "") put(said.kind === "text" ? "agent" : "tool", said.text);
416 if (said.text.trim() === "") return;
417 // Thinking is kept as a tool turn of its own shape, so the page can fold it.
418 if (said.kind === "thinking") put("tool", JSON.stringify({ thinking: said.text.slice(0, THINKING_CHARS) }));
419 else put(said.kind === "text" ? "agent" : "tool", said.text);
412420 },
413421 });
414422 const lost = !outcome.ok && lostSession(outcome.result);
src/http/api/conversation_talk.ts +6 −1
@@ -12,6 +12,7 @@ import { DEFAULT_PAGE, listTurns, MAX_PAGE, openConversation } from "../../core/
1212 import { WORDS_MAX } from "../../core/fence.ts";
1313 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1414 import { scopeAllows } from "../../core/tokens.ts";
15 import { worksOf } from "../../core/talk_work.ts";
1516 import { turnRunning } from "../../crew/conversation.ts";
1617 import { crossOrigin, type Handler } from "../context.ts";
1718 import { apiError, boundedBody, forbidden, json, unauthorized } from "../respond.ts";
@@ -67,6 +68,10 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
6768 limit: turnsLimit(ctx.url.searchParams.get("limit")),
6869 ...(ctx.url.searchParams.get("before") ? { before: ctx.url.searchParams.get("before")! } : {}),
6970 });
70 return json({ running: turnRunning(ctx.app, user.id), turns: turns.ok ? turns.value : [] });
71 // An agent turn carries the steps line and links of the calls before it (`core/talk_work.ts`).
72 const all = turns.ok ? turns.value : [];
73 const works = worksOf(ctx.app.db, user.id, conversation.id, all);
74 const shown = all.map((t) => (t.role === "agent" ? { ...t, work: works.get(t.id) ?? null } : t));
75 return json({ running: turnRunning(ctx.app, user.id), turns: shown });
7176 });
7277 }
src/web/pages/talk.ts +14 −3
@@ -20,6 +20,7 @@ import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversat
2020 import { DEFAULT_PAGE, lastExchange, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts";
2121 import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts";
2222 import { WORDS_MAX } from "../../core/fence.ts";
23 import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts";
2324 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2425 import { ASKING } from "../../crew/conversation_confirm.ts";
2526 import { turnRunning, type RedoHow } from "../../crew/conversation.ts";
@@ -78,6 +79,10 @@ const meta = (who: string, at: string): Html =>
7879 const toolCard = (id: string, says: string, name: string, at: string, set = ""): Html =>
7980 html`<li class="talk-turn talk-tool${set}" id="${id}"><details><summary>${name.slice(0, 60)} <span class="talk-when">${time(at)}</span></summary><p>${says}</p></details></li>`;
8081
82 /** The quiet steps line and the links to what the turn opened or changed, under its reply. */
83 const workHtml = (work: Work): Html =>
84 html`<p class="talk-steps small muted">${work.steps}</p>${work.links.length === 0 ? null : html`<p class="talk-links small">${work.links.map((l) => html`<a href="${l.href}">${l.label}</a>`)}</p>`}`;
85
8186 /** What an empty chat offers to start with; each is a link that fills the composer (`?q=`), so it works without the script. */
8287 const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const;
8388
@@ -110,7 +115,7 @@ const contextLine = (c: Context | undefined): Html =>
110115 const costLine = (usd: number): Html => html`<p class="talk-cost small muted">${dollars(usd)} this turn</p>`;
111116
112117 /** One turn as the page and the stream both draw it; `waiting` is the confirms still open. */
113 export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number, act?: Html): Html {
118 export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number, act?: Html, work?: Work): Html {
114119 const id = `t-${turn.id}`;
115120 const set = turn.replaced ? " talk-replaced" : "";
116121 const acts = act === undefined ? null : html`<div class="talk-acts">${act}</div>`;
@@ -128,10 +133,12 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number
128133 if (turn.role === "agent")
129134 return html`<li class="talk-turn talk-agent${set}" id="${id}">
130135 <div class="talk-body prose">${renderMarkdown(turn.text)}</div>
136 ${work === undefined ? null : workHtml(work)}
131137 ${label("Forge")}
132138 ${cost === undefined ? null : costLine(cost)}${acts}
133139 </li>`;
134140 const data = parse(turn.text);
141 const thought = thinkingOf(turn);
135142 if (turn.role === "confirm") {
136143 const confirm = String(data?.["confirm"] ?? "");
137144 const tool = String(data?.["tool"] ?? "");
@@ -140,6 +147,8 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number
140147 ${waiting.has(confirm) && !turn.replaced ? confirmButtons(confirm, tool) : html`<p class="muted small">Answered or past its ten minutes.</p>`}
141148 </li>`;
142149 }
150 if (thought !== undefined)
151 return html`<li class="talk-turn talk-tool talk-thinking${set}" id="${id}"><details><summary>Thinking <span class="talk-when">${time(turn.createdAt)}</span></summary><p>${thought}</p></details></li>`;
143152 // A tool turn: the confirm's outcome as JSON, or the call the agent made as text.
144153 if (data !== undefined && "confirm" in data) {
145154 const tool = String(data["tool"] ?? "");
@@ -162,6 +171,7 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b
162171 const running = clock.running(ctx.app, user.id);
163172 const costs = turnCosts(ctx.app.db, conversation.id);
164173 const context = contextOf(ctx.app.db, conversation.id);
174 const works = worksOf(ctx.app.db, user.id, conversation.id, turns);
165175 // Paging back, the log ends before the newest turn: it does not follow.
166176 const last = turns.at(-1)?.id ?? "";
167177 const acts = before === undefined ? exchangeActs(ctx.app.db, user.id, conversation.id, running) : new Map<string, Html>();
@@ -179,7 +189,7 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b
179189 ${turns.length === 0
180190 ? 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.
181191 <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${HERE}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>`
182 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id), acts.get(t.id)))}
192 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id), acts.get(t.id), works.get(t.id)))}
183193 </ol>
184194 <div id="end"></div>
185195 </div>
@@ -291,8 +301,9 @@ export function talkStream(ctx: Ctx): Response {
291301 const fresh = turnsAfter(app.db, user.id, conversation.id, cursor);
292302 if (fresh.length > 0) {
293303 const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id));
304 const works = worksOf(app.db, user.id, conversation.id, fresh);
294305 for (const turn of fresh) {
295 emit("turn", { id: turn.id, html: turnHtml(turn, waiting).text }, turn.id);
306 emit("turn", { id: turn.id, html: turnHtml(turn, waiting, undefined, undefined, works.get(turn.id)).text }, turn.id);
296307 cursor = turn.id;
297308 }
298309 }
src/web/pages/talk_client.ts +2
@@ -35,6 +35,8 @@ export const talkCss = /* css */ `
3535 .talk-tool details { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2px 10px; }
3636 .talk-tool summary { cursor: pointer; padding: 6px 0; min-height: 1.5rem; }
3737 .talk-tool details p { margin: 0 0 8px; white-space: pre-wrap; }
38 .talk-steps { margin: 4px 0 0; }
39 .talk-links { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 2px 0 0; }
3840 .talk-suggest { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
3941 .talk-suggest .btn { min-height: 2.75rem; }
4042 .talk-jump { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); z-index: 9; min-height: 2.75rem; }
test/cli-talk.test.ts added +70
@@ -0,0 +1,70 @@
1 /** `forge talk` against a fake conversation API, run as a shell runs it (#8821). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { EXIT } from "../src/cli/main.ts";
8
9 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
10
11 let server: ReturnType<typeof Bun.serve>;
12 let dir = "";
13 let said = false;
14
15 /** What the API answers once the words are said: a thought, a call, and the reply with its work. */
16 const ANSWER = [
17 { id: "t2", role: "tool", text: JSON.stringify({ thinking: "First \x1b[31mlook\x1b[0m\nat the issue." }) },
18 { id: "t3", role: "tool", text: 'mcp__forge__issue {"repo":"alice/x","number":12}' },
19 {
20 id: "t4",
21 role: "agent",
22 text: "Twelve is open.",
23 work: { steps: "opened #12\x07", links: [{ href: "/alice/x/issues/12", label: "alice/x #12" }, { href: "/alice/x/blob/main/a\x1b[2J.ts", label: "a" }] },
24 },
25 ];
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-cli-talk-"));
29 server = Bun.serve({
30 port: 0,
31 fetch(req) {
32 const url = new URL(req.url);
33 if (url.pathname === "/api/v1/conversation/messages" && req.method === "POST") {
34 said = true;
35 return Response.json({ how: "started" }, { status: 202 });
36 }
37 if (url.pathname === "/api/v1/conversation/turns")
38 return Response.json({ running: false, turns: [{ id: "t1", role: "agent", text: "Earlier.", work: null }, ...(said ? ANSWER : [])] });
39 return Response.json({ error: "not_found", message: "no" }, { status: 404 });
40 },
41 });
42 });
43
44 afterAll(async () => {
45 server.stop(true);
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 test("prints the thought on one line, the reply, its steps line and its link paths, all inert", async () => {
50 const proc = Bun.spawn(["bun", CLI, "talk", "what", "is", "open"], {
51 cwd: dir,
52 env: { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "", FORGE_URL: `http://127.0.0.1:${server.port}`, FORGE_TOKEN: "tok", FORGE_CONFIG_DIR: dir, FORGE_KEYCHAIN: "0", NO_COLOR: "1" },
53 stdout: "pipe",
54 stderr: "pipe",
55 });
56 const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
57 expect(code).toBe(EXIT.ok);
58 const lines = out.trimEnd().split("\n");
59 expect(lines).toEqual([
60 "thinking: First �[31mlook�[0m at the issue.",
61 "Twelve is open.",
62 "opened #12�",
63 "/alice/x/issues/12",
64 "/alice/x/blob/main/a�[2J.ts",
65 ]);
66 // Nothing the API said reaches the terminal as a control character.
67 expect(out.replaceAll("\n", "")).not.toMatch(/[\x00-\x1f\x7f-\x9f]/);
68 // A turn seen before the words were said is not printed again.
69 expect(out).not.toContain("Earlier.");
70 }, 15_000);
test/talk-work.test.ts added +227
@@ -0,0 +1,227 @@
1 /**
2 * /talk shows the agent's work (#8821): thinking folded to one line, a steps
3 * line, and links to what a turn opened or changed, from a recorded run's events.
4 */
5
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { chmodSync, writeFileSync } from "node:fs";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { appendTurn, listTurns, openConversation } from "../src/core/conversations.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { CHANGES, LINKS_MAX, lookupFrom, workBefore, workOf, worksOf } from "../src/core/talk_work.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { say, withHistory, type ConversationOptions } from "../src/crew/conversation.ts";
19 import { TOOLS } from "../src/http/mcp/tools.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { turnHtml } from "../src/web/pages/talk.ts";
22 import { promote } from "./helpers/members.ts";
23
24 let dir: string;
25 let app: App;
26 let userId: string;
27 let servers: Servers;
28 let cookie: string;
29 let repoId: string;
30
31 const options = (binary: string): ConversationOptions => ({ binary, localUrl: "http://127.0.0.1:9", budgetUsd: 1, timeoutMs: 30_000, signal: new AbortController().signal });
32
33 /** The events of a recorded run, replayed by a fake `claude`. */
34 const EVENTS = [
35 { type: "system", subtype: "init", session_id: "s", model: "fake" },
36 { type: "assistant", session_id: "s", message: { id: "1", content: [{ type: "thinking", thinking: "I should <b>look</b> first." }, { type: "tool_use", name: "mcp__forge__read_file", input: { repo: "alice/x", path: "src/a.ts" } }] } },
37 { type: "assistant", session_id: "s", message: { id: "2", content: [{ type: "tool_use", name: "mcp__forge__read_file", input: { repo: "alice/x", path: "src/b.ts" } }, { type: "tool_use", name: "mcp__forge__issue", input: { repo: "alice/x", number: 12 } }, { type: "tool_use", name: "mcp__forge__read_file", input: { repo: "../evil", path: "../../etc" } }] } },
38 { type: "assistant", session_id: "s", message: { id: "3", content: [{ type: "text", text: "Done." }] } },
39 { type: "result", subtype: "success", is_error: false, result: "Done.", total_cost_usd: 0, num_turns: 1, session_id: "s", terminal_reason: "completed", usage: { input_tokens: 1, output_tokens: 1 } },
40 ];
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-talk-work-"));
44 app = createApp({ dataDir: dir, port: 0 });
45 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
46 if (!a.ok) throw new Error("user");
47 userId = a.value.id;
48 promote(app.db, a.value);
49 servers = await serve(app);
50 cookie = `forge_session=${createSession(app.db, a.value.id).token}`;
51 const repo = await createRepo(app, a.value, { name: "x", visibility: "public" });
52 if (!repo.ok) throw new Error(repo.error);
53 repoId = repo.value.id;
54 const made = await commitToBranch(app, a.value, repo.value, { message: "start", files: [{ path: "README.md", content: "# x\n" }] });
55 if (!made.ok) throw new Error("commit");
56 });
57 afterAll(async () => {
58 servers.stop();
59 app.db.close();
60 await rm(dir, { recursive: true, force: true });
61 });
62
63 test("a recorded run's thinking, steps and links are drawn under the reply", async () => {
64 const path = join(dir, "claude");
65 writeFileSync(path, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi\nIFS= read -r first\n${EVENTS.map((e) => `echo '${JSON.stringify(e)}'`).join("\n")}\n`);
66 chmodSync(path, 0o755);
67 const said = say(app, options(path), userId, "look at it");
68 if (!said.ok) throw new Error(said.error);
69 await said.value.done;
70 const conversation = openConversation(app.db, userId);
71 const listed = listTurns(app.db, userId, conversation.id);
72 if (!listed.ok) throw new Error("turns");
73 const turns = listed.value;
74 const thought = turns.find((t) => t.text.startsWith('{"thinking"'));
75 expect(thought).toBeDefined();
76 const folded = turnHtml(thought!, new Set()).text;
77 expect(folded).toContain("<details><summary>Thinking");
78 expect(folded).not.toContain("<details open");
79 expect(folded).toContain("I should &lt;b&gt;look&lt;/b&gt; first.");
80 const at = turns.findIndex((t) => t.role === "agent");
81 const reply = turnHtml(turns[at]!, new Set(), undefined, undefined, workBefore(turns, at, lookupFrom(app.db))).text;
82 // The read with a bad repo and path still counts as a file read, with no link.
83 expect(reply).toContain("read 3 files, opened #12");
84 expect(reply).toContain('href="/alice/x/blob/main/src/a.ts"');
85 expect(reply).toContain('href="/alice/x/issues/12"');
86 expect(reply).not.toContain("evil");
87 });
88
89 test("a reply with no tool calls before it has no steps line", () => {
90 const turn = { id: "1", conversationId: "c", role: "agent", text: "hi", createdAt: new Date().toISOString(), replaced: false, failed: false } as const;
91 const html = turnHtml(turn, new Set(), undefined, undefined, workBefore([turn], 0)).text;
92 expect(html).not.toContain("talk-steps");
93 });
94
95 const call = (tool: string, args: object) => ({ role: "tool", text: `mcp__forge__${tool} ${JSON.stringify(args)}` });
96
97 test("a link without a ref points at the default branch, and that route answers", async () => {
98 const work = workOf([call("read_file", { repo: "alice/x", path: "README.md" })], lookupFrom(app.db));
99 expect(work?.links).toEqual([{ href: "/alice/x/blob/main/README.md", label: "alice/x/README.md" }]);
100 const res = await fetch(servers.url + work!.links[0]!.href, { headers: { cookie } });
101 expect(res.status).toBe(200);
102 // A repo nobody knows has no default branch, so no file link is drawn for it.
103 expect(workOf([call("read_file", { repo: "alice/none", path: "a.ts" })], lookupFrom(app.db))?.links).toEqual([]);
104 // A write names its branch.
105 expect(workOf([call("write_file", { repo: "alice/x", branch: "feat", path: "a.ts" })])?.links[0]?.href).toBe("/alice/x/blob/feat/a.ts");
106 });
107
108 test("a pull and an app are links; one other kind of call is named", () => {
109 const pull = workOf([call("pull", { repo: "alice/x", number: 4 })]);
110 expect(pull?.links).toEqual([{ href: "/alice/x/pulls/4", label: "alice/x #4" }]);
111 expect(pull?.steps).toBe("opened #4");
112 const app1 = workOf([call("new_app", { repo: "alice/x" })]);
113 expect(app1?.links).toEqual([{ href: "/alice/x", label: "alice/x" }]);
114 expect(app1?.steps).toBe("new app");
115 expect(workOf([call("runs", {}), call("crew", {})])?.steps).toBe("2 other steps");
116 });
117
118 test("links stop at LINKS_MAX", () => {
119 const calls = Array.from({ length: LINKS_MAX + 5 }, (_, i) => call("issue", { repo: "alice/x", number: i + 1 }));
120 expect(workOf(calls)?.links).toHaveLength(LINKS_MAX);
121 });
122
123 test("a call cut at the stored length is a step with no link", () => {
124 const work = workOf([{ role: "tool", text: 'mcp__forge__read_file {"repo":"alice/x","pa' }]);
125 expect(work?.links).toEqual([]);
126 expect(work?.steps).toBe("read 1 file");
127 });
128
129 test("a thought is kept to 4,000 characters", async () => {
130 const path = join(dir, "claude-long");
131 const long = { type: "assistant", session_id: "s", message: { id: "1", content: [{ type: "thinking", thinking: "x".repeat(10_000) }, { type: "text", text: "ok" }] } };
132 writeFileSync(path, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi\nIFS= read -r first\necho '${JSON.stringify(long)}'\necho '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0,"num_turns":1,"session_id":"s","usage":{"input_tokens":1,"output_tokens":1}}'\n`);
133 chmodSync(path, 0o755);
134 const said = say(app, options(path), userId, "think long");
135 if (!said.ok) throw new Error(said.error);
136 await said.value.done;
137 const conversation = openConversation(app.db, userId);
138 const listed = listTurns(app.db, userId, conversation.id);
139 if (!listed.ok) throw new Error("turns");
140 const mine = listed.value.filter((t) => t.text.startsWith('{"thinking"')).at(-1)!;
141 expect(JSON.parse(mine.text).thinking).toHaveLength(4_000);
142 });
143
144 test("history replayed to a new session leaves a thought out", () => {
145 const conversation = openConversation(app.db, userId);
146 appendTurn(app.db, userId, conversation.id, { role: "tool", text: JSON.stringify({ thinking: "secret musings" }) });
147 appendTurn(app.db, userId, conversation.id, { role: "tool", text: 'mcp__forge__issue {"repo":"alice/x","number":3}' });
148 const prompt = withHistory(app, conversation, "and now?");
149 expect(prompt).not.toContain("secret musings");
150 expect(prompt).not.toContain('{"thinking"');
151 expect(prompt).toContain("mcp__forge__issue");
152 });
153
154 test("the stream draws a reply with the same steps and links as the page", async () => {
155 const conversation = openConversation(app.db, userId);
156 appendTurn(app.db, userId, conversation.id, { role: "person", text: "what is open" });
157 appendTurn(app.db, userId, conversation.id, { role: "tool", text: 'mcp__forge__issue {"repo":"alice/x","number":7}' });
158 const reply = appendTurn(app.db, userId, conversation.id, { role: "agent", text: "Seven is open." });
159 if (!reply.ok) throw new Error("turn");
160 const live = worksOf(app.db, userId, conversation.id, [reply.value]).get(reply.value.id);
161 const fromStream = await (await fetch(`${servers.url}/talk/stream?after=${reply.value.id}`, { headers: { cookie } })).text();
162 expect(fromStream).not.toContain("Seven is open.");
163 const listed = listTurns(app.db, userId, conversation.id);
164 if (!listed.ok) throw new Error("turns");
165 const turns = listed.value;
166 const at = turns.findIndex((t) => t.id === reply.value.id);
167 const drawn = turnHtml(reply.value, new Set(), undefined, undefined, live).text;
168 expect(drawn).toBe(turnHtml(reply.value, new Set(), undefined, undefined, workBefore(turns, at, lookupFrom(app.db))).text);
169 expect(drawn).toContain("opened #7");
170 expect(drawn).toContain('href="/alice/x/issues/7"');
171 // The wire: a client resuming from the person's turn is sent the reply drawn the same way.
172 const person = turns[at - 2]!;
173 const wire = await (await fetch(`${servers.url}/talk/stream?after=${person.id}`, { headers: { cookie } })).text();
174 expect(wire).toContain("opened #7");
175 });
176
177 test("the API's agent turns carry the steps and links for the terminal", async () => {
178 const res = await fetch(`${servers.url}/api/v1/conversation/turns?limit=5`, { headers: { cookie } });
179 const body = (await res.json()) as { turns: { role: string; text: string; work?: { steps: string; links: { href: string }[] } | null }[] };
180 const reply = body.turns.findLast((t) => t.role === "agent")!;
181 expect(reply.work?.steps).toBe("opened #7");
182 expect(reply.work?.links.map((l) => l.href)).toEqual(["/alice/x/issues/7"]);
183 expect(body.turns.filter((t) => t.role !== "agent").every((t) => t.work === undefined)).toBe(true);
184 });
185
186 test("a tool that changes an issue or pull says changed, not opened", () => {
187 const work = workOf([call("issue", { repo: "alice/x", number: 3 }), call("say", { repo: "alice/x", number: 4, body: "hi" }), call("close", { repo: "alice/x", number: 5 })]);
188 expect(work?.steps).toBe("opened #3, changed #4, #5");
189 // A tool that is neither is a step with no number link.
190 expect(workOf([call("vision", { repo: "alice/x", number: 2 })])).toEqual({ steps: "vision", links: [] });
191 });
192
193 test("the tools said to change an issue or pull are the ones that write by repo and number", () => {
194 const numbered = TOOLS.filter((t) => ["repo", "number"].every((k) => Object.keys(t.inputSchema.properties ?? {}).includes(k)));
195 expect(new Set(numbered.filter((t) => t.writes).map((t) => t.name))).toEqual(CHANGES);
196 });
197
198 test("numbers from more than one repo say whose they are", () => {
199 expect(workOf([call("issue", { repo: "alice/x", number: 12 }), call("issue", { repo: "bob/y", number: 12 })])?.steps).toBe("opened alice/x#12, bob/y#12");
200 expect(workOf([call("issue", { repo: "alice/x", number: 12 }), call("say", { repo: "alice/x", number: 12 })])?.steps).toBe("opened #12, changed #12");
201 });
202
203 test("a number link goes where the forge says it is, an issue or a pull", async () => {
204 const pull = createIssue(app.db, { repoId, authorId: userId, title: "a pull", kind: "pull" });
205 const issue = createIssue(app.db, { repoId, authorId: userId, title: "an issue" });
206 if (!pull.ok || !issue.ok) throw new Error("issue");
207 const look = lookupFrom(app.db);
208 const work = workOf([call("say", { repo: "alice/x", number: pull.value.number }), call("merge", { repo: "alice/x", number: issue.value.number })], look);
209 expect(work?.links.map((l) => l.href)).toEqual([`/alice/x/pulls/${String(pull.value.number)}`, `/alice/x/issues/${String(issue.value.number)}`]);
210 const res = await fetch(servers.url + work!.links[1]!.href, { headers: { cookie } });
211 expect(res.status).toBe(200);
212 // Unknown to the forge, the tool's own kind decides.
213 expect(workOf([call("merge", { repo: "alice/x", number: 999 })], look)?.links[0]?.href).toBe("/alice/x/pulls/999");
214 });
215
216 test("a reply first on an older page keeps the steps of the calls on the page before", async () => {
217 const conversation = openConversation(app.db, userId);
218 appendTurn(app.db, userId, conversation.id, { role: "person", text: "and nine?" });
219 appendTurn(app.db, userId, conversation.id, { role: "tool", text: 'mcp__forge__issue {"repo":"alice/x","number":9}' });
220 const reply = appendTurn(app.db, userId, conversation.id, { role: "agent", text: "Nine is open." });
221 const after = appendTurn(app.db, userId, conversation.id, { role: "person", text: "thanks" });
222 if (!reply.ok || !after.ok) throw new Error("turn");
223 const res = await fetch(`${servers.url}/api/v1/conversation/turns?limit=1&before=${after.value.id}`, { headers: { cookie } });
224 const body = (await res.json()) as { turns: { id: string; work?: { steps: string } | null }[] };
225 expect(body.turns.map((t) => t.id)).toEqual([reply.value.id]);
226 expect(body.turns[0]?.work?.steps).toBe("opened #9");
227 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs