Merge commit '6ade5578596e4a1d173f7da6763e35ae9e0e9950' into brennanb2025/claude-sdk-ready-mainsync

This commit is contained in:
Merge Sim
2026-09-03 17:42:38 -07:00
2 changed files with 133 additions and 15 deletions
@@ -131,6 +131,85 @@ describe('structured agent session launch', () => {
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
/** A worktree is not resolvable for a beat after createWorktree resolves, so the probe fails with
* selector_not_found instead of answering. That is "not ready", not "no". */
it('retries a probe the host cannot answer yet, then creates', async () => {
const notResolvableYet = Object.assign(new Error('selector_not_found'), {
code: 'selector_not_found'
})
vi.mocked(callStructuredAgentSession)
.mockRejectedValueOnce(notResolvableYet)
.mockRejectedValueOnce(notResolvableYet)
.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 expect(launchStructuredAgentSession(intent)).resolves.toMatchObject({
sessionId: 'claude_1'
})
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.createSupport',
'agentSession.createSupport',
'agentSession.create'
])
})
it('refuses once the retry budget for an unresolvable selector is spent', async () => {
vi.mocked(callStructuredAgentSession).mockRejectedValue(
Object.assign(new Error('selector_not_found'), { code: 'selector_not_found' })
)
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
// Bounded: the first ask plus the retry delays, and never agentSession.create.
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.createSupport',
'agentSession.createSupport',
'agentSession.createSupport'
])
})
it('does not retry a host that answered no, or an unrelated failure', async () => {
vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'wsl' })
await expect(
launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
vi.mocked(callStructuredAgentSession).mockReset()
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable'))
await expect(
launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
/** A message-wrapped token must not be confused with prose that merely mentions it. */
it('does not retry a failure that only mentions the token in passing', async () => {
vi.mocked(callStructuredAgentSession).mockRejectedValue(
new Error('Access denied after a prior selector_not_found')
)
await expect(
launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
)
).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 () => {
@@ -8,6 +8,7 @@ import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
} from '../../../shared/structured-agent-session-mutation'
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { useAppStore } from '@/store'
@@ -77,29 +78,67 @@ export function abandonStructuredAgentSessionLaunchIntent(
)
}
/** The host answers a worktree selector it cannot resolve yet with this rather than a verdict. */
const SELECTOR_NOT_RESOLVABLE_CODE = 'selector_not_found'
/**
* A worktree is not resolvable for a beat after `createWorktree` resolves, so a probe fired
* immediately after creation fails instead of answering. Measured window: under ~250ms. These
* delays cover it with margin and bound the wait when the selector is genuinely absent.
*/
const CREATE_SUPPORT_RETRY_DELAYS_MS: readonly number[] = [50, 150, 300]
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Whether the executing host supports creating this session — retrying only while the host cannot
* yet resolve the worktree.
*
* "Could not answer" and "answered no" are different states and only the second is a verdict.
* Collapsing them sends a launch to the terminal because a selector was a beat late, which is
* indistinguishable to the user from the gate refusing them. The retry is narrowed to that one
* transient code so every other failure still refuses on the first ask.
*/
async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): Promise<boolean> {
for (let attempt = 0; ; attempt += 1) {
try {
const support = await callStructuredAgentSession<{ supported: boolean; reason?: string }>(
{ kind: 'local' },
'agentSession.createSupport',
{ worktree: intent.params.worktree, agent: intent.agent }
)
return support.supported === true
} catch (error) {
const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt]
if (
retryDelayMs === undefined ||
!hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE)
) {
// An unanswered probe is still not a yes.
return false
}
await delay(retryDelayMs)
}
}
}
/**
* Only the host that will execute the session can answer whether it supports creating one there —
* on Windows that means reading the provider child's process start time, which a client cannot
* observe. Codex is absent on purpose: its answer is settled by the launch route and owned
* elsewhere, so probing here would change Codex's wire traffic.
* observe.
*
* Codex is absent on purpose: its answer is settled by the launch route and owned elsewhere, so
* probing here would change Codex's wire traffic. Note that this early return is also why the
* unresolvable-selector race above has never been able to refuse a Codex launch — the race is
* identical for Codex, nothing asks. Whoever gives Codex a probe inherits it.
*/
async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise<void> {
if (intent.agent !== 'claude') {
return
}
let supported = false
try {
const support = await callStructuredAgentSession<{ supported: boolean; reason?: string }>(
{ kind: 'local' },
'agentSession.createSupport',
{ worktree: intent.params.worktree, agent: intent.agent }
)
supported = support.supported === true
} catch {
// An unanswered probe is not a yes.
supported = false
}
if (!supported) {
if (!(await hostSupportsCreate(intent))) {
abandonStructuredAgentSessionLaunchIntent(intent)
throw new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported')
}