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.
This commit is contained in:
Merge Sim
2026-09-03 14:21:53 -07:00
parent 2aaf7c7cde
commit aec6fbcfbd
2 changed files with 79 additions and 3 deletions
@@ -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)
})
})
@@ -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<Pick<AgentSessionAttachResult, 'sessionId' | 'fence'>> {
const result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionAttachResult>
>({ kind: 'local' }, 'agentSession.create', intent.params)
let result: AgentSessionMutationResult<AgentSessionAttachResult>
try {
result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionAttachResult>
>({ 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)