Commit Graph
8592 Commits
Author SHA1 Message Date
Brennan Benson c53dba195c fix(native-chat): correlate a Claude replay to its dispatch by uuid
The CLI round-trips a client-supplied uuid verbatim. Measured:

  stdin : {"type":"user","uuid":"11111111-2222-4333-8444-555555555555",...}
  stdout: replay uuid = 11111111-2222-4333-8444-555555555555

So stamp one on the outgoing frame and match the echo on it. A replay now
settles the dispatch it belongs to, whatever its queue position - which closes
the limit this PR had been documenting as a redesign:

- a late replay no longer settles the NEXT dispatch after its own waiter timed
  out, handing it the previous send's identity;
- the send-failure path now checks `waiter.settledUuid` - positive evidence that
  THIS waiter was settled - instead of inferring adoption from queue membership,
  which could return `accepted` carrying another dispatch's uuid.

The CLI also dedupes on the input uuid, so a resent frame cannot land twice.

The shape gates stay as the compat path for a CLI that mints its own uuid rather
than echoing ours; there the frame carries no correlation and the head is the
only candidate. Every pre-existing test exercises that path, since their frames
carry uuids that match no waiter.
2026-08-23 11:52:44 -07:00
Brennan Benson de6f1c705d fix(native-chat): reject harness breadcrumbs that Claude stamps as replays
`isReplay` is necessary but NOT sufficient, and the binary says so. Verified in
claude 2.1.237: the model-switch breadcrumb is enqueued as
`{type:'user', parent_tool_use_id:null, isReplay:true}` whose content is the
string `<local-command-stdout>Set model to ...`. Orca triggers that path itself
from the model picker (setClaudeStructuredOption sends set_model, and reconnect
replays it), so a send in flight during a model switch adopted the breadcrumb's
uuid and the real echo appended a second row.

The CLI cannot use the marker alone either - its own RemoteSessionManager
filters the same frames by content prefix:

  isReplay"in e&&e.isReplay===!0&&typeof e.message?.content==="string"
    &&e.message.content.startsWith(cvl)   // "<local-command-stdout>Set model to "
  -> "Dropped own set_model breadcrumb echo"

So: require the marker, then reject the harness shapes with the existing
isKnownHarnessInjectedUserTurnText. This is the classifier an earlier round
deleted - but as a NARROWING on top of the marker, not a fallback instead of
it, and now on primary evidence rather than speculation.

Also stop discarding an identity the replay already claimed: a write can reach
Claude and still reject on the following flush, and reporting `unknown` there
stranded a uuid nothing else could adopt.

Found by the review loop on #15657.
2026-08-20 20:13:04 -07:00
Brennan Benson 32c199e4b9 docs(native-chat): record the measured limits of the slash-token rule
Measured against the real binary rather than reasoned about:

- `  /clear` comes back as a stamped replay, so this lane reads an indented
  token as prose too. The no-trim rule was inherited from a comment about the
  TUIs; it is now verified for `claude -p --input-format stream-json`.
- `/clear` and `/model` answer with a bare `result` and NO echo, which is the
  only case acceptsResult exists for.
- A CUSTOM project command (`/probeit`) and a path-leading message
  (`/tmp what is in here?`) both DO produce a stamped replay.

So shape cannot separate `/tmp` from `/clear`, and the command catalog cannot
either: getVerifiedNativeChatCommands('claude') is a curated `/` menu (clear,
compact, init, review, help) that omits `/model` and `/permissions`. Gating on
it strands those sends - tried, and the pre-existing `/permissions` test caught
it immediately. Recording the residual and why the two obvious fixes are wrong,
so the next reader does not retry them.

Found by the review loop on #15657.
2026-08-20 19:55:26 -07:00
Brennan Benson 7746d71bbc fix(native-chat): match a command token, not any slash-leading text
Two holes left by the previous round's "no second slash" rule:

`/README.md - what does this do?` has no second slash, so it still opted into
the result-frame path where an unrelated turn's result can claim the send's
identity. A command token has no dot.

And trimming leading whitespace contradicted `classifyNativeChatSend`, which
deliberately does not trim: "the supported TUIs only treat a line-leading token
as a command, so a draft with leading whitespace is prose". `  /clear` was prose
to the composer and a command to dispatch.

Match a command token instead: `/model`, `/clear`, `/project:foo`. Deliberately
stricter than the shared classifier, and the comment says why - over-classifying
only suppresses an echo there, but claims a wrong identity here.

Found by the review loop on #15657.
2026-08-20 19:32:57 -07:00
Brennan Benson 279db7d6bc fix(native-chat): don't treat a path-leading message as a slash command
`acceptsResult` lets a send settle on a bare `result` frame, which carries no
correlation to the waiting dispatch. It was set for any text starting with '/',
so `/Users/me/repo/src/foo.ts - explain this` opted in: an unrelated in-flight
turn's result claimed the send's identity, nothing upserted into the adopted key
(readClaudeMessageEnvelope returns null for `type: 'result'`), and the real echo
appended a second row - this PR's own symptom through the one path it had not
touched.

Pre-existing, but narrowing the user-frame gate made the `result` race the only
quick settle for these sends rather than one of two, so it ships here.

A slash command's first token has no second slash; that is exactly what
separates `/model sonnet` and `/project:foo` from a path.

Also corrects the image-replay test's comment: it is NOT red against the merge
base (the old predicate accepts that frame too). It pins that the gate never
sniffs content, which is a different guarantee.

Found by the review loop on #15657.
2026-08-20 19:22:33 -07:00
Brennan Benson cbf0574d5a revert: drop the unmarked-echo warning
Measured on claude 2.1.237: interrupting a turn while a send is queued emits a
top-level user frame carrying `[Request interrupted by user for tool use]` with
no `isReplay` AND no `tool_use_result` - so it cleared every guard and made a
perfectly healthy CLI log "sends will report delivery unconfirmed until the CLI
stamps the marker". A false diagnosis.

Worse, the one-shot latch is process-wide, so that one healthy interrupt
permanently consumed the signal and a genuinely unmarked CLI could then never
report itself. The warning was less than useless.

Diagnosing this properly means warning only when a waiter actually times out
having seen unmarked frames - more state in the function whose extra state has
been the source of every defect in this review loop. The flag pin from the same
round is the part that carries real weight; a real-binary assertion is the
right complement and is recorded as follow-up.

Found by the review loop on #15657.
2026-08-20 19:05:10 -07:00
Brennan Benson 69d024147b test(native-chat): pin the replay flag and say why an echo went unmarked
`resolveClaudeReplayWaiter` now settles a send only on the `isReplay` marker,
so `--replay-user-messages` is load-bearing - but no test asserted it was in
CLAUDE_STRUCTURED_BASE_ARGS, and both fake connections synthesize the marker
themselves, so dropping the flag would have left the suite fully green while
every real send blocked its whole ack budget and landed "delivery unconfirmed".

Pin the flag, and warn once when a top-level user frame arrives without the
marker while a send is waiting. A CLI that echoes unmarked is otherwise
indistinguishable at runtime from a turn with no tool boundary inside the
budget: both just report delivery unconfirmed.

Found by the review loop on #15657.
2026-08-20 18:38:05 -07:00
Brennan Benson 0f76371cbd test(native-chat): stamp isReplay on the second fake Claude echo too
`src/main/runtime/claude-structured-session-integration.test.ts` has its own
fake connection echoing `{ ...message, uuid: 'user-1' }` without `isReplay`, so
the send waited out its whole ack budget and settled `unknown`. CI red; missed
locally because the previous run covered only src/main/claude and
src/main/native-chat.

Second instance of the hazard the sibling comment already names: a double that
does not stamp like the real binary. Swept the tree - these two fakes are the
only ones that echo a user frame.

Found by the review loop on #15657.
2026-08-20 18:20:44 -07:00
Brennan Benson 26e1ccd3bc refactor(native-chat): require Claude's isReplay marker, drop the shape fallback
The fallback was defensive code that kept finding new ways to be wrong: it
consulted a list `src/shared/harness-injected-user-turns.ts` maintains for
prompt-derived UI (so a UI edit silently changed which frames could claim a
send's identity), it rejected a genuine echo whose own text opened with a
harness shape, its string-content branch returned before the tool-result
rejection, and the latch that switched it off lived on the session object - so
it reset on every reconnect and re-opened the original bug once per connection.

Measured against the real CLI instead: `--replay-user-messages` is
unconditional in CLAUDE_STRUCTURED_BASE_ARGS, and every echo it produces is
stamped - verified for text, image-only and text+image sends. A slash command
may get no user echo at all, which `acceptsResult` already settles on the
command's result receipt.

So the marker is the whole test. No content sniffing, no shared list, no latch,
no per-reconnect window: -98 lines.

The fake CLI in claude-structured-session-adapter.test.ts echoed sends WITHOUT
`isReplay`, unlike the real binary. That double is what made a content-sniffing
gate look necessary; it now stamps like the real thing.

Found by the review loop on #15657.
2026-08-20 18:12:54 -07:00
Brennan Benson 67785d2a08 fix(native-chat): retire the failing dispatch's own waiter, not the queue head
`dispatchClaudeTurn`'s error path shifted `dispatchWaiters[0]`, which is only
this call's waiter when nothing else is in flight. With two sends queued, a
broken pipe on the SECOND resolved the FIRST with null - reporting a delivered
message as "delivery unconfirmed" while the send that actually failed stayed
queued for its full ack budget, and the first send's replay then settled a
waiter nobody awaited.

Same mis-adoption class this PR is hardening: settle a dispatch only on
evidence that belongs to it. `waitForReplay` now hands its waiter back so the
error path can splice that one, mirroring what the timeout path already did.

Also raises the tests' ack budget to 5s. vi.waitFor's first successful poll
lands ~50ms after the waiter is armed, so a 100ms budget left almost nothing
for the frame and could expire under parallel load.

Found by the review loop on #15657.
2026-08-20 18:01:23 -07:00
Brennan Benson 61adf0878e test(native-chat): make the image-replay case guard the branch it names
The frame carried `isReplay: true`, so the gate short-circuited and
`claudeUserFrameText` never ran - the content branch the test exists for was
uncovered. Verified by mutation: making claudeUserFrameText return null when no
part carries text (exactly the regression the comment warns about) left the
test green.

Drop the marker so the frame takes the fallback, and give this one case a 5s ack
budget: it does real file I/O before the timer starts, so 100ms could expire
under parallel load before the frame arrives.

The same mutation now fails the test.

Found by the review loop on #15657.
2026-08-20 17:50:14 -07:00
Brennan Benson 341ea87dce fix(native-chat): stop the injected-turn allowlist being load-bearing
The shape fallback ran on every frame without `isReplay`, so an injected turn
whose leading text is not on the hand-maintained allowlist in
harness-injected-user-turns.ts would still steal the send identity - and an
injected turn upserts a body, so it overwrites the user's own bubble before the
real replay appends a second one.

The launch args always request replays, so once this session has seen the CLI
stamp `isReplay`, a frame without it cannot be the echo. Latch that and switch
the fallback off; it now only ever runs on a CLI that never stamps the marker,
where the alternative is every send timing out into a false
"delivery unconfirmed" and being sent twice on retry.

Also corrects the string-content comment: `readClaudeMessageEnvelope` tolerates
a plain-string `content` but builds an EMPTY envelope from it, so such an echo
settles the dispatch without upserting a body - the same as an image-only echo,
not the reconciliation the old wording implied.

Found by the review loop on #15657.
2026-08-20 17:36:28 -07:00
Brennan Benson c0ee83c8d0 fix(native-chat): trust Claude's own isReplay marker for the send identity
"Not a tool result" was too weak. Claude publishes harness-injected turns -
interruption notices, system reminders, local-command output - in the same
top-level `user` shape with a null parent_tool_use_id, and those are the WORSE
half of the class: unlike a tool result they do build a journal body, so
adopting one upserts harness text over the user's own bubble and still leaves
the real replay to append as a second row.

`--replay-user-messages` stamps `isReplay` on the genuine echo, so trust that
when present. Keep a shape test as the fallback for a CLI that omits the field,
now excluding harness turns via the repo's existing
`isKnownHarnessInjectedUserTurnText` rather than a second enumeration.

Also accept string `content`. `readClaudeMessageEnvelope` already tolerates it,
so rejecting it was a new way for a send to go unacknowledged - which times out
into a false "delivery unconfirmed" and sends the message twice on retry.

Found by the review loop on #15657.
2026-08-20 17:25:31 -07:00
Brennan Benson 54d653240d fix(native-chat): gate the Claude replay waiter on shape, not on a modeled body
Requiring `claudeMessageBody(...) !== null` inherited that function's content
model: it builds blocks only for text and URL images, while dispatch
base64-encodes local image paths. An image-only send was therefore refused its
OWN replay, ran out the 10s ack timeout, and reported a delivered message as
`unknown` - which offers retry, and retry sends it to Claude a second time. A
duplicate render traded for a duplicate send.

Reject the tool-result shape directly instead. That still keeps a tool-result
turn from stealing the identity, without making confirmation depend on which
content parts the translator happens to model.

Measured against the real CLI while checking the timeout concern: a queued
send's replay is published at the FIRST tool boundary after it is queued, 2-3ms
after that boundary's tool result (1004ms queued -> 4772ms tool_result ->
4774ms replay, across a 22s three-tool turn). So rejecting tool results costs no
time - whenever there was a frame to adopt, the right one follows immediately -
and a turn with no boundary inside the timeout times out identically either way.

Found by the review loop on #15657.
2026-08-20 17:17:49 -07:00
Brennan Benson c266acc215 fix(native-chat): adopt only the real user replay as a Claude send's identity
Claude publishes a top-level `user` turn for tool results as well as for the
`--replay-user-messages` echo of the prompt Orca dispatched. Both carry
`type: "user"` and a null `parent_tool_use_id`, so resolveClaudeReplayWaiter
adopted whichever arrived first.

A send issued while a turn is running is dispatched with tools in flight, so
the tool-result turn takes the waiter. Dispatch then records that uuid as the
provider identity, the journal aliases the submission onto an item nothing
ever upserts, and the real replay lands as its own user item - the user's
message renders twice.

Gate the waiter on the same body claudeMessageBody builds, so the identity
dispatch adopts is always the one the echo upserts into. An unrecognized
frame leaves the send `unknown` ("delivery unconfirmed"), never duplicated.

Both frames in the regression test are captured verbatim from
`claude -p --input-format stream-json --output-format stream-json
--replay-user-messages`; the previous double invented a `user` frame with no
message body at all, which the CLI never emits.
2026-08-20 15:51:52 -07:00
Brennan Benson 10f565017e fix(native-chat): recover Claude outbox and resume state 2026-08-14 14:07:20 -07:00
Brennan Benson 53b2b51d60 Merge remote-tracking branch 'origin/brennanb2025/codex-structured-mobile-write' into brennanb2025/claude-structured-mobile
# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts
#	src/main/runtime/orca-runtime.ts
2026-08-13 13:29:28 -07:00
Brennan Benson 84727e695a fix reverse structured session handoff 2026-08-13 12:43:05 -07:00
Brennan Benson ecdd5c8dd9 fix structured session TUI handoff recovery 2026-08-13 11:22:13 -07:00
Brennan Benson ec43fdf43d Merge commit '114eee22dd9fb11f5e814dad36d5298d6768c57e' into brennanb2025/claude-structured-mobile
# Conflicts:
#	mobile/src/session/mobile-structured-tui-send.test.ts
#	mobile/src/session/use-mobile-structured-session-options.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
#	src/main/runtime/orca-runtime.ts
#	src/main/runtime/structured-tui-process-identity.test.ts
#	src/main/runtime/structured-tui-process-identity.ts
#	src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts
#	src/renderer/src/runtime/local-structured-session-tabs-sync.ts
2026-08-13 11:18:15 -07:00
Brennan Benson 114eee22dd fix structured provider visibility and request handling 2026-08-12 21:59:58 -07:00
Brennan Benson 414129d4b2 fix(native-chat): preserve Claude question groups 2026-08-12 21:04:49 -07:00
Brennan Benson b8d906228f fix structured outbox and tab restore races 2026-08-12 20:27:38 -07:00
Brennan Benson e444d4bedd fix(native-chat): drop local terminal topology from structured sync 2026-08-12 19:36:51 -07:00
Brennan Benson 1377932db3 fix(native-chat): keep structured recovery provider-neutral 2026-08-12 18:46:05 -07:00
Brennan Benson 20c2deda29 fix(native-chat): queue stale idle TUI handoffs 2026-08-12 18:42:39 -07:00
Brennan Benson 4446a4681a fix(native-chat): wait for structured TUI process proof 2026-08-12 18:41:13 -07:00
Brennan Benson 6f30927a6c fix(i18n): catalog structured session recovery control 2026-08-12 18:40:21 -07:00
Brennan Benson c23139423a fix(native-chat): gate restart recovery publications 2026-08-12 18:39:52 -07:00
Brennan Benson a03beee134 fix(native-chat): revalidate restored and retried owners 2026-08-12 18:39:16 -07:00
Brennan Benson 35fb039187 fix(native-chat): validate pinned launch environment 2026-08-12 18:38:23 -07:00
Brennan Benson e2b3600e3b fix(native-chat): close handoff admission races 2026-08-12 18:37:56 -07:00
Brennan Benson 62b26c58b9 fix(native-chat): serialize structured handoff recovery 2026-08-12 18:37:10 -07:00
Brennan Benson 2d3b082ab2 fix(native-chat): harden shared handoff recovery 2026-08-12 18:36:05 -07:00
Brennan Benson 53a3a9442b fix(native-chat): preserve handoff launch authority 2026-08-12 18:32:07 -07:00
Brennan Benson 8b2601b2f2 fix mobile structured first-send hydration race 2026-08-12 18:26:18 -07:00
Brennan Benson 90e3ead5cb fix(native-chat): keep Claude option controls idle 2026-08-12 18:24:50 -07:00
Brennan Benson 15ebaa1d74 Merge commit 'd53823acc7' into brennanb2025/claude-structured-mobile
# Conflicts:
#	mobile/src/session/use-mobile-structured-session-entry.ts
#	mobile/src/session/use-mobile-structured-session-options.test.ts
#	mobile/src/session/use-mobile-structured-session-options.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
#	src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts
#	src/main/runtime/orca-runtime.ts
#	src/shared/agent-session-record.ts
2026-08-12 18:06:39 -07:00
Brennan Benson d53823acc7 test(native-chat): give the host-oracle matrix test an explicit timeout 2026-08-12 17:40:41 -07:00
Brennan Benson 5fc6f7a1cc test(native-chat): derive refusal retry state from host 2026-08-12 17:35:57 -07:00
Brennan Benson 2355a20b29 fix(native-chat): rotate refused send operations 2026-08-12 17:21:10 -07:00
Brennan Benson d82d807180 fix(native-chat): rotate settled handoff operations 2026-08-12 17:04:49 -07:00
Brennan Benson 7f4a1d13f7 fix(native-chat): replay pending option mutations 2026-08-12 16:54:16 -07:00
Brennan Benson 973dd58aba fix(native-chat): preserve options across structured handoffs 2026-08-12 16:40:09 -07:00
Brennan Benson 2c8b009101 fix(native-chat): hydrate resumed structured options 2026-08-12 14:16:00 -07:00
Brennan Benson 5971fcb437 fix(native-chat): persist structured session options 2026-08-12 13:58:48 -07:00
Brennan Benson 1cf45caa24 fix(native-chat): route structured Codex options directly 2026-08-12 12:43:24 -07:00
Brennan Benson 5c2d2bf6e3 fix(native-chat): queue stale idle TUI handoffs 2026-08-12 02:58:46 -07:00
Brennan Benson f5cfa241a1 fix(native-chat): wait for structured TUI process proof 2026-08-12 01:54:47 -07:00
Brennan Benson b44714fe56 fix(i18n): catalog Claude session controls 2026-08-12 01:04:10 -07:00