Files
orca/src/renderer/src/lib/launch-structured-agent-session.test.ts
T
Merge Sim 9eb3aed2b3 Reach the Claude structured chat lane from the desktop UI
The main process has had a complete, correctly gated Claude Agent SDK lane for
a while, but no renderer ever asked for it: the launch route accepted only
`codex`, and the create path was typed `agent: 'codex'` end to end.

Widen both to the structured provider union that already exists
(`AgentSessionHandleProvider`), and generalize the codex-named create path
instead of adding a Claude twin beside it. The pending-launch registry is now
keyed by agent as well as workspace — a shared key handed a second caller the
first agent's intent, so a Claude and a Codex launch in one worktree collided.

Windows, per agent. Codex's client-side win32 refusal is deliberate and settled
elsewhere, so it stays exactly as it was. Claude's answer is no longer guessed
from the client's platform: a structured session fences its provider child on
that child's process start time, and only the executing host knows whether it
can read one. `agentSession.createSupport` already answers precisely that, per
agent, and had no renderer caller — so the Claude create path asks it before
creating and turns a "no", or a probe it cannot get answered, into the
definitive refusal the launch fallback already handles. Fail closed either way.

That refusal mapping also closes a real gap: the host reports an unsupported
location by throwing `structured_agent_session_unsupported`, which reaches the
client as a transport rejection rather than a refusal envelope, so
`StructuredAgentSessionCreateRefusalError` never fired. The launch would retry
the create, strand itself in `visibilityUnknown`, run no legacy fallback, and
show an error toast.

Close a fail-open hole while Claude and win32 become reachable: `create` with a
client-supplied location, and `ensure`, both skip the worktree-resolving support
check. They now ask the executing host the same question directly, so a host
that cannot fence a provider child no longer creates one on a client's say-so.

Also deletes `structured-agent-session-provider-routing.ts`, a duplicate of
`structured-agent-session-provider-support.ts` with no importers.

WSL, SSH and paired hosts, floating workspaces, draft prompt delivery, explicit
TUI customization and initial session options all keep refusing; folder
workspaces keep working.
2026-09-03 12:51:21 -07:00

166 lines
6.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import {
createStructuredAgentSessionLaunchIntent,
launchStructuredAgentSession,
StructuredAgentSessionCreateRefusalError
} from './launch-structured-agent-session'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn()
}))
describe('structured agent session launch', () => {
beforeEach(() => {
vi.mocked(callStructuredAgentSession).mockReset()
})
it('creates a native session with a host-verifiable launch intent', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
fence: 1,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-1', sequence: 0 }
},
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
}))
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
const receipt = await launchStructuredAgentSession(intent)
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(receipt).toEqual({
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{36}$/),
fence: 1
})
expect(callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
)
expect(params).toBe(intent.params)
})
it('names Claude as the create provider and in the session id', () => {
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
expect(intent.sessionId).toMatch(/^claude_[A-Za-z0-9_]{36}$/)
expect(intent.params.agent).toBe('claude')
expect(intent.params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: intent.sessionId,
fields: { worktree: 'id:workspace-1', agent: 'claude' }
})
)
})
it('asks the executing host for create support before creating a Claude session', async () => {
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) =>
method === 'agentSession.createSupport'
? { supported: true }
: { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } }
)
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await launchStructuredAgentSession(intent)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create'
])
expect(callStructuredAgentSession).toHaveBeenNthCalledWith(
1,
{ kind: 'local' },
'agentSession.createSupport',
{ worktree: 'id:workspace-1', agent: 'claude' }
)
})
it('refuses a Claude launch the host says it cannot support, without creating', async () => {
vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' })
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport'
])
})
it('fails closed when the create support probe cannot be answered', async () => {
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable'))
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
/** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the
* Claude probe did not change Codex's wire traffic. */
it('does not probe create support for Codex', async () => {
vi.mocked(callStructuredAgentSession).mockResolvedValue({
ok: true,
replayed: false,
value: { sessionId: 'codex_1', fence: 1 }
})
await launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
)
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.create'
])
})
it('replays the exact create envelope when an unknown outcome is retried', async () => {
const intent = createStructuredAgentSessionLaunchIntent('workspace-retry', 'codex')
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost'))
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2]
const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2]
expect(first).toBe(intent.params)
expect(second).toBe(first)
expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
})
})