Skip to content

feat(guest): turn-based guest participation (fix iOS guest never replying) - #3

Merged
boggspa merged 3 commits into
masterfrom
feat/ios-guest-turn-based
Jun 18, 2026
Merged

boggspa merged 3 commits into
masterfrom
feat/ios-guest-turn-based

Conversation

@boggspa

@boggspa boggspa commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Problem

Guest chats (host + one attached guest) worked on desktop but the guest never replied on iOS — you could add/configure a guest, but only starting a side-chat with it directly produced a response.

Root cause (GAP B from the 2026-06-16 guest-parity review): the guest trigger + reply-mirror lived only in the renderer (App.tsx). iOS-origin turns run through the Mac bridge's composerPromptFn, which never touches the renderer — so it only ever dispatched the host. The bridge didn't even tell the host a guest was attached.

Separately, desktop was a parallel fan-out (host and guest fired together), so the guest never saw the host's reply to the same turn.

Fix — turn-based guest participation on both platforms

Per the chosen behaviour: host responds first, then the guest (so the guest sees the host's reply); an @-tag aims the prompt at only that agent (ensemble-style).

  • New shared module src/main/GuestParticipantRun.ts (node-safe, unit-tested): steering preamble, parent-transcript peer-context builder, full guest-prompt composer, and the guest-return-${runId} reply-mirror message builder (deduped by guestRunId). Provider-label injected so it's tree-agnostic.
  • Bridge (the iOS fix) — composerPromptFn now passes guestParticipant to the host prompt (host-awareness), routes @-tags (@guest → guest only, @parent/@host → host only, no tag → host then guest), and after the host run finalizes dispatches the guest as a normal leaf run on its child chat (so it answers with the host's reply in context), then mirrors the guest's reply into the parent transcript on the guest run's finalize. Wired via a module-ref runner assigned in the bridge closure so the module-scope finalizeBridgeRunTranscript can reach the closure dispatch helpers.
  • Renderer (parity) — the fan-out now defers the guest: it's dispatched from the run-completion handler once the host run on that chat finishes (with a fresh chatRecord), instead of firing in parallel. @guest stays immediate; @parent/@host stays host-only.

iOS needs no changes — it already renders guestParticipantReply provider-tinted/attributed (GAP A, cb74513a) and excludes the guest from the side-chats tab (GAP C, 56899180). Once the Mac mirrors + broadcasts the guest reply, the phone shows it.

Verification

  • npm run typecheck:node + npm run build (main/preload/renderer) — green; confirms the cross-tree extractGuestParticipantAddressTarget import bundles into main.
  • Full vitest suite: 405 files / 4838 tests passing.
  • New unit tests: GuestParticipantRun.test.ts (13). @-routing already covered by ComposerMentionTrigger.test.ts; host guest-context by PromptComposition.test.ts.

Remaining verification (not in this PR)

  • Manual device test: on a paired iPhone, send to a guest chat → host replies, then the guest reply appears in the parent transcript; @guest / @parent route correctly.
  • Optional fake-provider e2e for the bridge host→finalize→guest→mirror chain (the bridge wiring is integration-level; the pure logic is unit-tested). Happy to add it if wanted before merge.

🤖 Generated with Claude Code

boggspa and others added 3 commits June 18, 2026 10:11
New node-safe module shared by the renderer and the Mac bridge: the
steering preamble, parent-transcript peer-context builder, the full
guest prompt composer, and the guest->parent reply mirror message
builder (deduped by guestRunId). Provider-label is injected so the
module stays free of any renderer/main label-resolution split.

Foundation for making guest participation turn-based AND reaching
iOS-origin turns (which run through the bridge, not the renderer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…turns)

iOS-origin turns run through the bridge composerPromptFn, which only ever
dispatched the host — so a configured guest never replied on the phone
(the trigger lived only in the renderer). Now the bridge:

- tells the host a guest is attached (passes guestParticipant to
  composeRunPrompt) so it can anticipate/avoid conflicts;
- routes @-tags like ensemble (@guest -> guest only, @parent/@host ->
  host only, no tag -> host then guest);
- after the host run finalizes (reply persisted), dispatches the guest as
  a normal leaf run on its child chat so it answers WITH the host's reply
  in context (turn-based);
- mirrors the guest's reply into the parent transcript on the guest run's
  finalize (guest-return-${runId}, deduped by guestRunId).

Driven via a module-ref runner assigned in the bridge closure so the
module-scope finalizeBridgeRunTranscript can reach the closure dispatch
helpers. iOS needs no changes — it already renders guestParticipantReply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The renderer fanned the guest out in parallel with the host, so the guest
never saw the host's reply to the same turn. Now a fan-out send defers the
guest: it's dispatched from the run-completion handler once the host run on
that chat finishes, with the host's reply already in the parent transcript
(passed via a fresh chatRecord). @guest stays immediate (guest only);
@parent/@host stays host only. This matches the new bridge behavior so
desktop and iOS guest chats now behave identically (turn-based).

Also drop the host-success gate on the bridge guest dispatch so the guest
still answers when the host run fails (fallback / second opinion), matching
the renderer completion hook which fires regardless of exit code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@boggspa
boggspa merged commit 9df0e08 into master Jun 18, 2026
16 checks passed
boggspa added a commit that referenced this pull request Jun 18, 2026
Build 21 ships the merged iOS work: turn-based guest participation (PR #3,
Mac-side bridge), active-chat/sidebar/Settings-sheet state preservation
across settings changes (PR #4), and surfaced new global/ensemble chat
create failures with retry instead of an infinite spinner (PR #5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
boggspa added a commit that referenced this pull request Jun 18, 2026
…nd summary

Build 23 = PR #8: composer collapses to one line on blur (above rows +
telemetry follow focus; ensemble included) + ensemble Task-complete summary
aggregates the whole round (#3 is Mac-projected, lands with v1.5.9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
boggspa added a commit that referenced this pull request Jun 23, 2026
Roadmap #3. The transcript scroll listener fired `evaluate` on every raw
scroll event, and `evaluate` reads scrollHeight/scrollTop/clientHeight — so a
programmatic autoscroll write (one per delta) interleaved a forced layout read
with each write, thrashing layout during streaming. Coalesce to at most one
evaluation per frame via requestAnimationFrame, exactly matching the
already-proven rAF-throttled Raw-Events twin in the same file. The synchronous
wheel/touch/key intent listeners still capture scroll-away, so the one-frame
re-engage delay cannot drop the disengage signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
boggspa added a commit that referenced this pull request Jul 3, 2026
Local-model orchestration (checklist #3, root-cause fix for format discipline):

On the json-tool-fallback path, pass a JSON SCHEMA as Ollama's `format` (envelope
{"taskwraith_tool":{name:<enum of advertised names>,arguments:object}}) instead of
the bare 'json' string. Ollama compiles it to a GBNF grammar in llama.cpp, so the
model literally cannot decode a wrong wrapper key or a hallucinated tool name —
eliminating most of the tool-format failure class at the source rather than
repairing it after the fact. The bare 'json' already assumed a tool call was
expected (it forces JSON even for prose), so this is a strict tightening in the
same modes; falls back to 'json' when the name list is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@boggspa
boggspa deleted the feat/ios-guest-turn-based branch August 1, 2026 09:49
boggspa added a commit that referenced this pull request Aug 7, 2026
…an say who wrote it

A peer thread message has always named the sending thread and nothing about the
sender. `ThreadMessageEvent` carried no provider, model, reasoning, permission
tier or role, so the receiving card could only ever show a decorative identicon
and a sentence — you could not tell a read-only Sonnet seat from a full-access
Opus one.

The seat is captured AT SEND TIME rather than resolved from `fromChatId` when
the card renders. Resolving late would let a later reconfiguration of the peer
thread silently rewrite what the reader is told about a message they already
received, and a solo peer chat has no participant to resolve at all. Same
reasoning the close-out table already uses for its seat column.

NO SCHEMA BUMP, deliberately — a bump here is a data-loss bug.
`normalizeThreadMessageInbox` opens with `if (record.schemaVersion !==
THREAD_MESSAGE_SCHEMA_VERSION) return emptyThreadMessageInbox(...)`, so moving
1 -> 2 discards every stored inbox on load: undelivered messages the user has
never seen, AND `deliveredIds`, which is the exactly-once guard. `seat` is
optional at v1 instead, so old records keep loading and simply render the
seatless line the card needs for them anyway.

The second trap in that same function is that it does not spread the stored
record — it REBUILDS each event through `createThreadMessageEvent` from a field
allowlist. A field threaded into the type but not into that loop is dropped on
every reload, and looks perfectly persisted until the app restarts. A test pins
the round-trip.

Capture rules, agreed with the seat element's author:

- Never emit a seat without BOTH provider and model. No seat is honest; an empty
  model is not, and it would collide with the close-out's use of that state to
  mean "we never saw one".
- Carry `thinkingEnabled`. It is a separate input from `reasoningEffort` and a
  Kimi/Mistral seat renders a blank suffix without it.
- Drop `grantsCount` (describes the workspace, not the sender) and `seatNumber`
  (`participant.order`, 1-based within ONE roster — a peer sender's "#3" names a
  position in a roster the reader is not in). Note `#N` stays correct for a
  fan-out lane, which does share the reader's roster.

Only the agent path captures. Both user-origin paths — the UI handler and the
paired-device bridge — deliberately store no seat: a human tapping send is not
an agent seat.

`resolveCallerSeat` is a new injected dep so the MCP executor stays free of the
store; the host resolves it via `getParticipantIdForRun`, so an ensemble send
names the seat that actually made the call rather than the chat's default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
boggspa added a commit that referenced this pull request Aug 7, 2026
… idiom stops being three

Three transcript surfaces answer the same question — which participant produced
this? — and each answered it in its own vocabulary. The peer thread-message card
had a decorative identicon, the sub-thread return card a bare provider label,
and the fan-out lane card `segmented-control-action` pills, while the
seat-change row and the round close-out both spoke the composer's chips. Only
`LiveActivityViewport` was actually shared.

The fan-out lane now renders `SeatStateChips`. `transcriptSeat.ts` is the shared
decoder that gets it there, and the two hosts that need a role render it
themselves via `composedSeatRole` rather than a `showRole` flag on the shared
component — the seat element's author declined that flag deliberately, since
each host wants the role in a different place.

`#N` IS KEPT HERE, and that is the opposite call from the peer-message card. A
fan-out lane belongs to the reader's own roster, so "#3 Reader" names a seat
they can see; a peer sender's seat number is an ordinal in a roster the reader
is not in. Same token, opposite meaning — the tests on both sides say so
explicitly, because the obvious instinct is to unify them.

The lane rows now carry `ensembleSeatSnapshot`, which the ChatRun already had
but the message did not. That is what makes the permission tier honest: the flat
`ensembleProvider`/`ensembleModel` fields cannot express it, and the seat
element resolves an absent preset to `'default'` — so a lane rendered without
the snapshot would claim "Accept Edits" for a run that may have been read-only.
Rows written before the snapshot existed therefore keep the original pills,
which claim nothing about permission at all.

Ollama and Pi lanes: the upstream brand overrides are respected as they always
were — the card accent, the chip hue and the provider LABEL are all the upstream
brand (Alibaba purple for a Qwen on Ollama, never generic Ollama green). The
serving provider's logo now appears beside that label, matching what the
close-out table and seat-change row already render for the same seat (owner call
2026-08-05). The tests were tightened rather than relaxed: they now pin the hue
AND the label, and assert the raw provider hue never wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
boggspa added a commit that referenced this pull request Sep 2, 2026
The electron-vite CLIENT build broke three times (last fixed in
cefa4b2) on one class: a module transitively reachable from
src/renderer/** via value imports carries a NAMED import of a Node
builtin, which dies at rollup's bind pass against the
__vite-browser-external stub. vitest, both typechecks, and dev never
run that pass, and `npm run ci` never ran the build, so every breakage
shipped silently until someone built.

guard:renderer-bundle walks the src/ import graph statically (~3s):
value edges only (import type / all-type clauses erased to match
esbuild), relative + @Renderer specifiers, BFS from every renderer
non-test file, then flags any reached module with named builtin
imports or re-exports. Namespace/default/side-effect builtin imports
stay tolerated, exactly as the real build tolerates them.
architecture-guard's direct-edge ratchet cannot see this class — all
three breakages rode a grandfathered direct edge whose main-side
subtree grew the bad hop — so this asserts the closure property that
ratchet structurally misses.

Proved red by re-adding occurrence #3's exact import
(McpToolProfiles -> McpToolGateway): the guard reported all 11
would-be bind failures with their chains, then went green on revert
(file byte-identical).

The package.json edit moves the studio custody digest, but the re-pin
is deliberately NOT in this commit: the live client-builtin-guard
session (namespace-converting the four tripwire modules this guard's
red run listed) holds the claim on
scripts/studio-acceptance-harness.test.ts and re-pins on landing.
Custody is expected red in the window between these two landings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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