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
22 changes: 19 additions & 3 deletions packages/ui/src/amicode/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { amicodeStage } from "./stage"
import { parseAskInput } from "./ask"
import { AmicodeAskCard } from "./ask-card"
import { parseDiffSentinel, receiptParts } from "./receipt"
import { receiptIsCurrent } from "./receipt-currency"
import { systemReceiptPieces, formulationReceiptPieces } from "./facets"
import { compositeChip, chipText } from "./problem"
import { AmicoMark } from "./spinner"
Expand Down Expand Up @@ -241,9 +242,17 @@ function Chip(props: { tool: string; status?: string; output?: string }) {
// In-transcript entity view (Kate 2026-07-24): the receipt renders the full
// verdict-first entity view inline — no click, no modal. Data comes from the
// rail via the ui bridge (undefined until the rail mounts, exactly like the run
// window). EVERY entity kind surfaces inline; a run with a live run_dir still
// prefers the richer RunWindow (matched earlier in the Switch), so a run only
// reaches the inline path as its verdict when there's no live window.
// window). A run with a live run_dir still prefers the richer RunWindow (matched
// earlier in the Switch), so a run only reaches the inline path as its verdict
// when there's no live window.
//
// AMENDED (spec-20260727-164748 §9.4): kind membership is necessary but NO LONGER
// SUFFICIENT. The inline view reads the LIVE problem view, so when every receipt
// of a kind took this path, N updates painted N identical copies of the present —
// and because the transcript fetches the globally-active problem with no ?slug=,
// switching problems mid-chat retroactively rewrote earlier receipts. Only the
// CURRENT receipt for the ACTIVE problem may render live (./receipt-currency.ts);
// the rest fall through to the Chip, which renders from their own captured diff.
const INLINE_KINDS = new Set(["system", "formulation", "run", "device_session", "calibration"])

function InlineEntityView(props: { kind: string; seq?: number }) {
Expand Down Expand Up @@ -289,6 +298,13 @@ export function AmicodeToolCard(props: {
if (!sentinel || !INLINE_KINDS.has(sentinel.entity)) return undefined
const view = amicodeProblemView()
if (!view) return undefined
// §9.4: superseded receipts, and receipts captured against a different
// problem, render from their captured diff via the Chip instead of the live
// view. Deliberately permissive when currency is ambiguous (a kind with no
// events yet stays live) — the original note here warns that tighter gates
// hid the view entirely.
if (!receiptIsCurrent({ problem: sentinel.problem, entity: sentinel.entity, seq: sentinel.seq }, view))
return undefined
return { kind: sentinel.entity, seq: sentinel.seq }
})

Expand Down
67 changes: 67 additions & 0 deletions packages/ui/src/amicode/receipt-currency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import { latestSeqForEntity, receiptIsCurrent } from "./receipt-currency"
import type { EventView } from "./problem"

const ev = (seq: number, entity: string): EventView => ({ seq, entity, action: "updated" })

// Three formulation updates and one system record, as a real session accumulates them.
const EVENTS: EventView[] = [ev(2, "system"), ev(3, "formulation"), ev(5, "formulation"), ev(7, "formulation")]
const VIEW = { slug: "ghz-state-rydberg", events: EVENTS }

describe("latestSeqForEntity", () => {
test("returns the highest seq for that kind only", () => {
expect(latestSeqForEntity(EVENTS, "formulation")).toBe(7)
expect(latestSeqForEntity(EVENTS, "system")).toBe(2)
})
test("undefined when the kind has no events (the view-lag case)", () => {
expect(latestSeqForEntity(EVENTS, "run")).toBeUndefined()
expect(latestSeqForEntity([], "formulation")).toBeUndefined()
})
test("does not assume the events are sorted", () => {
expect(latestSeqForEntity([ev(9, "run"), ev(4, "run")], "run")).toBe(9)
})
})

describe("receiptIsCurrent", () => {
test("the newest receipt for a kind is current", () => {
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, VIEW)).toBe(true)
})

// THE BUG: superseded receipts render the live view today, so three formulation
// updates paint three identical copies of the present.
test("superseded receipts for the same kind are NOT current", () => {
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 3 }, VIEW)).toBe(false)
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 5 }, VIEW)).toBe(false)
})

// THE WORSE BUG: the transcript fetches the globally-active problem with no slug,
// so switching problems mid-chat retroactively rewrote earlier receipts.
test("a receipt from another problem is NOT current, even at a matching seq", () => {
expect(receiptIsCurrent({ problem: "transmon-state-prep", entity: "formulation", seq: 7 }, VIEW)).toBe(false)
})

test("no view → not current (cannot establish currency; render captured)", () => {
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, undefined)).toBe(false)
})

test("missing seq → not current", () => {
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation" }, VIEW)).toBe(false)
})

// Preserves the existing card.tsx warning: "a record+update lands as two events but
// one receipt, and the view can lag a beat, so tighter gates hid the view entirely."
test("a kind with no events yet is current — the view may lag the receipt", () => {
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "run", seq: 11 }, VIEW)).toBe(true)
})

test("a view without a slug falls back to the seq check rather than collapsing everything", () => {
const noSlug = { events: EVENTS }
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 7 }, noSlug)).toBe(true)
expect(receiptIsCurrent({ problem: "ghz-state-rydberg", entity: "formulation", seq: 3 }, noSlug)).toBe(false)
})

test("a sentinel without a problem falls back to the seq check", () => {
expect(receiptIsCurrent({ entity: "formulation", seq: 7 }, VIEW)).toBe(true)
expect(receiptIsCurrent({ entity: "formulation", seq: 3 }, VIEW)).toBe(false)
})
})
64 changes: 64 additions & 0 deletions packages/ui/src/amicode/receipt-currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { EventView } from "./problem"

// AMICODE: is a given diff receipt the CURRENT state of the ACTIVE problem?
//
// WHY THIS EXISTS (spec-20260727-164748 §9.4). Every amicode_* receipt used to
// render the full entity view inline, and that view read the LIVE problem view
// rather than a snapshot at its own seq. Two consequences, both wrong:
//
// 1. N updates to one entity painted N identical copies of the present — the
// transcript looked like a history and carried none.
// 2. The transcript fetches the globally-active problem with NO ?slug=, and
// ~/.amico/problems/active is a single global file. So switching problems
// mid-chat retroactively rewrote every earlier receipt to the new problem.
//
// The rule: exactly the current receipt renders live; everything else renders
// from the captured sentinel diff it already carries. This module is the pure
// predicate for "current", kept out of card.tsx so it is testable without
// rendering anything.
//
// Deliberately permissive in the ambiguous cases. card.tsx's original gate note
// warns that "a record+update lands as two events but one receipt, and the view
// can lag a beat, so tighter gates hid the view entirely" — so a kind with no
// events YET stays current rather than collapsing to a chip that would look
// like data loss.

/** Highest `seq` among events for `kind`, or undefined when that kind has none.
* Does not assume the events are sorted. */
export function latestSeqForEntity(events: EventView[], kind: string): number | undefined {
let latest: number | undefined
for (const event of events) {
if (event.entity !== kind) continue
if (latest === undefined || event.seq > latest) latest = event.seq
}
return latest
}

export interface ReceiptRef {
/** Problem slug the receipt was captured against (sentinel `problem`). */
problem?: string
entity: string
seq?: number
}

export interface CurrencyView {
slug?: string
events: EventView[]
}

/** True when this receipt should render LIVE (it is the current state of the
* active problem); false when it should render from its captured diff. */
export function receiptIsCurrent(receipt: ReceiptRef, view: CurrencyView | undefined): boolean {
// No view: currency is unknowable. Render captured rather than guess.
if (!view) return false
// A receipt with no seq cannot be placed in the entity's history.
if (typeof receipt.seq !== "number") return false
// Captured against a DIFFERENT problem than the one now active → history.
// Skipped when either side lacks a slug: we cannot detect a switch, so fall
// through to the seq check rather than collapsing every receipt.
if (receipt.problem !== undefined && view.slug !== undefined && receipt.problem !== view.slug) return false
// Superseded by a later event for the same kind → history.
const latest = latestSeqForEntity(view.events, receipt.entity)
if (latest !== undefined && receipt.seq !== latest) return false
return true
}
Loading