Answered dialogs stay dialog messages carrying their recorded outcome; the pending dialog renders as an inline card placed by the client¶
ADR-0016's transcript translation demoted a resolved dialog: an
AskUserQuestion / ExitPlanMode tool_use with a matching tool_result
fell out of the dialog branch and emitted a generic tool chip, so history
showed a collapsed chip named after the tool with truncated raw-JSON
input/output — what was asked and what was chosen was unreadable, especially
on a phone. The pending side had its own mobile failure: the picker rendered
inside the sticky bottom composer behind nested max-height scrollboxes
(multi-question forms at ~half the viewport, plan markdown capped client-side
and cut server-side at truncateLimit), effectively laying over the
conversation. Issue #56 grilled both to consensus.
The wire change is one nullable field: provider.Dialog gains Outcome. A
dialog message with Outcome == nil is pending — exactly the old meaning —
and non-nil is answered and stays in the stream as history. Everything else
(placement, the collapsed composer, full plans) follows client-side, without
touching ADR-0020's server contract.
The decisions, pinned (settled with the operator 2026-07-08 — do not re-litigate):
- A resolved dialog tool is a dialog message, never a demoted tool chip.
The claudecode fold's first pass now collects
resolutions(aresolvedToolper resolved id: the event-leveltoolUseResult/toolDenialKind— the same compat §5 ground truth the ADR-0020 verification backstop reads, so this adds a reader, not a new coupling), and thetool_usebranch emits the dialog withOutcomestamped byoutcomeFor(dialogoutcome.go). The message is not registered inbyTool: its outcome is derived up front, there is no chip to back-patch when thetool_resultevent follows. The transcript records a resolution for every live answer path — TUI, claude.ai, or lab — so history is complete regardless of who answered. - The outcome shape is display-only and best-effort.
DialogOutcome{Dismissed, Results, Approved, Feedback}withQuestionResult{Question, Chosen, OtherText}, one per question in dialog order. Recorded answers resolve against listed option labels by equality: single-select free text lands verbatim inOtherText; multi-select splits the ", "-joined record in toggle order, non-label leftovers re-joining as the free text; plan rejection feedback rides after the last"the user said:\n"marker. Anything resolved without a readable answer — denial, interrupt, the 60s unattended timeout'sanswers:{}— degrades toDismissed, never to a pending-looking nil. Outcome gates nothing; answer desync warnings stay the intent backstop's job (dialogintent.go, untouched). - Only a pending dialog means question.
deriveStatereturnsStateQuestionfor a tail dialog only whenOutcome == nil— a retro-flushed answered dialog at the tail means the agent just resumed and derives working, exactly as its demoted chip did.internal/chat's dormant-fallbacklastDialogskips answered dialogs, soAnswerDialogcan never be aimed at a resolved picker. - Pending placement is the client's; ADR-0020 decision 5 stands. The
server serves the pending dialog only through the side-channel
pending_dialogfield — never a stream message, so seqs stay reparse-stable. The SPA renders it as the interactive card inline in the chat stream (at its dialog message's position when one matches bytool_id, else appended), deduped bytool_idso the same dialog never renders twice; answered dialogs render as compact, inert Q→A summaries (a dismissed marker when no answer landed). No nested scrollboxes — the chat pane is the only scroll container. Arrival scroll: only a not-yet-seentool_idretargets the follow scroll to the card's top; a reader scrolled up into history is never yanked. - The composer collapses while a dialog is pending to a one-line waiting note pointing at the card, plus the existing Interrupt — no textarea, since free text cannot answer a focused TUI picker (only the explicit Other row carries free text). The degraded state (question with no structured dialog) keeps its locked note.
- Plan markdown is never truncated.
planDialogpasses the full plan asPrompt(issue #56 decision 4: a cut plan is unreviewable, and plans are model-output-bounded, so the payload growth is accepted), pending and answered alike — the live spool path reuses the same mapper. Question prompts stay exempt fromtruncateLimittoo: the prompt doubles as the keytoolUseResult.answersrecords under, so truncating it would break the outcome lookup.
Status¶
Accepted. Amends ADR-0016 in two places: the translation premise — the dialog
message kind now means pending or answered, Outcome disambiguating — and
its transport tension note, whose "an answered dialog re-parses as a tool
message at the same seq" becomes "re-parses as an ANSWERED dialog message at
the same seq"; the client's latest-window merge-by-seq acceptance carries
over unchanged. Leaves ADR-0020 decision 5 intact: pending dialogs stay OUT
of the message stream server-side, spool-served via pending_dialog. No
schema change, no new compat coupling — compat §5's pinned resolution shapes
gained a second reader. Settled via issue #56.
Considered options¶
- Keep the tool-chip demotion and parse the raw JSON client-side.
Rejected: it moves a pinned Claude Code coupling — the §5
toolUseResultshapes — out from behind the provider seam into the SPA, exactly what the seam exists to prevent; a future provider would leak its native shapes the same way. - Derive the displayed answer from the in-memory answer-intent registry
(
dialogintent.go). Rejected: the registry is restart-lossy and records only lab-sent answers; the transcript'stoolUseResultis stamped on every live resolution regardless of who answered (TUI, claude.ai, lab) and lives as long as the transcript. - Emit the pending dialog as a synthetic stream message server-side, so placement is uniform. Rejected: ADR-0020 decision 5 — the stream stays purely transcript-derived so seq numbers remain reparse-stable and there is nothing to un-say when the retro-flush lands at a real seq. Placement is a rendering concern, and the client already holds both inputs.
Consequences¶
internal/provider:Dialoggains the nullableOutcome; new wire typesDialogOutcomeandQuestionResult; theMessageDialogdoc reads "pending (Outcome nil) or answered".internal/provider/claudecode: the fold's first pass keepsresolutionsinstead of a boolean answered-set; the dialog branch emits answered dialogs and skipsbyTool; newdialogoutcome.go(outcomeFor/planOutcome/questionOutcome) mirrorsdialogintent.go's parsing patterns;planDialogdrops its truncate call.internal/compat: the §5 clause records the outcome derivation as a second reader of the pinned resolution shapes; the live-transcript tests re-pin the resolutions as dialog-with-Outcomeassertions instead of chip strings.internal/chat:lastDialog(the dormant fallback answer path) skips messages withOutcomeset.- SPA + mirror:
web/src/api.ts'sDialogmirror gains the outcome types;RunChat.tsxrenders the pending card inline (dedupe bytool_id, top-of-card arrival scroll), the answered Q→A summaries, and the waiting-note composer;base.cssmoves options off pill segments onto card styling.