feat(guest): turn-based guest participation (fix iOS guest never replying) - #3
Merged
Merged
Conversation
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
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 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'scomposerPromptFn, 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).src/main/GuestParticipantRun.ts(node-safe, unit-tested): steering preamble, parent-transcript peer-context builder, full guest-prompt composer, and theguest-return-${runId}reply-mirror message builder (deduped byguestRunId). Provider-label injected so it's tree-agnostic.composerPromptFnnow passesguestParticipantto 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-scopefinalizeBridgeRunTranscriptcan reach the closure dispatch helpers.chatRecord), instead of firing in parallel.@gueststays immediate;@parent/@hoststays host-only.iOS needs no changes — it already renders
guestParticipantReplyprovider-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-treeextractGuestParticipantAddressTargetimport bundles into main.GuestParticipantRun.test.ts(13).@-routing already covered byComposerMentionTrigger.test.ts; host guest-context byPromptComposition.test.ts.Remaining verification (not in this PR)
@guest/@parentroute correctly.🤖 Generated with Claude Code