Skip to content

Native chat: desktop and mobile read the host's working answer; plain refusal for a stopped agent's prompt (3/5, rebuild of #26867) - #28104

Merged
brennanb2025 merged 26 commits into
mainfrom
brennanb2025/settle-split-3
Oct 15, 2026
Merged

brennanb2025 merged 26 commits into
mainfrom
brennanb2025/settle-split-3

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part 3 of 5 of a stack that rebuilds #26867 as smaller PRs. Stack: Part 1 #28092 (merged) → Part 2 #28103 (merged) → Part 3 (this PR) → Part 4 #28111 → Part 5 #28122. Base: main (Part 2 is merged as 3bfb1149da6).

ELI5

Part 2 taught the host to say, in one place, whether a chat's agent is really working and which approvals or questions are still waiting on you. This part makes the desktop app and the phone app use that answer instead of guessing from saved rows, so a dead agent's leftovers stop showing as Working or as an answerable approval. If you do try to answer a question from an agent that has stopped, Orca now says so plainly instead of sending the answer into the void.

What Changed

The problem

  • Desktop and mobile derived Working, Stop and "this prompt can be answered" from the rows they held. A dead agent's leftover turn and its pending approval looked live until a cleanup write landed.
  • Answering an approval or question from an agent that had already stopped could reach whichever agent runs now, or none, with no clear message.
  • Cancelling such a question on main also ran the chat's Stop on the new turn.

Before and after, as you see it

Before. After an agent died with an approval pending, the chat could still show Working and Stop, the approval still looked answerable, and pressing Cancel on it also stopped the chat's next turn.

After.

  • Desktop and mobile show Working, Stop and answerable prompts exactly as the host says. Against an older host they keep today's client-side derivation.
  • A stopped agent's question row stops looking active.
  • Answering it is refused with, for example, "Claude has stopped, so your answer was not sent. Send a message to continue."
  • Cancelling it only dismisses it. The chat's Stop does not run, the queue is not paused, and queued cards stay.

How it works

  • Clients. The shared reducer and coalescer keep the host's working and actionablePromptIds across batches the way they already keep latestTurn (a batch with rows restates them; one without leaves them alone). structuredAgentSessionHostSaysWorking and isActionableStructuredAgentSessionPrompt prefer the host's answer and fall back to the old derivation when a host sends none. Desktop (transport state, message list, transcript rows, resolution receipt, attention observation) and mobile (session hook, cancel, prompt responses, turn timing, send outcome) read them.
  • Host refusal. performPrompt refuses an answer to a prompt that is not current work with a new refusal reason, promptOwnerEnded, before any provider sees it. Its copy names the agent and says what to do next (providerExitedAnswer, in all six locales).
  • Dismiss-only Cancel. Cancelling a prompt an ended generation raised dismisses it in the journal alone, through the existing dismissPrompt with no provider call (no live provider holds that card), so the Cancel's receipt commits in the same write and a failed save answers as main's dismissal does. The live turn is interrupted only when the cancel names it, through its own cancel, never the chat's Stop.

Why

The host already knows which work is current (Part 2); having every client re-derive it from rows is what kept leftovers looking live. Preferring the host's fields and falling back otherwise keeps mixed versions working in both directions, which matters because desktop, mobile and remote hosts update independently.

Differences from the common pattern and from main

  • INTENDED, safer than main: cancelling a stopped agent's Claude question only dismisses it. Main stopped the chat's new turn too.
  • INTENDED, approved by Brennan: on the failure path, an older desktop or phone may still show stale Working and Stop, or a stale approval, which the host then refuses in plain words.
  • TEMPORARY: none.
  • Pre-existing, unchanged: a card behind a dead agent's turn reads Steer until cleanup lands.

Linked Issue

Part of the rebuild of #26867 (root-cause follow-up to #26664).

Visual Proof

No new UI: the change is which existing state shows (no Working, no answerable approval) after an agent dies. The full stack's before/after screenshots and QA, run on the combined code, are in Part 5.

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • New: structured-agent-session-ended-prompt-cancel.test.ts (Cancel on an ended agent's question only dismisses it: no Stop, no interrupt, queue untouched) and use-structured-agent-session-transport-state.working.test.tsx (desktop Working is the host's answer; an older host falls back to rows). Added cases: the reducer keeps the host's working and prompts across a quiet frame, takes a row-less restatement, and drops them for rows from a host that names none; the phone reads Working the same way with the same fallback; the refusal names the agent; the resolution receipt stops breathing for an ended agent's question; failure words cover the new sentence. The current-work test from Part 2 now also checks what a client reduces from the host's frames (working, the running turn, whether the prompt is answerable).

  • One flow per client and one older-host fallback each. Per-helper prompt tests, a transcript-placement test, the Steer cases that match main and per-reason refusal rows were dropped.

  • Merged main b83a6f74be9 through Part 2. Main now names the agent in "didn't accept this message"; this part's stopped-agent words keep their own module beside it. Main grew both chat-session hooks to their line limit, so the prompt reads take the transport state whole on desktop and one memo on the phone.

  • Merged main 3bfb1149da6 (Part 2's squash). Files this part never changed take main's text; where both changed a file, this part's change sits on main's, checked against Part 2's final head 674dbedd87e. Main's feat(native-chat): add a "New" line above unseen messages #28333 (a "New" line above unseen messages) crossed two of them: the client coalescer keeps this part's actionablePromptIds and working beside main's hasNewer, and the transcript row keeps this part's actionable-prompt import beside main's new-rows marker.

  • Local at 3dce3120fea: tc:node, tc:web, tc:cli, mobile typecheck and the mobile tests typecheck ratchet pass (on openclaw); src/main/native-chat (405 files), the native-chat renderer (381 files) and the mobile session suites (257 files) pass under Node; CI's lint and verify scripts pass apart from the known local-only mobile no-cycle. The changed-code and type-aware lint passes are left to CI.

  • CI at 3dce3120fea: green, 16 pass and 16 skipped, including the five test shards, relay integration, static analysis and typecheck, both packaging jobs and the test vs non-test line count check against main. package (windows) passed on its one rerun: its first run timed out a Windows installer process probe (nsis-process-check.test.mjs, untouched here) after the probe had printed its result.

  • Size against main: production +474/-119, tests +386/-39.

What the reviewer should check

  • Each client prefers working and actionablePromptIds when present and falls back otherwise.
  • The coalescer and reducer merge both fields across batches the way latestTurn already merges.
  • The phone and the desktop behave the same.
  • Answering a prompt that is no longer current is refused before any provider sees the answer.
  • Cancelling an ended run's prompt dismisses it in the journal only. The live turn is interrupted only when the cancel names it. The cancel never triggers the chat's Stop, never pauses the queue, and never withdraws queued cards.
  • The new copy names the agent and says what to do next.

AI Disclosure

Review

Parts 1 and 2 have merged, so this targets main. Parts 4 and 5 build on this branch.

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Mobile: reads the same host fields and falls back the same way; mobile typecheck and ratchet run.
  • Remote wire / mixed versions: new client against an old host derives as today; old client against a new host ignores the fields and the host refuses a stale answer in plain words.
  • Localization: one new string in en, es, fr, ja, ko, zh.

Author: Brennan Benson (@BrennanKB5).

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.
… record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.
…wers to a stopped agent plainly

Desktop and mobile prefer the host's working and actionablePromptIds fields
when present (merged across batches like latestTurn) and fall back to today's
row derivation against an older host. A prompt an ended agent raised stops
looking active; answering it is refused before any provider sees it, with
"<Agent> has stopped, so your answer was not sent. Send a message to continue."
Cancelling it only dismisses it in the journal: it no longer triggers the
chat's Stop, pauses the queue or withdraws queued cards.
…l until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.
…wner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).
…nd the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.
…plit-3

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts
…ack each

Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows.
…s as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.
…plit-1

# Conflicts:
#	src/main/acp/acp-timeline-grok-subagent-retention.test.ts
#	src/main/acp/acp-timeline-grok-subagents.test.ts
…n removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.
…amping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.
…plit-2

Brings in main 6741f4f through part 1. Main's history page now reads with a
HistoryReader, so the published current work moves to agent-session-published-work.ts and
lays over each finished page. Main's new Stop path (chat-stop target, acceptance, unconfirmed
note, targeted cancel) and the per-subagent row writer read what runs through the host's
current-work projection, as the rest of this part does.
…plit-3

Brings in main 6741f4f through parts 1 and 2. Main now names the agent in "didn't accept
this message"; the stopped-agent words this part adds keep their own module beside it.
…isions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.
…plit-2

Carries part 1's fix: an item's provenance is set when it is created and revisions keep it.
…plit-3

Syncs main b83a6f7 and part 1's creation-only provenance through part 2. Main grew both
chat-session hooks to their line limit, so this part's prompt reads take the transport state
whole on desktop and one memo on the phone.
…pt, through dismissPrompt

The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation
receipt and without the dismissal's failure handling, so a receipt write failing after it would
answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which
carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a
null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's
adapter refuses a fence with no live session outright).
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/settle-split-2 to main October 15, 2026 03:21
Part 2 landed on main as 3bfb114, so files this part never changed take
main's text. Where both sides changed a file, the result is this part's
change laid onto main, checked against Part 2's final head 674dbed:
- the client coalescer keeps this part's merged `actionablePromptIds` and
  `working` beside main's `hasNewer` (#28333, the "New" line above unseen
  messages);
- the transcript row keeps this part's actionable-prompt import beside main's
  new-rows marker imports (#28333).
@brennanb2025
brennanb2025 marked this pull request as ready for review October 15, 2026 06:41
@brennanb2025
brennanb2025 merged commit d9de1fa into main Oct 15, 2026
55 of 57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant