diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 3d7129b8d..bbc17d196 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -27,43 +27,13 @@ import { // handled per-call because fetchProblem resolves the active connection on // every invocation. -interface RailPartState { - status?: string - output?: string -} - -function countAmicodeParts( - messages: readonly { id: string }[], - partsFor: (messageID: string) => readonly RailPart[] | undefined, -): { any: number; completed: number } { - let any = 0 - let completed = 0 - for (const message of messages) { - for (const part of partsFor(message.id) ?? []) { - if (part?.type !== "tool" || typeof part.tool !== "string") continue - if (!part.tool.startsWith("amicode_")) continue - any++ - if (part.state?.status === "completed") completed++ - } - } - return { any, completed } -} - -/** The rail's session gate, exported so the host chrome can collapse the - * header's chip padding when the rail will not render (no amicode parts — - * the title otherwise sits over an empty reserved band). */ -export function sessionHasAmicodeParts( - messages: readonly { id: string }[], - partsFor: (messageID: string) => readonly RailPart[] | undefined, -): boolean { - return countAmicodeParts(messages, partsFor).any > 0 -} +// The session gate + refetch key now live in rail-gate.ts (pure, tested): the +// gate also recognises a SHELL-driven amicode session, which used to show no +// chips at all despite having real entities to describe. +export { countAmicodeParts, sessionHasAmicodeParts } from "./rail-gate" +import { countAmicodeParts as countParts, type GatePartLike } from "./rail-gate" -interface RailPart { - type?: string - tool?: string - state?: RailPartState -} +type RailPart = GatePartLike const RUN_POLL_MS = 2500 @@ -139,7 +109,7 @@ export function AmicodeEntityRail(props: { // exposes are all in scope. // Session gate + refetch key: completed amicode parts bump the counter → refetch. - const amicodeParts = createMemo(() => countAmicodeParts(props.messages, props.partsFor)) + const amicodeParts = createMemo(() => countParts(props.messages, props.partsFor)) const [problemRaw, { refetch }] = createResource( () => (amicodeParts().any > 0 ? amicodeParts().completed + 1 : undefined), diff --git a/packages/ui/src/amicode/rail-gate.test.ts b/packages/ui/src/amicode/rail-gate.test.ts new file mode 100644 index 000000000..581f40adf --- /dev/null +++ b/packages/ui/src/amicode/rail-gate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { countAmicodeParts, sessionHasAmicodeParts } from "./rail-gate" + +// Regression coverage for the missing entity chips. +// +// The gate used to be `part.tool.startsWith("amicode_")` alone, so a session that +// did its amicode work through the shell showed NO chips — observed 2026-07-29 on +// a session that created a problem workspace, wrote a solvespec, and drove a solve +// to iteration 29 with frames on disk, entirely via bash + amico-run. + +const msgs = [{ id: "m1" }] +const parts = + (...p: Array>) => + () => + p as never + +const amicodeTool = (status = "completed") => ({ type: "tool", tool: "amicode_record_system", state: { status } }) +const bash = (command: string) => ({ type: "tool", tool: "bash", state: { status: "running", input: { command } } }) + +describe("rail gate", () => { + test("an amicode_* tool part opens the gate (unchanged behaviour)", () => { + expect(sessionHasAmicodeParts(msgs, parts(amicodeTool()))).toBe(true) + }) + + test("a shell part that launches amico-run opens the gate", () => { + expect(sessionHasAmicodeParts(msgs, parts(bash("nohup amico-run --spec s.json solve.jl &")))).toBe(true) + }) + + test("a shell part that only touches the problems dir opens the gate", () => { + // the workspace gets created before any run exists + expect(sessionHasAmicodeParts(msgs, parts(bash("mkdir -p ~/.amico/problems/x-gate-1")))).toBe(true) + }) + + test("an unrelated session still shows nothing — the gate's whole purpose", () => { + expect(sessionHasAmicodeParts(msgs, parts(bash("git status"), bash("npm test")))).toBe(false) + expect(sessionHasAmicodeParts(msgs, parts({ type: "text", text: "hello" }))).toBe(false) + expect(sessionHasAmicodeParts([], () => undefined)).toBe(false) + }) + + test("a part whose input has not arrived yet does not match, and does not throw", () => { + expect(sessionHasAmicodeParts(msgs, parts({ type: "tool", tool: "bash", state: { status: "pending" } }))).toBe( + false, + ) + expect(sessionHasAmicodeParts(msgs, parts({ type: "tool", tool: "bash" }))).toBe(false) + }) + + // The refetch key must NOT be broadened: shell parts don't mutate the problem + // view through the tool seam, so counting them would refetch the problem on + // unrelated shell activity. + test("completed counts only amicode_* tools, never shell", () => { + const c = countAmicodeParts(msgs, parts(amicodeTool("completed"), bash("amico-run solve.jl"))) + expect(c.any).toBe(2) + expect(c.completed).toBe(1) + }) + + test("a non-completed amicode tool counts for the gate but not the refetch key", () => { + const c = countAmicodeParts(msgs, parts(amicodeTool("running"))) + expect(c.any).toBe(1) + expect(c.completed).toBe(0) + }) +}) diff --git a/packages/ui/src/amicode/rail-gate.ts b/packages/ui/src/amicode/rail-gate.ts new file mode 100644 index 000000000..67768e3f4 --- /dev/null +++ b/packages/ui/src/amicode/rail-gate.ts @@ -0,0 +1,78 @@ +// Does THIS session's transcript show amicode work? The entity rail renders only +// when it does, so a fresh or non-amicode session shows no empty chrome (the +// active problem pointer is global, the rail is session-scoped). +// +// Extracted from entity-rail.tsx to be testable, and broadened. The original test +// was `part.tool.startsWith("amicode_")` — a session that did its amicode work +// through the SHELL therefore showed no chips at all. Observed 2026-07-29: the +// agent created a problem workspace, wrote a solvespec, and launched a solve that +// reached iteration 29 with frames on disk — entirely via bash and `amico-run` — +// and the rail stayed hidden the whole time, because it never called an +// `amicode_*` tool. The chips had real entities to describe and refused to. +// +// A shell-driven solve is still unambiguously an amicode session, so the gate now +// also recognises a bash part whose command drives amicode. It stays SESSION-scoped +// (the point of the gate), so an unrelated session still shows nothing. + +/** Substrings in a shell command that mark it as amicode work. `amico-run` is the + * launcher; the problems dir is where a workspace gets created before any run. */ +const SHELL_MARKERS = ["amico-run", ".amico/problems", "amico plan", "amico spec"] as const + +export interface GatePartLike { + type?: string + tool?: string + state?: { + status?: string + input?: Record + } +} + +function isAmicodeToolPart(part: GatePartLike): boolean { + return part?.type === "tool" && typeof part.tool === "string" && part.tool.startsWith("amicode_") +} + +/** A shell part that drives amicode. Reads only `input.command`, so a part whose + * input has not arrived yet simply doesn't match (it will on the next render). */ +function isAmicodeShellPart(part: GatePartLike): boolean { + if (part?.type !== "tool") return false + const command = part.state?.input?.command + if (typeof command !== "string") return false + return SHELL_MARKERS.some((marker) => command.includes(marker)) +} + +export interface AmicodePartCounts { + /** Parts that mark this as an amicode session — the render gate. */ + any: number + /** Completed `amicode_*` tool parts — the refetch key. Deliberately NOT + * broadened: shell parts don't mutate the problem view through the tool seam, + * so counting them would refetch on unrelated shell activity. */ + completed: number +} + +export function countAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly T[] | undefined, +): AmicodePartCounts { + let any = 0 + let completed = 0 + for (const message of messages) { + for (const part of partsFor(message.id) ?? []) { + if (isAmicodeToolPart(part)) { + any++ + if (part.state?.status === "completed") completed++ + } else if (isAmicodeShellPart(part)) { + any++ + } + } + } + return { any, completed } +} + +/** The rail's session gate, exported so the host chrome can collapse the header's + * chip padding when the rail will not render. */ +export function sessionHasAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly T[] | undefined, +): boolean { + return countAmicodeParts(messages, partsFor).any > 0 +} diff --git a/packages/ui/src/amicode/shell-row.test.ts b/packages/ui/src/amicode/shell-row.test.ts new file mode 100644 index 000000000..bfb383c32 --- /dev/null +++ b/packages/ui/src/amicode/shell-row.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { clampShellLabel, shellRowLabel, SHELL_ROW_MAX } from "./shell-row" + +// Regression coverage for "a constant error not going away" (2026-07-29). +// +// A PENDING bash part has no `input.command` yet, so the label chain fell through +// to the model's free-text `description` and rendered a full prose sentence where +// users read a command — for the whole duration of the command. The text happened +// to paraphrase agent guidance about local launches being refused, so it read as a +// hard failure while the solve underneath was healthy and on iteration 29. +const REPORTED = + '"A local launch is REFUSED by amico-run while this solver is selected (exit 64), so attempting one only wastes a turn."' + +describe("shell row label", () => { + test("the reported case no longer fills the row, and loses its quote", () => { + const out = shellRowLabel({ state: { input: { description: REPORTED } } }) + expect(out.length).toBeLessThanOrEqual(SHELL_ROW_MAX) + expect(out.startsWith('"')).toBe(false) + expect(out.endsWith("…")).toBe(true) + }) + + test("the real command always wins over title and description", () => { + expect( + shellRowLabel({ state: { title: "Some title", input: { command: "ls -la", description: "listing files" } } }), + ).toBe("ls -la") + }) + + test("falls back to title, then description — both still useful", () => { + expect(shellRowLabel({ state: { title: "Install deps" } })).toBe("Install deps") + expect(shellRowLabel({ state: { input: { description: "Install deps" } } })).toBe("Install deps") + }) + + test("a short description is untouched — the fallback keeps its value", () => { + expect(shellRowLabel({ state: { input: { description: "Run the tests" } } })).toBe("Run the tests") + }) + + test("first line only, and blanks do not win", () => { + expect(shellRowLabel({ state: { input: { command: " make build \n&& make test" } } })).toBe("make build") + // an empty string must not beat a usable description + expect(shellRowLabel({ state: { input: { command: " ", description: "Install deps" } } })).toBe("Install deps") + }) + + test("nothing usable → the neutral placeholder, never a throw", () => { + expect(shellRowLabel({})).toBe("command") + expect(shellRowLabel({ state: {} })).toBe("command") + expect(shellRowLabel({ state: { input: {} } })).toBe("command") + }) + + test("clamping is exact and elides rather than truncating silently", () => { + const long = "x".repeat(SHELL_ROW_MAX + 20) + const out = clampShellLabel(long) + expect(out.length).toBe(SHELL_ROW_MAX) + expect(out.endsWith("…")).toBe(true) + expect(clampShellLabel("x".repeat(SHELL_ROW_MAX))).toBe("x".repeat(SHELL_ROW_MAX)) + }) + + test("single-quoted prose is unwrapped too", () => { + expect(clampShellLabel("'Install the dependencies'")).toBe("Install the dependencies") + }) +}) diff --git a/packages/ui/src/amicode/shell-row.ts b/packages/ui/src/amicode/shell-row.ts new file mode 100644 index 000000000..400bfed9b --- /dev/null +++ b/packages/ui/src/amicode/shell-row.ts @@ -0,0 +1,51 @@ +// The one-line label for a bash part's row inside the shell group. +// +// Extracted from message-part.tsx so the fallback chain is testable: it caused a +// bug that read as a hard error. While a bash part is PENDING its `input.command` +// is not populated yet, so the chain fell through to the model's free-text +// `description` — and rendered a full prose sentence in the slot where users read +// a command. Reported 2026-07-29 as "a constant error not going away": the text +// was `"A local launch is REFUSED by amico-run while this solver is selected +// (exit 64), so attempting one only wastes…"`, which is agent guidance being +// paraphrased, not a failure — and the solve underneath was running fine. +// +// The description fallback is still worth keeping (often a useful "Install +// dependencies"), so the fix is not to drop it but to stop it impersonating a +// command: strip wrapping quotes, keep one line, and clamp to a length that reads +// as a label rather than an error message. + +/** Longest label we render before eliding. Long enough for a real command or a + * short description, short enough that prose cannot fill the row. */ +export const SHELL_ROW_MAX = 72 + +export interface ShellRowPartLike { + state?: { + input?: Record + title?: unknown + } +} + +/** Trim, take the first line, drop wrapping quotes, and elide past SHELL_ROW_MAX. */ +export function clampShellLabel(raw: string, max: number = SHELL_ROW_MAX): string { + let s = raw.split("\n")[0]!.trim() + // A quoted prose sentence is the shape that read as an error; unwrap it so the + // row shows the sentence rather than a stray quote mark at the elision point. + if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) { + s = s.slice(1, -1).trim() + } else if (s.length >= 1 && (s.startsWith('"') || s.startsWith("'"))) { + s = s.slice(1).trim() + } + if (s.length <= max) return s + return s.slice(0, max - 1).trimEnd() + "…" +} + +/** Prefer the actual command; fall back to title, then the model's description. + * Whatever wins is clamped, so a pending part can never fill the row with prose. */ +export function shellRowLabel(part: ShellRowPartLike): string { + const input = (part.state?.input ?? {}) as Record + const command = typeof input.command === "string" && input.command.trim() !== "" ? input.command : undefined + const title = typeof part.state?.title === "string" && part.state.title.trim() !== "" ? part.state.title : undefined + const description = + typeof input.description === "string" && input.description.trim() !== "" ? input.description : undefined + return clampShellLabel(command ?? title ?? description ?? "command") +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 5a021a8af..77ab57049 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,6 +1,8 @@ import { AmicoSpinner, AmicoMark } from "../amicode/spinner" import { ThinkingLine } from "../amicode/thinking-line" import { turnTokens } from "../amicode/thinking" +import { shellRowLabel } from "../amicode/shell-row" +import { sessionHasAmicodeParts } from "../amicode/rail-gate" import { amicoBrainRef, emitAmicoBrainHover } from "../amicode/brain-ref" import { Component, @@ -618,12 +620,13 @@ export function AssistantParts(props: { // presence (the working lane below; the rail waking) keys off it. Plain-prose // turns are the *normal chat*: no Amico chrome in the flow // (spec-20260712-amico-third-actor). + // Shares the rail's gate (rail-gate.ts) rather than re-testing `amicode_*` + // here. Both used the tool-name test independently, so a SHELL-driven amicode + // session lost the chips AND Amico's presence mark together — the H-mark read + // as "inactive" while a solve was running at iteration 29 (2026-07-29). One + // definition, one place to widen. const inDomainTurn = createMemo(() => - props.messages.some((message) => - list(data.store.part?.[message.id], emptyParts).some( - (p) => p.type === "tool" && /^amicode_/.test((p as ToolPart).tool ?? ""), - ), - ), + sessionHasAmicodeParts(props.messages, (id) => list(data.store.part?.[id], emptyParts) as never), ) return ( @@ -732,15 +735,12 @@ export function AssistantParts(props: { ) } -// One-line command for a bash part's row in the shell group (prefer the actual -// command, fall back to title/description; first line only). +// One-line command for a bash part's row in the shell group. Logic lives in +// ../amicode/shell-row.ts so the fallback chain is testable — it shipped a bug +// where a pending part rendered the model's prose description as if it were the +// command, which read as a hard error for the command's whole duration. function shellCommandText(part: ToolPart): string { - const input = (part.state.input ?? {}) as Record - const command = typeof input.command === "string" ? input.command : undefined - const title = "title" in part.state && typeof part.state.title === "string" ? part.state.title : undefined - const description = typeof input.description === "string" ? input.description : undefined - const raw = command ?? title ?? description ?? "command" - return raw.split("\n")[0]!.trim() + return shellRowLabel(part) } function contextToolDetail(part: ToolPart): string | undefined {