Chat Transcript and Turns

August 30, 2026 · View on GitHub

The transcript is a JSON-lines stream of AgentChatEventEnvelope records. Everything the renderer draws (messages, tool calls, commands, file changes, plans, pending inputs, turn dividers) is derived from this one stream. Sessions persist the stream to disk so they survive restarts.

Event envelope

type AgentChatEventEnvelope = {
  sessionId: string;
  timestamp: string;
  event: AgentChatEvent;
  sequence?: number;
  provenance?: {
    messageId?: string;
    providerMessageId?: string;
    providerParentAgentId?: string | null;
    providerOrigin?: string | null;
    providerSupersedes?: string[];
    providerRetractedMessageIds?: string[];
    threadId?: string | null;
    role?: "user" | "orchestrator" | "worker" | "agent" | null;
    targetKind?: string | null;
    sourceSessionId?: string | null;
    attemptId?: string | null;
    stepKey?: string | null;
    laneId?: string | null;
    runId?: string | null;
  };
};

Type definitions live in apps/desktop/src/shared/types/chat.ts. The envelope carries transport metadata; the actual payload is the discriminated AgentChatEvent union.

provenance is populated for delegated worker chat, where messages can originate from orchestrator, worker, or user threads and must be routed back to the correct activity feed.

Canonical assistant text (fragile — read before editing)

chat.getTranscript — plus the cursor-paged chat-history reader and the internal readTranscriptEntries used by auto-title and handoff — flattens the envelope stream into role-tagged entries via transcriptEntriesFromEnvelopes in apps/desktop/src/main/services/chat/chatTranscriptEntries.ts. Clients that hold both the live fragment stream and this canonical text (iOS, the web client) reconcile the two, so the module owes them one invariant:

Canonical text is byte-identical to what a renderer draws from the same envelopes. ADE never invents a character.

That is stronger than "don't corrupt text", and it is the property that matters: a client holding both renditions can only reconcile them if they agree. The moment canonical groups or joins differently from a renderer, the two are no longer deltas of each other and the client concatenates them — rendering the message twice.

So canonical mirrors the renderer exactly:

  • Entries group on messageId (else the turn), because that is what every renderer keys on. Desktop's shouldMergeTextRows compares messageId and ignores itemId; iOS collapses a text event onto its messageId in workAssistantMessageStableId. Grouping more finely — for example splitting on the itemId Codex advances per provider message — makes a client silently concatenate two entries it keyed the same.
  • Identified fragments concatenate verbatim, whatever interleaves, because the renderer joins merged rows with a bare ${previous}${next}. ADE has no block-level identity it can trust, so guessing a boundary from interleaved events is what spliced "\n\n" into the middle of a word ("no new mod" + "ifier chain needed:").

Only when ADE cannot tie a fragment to a provider message does it fall back to inferring boundaries from interleaved events. Ephemeral chrome — activity hints, live context_usage, token counters — is invisible to every renderer, so letting it break a run made the canonical text disagree with what desktop, the TUI, and iOS actually draw. isTranscriptContentEvent is an allowlist of content types on purpose: a new event type defaults to "does not break the run", which at worst drops a paragraph break, whereas the inverse default corrupts words. Keep genuinely rendered rows (todo_update, the subagent_* cards, ade_card, done) in that list — they are not chrome.

A user message clears every open entry, so a stream key reused in a later turn cannot merge backwards into text that preceded the user.

A whitespace-only fragment (a word gap, or a markdown hard break " \n") is a real delta and is dropped only when there is no run for it to continue.

Because canonical and rendered text agree, clients need no reconciliation heuristic of their own. iOS merges every assistant fragment through mergeWorkStreamingText regardless of where the envelope came from. An earlier attempt to add a client-side guard for whole-message rows had to be removed: it could not tell a complete message from one chunk of a paged canonical fetch (getChatTranscriptPage can split a message at an envelope boundary), so it dropped the other chunk. Keep the agreement on the host side; do not reintroduce a client rule that has to guess what a sequence-less envelope contains.

Parsing

parseAgentChatTranscript(raw) in apps/desktop/src/shared/chatTranscript.ts is the canonical parser. It tolerates malformed lines (silently skips), normalises missing timestamps to Date.now(), and only passes through envelopes with a non-empty sessionId and a non-null event object.

The parser is used both in the main process (for persisted state replay, recovery, and auto-title generation) and the renderer (for transcript-derived summaries in session cards).

Two helpers summarise a parsed stream:

  • hasMaterialWorkerChatEvent(events) -- returns true when any event type in { text, reasoning, tool_call, tool_result, command, file_change } is present. Used to gate worker-chat activity badges.
  • hasWorkerChatLifecycleEvent(events) -- returns true when any event other than user_message is present.
  • deriveAgentChatTranscriptSummary(events, maxChars = 280) -- returns the last text/reasoning/error/status message, compacted to a single line.

The event union

AgentChatEvent is a discriminated union defined at apps/desktop/src/shared/types/chat.ts. Major members:

TypePurpose
user_messageA user turn; carries text, attachments, turnId, optional steerId and deliveryState. deliveryState is "queued" while a steer waits for turn-end delivery, "delivered" once flushed at turn boundary, "inline" when the user inline-dispatched a queued steer into the active Claude turn (SDK shouldQuery:false send), and "failed" if dispatch errored.
textStreaming assistant text; identified by messageId (preferred) or turn/item identity. Fragments merge when shouldMergeTextRows() returns true.
transcript_retractionProvider-level retraction signal. Claude emits this for refusal fallback retracted_message_uuids and assistant supersedes; renderers remove prior assistant text rows whose messageId matches messageIds, optionally retaining replacementMessageId as the new provider message id. The persisted JSONL remains append-only.
reasoningChain-of-thought or assistant-internal reasoning; surfaces as a distinct transcript row with a collapsible header.
tool_call / tool_resultPaired per tool invocation; rendered inside work-log groups. tool_result.status can be running, completed, failed, or interrupted. Claude SDK tool_result_meta is retained as optional toolResultMeta, and the provider's raw payload as optional structured; both are local-only debug material — they are bounded on disk and stripped from the sync wire, because no renderer, TUI, web, or iOS client decodes either. Provider-native MCP calls retain mcp: AgentChatMcpToolSource (server, tool, optional plugin/resource/app context) so transcript labels, the TUI/iOS, and Sources use the connector identity instead of a generic tool name.
file_changeEmitted when the agent writes or deletes a file; carries path, diff, and kind.
commandA shell command invocation; carries cwd, output, exitCode, durationMs.
planFinal plan payload (steps + explanation); replaces any earlier plan_text rows for that turn.
plan_textStreaming plan fragments; merged via shouldMergePlanTextRows().
approval_requestLegacy approval; newer code emits an embedded PendingInputRequest via detail.
structured_questionClaude SDK AskUserQuestion tool surface.
pending_input_resolvedHidden row; consumed by pending-input derivation to clear UI state.
statusTurn-level lifecycle: started, completed, interrupted, failed.
doneFinal turn marker with model, model id, usage, cost, and optional open-string terminalReason. Claude may also carry canonicalModel, modelProvider, apiErrorStatus, fastModeDisabledReason, userMessageUuid, and requestSentWallMs; these preserve billing/provider provenance, terminal HTTP class, Fast fallback cause, and request correlation/latency metadata without exposing raw provider envelopes. Failed/interrupted dividers translate known reasons into a short explanation; completed turns omit the reason. Also clears non-question pending inputs when status is not completed.
errorProvider/runtime failure with message, detail, and semantic errorInfo. Codex can report the same terminal failure first as an app-server error notification and again on failed turn/completed; ADE keeps one visible row for the same turn/error identity while preserving distinct failures.
activityEphemeral UI hint (thinking, searching, running_command). Hidden from the transcript.
todo_updateTask-list snapshot; consumed by ChatTasksPanel.
subagent_started / subagent_progress / subagent_resultLegacy Claude background subagent lifecycle. Each envelope carries taskId, parentToolUseId, description, and optional agentId, parentAgentId, agentType, and providerSessionId; Claude start rows bind the native child to the owning Claude session so transcript drill-in never mistakes the ADE chat id for a provider session id. For Claude / ade-code agentType is the Task tool's subagent_type (stashed at the tool_use boundary and joined on parentToolUseId); for Codex parallel agents it is a per-turn Agent #N label assigned at first announcement and the raw threadId is mirrored as agentId; for OpenCode subagents agentType is omitted so the row falls back to the description (taken from session.title). Codex app-server subAgentActivity items also flow into these rows and may carry label, model, and reasoningEffort for richer roster labels. Claude SDK runs also stash taskType (subagent / background / local_workflow / cron / other) and workflowName at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's skip_transcript=true flag — e.g. session-title generation) and plain Claude Code task runs (task_type other with no agent metadata, e.g. "Re-run affected test files") are both tracked only for cleanup and filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them, while a backgrounded Bash shell (task_type local_bash/background) is routed to the background pane rather than the roster. Every subagent_result is gated on a recorded subagent_started (emittedSubagentStartIds), so an interrupt cannot emit a phantom stopped card for a subagent that never announced; terminal events clear both the taskId and agentId aliases. The service also emits canonical subagent.started / subagent.progress / subagent.completed rows from runtimeEvents.ts so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: Claude Workflow runs — the SDK's undocumented workflow_progress snapshot on system:task_progress is normalized by claudeWorkflowProgress.ts (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable <taskId>::a<index> / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as stopped; and child chat spawns — a session created with orchestrationParentSessionId outside an orchestration run (e.g. ade chat create from a tracked agent shell) emits synthetic subagent_started/subagent_result events keyed chat:<childSessionId> into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped.
scheduled_work_updateScheduled/background-work lifecycle snapshot. ADE emits it for provider-neutral action schedules and Claude ScheduleWakeup, CronCreate, CronDelete, /loop/hook snapshots, remote triggers, cron/background task lifecycle messages, and durable scheduler transitions. It carries kind (wakeup, cron, loop, remote_trigger, background_task), status (scheduled, paused, running, fired, missed, completed, cancelled, failed, stopped), provenance ids, optional cron/prompt/reason/timestamps, firedAt, late, and durable; shared/chatScheduledWork.ts folds it into active/history Chat Info rows on desktop, ADE Code, and iOS. background_task is additionally how the live Claude runtime reports a backgrounded shell command — it emits no subagent lifecycle events for one — so on desktop those snapshots also drive the in-thread background_job_line; other kinds bundle as activity. Parent turn completion does not imply background completion, and background_task snapshots whose sourceTaskId belongs to a real subagent are omitted from the Background roster (and from the job line) to avoid duplicate Agent rows. One-shots progress through scheduled -> fired -> completed; crons record the fire and return to scheduled with lastRunAt plus their next occurrence.
tool_use_start / tool_use_complete / tool_use_summaryClaude SDK tool lifecycle tracking (see Claude tool-use tracking).
step_boundaryWorkflow step boundary marker.
system_noticeNon-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, and the status:"subagent_spawned" chip (emitted into the parent when a child chat session is created with a parent lineage; detail.spawnedSession carries the child sessionId/laneId/title and the chip deep-links via ade:work:select-session; the TUI shows the message line; iOS renders it through its existing system_notice mapping).
conversation_resetMarks a fresh Claude conversation inside the same ADE session. ADE adopts newConversationId as the next SDK resume pointer, clears conversation-scoped auto-title/continuity caches while preserving a manual title, and renderers show a New conversation divider.
interrupt_receiptRecords SDK UUIDs that remain queued or were cancelled after an interrupt, plus the selected stopMode. Clients show the full remaining count; ADE-attributed messages include their steerId and offer cancellation through the SDK control channel. The long-lived query remains attached while messages are still queued so the receipt stays actionable.
queue_recoveryBounded recovery lifecycle for Claude Stop & clear queue: available renders one Undo card for the actually cancelled ADE-attributed messages, restored rehydrates the original steer payloads/ids, and expired closes the eight-second window. Terminal recovery events suppress the earlier available card during replay.
command_lifecycleGround-truth lifecycle for ADE-owned Claude messages (queued, started, completed, cancelled, discarded). ADE ignores internal UUIDs it cannot attribute, deduplicates repeated states, clears staged composer rows once execution begins, and renders only cancelled/discarded terminal anomalies to keep transcripts quiet.
claude_goal_updated / claude_goal_clearedRead-only Claude /goal lifecycle. The update carries the condition, iteration count, token baseline, timestamps, and optional last reason; the session summary mirrors the latest value as claudeGoal.
session_meta_updatedRuntime-native session metadata update. Carries title / manual-name state, and — when a client changes the session's mode via updateSession — the permission/interaction mode fields (permissionMode, interactionMode, claudePermissionMode, codexApprovalPolicy/codexSandbox/codexConfigSource, opencodePermissionMode, droidPermissionMode, cursorModeId, cursorModeSnapshot). The renderer treats it as a local-touch event so Work lists and grid tiles refresh when a provider renames a session, patches the session summary with any mode fields present, and re-seeds the selected chat's composer mode controls so a mode change on another client (desktop ↔ iOS) shows up live without a refetch. All mode fields are optional; a title-only emit carries none of them.
completion_reportStructured closeout produced by the reportCompletion workflow tool.
turn_diff_summaryGit-level before/after SHA + per-file stats for a completed turn.
delegation_stateDelegated worker state updates.
context_usageProvider-neutral context occupancy. Automatic Claude samples use origin: "live" | "snapshot" | "compact" and are filtered out of the transcript; live is the responsive stream estimate, while snapshot/compact come from the SDK control channel after initialization, settled turns, and compact completion. state is measured, compacting, recalculating, or unknown; non-measured states deliberately hide the old percentage. Monotonic sampleId plus capturedAt support stale-response rejection and diagnostics. The user-invoked /context command carries origin: "command" (historical undefined-origin snapshots are treated the same) and still renders its inline breakdown card. Optional typed fields (inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) carry the breakdown the meter's hover shows without reparsing the display categories.
context_compactProvider-neutral manual/automatic compaction lifecycle. state: "started" begins the boundary and state: "completed" may carry preTokens, postTokens, tokensRemoved, durationMs, provider, and per-session count. trigger: "ade_fallback" identifies ADE's guarded fallback. A completed boundary invalidates older context-meter usage on desktop, ADE Code, and iOS; exact post-compaction snapshots may refill the meter immediately, while stale same-turn aggregate counters are ignored.
web_searchProvider-neutral web-search/fetch lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Actions can carry query, queries, title, url, and snippet; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. Codex 0.145 additionally emits structured results (an array of { url, title, snippet } capped at 8 by the adapter) plus resultsTotal (the pre-cap hit count). Renderers thread these onto the same grouped row — desktop/iOS surface them as Sources chips (deduped against the action URLs) and the Sources tab, and the TUI shows up to three title — domain preview lines with a +N more tail. Codex emits native web-search items; claudeStructuredActivity.ts maps Claude server-tool blocks into the same event.
codex_image_generation / codex_image_viewCompact generated/viewed-image lifecycle used across providers despite the legacy type prefix. Codex emits native image items, Cursor maps generateImage, OpenCode maps image file parts, and Droid maps assistant image blocks. Large stored data URIs are removed with original/omitted byte metadata.
codex_safety_buffering / codex_moderation_metadata / codex_sleep / codex_thread_deleted / codex_turn_stalledCodex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; codex_thread_deleted clears the stored upstream thread; codex_turn_stalled is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. Its actions are wait, steer, interrupt_retry_same_thread, and restart_resume_thread.
auto_approval_reviewWhen auto-approval policy kicks in, this event carries the review text.
prompt_suggestionSuggested follow-up prompts for the user.

Subagent model attribution

Subagent lifecycle rows may carry the child model and reasoningEffort. For Claude, the service resolves the model from the task lifecycle frame or the native Agent/Task tool input, including tool input that arrives after the lifecycle frame. If a start row was already emitted with inherited parent metadata, the late input updates the live snapshot and emits a corrected start row so the renderer's model chip reflects the child that actually ran.

Claude getSubagentTranscript reads also attach subagentMetadata to the provider message shape with the child thread id, parent thread id, and the first model found across the historical/current SDK message shapes. The renderer treats a reported model as authoritative and shows the parent model as inherited only when no child model was reported.

Claude context guardrails

Live Claude occupancy emits at most once every five seconds and only after a one-point percentage change. ADE never preempts the SDK's natural compaction. The streamed reading is refreshed from authoritative SDK getContextUsage() snapshots after runtime initialization, every settled turn, and compact completion. A compact start emits compacting; completion emits recalculating until exact postTokens or the control snapshot establishes the new measured value. A failed snapshot emits unknown, so an old 100% is never presented as fresh.

It sends a fallback /compact only at a turn boundary when all three gates hold: occupancy is at least 97%, no natural context_compact has appeared since occupancy crossed 90%, and no fallback was already issued in the same high-water episode. The episode resets below 80%.

If a turn ends with terminalReason: "prompt_too_long" (or the equivalent overflow signature), ADE compacts once and asks the user to re-send the last message. It does not automatically replay that message.

Canonical runtime events

apps/desktop/src/main/services/chat/runtimeEvents.ts defines the provider-neutral event vocabulary that runtime adapters should emit internally: turn.started, content.delta, tool.started, tool.completed, tool.failed, subagent.started, subagent.progress, subagent.completed, teammate.idle, task.completed, turn.completed, and compact.boundary.

The current migration is additive. Claude still emits the legacy underscore subagent rows used by older renderer paths, then buildCanonicalAgentChatRuntimeEvent() writes the canonical dotted subagent row beside it. AgentChatPane filters the dotted rows from the transcript display because they are coordination data, while subagent-specific panels can consume either shape during the transition.

Structured activity normalization

Adapters preserve provider richness while converging on compact event shapes:

  • Claude server web_search / web_fetch blocks become web_search start and terminal events. Claude MCP blocks become paired tool events; unfinished server activities are closed when the turn ends.
  • Codex 0.144.5 mcpToolCall items retain plugin/app/resource metadata, while native web/image/subagent items keep their specialized compact rows.
  • Cursor MCP calls and generated images, OpenCode image file parts, and Droid assistant image blocks reuse those same tool/image events.

Every event uses the provider item id (plus turn id) as its lifecycle key. Desktop chatTranscriptRows, ADE Code aggregateChatBlocks, and iOS WorkEventMapping/WorkTranscriptParser therefore update one row instead of printing repeated start/progress/result records. The Codex Sources tab derives files, web results, connector apps/actions, and external URLs from this same stream; it is a view, not a second persistence channel.

Render pipeline

apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts implements a two-layer transform:

  1. Render events. Raw envelopes become ChatTranscriptRenderEvent values:

    • Tool, command, file-change, and web-search events collapse into ChatWorkLogEntry objects (status, label, tone, diff stats, and web-search action metadata for result chips).

    • Text, reasoning, plan, status, pending input, and user-message events pass through as visible rows. Before a synthetic scheduled user_message carrying metadata.scheduledWake, the transform inserts a scheduled_wake_divider keyed scheduled-wake:<scheduleId>:<turnId> with fire time, reason, and late state; the compact while-you-were-away card scrolls to these stable keys.

    • subagent_started / subagent_progress / subagent_result events collapse per agent (keyed by agentId ?? taskId) into two stable render rows — a subagent_spawn_anchor at the start position (mutated in place as progress arrives) and a subagent_result_card at the settle position — while backgrounded shell commands collapse to a single background_job_line, pushed on the job's first sighting (so a running job is visible in the thread) and mutated in place through to its terminal state, which is never reopened by a late progress tick. Two producers feed that one row and share its key space: the LIVE runtime, which reports background shells only as scheduled_work_update {kind:"background_task"} (emitClaudeBackgroundTaskUpdate — it emits no subagent lifecycle events for them at all), and legacy subagent_* events carrying taskType: background, which is all older persisted transcripts hold. Other scheduled kinds are unaffected. Classification can legitimately flip mid-task, so both directions are guarded: once a task has opened a job line it stays a background job (a late agentType cannot strand the running line and push a card pair for the same task), and a task that proves itself a real subagent has its job line spliced out before the spawn anchor lands — the level-set path gates only on task type, so an agent that reports none falls through to the background emitter and would otherwise render as a line and a card pair. The anchor keys (subagent-spawn: / subagent-result: / background-chip:<agentKey>, the last kept from the finish-chip era on purpose) never change on rebind so the virtualizer's measured heights survive; a transcript_retraction splice repairs each stored row index, including the backgroundJobRowIndexByKey map that resolves job lines. Raw lifecycle events are then hidden.

      BackgroundJobLine renders that row as a quiet centered rule-line in the same idiom as the scheduled-wake and spawn-return dividers — deliberately not a card, because background jobs are frequent and rarely the point of the turn. Running reads ⚙ Background · <command> · <elapsed> with a once-per-second ticker anchored to the real start timestamp; a terminal update rewrites the same row to ✓/✗ · exit <code> · <duration>. The scheduled-work wire format carries no exit code, so a job reported only through the live producer shows a duration measured from its own first sighting and no exit. An open affordance dispatches ade:chat:open-info to reveal the chat actions pane, where the job's full state and output already live, and is omitted where no host registered a listener for that event.

    • ade_card collapses per cardId into ONE permanent chronological row keyed ade-card:<cardId>. A repeat emit mutates that row in place — a new object under the same key, merged over the previous payload, so an update that omits rows/metrics patches rather than blanks them — which keeps the row at its original position and preserves the virtualizer's measured height as a long-running card (CI, a build, an artifact pull) progresses. When a detail refresh fails, degradedReason does not destructively replace an earlier rich payload: rows/progress/metrics survive with stale: true, and a first-time empty failure renders detail unavailable plus its retry action instead of a false-green zero-count card. Emitters may provide durationMs only for a real measured run; the renderer labels the card update span as tracked so it is never presented as work time. rowsTruncated becomes a compact +N more summary. It is deliberately NOT activity: it is never classified by classifyActivityPhaseRow and never bundled, so an interleaved reasoning/work phase cannot swallow it. The payload contract and its helpers live in apps/desktop/src/shared/adeCard.ts, shared with the TUI; iOS mirrors it. Every surface renders fallbackText + the navTarget deeplink for a variant it does not recognize, which is what makes one wire contract safe across three independent release trains. There is no red tone in the vocabulary — failures are amber, per the house policy in SubagentActivityCards.tsx.

    • pending_input_resolved, activity, step_boundary, raw tool/ command/file-change events, standalone reasoning events, and scheduled_work_update are hidden (consumed by other derivations).

    • transcript_retraction is also hidden, but mutates the accumulated rows by removing prior assistant text rows whose provider messageId was retracted or superseded.

    • Exact duplicate error rows are collapsed by turn id, message, detail, and semantic errorInfo. This replay guard handles historical transcripts written before provider-side dedupe without hiding distinct failures from the same turn.

  2. Grouped envelopes. Adjacent work-log render events in the same turn merge into work_log_group blocks. When a tool_use_summary event immediately follows a group from the same turn, its summary and tool-use IDs are absorbed into the group instead of rendering as a separate row. This keeps the transcript compact when the agent runs many tools in a single turn.

  3. Activity phase collapse. After work-log grouping, contiguous runs of reasoning + work_log_group rows within the same turn (unbroken by assistant text, user messages, plans, pending inputs, or other hard boundaries) can merge again when the phase is noisy: at least three rows, or at least two reasoning rows, or at least two work groups. The pass emits one merged Thought row and one merged work-log group in chronological first-occurrence order. Simple one-thought + one-tool turns stay as two grouped envelopes. Shared logic lives in apps/desktop/src/shared/chatActivityPhase.ts; desktop wires it through groupChatTranscriptRows(), the TUI through aggregateChatBlocks(), and iOS through collapseActivityPhaseTimelineEntries().

  4. Client presentation. Grouping remains lossless, but normalized tool, command, hook, and web-search groups no longer occupy permanent transcript rows. During a live turn they are available from the expandable working status; after done they move to the existing turn-finished / Ran for status. On desktop the work_log_group envelopes are filtered out of the rendered timeline entirely rather than rendered empty, so they do not consume row gaps. File changes are reported once per turn, at that turn's done divider, instead of once per uninterrupted burst of tool entries — a turn whose bursts were broken up by prose used to stack six near-identical panels through one reply. Assistant narration is unchanged. Desktop and hosted web share this presentation in AgentChatMessageList; iOS opens the activity in a sheet so the working row stays readable at narrow widths; ADE Code expands the same activity from its working or turn-finished row. This is capability preserving: clients show only events and file data the selected provider actually emitted, without synthesizing Claude-style file histories for other runtimes.

Each work-log entry carries a collapseKey built from turnId, logicalItemId (preferred) or itemId, and tool/command identity. Streaming updates for the same tool call merge into the existing entry instead of appending a new row.

withLocalhostUrls(entry) runs at every emit/merge step and stamps entry.localUrls?: ChatLocalhostUrl[] whenever the entry's command/output/args/result/label/detail mention a localhost, 127.0.0.1, 0.0.0.0, or [::1] URL. The extractor (also exported as extractLocalhostUrlsFromText) trims trailing punctuation, normalises the host to localhost for the canonical href, and dedupes by href. Downstream ChatWorkLogBlock (specifically the ChatToolActivityDetails view reachable from the working indicator and the done divider) consumes entry.localUrls to render the localhost-strip chips that route into the in-app browser.

Text merging

Adjacent text events merge via shouldMergeTextRows():

  • Events with matching messageId always merge.
  • Events without messageId fall back to matching turnId and itemId.

This prevents duplicate rows when the provider streams fragmented text. For Claude SDK rows, messageId is the provider message UUID/id so a later transcript_retraction can remove the exact assistant text that a model refusal fallback or supersedes message invalidated.

plan_text merging uses shouldMergePlanTextRows() with the same heuristic. When a final plan event arrives for a turn, any preceding plan_text rows for that turn are discarded and replaced with the single plan row.

Turn diff summaries

When a turn completes on a lane and the service can compute a diff between the before and after SHAs, the service emits turn_diff_summary with per-file add/delete counts. The ChatTurnDiffPanel component renders the summary inline; individual file diffs are fetched lazily via ade.agentChat.getTurnFileDiff. This is the only summary that can offer real git diffs and a SHA-scoped revert.

A turn that changed files without moving HEAD emits no such event — no lane, a runtime with no git integration, or edits that never reached a checkpoint. Those turns fall back to ChatTurnFilesChangedSummary, derived purely from work-log entries and therefore available for every runtime (see composer-and-ui.md). Exactly one of the two renders per turn: the desktop message list suppresses the fallback for any done row whose turn id appears in the session's turn_diff_summary set.

Turn recap

chatTranscriptRows also emits a synthetic turn_recap row when a turn completes. The recap aggregates completed, failed, and interrupted tool invocations into a single summary line with task-progress counts.

Claude tool-use tracking

The Claude SDK runtime tracks individual tool invocations via the SDK's toolUseID:

  1. On tool_use_start the service records the invocation as in-progress.
  2. When the SDK returns a tool_use_summary with preceding_tool_use_ids, each ID is matched back to its pending invocation and marked complete, emitting tool_use_complete with the summary text.
  3. AskUserQuestion is special: when the SDK invokes it, the service builds a PendingInputRequest, attaches the toolUseID, and emits the request inline. When the user responds, a tool_result goes back to the SDK with the answer text, and pending_input_resolved clears the UI. There is no idle timer to suspend for human deliberation: Claude's time-based idle watchdog was removed because long tool calls emit no stream events, and pauseIdleWatchdog / resumeIdleWatchdog survive only as no-op stubs so approval and elicitation callers did not have to change.
  4. resolvedToolUseIds tracks already-resolved tool uses so double resolutions (UI double-click, interrupted turn, stale state) are swallowed rather than throwing.

Text batching

apps/desktop/src/main/services/chat/chatTextBatching.ts accumulates streaming text fragments for up to 100 ms before flushing as a single assistant-text event. This reduces renderer re-render frequency during fast streams.

Critical invariant: the buffer must be flushed immediately on every non-text event (tool call, turn boundary, error) to preserve ordering. shouldFlushBufferedAssistantTextForEvent() is the gate. Any new event type added to the union must be considered for this check.

getRecentEntries (used by auto-title and compaction flush) calls the flush helper first so reads always reflect the latest streamed content.

Every flush is labelled with why it happened — timer (the 100 ms window elapsed), identityBreak (the incoming fragment could not be appended to the buffered one), interleave (a non-text event forced the ordering flush) — and, while ADE_PERF_RUN_ID is set, recorded as a chatTextFlush perf event by services/perf/chatTextProbe.ts. That probe is what measures the real cadence the renderer has to absorb (flush count, characters per flush, deltas coalesced, gap since the previous flush). It is a no-op with no allocations when no run id is set. Note that agentChatService is not Electron-only: in a normal dev session the ade runtime daemon hosts the chat sessions, so it — not Electron main — is the process that emits these events, and both may append to the same log at once. See ARCHITECTURE.md.

The lumpiness this batching produces is what the renderer's paced text reveal smooths out; see composer-and-ui.md. The store still receives each flush whole and immediately — only the painted slice is paced.

Virtual scrolling and message-list layout

AgentChatMessageList.tsx keeps render cost proportional to the visible viewport rather than total message count using its own virtualizer — not @tanstack/react-virtual. The pieces:

  • measuredHeights: a Map from stable row key to last measured DOM height, with ESTIMATED_ROW_HEIGHT as the fallback for rows that have never been rendered.
  • Spacer divs: a top spacer offsets the rendered window to its correct scroll position and a bottom spacer fills the remaining scroll area, both sized from that map.
  • MeasuredEventRow: wraps each rendered row and reports its real height through a ResizeObserver; handleMeasure writes the map and calls reconcileMeasuredScrollTop, which adjusts scrollTop when a row above the viewport changed height so the visible content stays still.

Below VIRTUALIZATION_THRESHOLD rows the list skips all of this and renders every row directly. The transcript has one responsive content-width contract: chatAppearance.ts publishes --chat-content-width as min(100%, clamp(720px, 62vw, 1180px)) and aliases the older --chat-column variable to it. Prose, composer, cards, plans, file changes, activity details, and pills all use that token, while the JS resolveChatContentWidthPx() mirror supplies floating-pane layout math.

Card rows compose chatCardPrimitives.tsx: a fixed 16 px glyph column, flexible title/content column, and auto-sized meta/action column. Passing one-line facts use a hairline row, live/detail-bearing work uses an inset, and failures use an amber rail. The same primitives back AdeCard, CodexPlanCard, files changed, tool/work rows, and settled subagent rows.

Notable rendering rules:

  • Assistant messages and transcript cards share the responsive content width.
  • Completed-turn dividers show local time and measured duration. Tool activity and proof each have independent collapsed controls. Chat-owned proof is bucketed into turns by artifact.createdAt; expanding N proof renders the horizontal ChatProofFilmstrip immediately below that divider. The filmstrip is chronological, starts collapsed, and never moves to a pinned thread footer. Local project-relative URIs render through the artifact protocol; remote items fall back to their kind label and open the runtime-backed drawer.
  • Code blocks in assistant messages render through HighlightedCode.
  • User messages animate in with a motion/react spring transition, and over-long ones (>600 chars or >8 lines) collapse behind a CSS gradient mask with a Show full message toggle.
  • Tables use rounded borders and a subtle inset-shadow treatment.
  • System notices render compact inline rather than as pill badges.
  • Plan approval cards cap at max-h-72 with pre-wrapped text so long multi-step plans scroll.

History snapshots, scroll-back, and misses

A chat pane hydrates from ade.agentChat.getEventHistory (AgentChatEventHistorySnapshot) and pages backwards with ade.agentChat.getEventHistoryPage (AgentChatEventHistoryPage). Both DTOs live in apps/desktop/src/shared/types/chat.ts. Three fields carry the whole contract, and each exists because the obvious substitute is wrong.

Runtime callers send both actions as one object envelope ({ sessionId, beforeOffset?, maxBytes? }). The desktop preload and ADE Code TUI use that canonical shape; the runtime registry still accepts the legacy positional form while older packaged clients age out. This matters because a one-argument runtime wrapper silently discards a second positional options argument before validation, making every older-page request fail even though the initial snapshot succeeds.

hasOlderHistory — is there anything to scroll back to?

agentChatService derives it from the tail read (transcriptTruncated || windowTruncated), never from envelope object identity, so it stays correct when a snapshot was served entirely from the in-memory ring buffer or the envelopes were re-created by the coalescing/subagent pipeline (which loses identity).

Clients must gate the "load earlier messages" head slot on this field, not on tailStartOffset > 0. resolveSnapshotHistoryCursor in AgentChatPane enforces that: a hasOlderHistory === false returns cursor 0 even when a non-zero tailStartOffset is reported, because otherwise the list offers a scroll-back affordance that can only ever fail — which is exactly what produced the false couldn't load earlier messages banner. The field is optional for compatibility; older runtimes that omit it fall back to the legacy offset-only rule.

tailStartOffset — the paging cursor, in three tiers

tailStartOffset is the beforeOffset a client passes to getChatEventHistoryPage. It is resolved in strict precedence:

  1. Exact. The oldest returned event maps to a physical transcript line — use that line's byte offset.
  2. Identity lost, window complete. Nothing was dropped from the head of the merged window (!windowTruncated), so everything at or after the tail read's startOffset is already in this response and paging from startOffset is still exact. A tail that started at byte 0 yields a null cursor: there is nothing older.
  3. Degraded. Identity was lost and the merged window dropped events, so ADE cannot name the byte offset of the oldest returned event. It pages from endOffset (the end of the transcript), gated on hasOlderHistory. Pages then re-deliver events the client already shows — which the client dedupes — but scroll-back still reaches the head. This fallback must survive: removing it strands truncated transcripts whose snapshot came entirely from the ring buffer with no way to scroll back at all.

advanceOlderHistoryCursor requires hasMore and a strictly decreasing startOffset before it advances, which mirrors the service guarantee and makes client paging loops provably terminating. A page that claims hasMore without decreasing the cursor is a retryable protocol failure, not evidence that the head was reached.

Desktop, personal chat, and ADE Code request 256 KiB pages. Desktop and personal-chat selected views keep at most 60,000 events / 32 MiB resident; a background personal-chat view keeps 1,000 events / 2 MiB. Page responses are committed only while the selected session, bound runtime, request generation, and requested cursor still match.

One user-visible "load earlier" is one batch, not one page. readOlderHistoryBatch keeps pulling pages until the accumulated span contains a user_message, because pages are cut by bytes: a single page of a long streamed reply can be hundreds of superseded text delta rows that fold to one rendered line, so a page-per-trigger design reads as "load earlier did nothing". The same loop also continues through empty-but-progressing pages, so sparse transcript regions do not look exhausted. Two bounds keep it terminating: maxPages (default 8, shared by both behaviours) and maxAnchorEvents (default 400, for a turn that legitimately spans many pages). Hitting either is not an error — the reader has real content and the next scroll continues. Pages already collected in a batch are returned even if a later read reports sessionFound === false, which would otherwise latch "no older history" and drop them until a reload. An overlapping snapshot may preserve an exhausted cursor only when its oldest retained event survived the merge; a replacement snapshot or cap eviction re-arms paging.

History hydration and live delivery have separate authority. The history API owns the ordered range it returns; the live stream owns only events outside that range. shared/chatHistoryMerge.ts applies that contract across desktop and ADE Code: exact/semantic duplicates are removed, delayed live rows are inserted by timestamp before later terminal rows, and a replayed old turn can never be appended after the authoritative tail. Event identity is cached by envelope object so a 60,000-event resident window does not re-serialize every payload on each streaming flush. Desktop installs the live listener before its passive history read, while the local runtime pump replays the narrow handoff window and filters older buffered events by subscription start time. The hosted web adapter consumes chat_subscribe snapshots only as stream watermarks and hydrates visible history through chat.getChatEventHistory; it does not re-emit snapshot rows as new live messages. ADE Code uses its semantic provider-run identity for overlap, then normalizes delayed events chronologically. iOS continues to sort and dedupe its materialized event set by the same lifecycle contract.

The renderer keeps a bounded per-session view cache so switching back can paint immediately. A hidden chat's retention subscription captures the concrete outgoing project binding, even when that binding was the active unpinned path, so a later project switch cannot silently retarget the retained stream. Returning adopts that subscription synchronously, renders the cached tail, and reconciles against authoritative history without blanking the list. Composer controls are withheld for the one-frame interval where the incoming transcript id and internal selected-session id differ; an outgoing Stop button or pending input can therefore never appear over a settled incoming transcript.

unavailable — "could not reach the runtime", not "no such session"

sessionFound: false is an authoritative answer: this project runtime has no such session. unavailable: true is not — it means the bound runtime could not be reached (remote hop down, machine asleep, project switch in flight), so no history could be read at all. Clients must never clear, tombstone, or blank a chat on it.

It is produced at every boundary that can fail to reach a runtime:

  • Preload (apps/desktop/src/preload/preload.ts) returns it for getEventHistory / getEventHistoryPage when the call was left unhandled during a project transition and the window's runtime context is remote. Falling through to the local main-process chat service there was the bug: the local service has never heard of a remote session id, so it answers a false sessionFound: false that the renderer treats as authoritative and uses to wipe the transcript and its cache. Local bindings still fall through to IPC, because there the local service is the right answer. The page variant also echoes the caller's cursor back as startOffset so it does not additionally claim the head of the transcript was reached. See Remote runtime internals.
  • The web-client adapter (renderer/webclient/adapter/agentChat.ts, personalChats.ts) sets it on the fallback value used when the host command could not be reached or dispatched.

resolveChatHistoryMissAction in AgentChatPane turns a miss into one of three actions:

ResultActionMeaning
unavailable: truesync-pendingKeep everything, retry later, raise the catch-up hairline.
sessionFound: false, events renderedkeep-missingAuthoritative miss, but blanking a transcript the user is reading is strictly worse than leaving a stale-but-real one on screen. Marked for a later "chat no longer exists" pass.
sessionFound: false, nothing renderedclearSafe to drop the empty view.

Persisted transcript

Sessions persist the transcript to disk under the .ade layout. Chat replay prefers the dedicated per-session JSONL at .ade/transcripts/chat/<sessionId>.jsonl; the legacy managed transcript path can still exist for compatibility and may be byte-capped by the terminal/session storage budget. When multiple transcript candidates are present, recovery first prefers files that contain real chat event envelopes, then uncapped files, then newer readable candidates with file size only as a tie-breaker, so header-only or capped files do not hide compacted chat history.

Persisted chat events keep the same public AgentChatEvent shape, but bulky payloads are compacted before storage for rows users rarely need in full after the turn is over. Large command output, tool results (both result and the provider's raw structured payload), file diffs, reasoning text, and inline image data URIs are replaced with a short preview (or no inline media) plus original/omitted-byte metadata on the event (outputOriginalBytes, resultOmittedBytes, diffOmittedBytes, textOmittedBytes, urlOmittedBytes, etc.). Desktop/runtime live subscribers still receive the original event while a turn is active. Persisted-history consumers see the stored preview on replay.

The policy — every cap, every wrapper shape — lives in one module, apps/desktop/src/shared/chatEventCompaction.ts, because it has two consumers that must never disagree: the stored transcript (compactChatEventForStorage) and the mobile/web sync wire (compactChatEventForWire). The wire variant runs storage compaction first, so a live push and the same event re-read after reconnect hydration are byte-identical, then drops tool_result.structured and tool_result.toolResultMeta outright — no client decodes either field, so phones and web clients paid a download and a JSON parse for something they immediately discarded. Removing a field no client reads is backward-compatible by construction and needs no capability gate; adding or reshaping one still does.

Compaction must stay idempotent. The wire applies it to events that already came off disk compacted (hydration and the replay ring), and re-wrapping is not a harmless no-op: the wrapper's newline-dense preview re-serializes with JSON escaping and comes out bigger than the cap, so each pass grew the payload while overwriting originalBytes with the previous pass's size. The module recognizes its own wrapper by shape (summary starting with [ADE] Large , plus preview / originalBytes / omittedBytes) rather than by a marker key, because every surface that renders an object-shaped tool result dumps it as JSON, so an added key would become the first line the user reads — and shape detection also recognizes wrappers written by builds that predate the check. The recognition is size-bounded at 2× the cap so a provider payload cannot coincidentally buy itself an exemption.

The shortened-payload notices are user-facing copy now (the same compaction feeds phones), so they no longer mention "stored chat history". Transcripts already on disk carry the old wording; summarizeDiffStats in chatTranscriptRows.ts matches both so an old shortened diff is still counted as shortened rather than parsed as real diff lines.

sessionRecovery.ts implements version-2 reconstruction:

  • Recent entries (bounded) are parsed back into envelopes.
  • A continuity summary is injected into the new runtime context on resume.
  • Provider-native runtime state (Claude session id, Codex app-server socket path, OpenCode runtime ids) is rehydrated so the next turn can use the same session instead of creating a new one.

Claude restart and Stop recovery

Every parent turn must finish with both a terminal status and a matching done event. A process crash can occur after the user message or status: "started" has been persisted but before that pair is written. When a Claude runtime is created, agentChatService therefore checks the latest non-steer parent turn even when the previous process never persisted an SDK session id. It fills in only the missing member of the terminal pair, preserves an already-written terminal status, marks the session idle, and persists the repair. A newer complete parent turn makes an older incomplete turn irrelevant; restart recovery never rewrites historical turns.

Restart reconciliation first closes orphaned background and subagent rows, then appends the parent terminal pair last. This ordering is deliberate: renderer turn state is derived in event order, so a cleanup row must not make a repaired turn look active again. Pressing Stop on an already-idle Claude runtime runs the same parent-turn repair, which lets a stale red Stop state settle without requiring a live Claude process. Repeated reconciliation and repeated Stop calls are idempotent because an already-complete status + done pair is no longer an unsettled turn.

Live Claude control calls are bounded independently of the desktop action timeout. Provider interrupt() gets 2.5 seconds; active stopTask() calls get 2 seconds each and run concurrently. During ordinary Stop, a hung SDK control channel is logged and local interruption cleanup continues rather than holding the action bridge until its 30-second request timeout; an interrupt-and-replace request that requires provider acknowledgement fails within the control-call bound instead of sending the replacement ambiguously. Likewise, a steer sent to an idle or stale Claude session waits only for input-dispatch acceptance; the provider turn keeps streaming asynchronously instead of making the steer action wait for the full answer.

Queue handling is explicit at this boundary. stop_and_clear is the backward-compatible default and uses cancel_queued: true when the Claude session advertises the capability; otherwise ADE interrupts first and cancels each attributed provider-queued message through cancelAsyncMessage. Local pending steers are cleared in the same operation. stop_only interrupts the turn but preserves the local/provider queues. A successful clear snapshots only the queued steers the runtime actually cancelled, emits queue_recovery, and accepts one restoreCancelledQueue call for eight seconds; expiry and restore are persisted as terminal recovery events so replay cannot resurrect Undo.

A Codex turn ends through several paths — Stop, the local interrupt finish, the app-server's turn/aborted, runtime teardown, thread/deleted, an app-server crash — and every one of them runs settleCodexPendingInputs so the turn cannot leave a plan, exec, or permission card behind. That matters more for Codex than for the other providers because a Codex plan approval is raised after the turn completes, when activeTurnId is already null: Stop's "nothing to interrupt" arms are the common case for the one card that blocks every later send. The helper empties the approvals map as it settles, so the app-server's own turn/aborted arriving after a local interrupt is a no-op rather than a second receipt. Settle teardown's stop_only interrupt is the deliberate exception and leaves the cards for the user.

Codex adapters deduplicate repeated lifecycle notifications before converting them to envelope events. Terminal app-server failures use a bounded semantic key (turn id + message + detail + error identity) shared by the early notification and failed completion path; retrying notifications stay non-terminal provider-health notices. The renderer applies the same exact identity rule while replaying persisted history, so older transcripts do not regain duplicate visible failures after restart.

Gotchas

  • messageId is preferred over turn/item identity for merging. If a provider adapter stops emitting messageId, the fallback path is correct but noisier. Track regressions in shouldMergeTextRows when swapping SDKs.
  • Hidden event types drop silently. Adding a new event type that should still be grouped into the work log requires plumbing through chatTranscriptRows.ts and HiddenTranscriptEvent.
  • logicalItemId vs itemId. Collapse keys prefer logicalItemId so streaming updates of the same logical tool merge even when the provider re-emits with a new physical itemId. Missing this breaks into duplicate rows.
  • Turn diff emission depends on lane context. If a session is disassociated from a lane, turn_diff_summary will not emit. Do not rely on it for non-lane surfaces.
  • Claude parent terminal events are an ordered pair. Restart and idle-Stop repair must leave the parent status + done pair after any orphan cleanup.
  • A terminal turn does not clear a Codex card; a receipt does. A Codex plan approval is raised after turn/completed, so the renderer intentionally keeps plan-approval and question inputs across a done: completed. The only thing that retires such a card is an explicit pending_input_resolved — which is why every Codex turn-ending path has to emit one. On runtime death the receipt is deliberately withheld for a plan approval (preserveRecoverablePlanApprovals) so respondToInput can rebuild the card from the transcript; withholding it for anything else strands the composer. See README › Fragile and tricky wiring.
  • Claude idle turns close on an SDK event, never on a timer. An idle turn is opened by background/subagent output that has no result envelope of its own, so its only authoritative end is the system / session_state_changed message with state: "idle", which the SDK sends after heldBackResult flushes and the background-agent loop exits. finishClaudeIdleTurn no-ops when no idle turn is open, so handling every idle transition is safe. Do not reintroduce a time-based idle watchdog to cover this — the previous one fired false positives during long tool calls. New Claude system subtypes are caught by a compile-time exhaustiveness guard rather than by review; see README › Fragile and tricky wiring. Emitting later lifecycle rows can resurrect a stopped renderer state.
  • A failed history read is not evidence that a chat is gone. Never infer sessionFound: false from a connection or dispatch failure — set unavailable: true instead. Every new code path that can answer a chat history read without reaching the bound runtime has to make that distinction, or it will blank a healthy transcript. See History snapshots, scroll-back, and misses.
  • tailStartOffset > 0 does not mean older history exists. In the degraded tier it is a conservative end-of-file cursor. Gate scroll-back UI on hasOlderHistory.
  • Subscribed mobile history pages do not activate project runtimes. Modern sync hosts advertise chatHistoryPaging; the chat_subscribe ack carries cursorKind: "byte", tailStartOffset, and hasOlderHistory, and the phone requests chat_history pages against the already-authorized subscription. The host reads the same local, personal, or foreign quick-look transcript path already bound to that subscription. A scope mismatch or transient read failure returns unavailable: true with the caller's cursor intact; only an authoritative missing session or a strictly decreasing page that reaches zero exhausts history.