diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index 42d255d7f..81a519044 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -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" @@ -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 }) { @@ -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 } }) diff --git a/packages/ui/src/amicode/receipt-currency.test.ts b/packages/ui/src/amicode/receipt-currency.test.ts new file mode 100644 index 000000000..0f7b2287c --- /dev/null +++ b/packages/ui/src/amicode/receipt-currency.test.ts @@ -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) + }) +}) diff --git a/packages/ui/src/amicode/receipt-currency.ts b/packages/ui/src/amicode/receipt-currency.ts new file mode 100644 index 000000000..18917fdc1 --- /dev/null +++ b/packages/ui/src/amicode/receipt-currency.ts @@ -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 +}