From aec6fbcfbde8d2258787bb72beb31e1d74c767c7 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 14:21:53 -0700 Subject: [PATCH] fix(windows): engage the legacy fallback when the host THROWS a refusal Review found a P1 this merge composes: neither parent could reach it. At the lane head the only structured entry was launch-agent-in-new-tab (full store-backed WSL check); on main all win32 was refused. The merge enables win32 in creation flows that pass no projectRuntime, so a WSL folder workspace, a WSL-configured repo, or a repair-required runtime now routes structured -- and the host refuses correctly, but by THROWING rather than returning {ok:false, refusal}. Callers engage their legacy-terminal fallback on the refusal CLASS, so an unmapped throw arrives as a generic RPC rejection: no fallback, empty workspace, error toast, prompt stranded in the launch outbox. Pre-merge the same action opened a legacy terminal agent. Map the host's thrown definitive refusals onto the refusal class at the launch boundary, so every creation flow -- present and future -- degrades to the legacy terminal instead of stranding. Narrow predicate: unrelated failures (ECONNRESET, empty message, non-Error) still propagate untouched. Ablation-proven: removing the mapping reddens the fallback test. --- ...h-structured-codex-session-refusal.test.ts | 50 +++++++++++++++++++ .../lib/launch-structured-codex-session.ts | 32 ++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/launch-structured-codex-session-refusal.test.ts diff --git a/src/renderer/src/lib/launch-structured-codex-session-refusal.test.ts b/src/renderer/src/lib/launch-structured-codex-session-refusal.test.ts new file mode 100644 index 00000000000..6744b6197a5 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session-refusal.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { isThrownStructuredRefusal } from './launch-structured-codex-session' + +describe('isThrownStructuredRefusal', () => { + it('recognises the unsupported-location sentinel the host throws', () => { + expect(isThrownStructuredRefusal(new Error('structured_agent_session_unsupported'))).toBe(true) + }) + + it('recognises it when the transport prefixes the message', () => { + expect( + isThrownStructuredRefusal(new Error('rpc error: structured_agent_session_unsupported')) + ).toBe(true) + }) + + it('recognises a runtime that needs repair', () => { + expect( + isThrownStructuredRefusal(new Error('Project runtime requires repair: wsl-distro-missing')) + ).toBe(true) + }) + + it('does NOT swallow an unrelated failure', () => { + expect(isThrownStructuredRefusal(new Error('ECONNRESET'))).toBe(false) + expect(isThrownStructuredRefusal(Object.assign(new Error('x'), { message: '' }))).toBe(false) + expect(isThrownStructuredRefusal('not an error')).toBe(false) + expect(isThrownStructuredRefusal(undefined)).toBe(false) + }) +}) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => { + throw new Error('structured_agent_session_unsupported') + }) +})) + +describe('launchStructuredCodexSession', () => { + // Without the mapping the host's throw arrives as a generic RPC rejection and + // every caller's legacy-terminal fallback (which keys on the refusal class) + // silently fails to engage, stranding the launch. + it('converts a thrown host refusal into the refusal class callers key on', async () => { + const { launchStructuredCodexSession, StructuredAgentSessionCreateRefusalError } = + await import('./launch-structured-codex-session') + await expect( + launchStructuredCodexSession({ + sessionId: 's1', + worktreeId: 'folder:f1', + params: {} as never + } as never) + ).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-codex-session.ts index 32a3feb19b4..3489637726a 100644 --- a/src/renderer/src/lib/launch-structured-codex-session.ts +++ b/src/renderer/src/lib/launch-structured-codex-session.ts @@ -73,12 +73,38 @@ export function abandonStructuredAgentSessionLaunchIntent( ) } +/** The host refuses some launches by THROWING rather than returning a refusal + * envelope -- an unsupported location, or a runtime that needs repair. Those + * are still definitive refusals, but callers engage their legacy-terminal + * fallback on the refusal class alone, so an unmapped throw strands the launch + * with no agent pane and the prompt left in the outbox. */ +const THROWN_REFUSAL_MARKERS = [ + 'structured_agent_session_unsupported', + 'Project runtime requires repair' +] as const + +export function isThrownStructuredRefusal(error: unknown): boolean { + const message = error instanceof Error ? error.message : '' + return THROWN_REFUSAL_MARKERS.some((marker) => message.includes(marker)) +} + export async function launchStructuredCodexSession( intent: StructuredAgentSessionLaunchIntent ): Promise> { - const result = await callStructuredAgentSession< - AgentSessionMutationResult - >({ kind: 'local' }, 'agentSession.create', intent.params) + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession< + AgentSessionMutationResult + >({ kind: 'local' }, 'agentSession.create', intent.params) + } catch (error) { + if (!isThrownStructuredRefusal(error)) { + throw error + } + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : 'structured_agent_session_unsupported' + ) + } if (!result.ok) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError(result.refusal.message)