CodeNomad/dev-docs/TRANSCRIPT.md
heunghingwan 1577662068
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
fix(transcript): finish settlement reads from liveness and inbox evidence (#1187)
A settlement whose terminal event fell inside a disconnect window had no
trigger: finishOpenCodeDataEvent never ran, so the SDK's settlement read
was never finished and a tool row stayed "pending" until a reload. A
queued prompt delivered during the gap also vanished from both the queue
and the transcript.

The settlement read from #1169 is extracted as completeSettlementRead and
now also runs from two pieces of evidence the client already gathers:
the 30-second liveness pass reports each run it believed working that the
final activity read no longer lists (only while the transcript still
holds an unsettled tool), and the inbox sync reports an admitted item the
authoritative inbox no longer lists. Neither path has a terminal event
that invalidated the SDK's message key, so both invalidate it before the
read; otherwise the sync engine would skip it on any reducer that synced
before. The execution-epoch fence still lets only a newer execution
suppress the publication, and the display window keeps its membership.

Regressions start from a reducer whose first sync completed and expect
exactly one read: a liveness-observed settlement heals a stranded tool,
a newer execution suppresses that publication while the reducer still
reconciles, an inbox sync admits the delivered prompt, the production
liveness pass heals end to end, and a still-active run reads nothing.
Store suites and the UI typecheck pass.
2026-10-29 10:10:48 +01:00

9.2 KiB

Transcript rendering

Message content visibility and presets, token lines, tool renderers, native session lifecycle signals and transcript re-projection.

Agent conventions

  • The message-content popup and Chat settings share components/transcript-visibility.ts and the semantic icons from components/message-content-icons.ts; tool presentation metadata lives independently of renderers in components/tool-call/tool-presentation.ts. Popup styles live in styles/components/transcript-filters.css.
  • Transcript presets are Nothing (hides every row, including all tools), Minimal, Balanced and Everything (TRANSCRIPT_PRESETS); saved Detailed choices load as Custom with their effective tool column. Chat settings shows the four as buttons; the popup shows the same choice as a native select under its title, where a disabled Custom entry appears only after a manual row change. Escape closes an open list before the popup. Preset names and descriptions are localized in every locale; covered by tests/browser/transcript-presets.test.ts.
  • Status Tokens are independent of message token visibility. A collapsed section uses the non-interactive renderSummary slot for one ellipsized session input/output/cost line; expanded content shows session metrics then descendant-inclusive totals as separate wrapping groups. Keep this layout in styles/panels/status-tokens.css.
  • Message token disclosures keep one row in both states: collapsed shows the input/output/cost summary chips on a single ellipsized line; expanded swaps the same row to all six chips beside the chevron, which wrap onto further lines when narrow instead of truncating (expansion exists to read every value). Keep the metric renderer local and chip/row styles in styles/messaging/message-usage.css.
  • Tool-result images retain native state.content and render through the shared components/tool-call/output-images.tsx surface, including MCP and specialized tools. Keep image bytes out of text projections (copy/search/speech); image styles live in styles/messaging/tool-call/images.css.
  • Transcript pages carry large tool images by reference: the UI's native message list reads (lib/sdk-manager.ts) and history windows (fetchHistoryWindow) send the x-codenomad-media: deferred opt-in (server/src/opencode/session-media-contract.ts), and the server replaces each tool image data URI over INLINE_MEDIA_LIMIT (32 KiB) with a /api/workspaces/<id>/session-media/<session>/<message>/<part>/<index>/<sha256> path (opencode/session-media.ts). toolImageSource resolves that path on the API base, so the lazy <img> loads only shown images; routes/session-media.ts serves the bytes of the owned session's native message, pinned by the digest and refused at or after an undo boundary, as an immutable sandboxed image. Single-message reads (session.message.get: pruning revisions, previews), live events, user attachments and requests without the opt-in stay whole, so revisions hash native content.
  • Code Mode reads native metadata.toolCalls in tool-call/renderers/execute-data.ts; keep call-list styling in styles/messaging/tool-call/execute.css, with native image/output ownership in the shared tool shell.
  • Native websearch text is parsed conservatively by tool-call/renderers/websearch-data.ts; unknown/oversized responses use the shared raw renderer. Recognized provider-consent Forms retain the generic schema/answer path, with layout in styles/messaging/tool-call/websearch.css.
  • Session lifecycle features use OpenCode V2's published signals: one session.execution.{succeeded,failed,interrupted} per busy period (lib/native-execution-events.ts) and session.retry.scheduled for provider backoff. The schema-declared session.idle/session.status are never published; never wire a feature only to them. A terminal event is a hint until session.active confirms settlement, which alone announces "A session finished", only for root sessions ending in success, failure or an inactivity stop. Transcript reducers retire at settlement, deferred while prompts stay parked in their inbox or a user shell or unsettled tool call still needs reconciling there. A tool part still streaming (rendered "pending") once a settlement drained has lost its called transition: session.tool.success reduces solely from the running status (a later failed can still settle it). The SDK reducer already answers a terminal event whose transcript holds a streaming or running tool by invalidating and re-reading messages, but that refresh is dropped while the stream is not connected and never publishes its result; the terminal event therefore finishes the read itself (finishOpenCodeDataEvent: one message.sync that shares the SDK's in-flight read when connected and starts it when not) and projects the reconciled page, which keeps the display window's membership. Only a newer admitted execution suppresses that publication — same-round activity such as a late terminal tool event must not leave the repair unpublished. Non-event settlement evidence finishes the same read through the same fence: the liveness activity read observing that a run the UI still believed working has settled (observeNonEventSettlement, reported by session-api), and an authoritative inbox sync that drops an item the reducer admitted (a delivery or cancellation without events, whose user message may never be admitted either). Token/cost lines (components/message-usage.tsx) appear once per agent message: the row of an assistant message's final text (the one carrying "delete tools and reasoning") renders, once that message has native usage, the sum (sumMessageUsage) of its own usage and of every message holding a tool or reasoning part that cleanup selects (technicalCleanupParts). Reuse that selection rather than a separate projection. The line renders inside the content row, so it never shifts <Index> items or splits a technical group (whose Delete needs two tools); tool-only steps get no line. A message whose text is followed by tools also counts in the next message's line, as its tools belong to that selection. Sums cover the loaded transcript window only, like cleanup. Capture real streams with scripts/test-session-lifecycle-native.mjs.
  • Native transcript events reduce only through stores/opencode-data.ts (never add another partial-part reducer), which publishes bounded snapshots through hydrateMessages (message-v2/instance-store.ts); these per-event re-projections must not invalidate unchanged messages: hydrateMessages reuses the resident MessageRecord object whenever content, explicit timestamps and prompt-display metadata are unchanged, and an identical whole snapshot returns before any store write or session-revision bump. Part/info comparisons use allocation-free structural equality (partDataEqual), never JSON.stringify. The SDK reduce mutates rows in place, so reference identity can never prove a session unchanged — never add a reference-anchored projection skip. A session's reducer is replaced (authoritative resync after an overflow, rotation failure or reconnect; pruning invalidation) only through replaceTranscriptEntry, which bumps the transcript's entryRevision; reactive readers of the reducer, such as the inbox behind queued badges (getOpenCodeSessionInbox), track it so they rebind instead of keeping the disposed reducer's last state (opencode-inbox-rebinding.test.ts, tests/browser/session-rendering.test.ts).
  • Reducer page reads (the SDK's message.sync through the message.list proxy in createDataEntry) flush buffered compaction text, snapshot the session's live rows, fetch, flush again and answer with the page merged against that snapshot (stores/opencode-data-read-merge.ts), so buffered text is reduced exactly once: the page is the base and keeps its cursor, a live row new or changed since the snapshot keeps its live version (a row changed only in its root time.completed, which SDK step boundaries stamp, takes the page's version) except for its identified content parts, such as tool calls, left unchanged since the snapshot, which take the page's version so progress on one tool never hides authority for another, and rows created during the fetch and absent from the page ride along newest-first. Only events that remove or replace content (invalidatesFetchedPage: deletion, revert commit, inbox cancellation, compaction start/end/failure) restart the read; streamed text, reasoning, tool progress, compaction text and new rows merge, so a continuously streaming session completes its read in one fetch. Live versions are reconciled by the native full-text endings and the SDK's end-of-round refresh. A first latest-window load (loadMessages with no resident rows and no stored window) merges its 200-row page the same way and commits it while live revisions advance, then, unless the session is known idle, seeds a session reducer that holds no rows with the page's newest 20 rows, the SDK's own page size, without another read (seedOpenCodeSessionMessages), because the reducer applies streamed events only to messages it holds. Reloads and history windows keep the revision checks. Covered by opencode-data-trailing-read.test.ts, opencode-data-read-merge.test.ts and session-api-running-open.test.ts.
  • Shared speech byte/MediaSource transport lives in lib/audio-utils.ts; playback ownership and cancellation stay with its callers.