mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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.
166 lines
6.2 KiB
TypeScript
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}$/)
|
|
})
|
|
})
|