Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 7 additions & 37 deletions packages/ui/src/amicode/entity-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
61 changes: 61 additions & 0 deletions packages/ui/src/amicode/rail-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) =>
() =>
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)
})
})
78 changes: 78 additions & 0 deletions packages/ui/src/amicode/rail-gate.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}
}

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<T extends GatePartLike>(
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<T extends GatePartLike>(
messages: readonly { id: string }[],
partsFor: (messageID: string) => readonly T[] | undefined,
): boolean {
return countAmicodeParts(messages, partsFor).any > 0
}
60 changes: 60 additions & 0 deletions packages/ui/src/amicode/shell-row.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
51 changes: 51 additions & 0 deletions packages/ui/src/amicode/shell-row.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<string, unknown>
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")
}
26 changes: 13 additions & 13 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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<string, unknown>
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 {
Expand Down
Loading