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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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