diff --git a/src/main/GuestParticipantRun.test.ts b/src/main/GuestParticipantRun.test.ts new file mode 100644 index 00000000..254b5364 --- /dev/null +++ b/src/main/GuestParticipantRun.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest' +import type { ChatMessage, ChatRecord, ProviderId } from './store/types' +import { + GUEST_PARTICIPANT_STEERING_PREAMBLE, + truncateGuestContextText, + formatGuestParentContextMessage, + buildGuestParentTranscriptContext, + buildGuestParticipantPrompt, + buildGuestParticipantReplyMessage +} from './GuestParticipantRun' + +const label = (p: ProviderId): string => ({ codex: 'Codex', claude: 'Claude' })[p as string] || p + +function msg(partial: Partial): ChatMessage { + return { + id: partial.id || 'm', + role: partial.role || 'user', + content: partial.content ?? '', + timestamp: partial.timestamp || '2026-01-01T00:00:00.000Z', + ...partial + } as ChatMessage +} + +function chat(messages: ChatMessage[], provider: ProviderId = 'claude'): ChatRecord { + return { appChatId: 'parent-1', provider, messages } as ChatRecord +} + +describe('truncateGuestContextText', () => { + it('returns the value unchanged when within the limit', () => { + expect(truncateGuestContextText('hello', 10)).toBe('hello') + }) + it('truncates with an ellipsis when over the limit', () => { + const out = truncateGuestContextText('abcdefghij', 5) + expect(out).toBe('abcd…') + expect(out.length).toBe(5) + }) +}) + +describe('formatGuestParentContextMessage', () => { + it('labels user and assistant turns, skipping empties', () => { + expect(formatGuestParentContextMessage(msg({ role: 'user', content: 'hi' }), 'claude', label)).toBe( + 'User: hi' + ) + expect( + formatGuestParentContextMessage(msg({ role: 'assistant', content: 'yo' }), 'claude', label) + ).toBe('Claude parent agent: yo') + expect(formatGuestParentContextMessage(msg({ role: 'user', content: ' ' }), 'claude', label)).toBeNull() + }) + it('skips prior guest replies so the guest never re-reads its own output', () => { + const guestReply = msg({ + role: 'system', + content: 'earlier guest reply', + metadata: { kind: 'guestParticipantReply' } + }) + expect(formatGuestParentContextMessage(guestReply, 'claude', label)).toBeNull() + }) + it('surfaces returned sub-thread context', () => { + const sub = msg({ role: 'system', content: 'sub result', metadata: { kind: 'subThreadReturn' } }) + expect(formatGuestParentContextMessage(sub, 'claude', label)).toBe( + 'Returned sub-thread context: sub result' + ) + }) +}) + +describe('buildGuestParentTranscriptContext', () => { + it('is empty when there is nothing quotable', () => { + expect(buildGuestParentTranscriptContext(chat([]), label)).toBe('') + expect(buildGuestParentTranscriptContext(chat([msg({ content: '' })]), label)).toBe('') + }) + it('includes the heading + the host reply so the guest sees the parent turn', () => { + const out = buildGuestParentTranscriptContext( + chat([msg({ role: 'user', content: 'do X' }), msg({ role: 'assistant', content: 'host did X' })]), + label + ) + expect(out).toContain('Parent transcript context') + expect(out).toContain('User: do X') + expect(out).toContain('Claude parent agent: host did X') + }) + it('keeps only the last 20 turns', () => { + const many = Array.from({ length: 30 }, (_, i) => msg({ role: 'user', content: `turn ${i}` })) + const out = buildGuestParentTranscriptContext(chat(many), label) + expect(out).not.toContain('turn 9') + expect(out).toContain('turn 29') + }) +}) + +describe('buildGuestParticipantPrompt', () => { + it('joins preamble + context + request', () => { + const out = buildGuestParticipantPrompt({ + parentChat: chat([msg({ role: 'assistant', content: 'host reply' })]), + userText: 'please help', + providerLabel: label + }) + expect(out.startsWith(GUEST_PARTICIPANT_STEERING_PREAMBLE)).toBe(true) + expect(out).toContain('Claude parent agent: host reply') + expect(out).toContain('Current user request:\nplease help') + }) + it('omits the empty context block on a fresh chat', () => { + const out = buildGuestParticipantPrompt({ + parentChat: chat([]), + userText: 'first message', + providerLabel: label + }) + expect(out).toBe(`${GUEST_PARTICIPANT_STEERING_PREAMBLE}\n\nCurrent user request:\nfirst message`) + }) +}) + +describe('buildGuestParticipantReplyMessage', () => { + it('builds the parent mirror message with guest metadata', () => { + const message = buildGuestParticipantReplyMessage({ + parentChat: chat([]), + guestChatId: 'guest-1', + runId: 'run-9', + provider: 'codex', + model: 'gpt-x', + role: 'Guest', + content: ' guest opinion ' + }) + expect(message).not.toBeNull() + expect(message!.id).toBe('guest-return-run-9') + expect(message!.role).toBe('system') + expect(message!.content).toBe('guest opinion') + expect(message!.metadata).toMatchObject({ + kind: 'guestParticipantReply', + guestChatId: 'guest-1', + guestProvider: 'codex', + guestModel: 'gpt-x', + guestRole: 'Guest', + guestRunId: 'run-9', + parentChatId: 'parent-1' + }) + }) + it('returns null on empty content', () => { + expect( + buildGuestParticipantReplyMessage({ + parentChat: chat([]), + guestChatId: 'g', + runId: 'r', + provider: 'codex', + model: 'm', + role: 'Guest', + content: ' ' + }) + ).toBeNull() + }) + it('dedupes by guestRunId — never mirrors the same run twice', () => { + const existing = chat([ + msg({ + id: 'guest-return-run-9', + role: 'system', + content: 'already here', + metadata: { kind: 'guestParticipantReply', guestRunId: 'run-9' } + }) + ]) + expect( + buildGuestParticipantReplyMessage({ + parentChat: existing, + guestChatId: 'g', + runId: 'run-9', + provider: 'codex', + model: 'm', + role: 'Guest', + content: 'second attempt' + }) + ).toBeNull() + }) +}) diff --git a/src/main/GuestParticipantRun.ts b/src/main/GuestParticipantRun.ts new file mode 100644 index 00000000..30cffc68 --- /dev/null +++ b/src/main/GuestParticipantRun.ts @@ -0,0 +1,159 @@ +/** + * Guest-participant turn dispatch — the SINGLE SOURCE shared by the + * renderer (desktop sends) and the main-process bridge (iOS-origin sends). + * + * A solo chat may hold ONE `guestParticipant` (host + 1 guest). On a normal + * send the host (parent agent) responds first; the guest then responds in + * its child chat with the host's reply already in context (turn-based), and + * its final reply is mirrored back into the parent transcript as a + * `role:'system'` message with `metadata.kind:'guestParticipantReply'`. + * + * Before this module the trigger + mirror lived only in the renderer + * (`App.tsx`), so iOS-origin turns — which run through the bridge's + * `composerPromptFn`, never the renderer — never fired the guest. These + * pure helpers let both paths build the guest prompt + the mirrored reply + * identically. The provider-label function is injected so the module stays + * free of any renderer/main label-resolution split. + */ + +import type { ChatMessage, ChatRecord, ProviderId } from './store/types' + +/** Injected provider → display label (renderer `getProviderLabel`, main + * `providerLabel`). Cosmetic — it only labels the parent agent inside the + * guest's context block. */ +export type ProviderLabelFn = (provider: ProviderId) => string + +export const GUEST_PARTICIPANT_STEERING_PREAMBLE = + 'You are a guest participant attached to a standard TaskWraith chat. The main parent agent has priority. Respond to the user request in parallel as a second opinion or disjoint helper. Write or edit files only when useful and keep any changes disjoint from the main agent. If your intended edits overlap or conflict with the main agent, stop and explain the conflict instead of fighting the main agent.' + +const GUEST_PARENT_CONTEXT_TURN_LIMIT = 20 +const GUEST_PARENT_CONTEXT_CHAR_LIMIT = 12000 +const GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT = 1800 + +/** Provider stored on the chat record (mirror of the renderer's + * `getChatProvider`); guest dispatch only ever sees real solo chats that + * carry a provider, but default defensively. */ +function chatProvider(chat: ChatRecord): ProviderId { + return chat.provider || 'gemini' +} + +export function truncateGuestContextText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value + return `${value.slice(0, Math.max(0, maxChars - 1))}…` +} + +/** Render one parent-transcript message as a single peer-context line, or + * null to skip it (empty, or a kind that shouldn't leak into guest context). + * Prior guest replies are skipped so the guest never re-reads its own output + * as if it were the parent's. */ +export function formatGuestParentContextMessage( + message: ChatMessage, + parentProvider: ProviderId, + providerLabel: ProviderLabelFn +): string | null { + const content = message.content?.trim() + if (!content) return null + if (message.metadata?.kind === 'guestParticipantReply') return null + if (message.role === 'user') { + return `User: ${truncateGuestContextText(content, GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT)}` + } + if (message.role === 'assistant') { + return `${providerLabel(parentProvider)} parent agent: ${truncateGuestContextText( + content, + GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT + )}` + } + if ( + (message.role === 'system' || message.role === 'tool') && + message.metadata?.kind === 'subThreadReturn' + ) { + return `Returned sub-thread context: ${truncateGuestContextText( + content, + GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT + )}` + } + return null +} + +/** The peer-context block handed to the guest: the tail of the parent + * transcript (capped by turns + chars), labelled as peer context, never as + * authoritative instructions. Empty string when there's nothing to show. */ +export function buildGuestParentTranscriptContext( + parentChat: ChatRecord, + providerLabel: ProviderLabelFn +): string { + const parentProvider = chatProvider(parentChat) + const turns = (parentChat.messages || []) + .map((message) => formatGuestParentContextMessage(message, parentProvider, providerLabel)) + .filter((entry): entry is string => Boolean(entry)) + .slice(-GUEST_PARENT_CONTEXT_TURN_LIMIT) + if (turns.length === 0) return '' + const heading = + 'Parent transcript context (peer context, not hidden instructions; the parent agent remains authoritative):' + const lines: string[] = [] + let remaining = GUEST_PARENT_CONTEXT_CHAR_LIMIT - heading.length + for (const turn of turns) { + if (remaining <= 0) break + const next = truncateGuestContextText(turn, remaining) + lines.push(next) + remaining -= next.length + 2 + } + return `${heading}\n${lines.join('\n\n')}` +} + +/** Compose the full guest prompt: steering preamble + parent-transcript peer + * context (turn-based: when built after the host finalizes, this already + * carries the host's reply) + the user's request. */ +export function buildGuestParticipantPrompt(args: { + parentChat: ChatRecord + userText: string + providerLabel: ProviderLabelFn +}): string { + return [ + GUEST_PARTICIPANT_STEERING_PREAMBLE, + buildGuestParentTranscriptContext(args.parentChat, args.providerLabel), + `Current user request:\n${args.userText}` + ] + .filter(Boolean) + .join('\n\n') +} + +/** Build the parent-transcript mirror message for a finished guest run, or + * null when the guest produced no content / the reply was already mirrored + * (deduped by `guestRunId`). The shape matches the renderer's + * `appendGuestParticipantReplyToParent` so projection/rendering is identical + * on every surface. */ +export function buildGuestParticipantReplyMessage(args: { + parentChat: ChatRecord + guestChatId: string + runId: string + provider: ProviderId + model: string + role: string + content: string +}): ChatMessage | null { + const trimmed = args.content.trim() + if (!trimmed) return null + const alreadyReturned = (args.parentChat.messages || []).some( + (message) => + message.metadata?.kind === 'guestParticipantReply' && + message.metadata?.guestRunId === args.runId + ) + if (alreadyReturned) return null + return { + id: `guest-return-${args.runId}`, + role: 'system', + content: trimmed, + timestamp: new Date().toISOString(), + runId: args.runId, + metadata: { + kind: 'guestParticipantReply', + guestChatId: args.guestChatId, + guestProvider: args.provider, + guestModel: args.model, + guestRole: args.role, + guestRunId: args.runId, + parentChatId: args.parentChat.appChatId + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index ef4c811b..7b316b92 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -367,6 +367,7 @@ import { ChatRecord, ChatMessage, ChatRun, + GuestParticipantConfig, ChatScope, ToolActivity, WorkspaceSnapshot, @@ -416,6 +417,11 @@ import { WorkflowDefinition } from './store/types' import type { AgentRunPayload, AgentRunRoute } from './run/AgentRunTypes' +import { + buildGuestParticipantPrompt, + buildGuestParticipantReplyMessage +} from './GuestParticipantRun' +import { extractGuestParticipantAddressTarget } from '../renderer/src/lib/ComposerMentionTrigger' import { DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, @@ -766,6 +772,29 @@ const remoteQuestionRegistry = new RemoteQuestionRegistry({ defaultTtlMs: AGENT_QUESTION_TIMEOUT_MS }) let bridgeBroadcasterRef: BridgeBroadcaster | null = null +/** Guest-participant turn dispatch + reply mirror. Assigned inside the bridge + * setup closure (where dispatchAgentRun + the broadcast helpers live) so the + * module-scope finalizeBridgeRunTranscript can drive turn-based guest runs: + * host finishes -> dispatch guest (sees host reply) -> mirror guest reply. */ +let bridgeGuestParticipantRunner: { + dispatchGuestTurn: (args: { + parentChatId: string + guestConfig: GuestParticipantConfig + userText: string + workspaceId: string + approvalMode: string + }) => void + mirrorGuestReply: (args: { + runId: string + parentChatId: string + guestChatId: string + workspaceId: string + provider: ProviderId + model: string + role: string + content: string + }) => void +} | null = null // Remote-session power assertion: while >=1 iOS device is connected over the // bridge, hold an Electron powerSaveBlocker so the Mac stays awake to serve // remote approvals/questions (the agent runs on-Mac; a sleeping Mac can't be @@ -1387,6 +1416,26 @@ type BridgeRunTranscriptState = { extraWorkspacePaths?: string[] runDiff?: ChatRun['runDiff'] runDiffByPath?: ChatRun['runDiffByPath'] + /** Set on a HOST bridge run when the chat has a guest participant and the + * turn fanned out (no @-tag, or not @parent): finalize dispatches the guest + * run AFTER the host finishes so the guest sees the host's reply (turn-based). */ + guestFanout?: { + guestConfig: GuestParticipantConfig + userText: string + parentChatId: string + workspaceId: string + approvalMode: string + } + /** Set on a GUEST bridge run: finalize mirrors the guest's reply into the + * parent transcript (deduped by guestRunId), matching the renderer. */ + guestReturn?: { + parentChatId: string + guestChatId: string + workspaceId: string + provider: ProviderId + model: string + role: string + } } const bridgeRunTranscripts = new Map() @@ -3503,6 +3552,32 @@ function finalizeBridgeRunTranscript( console.warn(`[bridge-run] run diff failed for ${runId}:`, err) } flushBridgeRunTranscript(runId, true) + // Turn-based guest participation. After the HOST reply is flushed + // (persisted), dispatch the guest so it answers WITH the host's reply in + // context; after a GUEST run finishes, mirror its reply into the parent. + if (state.guestFanout) { + const f = state.guestFanout + bridgeGuestParticipantRunner?.dispatchGuestTurn({ + parentChatId: f.parentChatId, + guestConfig: f.guestConfig, + userText: f.userText, + workspaceId: f.workspaceId, + approvalMode: f.approvalMode + }) + } + if (state.guestReturn) { + const g = state.guestReturn + bridgeGuestParticipantRunner?.mirrorGuestReply({ + runId, + parentChatId: g.parentChatId, + guestChatId: g.guestChatId, + workspaceId: g.workspaceId, + provider: g.provider, + model: g.model, + role: g.role, + content: state.content + }) + } })() } @@ -16943,6 +17018,34 @@ if (isGeminiMcpBridgeProcess) { .find((run) => run.provider === 'gemini' && run.geminiAuthProfileId !== undefined) ?.geminiAuthProfileId : undefined + // Guest participant turn routing (parity with the renderer send + // path): default = host now, guest AFTER the host finishes + // (turn-based, marked below); @parent/@host = host only; @guest = + // guest only. The guest runs as a normal leaf turn on its child chat. + const guestParticipantConfig = + chat.chatKind !== 'ensemble' ? (chat.guestParticipant ?? null) : null + const guestAddressTarget = guestParticipantConfig + ? extractGuestParticipantAddressTarget(action.text, { + parentProvider: provider, + guestProvider: guestParticipantConfig.provider + }) + : null + if (guestParticipantConfig && guestAddressTarget === 'guest') { + // @guest — only the guest runs. The user message is already on the + // parent (prepareIosComposerPromptChat); the guest reply mirrors + // back via finalize. No host run, so no host appRunId to ack. + bridgeGuestParticipantRunner?.dispatchGuestTurn({ + parentChatId: chat.appChatId, + guestConfig: guestParticipantConfig, + userText: action.text, + workspaceId: action.workspaceId, + approvalMode: effectiveApprovalMode || 'default' + }) + const refreshed = AppStore.getChat(chat.appChatId) + if (refreshed) pushRemoteThreadSnapshot(refreshed, action.workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + return { dispatched: true, appRunId: null } + } const route = routeWithRunId(provider, { appChatId: chat.appChatId, appRunId: undefined @@ -17008,6 +17111,20 @@ if (isGeminiMcpBridgeProcess) { promptMessageId, workspacePath: workspaceRecord?.path ?? globalRunCwd() }) + if (guestParticipantConfig && guestAddressTarget !== 'parent') { + // Default fan-out (no tag): dispatch the guest once THIS host run + // finalizes, so it answers with the host's reply already in context. + const hostState = bridgeRunTranscripts.get(runId) + if (hostState) { + hostState.guestFanout = { + guestConfig: guestParticipantConfig, + userText: action.text, + parentChatId: chat.appChatId, + workspaceId: action.workspaceId, + approvalMode: effectiveApprovalMode || 'default' + } + } + } if (extraWorkspacePaths.length > 0) { const transcript = bridgeRunTranscripts.get(runId) if (transcript) transcript.extraWorkspacePaths = extraWorkspacePaths @@ -17066,6 +17183,10 @@ if (isGeminiMcpBridgeProcess) { finalPrompt: action.text, messages: priorMessages, chatContextTurns: AppStore.getSettings().chatContextTurns, + // Host-awareness: tell the parent agent a guest is attached + replay + // prior guest replies, so it can anticipate/avoid conflicts (parity + // with the desktop send path, which already passes this). + guestParticipant: guestParticipantConfig ?? undefined, ...(resumeSessionId ? { resumeSessionId } : {}), nextModel: action.model, codexHandoffsApplied: [], @@ -21594,6 +21715,249 @@ if (isGeminiMcpBridgeProcess) { return dispatchRunWithProviderPause(payload, event) } + // Guest-participant turn dispatch + reply mirror for bridge (iOS-origin) + // turns. The renderer's dispatchGuestParticipantRun/appendGuestParticipant + // ReplyToParent only ran in the renderer, so phone turns never fired the + // guest. This mirrors that on the bridge: dispatched AFTER the host (so the + // guest sees the host's reply — turn-based), running as a normal leaf run + // on the guest's child chat (no re-entry of the host fan-out logic, since + // the child chat has no guest participant of its own). + bridgeGuestParticipantRunner = { + dispatchGuestTurn: ({ parentChatId, guestConfig, userText, workspaceId, approvalMode }) => { + void (async () => { + try { + const isGlobalScope = workspaceId === GLOBAL_REMOTE_SCOPE + const workspaceRecord = isGlobalScope + ? null + : (AppStore.getWorkspaces().find((w) => w.id === workspaceId) ?? null) + if (!isGlobalScope && !workspaceRecord) return + const parent = AppStore.getChat(parentChatId) + let guestChat = AppStore.getChat(guestConfig.childChatId) + if ( + !parent || + !guestChat || + guestChat.archived || + guestChat.parentChatId !== parentChatId || + guestChat.parentChatRelation !== 'sideChat' || + guestChat.sideChatContext?.mode !== 'guestParticipant' + ) { + return + } + const provider = assertProviderId(guestConfig.provider) + const guestModel = + guestConfig.selectedModelType === 'custom' + ? guestConfig.customModel || undefined + : guestConfig.selectedModelType && guestConfig.selectedModelType !== 'default' + ? guestConfig.selectedModelType + : undefined + // Built AFTER the host finalized, so the parent transcript already + // carries the host's reply — the guest replies to it (turn-based). + const guestPrompt = buildGuestParticipantPrompt({ + parentChat: parent, + userText, + providerLabel + }) + const now = Date.now() + const userMessage: ChatMessage = { + id: `ios-guest-user-${randomUUID()}`, + role: 'user', + content: userText.trim(), + timestamp: new Date(now).toISOString() + } + guestChat = { + ...guestChat, + provider, + messages: [...(guestChat.messages || []), userMessage], + updatedAt: now + } + AppStore.saveChat(guestChat) + const route = routeWithRunId(provider, { + appChatId: guestChat.appChatId, + appRunId: undefined + } as AgentRunRoute) + const runId = route.appRunId! + const lastProviderRun = [...(guestChat.runs ?? [])] + .reverse() + .find((entry) => entry.runId !== runId && entry.provider === provider) + const inheritedProfileId = [...(guestChat.runs ?? [])] + .reverse() + .find((run) => run.provider === provider && run.runtimeProfileId)?.runtimeProfileId + const defaultProfileId = + inheritedProfileId || isGlobalScope + ? undefined + : AppStore.getRuntimeProfiles(provider).find( + (profile) => profile.builtin && profile.scope === 'workspace' + )?.id + const resolvedProfileId = inheritedProfileId ?? defaultProfileId + const run: ChatRun = { + runId, + provider, + startedAt: new Date().toISOString(), + promptMessageId: userMessage.id, + requestedModel: guestModel, + approvalMode, + ...(resolvedProfileId ? { runtimeProfileId: resolvedProfileId } : {}), + status: 'running', + rawEventsFile: `run-events/${runId}.jsonl` + } + guestChat = { + ...guestChat, + runs: [...(guestChat.runs || []).filter((entry) => entry.runId !== runId), run], + updatedAt: Date.now() + } + AppStore.saveChat(guestChat) + registerBridgeRunTranscript({ + runId, + chatId: guestChat.appChatId, + provider, + promptMessageId: userMessage.id, + workspacePath: workspaceRecord?.path ?? globalRunCwd() + }) + const guestState = bridgeRunTranscripts.get(runId) + if (guestState) { + guestState.guestReturn = { + parentChatId, + guestChatId: guestChat.appChatId, + workspaceId, + provider, + model: guestModel || '', + role: 'Guest' + } + } + if (workspaceRecord) { + void captureWorkspaceSnapshot(workspaceRecord.path) + .then((snapshot) => { + const t = bridgeRunTranscripts.get(runId) + if (t) t.preSnapshot = snapshot + }) + .catch(() => {}) + } + broadcastChatUpdated(guestChat) + broadcastThreadUpdate(guestChat.appChatId) + pushRemoteThreadSnapshot(guestChat, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + const linkedSessionForProvider = + provider === 'gemini' + ? guestChat.linkedGeminiSessionId + : guestChat.linkedProviderSessionId + const resumeSessionId = + (lastProviderRun + ? linkedSessionForProvider || lastProviderRun.providerThreadId + : undefined) || undefined + const priorMessages = guestChat.messages.filter((m) => m.id !== userMessage.id) + const composed = composeRunPrompt({ + provider, + finalPrompt: guestPrompt, + messages: priorMessages, + chatContextTurns: AppStore.getSettings().chatContextTurns, + ...(resumeSessionId ? { resumeSessionId } : {}), + nextModel: guestModel, + codexHandoffsApplied: [], + isGlobalRun: isGlobalScope, + approvalMode: approvalMode || 'default', + providerLabel: providerLabel(provider) + }) + const guestEffectivePermissions = + approvalMode === 'plan' + ? resolveEffectiveRunPermissions({ + provider, + workspacePath: isGlobalScope ? undefined : workspaceRecord?.path, + settings: AppStore.getSettings(), + presetId: 'read_only' + }) + : undefined + const payload: AgentRunPayload = { + provider, + scope: isGlobalScope ? 'global' : 'workspace', + ...(workspaceRecord ? { workspace: workspaceRecord.path } : {}), + prompt: composed.contextualPrompt, + ...(resumeSessionId ? { providerSessionId: resumeSessionId } : {}), + appChatId: guestChat.appChatId, + appRunId: runId, + approvalMode, + ...(guestEffectivePermissions + ? { effectivePermissions: guestEffectivePermissions } + : {}), + model: guestModel, + ...(provider === 'codex' && guestConfig.codexReasoningEffort + ? { reasoningEffort: guestConfig.codexReasoningEffort } + : {}), + ...(provider === 'claude' && guestConfig.claudeReasoningEffort + ? { claudeReasoningEffort: guestConfig.claudeReasoningEffort } + : {}), + ...(resolvedProfileId ? { runtimeProfileId: resolvedProfileId } : {}) + } + payload.effectivePermissionsSignature = signRunPosture( + payload.approvalMode, + payload.effectivePermissions, + runPostureContextFromPayload(payload) + ) + const liveSender = mainWindow?.webContents + const sender = + liveSender && !liveSender.isDestroyed() ? liveSender : createHeadlessRunSender() + const fakeEvent = { sender } as unknown as Electron.IpcMainInvokeEvent + void dispatchAgentRun(payload, fakeEvent) + .then((result) => { + if (!result.dispatched) { + finalizeBridgeRunTranscript( + runId, + 'failed', + 'Guest participant run did not dispatch — check the guest provider profile on your Mac.' + ) + } + const refreshed = AppStore.getChat(guestChat.appChatId) + if (refreshed) pushRemoteThreadSnapshot(refreshed, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + }) + .catch((err) => { + console.error('[remote-bridge] guest participant dispatch failed:', err) + }) + console.log( + `[bridge-run] guest participant turn dispatched run=${runId} parent=${parentChatId} provider=${provider}` + ) + } catch (err) { + console.error('[remote-bridge] guest participant dispatch error:', err) + } + })() + }, + mirrorGuestReply: ({ + runId, + parentChatId, + guestChatId, + workspaceId, + provider, + model, + role, + content + }) => { + const parent = AppStore.getChat(parentChatId) + if (!parent) return + const message = buildGuestParticipantReplyMessage({ + parentChat: parent, + guestChatId, + runId, + provider, + model, + role, + content + }) + if (!message) return + const updated: ChatRecord = { + ...parent, + messages: [...(parent.messages || []), message], + updatedAt: Date.now() + } + AppStore.saveChat(updated) + broadcastChatUpdated(updated) + broadcastThreadUpdate(updated.appChatId) + if (workspaceId) pushRemoteThreadSnapshot(updated, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + console.log( + `[bridge-run] mirrored guest reply run=${runId} -> parent=${parentChatId} (${content.length} chars)` + ) + } + } + ipcMain.handle('run-agent', async (event, payload: AgentRunPayload) => { await dispatchAgentRun(payload, event) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bfe37f82..fb879370 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2360,6 +2360,15 @@ function App(): React.JSX.Element { const currentWorkspaceIdRef = useRef(null) const currentChatIdRef = useRef(null) const chatByIdRef = useRef>(new Map()) + // Turn-based guest participation: a guest send is deferred here (keyed by + // parent chatId) and dispatched only once the host run on that chat + // completes, so the guest answers WITH the host's reply in context. The ref + // to dispatchGuestParticipantRun (defined far below) lets the run-completion + // handler fire it without a forward reference. + const pendingGuestDispatchRef = useRef>(new Map()) + const dispatchGuestParticipantRunRef = useRef< + ((request: QueuedRunRequest) => Promise) | null + >(null) const clearedChatIdsRef = useRef>(new Set()) const rawLogsByChatIdRef = useRef>(new Map()) const activeRunChatSnapshotRef = useRef(null) @@ -7731,6 +7740,20 @@ function App(): React.JSX.Element { } }) handlers.triggerFxBurst('run-complete') + // Turn-based guest participation (parity with the bridge): the host run + // just finished — fire the deferred guest now, with the host's reply + // already in the parent transcript so the guest can respond to it. + if (completedRunChatId && pendingGuestDispatchRef.current.has(completedRunChatId)) { + const deferredGuestRequest = pendingGuestDispatchRef.current.get(completedRunChatId) + pendingGuestDispatchRef.current.delete(completedRunChatId) + if (deferredGuestRequest) { + const freshParent = chatByIdRef.current.get(completedRunChatId) + void dispatchGuestParticipantRunRef.current?.({ + ...deferredGuestRequest, + chatRecord: freshParent ?? deferredGuestRequest.chatRecord + }) + } + } if (context.warnings.length > 0) { handlers.triggerFxBurst('run-summary') } @@ -10320,6 +10343,7 @@ function App(): React.JSX.Element { } void executeRunRef.current(guestRequest) } + dispatchGuestParticipantRunRef.current = dispatchGuestParticipantRun const appendGuestAddressedUserMessage = (request: QueuedRunRequest): void => { const parentChat = request.chatRecord || currentChat @@ -10494,9 +10518,10 @@ function App(): React.JSX.Element { if (isChatBusy(request.chatRecord?.appChatId || currentChat?.appChatId)) { queueRunRequest(request) - if (shouldDispatchGuest) { - if (parentChat?.appChatId) markGuestDispatchPending(parentChat.appChatId) - void dispatchGuestParticipantRun(request) + if (shouldDispatchGuest && parentChat?.appChatId) { + markGuestDispatchPending(parentChat.appChatId) + // Turn-based: defer the guest until this queued host run completes. + pendingGuestDispatchRef.current.set(parentChat.appChatId, request) } clearComposerAttachmentsForSubmittedRequest(request) if (!request.existingPrompt) { @@ -10509,9 +10534,11 @@ function App(): React.JSX.Element { } void executeRun(request) - if (shouldDispatchGuest) { - if (parentChat?.appChatId) markGuestDispatchPending(parentChat.appChatId) - void dispatchGuestParticipantRun(request) + if (shouldDispatchGuest && parentChat?.appChatId) { + markGuestDispatchPending(parentChat.appChatId) + // Turn-based: defer the guest until the host run completes (see the + // run-completion handler), so it answers with the host's reply in context. + pendingGuestDispatchRef.current.set(parentChat.appChatId, request) } }