Skip to content

feat(question): flag-gated external-result rework (PR A) - #764

Merged
Astro-Han merged 25 commits into
devfrom
claude/question-tool-flagged
May 19, 2026
Merged

feat(question): flag-gated external-result rework (PR A)#764
Astro-Han merged 25 commits into
devfrom
claude/question-tool-flagged

Conversation

@Astro-Han

@Astro-Han Astro-Han commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

PR A of the question-tool rework (issue #756): introduce the `ctx.externalResult` tool primitive, registry, route, and renderer branches behind `PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT` (default off). Legacy path is bit-for-bit unchanged. Question tool now declares `externalResult: true` and flag-gates to the new path inside execute.

The new path: a tool's execute suspends on a Deferred registered with `ExternalResult.register`; a POST `/session/:sessionID/tool/respond` route resolves the Deferred with the user's submitted payload (submit or dismiss); turn abort routes through `ctx.abort` → `failIfPending` with typed reason `aborted`; session destroy routes through `onSessionDestroyed` with typed reason `shutdown`. The processor's `failToolCall` persists `ToolStateError.reason` so the renderer can show typed copy ("Interrupted") instead of the legacy substring fallback.

PR B (not in this PR): SDK regen, dock data source switch to message-stream selector, legacy state machine deletion.

Why

Closes the 7-fix recurrence on question-tool cancel UX (#419 and seven follow-up PRs). Root cause was a parallel state machine (`sync.data.question` + blockers + recovery clock) layered alongside the normal tool-call lifecycle; every fix patched one corner and broke another. The first-principles rework collapses the question lifecycle into the standard tool-call path: the running tool part itself is the source of truth, the registry holds only the suspend/resume Deferred, the renderer keys on `state.status` + typed `ToolStateError.reason`.

Design doc with the v10 architecture, decision history, and three rounds of GPT Pro adversarial review lives in `docs/architecture/2026-05-19-question-as-tool-call.md` (referenced in issue #756). PR A is the flag-gated implementation slice; PR B finishes the deletion.

Related Issue

#756

Human Review Status

Pending

Review Focus

  • `packages/opencode/src/tool/external-result.ts` — registry lifecycle (pending → resolved tombstone → cleaned), TTL semantics, `hasPending` parallel guard, `onSessionDestroyed` teardown.
  • `packages/opencode/src/session/prompt.ts` — `ctx.externalResult` wiring: `metadata.externalResultReady` write, abort listener, Deferred await.
  • `packages/opencode/src/tool/tool.ts` — narrowed `Effect.catch` so `ExternalResultError` survives as a typed failure instead of being defectified.
  • `packages/opencode/src/server/instance/session.ts` — `/:sessionID/tool/respond` route + discriminated body validator + 404/409/422 status semantics.
  • `packages/opencode/src/tool/question.ts` — flag gate, submit-payload shape validation (coerces malformed to skipped slots), dismiss return.
  • `packages/app/src/pages/session/blockers/question-fallback.ts` — skip running question parts that have `state.metadata.externalResultReady` so the recovery clock no longer arms on every flag-on session.
  • `packages/ui/src/components/message-part/parts/tool.tsx` — flag-on rendering: thin inline marker during running, completed-dismissed pill, typed `aborted`/`shutdown` copy.

Risk Notes

The new path is fully behind `PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT` (default off). Flag-off path runs the legacy code with no functional change. Flag-on UX has a known scope gap: the dock data source still reads `sync.data.blocker` + `sync.data.question`, which the new path never populates, so the dock will be empty when the flag is on. Submission round-trip is API-only until PR B regenerates the SDK and rewires the dock. This is documented inline in `use-session-blockers.ts` and exercised end-to-end by the route fetch test.

No platform/packaging/updater surface touched. No migration or data shape changes (`ToolStateError.reason` is optional). Two-surface non-ambiguity (one input surface active at a time) is preserved by the existing dock + inline timeline rule.

How To Verify

turbo typecheck (8 packages: app, ui, opencode, core, desktop, ...): 8/8 pass
bun test packages/opencode: 2815 pass / 0 fail / 9 skip / 1 todo across 221 files
bun test packages/opencode/test/tool/external-result*.test.ts: 17 pass
bun test packages/opencode/test/tool/question-decoder.test.ts: 10 pass (shape guards + count/single-select/label-membership/whitespace/trim normalize)
bun test packages/opencode/test/server/tool-respond-route.test.ts: 7 pass (submit 200, decoderless pass-through, decoder 422 + retry, dismiss skips decoder, unknown 404, double-submit 409, malformed body 400)
bun test packages/opencode/test/tool/tool-define.test.ts: 5 pass
bun test packages/app/src/pages/session/blockers/: 60 pass (includes new fallback skip case for externalResultReady)
bun test packages/ui/src/components/message-part/parts/tool.tdz.test.tsx: 3 pass (source-order regression lock for the TDZ fix)

Review rounds:
- crosscheck (Claude Opus + Codex high) on full diff: P1 payload validation + completed-dismiss renderer reachability — fixed.
- Codex xhigh consult on C1 architecture: tool-owned response decoder (route stays generic, registry holds decoder ref, 422 + entry pending for retry) — implemented in ad426ecf0.
- CodeRabbit round 1: route validation (resolved by C1), session-destroy ordering (resolved by 679b31c92), TDZ in tool.tsx (resolved by e327368ec).
- CodeRabbit round 2: externalResultReady symmetric with ctx.metadata (5fb77b0ac, P2), option label trim normalize (330d9d826 with regression test, P1), test-infra migration to testEffect (declined as P3 — better as repo-wide infra PR).
- aborted/shutdown JSX collapse (b950fabee, P3).
- Earlier P1s pushed back with code-level proof: Deferred.succeed return-value race (single-threaded JS, no preemption between yield points); onSessionDestroyed at session.remove (verified via clearPendingInteractions at session.ts:691); NUL byte separator (actually a single space); try/finally listener cleanup on interrupt — small leak, deferred to PR B polish.

Known design gap (P2, intentional): with flag on, dock data source still reads sync.data.blocker + sync.data.question, which the new path never populates — dock will be empty. Submission round-trip is API-only until PR B. Flag is default off, so no user-visible UX change on dev.

Screenshots or Recordings

No visible UI under the default flag-off state. Flag-on inline marker is a `text-fg-weak` pendingMarker label rendered to the right of the timeline; completed-dismissed renders as a similar `text-fg-weak` Skipped label. Both are intentionally minimal until PR B activates the dock.

Checklist

  • Type label — this PR carries exactly one of `bug`, `enhancement`, `task`, `documentation`. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of `app`, `ui`, `platform`, `harness`, `ci`. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of `P0`, `P1`, `P2`, `P3`. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to `Pending`, `Approved by @`, or `Not required: ` (default is `Pending`; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting `dev`, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

  • New Features

    • Question tools can be resolved via an external submit/dismiss route.
    • Inline "pending question" marker shown while awaiting external response.
    • Dismissed questions are explicitly marked in the UI.
  • Improvements

    • Fallback/timeout logic ignores externally-managed question runs.
    • Tool error states include typed reasons (aborted/shutdown/tool_failure) for clearer UI.
    • Question answer validation tightened (strict shape and semantic checks).
  • Tests

    • Extensive tests for external-result flows, decoder validation, and registry behavior.

Review Change Stack

Astro-Han added 16 commits May 19, 2026 16:58
Add optional `reason: z.enum(["aborted", "shutdown", "tool_failure"])` to
durable ToolStateError. Backward-compatible: existing stored sessions
decode unchanged with `reason: undefined`. Future commits (writer wiring,
renderer branches) consume the field; this commit only widens the
schema so old data still parses and new writers have a slot.

Part of #756 PR A (question tool external-result rework, v10 spec).
Tag "ExternalResultError" with reason union "aborted" | "shutdown".
Will be raised by ctx.externalResult when the Deferred is aborted by
the surrounding turn (ctx.abort fires) or by session destroy
(onSessionDestroyed hook). The runner narrows the Tool wrapper's
catchAll so this error survives as a typed failure rather than being
defectified, and the processor's failToolCall reads `.reason` to
populate the durable ToolStateError.reason field added in the
previous commit.

No consumers yet; pure new module.

Part of #756 PR A.
…hine

State machine: pending → resolved (tombstone, 30s TTL) → cleaned.
Exports: register, lookup, resolveIfPending, failIfPending, hasPending,
onSessionDestroyed. Cleanup is on-lookup (no timer thread); the clock
is injectable for tests.

Semantic split (addresses v10 round-3 P2 #4): onSessionDestroyed fires
only on server shutdown / session delete and rejects pending Deferreds
with `ExternalResultError({reason: "shutdown"})`. User turn aborts will
flow through ctx.abort → failIfPending with reason: "aborted" — wired
in a later commit. The two paths never overlap; reason is always
"aborted" XOR "shutdown" XOR completed-normally.

Registry semantics (addresses v10 round-2 P2 #1): hasPending counts
only pending entries (tombstones excluded); resolveIfPending returns a
discriminated outcome ("resolved" / "already_resolved" / "not_found")
that the route maps to 200 / 409 / 404. Within the 30s tombstone window
a retry of the same user gesture sees the deterministic 409 instead of
a 404 race.

No consumers yet (route, ctx.externalResult, session-destroy hook all
come in later commits).

Part of #756 PR A.
Replace the blanket `Effect.orDie` in `wrap()` at tool.ts:131 with a
narrow `Effect.catch` that lets `ExternalResult.Error` propagate as a
typed failure, while every other typed error continues to defectify —
matching the prior `.orDie` behavior so existing tool error paths are
unchanged (verified across 305 tool/* tests).

The typed `ExternalResult.Error` needs to survive the wrapper because
the processor's failToolCall will (in a later commit) read its
`.reason` and persist it as `ToolStateError.reason`. With the prior
`.orDie`, the typed error was wrapped in a Die cause and the writer
could only inspect a generic defect string.

Part of #756 PR A.
Tools that suspend on a user response can statically declare
`externalResult: true` on their `Def`. The runner / renderer / dock can
then scope behavior to only externalResult tools (preparing-state
placeholder before the registry registers the Deferred; question dock
projects only over tool parts whose tool def declares this flag).

Declaration only; the consumer wiring (the metadata flag on the
running tool part, and the renderer / dock logic that reads it) is
added in subsequent commits when ctx.externalResult is plumbed
through.

Part of #756 PR A.
The new optional `ctx.externalResult({ inputSnapshot })` method
registers a Deferred in the external-result registry keyed by
(sessionID, messageID, callID), flips the running tool part's
`metadata.externalResultReady` flag to true (so the renderer
transitions from "preparing..." placeholder to active input
controls), wires the AbortSignal so a turn cancel rejects the
Deferred with `ExternalResultError({reason: "aborted"})`, and
suspends `execute` until the Deferred settles via:

- POST /session/.../tool/respond (later commit) — `{kind: "submitted",
  value}` or `{kind: "dismissed"}` on the success channel.
- ctx.abort firing — typed failure with reason "aborted".
- ExternalResult.onSessionDestroyed (later commit) — typed failure
  with reason "shutdown".

Property is optional on Tool.Context so non-suspending tools (the
vast majority) don't need to provide a stub. The question tool will
opt in by declaring `externalResult: true` on its Def and calling
this method from a flag-gated branch (later commit). Implementation
lives in the resolveTools context factory at prompt.ts:709, captured
in the closure with the EffectBridge shape from `run.promise`.

Part of #756 PR A.
`failToolCall` now reads `error.reason` from `ExternalResult.Error`
and persists it as `ToolStateError.reason` on the durable tool part.

Mapping is narrow on purpose:
- `error instanceof ExternalResult.Error` → write `reason` (one of
  "aborted" | "shutdown"). These are the only typed reasons emitted
  in PR A.
- `error instanceof Question.RejectedError` (legacy) → leave `reason`
  undefined so the renderer's substring fallback at tool.tsx:59,74
  continues to fire for legacy "dismissed" / "interrupted" copy.
- Any other thrown/defect → also leave `reason` undefined. The
  renderer's existing generic-error copy applies.

Net: flag-off remains bit-for-bit identical to today's renderer
output (no error part written during flag-off carries a reason);
only the new ExternalResult.Error path writes a typed value.

Part of #756 PR A.
The LLM provider silent-timeout path used to re-arm rather than abort
when blockers.hasAwaitingQuestion returned true (legacy path; protects
the user from being killed mid-question-answering). PR A adds the
new path's guard alongside it: ExternalResult.hasPending(sessionID).
Either path returning true re-arms.

After PR A merges and the flag is on by default in PR B, the legacy
hasAwaitingQuestion call (and the blockers namespace) goes away;
hasPending becomes the sole guard. Until then, both coexist to keep
both flag-off (legacy) and flag-on (new) sessions safe.

Part of #756 PR A.
New server route for resolving a pending external-result Deferred.
The body is a discriminated union on `kind`:

- `{ kind: "submit", messageID, callID, payload }` — payload is
  passed through as-is to the Deferred's resolved value as
  `{ kind: "submitted", value: payload }`. Tool-specific validation
  (label membership, count, multi-select bounds) will live in the
  question tool's submit handler once flag-on lands; the route's
  contract today is structural.
- `{ kind: "dismiss", messageID, callID }` — Deferred resolves with
  `{ kind: "dismissed" }`.

Failure mode order matches v10:
  422 parse → 404 lookup → 409 already_resolved → 200 resolve

Status codes 409/404 distinguish tombstone (recent resolve, within
30s TTL — second client / retry) from absent (never registered or
TTL elapsed). The renderer can surface 409 as "answered from
another device" and 404 as "session restarted, please retry".

Part of #756 PR A.
Wires ExternalResult.onSessionDestroyed into the existing
Session.clearPendingInteractions path that already tears down the
Question/Permission/SessionBlocker services on session delete and
session archive. Pending external-result Deferreds get rejected
with ExternalResultError({reason: "shutdown"}); tombstones are
dropped.

Per v10 semantic split (round-3 P2 #4): this hook does NOT fire on
turn abort or status transitions. Turn aborts route through
ctx.abort → failIfPending with reason: "aborted". So `reason:
"aborted"` and `reason: "shutdown"` never overlap.

Part of #756 PR A.
Adds `PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT` env flag (dynamic getter
on Flag namespace; default off). When on, the question tool's
execute uses `ctx.externalResult` to suspend on the new registry's
Deferred; when off, the legacy `Question.ask` path runs unchanged
(bit-for-bit identical to pre-PR-A).

Flag-on success-channel discriminant:
- `{kind: "dismissed"}` → tool returns `metadata: { answers: [],
  dismissed: true }` (renderer keys dismiss on `metadata.dismissed
  === true`, not on `answers.length`).
- `{kind: "submitted", value}` → tool reads `value.answers` and
  formats the same user-facing string as the legacy path.

ExternalResultError (turn abort, session shutdown) is NOT caught
here — it propagates as a typed failure through the Tool wrapper
(now narrowed via Effect.catch) to the writer, which records
`ToolStateError.reason`. The legacy branch's `.pipe(Effect.orDie)`
is removed since the wrapper now handles defectification of all
non-ExternalResult typed errors equivalently.

Declares `externalResult: true` statically so the renderer / dock
can scope behavior (the "preparing..." placeholder while the
registry registers the Deferred, the inline timeline marker
during running). The flag-off behavior is unaffected by the
declaration.

Part of #756 PR A.
Updates tool.tsx render switch with the v10 two-surface rendering rule
(D1 = B, dock projection):

- Flag-on question running → thin marker "↓ Pending question — answer
  below" inline in the timeline. No submit/dismiss controls (those
  live in the dock). Detection: the new path writes
  `metadata.externalResultReady` when ctx.externalResult registers.
- Flag-on question completed with `metadata.dismissed === true` →
  "Skipped by user" friendly copy (keys on the explicit dismissed
  flag, NOT on `answers.length`, since all-blank submit is a
  legitimate non-dismiss case).
- Flag-on question error with `state.reason === "aborted" |
  "shutdown"` → friendly copy via typed branch (no substring match
  needed).
- Legacy substring fallback on `state.error` for parts written before
  the new path (reason undefined) is preserved.

The `hideQuestion` memo now hides only when the new path is NOT
active (flag-off legacy continues to hide running questions because
the dock is the only render surface). Flag-on running surfaces the
marker.

i18n: adds `ui.messagePart.questions.pendingMarker` to en and zh.

Part of #756 PR A.
The flag-on path needs a dock data source that reads running question
parts from sync.data.message and routes submissions through POST
/session/:sessionID/tool/respond. Both depend on SDK regeneration, so
the full wire-up lands in PR B. Document the gap inline so reviewers
know the dock is intentionally empty when flag is on, and PR A's E2E
exercises the route directly via fetch.
The recovery clock's missingRunning snapshot heuristic detects a
running question tool part with no matching sync.data.question entry
and arms a halt timer. With PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT on,
the new path never writes sync.data.question (the tool part itself is
the source of truth, and the external-result registry manages the
lifecycle). Without this skip, the recovery clock would arm on every
flag-on session, retry, and escalate to halt — reintroducing the
exact bug this rework targets.

Detection key: state.metadata.externalResultReady, written by
ctx.externalResult once the registry has registered the Deferred.
This keeps the gate at the part level so mixed legacy + flag-on
sessions during a rolling deploy remain safe.
…4/409/422

Cover the route round-trip end to end: register a Deferred via
ExternalResult.register, hit the route through the real instance
router, assert HTTP status and that the Deferred resolves with the
expected discriminated value. Five paths:

- submit: 200, Deferred -> {kind:"submitted", value}
- dismiss: 200, Deferred -> {kind:"dismissed"}
- unknown (sessionID,messageID,callID): 404
- double-submit inside tombstone TTL: second call 409
- malformed body (kind missing/wrong): 4xx

Abort path is covered by external-result-registry.test.ts via
failIfPending; ctx.externalResult abort wiring lives in prompt.ts and
is exercised by session-level tests in PR B once SDK regen lands.
…ss renderable

Two crosscheck findings on PR A:

- question.ts: the new POST /:sessionID/tool/respond accepts payload
  as z.unknown(), so a malformed submit (missing/non-array answers)
  would crash formatAnswers with TypeError. Validate the shape at the
  tool boundary and coerce malformed input to empty answer slots,
  semantically equivalent to a dismiss with all slots skipped. Same
  failure surface as legacy all-blank.

- tool.tsx: the completed-dismiss Match branch gated on
  newQuestionPath(), which reads metadata.externalResultReady on the
  tool part state. That flag is only present in the running-state
  metadata; the writer replaces state on completion so the branch was
  unreachable. metadata.dismissed === true is unique to the new path
  (legacy dismiss routes through the error branch), so we can drop
  the newQuestionPath gate without ambiguity.
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds an external-result execution path for the question tool (flag-gated), including Context API, question decoder, server submit/dismiss route, session/timeout integration, frontend rendering changes, extended error metadata, and comprehensive tests.

Changes

External Result Tool Execution

Layer / File(s) Summary
Feature flag and tool system contracts
packages/core/src/flag/flag.ts, packages/opencode/src/tool/tool.ts
Adds PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT, extends Tool.Context with externalResult(), re-exports ExternalResult types, adds Def.externalResult, and narrows error handling to preserve ExternalResult errors as typed failures.
Question tool implementation (external + legacy)
packages/opencode/src/tool/question.ts
Question tool declares externalResult: true; flag-on path suspends on ctx.externalResult() with strict questionDecoder validation and dismissed/submitted outcomes; legacy path still calls question.ask(); both return standardized metadata including optional dismissed.
Prompt/tool context wiring
packages/opencode/src/session/prompt.ts
Implements Context.externalResult: registers pending entries, sets metadata.externalResultReady, wires abort to reject with aborted, awaits deferred outcome, and returns typed ExternalResultOutcome.
Server route, timeout, processor, session lifecycle
packages/opencode/src/server/instance/session.ts, packages/opencode/src/session/llm.ts, packages/opencode/src/session/processor.ts, packages/opencode/src/session/session.ts
Adds POST /:sessionID/tool/respond (submit/dismiss) with validation, decoder-based 422, 404/409 handling, and resolution via ExternalResult.resolveIfPending. Integrates ExternalResult.hasPending() in llm timeout re-arm, extracts typed reason into part failure state, and calls ExternalResult.onSessionDestroyed() on session delete/archive.
Message schema updates for error categorization
packages/opencode/src/session/message-v2.ts
Adds optional reason enum to ToolStateError (aborted
Frontend question rendering and i18n
packages/ui/src/components/message-part/parts/tool.tsx, packages/ui/src/i18n/en.ts, packages/ui/src/i18n/zh.ts
Refactors hide/running-question logic to detect new path via externalResultReady, shows pending marker for running new-path questions, dismissed marker from metadata.dismissed, interrupted marker from typed state.reason, and adds i18n entries.
Legacy fallback blocker updates and safety
packages/app/src/pages/session/blockers/use-session-blockers.ts, packages/app/src/pages/session/blockers/question-fallback.ts, packages/app/src/pages/session/blockers/question-fallback.test.ts
Documents legacy vs flag-gated routing, updates findRunningQuestionFallbackSession to skip externalResultReady parts, and adds tests ensuring fallback is not armed for new-path questions.
Comprehensive tests
packages/opencode/test/server/tool-respond-route.test.ts, packages/opencode/test/tool/external-result-registry.test.ts, packages/opencode/test/tool/external-result.test.ts, packages/opencode/test/session/message-v2.test.ts, packages/opencode/test/tool/tool-define.test.ts, packages/opencode/test/tool/question-decoder.test.ts
Adds extensive tests for server route, registry lifecycle/TTL/shutdown, ExternalResult.Error behavior, message schema parsing, tool wrapper behavior, question decoder semantics, and fallback safety.

Sequence Diagram

sequenceDiagram
  participant ToolExec as Tool Execution
  participant ExtResult as ExternalResult Registry
  participant ServerRoute as POST /session/:sessionID/tool/respond
  participant Client as Client/UI

  ToolExec->>ExtResult: register(sessionID, messageID, callID)
  ExtResult-->>ToolExec: Deferred (awaiting)
  ToolExec->>ToolExec: suspend
  Client->>ServerRoute: submit/dismiss payload
  ServerRoute->>ExtResult: resolveIfPending(sessionID,messageID,callID)
  ExtResult-->>ToolExec: resolve/reject Deferred
  ToolExec->>ToolExec: resume with outcome
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Astro-Han/pawwork#430: Modifies the same findRunningQuestionFallbackSession recovery function and fallback heuristics; closely related to the fallback-safety changes in this PR.

Poem

🐰 I paused my hop to watch a submit fly,

external answers float from you to I.
Deferreds await, then gently wake,
questions answered for curiosity's sake.
A rabbit cheers — async dreams on high.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the primary change: introducing a flag-gated external-result rework for the question tool, using the 'feat(question):' conventional commit scope and type.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description comprehensively covers all required template sections with substantive detail: Summary, Why, Related Issue, Human Review Status, Review Focus, Risk Notes, How To Verify, and Checklist are complete and properly filled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/question-tool-flagged

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added app Application behavior and product flows ui Design system and user interface harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority labels May 19, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes user-path files (packages/app/src/pages/session/blockers/question-fallback.test.ts, packages/app/src/pages/session/blockers/question-fallback.ts, packages/app/src/pages/session/blockers/use-session-blockers.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@Astro-Han Astro-Han added the enhancement New feature or request label May 19, 2026
@Astro-Han
Astro-Han force-pushed the claude/question-tool-flagged branch from 2c08a55 to e67819d Compare May 19, 2026 09:53

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new "external-result" mechanism for tool calls, specifically for the question tool. It adds a new API endpoint /:sessionID/tool/respond to handle user submissions or dismissals, updates the session processor to manage pending external results via Deferreds, and modifies the UI to display pending markers and handle specific error reasons like aborts or shutdowns. Feedback identifies an undefined variable run in the abortHandler and a type mismatch in the test payload for the new response route.

Comment thread packages/opencode/src/session/prompt.ts
Comment thread packages/opencode/test/server/tool-respond-route.test.ts Outdated
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown

Perf delta summary

Comparator: pass

Profile / Scenario interaction median interaction worst long task max tbt frame gap p95 frame gap max jank count cls status
default / homepage-cold 40 -> 32 (-8) 48 -> 40 (-8) 83 -> 64 (-19) 33 -> 14 (-19) 33.3 -> 33.3 (0) 183.4 -> 183.3 (-0.1) 4 -> 2 (-2) 0 -> 0 (0) pass
default / long-session-input-lag 48 -> 48 (0) 48 -> 64 (+16) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.8 (+0.1) 16.7 -> 16.8 (+0.1) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-streaming-long 48 -> 40 (-8) 80 -> 64 (-16) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.8 (+0.1) 16.8 -> 33.4 (+16.6) 0 -> 0 (0) 0 -> 0 (0) pass
default / tool-call-expand 16 -> 16 (0) 16 -> 16 (0) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.7 (-0.1) 16.8 -> 16.7 (-0.1) 0 -> 0 (0) 0 -> 0 (0) pass
default / tool-default-open-heavy-bash 24 -> 32 (+8) 32 -> 32 (0) 64 -> 63 (-1) 14 -> 15 (+1) 50 -> 50 (0) 116.7 -> 116.6 (-0.1) 2 -> 3 (+1) 0 -> 0 (0) pass
default / terminal-side-panel-open 56 -> 48 (-8) 72 -> 56 (-16) 0 -> 0 (0) 0 -> 0 (0) 33.4 -> 33.3 (-0.1) 33.5 -> 33.3 (-0.2) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-scroll-reading 32 -> 16 (-16) 32 -> 32 (0) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.8 (0) 16.8 -> 16.8 (0) 0 -> 0 (0) 0.505 -> 0.505 (0) warn: cls
low-end / session-scroll-reading-long 64 -> 56 (-8) 72 -> 64 (-8) 67 -> 67 (0) 32 -> 26 (-6) 16.8 -> 16.8 (0) 200 -> 66.7 (-133.3) 2 -> 3 (+1) 0.011 -> 0.011 (0) pass
low-end / session-timeline-recompute 128 -> 112 (-16) 128 -> 120 (-8) 115 -> 100 (-15) 176 -> 174 (-2) 99.9 -> 83.3 (-16.6) 183.4 -> 183.4 (0) 3 -> 3 (0) 0.081 -> 0.081 (0) pass
low-end / concurrent-shimmer-extreme 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.7 (-0.1) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass

@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/opencode/src/session/prompt.ts (1)

320-329: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve soft-cancel semantics for external-result questions.

This adds a second “awaiting user” state, but cancel({ mode: "soft" }) still only checks SessionBlocker.hasAwaitingQuestion(). With the flag on, a soft cancel now aborts a pending external-result question instead of leaving it open for the user to answer. Please gate soft cancel on ExternalResult.hasPending(sessionID) too, the same way the silent-timeout path does.

Suggested fix
       const mode = options?.mode ?? "hard"
       yield* elog.info("cancel", { sessionID, mode })
-      if (mode === "soft" && (yield* blockers.hasAwaitingQuestion(sessionID))) {
+      if (
+        mode === "soft" &&
+        ((yield* blockers.hasAwaitingQuestion(sessionID)) || ExternalResult.hasPending(sessionID))
+      ) {
         yield* elog.info("cancel ignored", { sessionID, mode, reason: "awaiting_question" })
         return false
       }

Also applies to: 736-791

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/session/prompt.ts` around lines 320 - 329, The
soft-cancel path in SessionPrompt.cancel currently only checks
blockers.hasAwaitingQuestion(sessionID) and will abort external-result
questions; update the soft cancel guard to check both
blockers.hasAwaitingQuestion(sessionID) and ExternalResult.hasPending(sessionID)
(i.e., only treat as "awaiting user" if either blocker is true) so that soft
cancels do not abort pending ExternalResult questions, and apply the identical
change to the other cancel/silent-timeout handling block that mirrors this
logic; keep elog.info logging consistent when skipping the cancel.
🧹 Nitpick comments (1)
packages/opencode/test/server/tool-respond-route.test.ts (1)

145-159: ⚡ Quick win

Pin malformed-body responses to the contract status (422).

This test currently passes on any 4xx, so a regression away from the intended validation status could slip through unnoticed. Consider asserting 422 directly.

Suggested change
-        expect(res.status).toBeGreaterThanOrEqual(400)
-        expect(res.status).toBeLessThan(500)
+        expect(res.status).toBe(422)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/server/tool-respond-route.test.ts` around lines 145 -
159, The test for malformed request bodies currently accepts any 4xx; change the
assertions in the test inside tool-respond-route.test.ts (the test that builds
app via Server.Default().app and creates a session via Session.Service.create)
to require the specific contract validation status 422 instead of the range
checks—replace expect(res.status).toBeGreaterThanOrEqual(400) /
toBeLessThan(500) with a single expect(res.status).toBe(422) so regressions that
return other 4xx codes are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/opencode/src/server/instance/session.ts`:
- Around line 467-499: The route currently accepts body.payload as z.unknown()
and resolves the deferred immediately; change the handler so that after
ExternalResult.lookup({ sessionID, messageID, callID }) and before calling
AppRuntime.runPromise(ExternalResult.resolveIfPending(...)) you validate
body.payload against the registered inputSnapshot for that pending external call
(the same validation logic used in question.ts), returning c.json({ error:
"...validation_error..." }, 422) on failure; only when validation succeeds
proceed to call ExternalResult.resolveIfPending({ sessionID, messageID, callID,
value }) (and keep dismiss handling unchanged). Ensure you reference
messageID/callID to fetch the inputSnapshot used for validation and preserve the
existing response branches for outcome values ("resolved", "already_resolved",
"no_pending_tool_call").

In `@packages/opencode/src/session/session.ts`:
- Around line 589-595: The call to ExternalResult.onSessionDestroyed(sessionID)
must run even when InstanceState is missing because clearPendingInteractions()
currently bails out on missing InstanceState and prevents ExternalResult
shutdown cleanup; move the yield* ExternalResult.onSessionDestroyed(sessionID)
invocation to execute before the InstanceState guard (or split the guard so
clearPendingInteractions/legacy cleanup remains gated but
ExternalResult.onSessionDestroyed always runs), ensuring pending ExternalResult
waiters are rejected with the "shutdown" durable reason on session
delete/archive; update the function containing clearPendingInteractions(), the
InstanceState check, and any code paths related to remove() to preserve existing
legacy gating while guaranteeing ExternalResult.onSessionDestroyed(sessionID)
always executes.

In `@packages/ui/src/components/message-part/parts/tool.tsx`:
- Around line 26-35: The memo hideQuestion triggers a TDZ because createMemo
evaluates immediately but partMetadata is declared later; move the declarations
of isQuestion, isQuestionRunning, newQuestionPath, and hideQuestion so they come
after the partMetadata definition. Specifically, locate the partMetadata symbol
(where it's initialized) and relocate the functions isQuestion,
isQuestionRunning, newQuestionPath and the createMemo call for hideQuestion to
follow that declaration, ensuring the createMemo callback calls partMetadata
only after partMetadata exists.

---

Outside diff comments:
In `@packages/opencode/src/session/prompt.ts`:
- Around line 320-329: The soft-cancel path in SessionPrompt.cancel currently
only checks blockers.hasAwaitingQuestion(sessionID) and will abort
external-result questions; update the soft cancel guard to check both
blockers.hasAwaitingQuestion(sessionID) and ExternalResult.hasPending(sessionID)
(i.e., only treat as "awaiting user" if either blocker is true) so that soft
cancels do not abort pending ExternalResult questions, and apply the identical
change to the other cancel/silent-timeout handling block that mirrors this
logic; keep elog.info logging consistent when skipping the cancel.

---

Nitpick comments:
In `@packages/opencode/test/server/tool-respond-route.test.ts`:
- Around line 145-159: The test for malformed request bodies currently accepts
any 4xx; change the assertions in the test inside tool-respond-route.test.ts
(the test that builds app via Server.Default().app and creates a session via
Session.Service.create) to require the specific contract validation status 422
instead of the range checks—replace
expect(res.status).toBeGreaterThanOrEqual(400) / toBeLessThan(500) with a single
expect(res.status).toBe(422) so regressions that return other 4xx codes are
caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 81920945-7e92-4e5d-ae25-681c5c63624c

📥 Commits

Reviewing files that changed from the base of the PR and between 31236e7 and e67819d.

📒 Files selected for processing (21)
  • packages/app/src/pages/session/blockers/question-fallback.test.ts
  • packages/app/src/pages/session/blockers/question-fallback.ts
  • packages/app/src/pages/session/blockers/use-session-blockers.ts
  • packages/core/src/flag/flag.ts
  • packages/opencode/src/server/instance/session.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/session.ts
  • packages/opencode/src/tool/external-result.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/test/server/tool-respond-route.test.ts
  • packages/opencode/test/session/message-v2.test.ts
  • packages/opencode/test/tool/external-result-registry.test.ts
  • packages/opencode/test/tool/external-result.test.ts
  • packages/opencode/test/tool/tool-define.test.ts
  • packages/ui/src/components/message-part/parts/tool.tsx
  • packages/ui/src/i18n/en.ts
  • packages/ui/src/i18n/zh.ts

Comment thread packages/opencode/src/server/instance/session.ts
Comment thread packages/opencode/src/session/session.ts Outdated
Comment thread packages/ui/src/components/message-part/parts/tool.tsx
Astro-Han added 4 commits May 19, 2026 19:11
Soft cancel (Stop button / Escape) only checked the legacy
hasAwaitingQuestion blocker; with PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT
on, pending external-result questions slipped through and got aborted.
Mirror the silent-timeout re-arm in llm.ts:486 by OR-ing
ExternalResult.hasPending(sessionID) into the same gate.

Reported by CodeRabbit on PR #764.
Solid createMemo evaluates eagerly at component init. hideQuestion's
callback synchronously calls newQuestionPath() -> partMetadata(), but
partMetadata was declared on a later line so the const binding was in
its temporal dead zone. Any running question rendering through this
component path hit ReferenceError at mount.

Reorder the helper declarations so partMetadata exists before the
createMemo runs. Reported by CodeRabbit on PR #764.
clearPendingInteractions early-returns when InstanceState is missing
because the legacy Question/Permission/Blocker services depend on
instance context. But ExternalResult's registry is module-level and
remove() explicitly supports broken-session cleanup paths that have no
InstanceState. With the guard order swapped, pending external-result
Deferreds for those sessions never received the 'shutdown' reason.

Run ExternalResult.onSessionDestroyed before the guard so the
shutdown path is unconditional; legacy clearers stay gated below.

Reported by CodeRabbit on PR #764.
The route accepted any payload and resolved the Deferred immediately,
so a malformed POST became a successful tool result with all answers
silently coerced to empty. Three review rounds converged on the same
load-bearing question: should the route allow external POSTs to
fabricate a completed tool result, or only accept decoder-validated
responses?

The answer is decoder-validated. The route stays tool-agnostic; tools
that suspend on ctx.externalResult may register a response decoder
that the route runs before resolving. Decoder failure returns 422
with structured error + details and leaves the registry entry pending
so the client can correct and resubmit.

Wiring:
- external-result.ts: ResponseDecoder + DecodeResult types; PendingEntry
  carries an optional decoder; lookup() exposes it.
- tool.ts: Context.externalResult accepts decoder; re-export types.
- prompt.ts: externalResult primitive forwards decoder to register.
- session.ts route: submit branch runs lookup.decoder before
  resolveIfPending; 422 on failure (Deferred untouched).
- question.ts: questionDecoder enforces the legacy 4 reply-time rules
  (count, trim, single-select multi-answer, custom:false label
  membership). Snapshot self-check for duplicate option labels runs
  before ctx.externalResult — that rule is about the LLM's prompt,
  not the user's submission.

Tests:
- question-decoder.test.ts: rule-by-rule coverage.
- tool-respond-route.test.ts: 422 + retry success on corrected payload;
  decoderless tool still forwards raw payload; dismiss skips decoder;
  malformed outer body still 400 (zod validator, distinct contract).

Reported on PR #764 by CodeRabbit (P2 outside-diff), with the same
shape earlier flagged by crosscheck Codex P1 + Claude P1.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Addressing the outside-diff finding and the nitpick from the CodeRabbit review:

Outside-diff: soft-cancel guard for ExternalResult.hasPending — Fixed in 851116b. SessionPrompt.cancel's soft-mode guard now OR's ExternalResult.hasPending(sessionID) into the awaiting-question check, mirroring the silent-timeout re-arm in llm.ts:486. Without this, pressing Stop with the flag on would have aborted pending external-result questions.

Nitpick: pin malformed-body test to 422 — Partially adopted. The test that sends a malformed outer body ({kind: "bogus"}) continues to assert 400, because hono-openapi's zod validator returns 400 on discriminatedUnion failure (verified empirically). The 422 contract is now genuinely emitted by the new decoder failure path and is asserted directly in the new test "decoder rejects malformed payload with 422 and leaves entry pending for retry". The two error sources are distinct contracts: 400 = outer body shape (route generic), 422 = tool-owned decoder rejection (per-tool semantics). A comment in the test file documents the split.

Round-up of dispositions:

  • C3 (TDZ, e327368), C2 (shutdown ordering, 679b31c), C4 (soft-cancel guard, 851116b) — all merged.
  • C1 (payload validation) — adopted as tool-owned decoder + 422 retry (ad426ec) rather than route-level question-specific validation.
  • G2 (test payload shape) — folded into the decoder rewrite (ad426ec).
  • G1 (run undefined) — pushed back: run is captured by closure from runner() at prompt.ts:668.
  • C5 (pin 422) — adopted for the new decoder-failure assertion; the outer-body test stays at 400.

@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Adds three source-text assertions to ensure `partMetadata` is declared
before `newQuestionPath` and before the eager `hideQuestion = createMemo(...)`
call. Anyone who reorders the declarations back will flunk this test.

Source-text matches existing ui-package convention (see
button-states.test.ts and undefined-tokens.test.ts) and avoids the
runtime cost of a browser-conditions render — happydom alone resolves
solid-js/web to its server build, where router-touching components
throw notSup.

Covers the regression fixed in e327368.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/ui/src/components/message-part/parts/tool.tsx (1)

109-126: ⚡ Quick win

Combine duplicate error reason branches.

The aborted and shutdown branches render identical JSX with the same "interrupted" message. Combine them into a single condition to eliminate duplication.

♻️ Proposed refactor
-              if (isQuestion() && reason === "aborted") {
-                return (
-                  <div style="width: 100%; display: flex; justify-content: flex-end;">
-                    <span class="text-body text-fg-weak cursor-default">
-                      {i18n.t("ui.messagePart.questions.interrupted")}
-                    </span>
-                  </div>
-                )
-              }
-              if (isQuestion() && reason === "shutdown") {
+              if (isQuestion() && (reason === "aborted" || reason === "shutdown")) {
                 return (
                   <div style="width: 100%; display: flex; justify-content: flex-end;">
                     <span class="text-body text-fg-weak cursor-default">
                       {i18n.t("ui.messagePart.questions.interrupted")}
                     </span>
                   </div>
                 )
               }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/message-part/parts/tool.tsx` around lines 109 -
126, The two identical branches checking reason === "aborted" and reason ===
"shutdown" should be merged into a single condition; update the component in
message-part/parts/tool.tsx so that the existing JSX is returned when
isQuestion() && (reason === "aborted" || reason === "shutdown") instead of
duplicating the same block twice, removing the redundant branch and keeping the
single span that renders i18n.t("ui.messagePart.questions.interrupted").
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/opencode/src/session/prompt.ts`:
- Around line 758-771: The updateToolCall callback only sets externalResultReady
when match.state.status === "running", which misses parts still in "pending";
change the status check in input.processor.updateToolCall (the callback using
callID and match) to treat "pending" as reachable too (e.g., update when
match.state.status === "running" || match.state.status === "pending" or check
for !== "completed"/terminal states), and keep the existing metadata merge logic
so metadata: { ...existing, externalResultReady: true } is written for both
running and pending parts.

In `@packages/opencode/src/tool/question.ts`:
- Around line 90-97: The membership check fails when option labels contain
incidental whitespace because validLabels is built from raw q.options while
answer entries were trimmed earlier; update the validation in the block where
q.custom === false (using variables validLabels, q.options, answer) to normalize
option labels the same way answers are normalized (e.g., trim each option.label
before inserting into validLabels) so comparisons use the same normalized form
and the details object can still include the original or normalized labels as
appropriate.

In `@packages/opencode/test/server/tool-respond-route.test.ts`:
- Around line 11-12: The tests currently use a manual runtime shim (run,
AppRuntime.runPromise) and Instance.provide; migrate each test to the repo
pattern by replacing ad-hoc run(...) and Instance.provide usage with testEffect
+ Effect.gen and provideTmpdirInstance: define const it = testEffect(...) with
the same required layers (e.g., Session.defaultLayer, Config.defaultLayer), wrap
each test body as provideTmpdirInstance(() => Effect.gen(function* () { ... }),
{ git: true }), convert awaited run(Service.use(...)) calls into yield*
expressions inside Effect.gen using Service.pipe/Effect.flatMap, and remove the
run helper and manual Instance.provide blocks entirely to avoid unsafe casting.

---

Nitpick comments:
In `@packages/ui/src/components/message-part/parts/tool.tsx`:
- Around line 109-126: The two identical branches checking reason === "aborted"
and reason === "shutdown" should be merged into a single condition; update the
component in message-part/parts/tool.tsx so that the existing JSX is returned
when isQuestion() && (reason === "aborted" || reason === "shutdown") instead of
duplicating the same block twice, removing the redundant branch and keeping the
single span that renders i18n.t("ui.messagePart.questions.interrupted").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 619d669f-965b-4142-acbb-63dc78a64201

📥 Commits

Reviewing files that changed from the base of the PR and between e67819d and ad426ec.

📒 Files selected for processing (9)
  • packages/opencode/src/server/instance/session.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/session.ts
  • packages/opencode/src/tool/external-result.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/test/server/tool-respond-route.test.ts
  • packages/opencode/test/tool/question-decoder.test.ts
  • packages/ui/src/components/message-part/parts/tool.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opencode/src/server/instance/session.ts

Comment thread packages/opencode/src/session/prompt.ts
Comment thread packages/opencode/src/tool/question.ts
Comment thread packages/opencode/test/server/tool-respond-route.test.ts
Astro-Han added 4 commits May 19, 2026 20:06
LLM-supplied option labels may carry incidental whitespace. The decoder
trims answers (so " yes " becomes "yes") but used raw option labels in
the membership set, producing a 422 the client can never recover from.
Normalize both sides.
ctx.externalResult's writer only matched "running", while the adjacent
ctx.metadata helper accepts pending+running and upgrades pending to
running. Today's stream order flips parts to running before execute()
runs, so the asymmetry is not observable; matching the two keeps the
external-result writer correct under future re-orderings.
Both error reasons render the same `ui.messagePart.questions.interrupted`
copy. Merge the two `if` branches into one disjunction so the two paths
cannot drift in future copy edits.
Resolves one content conflict in packages/opencode/src/session/prompt.ts
cancel() — dev added a `source` log field; this branch added an
ExternalResult.hasPending guard alongside hasAwaitingQuestion. The
merged form keeps both: `source` is threaded through info logs, and
the soft-cancel OR-guard now reads `(hasAwaitingQuestion || hasPending)`.
@Astro-Han
Astro-Han merged commit 20aa07e into dev May 19, 2026
27 checks passed
@Astro-Han
Astro-Han deleted the claude/question-tool-flagged branch May 19, 2026 13:13
Astro-Han added a commit that referenced this pull request May 20, 2026
…l lifecycle (#772)

## Summary

First-principles refactor of the question tool that collapses the parallel `Question.ask`/`Question.recover` state machine into a single tool-call lifecycle driven by `ctx.externalResult` (the primitive shipped in #764). The question tool now suspends on a Deferred, the dock submits via `POST /session/:sessionID/tool/respond`, and every legacy concept that propped up the old design — bridge events, recovery clock, fallback refetch, blocker namespace, soft cancel, and the gating flag — is removed.

## Why

PR A introduced `ctx.externalResult` behind `PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT` to validate the new primitive without disturbing the legacy path. PR B closes the loop: switch the dock to the new selector, gate-flip ON, delete the legacy path. Three originally-planned PRs (route switch, legacy delete, follow-on cleanup) are merged into one so the dev branch never carries both implementations simultaneously.

## Related Issue

No issue — direct follow-on to #764 (PR A).

## Human Review Status

Pending

## Review Focus

- `packages/opencode/src/tool/question.ts` — the inline decoder and the snapshot-level duplicate-label guard
- `packages/app/src/pages/session/blockers/running-external-result-question.ts` — message-stream selector and the dock/sidebar tree-walk (parent session page surfaces a child agent question; matches `sessionPermissionRequest` semantics)
- `packages/app/src/pages/layout.tsx` — background question OS notification reattached to `message.part.updated`; dedup pruned on transition out of `running`; suppression walks ancestors (matches the dock)
- `packages/opencode/src/server/instance/external-result.ts` — new `GET /external-result` route that joins each pending `ctx.externalResult` Deferred with its session and message+part snapshot. Brief retry covers the register / processor.updateToolCall race window.
- `packages/app/src/context/global-sync/bootstrap.ts` — `hydratePendingExternalResults` writes the trio into session/message/part stores during the slow bootstrap phase so parent-page reload / cold-open still surfaces a child agent's pending question. Fetch failures swallowed to avoid the project-level reloadFailed toast.
- `packages/app/src/pages/session/composer/session-question-dock.tsx` — 404/409/422 toast routing and the void-promise rejection swallow
- `packages/opencode/src/session/prompt.ts` — `cancel()` signature collapse (no more `mode: "soft" | "hard"`), and the externalResult abort handler now uses `ExternalResult.abortPendingSync` so the registry tombstone lands synchronously when the abort signal fires
- `packages/opencode/src/tool/external-result.ts` — new `abortPendingSync` helper plus reordered tombstone-before-yield in `resolveIfPending` / `failIfPending` so the pending → resolved transition wins races against any concurrently scheduled Effect
- `packages/app/src/pages/session.tsx`, `submit.ts`, `use-session-commands.tsx` — every abort call now passes only `{sessionID, source}`
- `packages/core/src/flag/flag.ts` — `PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT` is gone entirely
- E2E specs in `packages/app/e2e/session/session-composer-dock.spec.ts` — five legacy-recovery tests deleted; surviving question tests drive the dock through the real tool runner via `seedSessionQuestion`

## Risk Notes

- The `/question`, `/blocker`, `/session/:id/question`, `/__e2e/ask`, and `/__e2e/publish-asked` routes are deleted. Any external client still calling them will 404. There is no known consumer outside this repo.
- `session.abort` no longer accepts `mode=soft`. Callers that passed `mode=soft` will get a 400 from the query validator. Internal callers (and the SDK) are updated; the abort renderer diagnostic no longer carries `mode`.
- Stage 9 (capability split + lint enforcement + 4 isolation fixtures + CI job) from the original 11-stage plan was dropped after discussion: the only background timers that needed gating were deleted in Stage 6, so the lint rule would guard non-existent code. Will land separately when real background code returns.

## How To Verify

```text
bun --cwd packages/sdk/js   run typecheck   ok
bun --cwd packages/core     run typecheck   ok
bun --cwd packages/opencode run typecheck   ok
bun --cwd packages/app      run typecheck   ok
bun --cwd packages/ui       run typecheck   ok
cd packages/opencode && bun test            2786 pass / 0 fail
cd packages/app      && bun test            1116 pass / 0 fail
cd packages/core     && bun test            55 pass / 1 unrelated fail (cross-spawn cwd, pre-existing)
cd packages/ui       && bun test            552 pass / 4 unrelated fail (icon-button size, pre-existing)
Fresh-eyes crosscheck round 2: Codex 0 findings; Claude 0 confirmed P0/P1 (both flagged P1s were self-marked as non-issues by the reviewer)
External GPT review surfaced 2 P1s in dock/notification glue (parent page missing child agent question; background question OS notification dropped). Fixed in 0a90193 / 6ed20d8 / 4b439d6 / 06e09a9; round-2 crosscheck on the fixes returned 0 P0/P1 from both reviewers.
Second external GPT review surfaced 1 P1: pending child-agent questions stayed invisible across parent-page reload / cold-open because Stage 6 dropped question.asked from the SSE replay buffer and the new message.part.updated path is not replayable. Fixed in 974241b by adding GET /external-result + a bootstrap hydrate phase that rebuilds the (session, message, part) trio. Round-2 crosscheck on the fix: Codex 0 findings; Claude 0 P0/P1 (remaining P2/P3 were nit-level or PR-scope-external).
Third external GPT review surfaced 1 P2 (race between abort signal and /tool/respond) and 1 P3 (silent skip when part-flush exceeds the 150ms retry window); review verdict was mergeable with both non-blocking. P2 fixed in e255aa4 by hoisting the registry tombstone out of the microtask queue (sync `abortPendingSync` helper) and reordering tombstone-before-yield in `resolveIfPending` / `failIfPending`. P3 closed without code change: skipped entries remain in the registry so the next hydrate cycle or live SSE recovers the dock; adding a warn for a never-observed path is preventive noise.
```

## Screenshots or Recordings

No visible UI surface changed — the dock still renders the same way; only its submit path moved from `/session/:id/question/:id/reply` to `/session/:id/tool/respond`.

## Checklist

- [ ] **Type label** — this PR carries exactly one of \`bug\`, \`enhancement\`, \`task\`, \`documentation\`. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
- [ ] **Routing labels** — this PR carries at least one of \`app\`, \`ui\`, \`platform\`, \`harness\`, \`ci\`. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
- [ ] **Priority label** — this PR carries exactly one of \`P0\`, \`P1\`, \`P2\`, \`P3\`. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
- [x] Human Review Status above is set to \`Pending\`, \`Approved by @<reviewer>\`, or \`Not required: <reason>\` (default is \`Pending\`; "not required" is restricted to bot-authored low-risk PRs).
- [x] I linked the related issue, or stated in Summary why there is no issue.
- [x] I described the review focus and any meaningful risks.
- [x] I replaced the example block in How To Verify with the real verification steps and the key result for each.
- [x] I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
- [ ] **(conditional)** I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
- [ ] **(conditional)** I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
- [x] **(conditional)** I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant.
- [x] I reviewed the final diff for unrelated changes and suspicious dependency changes.
- [x] I am targeting \`dev\`, and my PR title and commit messages use Conventional Commits in English.



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Child question docks now survive hard page reloads via persistent external-result hydration.

* **Bug Fixes**
  * Simplified abort behavior to improve reliability and reduce confusing abort modes.
  * Submit/dismiss flows for question docks handle common HTTP errors with clearer toasts and swallow transient failures.

* **Refactor**
  * Notifications for pending tool-question events improved and deduped to reduce noise.
  * Session blocker/recovery behaviors streamlined (less noisy auto-heal activity).

* **Documentation**
  * Added localized error strings for session-question error states.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/Astro-Han/pawwork/pull/772?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant