From fa78f8f1b3e5b9d19159d7c164bf1f970904ee4b Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 29 Jul 2026 05:29:53 -0400 Subject: [PATCH 1/2] fix(chat): a pending shell row stops impersonating an error, and chips survive a shell-driven session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports from the same screenshot, two unrelated causes. 1. "A CONSTANT ERROR NOT GOING AWAY EVEN THOUGH MY SESSION IS PROGRESSING." The shell row's label chain was `command ?? title ?? description`. A PENDING bash part has no `input.command` yet, so it fell through to the model's free-text description and rendered a full prose sentence in the slot where users read a command — for the command's whole duration (1m55s observed). The text was a paraphrase of agent guidance: "A local launch is REFUSED by amico-run while this solver is selected (exit 64), so attempting one only wastes a turn." Nothing had failed. The solve underneath was on iteration 29 with frames on disk, and the solver was `piccolo`, so the sentence was not even applicable. The description fallback is worth keeping (often a useful "Install deps"), so the fix is not to drop it but to stop it impersonating a command: unwrap quotes, first line only, clamp to 72 chars with an ellipsis. Prose can no longer fill the row. 2. "WHAT HAPPENED TO THE CHIPS AT THE TOP." The rail's session gate was `part.tool.startsWith("amicode_")`, so a session that did its amicode work through the SHELL showed no chips at all. Observed the same session create a problem workspace, write a solvespec, and drive a solve to iteration 29 — entirely via bash and amico-run — with the rail hidden the whole time. It had real entities to describe and refused to. The gate now also matches a shell part whose command drives amicode (`amico-run`, `.amico/problems`, `amico plan|spec`). It stays SESSION-scoped, which is the point of the gate — an unrelated session still shows nothing, and there is a test pinning that. The refetch key is deliberately NOT broadened: shell parts don't mutate the problem view through the tool seam, so counting them would refetch on unrelated shell activity. Both rules are extracted to pure modules (rail-gate.ts, shell-row.ts) because neither was testable inside its .tsx and both shipped a user-visible bug. 15 new tests; ui suite 318 pass; tsgo clean in ui and app. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui/src/amicode/entity-rail.tsx | 44 ++---------- packages/ui/src/amicode/rail-gate.test.ts | 61 ++++++++++++++++ packages/ui/src/amicode/rail-gate.ts | 78 +++++++++++++++++++++ packages/ui/src/amicode/shell-row.test.ts | 60 ++++++++++++++++ packages/ui/src/amicode/shell-row.ts | 51 ++++++++++++++ packages/ui/src/components/message-part.tsx | 14 ++-- 6 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/amicode/rail-gate.test.ts create mode 100644 packages/ui/src/amicode/rail-gate.ts create mode 100644 packages/ui/src/amicode/shell-row.test.ts create mode 100644 packages/ui/src/amicode/shell-row.ts 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..c35091930 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,6 +1,7 @@ import { AmicoSpinner, AmicoMark } from "../amicode/spinner" import { ThinkingLine } from "../amicode/thinking-line" import { turnTokens } from "../amicode/thinking" +import { shellRowLabel } from "../amicode/shell-row" import { amicoBrainRef, emitAmicoBrainHover } from "../amicode/brain-ref" import { Component, @@ -732,15 +733,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 { From c1389941874a3a4a04904f47bb85f2caa7e099c8 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 29 Jul 2026 05:36:33 -0400 Subject: [PATCH 2/2] fix(chat): Amico's presence mark shares the rail's gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported right after the chips: "where did the little amico icon go, why is that not active anymore?" Same cause. The working-presence lane (the pulsing H-mark + thinking line) gated on its own copy of the amicode_* tool-name test: p.type === "tool" && /^amicode_/.test(p.tool) identical in spirit to the rail's gate, and duplicated. So a session that did its amicode work through the SHELL lost the chips and Amico's presence together — the mark read as "inactive" while a solve was running at iteration 29. It now calls sessionHasAmicodeParts, so there is ONE definition of "this session is amicode work" and one place to widen it. Behaviour for tool-driven sessions is unchanged; shell-driven sessions get their presence back. Worth noting this was the third symptom of a single assumption — that amicode work always arrives as an amicode_* tool part. Chips, presence mark, and (indirectly) the missing telemetry all traced to it. ui 318 pass; tsgo clean in ui and app. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui/src/components/message-part.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c35091930..77ab57049 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -2,6 +2,7 @@ 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, @@ -619,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 (