Files
orca/src/shared/agent-launch-operation.test.ts
T
Brennan Benson a375936c04 feat(agent-launch): let a caller reserve the pane its terminal launch creates (#22291)
* feat(agent-launch): let a caller reserve the pane its terminal launch creates

* fix(agent-launch): refuse a launch whose reserved pane is already live

* fix(agent-launch): refuse a live reserved pane before it is revealed

The live-pane refusal used to fire in the executor, after createTerminal
had already issued a handle, published the mobile snapshot and revealed
the tab. The reveal re-registered a fresh launch config over the running
agent's. agent.launch now passes requireFreshPane with a reserved pane,
and createTerminal throws AgentLaunchPaneAlreadyLiveError as soon as
spawn reports it attached to a live pane. That is before any handle,
snapshot or reveal. The spawn reattach itself is the one terminal.create
already uses, so the live PTY is never killed, and the stable-pane
create claim is still released in finally. The isReattach plumbing
added to the launch factory for the old check is gone.

A replay-safe launch refused this way on an existing workspace now
records a failed ledger row, the same way a name collision does.
Before, the row stayed claimed, so every retry got
agent_session_operation_unknown. agent.launchReplay passes the code
through. On create-worktree the workspace already exists when the
terminal is refused, so the row stays unknown. The code is added to
the runtime passthrough list so callers can branch on it.

The pane key is now in the replay fingerprint, deliberately. It is not
placement: group, anchor and focus still stay out of the request and
out of the ledger. It is identity. It is written into the pane's PTY
environment and names the tab the caller has placed. A retry that
reserved a different pane is therefore a different request. Replaying
the first answer would return a key the new reservation can never
find. This matches terminal.createAgentSession, which also fingerprints
its tab and leaf ids. The key is only folded in when present, so every
existing digest is unchanged, and a test pins that.

The wire schema now refuses a tab id the runtime would not adopt as
sent: one with surrounding whitespace, which the runtime trims, and one
longer than 512 characters, which the spawn reservation does not key
on. It reuses the tab-id schema that Placement uses.

* test(agent-launch): pin that a refused live pane issues no handle

The refusal test named handle issuance but only asserted the reveal, so a
throw moved to just before the reveal would still pass. Assert no terminal
is registered, with the attach test as the positive control.
2026-09-23 10:18:16 -07:00

129 lines
5.4 KiB
TypeScript

/**
* Which launch inputs the replay digest covers, and — just as deliberately — which it does not.
*
* The digest decides whether a retry replays the first answer or is refused as a different call
* wearing the same id, so every field added to the launch wire is a decision in one of two
* directions. Both directions are pinned here: a behavioural field that fell OUT would let a retry
* carrying different arguments silently inherit the original's outcome, and a telemetry field that
* crept IN would refuse an honest retry that merely got re-attributed.
*/
import { describe, expect, it } from 'vitest'
import {
computeAgentLaunchFingerprint,
type AgentLaunchFingerprintInput
} from './agent-launch-operation'
import { canonicalAgentSessionDigest } from './agent-session-mutation-envelope'
const BASE = {
agent: 'claude',
target: { kind: 'existing' as const, worktree: 'wt-1' }
}
const PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301'
const OTHER_PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:6fa459ea-ee8a-4ca4-894e-db77e160355e'
/**
* The handler digests the whole params object, `launchSource` included — excess properties are only
* rejected on an object literal, never on the params value it actually passes. Going through this
* signature keeps the exclusion tests honest: they prove the digest IGNORES the field at runtime,
* not merely that the input type has no name for it.
*/
function fingerprintOfWirePayload(
params: AgentLaunchFingerprintInput & { launchSource?: string }
): string {
return computeAgentLaunchFingerprint(params)
}
describe('fields the launch fingerprint covers', () => {
it('separates two launches that differ only in agentArgs', () => {
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus' })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model sonnet' })
)
})
it('treats an explicit "no arguments" as different from falling back to the settings default', () => {
// `null` and absent are the tri-state the wire keeps; collapsing them here would let a retry
// that cleared its arguments replay an answer produced with the user's configured ones.
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: null })).not.toBe(
computeAgentLaunchFingerprint(BASE)
)
})
it('separates two launches that differ only in cwd', () => {
expect(computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/a' })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/b' })
)
})
it('separates two launches that reserved different panes', () => {
// The key is baked into the pane's env; replaying another pane's key strands the new reservation.
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, paneKey: OTHER_PANE_KEY })
)
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
computeAgentLaunchFingerprint(BASE)
)
})
})
describe('fields the launch fingerprint deliberately ignores', () => {
it('does not separate two launches that differ only in launchSource', () => {
// Telemetry. Two buttons producing the same launch are one operation, and a retry that got
// re-attributed must replay rather than be refused as a conflict.
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'shortcut' })).toBe(
fingerprintOfWirePayload({ ...BASE, launchSource: 'tab_bar_quick_launch' })
)
})
it('ignores launchSource entirely, so sending one matches sending none', () => {
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'sidebar' })).toBe(
computeAgentLaunchFingerprint(BASE)
)
})
})
describe('compatibility with rows written before these fields existed', () => {
/**
* A client that sends none of the new fields must still digest to what the previous build
* produced, or an upgraded host would refuse the in-flight retries of every launch admitted
* before it restarted. This is the old expression verbatim rather than a captured constant, so it
* keeps checking the property rather than a value someone can re-record.
*/
it('matches the pre-existing digest when no new field is sent', () => {
const previousBuild = canonicalAgentSessionDigest({
method: 'agent.launch',
agent: BASE.agent,
target: BASE.target,
prompt: undefined,
sessionOptions: undefined,
reuseTerminal: undefined
})
expect(computeAgentLaunchFingerprint(BASE)).toBe(previousBuild)
})
it('matches the digest from before the pane key existed when no pane is reserved', () => {
const beforePaneKey = canonicalAgentSessionDigest({
method: 'agent.launch',
agent: BASE.agent,
target: BASE.target,
prompt: undefined,
sessionOptions: undefined,
reuseTerminal: undefined,
agentArgs: '--model opus',
cwd: '/repo/packages/a'
})
expect(
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus', cwd: '/repo/packages/a' })
).toBe(beforePaneKey)
})
it('still matches when the caller sends only telemetry the digest excludes', () => {
const previousBuild = canonicalAgentSessionDigest({
method: 'agent.launch',
agent: BASE.agent,
target: BASE.target
})
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'quick_command' })).toBe(previousBuild)
})
})