Files
orca/tests/e2e/structured-agent-session-launch-continuation.unit.test.ts
T

478 lines
20 KiB
TypeScript

// @vitest-environment happy-dom
import {
mocks,
resetCreateMessageFixture,
cleanupCreateMessageFixture,
created,
createParams
} from '../../src/renderer/src/lib/structured-agent-session-launch-create-message.test-fixture'
import type { AgentSessionModelCatalogResult } from '../../src/shared/agent-session/agent-session-wire'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type * as HostCapability from '@/runtime/structured-agent-session-host-capability'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
import { prepareStructuredAgentSessionCreateForWorktree } from '../../src/main/runtime/rpc/methods/structured-agent-session-create'
import type { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
import { structuredAgentRuntimeMetadata } from '../../src/main/runtime/agent-session/installation/structured-agent-runtime-metadata'
import { claudeStructuredSpawnOptions } from '../../src/main/claude/structured/launch/claude-structured-spawn-options'
import { openCodexThread } from '../../src/main/codex/structured/threads/codex-structured-thread-open'
import { resolvedCodexMessageOptions } from '../../src/main/codex/structured/turns/codex-structured-message-options'
import { optionSession } from '../../src/main/codex/structured/catalog/codex-structured-option-session.test-fixture'
import { startStructuredAgentLaunch } from '../../src/renderer/src/lib/agent-session/launch/structured-agent-session-launch'
import { getStructuredAgentSessionLaunchSelection } from '../../src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-options'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots'
import { useStructuredAgentSessionOptions } from '@/components/native-chat/use-structured-agent-session-options'
import { useNativeChatProvisionalLaunch } from '@/components/native-chat/use-native-chat-provisional-launch'
import { structuredAgentSessionMessageSelection } from '../../src/shared/agent-session/options/structured-agent-session-option-view'
import { structuredAgentSessionModelId } from '../../src/shared/agent-session/options/structured-agent-session-option-state'
import { createGlobalSettingsFixture } from '../../src/shared/global-settings-test-fixture'
import { CreateIntentParams } from '../../src/shared/rpc-contract/structured-agent-session-params'
import { structuredAgentSessionCreateFingerprint } from '../../src/shared/agent-session/lifecycle/structured-agent-session-mutation'
import {
retryStructuredAgentSessionLaunchIntent,
restoreStructuredAgentSessionLaunchIntent
} from '../../src/renderer/src/lib/launch-structured-agent-session'
import {
resetStructuredAgentLaunchRegistryForTests,
getStructuredLaunchStateBySessionId
} from '../../src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-registry'
vi.mock('@/runtime/structured-agent-session-host-capability', async (original) => ({
...(await original<typeof HostCapability>()),
useStructuredAgentSessionHostCapability: () => false,
useStructuredAgentSessionOwnPermissionState: () => 'supported'
}))
vi.mock('@/components/native-chat/StructuredAgentSessionStatusBridge', () => ({
useStructuredAgentSessionStatusSummary: () => ({ summary: null, observation: 'live' })
}))
vi.mock('../../src/main/agent-launch/trust/agent-workspace-trust', () => ({
applyAgentWorkspaceTrust: vi.fn(async () => ({}))
}))
let restore: (() => void) | undefined
let root: string
beforeEach(async () => {
restore = resetCreateMessageFixture()
mocks.exactSupport.mockResolvedValue(true)
root = await mkdtemp(join(await realpath(tmpdir()), 'orca-36210-continuation-'))
vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'profile'))
})
afterEach(async () => {
vi.unstubAllEnvs()
setStructuredAgentSessionHost(null)
cleanupCreateMessageFixture(restore)
for (const key of Object.keys(mocks.settings)) {
delete mocks.settings[key]
}
await rm(root, { recursive: true, force: true })
})
it.each(['claude', 'codex'] as const)(
'carries renderer lifetime through host intent to the real %s provider builders',
async (agent) => {
const home = join(root, 'account')
const providerSessionId = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a'
const transcript = join(
home,
agent === 'claude' ? 'projects/workspace' : 'sessions',
`${providerSessionId}.jsonl`
)
await mkdir(dirname(transcript), { recursive: true })
await writeFile(transcript, '{}\n')
const settings = createGlobalSettingsFixture({
agentPermissionDefault: 'own',
nativeChatInheritShellEnvironment: false,
agentDefaultEnv: {
[agent]: { [agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME']: home }
},
agentLaunchChoices: {
[agent]: {
model: 'authored-new',
options: {
effort: 'low',
...(agent === 'claude' ? { fastMode: 'false' } : { serviceTier: 'default' })
}
}
}
})
Object.assign(mocks.settings, settings)
const store = { getSettings: () => settings }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create-intent resolution only reads this runtime store's settings.
const runtime = new OrcaRuntimeService(store as never, undefined, {
prepareCodexStructuredLaunch: () => home
})
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
supported: true
})
vi.spyOn(runtime, 'assertStructuredAdoptionTerminalWritersExited').mockResolvedValue()
Object.assign(runtime, {
resolveStructuredAgentSessionLocation: async () => ({
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
}),
resolveRuntimeFileTarget: async () => ({ worktree: { path: root } })
})
const minimalHost = {
deps: {
store: {
getOperationRow: () => undefined,
getRecord: () => undefined,
listOperationRows: () => [],
listConversations: () => []
},
agents: {
definition: (id: string) => structuredAgentRuntimeMetadata(id)?.definition ?? null
}
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this preparation-only host exposes exactly the record/definition readers; no provider/host method is invoked.
const host = minimalHost as unknown as StructuredAgentSessionHost
for (const paired of [false, true]) {
for (const fastMode of agent === 'claude' ? [true, false] : [false]) {
for (const scenario of ['fresh', 'continuing', 'picked'] as const) {
resetStructuredAgentLaunchRegistryForTests()
const continuing = scenario !== 'fresh'
const explicit =
scenario === 'picked'
? {
model: 'genuine-pick',
effort: 'high',
...(agent === 'claude'
? { fastMode: String(fastMode) }
: { serviceTier: 'default' })
}
: undefined
const remembered = {
model: 'remembered',
valuesByModel: {
remembered: {
effort: 'high',
...(agent === 'claude' ? { fastMode } : { serviceTier: 'default' })
}
}
}
settings.nativeChatSessionOptions = { [agent]: remembered }
Object.assign(mocks.settings, settings)
const target = paired
? { kind: 'environment' as const, environmentId: 'alpha' }
: { kind: 'local' as const }
recordHostModelCatalogSnapshot(target, agent, null, {
origin: 'probe',
fetchedAt: 1,
defaultHoldsInEveryWorkspace: true,
models: [
{
id: 'authored-new',
label: 'Authored',
isDefault: true,
efforts: [{ value: 'low', label: 'Low' }]
},
{
id: 'remembered',
label: 'Remembered',
isDefault: false,
efforts: [{ value: 'high', label: 'High' }]
}
]
})
mocks.admit.mockResolvedValue({
kind: 'admitted',
seedOptions: {
model: 'authored-new',
effort: 'low',
...(agent === 'claude' ? { fastMode: 'false' } : { serviceTier: 'default' })
}
})
let prepared:
| Awaited<ReturnType<typeof prepareStructuredAgentSessionCreateForWorktree>>
| undefined
mocks.call.mockImplementation(async (_target, method, input) => {
if (method !== 'agentSession.create') {
throw new Error(`unexpected ${method}`)
}
const params = CreateIntentParams.parse(input)
prepared = await prepareStructuredAgentSessionCreateForWorktree({
runtime,
ensureHost: async () => host,
caller: { callerKey: 'renderer' },
...params,
atRest: true
})
return created()
})
mocks.call.mockClear()
const launch = startStructuredAgentLaunch('workspace-1', agent, {
requestId: createBrowserUuid(),
executionHostId: paired ? 'runtime:alpha' : 'local',
prompt: 'continue normally',
...(continuing ? { resumeFrom: { providerSessionId } } : {}),
...(explicit ? { launchOptions: explicit } : {})
})
await launch.launchResult
expect(prepared).toBeDefined()
const params = createParams()
const options = prepared?.attachParams.options
const expectedModel = explicit
? 'genuine-pick'
: continuing
? 'remembered'
: 'authored-new'
expect(options).toMatchObject({
model: expectedModel,
effort: continuing ? 'high' : 'low',
...(agent === 'claude'
? { fastMode: continuing ? String(fastMode) : 'false' }
: { serviceTier: 'default' })
})
expect(prepared?.attachParams.accountHome).toEqual({
variable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: home
})
if (continuing) {
if (explicit) {
expect(params.options).toMatchObject(explicit)
} else {
expect(params.options).toBeUndefined()
}
expect(params.firstMessage?.selection).toEqual(explicit ?? {})
expect(prepared?.attachParams.adopt).toMatchObject({
transcriptPath: transcript,
source: { providerSessionId, workspaceCwd: root }
})
} else {
expect(params.firstMessage?.selection?.model).toBe('authored-new')
}
if (agent === 'claude') {
const spawn = claudeStructuredSpawnOptions({
launch: {
options: {
resume: continuing ? providerSessionId : undefined,
extraArgs: { model: 'raw-pin', settings: '{"theme":"light"}', 'add-dir': 'docs' }
},
resumesTranscript: continuing
},
saved: options,
effortFlag: 'supported',
flagSettingsControl: 'supported'
})
expect(spawn.sdkOptions.model).toBe(expectedModel)
expect(spawn.sdkOptions.extraArgs).toEqual({
settings: '{"theme":"light"}',
'add-dir': 'docs'
})
expect(spawn.options.get('fastMode')).toBe(continuing ? String(fastMode) : 'false')
expect(spawn.fastModeAtStart).toBe(true)
} else {
const request = vi.fn<
(
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number | null }
) => Promise<unknown>
>(async () => ({
thread: { id: providerSessionId },
model: 'gpt-live',
reasoningEffort: 'high'
}))
await openCodexThread(
{ request },
{
cwd: root,
resumeThreadId: continuing ? providerSessionId : null,
model: options?.model,
commandEnv: { ORIGINAL: 'retained' }
},
null
)
expect(request.mock.calls[0]?.[0]).toBe(continuing ? 'thread/resume' : 'thread/start')
expect(request.mock.calls[0]?.[1]).toMatchObject({
cwd: root,
config: { 'shell_environment_policy.set.ORIGINAL': 'retained' }
})
if (continuing) {
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model')
}
const turn = await resolvedCodexMessageOptions(
optionSession(request),
params.firstMessage?.selection
)
if (explicit) {
expect(turn?.model).toBe('genuine-pick')
} else if (continuing) {
expect(turn).not.toHaveProperty('model')
} else {
expect(turn?.model).toBe('authored-new')
}
}
}
}
}
}
)
it('admits paired continuation picks after support, keeps unknown display unsent, and freezes prepared replay', async () => {
const admission = Promise.withResolvers<Awaited<ReturnType<typeof mocks.admit>>>()
const capability = Promise.withResolvers<boolean>()
const create = Promise.withResolvers<unknown>()
const hydration = Promise.withResolvers<unknown>()
const target = { kind: 'environment', environmentId: 'alpha' } as const
const catalog: AgentSessionModelCatalogResult = {
origin: 'probe',
fetchedAt: 1,
defaultHoldsInEveryWorkspace: true,
models: [
{
id: 'fresh-map',
label: 'Fresh map',
isDefault: true,
efforts: [{ value: 'low', label: 'Low' }]
},
{
id: 'literal-held',
label: 'Held choice',
isDefault: false,
efforts: [{ value: 'high', label: 'High' }]
}
]
}
replaceRuntimeEnvironmentRevisions([{ id: 'alpha', createdAt: 1 }])
recordHostModelCatalogSnapshot(target, 'claude', null, catalog)
mocks.admit.mockReturnValue(admission.promise)
mocks.exactSupport.mockReturnValue(capability.promise)
mocks.call.mockImplementation(async (_target, method) => {
if (method === 'agentSession.modelCatalog') {
return catalog
}
if (method === 'agentSession.options') {
return hydration.promise
}
if (method === 'agentSession.create') {
return create.promise
}
throw new Error(`unexpected ${method}`)
})
const launch = startStructuredAgentLaunch('workspace-1', 'claude', {
requestId: createBrowserUuid(),
executionHostId: 'runtime:alpha',
resumeFrom: { providerSessionId: 'existing' },
prompt: 'next'
})
const view = renderHook(
({ attached }) => {
const provisional = useNativeChatProvisionalLaunch('workspace-1', launch.sessionId)
return useStructuredAgentSessionOptions({
agent: 'claude',
sessionId: launch.sessionId,
target,
launch: provisional.launch,
transportEnabled: attached,
isVisible: true,
providerVisible: attached,
fence: attached ? 1 : null,
turnId: null,
unloadedTurnRevisions: undefined,
mutate: vi.fn()
})
},
{ initialProps: { attached: false } }
)
expect(getStructuredAgentSessionLaunchSelection(launch.sessionId)?.seedPending).toBe(true)
expect(view.result.current.optionSnapshot.find((row) => row.id === 'model')?.settable).toBe(false)
await act(async () =>
admission.resolve({ kind: 'admitted', seedOptions: { model: 'fresh-map', effort: 'low' } })
)
await vi.waitFor(() =>
expect(getStructuredAgentSessionLaunchSelection(launch.sessionId)?.seedPending).toBeUndefined()
)
const selection = getStructuredAgentSessionLaunchSelection(launch.sessionId)
expect(selection?.seed).toBeUndefined()
const provisional = view.result.current.sendSource.optionView(
view.result.current.sendSource.optionStateRef.current,
selection?.seed
)
expect(structuredAgentSessionModelId(provisional.record, 'own')).toBeNull()
expect(structuredAgentSessionMessageSelection(provisional)).toEqual({})
await act(async () => {
expect(await view.result.current.setStructuredOption('model', 'literal-held')).toBe(true)
})
await act(async () => {
expect(await view.result.current.setStructuredOption('effort', 'high')).toBe(true)
expect(await view.result.current.setStructuredOption('fastMode', false)).toBe(true)
})
capability.resolve(true)
await vi.waitFor(() =>
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create')
).toHaveLength(1)
)
const params = createParams()
expect(params.options).toEqual({ model: 'literal-held', effort: 'high', fastMode: 'false' })
expect(params.firstMessage?.selection).toEqual(params.options)
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionCreateFingerprint({ sessionId: launch.sessionId, ...params })
)
const state = getStructuredLaunchStateBySessionId(launch.sessionId)
if (!state) {
throw new Error('missing continuing launch')
}
Object.assign(mocks.settings, { agentLaunchChoices: { claude: { model: 'later' } } })
const restored = restoreStructuredAgentSessionLaunchIntent({
worktreeId: 'workspace-1',
executionHostId: 'runtime:alpha',
sessionId: launch.sessionId,
clientOperationId: params.envelope.clientOperationId,
payloadFingerprint: params.envelope.payloadFingerprint,
expectedRuntimeFence: params.envelope.expectedRuntimeFence,
agent: 'claude',
resumeFrom: params.resumeFrom,
options: params.options,
firstMessage: params.firstMessage,
createMessageSupport: true
})
expect(restored.params).toEqual(params)
expect(restored.seedOptions).toBeUndefined()
expect(retryStructuredAgentSessionLaunchIntent(state.intent).seedOptions).toBeUndefined()
expect(
restoreStructuredAgentSessionLaunchIntent({
worktreeId: 'workspace-1',
executionHostId: 'local',
sessionId: launch.sessionId,
agent: 'claude',
clientOperationId: 'new',
payloadFingerprint: '',
expectedRuntimeFence: null,
resumeFrom: params.resumeFrom
}).seedOptions?.model
).not.toBe('later')
await act(async () => {
create.resolve(created())
await launch.launchResult
})
view.rerender({ attached: true })
await act(async () =>
hydration.resolve({
models: catalog.models,
current: {
model: 'literal-held',
effort: 'high',
fastMode: false,
permissionMode: 'own',
defaulted: []
}
})
)
await vi.waitFor(() =>
expect(view.result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true)
)
const hydrated = view.result.current.optionSnapshot.find((row) => row.id === 'model')
expect(hydrated).toMatchObject({ settable: true, kind: { currentValue: 'literal-held' } })
view.unmount()
})