diff --git a/config/scripts/vitest-sqlite-runtime-boundary.test.ts b/config/scripts/vitest-sqlite-runtime-boundary.test.ts index 2f5c7c59077..4c86e8a57b8 100644 --- a/config/scripts/vitest-sqlite-runtime-boundary.test.ts +++ b/config/scripts/vitest-sqlite-runtime-boundary.test.ts @@ -8,6 +8,7 @@ import { NODE_RUNTIME_INCLUDE } from './vitest-node-runtime-files.mjs' const sqliteHarnesses = new Set([ 'persistence-test-harness', 'agent-session-record-store-test-harness', + 'structured-agent-session-rest-test-rig', 'journal-host-database-test-support' ]) const sqliteModules = new Set([ @@ -53,6 +54,8 @@ function importsSqliteRuntime(file: string, source: string): boolean { } it.each([ + ['import { createRestTestRig } from "./structured-agent-session-rest-test-rig"', true], + ['import type { RestTestRig } from "./structured-agent-session-rest-test-rig"', false], ['import Database, { type Options } from "./sync-database"', true], ['import {} from "./sync-database"', true], ['import "./sync-database"', true], diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 85a6f048eb5..6f5ca5acd13 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -88,10 +88,13 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', @@ -104,6 +107,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -135,6 +139,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', @@ -149,6 +154,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', @@ -209,8 +215,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts', 'src/main/runtime/runtime-orchestration-sender-names.test.ts', diff --git a/mobile/src/session/structured-create-wire-types.test.ts b/mobile/src/session/structured-create-wire-types.test.ts new file mode 100644 index 00000000000..6fc2aba1c29 --- /dev/null +++ b/mobile/src/session/structured-create-wire-types.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { z } from 'zod' +import { + structuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage +} from '../../../src/shared/structured-agent-session-create' +import { CreateIntentParams } from '../../../src/shared/rpc-contract/structured-agent-session-params' + +describe('the shared create builder used by mobile', () => { + it('remains assignable to the strict create request without an opening message', () => { + const params = structuredAgentSessionCreateParams({ + sessionId: 'codex_11111111_2222_3333_4444_555555555555', + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '11111111-2222-3333-4444-555555555555' + }) + expectTypeOf(params).toExtend>() + expect(CreateIntentParams.safeParse(params).success).toBe(true) + expect(params).not.toHaveProperty('firstMessage') + }) + + it('accepts only user-authored opening messages at the type and wire boundaries', () => { + type AssistantBody = { + kind: 'message' + role: 'assistant' + blocks: { type: 'text'; text: string }[] + } + expectTypeOf().not.toExtend() + expectTypeOf().toEqualTypeOf<'user'>() + expect( + CreateIntentParams.shape.firstMessage.safeParse({ + clientMessageId: 'opening-message', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'forged' }] } + }).success + ).toBe(false) + }) +}) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index eeb5ccc4664..1099746e82e 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -11,7 +11,10 @@ import { structuredAgentSessionOptionSnapshot } from '../../shared/structured-agent-session-options' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { @@ -24,6 +27,8 @@ import { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { AcpSessionRuntime } from './acp-session-runtime' import { GROK, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { createTestParams } from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create' import { attachParams, launch, openHostRig } from './acp-structured-host.test-support' const GROK_HOME = { variable: 'GROK_HOME', path: '/grok' } @@ -95,8 +100,9 @@ function firstFrame(answer: AgentSessionModelCatalogResult) { return { model: select('model'), effort: select('effort') } } -/** A Grok chat opened with no model pick, whose own session runs `runs`. */ -async function openGrokHost(runs: string) { +/** A Grok chat opened with no model pick, whose own session runs `runs`; `create` founds it + * with its first message, so only delivery starts the agent. */ +async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMessage) { const records: { store: AgentSessionRecordStore | null } = { store: null } const discovery = GROK.modelDiscovery const catalog = createAgentModelCatalogService({ @@ -125,7 +131,10 @@ async function openGrokHost(runs: string) { deps: { resolveLaunch: launch(() => false) } }) records.store = rig.store - expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + const opening = create + ? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create }) + : rig.host.attach(CALLER, attachParams()) + expect(await opening).toMatchObject({ ok: true }) return { ...rig, catalog } } @@ -150,4 +159,20 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { }) expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) }) + + it('learns 4.6 from a chat created with its first message and no pick', async () => { + const { catalog, host, rig } = await openGrokHost('grok-4.6', { + clientMessageId: 'opening', + body: hostTestMessage('hello') + }) + await catalog.read({ agent: 'grok', waitForListing: true }) + // Delivery started the agent and handed it the opening message; nothing picked a model. + const prompt = await rig.frame('session/prompt') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) + expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) + expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) + }) }) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 4a5e64296a0..d44fda04208 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -12,6 +12,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../shared/agent-session-optional-model-capability' import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, @@ -34,6 +35,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shar * copies, and a capability added to one and missed on the other is invisible until a user hits it. */ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index e86e30d8f5d..ebc91a4c00b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -69,8 +69,20 @@ export async function importAdoptedTranscript( params: AgentSessionAttachParams, attached: AttachedJournal, record: AgentSessionRecord, - prepared: JournalReplacementItem[] | null + prepared: JournalReplacementItem[] | null, + options: { uncommittedCreate?: true } = {} ): Promise { + if (options.uncommittedCreate) { + // A rolled-back create can leave imported history, but no conversation owns it yet. + if (prepared || attached.journal.cursor().sequence > 1) { + await attached.journal.replaceEpochItems( + params.adopt ? 'legacy_import' : 'session_created', + record.lease.runtimeFence, + prepared ?? [] + ) + } + return + } // The journal is the conversation's, which outlives a failed import; nothing here closes it. await applyAdoptedTranscript(params, attached, record, prepared) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts new file mode 100644 index 00000000000..0d87155f514 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -0,0 +1,250 @@ +import { writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + createTestParams, + stopCreatedChat, + CREATE_TEST_CALLER as CALLER +} from './structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestOperationId, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) +}) +afterEach(async () => { + await rig.dispose() + vi.restoreAllMocks() +}) + +async function adoptedParams(history = 'adopted history', threadId = THREAD) { + const transcriptPath = join(rig.root, 'rollout.jsonl') + await writeFile( + transcriptPath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { id: threadId, timestamp: '2026-09-06T18:00:00.000Z', cwd: rig.root } + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { type: 'message', role: 'user', content: history } + }) + ].join('\n') + ) + return createTestParams(undefined, { + adopt: { providerHandle: { kind: 'codex', threadId }, transcriptPath } + }) +} + +function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachParams { + return createTestParams(undefined, { + ...params, + envelope: { + ...params.envelope, + sessionId: 'session-second', + clientOperationId: hostTestOperationId() + } + }) +} + +it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { + const params = await adoptedParams() + const history = vi.fn(async () => null) + rig.host.deps.adapter.providerHistoryWindow = history + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: true, + value: { + page: { + items: expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ blocks: [{ type: 'text', text: 'adopted history' }] }) + }) + ]) + } + } + }) + expect(rig.store.getRecord(SESSION)?.providerHandleChain).toMatchObject([ + { origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } } + ]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(history).not.toHaveBeenCalled() + await rm(join(rig.root, 'rollout.jsonl')) + expect( + await rig.host.create(CALLER, { + ...params, + accountHome: { variable: 'CODEX_HOME', path: '/drifted/account' }, + adopt: { providerHandle: { kind: 'codex', threadId: THREAD } } + }) + ).toMatchObject({ ok: true, replayed: true }) + expect(await rig.host.send(CALLER, restTestSend('new work'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ + providerHandle: expect.objectContaining({ nativeId: THREAD }) + }) + }) + ) + expect(history).toHaveBeenCalledOnce() + expect(history.mock.invocationCallOrder[0]).toBeLessThan( + rig.adapter.acquire.mock.invocationCallOrder[0] + ) + expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) +}) + +it('refuses concurrent adoption of a provider conversation by another session', async () => { + const params = await adoptedParams() + const results = await Promise.all([ + rig.host.create(CALLER, params), + rig.host.create(CALLER, anotherSession(params)) + ]) + expect(results.filter((result) => result.ok)).toHaveLength(1) + expect(results.filter((result) => !result.ok)).toMatchObject([ + { + refusal: { code: 'agent_session_conflict', details: { reason: 'conversationHeldElsewhere' } } + } + ]) + expect(rig.store.listRecords()).toHaveLength(1) + expect(rig.store.listOperationRows()).toHaveLength(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('refuses concurrent founding under a tab another chat already owns', async () => { + const params = createTestParams(undefined, { surfaceTabId: 'reserved-chat-tab' }) + const results = await Promise.all([ + rig.host.create(CALLER, params), + rig.host.create(CALLER, anotherSession(params)) + ]) + expect(results.filter((result) => result.ok)).toHaveLength(1) + expect(results.filter((result) => !result.ok)).toMatchObject([ + { refusal: { code: 'agent_session_conflict', details: { reason: 'tabIdTaken' } } } + ]) + expect(rig.store.listVisibleSessionIds()).toHaveLength(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('validates adoption history before founding anything', async () => { + const params = await adoptedParams() + await writeFile(join(rig.root, 'rollout.jsonl'), 'not a transcript\n') + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_identity_required' } + }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) +}) + +it('does not found a record whose account namespace its agent cannot drive', async () => { + const params = createTestParams(undefined, { + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/wrong/account' } + }) + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) +}) + +it.each([ + { executionHostId: 'local', wslDistro: null }, + { executionHostId: 'ssh:host-1', wslDistro: null }, + { executionHostId: 'local', wslDistro: 'Ubuntu' }, + { executionHostId: 'runtime:paired-host', wslDistro: null } +] as const)( + 'preserves folder execution identity at rest: $executionHostId $wslDistro', + async (location) => { + const params = createTestParams(undefined, { + location: { ...location, workspaceId: 'folder-1', workspaceKind: 'folder' } + }) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION)?.location).toEqual(params.location) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) + +it('pins the host-resolved directory of a floating chat before any acquire', async () => { + const params = createTestParams(undefined, { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + expect(await rig.host.create(CALLER, params, { hostLaunchDirectory: rig.root })).toMatchObject({ + ok: true + }) + expect(rig.store.getRecord(SESSION)?.launchDirectory).toBe(rig.root) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it.each(['adopted', 'blank'] as const)( + 'retries a rolled-back create with only the new %s history', + async (nextSource) => { + const first = { clientMessageId: hostTestOperationId(), body: hostTestMessage('first attempt') } + const adopted = await adoptedParams('old imported history') + const db = openTestJournalHostDatabase(rig.root).db + db.exec( + `CREATE TRIGGER refuse_first BEFORE INSERT ON journal_rows WHEN NEW.row_json LIKE '%"kind":"submission"%' BEGIN SELECT RAISE(ABORT, 'simulated first write failure'); END` + ) + expect( + await rig.host.create(CALLER, createTestParams(first, adopted), { firstMessage: first }) + ).toMatchObject({ ok: false }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + db.exec('DROP TRIGGER refuse_first') + const next = { clientMessageId: hostTestOperationId(), body: hostTestMessage('next attempt') } + const nextParams = + nextSource === 'adopted' + ? await adoptedParams('new imported history', '019fd532-7c11-7a90-b6de-4e1a2c3d5f61') + : createTestParams() + let stopping: ReturnType | undefined + const result = await rig.host.create( + CALLER, + createTestParams(next, { + ...nextParams, + envelope: { ...nextParams.envelope, clientOperationId: hostTestOperationId() } + }), + { + firstMessage: next, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + } + ) + expect(result).toMatchObject({ ok: true }) + if (!result.ok) { + throw new Error('retry create failed') + } + const history = (text: string) => + expect.objectContaining({ + body: expect.objectContaining({ blocks: [{ type: 'text', text }] }) + }) + expect(result.value.page.items).not.toEqual( + expect.arrayContaining([history('old imported history')]) + ) + if (nextSource === 'adopted') { + expect(result.value.page.items).toEqual( + expect.arrayContaining([history('new imported history')]) + ) + } + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts new file mode 100644 index 00000000000..ab375470e0e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts @@ -0,0 +1,54 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { + hostTestAttachParams, + hostTestOperationId, + HOST_TEST_SESSION +} from './structured-agent-session-host-test-data' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export const CREATE_TEST_CALLER = { callerKey: 'client-1' } + +export function createTestParams( + firstMessage?: StructuredAgentSessionFirstMessage, + overrides: Partial = {} +): AgentSessionAttachParams { + const params = hostTestAttachParams(null, { providerHandle: undefined, ...overrides }) + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { + worktree: `id:${params.location.workspaceId}`, + agent: params.agent, + firstMessage, + options: params.options, + tabId: params.surfaceTabId + } + }) + } + } +} + +export function stopCreatedChat( + host: StructuredAgentSessionHost, + sessionId = HOST_TEST_SESSION, + expectedRuntimeFence: number | null = null +) { + const envelope: AgentSessionMutationEnvelope = { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId, + fields: {} + }) + } + return host.cancel(CREATE_TEST_CALLER, { envelope }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts new file mode 100644 index 00000000000..c3f2a485598 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -0,0 +1,433 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + CREATE_TEST_CALLER as CALLER, + createTestParams, + stopCreatedChat +} from './structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { + openTestJournalHostDatabase, + loadTestJournal +} from '../agent-session-journal/journal-host-database-test-support' +import { readPersistedTestAgentSessionStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' + +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) +}) +afterEach(async () => { + await rig.dispose() + vi.restoreAllMocks() +}) + +function firstMessage() { + return { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening message') } +} + +function abortableAcquisition() { + const entered = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce( + ({ signal }) => + new Promise((_resolve, reject) => { + entered.resolve() + const abort = () => reject(new AgentSessionPreSpawnError(signal?.reason)) + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + return entered.promise +} + +it('commits the record, tab, operation and first message together and returns while acquire is unresolved', async () => { + const first = firstMessage() + const params = createTestParams(first, { surfaceTabId: 'chat-create-tab' }) + const entered = abortableAcquisition() + const observed: unknown[] = [] + rig.store.onFirstRecord(() => + observed.push({ + record: rig.store.getRecord(SESSION), + tab: rig.store.getSessionTabId(SESSION), + operation: rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId), + submission: loadTestJournal(rig.root, SESSION)?.state.submissions.get(first.clientMessageId) + }) + ) + const publishTab = vi.fn(async () => { + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null + }) + }) + const result = await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + expect(result).toMatchObject({ + ok: true, + fence: 1, + value: { + tabId: 'chat-create-tab', + firstMessage: { clientMessageId: first.clientMessageId, dispatchState: 'pending' }, + page: { + submissions: [{ clientMessageId: first.clientMessageId, dispatchState: 'pending' }], + items: expect.arrayContaining([expect.objectContaining({ body: first.body })]) + } + } + }) + expect(observed).toMatchObject([ + { + tab: 'chat-create-tab', + operation: { outcome: { status: 'succeeded' } }, + submission: { clientMessageId: first.clientMessageId }, + record: { lease: { claimStatus: 'released' } } + } + ]) + expect(publishTab).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await entered + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) +}) + +it('creates a blank chat at rest without acquiring and persists its launch arguments', async () => { + const resolveLaunchArgs = vi.fn(() => ['--test-flag']) + const resolveLaunchEnv = vi.fn(() => ({ SAFE_FLAG: 'yes' })) + await rig.dispose() + rig = await createRestTestRig({ + resolveLaunchArgs, + resolveLaunchEnv, + idleSweep: { intervalMs: 60_000 } + }) + expect(await rig.host.create(CALLER, createTestParams())).toMatchObject({ + ok: true, + value: { page: { submissions: [] } } + }) + expect(rig.store.getRecord(SESSION)).toMatchObject({ + launchArgs: ['--test-flag'], + lease: { + claimStatus: 'released', + runtimeFence: 1, + ownerProcess: null, + reservedSpawnToken: null + } + }) + expect(resolveLaunchEnv).toHaveBeenCalledOnce() + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('keeps delivery behind runtime tab publication even after the message is committed', async () => { + const first = firstMessage() + const held = Promise.withResolvers() + const release = Promise.withResolvers() + const entered = abortableAcquisition() + let answered = false + const creating = rig.host + .create(CALLER, createTestParams(first), { + firstMessage: first, + publishTab: async () => { + held.resolve() + await release.promise + } + }) + .then((result) => { + answered = true + return result + }) + await held.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.store.getRecord(SESSION)).not.toBeNull() + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(answered).toBe(false) + release.resolve() + expect(await creating).toMatchObject({ ok: true }) + await entered + await stopCreatedChat(rig.host) +}) + +it('an immediate Stop cancels the queued first message before acquire and replay never redelivers it', async () => { + const first = firstMessage() + const params = createTestParams(first) + let stopping: ReturnType | undefined + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + }) + ).toMatchObject({ ok: true }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true, + value: { + page: { + submissions: [{ clientMessageId: first.clientMessageId, rejection: { kind: 'cancelled' } }] + } + } + }) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() + ).toMatchObject({ event: { reason: 'user-stop' } }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('holds the created first message until the child proves its start, then hands it over once', async () => { + const first = firstMessage() + const spawn = rig.adapter.acquire.getMockImplementation()! + let generation = '' + rig.adapter.acquire.mockImplementationOnce(async (input) => { + const child = await spawn(input) + generation = child.acquisitionGeneration ?? '' + return { ...child, providerChildPhase: 'starting' as const } + }) + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + const held = (await rig.host.journalSnapshot(SESSION)).submissions + expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) + expect(held[0].handedOverAt).toBeUndefined() + + await rig.host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: generation, + reportedOptions: { model: 'gpt-live' }, + restoreSkippedOptions: [], + optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + }) + + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: first.body })) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.adapter.dispatch).toHaveBeenCalledOnce() +}) + +it('Stop while the child proves its start withdraws the created first message once, unsent', async () => { + const first = firstMessage() + const spawn = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), + providerChildPhase: 'starting' as const + })) + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) + + const submissions = (await rig.host.journalSnapshot(SESSION)).submissions + expect(submissions).toEqual([ + expect.objectContaining({ + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: expect.objectContaining({ kind: 'cancelled' }) + }) + ]) + expect(submissions[0].handedOverAt).toBeUndefined() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('Stop aborts a handshake outside the lane and the next send sends only its own text', async () => { + const first = firstMessage() + const params = createTestParams(first) + const entered = abortableAcquisition() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ ok: true }) + await entered + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true + }) + const next = restTestSend('next message', rig.store.getRecord(SESSION)?.lease.runtimeFence) + expect(await rig.host.send(CALLER, next)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: next.body })) + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.submissions).toHaveLength(2) + expect( + snapshot.submissions.find(({ clientMessageId }) => clientMessageId === first.clientMessageId) + ?.rejection + ).toMatchObject({ kind: 'cancelled' }) +}) + +it('replays the stopped first message even after its row falls outside the hydration page', async () => { + const first = firstMessage() + const params = createTestParams(first) + let stopping: ReturnType | undefined + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + }) + ).toMatchObject({ ok: true }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('created chat has no journal') + } + for (let ordinal = 1; ordinal <= AGENT_SESSION_HISTORY_MAX_LIMIT + 1; ordinal += 1) { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'later-history', ordinal }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `later ${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const append = vi.spyOn(journal, 'appendSubmission') + const replay = await rig.host.create(CALLER, params, { firstMessage: first }) + if (!replay.ok) { + throw new Error(`create replay refused: ${replay.refusal.code}`) + } + expect(replay.replayed).toBe(true) + expect(replay.value.page.hasOlder).toBe(true) + expect(replay.value.page.items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ body: first.body })]) + ) + expect(replay.value.page.submissions).toEqual([]) + expect(replay.value.firstMessage).toMatchObject({ + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + }) + expect(append).not.toHaveBeenCalled() + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('admits Stop with the create-returned fence after acquisition advances it and keeps the stopped row', async () => { + const first = firstMessage() + const entered = abortableAcquisition() + const created = await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + if (!created.ok) { + throw new Error(`create refused: ${created.refusal.code}`) + } + expect(created.fence).toBe(1) + await entered + expect(rig.store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(created.fence) + const stopping = stopCreatedChat(rig.host, SESSION, created.fence) + let stopped: Awaited | undefined + void stopping.then((outcome) => { + stopped = outcome + }) + try { + await vi.waitFor(() => expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } })) + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.submissions).toMatchObject([ + { + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + } + ]) + expect( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: true + }) + ).toMatchObject([ + { role: 'user', blocks: first.body.blocks }, + { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } + ]) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() + ).toMatchObject({ event: { reason: 'user-stop' } }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + } finally { + rig.host.collaboratorsForTests().runtimeState.acquireAborts.abort(SESSION, 'test cleanup') + await stopping + } +}) + +it.each(['journal_rows', 'agent_session_operations'])( + 'rolls back all create state when %s refuses its write', + async (table) => { + const first = firstMessage() + const params = createTestParams(first) + const db = openTestJournalHostDatabase(rig.root).db + const when = table === 'journal_rows' ? `WHEN NEW.row_json LIKE '%"kind":"submission"%'` : '' + db.exec( + `CREATE TRIGGER refuse_create BEFORE INSERT ON ${table} ${when} BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END` + ) + const publishTab = vi.fn(async () => {}) + expect( + await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + ).toMatchObject({ ok: false }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.getSessionTabId(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + expect((await readPersistedTestAgentSessionStore(rig.root)).records).toEqual({}) + expect(loadTestJournal(rig.root, SESSION)?.state.submissions.size).toBe(0) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(publishTab).not.toHaveBeenCalled() + db.exec('DROP TRIGGER refuse_create') + const entered = abortableAcquisition() + expect( + await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + ).toMatchObject({ ok: true }) + await entered + await stopCreatedChat(rig.host) + } +) + +it('answers a journal founding failure as a refusal before publishing any create state', async () => { + const db = openTestJournalHostDatabase(rig.root).db + db.exec( + `CREATE TRIGGER refuse_epoch BEFORE INSERT ON journal_rows WHEN NEW.row_json LIKE '%"kind":"epoch"%' BEGIN SELECT RAISE(ABORT, 'simulated journal open failure'); END` + ) + const first = firstMessage() + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_journal_unreadable' } }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('retries tab publication from the committed message without acquiring early or appending again', async () => { + const first = firstMessage() + const params = createTestParams(first) + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + throw new Error('tab unavailable') + } + }) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + const append = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') + const entered = abortableAcquisition() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true + }) + expect(append).not.toHaveBeenCalled() + await entered + await stopCreatedChat(rig.host) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts new file mode 100644 index 00000000000..5f3510d2029 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -0,0 +1,285 @@ +import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + refuse, + type AgentSessionAttachResult, + type AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { journalOpenRefusalError } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' +import { + prepareAdoptedTranscript, + importAdoptedTranscript +} from './structured-agent-session-adopted-import' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' +import { + adapterSupportsCreateIfDeclared, + hostCanStartRecord +} from './structured-agent-session-provider-support' +import { performSend } from './structured-agent-session-turns' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +export type StructuredAgentSessionCreateOptions = { + firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } + hostLaunchDirectory?: string + /** Called after the durable create, before delivery can start the agent. */ + publishTab?: () => Promise +} + +/** Founds a chat and its opening message; only the delivery loop acquires the agent. */ +export function createStructuredAgentSession( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionCreateOptions = {} +): Promise> { + return context.tasks.trackAttach( + context.serialize(params.envelope.sessionId, async () => { + try { + return await foundConversation(context, callerKey, params, options) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { ok: false, refusal: error.refusal } + } + throw error + } + }) + ) +} + +async function foundConversation( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionCreateOptions +): Promise> { + const { deps } = context + const { store } = deps + const sessionId = params.envelope.sessionId + const prior = store.getRecord(sessionId) + if (params.envelope.expectedRuntimeFence !== null || params.agent !== params.provider) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + 'The create request is malformed.' + ) + } + } + if ( + prior + ? !hostCanStartRecord(deps, prior) + : !deps.agents.definition(params.agent) || + !adapterSupportsCreateIfDeclared(deps.adapter, params.location, params.agent) + ) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested chat.' + ) + } + } + const launchDirectory = + !prior && isFloatingWorkspaceId(params.location.workspaceId) + ? (options.hostLaunchDirectory ?? + (await deps.resolveWorkspacePath?.(params.location.workspaceId))) + : undefined + if (!prior && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { + throw new Error('floating_agent_session_launch_directory_unavailable') + } + const request = reserveRequestFor({ + sessionId, + params, + callerKey, + fingerprint: params.envelope.payloadFingerprint, + now: context.now(), + authority: { + ...(launchDirectory ? { launchDirectory } : {}), + spawnToken: () => { + throw new Error('founding_must_not_reserve_a_spawn') + }, + claimKeyId: deps.claimKeyId, + handoffOperationId: null, + probe: { outcome: 'pid-absent' }, + ...(await pinnedAgentSessionLaunchArgs(deps.resolveLaunchArgs, params)), + ...(await pinnedAgentSessionLaunchEnv(deps.resolveLaunchEnv, params)) + } + }) + const candidate = store.founding.prepare(request) + if (!hostCanStartRecord(deps, candidate.record)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) + } + } + const replayed = candidate.disposition === 'replayed' + if (replayed && candidate.operationRow.outcome.status !== 'succeeded') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: candidate.operationRow.outcome, + reconstruct: () => null + }) + return { + ok: false, + refusal: + replay.decision === 'refuse' + ? replay.refusal + : refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + 'The create outcome is unknown.' + ) + } + } + const prepared = replayed + ? { ok: true as const, items: null } + : await prepareAdoptedTranscript(params) + if (!prepared.ok) { + return prepared + } + const existing = context.sessions.get(sessionId) + const opened = existing + ? { session: existing } + : await openStructuredAgentSessionConversationJournal(deps, candidate.record).catch( + (error: unknown) => { + deps.logger.warn('opening the journal of a new chat failed', { + scope: 'create-journal-open', + sessionId, + error + }) + throw journalOpenRefusalError(error) + } + ) + const { session } = opened + const fence = candidate.record.lease.runtimeFence + let committed = replayed + try { + if (!replayed) { + await importAdoptedTranscript( + params, + { journal: session.journal, unconfirmedClientMessageIds: [] }, + candidate.record, + prepared.items, + { uncommittedCreate: true } + ) + const first = options.firstMessage + if (first) { + const sent = await performSend( + { + sessionId, + journal: session.journal, + fence, + adapter: deps.adapter, + agents: deps.agents, + agent: params.agent, + logger: deps.logger, + resolvedBy: callerKey, + persistOptions: async () => {}, + publish: () => {}, + operationReceipt: store.founding.receipt(request), + now: context.now + }, + { + ...first, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body: first.body } + }), + origin: 'client', + source: { kind: 'user' } + } + ) + if (!sent.ok) { + return sent + } + } else { + await store.founding.commit(request) + } + committed = true + } + try { + await options.publishTab?.() + } catch (error) { + deps.logger.warn('publishing the tab of a created chat failed', { + scope: 'create-tab-publication', + sessionId, + error + }) + if (!existing) { + context.sessions.set(sessionId, session) + } + return { + ok: false, + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'tabUnconfirmed' }, + 'The chat may have been created, but its tab could not be confirmed.' + ) + } + } + if (!existing) { + context.sessions.set(sessionId, session) + } + context.publishStatus(sessionId) + const tabId = store.getSessionTabId(sessionId) + const firstMessage = options.firstMessage + ? session.journal.submission(options.firstMessage.clientMessageId) + : undefined + const result = { + ok: true as const, + replayed, + fence, + cursor: session.journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(session.journal, fence), + unconfirmedClientMessageIds: [], + ...(firstMessage ? { firstMessage } : {}), + ...(tabId ? { tabId } : {}) + } + } + context.wakeDelivery?.(sessionId) + return result + } finally { + if (!committed && !existing) { + await session.journal.close() + } + } +} + +/** Host entry points that open a chat: attach an existing one or create a new one. */ +export function structuredAgentSessionEntryDelegates( + context: () => StructuredAgentSessionAttachContext +) { + return { + attach: ( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams, + options?: Parameters[3] + ) => attachStructuredAgentSession(context(), caller.callerKey, params, options), + create: ( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams, + options?: StructuredAgentSessionCreateOptions + ) => createStructuredAgentSession(context(), caller.callerKey, params, options) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index c7e2e039078..ce3a5093888 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -1,7 +1,4 @@ -import type { - AgentJournalMessageItem, - AgentJournalSnapshot -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' @@ -32,7 +29,7 @@ export function hostTestOperationId(): string { return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` } -export function hostTestMessage(text: string): AgentJournalMessageItem { +export function hostTestMessage(text: string): ReturnType { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 251ccc88e32..072bb865c34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -9,7 +9,6 @@ import { StructuredConversationCommandController } from './structured-conversati import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -17,7 +16,7 @@ import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' import { @@ -244,13 +243,9 @@ export class StructuredAgentSessionHost { private serialize = this.tasks.serialize.bind(this.tasks) - attach( - caller: StructuredAgentSessionCaller, - params: AgentSessionAttachParams, - options?: Parameters[3] - ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params, options) - } + private readonly entry = structuredAgentSessionEntryDelegates(() => this.attachContext()) + attach = this.entry.attach + create = this.entry.create /** Test barrier: every write has landed by its call's return, so no production path needs it. */ flushStreamedEvents = (id: string): Promise => this.runtimeState.flushEventSink(id) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 9b370959be0..7810ace2567 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -24,6 +24,23 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick +/** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ +export function structuredAgentSessionOptionOverridesRefusal( + agents: Pick, + provider: string, + options: Readonly> +) { + const rules = agents.definition(provider)?.restingOptions + const key = Object.keys(options).find((key) => !rules?.acceptsKey(key)) + return key === undefined + ? null + : refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${provider} has no session option named ${key}` + ) +} + /** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ function restingOptionRules( agents: Pick, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 786c7360e46..a35b13db74e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' @@ -22,11 +22,13 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, + hostTestMessage, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { createTestParams } from './structured-agent-session-create-test-fixture' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -54,6 +56,13 @@ beforeEach(async () => { applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, sources: {} + }, + // What a chat's options read lists; startup lists from initialize, which names only Sonnet. + routes: { + list_models: () => [ + { value: 'claude-sonnet', displayName: 'Sonnet' }, + { value: 'claude-opus-9', displayName: 'Opus 9' } + ] } }) adapter = new ClaudeStructuredSessionAdapter({ @@ -189,4 +198,31 @@ describe('a publish-first Claude create whose init is slow', () => { } ]) }) + it('reports the configured default from a chat created with its first message and no pick', async () => { + const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } + await expect( + host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) + ).resolves.toMatchObject({ ok: true }) + + // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the + // settings read after it (`options-reported`) is what resolves the configured model, so the + // chat's options read from then on names it, since nothing was picked. + await vi.waitFor(() => expect(savedListings).toHaveLength(1)) + expect(savedListings[0]).toEqual({ + sessionId: SESSION, + listing: expect.objectContaining({ + models: [expect.objectContaining({ id: 'claude-sonnet' })] + }) + }) + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) + expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + await host.readOptions(SESSION) + expect(savedListings.at(-1)).toEqual({ + sessionId: SESSION, + listing: expect.objectContaining({ + configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) + }) + }) + }) }) diff --git a/src/main/runtime/agent-session-founding-admission.ts b/src/main/runtime/agent-session-founding-admission.ts new file mode 100644 index 00000000000..0d83131c322 --- /dev/null +++ b/src/main/runtime/agent-session-founding-admission.ts @@ -0,0 +1,61 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { + assertAdoptedConversationUnowned, + assertReservedTabUnheld, + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + validateAgentSessionReservationInput, + type AgentSessionReserveRequest, + type AgentSessionReserveResult +} from './agent-session-reservation-admission' +import type { AgentSessionStoreState } from './agent-session-store-state' +import { setAgentSessionTabVisibility } from './agent-session-tab-table' + +/** The same create admission as a reservation, with no obligation to acquire an owner. */ +export function commitAgentSessionFounding( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): AgentSessionReserveResult { + const decision = evaluateAgentSessionReserveOperation(state, request) + if (decision.decision === 'refused') { + throw agentSessionRefusalError(decision.code, decision.details) + } + if (decision.decision === 'replay') { + return { + record: requireAgentSessionRecordForReplay(state, decision.row, request.sessionId), + disposition: 'replayed', + operationRow: decision.row + } + } + validateAgentSessionReservationInput(request) + if (request.expectedFence !== null) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + if (state.unreadableRecords.has(request.sessionId)) { + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + } + if (state.records.has(request.sessionId)) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + } + assertAdoptedConversationUnowned(state, request) + assertReservedTabUnheld(state, request) + const founded = foundAgentSessionRecord(request, request) + const record = request.adoptedHandleLink + ? { ...founded, providerHandleChain: [request.adoptedHandleLink] } + : founded + const operationRow = { + ...decision.row, + outcome: { status: 'succeeded' as const, sessionId: request.sessionId } + } + state.records.set(request.sessionId, record) + state.operations.set( + agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), + operationRow + ) + setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId) + return { record, disposition: 'created', operationRow } +} diff --git a/src/main/runtime/agent-session-founding-store.ts b/src/main/runtime/agent-session-founding-store.ts new file mode 100644 index 00000000000..d939ed9a7a2 --- /dev/null +++ b/src/main/runtime/agent-session-founding-store.ts @@ -0,0 +1,49 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' +import { commitAgentSessionFounding } from './agent-session-founding-admission' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { draftAgentSessionStoreState } from './agent-session-store-draft' +import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' + +/** Founding shares the store's admission and publishes only after the message transaction commits. */ +export function createAgentSessionFoundingStore( + transactions: AgentSessionStoreTransactions, + firstRecordListeners: ReadonlySet<() => void> +) { + return { + prepare: (request: AgentSessionReserveRequest) => + commitAgentSessionFounding(draftAgentSessionStoreState(transactions.state), request), + commit: async (request: AgentSessionReserveRequest) => { + let heldBefore = true + const result = await transactions.transact((draft) => { + heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 + return commitAgentSessionFounding(draft, request) + }) + if (!heldBefore) { + firstRecordListeners.forEach((listener) => listener()) + } + return result + }, + receipt: (request: AgentSessionReserveRequest): JournalOperationReceipt => { + let heldBefore = true + const receipt = transactions.receipt((draft) => { + heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 + const result = commitAgentSessionFounding(draft, request) + if (result.disposition === 'replayed') { + throw agentSessionRefusalError('agent_session_operation_unknown', { + reason: 'outcomeUnknown' + }) + } + }) + return { + write: receipt.write, + committed: () => { + receipt.committed() + if (!heldBefore) { + firstRecordListeners.forEach((listener) => listener()) + } + } + } + } + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 1b4dd7bd9d6..9cca024aca1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -73,6 +73,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { createAgentSessionFoundingStore } from './agent-session-founding-store' import { compareAndSetAgentSessionRecordName, type CompareAndSetConversationName, @@ -93,6 +94,7 @@ export class AgentSessionRecordStore { private readonly transactions: AgentSessionStoreTransactions, readonly hostId: string ) { + this.founding = createAgentSessionFoundingStore(transactions, this.firstRecordListeners) this.conversationReceipts = createAgentSessionConversationReceipts(transactions) } @@ -193,11 +195,12 @@ export class AgentSessionRecordStore { isClaimKeyVerifiable = (keyId: string, now: number): boolean => isAgentSessionClaimKeyVerifiable(this.state, keyId, now) - async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact((draft) => + reserveOwner = (request: AgentSessionReserveRequest): Promise => + this.transact((draft) => commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) ) - } + + readonly founding: ReturnType commitProcessIdentity = (args: AgentSessionProcessIdentityCommit): Promise => this.mutate(args.sessionId, (record) => commitAgentSessionProcessIdentity({ ...args, record })) @@ -232,10 +235,8 @@ export class AgentSessionRecordStore { args: AgentSessionFailedPostAcquisitionAttachmentSettlement ) => this.transact((draft) => settleFailedAgentSessionPostAcquisitionAttachment(draft, args)) - async renewLease(args: AgentSessionLeaseRenewal): Promise { - const [renewed] = await this.renewLeases([args]) - return renewed - } + renewLease = (args: AgentSessionLeaseRenewal): Promise => + this.renewLeases([args]).then(([renewed]) => renewed) async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { return this.transact((draft) => @@ -306,9 +307,8 @@ export class AgentSessionRecordStore { claimAfter: ClaimAfterAdmission ) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter)) - async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { - await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) - } + recordOperationOutcome = (args: AgentSessionOperationSettlement): Promise => + this.transact((draft) => settleAgentSessionOperationInto(draft, args)) /** The same settlement, committed by the journal write that makes it true. It changes only the * ledger, so no record listener is owed. */ diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index ff840c755b3..eb956e4b34c 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -143,14 +143,7 @@ export function admitPendingAgentSessionReservationReplay( return record } -export function applyAgentSessionReservation( - state: AgentSessionStoreState, - request: AgentSessionReserveRequest, - leaseTtlMs: number -): { - record: AgentSessionRecord - disposition: Exclude -} { +export function validateAgentSessionReservationInput(request: AgentSessionReserveRequest): void { if (request.launchEnv && !isAgentSessionLaunchEnv(request.launchEnv)) { throw new Error('agent_session_launch_env_invalid') } @@ -160,6 +153,17 @@ export function applyAgentSessionReservation( if (request.options && !isAgentSessionOptions(request.options)) { throw new Error('agent_session_options_invalid') } +} + +export function applyAgentSessionReservation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + leaseTtlMs: number +): { + record: AgentSessionRecord + disposition: Exclude +} { + validateAgentSessionReservationInput(request) const reservation: AgentSessionReservation = { spawnToken: typeof request.spawnToken === 'function' ? request.spawnToken() : request.spawnToken, @@ -228,7 +232,7 @@ export function applyAgentSessionReservation( * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread * silently, so the cost of missing this is a corrupted conversation rather than an error. */ -function assertAdoptedConversationUnowned( +export function assertAdoptedConversationUnowned( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { @@ -257,7 +261,7 @@ function assertAdoptedConversationUnowned( * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never * gets that far leaves nothing in the table to restore or release. */ -function assertReservedTabUnheld( +export function assertReservedTabUnheld( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts new file mode 100644 index 00000000000..a917eff7eef --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -0,0 +1,100 @@ +import type { z } from 'zod' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import type { RpcContext } from '../core' +import { + ensureStructuredHostInstalled as ensureHostInstalled, + requireStructuredHost as requireHost, + requireStructuredAgentAudience, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import type { AttachParams, CreateParams } from './structured-agent-session-schemas' +import { + commitStructuredAgentSessionCreate, + prepareStructuredAgentSessionCreateForWorktree, + structuredAgentSessionCreateIntentFingerprint +} from './structured-agent-session-create' +import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' + +/** + * The attach-shaped entries take the location from the client instead of resolving it from a + * worktree, so they never reach the worktree-resolving create-support check. Ask the executing + * host the same question directly: only it knows which agents and locations it runs, and a client + * cannot forge that answer. + */ +async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { + await ensureHostInstalled(ctx, params.agent) + const host = requireHost(ctx, params.agent) + if (!host.supportsCreate(params.location, params.agent)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const attachParams: AgentSessionAttachParams = params + return { host, attachParams } +} + +export async function attachClientSuppliedLocation( + params: z.infer, + ctx: RpcContext +): Promise { + const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) + return host.attach(callerFor(ctx), attachParams) +} + +export async function handleStructuredAgentSessionCreate( + params: z.infer, + ctx: RpcContext +) { + requireStructuredAgentAudience(ctx, params.agent) + if (params.envelope.expectedRuntimeFence !== null) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so + // a client can tell "nothing was created" from "the outcome is unknown". + const prepared = await resolveUncommittedStructuredCreate(async () => { + if ('worktree' in params) { + const conflict = agentSessionFingerprintConflict( + params.envelope, + structuredAgentSessionCreateIntentFingerprint(params) + ) + if (conflict) { + return { refusal: conflict } + } + return prepareStructuredAgentSessionCreateForWorktree({ + runtime: ctx.runtime, + ensureHost: async () => { + await ensureHostInstalled(ctx) + return requireHost(ctx) + }, + envelope: params.envelope, + worktree: params.worktree, + agent: params.agent, + caller: callerFor(ctx), + ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), + ...(params.tabId ? { tabId: params.tabId } : {}), + ...(params.options ? { options: params.options } : {}), + ...(params.firstMessage ? { firstMessage: params.firstMessage } : {}), + atRest: Boolean( + params.firstMessage || + ctx.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + ) + }) + } + const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) + return { host, attachParams, tab: null } + }) + if ('refusal' in prepared) { + return { ok: false, refusal: prepared.refusal } + } + return commitStructuredAgentSessionCreate({ + runtime: ctx.runtime, + caller: callerFor(ctx), + prepared, + activate: true + }) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts new file mode 100644 index 00000000000..82307145453 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -0,0 +1,259 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW, + hostTestMessage, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture' +import { stopCreatedChat } from '../../../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { AgentSessionPreSpawnError } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' + +const legacy = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} +const capable = { + ...legacy, + clientCapabilities: [ + ...legacy.clientCapabilities, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.restoreAllMocks() +}) + +function params(options?: Readonly>, withMessage = false) { + return structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '00000000-0000-0000-0000-0000000000fa', + now: NOW, + ...(options ? { options } : {}), + ...(withMessage + ? { + firstMessage: { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') } + } + : {}) + }) +} + +it('a capable client creates a blank chat without acquire', async () => { + expect(await call('agentSession.create', params(), capable)).toMatchObject({ + ok: true, + result: { ok: true, value: { page: { submissions: [] } } } + }) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() +}) + +it.each([ + { name: 'legacy', client: legacy }, + { name: 'capable', client: capable } +])( + 'a $name client receives its first-message receipt before handshake and on replay after Stop', + async ({ client }) => { + const input = params(undefined, true) + const entered = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce( + ({ signal }) => + new Promise((_resolve, reject) => { + entered.resolve() + signal?.addEventListener( + 'abort', + () => reject(new AgentSessionPreSpawnError(signal.reason)), + { once: true } + ) + }) + ) + expect(await call('agentSession.create', input, client)).toMatchObject({ + ok: true, + result: { + ok: true, + value: { + firstMessage: { + clientMessageId: input.firstMessage?.clientMessageId, + dispatchState: 'pending' + }, + page: { submissions: [{ clientMessageId: input.firstMessage?.clientMessageId }] } + } + } + }) + await entered.promise + await stopCreatedChat(rig.host) + expect(await call('agentSession.create', input, client)).toMatchObject({ + ok: true, + result: { + ok: true, + replayed: true, + value: { + firstMessage: { + clientMessageId: input.firstMessage?.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + } + } + } + }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + } +) + +it('the old client retains acquired create and waits for acquisition before publishing', async () => { + const acquired = rig.adapter.acquire.getMockImplementation() + if (!acquired) { + throw new Error('test acquire missing') + } + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce(async (input) => { + entered.resolve() + await release.promise + return acquired(input) + }) + let answered = false + const creating = call('agentSession.create', params(), legacy).then((result) => { + answered = true + return result + }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await entered.promise + expect(answered).toBe(false) + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + release.resolve() + expect(await creating).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() +}) + +it('replays an acquired create after the client negotiates create-message support', async () => { + const input = params() + expect(await call('agentSession.create', input, legacy)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true, replayed: true, value: { sessionId: SESSION } } + }) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() +}) + +it('keeps an at-rest replay at rest when the client capability list changes', async () => { + const input = params() + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(await call('agentSession.create', input, legacy)).toMatchObject({ + ok: true, + result: { ok: true, replayed: true } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') +}) + +it.each(['firstMessage', 'options'] as const)( + 'refuses adding %s to an already-acquired create operation', + async (field) => { + expect(await call('agentSession.create', params(), legacy)).toMatchObject({ + ok: true, + result: { ok: true } + }) + const changed = + field === 'firstMessage' ? params(undefined, true) : params({ model: 'held-model' }) + expect(await call('agentSession.create', changed, capable)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } + }) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([]) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + } +) + +it('merges held option overrides with host seeds before the first acquisition', async () => { + const input = params({ model: 'held-model' }) + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(await rig.host.send({ callerKey: 'client-1' }, restTestSend('first work'))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ options: { model: 'held-model', effort: 'medium' } }) + ) +}) + +it('rejects an explicit option the resting agent would reject before founding', async () => { + expect( + await call('agentSession.create', params({ unknownOption: 'yes' }), capable) + ).toMatchObject({ + ok: true, + result: { ok: false, refusal: { details: { reason: 'optionRejected' } } } + }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it.each(['options', 'firstMessage'] as const)( + 'rejects changes to firstMessage or explicit options under a replayed create operation: %s', + async (field) => { + const original = params({ model: 'held-model' }, true) + // Keep delivery held while reusing the create id with different intent. + expect( + await call('agentSession.create', original, capable, { + publishStructuredAgentSessionTab: async () => { + throw new Error('deferred tab') + } + }) + ).toMatchObject({ ok: true, result: { ok: false } }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' }) + const first = original.firstMessage + if (!first) { + throw new Error('fixture first message missing') + } + expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { clientMessageId: first.clientMessageId } + ]) + const changed = structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: original.worktree, + agent: original.agent, + now: NOW, + randomUuid: () => '00000000-0000-0000-0000-0000000000fa', + options: field === 'options' ? { model: 'other-model' } : original.options, + firstMessage: + field === 'firstMessage' ? { ...first, body: hostTestMessage('changed') } : first + }) + expect(await call('agentSession.create', changed, capable)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index d1857652f2d..924450fa19c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,18 +1,6 @@ -/** - * Creating a structured session for a worktree: resolve the create intent, attach it under the - * host-computed fingerprint, then publish its tab. - * - * Extracted from `agentSession.create` so orchestration can start a native-born structured worker - * on exactly the same path. `activate` is the only knob the two callers differ on: a chat the user - * asked for takes the surface, a background dispatch must not steal it (the terminal worker path's - * `surfaceOwner: false`). - * - * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before `attach` - * commits a session, so that span answers with a refusal, and nothing after it may be folded back - * in. Both callers run the same two halves, so orchestration gets that guarantee too. - */ +// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path. -import { refuse } from '../../../../shared/agent-session-wire-refusals' +import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -25,7 +13,11 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' -import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create' +import type { + StructuredAgentSessionResumeSource, + StructuredAgentSessionFirstMessage +} from '../../../../shared/structured-agent-session-create' +import { structuredAgentSessionOptionOverridesRefusal } from '../../../native-chat/agent-session-wire/structured-agent-session-options-read' import type { OrcaRuntimeService } from '../../orca-runtime' import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle' import { @@ -39,21 +31,19 @@ export type PreparedStructuredAgentSessionCreate = { hostLaunchDirectory?: string /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ tab: { workspaceId: string; agent: StructuredAgentId } | null + /** Absent for older clients and provider-dependent in-process callers. */ + atRest?: { firstMessage?: StructuredAgentSessionFirstMessage } } -/** - * What a client's create intent fingerprints, recomputed host-side. `resumeFrom` is part of the - * intent, not a detail of it: without it a retry of "adopt this conversation" would replay as, or - * conflict with, a blank create. `tabId` is covered so the declared digest spans the payload, but - * replay keys on the attach fingerprint, so a retry naming another tab is answered with the one the - * chat's tab holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. - */ +/** New creates keep the immutable client intent in the ledger; legacy creates keep attach identity. */ export function structuredAgentSessionCreateIntentFingerprint(params: { envelope: AgentSessionMutationEnvelope worktree: string agent: string resumeFrom?: StructuredAgentSessionResumeSource tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> }): string { return computeAgentSessionPayloadFingerprint({ method: 'agentSession.create', @@ -62,7 +52,9 @@ export function structuredAgentSessionCreateIntentFingerprint(params: { worktree: params.worktree, agent: params.agent, resumeFrom: params.resumeFrom, - tabId: params.tabId + tabId: params.tabId, + firstMessage: params.firstMessage, + options: params.options } }) } @@ -78,13 +70,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { agent: StructuredAgentId caller: StructuredAgentSessionCaller resumeFrom?: StructuredAgentSessionResumeSource - /** Replaces the seed options the host resolves from settings. Orchestration passes the - * `--model`/`--effort` the dispatch asked for; a chat the user opened passes nothing and keeps - * the saved selection. Narrowed by the caller, so `{}` never reaches the reservation. */ + /** Explicit picks override seed keys at rest; acquired in-process launches replace the seed. */ options?: Readonly> /** The tab id the caller reserved for this chat, taken when its tab is published; absent, the tab * gets the id clients derive. Beside `options`, after the fingerprint, likewise. */ tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + atRest?: boolean }): Promise { // Adoption replay may need the record loaded from disk before source discovery can be skipped. let host = args.resumeFrom ? await args.ensureHost() : null @@ -95,12 +87,36 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { callerKey: args.caller.callerKey, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) }) - const hostFingerprint = computeAgentSessionPayloadFingerprint({ + const attachFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId: args.envelope.sessionId, fields: attachFingerprintFields({ ...resolved, envelope: args.envelope }) }) host ??= await args.ensureHost() + const priorOperation = + args.atRest === undefined + ? null + : host.deps.store.getOperationRow(args.caller.callerKey, args.envelope.clientOperationId) + // A capability update cannot change the contract of an already-recorded create. + const legacyReplay = + args.firstMessage === undefined && + args.options === undefined && + priorOperation?.fingerprint === attachFingerprint + const atRest = + args.atRest !== undefined && + (priorOperation?.fingerprint === args.envelope.payloadFingerprint || + (args.atRest === true && !legacyReplay)) + const hostFingerprint = atRest ? args.envelope.payloadFingerprint : attachFingerprint + if (atRest && args.options) { + const refusal = structuredAgentSessionOptionOverridesRefusal( + host.deps.agents, + args.agent, + args.options + ) + if (refusal) { + throw new AgentSessionRefusalError(refusal) + } + } const { agent: _resolvedAgent, provider: _resolvedProvider, @@ -112,15 +128,16 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, - // After the fingerprint, deliberately: `attachFingerprintFields` excludes options because - // they are the session's initial state, not its identity, so a retry that re-resolves them - // must replay rather than conflict. - ...(args.options ? { options: args.options } : {}), + // The ledger covers explicit overrides, never defaults that settings can change on replay. + ...(args.options + ? { options: atRest ? { ...resolved.options, ...args.options } : args.options } + : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), provider: resolved.provider, agent: resolved.agent, envelope: { ...args.envelope, payloadFingerprint: hostFingerprint } }, + ...(atRest ? { atRest: args.firstMessage ? { firstMessage: args.firstMessage } : {} } : {}), tab: { workspaceId: resolved.location.workspaceId, agent: resolved.agent @@ -136,6 +153,29 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args + const tab = prepared.tab + if (prepared.atRest) { + return prepared.host.create(args.caller, prepared.attachParams, { + ...prepared.atRest, + ...(prepared.hostLaunchDirectory + ? { hostLaunchDirectory: prepared.hostLaunchDirectory } + : {}), + ...(tab + ? { + publishTab: () => + args.runtime.publishStructuredAgentSessionTab({ + workspaceId: tab.workspaceId, + sessionId: prepared.attachParams.envelope.sessionId, + agent: tab.agent, + activate: args.activate, + ...(prepared.attachParams.surfaceTabId + ? { tabId: prepared.attachParams.surfaceTabId } + : {}) + }) + } + : {}) + }) + } const result = prepared.hostLaunchDirectory ? await prepared.host.attach(args.caller, prepared.attachParams, { hostLaunchDirectory: prepared.hostLaunchDirectory diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index e837a7efdb0..6675ab4a1f0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -51,10 +51,13 @@ function hostStub(): StructuredAgentSessionHost { cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only attach, and the logger a failure past it reports to. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: legacy create reads the empty ledger, calls attach and logs failures. return { attach, - deps: { logger: recordingStructuredAgentSessionLogger().logger } + deps: { + logger: recordingStructuredAgentSessionLogger().logger, + store: { getOperationRow: vi.fn(() => null) } + } } as unknown as StructuredAgentSessionHost } @@ -201,6 +204,7 @@ describe('the boundary the envelope stops at', () => { expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } }) expect(refusalOf(response)).toBeNull() + expect(attach).toHaveBeenCalledOnce() }) it('leaves a committed create whose tab could not be published unknown', async () => { @@ -213,6 +217,7 @@ describe('the boundary the envelope stops at', () => { const refusal = refusalOf(response) expect(refusal?.code).toBe('agent_session_operation_unknown') expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(false) + expect(attach).toHaveBeenCalledOnce() }) it('keeps hiding the surface from a client that never advertised it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a24d21e6938..dfda59b17b3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -212,7 +212,8 @@ export function hostStub(): StructuredAgentSessionHost { unsubscribe: vi.fn() }) // Not a call: the logger the host hands a runtime caller that reports for it. - Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger }) + const logger = recordingStructuredAgentSessionLogger().logger + Reflect.set(hostCalls, 'deps', { logger, store: { getOperationRow: vi.fn(() => null) } }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4d22e9ff2a2..37b9e62f960 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,9 +5,6 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. -import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' -import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' -import type { z } from 'zod' import { projectBackgroundTaskEvent, projectBackgroundTaskHistory @@ -16,30 +13,25 @@ import { projectTurnItemEvent, projectTurnItemHistory } from './structured-agent-session-turn-item-capability' -import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, - requireStructuredAgentAudience, requireStructuredCleanupHost, requireStructuredCreateSupportAdmission, - requireStructuredHost as requireHost, requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' -import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { - commitStructuredAgentSessionCreate, - prepareStructuredAgentSessionCreateForWorktree, - structuredAgentSessionCreateIntentFingerprint -} from './structured-agent-session-create' + handleStructuredAgentSessionCreate, + attachClientSuppliedLocation +} from './structured-agent-session-create-handler' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './structured-agent-session-prompt-response' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' -import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { bindStructuredAgentSessionStream, STRUCTURED_AGENT_SESSION_STATUS_METHODS @@ -67,37 +59,6 @@ import { } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' -/** - * The attach-shaped entries take the location from the client instead of resolving it from a - * worktree, so they never reach the worktree-resolving create-support check. Ask the executing - * host the same question directly: only it knows which agents and locations it runs, and a client - * cannot forge that answer. - */ -async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { - await ensureHostInstalled(ctx, params.agent) - const host = requireHost(ctx, params.agent) - if (!host.supportsCreate(params.location, params.agent)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } - const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params - const attachParams = { - ...attachWithoutAgent, - provider: params.provider as 'claude' | 'codex', - agent: params.agent as 'claude' | 'codex' - } as AgentSessionAttachParams - return { host, attachParams } -} - -async function attachClientSuppliedLocation( - params: z.infer, - ctx: RpcContext -): Promise { - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return host.attach(callerFor(ctx), attachParams) -} - export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', @@ -142,51 +103,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.create', permission: 'workspace', params: CreateParams, - handler: async (params, ctx) => { - requireStructuredAgentAudience(ctx, params.agent) - if (params.envelope.expectedRuntimeFence !== null) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'requestMalformed' - }) - } - // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so - // a client can tell "nothing was created" from "the outcome is unknown". - const prepared = await resolveUncommittedStructuredCreate(async () => { - if ('worktree' in params) { - const conflict = agentSessionFingerprintConflict( - params.envelope, - structuredAgentSessionCreateIntentFingerprint(params) - ) - if (conflict) { - return { refusal: conflict } - } - return prepareStructuredAgentSessionCreateForWorktree({ - runtime: ctx.runtime, - ensureHost: async () => { - await ensureHostInstalled(ctx) - return requireHost(ctx) - }, - envelope: params.envelope, - worktree: params.worktree, - agent: params.agent, - caller: callerFor(ctx), - ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), - ...(params.tabId ? { tabId: params.tabId } : {}) - }) - } - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return { host, attachParams, tab: null } - }) - if ('refusal' in prepared) { - return { ok: false, refusal: prepared.refusal } - } - return commitStructuredAgentSessionCreate({ - runtime: ctx.runtime, - caller: callerFor(ctx), - prepared, - activate: true - }) - } + handler: handleStructuredAgentSessionCreate }), defineMethod({ name: 'agentSession.ensure', diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx new file mode 100644 index 00000000000..d7f0a5ac4fc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' +import { structuredSessionNotices } from './native-chat-structured-session-notices' + +afterEach(cleanup) + +it.each(['failed', 'visibility-unknown'] as const)( + 'says the create hand-back failure once and keeps Retry for %s', + (lifecycle) => { + const retry = vi.fn() + const message = 'The message is back in the composer.' + const notices = structuredSessionNotices({ + launch: { lifecycle, failure: null, retry }, + agentLabel: 'Codex', + sessionError: message, + composerError: null + }) + render() + expect(screen.getAllByText(message)).toHaveLength(1) + expect(screen.queryByText('Chat could not be started.')).toBeNull() + expect(screen.queryByText('Chat connection could not be confirmed.')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(retry).toHaveBeenCalledOnce() + } +) + +it('keeps unrelated composer errors beside the hand-back notice', () => { + render( + {} }, + agentLabel: 'Codex', + sessionError: 'The message is back in the composer.', + composerError: { text: 'Attachment could not be added.', onDismiss: () => {} } + })} + /> + ) + expect(screen.getByText('The message is back in the composer.')).toBeTruthy() + expect(screen.getByText('Attachment could not be added.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index c6dcd6abd89..6f28e7b6990 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -101,8 +101,17 @@ export function structuredSessionNotices({ }) return [ ...(availability ? [availability] : []), - ...(launchNotice ? [launchNotice] : []), - ...(sessionError ? [{ key: 'session', kind: 'error' as const, text: sessionError }] : []), + ...(launchNotice && !sessionError ? [launchNotice] : []), + ...(sessionError + ? [ + { + key: 'session', + kind: 'error' as const, + text: sessionError, + ...(launchNotice ? { action: launchNotice.action } : {}) + } + ] + : []), ...(composerError ? [{ key: 'composer-error', kind: 'error' as const, ...composerError }] : []) ] } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 3aacca13263..c47551febc9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { - AgentJournalMessageItem, AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' @@ -19,7 +18,7 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi const SENDING = 'Sending…' -function body(text: string): AgentJournalMessageItem { +function body(text: string): StructuredAgentSessionPendingSend['body'] { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 87d96fd97df..b554778544e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -9,23 +9,18 @@ // one request, and whatever its answer, nothing sends it again. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionFirstMessage } from '../../../../shared/structured-agent-session-create' import { agentSessionUnconfirmedSendParts, - agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { attemptStructuredAgentSessionSend, forgetStructuredAgentSessionFence, resetStructuredAgentSessionFencesForTests } from './structured-agent-session-send-attempt' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { nativeChatRewindReasonCopy } from './native-chat-rewind-copy' -import { handBackStructuredAgentSessionMessage } from './structured-agent-session-message-hand-back' import { takeStructuredAgentSessionSendSlot, type StructuredAgentSessionSendInput @@ -35,108 +30,26 @@ import { findStructuredAgentSessionPendingSend, getStructuredAgentSessionPendingSends, publishStructuredAgentSessionSends, - structuredAgentSessionSendsWatched, structuredAgentSessionsWithPendingSends, updateStructuredAgentSessionPendingSend, type StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' - -/** From the moment a message is sent: past it, the message goes back to the composer, saying it - * was not sent if it never went out, and that nobody could confirm it if it did. */ -export const STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS = 30_000 - -/** `returned`: back in the composer, not sent. `unconfirmed`: back in the composer, though the host - * may hold it. */ -export type StructuredAgentSessionSendOutcome = 'recorded' | 'returned' | 'unconfirmed' | 'dropped' - -type SendRuntime = { - target: RuntimeClientTarget - abort: AbortController - deadline: ReturnType - resolve: (outcome: StructuredAgentSessionSendOutcome) => void -} - -const runtimes = new Map() - -/** Ends a send for good: the entry leaves (or stays as `recorded`) and its caller is answered. */ -function finish( - entry: StructuredAgentSessionPendingSend, - outcome: StructuredAgentSessionSendOutcome, - keep?: Partial -): void { - const runtime = runtimes.get(entry.clientMessageId) - if (runtime) { - clearTimeout(runtime.deadline) - runtimes.delete(entry.clientMessageId) - runtime.resolve(outcome) - } - updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) -} - -/** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ -function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { - try { - handBackStructuredAgentSessionMessage( - entry.sessionId, - entry.clientMessageId, - entry.body, - entry.imageConnectionIds - ) - return true - } catch (error) { - console.error('[native-chat-send] a message could not be put back in the composer', error) - return false - } -} - -function handBack( - entry: StructuredAgentSessionPendingSend, - notice: readonly AgentSessionWriteNoticePart[] | null -): void { - try { - if (entry.callerKeepsText) { - return - } - const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer(entry) - ? notice - : [...(notice ?? []), 'messageNotSaved'] - if (parts) { - publishStructuredAgentSessionSends(entry.sessionId, { - notice: agentSessionWriteNoticeText([...parts]) - }) - } - } finally { - // Bookkeeping never holds the chat: whatever the hand-back met, the send ends. - finish(entry, notice?.includes('sendOutcomeLost') ? 'unconfirmed' : 'returned') - } -} - -/** Settled by the host's answer, from the send's own reply or the journal, whichever comes first. */ -function settleRecorded( - entry: StructuredAgentSessionPendingSend, - submission: AgentJournalSubmission | null, - from: 'reply' | 'journal' -): void { - // A message a Stop took back stays in the chat with its stop row, drawn by the host's row; the - // composer is left alone. An open chat draws a recorded one until its row arrives, which can - // trail the reply. - const keep = - from === 'reply' && - !(submission && dispatchWasWithdrawn(submission)) && - structuredAgentSessionSendsWatched(entry.sessionId) - finish(entry, 'recorded', keep ? { phase: 'recorded', issued: true } : undefined) -} - -/** Why the host turned the send away. Behind a rewind whose outcome is unknown it was not sent: the - * rewind's words say why, and the next send has the host check it first. */ -function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { - return failure.kind === 'refused' && failure.details?.reason === 'rewindUnconfirmed' - ? [ - { text: nativeChatRewindReasonCopy('outcome-unknown') }, - ...agentSessionWriteNotDoneParts('composer-send') - ] - : agentSessionWriteNoticeParts(failure, 'composer-send') -} +import { + beginSend, + finish, + handBack, + refusedSendParts, + runtimes, + settleRecorded, + STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS, + type StructuredAgentSessionSendOutcome, + type StructuredAgentSessionSent +} from './structured-agent-session-send-runtime' +export { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from './structured-agent-session-send-runtime' +export type { + StructuredAgentSessionSendOutcome, + StructuredAgentSessionSent +} from './structured-agent-session-send-runtime' /** The send's one request, and what its answer settles. Nothing is ever sent again. */ async function attempt(entry: StructuredAgentSessionPendingSend): Promise { @@ -169,45 +82,13 @@ async function attempt(entry: StructuredAgentSessionPendingSend): Promise } } -function onDeadline(sessionId: string, clientMessageId: string): void { - const entry = findStructuredAgentSessionPendingSend(sessionId, clientMessageId) - if (!entry || entry.phase === 'recorded') { - return - } - runtimes.get(clientMessageId)?.abort.abort() - // One that went out may still land: its row then shows it beside the text given back. - handBack( - entry, - entry.issued - ? ['sendOutcomeLost'] - : ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')] - ) -} - -export type StructuredAgentSessionSent = { - clientMessageId: string - outcome: Promise -} - -/** Sends a message holding its chat's slot; its 30 s start now. */ function dispatch( entry: StructuredAgentSessionPendingSend, target: RuntimeClientTarget ): StructuredAgentSessionSent { - const { clientMessageId, sessionId } = entry - const outcome = new Promise((resolve) => { - runtimes.set(clientMessageId, { - target, - abort: new AbortController(), - deadline: setTimeout( - () => onDeadline(sessionId, clientMessageId), - STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - ), - resolve - }) - }) + const sent = beginSend(entry, target) void attempt(entry) - return { clientMessageId, outcome } + return sent } /** @@ -224,12 +105,27 @@ export function sendStructuredAgentSessionMessage( } export type StructuredAgentSessionReservedSend = { + outcome: Promise + /** Older creates wait for acquisition; their send budget still begins at send. */ + legacy: () => void /** Sends it once the chat exists; null when the reservation was released or dropped meanwhile. */ send: (target: RuntimeClientTarget) => StructuredAgentSessionSent | null + /** The same message, owned by create instead of a later send request. */ + create: (target: RuntimeClientTarget) => StructuredAgentSessionCreateSend | null /** Gives the slot back unsent. */ release: () => void } +export type StructuredAgentSessionCreateSend = StructuredAgentSessionSent & { + message: StructuredAgentSessionFirstMessage + settle: ( + result: + | { kind: 'recorded'; submission: AgentJournalSubmission } + | { kind: 'refused'; failure?: AgentSessionWriteFailure } + | { kind: 'unconfirmed' } + ) => void +} + /** * A launch's prompt holds its chat's one send from the click: drawn as sending, so nothing typed * meanwhile overtakes it, and sent once the chat exists. Null while the chat already has a send out. @@ -243,17 +139,81 @@ export function reserveStructuredAgentSessionSend( } // Once sent, the entry is the send's: what it keeps after settling is not the reservation's. let sent = false + let settleReservation = (_outcome: StructuredAgentSessionSendOutcome): void => {} + const outcome = new Promise((resolve) => { + settleReservation = resolve + }) const held = (): StructuredAgentSessionPendingSend | undefined => sent ? undefined : findStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId) + const deadline = setTimeout(() => { + const current = held() + if (current) { + handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) + settleReservation('returned') + } + }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) return { + outcome, + legacy: () => clearTimeout(deadline), send: (target) => { const current = held() sent = true - return current ? dispatch(current, target) : null + clearTimeout(deadline) + const sending = current ? dispatch(current, target) : null + if (sending) { + void sending.outcome.then(settleReservation) + } + return sending + }, + create: (target) => { + const current = held() + if (!current) { + return null + } + sent = true + clearTimeout(deadline) + const remaining = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) + if (remaining <= 0) { + handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) + settleReservation('returned') + return null + } + const sending = beginSend(current, target, remaining) + void sending.outcome.then(settleReservation) + updateStructuredAgentSessionPendingSend(current.sessionId, current.clientMessageId, { + issued: true + }) + return { + ...sending, + message: { clientMessageId: current.clientMessageId, body: current.body }, + settle: (result) => { + const pending = findStructuredAgentSessionPendingSend( + current.sessionId, + current.clientMessageId + ) + if (!pending || pending.phase === 'recorded' || !runtimes.has(current.clientMessageId)) { + return + } + if (result.kind === 'recorded') { + settleRecorded(pending, result.submission, 'reply') + } else { + handBack( + pending, + result.kind === 'unconfirmed' + ? agentSessionUnconfirmedSendParts(undefined) + : result.failure + ? refusedSendParts(result.failure) + : agentSessionWriteNotDoneParts('composer-send') + ) + } + } + } }, release: () => { + clearTimeout(deadline) if (held()) { updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) + settleReservation('dropped') } } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index cf9e66a0db8..24ee3073779 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -1,12 +1,12 @@ // Each chat's sends the host has not answered yet, in memory only: what the sender keeps and the // chat's view reads. -import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' export type StructuredAgentSessionPendingSend = { clientMessageId: string sessionId: string - body: AgentJournalMessageItem + body: ReturnType previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts index 946ffbf966b..00d93bdd8f8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts @@ -11,6 +11,20 @@ export function findStructuredAgentSessionReadOwner( ): StructuredAgentSessionReadOwner | undefined { return owners.get(structuredReadOwnerKey(sessionId, target)) } +/** A closed chat no longer needs an unmounted create page or an active reader. */ +export function retireStructuredAgentSessionReadOwner( + sessionId: string, + target: RuntimeClientTarget +): void { + const key = structuredReadOwnerKey(sessionId, target) + const owner = owners.get(key) + owners.delete(key) + try { + owner?.dispose() + } catch (error) { + console.warn('[native-chat] retiring a conversation reader failed', error) + } +} export function getOrCreateStructuredReadOwner( key: string, create: () => StructuredAgentSessionReadOwner diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index eb08719aba0..ac45d2c0cbf 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -3,6 +3,7 @@ import type { AgentProviderSessionMetadata } from '../../../../shared/agent-sess import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import { AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryPage, type AgentSessionHistoryResult } from '../../../../shared/agent-session-wire' import { @@ -30,6 +31,7 @@ export type StructuredAgentSessionReadSnapshot = { } export type StructuredAgentSessionReadOwner = { + publishCreated: (page: AgentSessionHistoryPage) => void activate: () => () => void dispose: () => void getSnapshot: () => StructuredAgentSessionReadSnapshot @@ -45,6 +47,7 @@ import { } from './structured-agent-session-read-owner-registry' export { findStructuredAgentSessionReadOwner, + retireStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner-registry' @@ -218,6 +221,11 @@ function createReadOwner( } } owner = { + publishCreated: (page) => { + if (snapshot.state.epoch === null) { + apply({ type: 'history-page', page }) + } + }, activate: () => { // Remounts and StrictMode re-run setup on an owner whose cleanup just forgot it. adoptStructuredReadOwner(key, owner) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts new file mode 100644 index 00000000000..fe8cfafa58c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -0,0 +1,150 @@ +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { + agentSessionWriteNoticeParts, + agentSessionWriteNotDoneParts +} from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' +import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import { nativeChatRewindReasonCopy } from './native-chat-rewind-copy' +import { handBackStructuredAgentSessionMessage } from './structured-agent-session-message-hand-back' +import { + findStructuredAgentSessionPendingSend, + publishStructuredAgentSessionSends, + structuredAgentSessionSendsWatched, + updateStructuredAgentSessionPendingSend, + type StructuredAgentSessionPendingSend +} from './structured-agent-session-pending-sends' + +export const STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS = 30_000 + +export type StructuredAgentSessionSendOutcome = 'recorded' | 'returned' | 'unconfirmed' | 'dropped' + +type SendRuntime = { + target: RuntimeClientTarget + abort: AbortController + deadline: ReturnType + resolve: (outcome: StructuredAgentSessionSendOutcome) => void +} + +export const runtimes = new Map() + +/** Ends a send for good: the entry leaves (or stays as `recorded`) and its caller is answered. */ +export function finish( + entry: StructuredAgentSessionPendingSend, + outcome: StructuredAgentSessionSendOutcome, + keep?: Partial +): void { + const runtime = runtimes.get(entry.clientMessageId) + if (runtime) { + clearTimeout(runtime.deadline) + runtimes.delete(entry.clientMessageId) + runtime.resolve(outcome) + } + updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) +} + +/** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ +function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { + try { + handBackStructuredAgentSessionMessage( + entry.sessionId, + entry.clientMessageId, + entry.body, + entry.imageConnectionIds + ) + return true + } catch (error) { + console.error('[native-chat-send] a message could not be put back in the composer', error) + return false + } +} + +export function handBack( + entry: StructuredAgentSessionPendingSend, + notice: readonly AgentSessionWriteNoticePart[] | null +): void { + try { + if (entry.callerKeepsText) { + return + } + const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer(entry) + ? notice + : [...(notice ?? []), 'messageNotSaved'] + if (parts) { + publishStructuredAgentSessionSends(entry.sessionId, { + notice: agentSessionWriteNoticeText([...parts]) + }) + } + } finally { + // Bookkeeping never holds the chat: whatever the hand-back met, the send ends. + finish(entry, notice?.includes('sendOutcomeLost') ? 'unconfirmed' : 'returned') + } +} + +/** Settled by the host's answer, from the send's own reply or the journal, whichever comes first. */ +export function settleRecorded( + entry: StructuredAgentSessionPendingSend, + submission: AgentJournalSubmission | null, + from: 'reply' | 'journal' +): void { + // A message a Stop took back stays in the chat with its stop row, drawn by the host's row; the + // composer is left alone. An open chat draws a recorded one until its row arrives, which can + // trail the reply. + const keep = + from === 'reply' && + !(submission && dispatchWasWithdrawn(submission)) && + structuredAgentSessionSendsWatched(entry.sessionId) + finish(entry, 'recorded', keep ? { phase: 'recorded', issued: true } : undefined) +} + +/** Why the host turned the send away. Behind a rewind whose outcome is unknown it was not sent: the + * rewind's words say why, and the next send has the host check it first. */ +export function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { + return failure.kind === 'refused' && failure.details?.reason === 'rewindUnconfirmed' + ? [ + { text: nativeChatRewindReasonCopy('outcome-unknown') }, + ...agentSessionWriteNotDoneParts('composer-send') + ] + : agentSessionWriteNoticeParts(failure, 'composer-send') +} + +function onDeadline(sessionId: string, clientMessageId: string): void { + const entry = findStructuredAgentSessionPendingSend(sessionId, clientMessageId) + if (!entry || entry.phase === 'recorded') { + return + } + runtimes.get(clientMessageId)?.abort.abort() + // One that went out may still land: its row then shows it beside the text given back. + handBack( + entry, + entry.issued + ? ['sendOutcomeLost'] + : ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')] + ) +} + +export type StructuredAgentSessionSent = { + clientMessageId: string + outcome: Promise +} + +/** Sends a message holding its chat's slot; its 30 s start now. */ +export function beginSend( + entry: StructuredAgentSessionPendingSend, + target: RuntimeClientTarget, + budgetMs = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS +): StructuredAgentSessionSent { + const { clientMessageId, sessionId } = entry + const outcome = new Promise((resolve) => { + runtimes.set(clientMessageId, { + target, + abort: new AbortController(), + deadline: setTimeout(() => onDeadline(sessionId, clientMessageId), budgetMs), + resolve + }) + }) + return { clientMessageId, outcome } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts index 4babd5ab7ec..20df6c45fe0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts @@ -3,6 +3,8 @@ export function structuredAgentSessionStopControl(input: { /** The host has published this chat to this view. */ published: boolean + /** Temporary: hosts without create.message.v1 still leave the prompt on this client. */ + legacyLaunch?: { takeBackText: () => void } /** The Stop the host is asked for once it has published this chat. */ host: { /** The host takes a Stop naming no turn (and this view holds its fence). */ @@ -15,24 +17,21 @@ export function structuredAgentSessionStopControl(input: { sending: boolean /** A Stop's part of that send: what has not gone out goes no further. */ stopSends: () => void - /** Before the host publishes the chat: the launch's unsent text goes back to the composer. */ - takeBackLaunchText: () => void } }): { canStop: boolean; stop: () => Promise } { - const { published, host } = input + const { published, host, legacyLaunch } = input const { turnId, isWorking } = input.transportState - const { sending, stopSends, takeBackLaunchText } = input.sends + const { sending, stopSends } = input.sends return { - // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes - // back what this client holds, so a start that never answers cannot hold the message hostage. - canStop: - turnId !== null || - (!published && sending) || - (host.stopsConversation && (isWorking || sending)), + canStop: published + ? turnId !== null || (host.stopsConversation && (isWorking || sending)) + : legacyLaunch !== undefined && sending, stop: () => { if (!published) { - takeBackLaunchText() - stopSends() + if (legacyLaunch) { + legacyLaunch.takeBackText() + stopSends() + } return Promise.resolve(null) } return host.stop(turnId, stopSends) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 6ea30fa31a3..84c3897fcfc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -59,8 +59,12 @@ vi.mock('./use-structured-agent-session-sends', () => ({ } })) +vi.mock('@/lib/structured-agent-session-launch-create-support', () => ({ + useStructuredLaunchCreateSupport: () => false +})) + vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({ - takeBackStructuredLaunchPrompts: mocks.takeBackLaunchText + takeBackLegacyStructuredLaunchPrompts: mocks.takeBackLaunchText })) vi.mock('./native-chat-session-option-settings-write', () => ({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 5c5f4615057..f2af91bc5c3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -5,7 +5,7 @@ import { structuredAgentSessionNewSendsQueue } from './structured-agent-session- import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' -import { takeBackStructuredLaunchPrompts } from '@/lib/structured-agent-session-launch-prompt' +import { useStructuredLegacyLaunchStop } from '@/lib/structured-agent-session-legacy-launch-stop' import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostQueuesCommands, @@ -92,6 +92,7 @@ export function useStructuredAgentSession(args: { enabled: transportEnabled }) const commandPending = useRef(false) + const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) const transportState = useStructuredAgentSessionTransportState(state, transportEnabled) const { conversationCommands, @@ -274,12 +275,12 @@ export function useStructuredAgentSession(args: { turnId: transportState.turnId, ...structuredAgentSessionStopControl({ published: transportEnabled, + ...legacyLaunchStop, host: stopControl, transportState, sends: { sending, - stopSends: sends.stopSends, - takeBackLaunchText: () => takeBackStructuredLaunchPrompts(sessionId) + stopSends: sends.stopSends } }), stopPressed: stopControl.pressed, diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 6f95d73d0ed..3b2d0c778f2 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,20 +1,12 @@ import type { TuiAgent } from '../../../shared/tui-agent' -import type { - AgentSessionAttachResult, - AgentSessionMutationResult -} from '../../../shared/agent-session-wire' import { createStructuredAgentSessionId, structuredAgentSessionCreateParams, type StructuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage, type StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' -import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' -import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' -import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' -import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' import { @@ -30,9 +22,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { ExecutionHostId } from '../../../shared/execution-host' import { createBrowserUuid } from '@/lib/browser-uuid' -import { askHostCreateSupport } from '@/lib/structured-agent-session-host-admission' import { - StructuredAgentSessionCreateError, StructuredAgentSessionCreateRefusalError, StructuredAgentSessionCreateUnknownOutcomeError, StructuredAgentSessionOwnerUnresolvedError @@ -54,6 +44,9 @@ export type StructuredAgentSessionLaunchIntent = { target: RuntimeClientTarget agent: TuiAgent params: StructuredAgentSessionCreateParams + createMessageSupport?: boolean + createPrepared?: true + prepareCreate?: () => void /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> } @@ -86,27 +79,6 @@ function structuredAgentSessionOwnerTarget( return { executionHostId, target } } -const DEFINITIVE_CREATE_FAILURE_CODES = [ - 'structured_agent_session_unsupported', - 'method_not_found' -] as const - -function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string | null { - if (error instanceof StructuredAgentSessionCreateError) { - // Our own classes already carry the verdict; message sniffing below could only invert it. - return error instanceof StructuredAgentSessionCreateRefusalError && - isDefinitiveAgentSessionCreateRefusal(error.code) - ? error.code - : null - } - for (const code of DEFINITIVE_CREATE_FAILURE_CODES) { - if (hasRuntimeRpcErrorCode(error, code)) { - return code - } - } - return null -} - /** `executionHostId` is the host the launch was routed to; absent, the catalog must name exactly * one, or the launch is refused rather than sent to whichever host a fallback picks. */ export function createStructuredAgentSessionLaunchIntent( @@ -190,6 +162,9 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { resumeFrom?: StructuredAgentSessionResumeSource /** A paired server's seed, kept with the launch so a reload shows what create runs. */ seedOptions?: Readonly> + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> + createMessageSupport?: boolean }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) @@ -215,8 +190,14 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }, worktree: toRuntimeWorktreeSelector(args.worktreeId), agent: args.agent, - ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) + ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), + ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.options ? { options: args.options } : {}) }, + ...(args.createMessageSupport === undefined + ? {} + : { createMessageSupport: args.createMessageSupport }), + ...(args.createMessageSupport === undefined ? {} : { createPrepared: true as const }), ...launchSeedOptions(state, { target }, args.agent, args.seedOptions) } } @@ -231,75 +212,7 @@ export function abandonStructuredAgentSessionLaunchIntent( ) } -/** - * Only the host that will execute the session can answer whether it supports creating one there. - * Both providers ask: the host classifies per agent, and Codex inherits the - * unresolvable-selector retry above along with the probe. The unknown branch stays on the chat for - * reconciliation: a retry may follow a create whose reply was lost. Answers the seed create will use. - */ -async function requireHostCreateSupport( - intent: StructuredAgentSessionLaunchIntent -): Promise { - const support = await askHostCreateSupport(intent.target, intent.params.worktree, intent.agent) - if (support.kind === 'unreachable') { - throw new StructuredAgentSessionCreateUnknownOutcomeError( - support.message, - support.code, - readAgentSessionErrorRefusal(support.error) - ) - } - // A workspace the host still cannot resolve is refused here, as before; Retry asks again. - if (support.kind === 'declined' || support.kind === 'workspace-unresolved') { - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError( - 'structured_agent_session_unsupported', - 'structured_agent_session_unsupported' - ) - } - return support.seedOptions -} - -/** Told the seed a paired server says this create will use, which may differ from an earlier - * attempt's; a local launch reads its own settings instead. */ -export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void - -export async function launchStructuredAgentSession( - intent: StructuredAgentSessionLaunchIntent, - onHostSeed?: StructuredLaunchHostSeedListener -): Promise> { - const hostSeed = await requireHostCreateSupport(intent) - if (intent.target.kind !== 'local') { - onHostSeed?.(hostSeed) - } - let result: AgentSessionMutationResult - try { - result = await callStructuredAgentSession>( - intent.target, - 'agentSession.create', - intent.params - ) - } catch (error) { - const code = definitiveStructuredAgentSessionCreateErrorCode(error) - if (code) { - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError( - error instanceof Error ? error.message : String(error), - code, - readAgentSessionErrorRefusal(error) - ) - } - throw error - } - if (!result.ok) { - const { code, message, ownerVerdict } = result.refusal - const refusal = readAgentSessionRefusalReference(result.refusal) - // A failed operation whose provider is proven gone is a failure a new operation may retry. - if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { - // Keep the focus intent: the session may exist, and recovery still has to adopt it. - throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) - } - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) - } - return { sessionId: result.value.sessionId, fence: result.value.fence } -} +export { + launchStructuredAgentSession, + type StructuredLaunchHostSeedListener +} from './structured-agent-session-launch-create-call' diff --git a/src/renderer/src/lib/structured-agent-session-create-hydration.ts b/src/renderer/src/lib/structured-agent-session-create-hydration.ts new file mode 100644 index 00000000000..9525df8f742 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-create-hydration.ts @@ -0,0 +1,18 @@ +import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' +import { hasStructuredAgentLaunchCancellationTombstonePersisted } from './structured-agent-session-launch-persistence' +import { getStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' +import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' + +/** The host's create proof belongs to the existing reader until its tab closes. */ +export function publishStructuredAgentSessionCreateHydration( + intent: StructuredAgentSessionLaunchIntent, + page: AgentSessionHistoryPage, + fence: number +): void { + if (hasStructuredAgentLaunchCancellationTombstonePersisted(intent.sessionId)) { + return + } + getStructuredAgentSessionReadOwner(intent.sessionId, intent.target).publishCreated(page) + noteStructuredAgentSessionFence(intent.sessionId, fence) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts new file mode 100644 index 00000000000..be0f3c6b853 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -0,0 +1,153 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' +import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' +import { + callStructuredAgentSession, + supportsStructuredAgentSessionCreateMessage +} from '@/runtime/structured-agent-session-client' +import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' +import { askHostCreateSupport } from './structured-agent-session-host-admission' +import { + abandonStructuredAgentSessionLaunchIntent, + type StructuredAgentSessionLaunchIntent +} from './launch-structured-agent-session' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { + StructuredAgentSessionCreateError, + StructuredAgentSessionCreateRefusalError, + StructuredAgentSessionCreateUnknownOutcomeError +} from './structured-agent-session-launch-errors' + +type LaunchSeed = Readonly> | undefined +const DEFINITIVE_CREATE_FAILURE_CODES = [ + 'structured_agent_session_unsupported', + 'method_not_found' +] as const + +function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string | null { + if (error instanceof StructuredAgentSessionCreateError) { + // Our own classes already carry the verdict; message sniffing below could only invert it. + return error instanceof StructuredAgentSessionCreateRefusalError && + isDefinitiveAgentSessionCreateRefusal(error.code) + ? error.code + : null + } + for (const code of DEFINITIVE_CREATE_FAILURE_CODES) { + if (hasRuntimeRpcErrorCode(error, code)) { + return code + } + } + return null +} + +/** + * Only the host that will execute the session can answer whether it supports creating one there. + * Both providers ask: the host classifies per agent, and Codex inherits the + * unresolvable-selector retry above along with the probe. The unknown branch stays on the chat for + * reconciliation: a retry may follow a create whose reply was lost. Answers the seed create will use. + */ +async function requireHostCreateSupport( + intent: StructuredAgentSessionLaunchIntent +): Promise { + const support = await askHostCreateSupport(intent.target, intent.params.worktree, intent.agent) + if (support.kind === 'unreachable') { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + support.message, + support.code, + readAgentSessionErrorRefusal(support.error) + ) + } + if (support.kind === 'declined' || support.kind === 'workspace-unresolved') { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + 'structured_agent_session_unsupported', + 'structured_agent_session_unsupported' + ) + } + return support.seedOptions +} + +/** Told the seed a paired server says this create will use, which may differ from an earlier + * attempt's; a local launch reads its own settings instead. */ +export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void + +export async function launchStructuredAgentSession( + intent: StructuredAgentSessionLaunchIntent, + onHostSeed?: StructuredLaunchHostSeedListener +): Promise { + const hostSeed = await requireHostCreateSupport(intent) + if (intent.target.kind !== 'local') { + onHostSeed?.(hostSeed) + } + if (!intent.createPrepared) { + let supported = false + try { + supported = await supportsStructuredAgentSessionCreateMessage(intent.target) + } catch { + // Unknown capability stays on the compatible create path. + } + if (!intent.createPrepared) { + intent.createMessageSupport = supported + intent.prepareCreate?.() + intent.createPrepared = true + } + } + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession>( + intent.target, + 'agentSession.create', + intent.params + ) + } catch (error) { + const code = definitiveStructuredAgentSessionCreateErrorCode(error) + if (code) { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : String(error), + code, + readAgentSessionErrorRefusal(error) + ) + } + throw error + } + if (!result.ok) { + const { code, message, ownerVerdict } = result.refusal + const refusal = readAgentSessionRefusalReference(result.refusal) + // A failed operation whose provider is proven gone is a failure a new operation may retry. + if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { + // Keep the focus intent: the session may exist, and recovery still has to adopt it. + throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) + } + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) + } + const firstMessage = intent.params.firstMessage + ? (result.value.firstMessage ?? + result.value.page.submissions.find( + (submission) => submission.clientMessageId === intent.params.firstMessage?.clientMessageId + )) + : undefined + if ( + intent.params.firstMessage && + firstMessage?.clientMessageId !== intent.params.firstMessage.clientMessageId + ) { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + 'The created chat did not confirm its first message.', + 'agent_session_operation_unknown' + ) + } + if (intent.createMessageSupport === true) { + publishStructuredAgentSessionCreateHydration(intent, result.value.page, result.value.fence) + } + return { + sessionId: result.value.sessionId, + fence: result.value.fence, + ...(firstMessage ? { firstMessage } : {}) + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts new file mode 100644 index 00000000000..45613b5c7aa --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' +import { + CreateIntentParams, + SendParams +} from '../../../shared/rpc-contract/structured-agent-session-params' +import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { HostCreateSupport } from './structured-agent-session-host-admission' + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + supports: vi.fn<() => Promise>(), + admit: vi.fn<() => Promise>(), + inventory: vi.fn() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionCreateMessage: mocks.supports +})) +vi.mock('./structured-agent-session-host-admission', () => ({ + askHostCreateSupport: mocks.admit +})) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: mocks.inventory +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: {}, + unifiedTabsByWorktree: {}, + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + subscribe: () => () => {} + } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { + startStructuredAgentLaunch, + cancelStructuredAgentLaunch, + retryStructuredAgentSessionLaunch, + getStructuredAgentSessionLaunchLifecycle +} from './structured-agent-session-launch' +import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' +import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { + resetStructuredAgentLaunchPersistenceForTests, + readStructuredAgentLaunchRecord +} from './structured-agent-session-launch-persistence' +import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' +import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' +import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache, + writeNativeChatDraftCache +} from '@/components/native-chat/native-chat-draft-cache' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' + +function deferred() { + let resolve = (_value: T): void => {} + let reject = (_error: unknown): void => {} + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +function createParams() { + return CreateIntentParams.parse( + mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] + ) +} +function submission(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'pending', + handoverRecorded: true, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } +} +function created() { + const params = createParams() + const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : [] + const cursor = { epoch: 'epoch-1', sequence: rows.length } + const page: AgentSessionHistoryPage = { + sessionId: params.envelope.sessionId, + epoch: cursor.epoch, + fence: 1, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: rows, + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + latestTurn: null + } + return { + ok: true, + replayed: false, + value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] } + } +} +const target = { kind: 'local' } as const +const start = () => + startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text' + }) +const draft = (sessionId: string) => + readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId)) + +describe('the first message owned by create', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + clearNativeChatDraftCacheForTests() + mocks.supports.mockResolvedValue(true) + mocks.admit.mockResolvedValue({ kind: 'admitted' }) + mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) + }) + afterEach(() => { + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + vi.useRealTimers() + }) + + it('freezes the message and held options in create and publishes without another read or send', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'picked-model') + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const params = createParams() + expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }]) + expect(params.options).toEqual({ model: 'picked-model' }) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionCreateFingerprint({ sessionId: launch.sessionId, ...params }) + ) + expect(getStructuredAgentSessionPendingSends(launch.sessionId)[0]?.phase).toBe('sending') + const stop = vi.fn(async () => null) + expect( + structuredAgentSessionStopControl({ + published: false, + host: { stopsConversation: true, stop }, + transportState: { turnId: null, isWorking: false }, + sends: { sending: true, stopSends: vi.fn() } + }).canStop + ).toBe(false) + create.resolve(created()) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + const state = getStructuredAgentSessionReadOwner(launch.sessionId, target).getSnapshot().state + expect(state.fence).toBe(1) + const isWorking = isStructuredAgentSessionMainAgentWorking(null, state.submissions, state.fence) + expect(isWorking).toBe(true) + const control = structuredAgentSessionStopControl({ + published: true, + host: { stopsConversation: true, stop }, + transportState: { turnId: null, isWorking }, + sends: { sending: false, stopSends: vi.fn() } + }) + expect(control.canStop).toBe(true) + await control.stop() + expect(stop).toHaveBeenCalledWith(null, expect.any(Function)) + expect(draft(launch.sessionId)).toBe('') + expect(mocks.inventory).not.toHaveBeenCalled() + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('returns a refused create once after the new draft, with no later send', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + writeNativeChatDraftCache( + structuredAgentSessionDraftScopeKey(launch.sessionId), + 'typed meanwhile' + ) + create.resolve({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'unavailable' } + }) + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await launch.promptDeliveryResult?.catch(() => undefined) + expect(draft(launch.sessionId)).toBe('typed meanwhile\n\nopening text') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect(getStructuredAgentSessionPendingSends(launch.sessionId)).toEqual([]) + }) + + it('returns text at 30 seconds and a late create reply cannot send it again', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(30_000) + expect(draft(launch.sessionId)).toBe('opening text') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true, + unconfirmed: true + }) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + create.resolve(created()) + await launch.launchResult + expect(draft(launch.sessionId)).toBe('opening text') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('keeps the old create-then-send payload for an older host', async () => { + mocks.supports.mockResolvedValue(false) + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + return created() + } + const send = SendParams.parse(params) + return { + ok: true, + value: { + clientMessageId: send.envelope.clientOperationId, + submission: submission(send.envelope.clientOperationId) + } + } + }) + const launch = start() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(createParams()).not.toHaveProperty('firstMessage') + expect(createParams()).not.toHaveProperty('options') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.send' + ]) + }) + + it('keeps the older host acquisition wait outside the send deadline', async () => { + const create = deferred() + mocks.supports.mockResolvedValue(false) + mocks.call.mockReturnValue(create.promise) + const launch = start() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(45_000) + expect(draft(launch.sessionId)).toBe('') + expect(getStructuredAgentSessionPendingSends(launch.sessionId)[0]?.phase).toBe('sending') + mocks.call.mockImplementation(async (_target, _method, params) => { + const send = SendParams.parse(params) + return { + ok: true, + value: { + clientMessageId: send.envelope.clientOperationId, + submission: submission(send.envelope.clientOperationId) + } + } + }) + create.resolve(created()) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.send' + ]) + }) + + it('bounds the click even if capability negotiation has not answered', async () => { + const support = deferred() + mocks.supports.mockReturnValue(support.promise) + mocks.call.mockImplementation(async () => created()) + const launch = start() + await vi.advanceTimersByTimeAsync(30_000) + expect(draft(launch.sessionId)).toBe('opening text') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true + }) + expect(draft(launch.sessionId)).toBe('opening text') + support.resolve(true) + await launch.launchResult + expect(createParams()).not.toHaveProperty('firstMessage') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it('keeps the same first-message intent when the paired host reports its seed', async () => { + mocks.admit.mockResolvedValue({ kind: 'admitted', seedOptions: { model: 'host-default' } }) + mocks.call.mockImplementation(async () => created()) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'runtime:server-1', + prompt: 'opening text' + }) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(createParams().firstMessage?.body.blocks).toEqual([ + { type: 'text', text: 'opening text' } + ]) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'server-1' }, + 'agentSession.create', + expect.objectContaining({ firstMessage: expect.any(Object) }) + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect( + getStructuredAgentSessionReadOwner(launch.sessionId, { + kind: 'environment', + environmentId: 'server-1' + }).getSnapshot().state.fence + ).toBe(1) + expect( + getStructuredAgentSessionReadOwner(launch.sessionId, target).getSnapshot().state.fence + ).toBeNull() + }) + + it('publishes while a later option write still waits for the agent', async () => { + const create = deferred() + const option = deferred() + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.setOption' ? option.promise : create.promise + ) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick') + create.resolve(created()) + let published = false + void launch.launchResult.then(() => { + published = true + }) + await vi.waitFor(() => expect(published).toBe(true)) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.setOption' + ]) + option.resolve({ ok: true, value: { selected: 'later-pick' } }) + }) + + it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + expect(readStructuredAgentLaunchRecord(launch.sessionId)?.firstMessage).toEqual( + original.firstMessage + ) + const response = created() + const receipt = response.value.page.submissions[0] + response.value.page.submissions = [] + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.history' + ? { ok: true, page: response.value.page } + : { ...response, replayed: true, value: { ...response.value, firstMessage: receipt } } + ) + markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + expect(creates).toHaveLength(2) + expect(creates[1]?.[2]).toEqual(original) + expect(mocks.supports).toHaveBeenCalledOnce() + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([]) + expect(draft(launch.sessionId)).toBe('') + }) + + it('allows Retry after the deadline while the original create never answers', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue([]) + mocks.call.mockImplementation(async () => created()) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + expect(creates).toHaveLength(2) + expect(creates[1]?.[2]).toEqual(original) + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([]) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it('does not revive a closed conversation reader when create replies late', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(cancelStructuredAgentLaunch('workspace-1', launch.sessionId)).toBe(true) + create.resolve(created()) + await expect(launch.launchResult).rejects.toThrow('structured session launch cancelled') + expect(findStructuredAgentSessionReadOwner(launch.sessionId, target)).toBeUndefined() + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('keeps Retry actionable when a published tab starts an unanswered message confirmation', async () => { + mocks.call.mockReturnValue(new Promise(() => {})) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue([ + { + worktree: 'workspace-1', + tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] + } + ]) + expect( + markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') + ).toBe(false) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(30_000) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + mocks.inventory.mockResolvedValue([]) + mocks.call.mockImplementation(async () => created()) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.history', + 'agentSession.create' + ]) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it.each(['create', 'history'] as const)( + 'offers Retry again when a second %s attempt never answers, without returning text twice', + async (method) => { + mocks.call.mockReturnValue(new Promise(() => {})) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue( + method === 'create' + ? [] + : [ + { + worktree: 'workspace-1', + tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] + } + ] + ) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(30_000) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + mocks.call.mockImplementation(async () => + method === 'history' ? { ok: true, page: created().value.page } : created() + ) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + `agentSession.${method}`, + `agentSession.${method}` + ]) + expect( + mocks.call.mock.calls + .filter(([, name]) => name === 'agentSession.create') + .map(([, , params]) => params) + ).toEqual(method === 'create' ? [original, original, original] : [original]) + expect(draft(launch.sessionId)).toBe('opening text') + } + ) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts new file mode 100644 index 00000000000..e04f44b15e1 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -0,0 +1,60 @@ +import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { StructuredLaunchState } from './structured-agent-session-launch-registry' +import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' +import { + prepareStructuredLaunchPromptForCreate, + prepareLegacyStructuredLaunchPrompts +} from './structured-agent-session-launch-prompt' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { + applyStructuredLaunchHeldOptions, + settleStructuredLaunchCreateOptions +} from './structured-agent-session-launch-options' + +/** Freeze what create carries once; an ambiguous reply always replays the same payload. */ +export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { + const { intent } = state + if (intent.createPrepared) { + return + } + if (intent.createMessageSupport !== true) { + prepareLegacyStructuredLaunchPrompts(intent.sessionId) + intent.createPrepared = true + notifyStructuredLaunchListeners() + return + } + const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const options = Object.keys(state.selection.held).length ? { ...state.selection.held } : undefined + const params = { + ...intent.params, + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}) + } + intent.params = { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: structuredAgentSessionCreateFingerprint({ + sessionId: intent.sessionId, + ...params + }) + } + } + intent.createPrepared = true + notifyStructuredLaunchListeners() +} + +/** Later picks use the normal option path without holding publication behind startup. */ +export function publishStructuredLaunchCreateOptions( + state: StructuredLaunchState, + receipt: StructuredAgentLaunchReceipt +): StructuredAgentLaunchReceipt | Promise { + if (state.intent.createMessageSupport !== true) { + return applyStructuredLaunchHeldOptions(state, receipt) + } + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) + void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { + console.warn('[native-chat] applying a launch option failed', error) + }) + return receipt +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-support.ts b/src/renderer/src/lib/structured-agent-session-launch-create-support.ts new file mode 100644 index 00000000000..2b46b5c4342 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-support.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from 'react' +import { + getPersistedStructuredAgentLaunchRecord, + getStructuredLaunchStateBySessionId, + subscribeStructuredAgentLaunchStatus +} from './structured-agent-session-launch-registry' + +/** Unknown capability keeps Stop unavailable until create can publish or choose the old flow. */ +export function useStructuredLaunchCreateSupport(sessionId: string): boolean | undefined { + return useSyncExternalStore( + subscribeStructuredAgentLaunchStatus, + () => + getStructuredLaunchStateBySessionId(sessionId)?.intent.createMessageSupport ?? + getPersistedStructuredAgentLaunchRecord(sessionId)?.createMessageSupport, + () => undefined + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-message.ts b/src/renderer/src/lib/structured-agent-session-launch-message.ts index e31de65c8a8..a1d42025b3e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-message.ts @@ -12,6 +12,7 @@ import { } from './structured-agent-session-launch-registry' import { retryStructuredAgentSessionLaunch } from './structured-agent-session-launch' import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { trackStructuredLaunchPromptOutcome } from './structured-agent-session-launch-prompt-outcome' /** Null when the chat's start has not failed, or its restart could not begin. */ export function relaunchFailedStructuredAgentSessionWithMessage( @@ -37,5 +38,6 @@ export function relaunchFailedStructuredAgentSessionWithMessage( options: { requestId: newAgentLaunchRequestId(), prompt: text }, stagedPrompt: stageStructuredLaunchPrompt(sessionId, text, options) }) + trackStructuredLaunchPromptOutcome(state, caller) return caller.promptDeliveryResult ?? null } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 136ef96096e..5f5bde375d0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -142,6 +142,17 @@ function settleHeldOption( replies.delete(id) } notifyStructuredLaunchListeners() + state.callers.onSettled() +} + +/** Selections included in the durable create are already the host's option intent. */ +export function settleStructuredLaunchCreateOptions( + state: StructuredLaunchState, + options: Readonly> +): void { + for (const [id, encoded] of Object.entries(options)) { + settleHeldOption(state, id, encoded, { kind: 'accepted', options }) + } } /** diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index fb64780083b..02fd0616618 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,8 +1,12 @@ import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { settleStructuredLaunchCallers } from '@/lib/structured-agent-session-launch-callers' +import { + settleStructuredLaunchCallers, + structuredLaunchCallersHavePendingWork +} from '@/lib/structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from '@/lib/structured-agent-session-launch-recovery' import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { + deleteStructuredLaunchStateIfCurrent, notifyStructuredLaunchListeners, retireStructuredAgentSessionLaunchCancellationTombstone, type StructuredLaunchState @@ -10,6 +14,23 @@ import { // How a launch's create outcome settles its callers: published, refused, failed or unknown. +function cleanupLaunchState(state: StructuredLaunchState): void { + if (deleteStructuredLaunchStateIfCurrent(state)) { + notifyStructuredLaunchListeners() + } +} + +export function maybeCleanupLaunchState(state: StructuredLaunchState): void { + if ( + state.callers.outcome === 'failed' || + structuredLaunchCallersHavePendingWork(state.callers) || + Object.keys(state.selection.held).length > 0 + ) { + return + } + cleanupLaunchState(state) +} + function settleStructuredLaunchRefusal(state: StructuredLaunchState): void { if (state.callers.outcome !== 'pending' && state.callers.outcome !== 'unknown') { return diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index bd241b890bc..ccd9298f380 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,54 +1,19 @@ -import { isTuiAgent } from '../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../shared/tui-agent' -import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import { + validRecord, + type StructuredAgentLaunchPersistedRecord +} from './structured-agent-session-launch-record' +export { + structuredAgentLaunchRecordFor, + type StructuredAgentLaunchPersistedRecord, + type StructuredAgentLaunchPersistedLifecycle +} from './structured-agent-session-launch-record' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' -import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' - -export type StructuredAgentLaunchPersistedRecord = { - sessionId: string - /** The host the chat was created on. Records written before paired hosts could hold a chat lack - * it and load as local, the only host a chat could then be launched on. */ - executionHostId: ExecutionHostId - agent: TuiAgent - lifecycle: StructuredAgentLaunchPersistedLifecycle - clientOperationId: string - payloadFingerprint: string - expectedRuntimeFence: number | null - resumeFrom?: StructuredAgentSessionResumeSource - /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ - seedOptions?: Readonly> - /** When a failed launch failed; records written by older builds lack it. */ - failedAt?: number -} - -/** What survives a reload of an unpublished launch. */ -export function structuredAgentLaunchRecordFor( - intent: StructuredAgentSessionLaunchIntent, - lifecycle: StructuredAgentLaunchPersistedLifecycle -): StructuredAgentLaunchPersistedRecord { - const { envelope, resumeFrom } = intent.params - // A local launch re-reads this machine's settings on reload; only a paired server's seed is kept. - const pairedSeed = intent.target.kind === 'local' ? undefined : intent.seedOptions - return { - sessionId: intent.sessionId, - executionHostId: intent.executionHostId, - agent: intent.agent, - lifecycle, - clientOperationId: envelope.clientOperationId, - payloadFingerprint: envelope.payloadFingerprint, - expectedRuntimeFence: envelope.expectedRuntimeFence, - ...(resumeFrom ? { resumeFrom } : {}), - ...(pairedSeed ? { seedOptions: pairedSeed } : {}) - } -} - const LAUNCH_STORAGE_KEY = 'orca:structuredAgentLaunches:v1' const TOMBSTONE_STORAGE_KEY = 'orca:structuredAgentLaunchCancelledSessions:v1' const records = new Map() @@ -59,55 +24,6 @@ const remoteTombstoneCancelledAt = new Map() const REMOTE_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000 let loaded = false -function validRecord(value: unknown): value is Omit< - StructuredAgentLaunchPersistedRecord, - 'executionHostId' -> & { - executionHostId?: string -} { - if (!value || typeof value !== 'object') { - return false - } - if ( - !('sessionId' in value) || - !('agent' in value) || - !('lifecycle' in value) || - !('clientOperationId' in value) || - !('payloadFingerprint' in value) || - !('expectedRuntimeFence' in value) - ) { - return false - } - const { - sessionId, - agent, - lifecycle, - clientOperationId, - payloadFingerprint, - expectedRuntimeFence - } = value - const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined - const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined - const failedAt = 'failedAt' in value ? value.failedAt : undefined - return ( - (executionHostId === undefined || - (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && - typeof sessionId === 'string' && - sessionId.length > 0 && - isTuiAgent(agent) && - (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && - typeof clientOperationId === 'string' && - typeof payloadFingerprint === 'string' && - (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && - (failedAt === undefined || Number.isFinite(failedAt)) && - (resumeFrom === undefined || - (typeof resumeFrom === 'object' && - resumeFrom !== null && - 'providerSessionId' in resumeFrom && - typeof resumeFrom.providerSessionId === 'string')) - ) -} - function load(): void { if (loaded) { return diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts new file mode 100644 index 00000000000..aba3d5db48a --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -0,0 +1,53 @@ +import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' +import { + notifyStructuredLaunchListeners, + type StructuredLaunchState +} from './structured-agent-session-launch-registry' + +/** The message deadline frees the composer and offers the existing Retry while create is unknown. */ +export function trackStructuredLaunchPromptOutcome( + state: StructuredLaunchState, + caller: StructuredLaunchCaller +): void { + const group = state.callers + void caller.promptDeliveryResult?.then((outcome) => { + if ( + state.callers !== group || + state.cancelled || + group.outcome !== 'pending' || + !(outcome.inComposer || outcome.unconfirmed) + ) { + return + } + state.visibilityUnknown = true + group.outcome = 'unknown' + notifyStructuredLaunchListeners() + }) +} + +/** Confirmation checks share the opening message deadline after its send slot has settled. */ +export function trackStructuredLaunchConfirmationDeadline( + state: StructuredLaunchState, + promise: Promise +): void { + if (state.intent.createMessageSupport === false) { + return + } + const deadline = setTimeout(() => { + if ( + state.promise !== promise || + state.cancelled || + state.callers.outcome !== 'pending' || + state.intent.createMessageSupport === false + ) { + return + } + state.visibilityUnknown = true + state.callers.outcome = 'unknown' + notifyStructuredLaunchListeners() + }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) + const clear = (): void => clearTimeout(deadline) + void promise.then(clear, clear) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts index df96ac036f3..6082f6d1ff7 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts @@ -36,7 +36,7 @@ import { hasStagedStructuredLaunchPrompt, settleStructuredAgentLaunchPrompt, stageStructuredLaunchPrompt, - takeBackStructuredLaunchPrompts + takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import { newAgentPromptOutcome } from './new-agent-prompt-outcome' @@ -97,14 +97,14 @@ function unpublishedStop(stopSends = () => stopStructuredAgentSessionSends(SESSI const hostStop = vi.fn(async () => null) const control = structuredAgentSessionStopControl({ published: false, + legacyLaunch: { takeBackText: () => takeBackLegacyStructuredLaunchPrompts(SESSION) }, host: { stopsConversation: true, stop: hostStop }, transportState: { turnId: null, isWorking: false }, sends: { sending: getStructuredAgentSessionPendingSends(SESSION).some( (entry) => entry.phase === 'sending' ), - stopSends, - takeBackLaunchText: () => takeBackStructuredLaunchPrompts(SESSION) + stopSends } }) return { control, hostStop } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index c538b9ced5e..f1a2b826d19 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -34,7 +34,7 @@ import { hasStagedStructuredLaunchPrompt, settleStructuredAgentLaunchPrompt, stageStructuredLaunchPrompt, - takeBackStructuredLaunchPrompts + takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' const SESSION = 'session-1' @@ -188,7 +188,7 @@ describe('settleStructuredAgentLaunchPrompt', () => { }) expect(pendingTexts()).toEqual(['review this:sending']) - takeBackStructuredLaunchPrompts(SESSION) + takeBackLegacyStructuredLaunchPrompts(SESSION) publish({ sessionId: SESSION, fence: 1 }) await expect(settled).resolves.toEqual({ delivered: false, failureNotified: true }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 0a5cd692c45..c8b9db609e1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -1,9 +1,14 @@ import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, sendStructuredAgentSessionMessage, + type StructuredAgentSessionCreateSend, type StructuredAgentSessionReservedSend } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' @@ -32,7 +37,7 @@ export type StructuredLaunchPromptOptions = { onPromptDelivered?: () => void } -type LaunchReceipt = { sessionId: string; fence: number } +type LaunchReceipt = { sessionId: string; fence: number; firstMessage?: AgentJournalSubmission } /** A launch's text, held in memory from the click until its chat exists, then sent once: every * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn @@ -44,6 +49,11 @@ export type StagedStructuredLaunchPrompt = { /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ callerKeepsText?: true delivery?: Promise + /** Create owns this prompt; it must never become a later send. */ + createOwned?: true + createSend?: StructuredAgentSessionCreateSend + whenCreateSettled: Promise + settleCreate: (outcome: StagedDelivery) => void /** Its launch was cancelled: never sent, never given back. */ discarded?: true /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ @@ -62,17 +72,35 @@ export function stageStructuredLaunchPrompt( const whenDiscarded = new Promise((resolve) => { discard = resolve }) + let settleCreate = (_outcome: StagedDelivery): void => {} + const whenCreateSettled = new Promise((resolve) => { + settleCreate = resolve + }) const prompt: StagedStructuredLaunchPrompt = { sessionId, text, slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options }), ...options, whenDiscarded, - discard + discard, + whenCreateSettled, + settleCreate } const forSession = staged.get(sessionId) ?? new Set() forSession.add(prompt) staged.set(sessionId, forSession) + void prompt.slot?.outcome.then((outcome) => { + const result: StagedDelivery = { + delivered: outcome === 'recorded', + inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true } : {}) + } + if (!prompt.delivery) { + prompt.delivery = Promise.resolve(result) + unstage(prompt) + } + prompt.settleCreate(result) + }) return prompt } @@ -103,9 +131,9 @@ export function discardStructuredAgentSessionChatSends(sessionId: string): void /** A Stop before the chat is published: text its launch has not sent goes back to the composer, * never sent. The start itself goes on. */ -export function takeBackStructuredLaunchPrompts(sessionId: string): void { +export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { for (const prompt of staged.get(sessionId) ?? []) { - if (prompt.delivery) { + if (prompt.delivery || prompt.createOwned) { continue } unstage(prompt) @@ -115,6 +143,44 @@ export function takeBackStructuredLaunchPrompts(sessionId: string): void { } } +export function prepareLegacyStructuredLaunchPrompts(sessionId: string): void { + for (const prompt of staged.get(sessionId) ?? []) { + prompt.slot?.legacy() + } +} + +/** Transfers the first staged prompt to create, retaining the sender's deadline and bubble. */ +export function prepareStructuredLaunchPromptForCreate( + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionFirstMessage | undefined { + const prompt = [...(staged.get(sessionId) ?? [])].find( + (candidate) => !candidate.discarded && !candidate.delivery + ) + if (!prompt) { + return undefined + } + prompt.createOwned = true + const sent = prompt.slot?.create(target) + prompt.createSend = sent ?? undefined + prompt.delivery = sent + ? sent.outcome.then((outcome) => { + unstage(prompt) + return { + delivered: outcome === 'recorded', + inComposer: + !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) + } + }) + : Promise.resolve({ delivered: false, inComposer: !prompt.callerKeepsText }).then((outcome) => { + unstage(prompt) + return outcome + }) + void prompt.delivery.then(prompt.settleCreate) + return sent?.message +} + /** Whether a launch still holds text for this chat that has not reached its host. */ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { return (staged.get(sessionId)?.size ?? 0) > 0 @@ -192,14 +258,18 @@ export function settleStructuredAgentLaunchPrompt(args: { if (!prompt || prompt.discarded) { return { delivered: false, failureNotified: true } } + if (prompt.createOwned) { + prompt.createSend?.settle( + receipt.firstMessage + ? { kind: 'recorded', submission: receipt.firstMessage } + : { kind: 'unconfirmed' } + ) + } const { delivered, inComposer, unconfirmed, busy } = await sendStagedPrompt( prompt, receipt, args.target ) - if (delivered) { - args.options.onPromptDelivered?.() - } return { delivered, failureNotified: false, @@ -209,6 +279,13 @@ export function settleStructuredAgentLaunchPrompt(args: { } }, (error: unknown) => { + if (prompt?.createOwned) { + prompt.createSend?.settle( + error instanceof StructuredAgentSessionCreateRefusalError + ? { kind: 'refused', failure: agentSessionThrownFailure(error, error.code) } + : { kind: 'unconfirmed' } + ) + } // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { unstage(prompt) @@ -218,12 +295,24 @@ export function settleStructuredAgentLaunchPrompt(args: { throw error } ) - if (!prompt || prompt.delivery) { - return settled - } - const cancelled = prompt.whenDiscarded.then((): StructuredPromptDeliveryResult => ({ - delivered: false, - failureNotified: true - })) - return Promise.race([settled, cancelled]) + const result = prompt + ? Promise.race([ + settled, + prompt.whenCreateSettled.then(({ inComposer, ...outcome }) => ({ + ...outcome, + failureNotified: false, + ...(inComposer ? { inComposer: true as const } : {}) + })), + prompt.whenDiscarded.then((): StructuredPromptDeliveryResult => ({ + delivered: false, + failureNotified: true + })) + ]) + : settled + return result.then((outcome) => { + if (outcome.delivered) { + args.options.onPromptDelivered?.() + } + return outcome + }) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts new file mode 100644 index 00000000000..f172a83e7e4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts @@ -0,0 +1,33 @@ +import { recoverPublishedSessionReceipt } from './structured-agent-session-launch-recovery' +import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' +import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' +import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' +import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' +import { + getStructuredLaunchStateBySessionId, + notifyStructuredLaunchListeners +} from './structured-agent-session-launch-registry' + +/** A tab proves the chat exists; its journal must also confirm the immutable opening message. */ +export function confirmStructuredLaunchFirstMessagePublication( + worktreeId: string, + sessionId: string +): void { + const state = + getStructuredLaunchStateBySessionId(sessionId) ?? + restorePersistedStructuredLaunchState(worktreeId, sessionId) + if (!state || state.callers.outcome === 'pending' || state.cancelled) { + return + } + state.callers.outcome = 'pending' + state.visibilityUnknown = false + state.promise = recoverPublishedSessionReceipt(state) + .then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) + .catch((error: unknown) => { + state.visibilityUnknown = true + throw error + }) + trackLaunchSettlement(state, state.promise) + trackStructuredLaunchConfirmationDeadline(state, state.promise) + notifyStructuredLaunchListeners() +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 76f4c728f99..3a8161a2dfe 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -8,6 +8,20 @@ import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' +function confirmStructuredLaunchFirstMessagePublication( + worktreeId: string, + sessionId: string +): void { + // Store teardown imports publication bookkeeping; recovery may read the store after it exists. + void import('./structured-agent-session-launch-publication-confirmation') + .then((confirmation) => + confirmation.confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + ) + .catch((error: unknown) => { + console.warn('[native-chat] confirming a published opening message failed', error) + }) +} + /** `executionHostId` published the chat; only the host its launch was sent to settles it. */ export function markStructuredAgentSessionLaunchPublished( worktreeId: string, @@ -20,6 +34,10 @@ export function markStructuredAgentSessionLaunchPublished( if (persisted?.executionHostId !== executionHostId) { return false } + if (persisted.firstMessage) { + confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + return false + } deleteStructuredAgentLaunchRecord(sessionId) notifyStructuredLaunchListeners() return true @@ -38,6 +56,10 @@ export function markStructuredAgentSessionLaunchPublished( if (state.callers.outcome === 'pending') { return true } + if (state.intent.params.firstMessage) { + confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + return false + } state.callers.outcome = 'published' deleteStructuredAgentLaunchRecord(sessionId) state.callers.onSettled() diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.test.ts b/src/renderer/src/lib/structured-agent-session-launch-record.test.ts new file mode 100644 index 00000000000..20de32c7641 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-record.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { validRecord } from './structured-agent-session-launch-record' + +const record = { + sessionId: 'codex_11111111_2222_3333_4444_555555555555', + executionHostId: 'local', + agent: 'codex', + lifecycle: 'visibility-unknown', + clientOperationId: 'create-operation', + payloadFingerprint: 'a'.repeat(64), + expectedRuntimeFence: null, + createMessageSupport: true, + firstMessage: { + clientMessageId: 'opening-message', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening π text' }] } + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('a persisted create opening message', () => { + it('loads the frozen user message in a browser without Node Buffer', () => { + vi.stubGlobal('Buffer', undefined) + expect(() => validRecord(record)).not.toThrow() + expect(validRecord(record)).toBe(true) + }) + + it.each([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'forged response' }] }, + { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'forged tool' }] }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 5 }] } + ])('refuses a saved opening body that cannot be sent through the user-message wire', (body) => { + expect(validRecord({ ...record, firstMessage: { ...record.firstMessage, body } })).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.ts b/src/renderer/src/lib/structured-agent-session-launch-record.ts new file mode 100644 index 00000000000..0f631ef5811 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-record.ts @@ -0,0 +1,125 @@ +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + StructuredAgentSessionFirstMessage, + StructuredAgentSessionResumeSource +} from '../../../shared/structured-agent-session-create' +import { isAgentSessionOptions } from '../../../shared/agent-session-record' +import { SendBodyStructure } from '../../../shared/rpc-contract/structured-agent-session-message-params' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' + +export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' + +export type StructuredAgentLaunchPersistedRecord = { + sessionId: string + /** The host the chat was created on. Records written before paired hosts could hold a chat lack + * it and load as local, the only host a chat could then be launched on. */ + executionHostId: ExecutionHostId + agent: TuiAgent + lifecycle: StructuredAgentLaunchPersistedLifecycle + clientOperationId: string + payloadFingerprint: string + expectedRuntimeFence: number | null + resumeFrom?: StructuredAgentSessionResumeSource + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> + createMessageSupport?: boolean + /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ + seedOptions?: Readonly> + /** When a failed launch failed; records written by older builds lack it. */ + failedAt?: number +} + +/** What survives a reload of an unpublished launch. */ +export function structuredAgentLaunchRecordFor( + intent: StructuredAgentSessionLaunchIntent, + lifecycle: StructuredAgentLaunchPersistedLifecycle +): StructuredAgentLaunchPersistedRecord { + const { envelope, resumeFrom, firstMessage, options } = intent.params + // A local launch re-reads this machine's settings on reload; only a paired server's seed is kept. + const pairedSeed = intent.target.kind === 'local' ? undefined : intent.seedOptions + return { + sessionId: intent.sessionId, + executionHostId: intent.executionHostId, + agent: intent.agent, + lifecycle, + clientOperationId: envelope.clientOperationId, + payloadFingerprint: envelope.payloadFingerprint, + expectedRuntimeFence: envelope.expectedRuntimeFence, + ...(resumeFrom ? { resumeFrom } : {}), + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}), + ...(intent.createMessageSupport === undefined + ? {} + : { createMessageSupport: intent.createMessageSupport }), + ...(pairedSeed ? { seedOptions: pairedSeed } : {}) + } +} + +export function validRecord(value: unknown): value is Omit< + StructuredAgentLaunchPersistedRecord, + 'executionHostId' +> & { + executionHostId?: string +} { + if (!value || typeof value !== 'object') { + return false + } + if ( + !('sessionId' in value) || + !('agent' in value) || + !('lifecycle' in value) || + !('clientOperationId' in value) || + !('payloadFingerprint' in value) || + !('expectedRuntimeFence' in value) + ) { + return false + } + const { + sessionId, + agent, + lifecycle, + clientOperationId, + payloadFingerprint, + expectedRuntimeFence + } = value + const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined + const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined + const failedAt = 'failedAt' in value ? value.failedAt : undefined + const firstMessage = 'firstMessage' in value ? value.firstMessage : undefined + const options = 'options' in value ? value.options : undefined + const createMessageSupport = + 'createMessageSupport' in value ? value.createMessageSupport : undefined + return ( + (executionHostId === undefined || + (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && + typeof sessionId === 'string' && + sessionId.length > 0 && + isTuiAgent(agent) && + (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && + typeof clientOperationId === 'string' && + typeof payloadFingerprint === 'string' && + (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && + (failedAt === undefined || Number.isFinite(failedAt)) && + (firstMessage === undefined || validFirstMessage(firstMessage)) && + (options === undefined || isAgentSessionOptions(options)) && + (createMessageSupport === undefined || typeof createMessageSupport === 'boolean') && + (resumeFrom === undefined || + (typeof resumeFrom === 'object' && + resumeFrom !== null && + 'providerSessionId' in resumeFrom && + typeof resumeFrom.providerSessionId === 'string')) + ) +} + +function validFirstMessage(value: unknown): value is StructuredAgentSessionFirstMessage { + return ( + typeof value === 'object' && + value !== null && + 'clientMessageId' in value && + typeof value.clientMessageId === 'string' && + 'body' in value && + SendBodyStructure.safeParse(value.body).success + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 8743cc1a27f..5badfaf0496 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -1,4 +1,5 @@ import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { launchStructuredAgentSession, StructuredAgentSessionCreateRefusalError, @@ -7,8 +8,13 @@ import { } from '@/lib/launch-structured-agent-session' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' +import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' -export type StructuredAgentLaunchReceipt = { sessionId: string; fence: number } +export type StructuredAgentLaunchReceipt = { + sessionId: string + fence: number + firstMessage?: AgentJournalSubmission +} export type StructuredLaunchRecoveryState = { intent: StructuredAgentSessionLaunchIntent @@ -47,7 +53,7 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro } } -async function recoverPublishedSessionReceipt( +export async function recoverPublishedSessionReceipt( state: StructuredLaunchRecoveryState ): Promise { await verifyPublishedSession(state) @@ -61,7 +67,19 @@ async function recoverPublishedSessionReceipt( if (typeof fence !== 'number') { throw new Error('structured session fence publication unavailable') } - return { sessionId: state.intent.sessionId, fence } + const firstMessage = state.intent.params.firstMessage + ? history.page.submissions.find( + (submission) => + submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId + ) + : undefined + if (state.intent.params.firstMessage && !firstMessage) { + return launchStructuredAgentSession(state.intent, state.onHostSeed) + } + if (state.intent.createMessageSupport === true) { + publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) + } + return { sessionId: state.intent.sessionId, fence, ...(firstMessage ? { firstMessage } : {}) } } async function retrySameIntent( @@ -72,7 +90,9 @@ async function retrySameIntent( try { const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) throwIfLaunchCancelled(state) - await verifyPublishedSession(state) + if (state.intent.createMessageSupport !== true) { + await verifyPublishedSession(state) + } return receipt } catch (error) { if (state.cancelled) { @@ -116,7 +136,9 @@ export async function launchAndReconcile( } try { throwIfLaunchCancelled(state) - await verifyPublishedSession(state) + if (state.intent.createMessageSupport !== true) { + await verifyPublishedSession(state) + } return receipt } catch (error) { if (state.cancelled) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 5bf6859ef05..8a511ff876f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -33,7 +33,12 @@ export function restorePersistedStructuredLaunchState( payloadFingerprint: record.payloadFingerprint, expectedRuntimeFence: record.expectedRuntimeFence, ...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {}), - ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}) + ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}), + ...(record.firstMessage ? { firstMessage: record.firstMessage } : {}), + ...(record.options ? { options: record.options } : {}), + ...(record.createMessageSupport === undefined + ? {} + : { createMessageSupport: record.createMessageSupport }) }) } catch (error) { // A record naming a host no runtime serves cannot be retried anywhere. diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 50a352ce92e..4af2eae3879 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -19,13 +19,11 @@ import { addStructuredLaunchCaller, createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, - structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller } from '@/lib/structured-agent-session-launch-callers' import * as launchDraft from './structured-agent-session-launch-draft' import { - deleteStructuredLaunchStateIfCurrent, getStructuredAgentSessionLaunchLifecycle, getStructuredLaunchStateBySessionId, markStructuredAgentSessionLaunchCancelled, @@ -39,8 +37,18 @@ import { claimableStructuredLaunchAttempt, getJoinableStructuredLaunchState } from './structured-agent-session-launch-holders' -import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options' -import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' +import { + prepareStructuredLaunchCreateMessage, + publishStructuredLaunchCreateOptions +} from './structured-agent-session-launch-create-message' +import { + maybeCleanupLaunchState, + trackLaunchSettlement +} from './structured-agent-session-launch-outcome-tracking' +import { + trackStructuredLaunchPromptOutcome, + trackStructuredLaunchConfirmationDeadline +} from './structured-agent-session-launch-prompt-outcome' import { repeatedStructuredLaunchAttempt, structuredLaunchRequest, @@ -94,25 +102,13 @@ function joinLaunchDelivery( return mode ? { ...rest, promptDelivery: mode } : rest } -function cleanupLaunchState(state: StructuredLaunchState): void { - if (deleteStructuredLaunchStateIfCurrent(state)) { - notifyStructuredLaunchListeners() - } -} - -function maybeCleanupLaunchState(state: StructuredLaunchState): void { - if (state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers)) { - return - } - cleanupLaunchState(state) -} - /** Every sender waits on the launch promise, so picks held during launch reach the host first. */ function publishWithHeldOptions( state: StructuredLaunchState, created: Promise ): Promise { - return created.then((receipt) => applyStructuredLaunchHeldOptions(state, receipt)) + state.intent.prepareCreate = () => prepareStructuredLaunchCreateMessage(state) + return created.then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) } /** Each attempt's probe names the seed the paired server's create will use; the picker shows it. */ @@ -123,8 +119,11 @@ function adoptPairedHostSeed( if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } - const { seedOptions: _previous, ...intent } = state.intent - state.intent = seedOptions ? { ...intent, seedOptions } : intent + if (seedOptions) { + state.intent.seedOptions = seedOptions + } else { + delete state.intent.seedOptions + } state.selection = { ...state.selection, seed: seedOptions } notifyStructuredLaunchListeners() } @@ -151,6 +150,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { wasVisibilityUnknown ? reconcileUnknownLaunch(state) : launchAndReconcile(state) ) trackLaunchSettlement(state, state.promise) + trackStructuredLaunchConfirmationDeadline(state, state.promise) notifyStructuredLaunchListeners() } @@ -191,16 +191,15 @@ function joinStructuredLaunchState( // A re-delivery waits on the text its action staged, if any, and never stages its own. const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined const callerOptions = retrying || (repeat && !repeat.stagedPrompt) ? joinedWithoutPrompt : joined - return { - state: existing, - caller: addStructuredLaunchCaller({ - group: existing.callers, - launchResult: existing.promise, - target: existing.intent.target, - options: callerOptions, - stagedPrompt - }) - } + const caller = addStructuredLaunchCaller({ + group: existing.callers, + launchResult: existing.promise, + target: existing.intent.target, + options: callerOptions, + stagedPrompt + }) + trackStructuredLaunchPromptOutcome(existing, caller) + return { state: existing, caller } } function structuredAgentLaunchState( @@ -255,6 +254,7 @@ function structuredAgentLaunchState( options, stagedPrompt }) + trackStructuredLaunchPromptOutcome(state, caller) setStructuredLaunchState(state) notifyStructuredLaunchListeners() trackLaunchSettlement(state, state.promise) diff --git a/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts b/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts new file mode 100644 index 00000000000..3d574014390 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts @@ -0,0 +1,11 @@ +import { useStructuredLaunchCreateSupport } from './structured-agent-session-launch-create-support' +import { takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' + +/** Stop takes the launch text back only when the host predates create carrying the first message. */ +export function useStructuredLegacyLaunchStop(sessionId: string): { + legacyLaunch?: { takeBackText: () => void } +} { + return useStructuredLaunchCreateSupport(sessionId) === false + ? { legacyLaunch: { takeBackText: () => takeBackLegacyStructuredLaunchPrompts(sessionId) } } + : {} +} diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 1b7fb4db53e..9b53f8e3c34 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -17,6 +17,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, @@ -52,6 +53,12 @@ export function supportsStructuredAgentSessionPromptCancel( return structuredAgentSessionHostSupports(target, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) } +export function supportsStructuredAgentSessionCreateMessage( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports(target, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) +} + /** Whether the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. */ export function supportsStructuredAgentSessionQuietRepeatedStop( target: RuntimeClientTarget diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index 57f96f63aba..4e1ad623341 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -46,6 +46,11 @@ import { retireStructuredAgentSessionTab, suppressCancelledStructuredSessionTabs } from './structured-agent-session-tab-retirement' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' const target: RuntimeClientTarget = { kind: 'local' } @@ -79,6 +84,7 @@ function snapshot(): RuntimeMobileSessionTabsResult { beforeEach(() => { vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() mocks.closeSession.mockResolvedValue('closed') mocks.callRuntime.mockResolvedValue(undefined) mocks.hasTombstone.mockReturnValue(false) @@ -147,4 +153,32 @@ describe('structured agent session tab retirement', () => { expect(mocks.closeSession).toHaveBeenCalledTimes(1) release() }) + + it('retires an unmounted created reader only on the owning host', async () => { + const local = getStructuredAgentSessionReadOwner('session-1', target) + const remoteTarget = { kind: 'environment', environmentId: 'server-1' } as const + const remote = getStructuredAgentSessionReadOwner('session-1', remoteTarget) + const dispose = vi.spyOn(local, 'dispose') + retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) + expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() + expect(findStructuredAgentSessionReadOwner('session-1', remoteTarget)).toBe(remote) + expect(dispose).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1')) + }) + + it('still closes the host tab if reader disposal fails', async () => { + const owner = getStructuredAgentSessionReadOwner('session-1', target) + vi.spyOn(owner, 'dispose').mockImplementation(() => { + throw new Error('subscription cleanup failed') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => + retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) + ).not.toThrow() + expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() + await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) + expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 93779ae87b9..7ef64129b87 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -10,6 +10,7 @@ import { import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' import { stopStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-message-sender' +import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { closeStructuredAgentSession } from './structured-agent-session-close' import { withLocalSessionTabCloseOwner } from './local-session-tab-close-owner' import { executionHostIdForStructuredTarget } from './structured-agent-session-owner' @@ -29,6 +30,7 @@ export function retireStructuredAgentSessionTab(args: { sessionId: string onError?: (error: unknown) => void }): void { + retireStructuredAgentSessionReadOwner(args.sessionId, args.target) const key = retirementKey(args.target, args.worktreeId, args.sessionId) const existing = inFlightRetirements.get(key) if (existing) { diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts index 8b3c73e2ff1..bb948c90112 100644 --- a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -3,6 +3,11 @@ import * as intent from '@/lib/worktree-sleep-intent' import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent const WORKTREE_ID = 'repo1::/path/wt1' @@ -27,6 +32,7 @@ describe('worktree sleep intent lifecycle', () => { beforeEach(() => { clearWorktreeSleepIntent(WORKTREE_ID) clearWorktreeSleepIntent(FOLDER_KEY) + resetStructuredAgentSessionReadOwnersForTests() }) it('is released by activating the worktree', () => { @@ -164,4 +170,25 @@ describe('worktree sleep intent lifecycle', () => { expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) expect(woke).not.toHaveBeenCalled() }) + + it.each([WORKTREE_ID, FOLDER_KEY])( + 'retires created chat readers when %s is purged', + (workspaceId) => { + const store = createTestStore() + seedWorktree(store) + const target = { kind: 'environment', environmentId: 'server-1' } as const + const owner = getStructuredAgentSessionReadOwner('created-chat', target) + const local = getStructuredAgentSessionReadOwner('created-chat', { kind: 'local' }) + store.getState().createUnifiedTab(workspaceId, 'agent-session', { + entityId: 'created-chat', + executionHostId: 'runtime:server-1', + activate: false + }) + const dispose = vi.spyOn(owner, 'dispose') + store.setState(buildWorktreePurgeState(store.getState(), [workspaceId])) + expect(findStructuredAgentSessionReadOwner('created-chat', target)).toBeUndefined() + expect(findStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })).toBe(local) + expect(dispose).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 39552a15a3e..bfca9033088 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -16,9 +16,11 @@ import { structuredLaunchStates } from '@/lib/structured-agent-session-launch-registry' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' +import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { structuredAgentSessionFocusOwner, + structuredAgentSessionTargetForTab, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' @@ -45,6 +47,7 @@ export function buildWorktreePurgeState( launch.intent.executionHostId ) discardStructuredAgentSessionChatSends(launch.intent.sessionId) + retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target) clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(launch.intent.target), worktreeId, @@ -55,6 +58,12 @@ export function buildWorktreePurgeState( } for (const worktreeId of worktreeIdSet) { for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType === 'agent-session') { + const target = structuredAgentSessionTargetForTab(s, tab) + if (target) { + retireStructuredAgentSessionReadOwner(tab.entityId, target) + } + } // A retained launch here survived a reload, so its persisted record names its host. const owner = tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts new file mode 100644 index 00000000000..68f05cc210a --- /dev/null +++ b/src/shared/agent-session-create-capabilities.ts @@ -0,0 +1,15 @@ +// The capabilities that gate what an `agentSession.create` may carry. + +// Why: `agentSession.create` is a strict object, so an older host refuses a payload carrying the +// reserved `tabId` rather than ignoring it. A client sends the field only to a host advertising this. +export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = + 'agentSession.create.tab-id.v1' as const +// Why: same strict object; a host advertising this records the conversation's first message with +// the chat it creates, so the message is the host's from the start. +export const AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY = + 'agentSession.create.message.v1' as const + +export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 21aec935cda..ae2ddc18e25 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -326,6 +326,8 @@ export type AgentSessionMutationResult = // ─── Per-method payloads ──────────────────────────────────────────────────── export type AgentSessionAttachResult = { + /** Create's committed opening message, even when its row is outside the returned history page. */ + firstMessage?: AgentJournalSubmission sessionId: string fence: number page: AgentSessionHistoryPage diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 37f5cbcb7aa..c6f3882e4ed 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -12,12 +12,14 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from './agent-session-create-capabilities' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from './agent-session-optional-model-capability' // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, // The renderer that reads a paired host's structured chats is the one that reads its own. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ea0e2e98237..5d6c8cf0fa6 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -58,6 +58,7 @@ export { } from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' import { @@ -332,16 +333,11 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const -// Why: `agentSession.create` is a strict object, so an older host refuses a payload carrying the -// reserved `tabId` rather than ignoring it. A client sends the field only to a host advertising this. -export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = - 'agentSession.create.tab-id.v1' as const - export const RUNTIME_CAPABILITIES = [ ...ORCAD_RUNTIME_CAPABILITIES, QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, - AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, + ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, 'files.pathsExist', 'runtime.status.compat.v1', diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts index a54be6e3e72..bdcbdedaaa3 100644 --- a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -4,8 +4,10 @@ import { AttachParams, CreateIntentParams, CreateSupportParams, - ModelCatalogParams + ModelCatalogParams, + RespondToQuestionParams } from './structured-agent-session-params' +import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' const ENVELOPE = { sessionId: 'agent-session-0123456789abcdef0123456789abcdef', @@ -15,6 +17,53 @@ const ENVELOPE = { } describe('structured agent params', () => { + it('keeps normal question answers valid and bounds the combined answer body after schema extraction', () => { + const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } + const ordinary = { ...base, answers: [{ questionId: 'q1', optionIds: ['yes'], other: 'why' }] } + expect(() => RespondToQuestionParams.safeParse(ordinary)).not.toThrow() + expect(RespondToQuestionParams.safeParse(ordinary).success).toBe(true) + const oversized = { + ...base, + answers: Array.from({ length: 4 }, (_, index) => ({ + questionId: `q${index}`, + optionIds: [], + other: 'x'.repeat(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + })) + } + expect(RespondToQuestionParams.safeParse(oversized).success).toBe(false) + }) + it('accepts only the same bounded user message a send accepts, with bounded options', () => { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] } + const params = { + envelope: ENVELOPE, + worktree: 'wt', + agent: 'codex', + firstMessage: { clientMessageId: 'message-1', body }, + options: { model: 'picked-model' } + } + expect(CreateIntentParams.safeParse(params).success).toBe(true) + for (const firstMessage of [ + { clientMessageId: '', body }, + { clientMessageId: 'message-1', body: { ...body, role: 'assistant' } }, + { clientMessageId: 'message-1', body: { ...body, blocks: [] } }, + { clientMessageId: 'message-1', body, source: 'provider' }, + { + clientMessageId: 'message-1', + body: { ...body, blocks: [{ type: 'text', text: 'x'.repeat(256 * 1024) }] } + } + ]) { + expect(CreateIntentParams.safeParse({ ...params, firstMessage }).success).toBe(false) + } + expect(CreateIntentParams.safeParse({ ...params, options: { model: 1 } }).success).toBe(false) + expect( + CreateIntentParams.safeParse({ + ...params, + options: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`key-${index}`, 'value']) + ) + }).success + ).toBe(false) + }) it.each(['claude', 'codex', 'grok', 'qwen-code'])('accept the agent id %s', (agent) => { expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(true) expect(ModelCatalogParams.safeParse({ agent }).success).toBe(true) diff --git a/src/shared/rpc-contract/structured-agent-session-message-params.ts b/src/shared/rpc-contract/structured-agent-session-message-params.ts new file mode 100644 index 00000000000..13c27385f94 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-message-params.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +export const MAX_PROMPT_BYTES = 256 * 1024 +export const MAX_BLOCKS = 64 +export const MAX_OPTION_LABEL = 512 + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +export const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendBodyStructure = z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + +export const SendBody = SendBodyStructure.refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' +) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 0d89fda381f..d9dd55c7b44 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,3 +1,14 @@ +import { + SendBody, + MAX_OPTION_LABEL, + MAX_PROMPT_BYTES +} from './structured-agent-session-message-params' +export { + SendBlock, + MAX_BLOCKS, + MAX_OPTION_LABEL, + MAX_PROMPT_BYTES +} from './structured-agent-session-message-params' import { Identifier, JournalCursor, @@ -12,6 +23,7 @@ export { } from './structured-agent-session-identifiers' import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' +import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' import { @@ -27,16 +39,10 @@ import { // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH -export const MAX_PROMPT_BYTES = 256 * 1024 - /** Matches the journal's bounds on one grouped prompt. */ const MAX_QUESTION_ANSWER_QUESTIONS = 4 const MAX_QUESTION_ANSWER_OPTIONS = 64 -export const MAX_BLOCKS = 64 - -export const MAX_OPTION_LABEL = 512 - /** One relaunch cannot offer more chats than a profile plausibly holds. */ export const MAX_RESTART_RESUME_SESSIONS = 512 @@ -114,26 +120,6 @@ export const ResumeSource = z }) .strict() -export const CreateIntentParams = z - .object({ - envelope: MutationEnvelope, - worktree: Identifier('Invalid worktree selector'), - agent: StructuredAgent, - resumeFrom: ResumeSource.optional(), - /** - * The tab id the client reserved for this chat, so it can place the tab before the reply. The - * host owns the id from here: it is persisted on the session record and is what the host's tab - * snapshot will publish, so it must be a host tab id, as `agent.launch` requires of `paneKey`. - * - * This object is strict, so an older host refuses a payload carrying it. A client sends it - * only after `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY` is advertised. - */ - tabId: z.string().refine(isAgentSessionSurfaceTabId, 'Invalid chat tab ID').optional() - }) - .strict() - -export const CreateParams = z.union([AttachParams, CreateIntentParams]) - export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), @@ -141,25 +127,6 @@ export const CreateSupportParams = z }) .strict() -/** Clients may only author user turns. Accepting an assistant or tool role here - * would let one client write words into the agent's mouth in another's - * timeline, and the provider — not the client — owns those. */ -export const SendBlock = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), text: z.string() }).strict(), - z - .object({ - type: z.literal('image-ref'), - path: z.string().min(1).max(4096).optional(), - url: z.string().min(1).max(4096).optional(), - alt: z.string().max(MAX_OPTION_LABEL).optional() - }) - .strict() - .refine( - (value) => Boolean(value.path) !== Boolean(value.url), - 'Provide exactly one of path/url' - ) -]) - export const SendParams = z .object({ envelope: MutationEnvelope, @@ -168,20 +135,32 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), - body: z - .object({ - kind: z.literal('message'), - role: z.literal('user'), - blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) - }) - .strict() - .refine( - (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, - 'Message is too large' - ) + body: SendBody }) .strict() +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: StructuredAgent, + resumeFrom: ResumeSource.optional(), + /** Strict payload: send only after the host advertises the create-tab-id capability. */ + tabId: z.string().refine(isAgentSessionSurfaceTabId, 'Invalid chat tab ID').optional(), + /** Both fields require `agentSession.create.message.v1` on the executing host. */ + firstMessage: z + .object({ + clientMessageId: Identifier('Invalid client message id'), + body: SendParams.shape.body + }) + .strict() + .optional(), + options: z.record(z.string(), z.string()).refine(isAgentSessionOptions).optional() + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + export const CancelParams = z .object({ envelope: MutationEnvelope, diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index 3bee2efb623..f83658659a1 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' +import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, - RUNTIME_CAPABILITIES -} from './protocol-version' -import { structuredAgentSessionCreateParams } from './structured-agent-session-create' + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY +} from './agent-session-create-capabilities' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities' +import { + structuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage +} from './structured-agent-session-create' import { structuredAgentSessionCreateFingerprint, structuredAgentSessionPayloadFingerprint @@ -20,7 +25,12 @@ function nextUuid(): string { } function createParams( - overrides: { resumeFrom?: { providerSessionId: string }; tabId?: string } = {} + overrides: { + resumeFrom?: { providerSessionId: string } + tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Record + } = {} ) { return structuredAgentSessionCreateParams({ sessionId: SESSION_ID, @@ -33,6 +43,45 @@ function createParams( } describe('structured agent session create params', () => { + it('advertises create-message support only on clients using the new flow', () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) + expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).not.toContain( + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) + }) + it('fingerprints the first message identity, content and opening option picks', () => { + const firstMessage: StructuredAgentSessionFirstMessage = { + clientMessageId: 'message-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] } + } + const first = createParams({ firstMessage, options: { model: 'picked-model' } }) + expect(first.firstMessage).toEqual(firstMessage) + expect(first.options).toEqual({ model: 'picked-model' }) + expect(first.envelope.payloadFingerprint).toBe( + createParams({ firstMessage, options: { model: 'picked-model' } }).envelope.payloadFingerprint + ) + for (const changed of [ + createParams(), + createParams({ + firstMessage: { ...firstMessage, clientMessageId: 'message-2' }, + options: { model: 'picked-model' } + }), + createParams({ + firstMessage: { + ...firstMessage, + body: { ...firstMessage.body, blocks: [{ type: 'text', text: 'different text' }] } + }, + options: { model: 'picked-model' } + }), + createParams({ firstMessage, options: { model: 'other-model' } }) + ]) { + expect(changed.envelope.payloadFingerprint).not.toBe(first.envelope.payloadFingerprint) + } + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + expect(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY).toBe('agentSession.create.message.v1') + }) it('carries resumeFrom only when the create adopts a conversation', () => { expect(createParams()).not.toHaveProperty('resumeFrom') expect(createParams({ resumeFrom: RESUME })).toMatchObject({ resumeFrom: RESUME }) diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 1ca1dd7f627..c4ef6fb40c5 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,5 +1,7 @@ import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { z } from 'zod' +import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { createStructuredAgentSessionOperationId, structuredAgentSessionCreateFingerprint @@ -25,6 +27,14 @@ export type StructuredAgentSessionCreateParams = { resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string + /** Sent only to a host advertising `AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY`. */ + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> +} + +export type StructuredAgentSessionFirstMessage = { + clientMessageId: string + body: z.infer } /** Provider-prefixed so a session id names its lane on sight, and underscore-only @@ -55,6 +65,8 @@ export function structuredAgentSessionCreateParams(args: { agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> randomUuid: () => string now?: number }): StructuredAgentSessionCreateParams { @@ -62,7 +74,9 @@ export function structuredAgentSessionCreateParams(args: { worktree: args.worktree, agent: args.agent, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), - ...(args.tabId ? { tabId: args.tabId } : {}) + ...(args.tabId ? { tabId: args.tabId } : {}), + ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.options ? { options: args.options } : {}) } return { envelope: { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index d075af5d28b..1d20f32e09f 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,5 @@ import { sha256 } from './sha256' +import type { StructuredAgentSessionFirstMessage } from './structured-agent-session-create' import { isAgentSessionHandleProvider, type StructuredAgentId @@ -48,6 +49,8 @@ export function structuredAgentSessionCreateFingerprint(input: { agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> }): string { return structuredAgentSessionPayloadFingerprint({ method: 'agentSession.create', @@ -60,7 +63,9 @@ export function structuredAgentSessionCreateFingerprint(input: { resumeFrom: input.resumeFrom, // The host digests the same field; a retry naming another tab still replays with the // recorded one, since the host owns the id. - tabId: input.tabId + tabId: input.tabId, + firstMessage: input.firstMessage, + options: input.options } }) } diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index f5a67421301..aec8ad3988e 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,5 +1,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { z } from 'zod' +import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' export type StructuredAgentSessionSendMutation = { @@ -65,7 +67,7 @@ export type StructuredAgentSessionAttachment = { export function structuredAgentSessionSendBody( text: string, attachments: readonly StructuredAgentSessionAttachment[] -): AgentJournalMessageItem { +): z.infer { return { kind: 'message', role: 'user', diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 12fd794ff2d..d29a36c2da7 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -32,6 +32,7 @@ import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { resolveBaselineReleaseRef } from './release-checkout' import { callBuild, @@ -98,7 +99,9 @@ beforeAll(async () => { */ function legacyClientCapabilities(): string[] { return baseline.capabilities.filter( - (capability) => capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + (capability) => + capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY && + capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY ) } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 949457d4f63..cab588ad54d 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -26,6 +26,7 @@ export function structuredHostStub( restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), continueInterrupted: vi.fn(async () => ({ sessionId, outcome: 'superseded' })), attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), + create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), // Attach-shaped entries take a client-supplied location, so the host is asked whether it // supports creating there. A real host always answers; leaving it unstubbed made every // `ensure` refuse for the harness's own reason rather than the location's. @@ -111,7 +112,10 @@ export function installableHost( const host = { ...hostCalls, // The catalog read checks the session's record for a floating chat's own folder; none here. - deps: { modelCatalog: { read: hostCalls.modelCatalog }, store: { getRecord: () => null } }, + deps: { + modelCatalog: { read: hostCalls.modelCatalog }, + store: { getRecord: () => null, getOperationRow: () => null } + }, restartResume: { list: hostCalls.restartResumableList, listFailures: hostCalls.restartResumableFailures, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 2ce2afbb317..0a7753eac29 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { attachParams, ATTENTION_READ, @@ -73,9 +74,17 @@ export async function expectDeclaredSurfaceExecutes( const retirement = vi.fn() const runtime = runtimeStub({ retireStructuredAttention: retirement }) for (const { method, hostMethod, result } of STRUCTURED_CALLS) { + const atRestCreate = + method === 'agentSession.create' && + build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && + clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + const expectedHostMethod = atRestCreate ? 'create' : hostMethod + const otherCreateMethod = + method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null // Two methods share one host method, so "has been called" would already be // true from the earlier one: only this call's own delta pins the pairing. - const before = hostMethod ? hostCalls[hostMethod].mock.calls.length : 0 + const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 + const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 const replies = await callBuild( build, method, @@ -83,12 +92,15 @@ export async function expectDeclaredSurfaceExecutes( { clientKind: 'runtime', clientCapabilities }, runtime ) - if (hostMethod) { + if (expectedHostMethod) { expect( - hostCalls[hostMethod].mock.calls.length - before, - `${build.label}: ${method} did not reach the host` + hostCalls[expectedHostMethod].mock.calls.length - before, + `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` ).toBe(1) } + if (otherCreateMethod) { + expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) + } if (method === 'agentSession.acknowledgeAttention') { expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) }