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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8821-talk-shows-the-agent-s-thinking-collapse → main 7 commits fork 3 threads · 3 unresolved
12 files
changed +550
−12
docs/design/areas/talk.md +2
| @@ -18,6 +18,8 @@ | ||
| 18 | 18 | - `.talk-copy` — a quiet text button that copies a message or a code block. |
| 19 | 19 | - `.talk-suggest` — the starter questions on an empty chat; each fills the composer. |
| 20 | 20 | - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end. |
| 21 | - `.talk-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. | |
| 21 | 23 | - `.talk-replaced` — a turn the person edited, regenerated or retried past: kept in the log, dimmer, labelled "replaced". |
| 22 | 24 | - `.talk-failed` — the plain-words line for a run that could not answer; Retry sits under it. |
| 23 | 25 | - `.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 | ||
| 55 | 55 | | Tool calls as closed cards | this pull | AI Elements `Tool` | |
| 56 | 56 | | Starter questions on an empty chat (links to `/talk?q=`, so they work without the script) | this pull | AI Elements `Suggestion` | |
| 57 | 57 | | 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` | | |
| 58 | 59 | | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person | |
| 59 | 60 | | Edit and resend, regenerate, Retry (see below) | have (#8817) | replaced turns are kept and marked, not deleted | |
| 60 | 61 | | Queue | next pull | needs a queue on `crew/conversation.ts` | |
| 61 | 62 | | 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 | | |
| 63 | 64 | | 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 | |
| 64 | 65 | |
| 65 | 66 | **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. |
| 66 | 67 | |
| 67 | 68 | **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. |
| 68 | 69 | |
| 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. | |
| 70 | 71 | |
| 71 | 72 | ## Code, tables and Copy in a reply (#8816) |
| 72 | 73 | |
| 73 | 74 | 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. |
| 74 | 75 | |
| 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. | |
| 76 | 77 | |
| 77 | 78 | **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 { | ||
| 473 | 473 | } |
| 474 | 474 | |
| 475 | 475 | /** 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 }[] }>> { | |
| 477 | 477 | return this.call("/api/v1/conversation/turns?limit=20"); |
| 478 | 478 | } |
| 479 | 479 |
src/cli/commands/talk.ts +16 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 2 | import { thinkingOf } from "../../core/talk_work.ts"; | |
| 2 | 3 | import { ForgeApi } from "../api.ts"; |
| 3 | 4 | import type { Args } from "../args.ts"; |
| 4 | 5 | import { requireConfig } from "../context.ts"; |
| @@ -9,6 +10,12 @@ import type { Command } from "./types.ts"; | ||
| 9 | 10 | /** Seconds `forge talk` waits for the answer before it says where to read it. */ |
| 10 | 11 | const WAIT_S = 120; |
| 11 | 12 | |
| 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 | ||
| 12 | 19 | /** |
| 13 | 20 | * `forge talk "words"`: say something to the forge's own agent, as you, and |
| 14 | 21 | * 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> { | ||
| 41 | 48 | for (const turn of now.value.turns) |
| 42 | 49 | if (!seen.has(turn.id)) { |
| 43 | 50 | 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 | } | |
| 45 | 60 | } |
| 46 | 61 | if (!now.value.running) return EXIT.ok; |
| 47 | 62 | } |
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 { | ||
| 57 | 57 | /** The session id, once the init record names it (a restart resumes it). */ |
| 58 | 58 | readonly onSession?: ((sessionId: string) => void) | undefined; |
| 59 | 59 | /** 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; | |
| 61 | 61 | /** |
| 62 | 62 | * How long a session may stay silent after a result while its input is |
| 63 | 63 | * 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> { | ||
| 368 | 368 | const b = (block ?? {}) as Record<string, unknown>; |
| 369 | 369 | if (b["type"] === "text" && typeof b["text"] === "string") |
| 370 | 370 | 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"] }); | |
| 371 | 373 | else if (b["type"] === "tool_use" && typeof b["name"] === "string") |
| 372 | 374 | input.onSaid({ kind: "tool", text: `${b["name"]} ${JSON.stringify(b["input"] ?? {})}` }); |
| 373 | 375 | } |
src/crew/conversation.ts +10 −2
| @@ -22,6 +22,7 @@ import { dailyLeft } from "../core/account_ceiling.ts"; | ||
| 22 | 22 | import { appendTurn, lastExchange, listTurns, openConversation, removeTurn, restoreTurns, setAsideFrom, setClaudeSession, type Conversation } from "../core/conversations.ts"; |
| 23 | 23 | import { carryWrites, forgetWrites, unended } from "../core/conversation_writes.ts"; |
| 24 | 24 | import { cancelHeld, WORDS_MAX } from "../core/fence.ts"; |
| 25 | import { thinkingOf } from "../core/talk_work.ts"; | |
| 25 | 26 | import { PLATFORM, pickSubscription, type Pick } from "../core/subscriptions.ts"; |
| 26 | 27 | import { getUserById } from "../core/users.ts"; |
| 27 | 28 | import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts"; |
| @@ -146,6 +147,9 @@ export const sessionsDir = (dataDir: string, configured?: string): string => { | ||
| 146 | 147 | /** Claude Code's refusal to resume a session whose file is gone. */ |
| 147 | 148 | export const lostSession = (result: string): boolean => /No conversation found with session ID/i.test(result); |
| 148 | 149 | |
| 150 | /** Most of a thought kept for the page; the rest is not shown. */ | |
| 151 | const THINKING_CHARS = 4_000; | |
| 152 | ||
| 149 | 153 | const HISTORY_TURNS = 40; |
| 150 | 154 | const HISTORY_TURN_CHARS = 2_000; |
| 151 | 155 | const HISTORY_CHARS = 20_000; |
| @@ -163,7 +167,8 @@ export function withHistory(app: App, conversation: Conversation, prompt: string | ||
| 163 | 167 | const before = last !== undefined && last.role === "person" && last.text === prompt ? stored.slice(0, -1) : stored; |
| 164 | 168 | const lines: string[] = []; |
| 165 | 169 | 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()) { | |
| 167 | 172 | const line = `${t.role === "person" ? "Person" : t.role === "agent" ? "You" : "Tool"}: ${t.text.slice(0, HISTORY_TURN_CHARS)}`; |
| 168 | 173 | if (size + line.length > HISTORY_CHARS) break; |
| 169 | 174 | size += line.length; |
| @@ -408,7 +413,10 @@ function begin( | ||
| 408 | 413 | }, |
| 409 | 414 | onSaid: (said) => { |
| 410 | 415 | 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); | |
| 412 | 420 | }, |
| 413 | 421 | }); |
| 414 | 422 | 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/ | ||
| 12 | 12 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 13 | 13 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 14 | 14 | import { scopeAllows } from "../../core/tokens.ts"; |
| 15 | import { worksOf } from "../../core/talk_work.ts"; | |
| 15 | 16 | import { turnRunning } from "../../crew/conversation.ts"; |
| 16 | 17 | import { crossOrigin, type Handler } from "../context.ts"; |
| 17 | 18 | import { apiError, boundedBody, forbidden, json, unauthorized } from "../respond.ts"; |
| @@ -67,6 +68,10 @@ export function conversationTalkRoutes(router: Router<Handler>): void { | ||
| 67 | 68 | limit: turnsLimit(ctx.url.searchParams.get("limit")), |
| 68 | 69 | ...(ctx.url.searchParams.get("before") ? { before: ctx.url.searchParams.get("before")! } : {}), |
| 69 | 70 | }); |
| 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 }); | |
| 71 | 76 | }); |
| 72 | 77 | } |
src/web/pages/talk.ts +14 −3
| @@ -20,6 +20,7 @@ import { REFUSED, sayNo, sayYes, waitingConfirms } from "../../actions/conversat | ||
| 20 | 20 | import { DEFAULT_PAGE, lastExchange, listTurns, MAX_PAGE, openConversation, turnsAfter, type Turn } from "../../core/conversations.ts"; |
| 21 | 21 | import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts"; |
| 22 | 22 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 23 | import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts"; | |
| 23 | 24 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 24 | 25 | import { ASKING } from "../../crew/conversation_confirm.ts"; |
| 25 | 26 | import { turnRunning, type RedoHow } from "../../crew/conversation.ts"; |
| @@ -78,6 +79,10 @@ const meta = (who: string, at: string): Html => | ||
| 78 | 79 | const toolCard = (id: string, says: string, name: string, at: string, set = ""): Html => |
| 79 | 80 | 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>`; |
| 80 | 81 | |
| 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 | ||
| 81 | 86 | /** What an empty chat offers to start with; each is a link that fills the composer (`?q=`), so it works without the script. */ |
| 82 | 87 | const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const; |
| 83 | 88 | |
| @@ -110,7 +115,7 @@ const contextLine = (c: Context | undefined): Html => | ||
| 110 | 115 | const costLine = (usd: number): Html => html`<p class="talk-cost small muted">${dollars(usd)} this turn</p>`; |
| 111 | 116 | |
| 112 | 117 | /** 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 { | |
| 114 | 119 | const id = `t-${turn.id}`; |
| 115 | 120 | const set = turn.replaced ? " talk-replaced" : ""; |
| 116 | 121 | 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 | ||
| 128 | 133 | if (turn.role === "agent") |
| 129 | 134 | return html`<li class="talk-turn talk-agent${set}" id="${id}"> |
| 130 | 135 | <div class="talk-body prose">${renderMarkdown(turn.text)}</div> |
| 136 | ${work === undefined ? null : workHtml(work)} | |
| 131 | 137 | ${label("Forge")} |
| 132 | 138 | ${cost === undefined ? null : costLine(cost)}${acts} |
| 133 | 139 | </li>`; |
| 134 | 140 | const data = parse(turn.text); |
| 141 | const thought = thinkingOf(turn); | |
| 135 | 142 | if (turn.role === "confirm") { |
| 136 | 143 | const confirm = String(data?.["confirm"] ?? ""); |
| 137 | 144 | const tool = String(data?.["tool"] ?? ""); |
| @@ -140,6 +147,8 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number | ||
| 140 | 147 | ${waiting.has(confirm) && !turn.replaced ? confirmButtons(confirm, tool) : html`<p class="muted small">Answered or past its ten minutes.</p>`} |
| 141 | 148 | </li>`; |
| 142 | 149 | } |
| 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>`; | |
| 143 | 152 | // A tool turn: the confirm's outcome as JSON, or the call the agent made as text. |
| 144 | 153 | if (data !== undefined && "confirm" in data) { |
| 145 | 154 | const tool = String(data["tool"] ?? ""); |
| @@ -162,6 +171,7 @@ function talkPage(ctx: Ctx, before: string | undefined, prefill: string, edit: b | ||
| 162 | 171 | const running = clock.running(ctx.app, user.id); |
| 163 | 172 | const costs = turnCosts(ctx.app.db, conversation.id); |
| 164 | 173 | const context = contextOf(ctx.app.db, conversation.id); |
| 174 | const works = worksOf(ctx.app.db, user.id, conversation.id, turns); | |
| 165 | 175 | // Paging back, the log ends before the newest turn: it does not follow. |
| 166 | 176 | const last = turns.at(-1)?.id ?? ""; |
| 167 | 177 | 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 | ||
| 179 | 189 | ${turns.length === 0 |
| 180 | 190 | ? 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. |
| 181 | 191 | <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)))} | |
| 183 | 193 | </ol> |
| 184 | 194 | <div id="end"></div> |
| 185 | 195 | </div> |
| @@ -291,8 +301,9 @@ export function talkStream(ctx: Ctx): Response { | ||
| 291 | 301 | const fresh = turnsAfter(app.db, user.id, conversation.id, cursor); |
| 292 | 302 | if (fresh.length > 0) { |
| 293 | 303 | const waiting = new Set(waitingConfirms(ctx, user).map((w) => w.id)); |
| 304 | const works = worksOf(app.db, user.id, conversation.id, fresh); | |
| 294 | 305 | 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); | |
| 296 | 307 | cursor = turn.id; |
| 297 | 308 | } |
| 298 | 309 | } |
src/web/pages/talk_client.ts +2
| @@ -35,6 +35,8 @@ export const talkCss = /* css */ ` | ||
| 35 | 35 | .talk-tool details { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2px 10px; } |
| 36 | 36 | .talk-tool summary { cursor: pointer; padding: 6px 0; min-height: 1.5rem; } |
| 37 | 37 | .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; } | |
| 38 | 40 | .talk-suggest { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } |
| 39 | 41 | .talk-suggest .btn { min-height: 2.75rem; } |
| 40 | 42 | .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 <b>look</b> 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 | }); |
new above
offline