From 9ae33a96f9456ccf2aa3fa0e5d48b0a1453b6c11 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:16:41 -0700 Subject: [PATCH] fix(native-chat): keep damaged chats readable without granting agent actions (#31931) * fix(native-chat): validate saved next-fence floors before use * WIP: separate saved conversation observations from runtime proof * Consolidate duplicate imports for WIP compiler checkpoint * Preserve saved conversation observations with strict runtime authority * Consolidate session admission and catalog imports * fix: align conversation observation contracts and fixtures * test: update conversation observation and writer fixtures * test: use checked host fixtures for structured workers * fix(native-chat): bound conversation projection work per request * test(native-chat): consolidate scale fixture imports * test(native-chat): use checked scale fixture types * test(native-chat): bound unreadable inventory traversal * Test native CLI admission and durable reference reads * test: bound cold conversation name subscription work * test: distinguish native receipt probes from terminal identity * test: retain exact mutation refusal recovery identity * fix: explain agent conversation preparation refusals * test: adapt remaining conversation readers and document CLI timeout * fix: register preparation refusal English message * test: align conversation fixtures with checked read APIs * test(native-chat): use checked conversation in Codex fixture --- ...structured-host-configured-default.test.ts | 10 +- .../acp-structured-host-live-listing.test.ts | 1 + .../first-work-branch-rename.test.ts | 4 +- .../ai-vault/structured-chat-cli-fork.test.ts | 9 +- src/main/ai-vault/structured-chat-cli-fork.ts | 19 +- .../structured-chat-conversation-copy.ts | 13 +- ...ructured-session-ownership.test-support.ts | 57 +- .../structured-session-ownership.test.ts | 20 +- .../ai-vault/structured-session-ownership.ts | 6 +- .../claude-background-wake-retention.test.ts | 2 +- ...claude-context-usage-unloaded-turn.test.ts | 18 +- ...-structured-effort-default-at-rest.test.ts | 48 +- ...laude-structured-launch-resolution.test.ts | 5 +- ...claude-subagent-permission-request.test.ts | 2 +- ...codex-structured-launch-resolution.test.ts | 5 +- src/main/ipc/ai-vault-search.test.ts | 5 +- .../pty-attach-view-joins-main-start.test.ts | 5 +- .../pty-spawn-saved-resume-admission.test.ts | 3 +- .../pty/host-env/saved-resume-admission.ts | 4 +- .../ipc/pty/pty-launch-race-parity.test.ts | 3 +- .../spawn-preflight-saved-resume.test.ts | 18 +- ...-model-catalog-discovery-freshness.test.ts | 11 +- ...odel-catalog-independent-discovery.test.ts | 5 + ...gent-model-catalog-learned-default.test.ts | 11 +- .../agent-model-catalog-lister.ts | 4 + .../agent-model-catalog-prewarm.test.ts | 32 +- ...gent-model-catalog-saved-only-read.test.ts | 12 +- .../agent-model-catalog-scope.test.ts | 1 + .../agent-model-catalog-scope.ts | 16 +- .../agent-model-catalog-service-contract.ts | 9 +- .../agent-model-catalog-service.test.ts | 24 +- .../agent-model-catalog-service.ts | 69 +-- .../agent-model-catalog-unavailable.test.ts | 1 + .../agent-model-catalog-workspace.ts | 54 +- .../structured-agent-session-agent-start.ts | 9 + ...-agent-session-attach-test-conversation.ts | 3 +- .../structured-agent-session-attach.ts | 2 +- ...nt-session-background-task-channel.test.ts | 2 +- ...d-agent-session-background-task-channel.ts | 2 +- .../structured-agent-session-child-close.ts | 1 + ...t-session-codex-input-finalization.spec.ts | 2 +- ...uctured-agent-session-command-admission.ts | 24 +- ...red-agent-session-conversation-lifetime.ts | 28 +- ...uctured-agent-session-conversation-open.ts | 26 +- .../structured-agent-session-create.ts | 22 +- .../structured-agent-session-current-work.ts | 20 +- ...ured-agent-session-damaged-runtime.test.ts | 239 ++++++++ ...ctured-agent-session-forget-status.test.ts | 8 +- ...structured-agent-session-history-result.ts | 8 +- ...uctured-agent-session-host-current-work.ts | 4 +- .../structured-agent-session-host-delivery.ts | 9 +- .../structured-agent-session-host-lifetime.ts | 6 +- ...ructured-agent-session-host-status-feed.ts | 9 +- .../structured-agent-session-host-tabs.ts | 4 +- .../structured-agent-session-host-types.ts | 4 +- .../structured-agent-session-host.ts | 2 +- ...tructured-agent-session-idle-sweep.test.ts | 2 +- ...uctured-agent-session-launch-permission.ts | 4 +- ...tured-agent-session-leftover-settlement.ts | 3 +- ...ctured-agent-session-mutation-admission.ts | 106 ++-- ...tured-agent-session-mutation-admits-now.ts | 5 +- ...structured-agent-session-mutation-plans.ts | 5 +- ...red-agent-session-mutation-turn-context.ts | 22 +- .../structured-agent-session-never-sent.ts | 2 +- ...nt-session-operation-start-refusal.test.ts | 24 +- ...uctured-agent-session-options-read.test.ts | 123 +++- .../structured-agent-session-options-read.ts | 10 +- .../structured-agent-session-owner-status.ts | 4 +- ...ured-agent-session-permission-mode.test.ts | 1 + ...structured-agent-session-provider-child.ts | 4 +- ...ructured-agent-session-provider-support.ts | 4 +- ...tured-agent-session-queued-command.test.ts | 48 ++ ...tructured-agent-session-queued-messages.ts | 2 +- ...ctured-agent-session-queued-publication.ts | 5 +- ...ctured-agent-session-queued-send-answer.ts | 4 +- ...uctured-agent-session-read-restore.test.ts | 33 +- .../structured-agent-session-read-restore.ts | 2 +- ...ed-agent-session-readable-restorer.test.ts | 13 +- ...uctured-agent-session-readable-restorer.ts | 2 +- ...ured-agent-session-ready-operation-plan.ts | 9 + ...tructured-agent-session-ready-operation.ts | 39 +- ...agent-session-recovered-turn-clock.test.ts | 2 +- ...agent-session-replaced-server-turn.test.ts | 9 +- ...ent-session-restart-failure-filing.test.ts | 20 +- ...ed-agent-session-restart-failure-ledger.ts | 5 +- ...ured-agent-session-restart-restore.test.ts | 46 +- ...tructured-agent-session-restart-restore.ts | 7 +- ...tured-agent-session-restart-resume-host.ts | 4 +- ...uctured-agent-session-restart-witnesses.ts | 7 +- .../structured-agent-session-reveal.test.ts | 17 +- .../structured-agent-session-reveal.ts | 4 +- ...gent-session-rewind-replay.test-fixture.ts | 49 +- .../structured-agent-session-rewind.test.ts | 54 +- .../structured-agent-session-rewind.ts | 13 +- ...ructured-agent-session-send-preparation.ts | 2 +- ...uctured-agent-session-start-eligibility.ts | 11 +- ...ent-session-status-feed-child-work.test.ts | 2 +- ...ed-agent-session-status-feed-child.test.ts | 2 +- ...agent-session-status-feed-children.test.ts | 4 +- ...ed-agent-session-status-feed-clock.test.ts | 2 +- ...sion-status-feed-conversation-name.test.ts | 8 +- ...ructured-agent-session-status-feed-deps.ts | 16 +- ...ssion-status-feed-launch-directory.test.ts | 2 +- ...agent-session-status-feed-stopping.test.ts | 7 +- ...ructured-agent-session-status-feed.test.ts | 5 +- .../structured-agent-session-status-feed.ts | 11 +- ...ession-status-first-input-identity.test.ts | 8 +- ...agent-session-status-journal-projection.ts | 6 +- ...tured-agent-session-status-reentry.test.ts | 2 +- ...structured-agent-session-status-summary.ts | 15 +- ...red-agent-session-subagent-recency.test.ts | 2 +- ...ructured-agent-session-subscribers.test.ts | 2 +- .../structured-agent-session-turns.ts | 10 + ...tured-agent-session-working-at-teardown.ts | 4 +- ...t-session-workspace-sleep-lifetime.test.ts | 9 +- ...ured-agent-session-workspace-sleep-stop.ts | 1 + ...tructured-agent-session-workspace-sleep.ts | 18 +- .../structured-conversation-command.test.ts | 14 +- .../structured-conversation-command.ts | 6 +- .../structured-conversation-compaction.ts | 6 +- .../structured-provider-session-ownership.ts | 19 +- ...red-agent-session-history-adoption.test.ts | 9 +- ...ructured-agent-session-history-adoption.ts | 9 +- ...structured-chat-naming-publication.test.ts | 25 +- .../structured-chat-naming.test.ts | 6 +- .../structured-conversation-key.ts | 2 +- src/main/pi/rpc-launch-resolution.test.ts | 20 +- .../agent-session-founding-admission.ts | 13 +- .../agent-session-inventory-scale.test.ts | 572 ++++++++++++++++++ .../agent-session-publication-scale.test.ts | 265 ++++++++ .../runtime/agent-session-record-founding.ts | 19 +- .../agent-session-record-launch-directory.ts | 4 +- src/main/runtime/agent-session-record-rows.ts | 15 +- ...agent-session-record-store-damaged.test.ts | 329 ++++++++++ .../agent-session-record-store.test.ts | 66 ++ .../runtime/agent-session-record-store.ts | 28 +- .../agent-session-scale.test-fixture.ts | 177 ++++++ .../agent-session-store-load-scale.test.ts | 92 +++ src/main/runtime/agent-session-store-state.ts | 57 +- .../agent-session-store-transactions.ts | 7 +- .../agent-session-replaced-runtime.ts | 11 +- .../agent-session/agent-session-tab-table.ts | 34 +- .../ownership/agent-session-lease-renewal.ts | 14 +- .../agent-session-operation-admission.ts | 24 +- .../agent-session-reservation-admission.ts | 47 +- .../structured-agent-session-close.test.ts | 29 +- .../orca-runtime-desktop-tab-names.test.ts | 2 +- ...ntime-process-incarnation-liveness.test.ts | 25 +- ...ctured-agent-session-create-intent.test.ts | 5 +- ...runtime-structured-session-restore.test.ts | 5 +- ...session-tabs-targeted-agent-resume.spec.ts | 3 +- src/main/runtime/orca-runtime.ts | 3 +- .../orchestration-cli-subprocess.test.ts | 354 +++++++++++ .../canonical-orca-session-id.ts | 2 +- .../orchestration/chat-assignee.test.ts | 6 +- .../runtime/orchestration/chat-assignee.ts | 4 +- ...ker-error-operation-counts.test-support.ts | 8 +- .../orchestration-party-location.test.ts | 9 +- .../orchestration/orchestration-party.ts | 6 +- .../structured-mailbox-pointer-host.test.ts | 32 +- .../structured-mailbox-pointer-host.ts | 6 +- .../structured-session-lineage.ts | 29 +- .../structured-session-mail-address.ts | 21 +- .../structured-session-mail-target.test.ts | 8 +- ...tured-worker-direct-mailbox-target.test.ts | 26 +- ...structured-worker-group-addressing.test.ts | 37 +- ...roject-removal-structured-sessions.test.ts | 37 +- .../ai-vault-structured-ownership.test.ts | 9 +- ...ration-structured-worker-lifecycle.test.ts | 33 +- ...stration-structured-worker-redrive.test.ts | 76 ++- ...stration-structured-worker-session.test.ts | 38 +- ...orchestration-structured-worker-session.ts | 13 +- ...on-structured-worker-start-failure.test.ts | 43 +- .../orchestration-worker-mode-opacity.test.ts | 21 +- .../orchestration/chat-assignee-admission.ts | 4 +- .../orchestration/chat-task-delivery.ts | 7 +- ...ker-start-agent-placement-teardown.test.ts | 11 +- ...orker-start-launch-executor-parity.test.ts | 11 +- .../worker-start-readiness-settlement.test.ts | 14 +- ...uctured-agent-session-options-read.test.ts | 41 +- .../structured-agent-session-options-read.ts | 2 +- ...nt-session-options-workspace-scope.test.ts | 1 + .../structured-agent-session-rename.test.ts | 229 ++++++- ...d-agent-session-rpc-client.test-fixture.ts | 19 + ...ructured-agent-session-rpc.test-fixture.ts | 30 +- .../structured-agent-session-status-stream.ts | 4 +- .../structured-agent-session-visual.test.ts | 13 +- .../structured-agent-session-visual.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 10 +- .../structured-worker-read-cursor.test.ts | 12 +- .../structured-worker-stop-receipt.test.ts | 29 +- .../structured-worker-tab-retirement.test.ts | 54 +- ...n-legacy-session-address-migration.test.ts | 14 +- ...chestration-session-caller-test-fixture.ts | 24 +- .../rpc/orchestration-session-caller.ts | 12 +- .../orchestration-session-recipient.test.ts | 11 +- ...runtime-orchestration-sender-names.test.ts | 9 +- .../runtime/runtime-reference-agents.test.ts | 8 +- src/main/runtime/runtime-reference-agents.ts | 14 +- .../runtime-reference-integration.test.ts | 2 +- ...untime-worktree-agent-rows-verdict.test.ts | 3 +- ...ree-structured-agent-rows-liveness.test.ts | 2 +- .../prune-mobile-session-tab-group-layout.ts | 2 +- ...d-agent-launch-composition.test-support.ts | 29 +- .../structured-agent-launch-composition.ts | 11 +- .../structured-agent-launch-parity.test.ts | 14 + ...ed-agent-model-catalog-first-frame.test.ts | 11 +- .../structured-agent-model-catalog-wiring.ts | 5 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 23 +- ...tructured-agent-session-create-adoption.ts | 2 +- ...red-chat-dispatch-worker-lifecycle.test.ts | 6 +- ...ructured-session-worktree-teardown.test.ts | 6 + .../structured-session-worktree-teardown.ts | 4 +- .../runtime/structured-worker-at-rest.test.ts | 38 +- .../structured-worker-authority.test.ts | 2 +- .../runtime/structured-worker-authority.ts | 19 +- .../structured-worker-cleared-session.test.ts | 52 +- src/main/runtime/structured-worker-custody.ts | 13 +- ...structured-worker-hook-attestation.test.ts | 15 +- .../structured-worker-identity.test.ts | 47 +- .../runtime/structured-worker-identity.ts | 12 +- .../structured-worker-mail-routing.test.ts | 23 +- .../structured-worker-rpc.test-support.ts | 30 + ...tructured-worker-takeover-pane-key.test.ts | 16 +- .../structured-worker-terminal-read.test.ts | 26 +- ...structured-worker-terminal-refusal.test.ts | 21 +- .../tab-bar/native-chat-cli-fork-run.test.ts | 59 +- src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/ai-vault-resume-refusal.ts | 7 + src/shared/agent-session-conversation-view.ts | 58 ++ .../agent-session-lease-adjudication.ts | 13 +- .../agent-session-legacy-handoff-lease.ts | 25 +- src/shared/agent-session-mutation-envelope.ts | 5 +- src/shared/agent-session-next-fence.ts | 4 +- .../agent-session-options-replacement.ts | 7 + .../agent-session-record-stored-form.test.ts | 62 +- .../agent-session-record-stored-form.ts | 10 +- src/shared/agent-session-record.ts | 126 ++-- ...claude-auto-mode-host-catalog.unit.test.ts | 7 +- ...x-child-approval-activity-row.unit.test.ts | 2 +- ...-model-catalog-released-phone.unit.test.ts | 3 +- .../structured-agent-session-host-fixture.ts | 2 +- .../versioned-agent-session-wire.ts | 4 +- ...ured-agent-session-read-owner.unit.test.ts | 2 +- 244 files changed, 4828 insertions(+), 1172 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-damaged-runtime.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-plan.ts create mode 100644 src/main/runtime/agent-session-inventory-scale.test.ts create mode 100644 src/main/runtime/agent-session-publication-scale.test.ts create mode 100644 src/main/runtime/agent-session-record-store-damaged.test.ts create mode 100644 src/main/runtime/agent-session-scale.test-fixture.ts create mode 100644 src/main/runtime/agent-session-store-load-scale.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-rpc-client.test-fixture.ts create mode 100644 src/main/runtime/structured-worker-rpc.test-support.ts create mode 100644 src/shared/agent-session-conversation-view.ts create mode 100644 src/shared/agent-session-options-replacement.ts 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 49422d155b7..ff7b6c99e9c 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' // Regression: initialize listed Grok 4.7 while new sessions actually ran Grok 4.6. import { afterEach, describe, expect, it } from 'vitest' @@ -44,7 +45,9 @@ const OTHER_CHAT = 'grok-chat-2' function otherChat(store: AgentSessionRecordStore | null) { const first = store?.getRecord(SESSION) - return first ? { ...first, sessionId: OTHER_CHAT } : undefined + return first + ? { ...first, sessionId: OTHER_CHAT, lease: { ...first.lease, sessionId: OTHER_CHAT } } + : undefined } const agents: AcpScriptedAgent[] = [] @@ -112,6 +115,11 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe const discovery = GROK.modelDiscovery const catalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), + getConversation: (id) => { + const saved = + records.store?.getRecord(id) ?? (id === OTHER_CHAT ? otherChat(records.store) : undefined) + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (sessionId) => records.store?.getRecord(sessionId) ?? (sessionId === OTHER_CHAT ? otherChat(records.store) : undefined), diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 6eb1fa4bd64..371d2c57c88 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -24,6 +24,7 @@ describe('a live Grok listing', () => { const catalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), getRecord: (id) => records?.getRecord(id) ?? undefined, + getConversation: (id) => records?.getConversation(id) ?? undefined, drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'GROK_HOME', path: '/grok' }), recordWorkspacePath: async () => '/workspace/project', diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 356ae2d7c16..56c36ead12e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -152,7 +152,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1, onStatusChanged: observe }) @@ -252,7 +252,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]), - getRecord: () => null, + getConversation: () => null, now: () => 1, onStatusChanged: (summary, options) => { expect(summary.workspaceId).toBe(workspaceId) diff --git a/src/main/ai-vault/structured-chat-cli-fork.test.ts b/src/main/ai-vault/structured-chat-cli-fork.test.ts index 1626c52846e..bd8e51ca0aa 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.test.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -39,7 +40,13 @@ function install(record: AgentSessionRecord, copy = vi.fn(), journal = savedJour setStructuredAgentSessionHost({ journalSnapshot: async () => journal, deps: { - store: { getRecord: () => record, listRecords: () => [record] }, + store: { + getRecord: () => record, + listRecords: () => [record], + listConversations: () => [agentSessionConversationView(record)], + selectWritableRecord: () => ({ kind: 'writable', record }), + requireWritableRecord: () => record + }, adapter: { copyConversation: copy }, resolveWorkspacePath: async () => root, resolveLaunchEnv: () => ({}) diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts index 58151b86245..5393ff73771 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -77,7 +77,10 @@ export async function prepareStructuredChatCliFork( prepare: AiVaultSessionResumePreparation ): Promise { const host = getStructuredAgentSessionHost() - const record = host?.deps.store.getRecord(args.sourceChat.sessionId) + const record = + host && host.deps.store.selectWritableRecord(args.sourceChat.sessionId).kind !== 'missing' + ? host.deps.store.requireWritableRecord(args.sourceChat.sessionId) + : undefined const registration = record && structuredAgentRuntimeRegistration(record.provider) const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain) if ( @@ -102,6 +105,18 @@ export async function prepareStructuredChatCliFork( ) { throw new Error('Send a message after rewinding this chat, then open it in a terminal.') } + const requireCurrent = (): void => { + const current = host.deps.store.requireWritableRecord(record.sessionId) + const currentHead = agentSessionProviderHandleChainHead(current.providerHandleChain) + if ( + current.location.workspaceId !== record.location.workspaceId || + current.location.executionHostId !== record.location.executionHostId || + current.location.wslDistro !== record.location.wslDistro || + currentHead?.linkId !== head.linkId + ) { + throw new Error('This chat has no saved conversation available on this host.') + } + } const agent = record.provider const cwd = (await host.deps.resolveWorkspacePath?.(record.location.workspaceId)) || record.launchDirectory @@ -114,6 +129,7 @@ export async function prepareStructuredChatCliFork( } assertNativeConversationContent(await host.journalSnapshot(record.sessionId)) if (agentForksThroughChat(agent)) { + requireCurrent() const result = await copyStructuredChatRecordConversation(record, providerSessionId) const copy = result.forkedSession if (!copy || (agent === 'omp' && !copy.filePath)) { @@ -164,6 +180,7 @@ export async function prepareStructuredChatCliFork( agent === 'codex' && isLegacyAgentSessionAccountHome(record.accountHome) ? record.accountHome.path : null + requireCurrent() const result = await prepare({ agent, sessionId: providerSessionId, diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts index 18774a5e4d5..43ff0efff07 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -18,7 +18,12 @@ export async function copyStructuredChatConversation( } const host = getStructuredAgentSessionHost() const ownership = findResumeOwnership(args) - const record = ownership ? host?.deps.store.getRecord(ownership.sessionId) : undefined + const record = + ownership && + host && + host.deps.store.selectWritableRecord(ownership.sessionId).kind !== 'missing' + ? host.deps.store.requireWritableRecord(ownership.sessionId) + : undefined const copy = host?.deps.adapter.copyConversation if (!host || !ownership || !record || !copy) { throw new Error('This chat is no longer in Orca, so it cannot be copied to a new CLI.') @@ -32,7 +37,11 @@ export async function copyStructuredChatRecordConversation( ): Promise { const host = getStructuredAgentSessionHost() const copy = host?.deps.adapter.copyConversation - if (!host || !copy || !isAiVaultAgent(record.provider)) { + if (!host) { + throw new Error('This chat cannot be copied to a new CLI.') + } + record = host.deps.store.requireWritableRecord(record.sessionId) + if (!copy || !isAiVaultAgent(record.provider)) { throw new Error('This chat cannot be copied to a new CLI.') } const forkedSession = await copy.call(host.deps.adapter, { diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index 6da15e055b2..67868e9e97a 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseFixture, @@ -26,32 +27,39 @@ export function installOwnership( workspaceId: 'workspace-1', provider: handle?.agent ?? 'codex', providerSessionId: PROVIDER_SESSION, - lease: agentSessionLeaseFixture(), + ownerObservation: agentSessionConversationView(agentSessionRecordFixture()).ownerObservation, ...ownershipOverrides } - const record = agentSessionRecordFixture(ownership.lease) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: ownership.sessionId }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listConversations`; the rest of the host is never reached. setStructuredAgentSessionHost({ deps: { store: { - listRecords: () => [ + listConversations: () => [ { - ...record, - sessionId: ownership.sessionId, - location: { ...record.location, workspaceId: ownership.workspaceId }, - provider: ownership.provider, - providerHandleChain: [ - { - ...record.providerHandleChain[0]!, - handle: - handle ?? - (ownership.provider === 'claude' - ? claudeProviderHandle(ownership.providerSessionId, null) - : codexProviderHandle(ownership.providerSessionId)) - } - ], - lease: { ...ownership.lease, sessionId: ownership.sessionId }, - ...(ownership.conversationName ? { conversationName: ownership.conversationName } : {}) + ...agentSessionConversationView({ + ...record, + sessionId: ownership.sessionId, + location: { ...record.location, workspaceId: ownership.workspaceId }, + provider: ownership.provider, + providerHandleChain: [ + { + ...record.providerHandleChain[0]!, + handle: + handle ?? + (ownership.provider === 'claude' + ? claudeProviderHandle(ownership.providerSessionId, null) + : codexProviderHandle(ownership.providerSessionId)) + } + ], + lease: record.lease, + ...(ownership.conversationName + ? { conversationName: ownership.conversationName } + : {}) + }), + ownerObservation: ownership.ownerObservation } ] } @@ -79,6 +87,9 @@ export function installCopyingChat( deps: { store: { listRecords: () => [record, ...otherRecords], + listConversations: () => [record, ...otherRecords].map(agentSessionConversationView), + selectWritableRecord: () => ({ kind: 'writable', record }), + requireWritableRecord: () => record, getRecord: (sessionId: string) => (sessionId === record.sessionId ? record : null) }, adapter: { copyConversation: copy } @@ -104,8 +115,10 @@ export function codexChatHolding(sessionId: string, threadIds: string[]): AgentS } export function installChats(records: AgentSessionRecord[]): void { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. - setStructuredAgentSessionHost({ deps: { store: { listRecords: () => records } } } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listConversations`; the rest of the host is never reached. + setStructuredAgentSessionHost({ + deps: { store: { listConversations: () => records.map(agentSessionConversationView) } } + } as never) } export function listResult(): AiVaultListResult { diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 098ef959aef..2d4c206398d 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { assertLegacyAiVaultResumeAllowed, @@ -155,11 +159,15 @@ describe('structured AI Vault ownership', () => { ).toThrow('agent_session_conflict') installOwnership({ - lease: agentSessionLeaseFixture({ - handoffStage: 'new-owner-proving', - claimStatus: 'reserved', - ownerProcess: null - }) + ownerObservation: agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + handoffStage: 'new-owner-proving', + claimStatus: 'reserved', + ownerProcess: null + }) + ) + ).ownerObservation }) await expect( assertLegacyAiVaultResumeCommandAllowed( diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 7316b3573ac..c81e38824bc 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -1,5 +1,5 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' +import { agentSessionOwnerObservationIsLive } from '../../shared/agent-session-lease-adjudication' import { defaultAgentChatLabel } from '../../shared/agent-session/agent-session-chat-label' import type { AiVaultListResult } from '../../shared/ai-vault-types' import { historyRowFallsBackToChatLabel } from '../../shared/structured-chat-row-name' @@ -202,7 +202,7 @@ function resumeOwnerships( function listOwnership(): StructuredProviderSessionOwnership[] { const host = getStructuredAgentSessionHost() - return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : [] + return host ? listStructuredProviderSessionOwnership(host.deps.store.listConversations()) : [] } function resumesOwnedSession( @@ -258,7 +258,7 @@ function parseResumeInvocations(command: string): ResumeInvocation[] { function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never { throw new Error( - agentSessionLeaseAdmitsWriter(ownership.lease) + agentSessionOwnerObservationIsLive(ownership.ownerObservation) ? 'agent_session_conflict' : 'agent_session_ownership_unknown' ) diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 2b339951f0d..534c1fd6283 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -63,7 +63,7 @@ async function wiredSession() { } ] ]), - getRecord: () => null, + getConversation: () => null, now: () => Date.now(), statusSink: () => ({ publish: (summary, subject) => host.ingestStructuredStatus(summary, subject), diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 2a5a98c2742..9039ea4d3c8 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -1,4 +1,8 @@ -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -92,6 +96,10 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist child: { fence: 1, generation: 'generation-1' }, params: { provider: 'claude' } } + const record = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION, runtimeFence: 1 })), + options: { model: 'opus' } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members. const context = { deps: { @@ -100,12 +108,8 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist }, agents, store: { - getRecord: () => ({ - ...agentSessionRecordFixture(), - sessionId: SESSION, - provider: 'claude', - options: { model: 'opus' } - }) + requireWritableRecord: () => record, + getConversation: () => agentSessionConversationView(record) } }, serialize: (_sessionId: string, task: () => Promise) => task(), diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 6573d296ac7..f73e7d89bb4 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -1,3 +1,6 @@ +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' @@ -142,19 +145,22 @@ async function startChild( function restingRecord( options: Record, - providerHandleChain: { origin: string }[] = [] + providerHandleChain: { origin: 'created' | 'adopted' }[] = [] ): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. - return { + return agentSessionRecordFixture({ sessionId: SESSION, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, - providerHandleChain, + providerHandleChain: providerHandleChain.map(({ origin }, index) => ({ + origin, + linkId: `link-${index}`, + mintedAtFence: 1, + observedAt: 0, + handle: claudeProviderHandle('thread-1', null) + })), location: { executionHostId: 'local', wslDistro: null }, - // A released chat reads current account configuration on its next start. - lease: { claimStatus: 'released' }, options - } as unknown as AgentSessionRecord + }) } function exactKey(record: AgentSessionRecord): string { @@ -172,6 +178,10 @@ function catalogDefault(store: AgentModelCatalogStore, model: string): string | function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecord) { return createAgentModelCatalogService({ store, + getConversation: () => { + const saved = record + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: () => record, drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), @@ -199,7 +209,11 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { deps: { adapter: {}, agents: claudeAndCodexAgents(), - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, modelCatalog }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -316,14 +330,14 @@ describe('Claude effort default at rest', () => { it('lists an unlisted Codex model at rest exactly as its live child does', async () => { const store = new AgentModelCatalogStore() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. - const record = { + const record = agentSessionRecordFixture({ + sessionId: SESSION, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-unlisted', effort: 'high' } - } as unknown as AgentSessionRecord + }) const listing = { models: [ { @@ -396,14 +410,14 @@ describe('Claude effort default at rest', () => { }) it("keeps a Codex model's effort at rest with no catalog yet, from the client's own defaults", async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. - const record = { + const record = agentSessionRecordFixture({ + sessionId: SESSION, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5', effort: 'high' } - } as unknown as AgentSessionRecord + }) const resting = await readAtRest(new AgentModelCatalogStore(), record) @@ -415,14 +429,14 @@ describe('Claude effort default at rest', () => { it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => { const store = new AgentModelCatalogStore() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. - const record = { + const record = agentSessionRecordFixture({ + sessionId: SESSION, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5' } - } as unknown as AgentSessionRecord + }) store.recordSuccess( exactKey(record), 'codex', diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 711468c2ba4..b3ccf0f0445 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -21,7 +22,7 @@ const IDENTITY = { sessionId: SESSION_ID } as Parameters< >[0]['identity'] function record(overrides: Partial = {}): AgentSessionRecord { - return { + return agentSessionRecordFixture({ sessionId: SESSION_ID, provider: 'claude', location: { @@ -33,7 +34,7 @@ function record(overrides: Partial = {}): AgentSessionRecord accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, providerHandleChain: [], ...overrides - } as AgentSessionRecord + }) } function identityAt(leafUuid: string | null): typeof IDENTITY { diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index 607c55a198e..5c943297754 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -153,7 +153,7 @@ async function pipeline() { }) ] ]), - getRecord: () => null, + getConversation: () => null, now, statusSink: () => ({ publish: (summary, subject) => { diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 6b01968c856..c2feb2207c2 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' import { createCodexStructuredLaunchResolver, TEST_SPAWN_TOKEN @@ -41,7 +42,7 @@ async function withPlatform(platform: NodeJS.Platform, run: () => Promise) } function record(overrides: Partial = {}): AgentSessionRecord { - return { + return agentSessionRecordFixture({ sessionId: SESSION_ID, provider: 'codex', location: { @@ -53,7 +54,7 @@ function record(overrides: Partial = {}): AgentSessionRecord accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' }, providerHandleChain: [], ...overrides - } as AgentSessionRecord + }) } function resolverFor( diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index aeef2a74d62..cfb76ba3cf1 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({ @@ -240,7 +241,9 @@ describe('local search hits owned by a native chat', () => { { ...record.providerHandleChain[0]!, handle: codexProviderHandle('host-session') } ] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. - setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + setStructuredAgentSessionHost({ + deps: { store: { listConversations: () => [agentSessionConversationView(record)] } } + } as never) return { sessionId: record.sessionId, workspaceId: record.location.workspaceId } } diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index 5bb7522d0cc..ba9957cc7cc 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -12,6 +12,7 @@ import { } from './pty/pane/spawn-reservation' import { makePaneKey } from '../../shared/stable-pane-id' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { agentSessionRecordFixture, @@ -225,8 +226,8 @@ describe('a window view of a pane main is starting', () => { }) ) const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) - const list = vi.fn(() => [chat]) - records.listRecords = list + const list = vi.fn(() => [agentSessionConversationView(chat)]) + records.listConversations = list records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) const capture = vi.spyOn(runtime, 'readSavedResumeSource').mockResolvedValue(saved) const cleanup = vi.spyOn(runtime, 'forgetRefusedSavedResume').mockResolvedValue() diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts index 7143689445a..483d7e38d38 100644 --- a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -5,6 +5,7 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' import { registerPtyHandlers } from './pty/register-handlers' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { agentSessionLeaseFixture, @@ -76,7 +77,7 @@ describe('a pane restore of a conversation a native chat continues', () => { throw new Error('records unavailable') } const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) - store.listRecords = () => [chat] + store.listConversations = () => [agentSessionConversationView(chat)] store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs }) return store }) diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index af11bc574e6..76aa742ef34 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -137,7 +137,7 @@ async function judgeResumes( ) return { verdict: 'unverifiable', refused: resumes[0] } } - const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( + const heads = listStructuredProviderSessionOwnership(store.listConversations()).filter( (holding) => holding.role === 'head' ) const tabs = store.getVisibleSessionTabIndex() @@ -145,7 +145,7 @@ async function judgeResumes( const holders = heads.filter((holding) => keysOf(refused).some((key) => keysMayMatch(holding.key, key)) ) - if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { + if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.ownerObservation))) { return { verdict: 'chat-writing', refused } } if (holders.length > 0 && (!tabs || !tabs.present)) { diff --git a/src/main/ipc/pty/pty-launch-race-parity.test.ts b/src/main/ipc/pty/pty-launch-race-parity.test.ts index 8eccf4e89d9..7559cb8ad30 100644 --- a/src/main/ipc/pty/pty-launch-race-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-race-parity.test.ts @@ -31,6 +31,7 @@ import { type LaunchRaceLane } from '../pty-launch-race-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { agentSessionRecordFixture, @@ -757,7 +758,7 @@ describe('launch race parity, both spawn lanes', () => { }) ) const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) - records.listRecords = () => [chat] + records.listConversations = () => [agentSessionConversationView(chat)] records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] }) const forget = vi.fn(async () => {}) const remember = vi.fn() diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index 5733957e44b..8e285b7593d 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import { CLAUDE_CLI_CONVERSATIONS } from '../../../claude/claude-cli-conversations' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { localProvider } from '../provider/registry' @@ -67,7 +68,7 @@ const chat = agentSessionRecordFixture( ) const store = { - listRecords: () => [chat], + listConversations: () => [agentSessionConversationView(chat)], getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] }) } @@ -174,7 +175,9 @@ describe('actual runtime saved-resume outcomes', () => { handle: { ...link.handle, nativeId: `unrelated-${index}` } })) })) - const list = vi.spyOn(store, 'listRecords').mockImplementation(() => records) + const list = vi + .spyOn(store, 'listConversations') + .mockImplementation(() => records.map(agentSessionConversationView)) const visible = vi.spyOn(store, 'getVisibleSessionTabIndex').mockReturnValue({ present: true, sessionIds: records.map((record) => record.sessionId) @@ -408,16 +411,17 @@ it('preserves case-distinct conversation heads and saved homes across the shared })) } const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) - opencodeStore.listRecords = () => [record] + opencodeStore.listConversations = () => [agentSessionConversationView(record)] opencodeStore.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [record.sessionId] }) const runtime = { openAgentSessionRecordStore: async () => opencodeStore } - expect(listStructuredProviderSessionOwnership([record]).map((holding) => holding.role)).toEqual([ - 'earlier', - 'head' - ]) + expect( + listStructuredProviderSessionOwnership([agentSessionConversationView(record)]).map( + (holding) => holding.role + ) + ).toEqual(['earlier', 'head']) for (const [index, id] of ids.entries()) { await expect( admitResumeSpawn( diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts index 1275cd5863c..c0e57aaed5c 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts @@ -1,6 +1,7 @@ +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' @@ -12,7 +13,7 @@ import { const HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/homes/grok' } -const RECORD = { +const RECORD = agentSessionRecordFixture({ sessionId: 'grok-session', provider: 'grok', accountHome: HOME, @@ -22,7 +23,7 @@ const RECORD = { workspaceId: 'ws-1', workspaceKind: 'git-worktree' } -} as const satisfies Pick +}) const EFFORTS = [ { value: 'low', label: 'Low' }, @@ -54,8 +55,8 @@ function harness(probe: () => Promise) { const probes = vi.fn(probe) const catalog = createAgentModelCatalogService({ store, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location. - getRecord: () => RECORD as unknown as AgentSessionRecord, + getConversation: () => agentSessionConversationView(RECORD), + getRecord: () => RECORD, drivesRecord: () => true, resolveAccountHome: async () => HOME, probes: { grok: probes }, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts index 4deb4ef8343..1c15661cf01 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-independent-discovery.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' @@ -49,6 +50,10 @@ function harness() { const probe: AgentModelCatalogProbe = Object.assign(async () => run(), { resolve }) const service = createAgentModelCatalogService({ store, + getConversation: (id) => { + const saved = record(id, id === 'a' ? 'old' : 'another') + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (id) => record(id, id === 'a' ? 'old' : 'another'), drivesRecord: () => true, resolveAccountHome: async () => HOME, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts index 26f47618824..422f45a32da 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -45,8 +47,7 @@ function workspace(name: string, files: Record = {}): string { } function record(agent: string, workspacePath: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. - return { + return agentSessionRecordFixture({ sessionId: `${agent}-1`, provider: agent, accountHome: HOMES[agent]!, @@ -57,7 +58,7 @@ function record(agent: string, workspacePath: string): AgentSessionRecord { workspaceId: 'ws-1', workspaceKind: 'git-worktree' } - } as AgentSessionRecord + }) } async function afterNoPickChat(agent: string, workspacePath: string, picked = false) { @@ -79,6 +80,10 @@ async function afterNoPickChat(agent: string, workspacePath: string, picked = fa ) const catalog = createAgentModelCatalogService({ store, + getConversation: (sessionId) => { + const saved = sessionId === chat.sessionId ? chat : undefined + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (sessionId) => (sessionId === chat.sessionId ? chat : undefined), drivesRecord: () => true, resolveAccountHome: async () => HOMES[agent]!, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts index a9b350e29af..dda5308a835 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-lister.ts @@ -118,6 +118,7 @@ export function refreshAgentModelCatalogListing(input: { export function scopedAgentModelCatalogLister(input: { legacy?: boolean + canList: () => boolean admission: PrioritySemaphore signal: AbortSignal resolved: AgentModelCatalogResolvedProbe | undefined @@ -133,6 +134,9 @@ export function scopedAgentModelCatalogLister(input: { const timer = legacy ? setTimeout(() => deadline.abort(), 30_000) : undefined const attemptSignal = AbortSignal.any([signal, deadline.signal]) try { + if (!input.canList()) { + throw new AgentModelCatalogListingStoppedError('catalog scope unavailable') + } if (resolved) { return await resolved.run(attemptSignal) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index 8de5be44030..224d185b645 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { describe, expect, it, vi } from 'vitest' @@ -63,6 +65,7 @@ describe('model catalog prewarm', () => { const grok = vi.fn(async (_home: AgentSessionAccountHome) => listing('grok-4')) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, hasChatRecords: () => true, @@ -99,8 +102,8 @@ describe('model catalog prewarm', () => { it("saves a running chat's reports in the order it sent them, per folder", async () => { const store = new AgentModelCatalogStore() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory. - const chat = { + + const chat = agentSessionRecordFixture({ sessionId: 'chat-b', provider: 'opencode2', accountHome: HOME('OPENCODE_HOME', '/homes/a'), @@ -110,11 +113,12 @@ describe('model catalog prewarm', () => { workspaceId: 'ws-b', workspaceKind: 'folder' } - } as AgentSessionRecord + }) const lookups = [deferred(), deferred()] let lookup = 0 const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(chat), getRecord: () => chat, drivesRecord: () => true, resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'), @@ -135,8 +139,8 @@ describe('model catalog prewarm', () => { async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) => listing(options?.workspacePath === '/work/b' ? 'only-in-b' : 'big-pickle') ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory. - const chatInB = { + + const chatInB = agentSessionRecordFixture({ sessionId: 'chat-b', provider: 'opencode2', accountHome: HOME('OPENCODE_HOME', '/homes/a'), @@ -147,9 +151,13 @@ describe('model catalog prewarm', () => { workspaceId: 'ws-b', workspaceKind: 'folder' } - } as AgentSessionRecord + }) const service = createAgentModelCatalogService({ store, + getConversation: (sessionId) => { + const saved = sessionId === 'chat-b' ? chatInB : undefined + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (sessionId) => (sessionId === 'chat-b' ? chatInB : undefined), drivesRecord: () => true, resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'), @@ -192,6 +200,7 @@ describe('model catalog prewarm', () => { ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, hasChatRecords: () => true, @@ -226,6 +235,7 @@ describe('model catalog prewarm', () => { ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, hasChatRecords: () => true, @@ -254,6 +264,7 @@ describe('model catalog prewarm', () => { const claude = vi.fn(async (_home: AgentSessionAccountHome) => listing('opus')) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, hasChatRecords: () => true, @@ -287,6 +298,7 @@ describe('model catalog prewarm', () => { store.recordSuccess('claude-other-account', 'claude', listing('opus'), 'discovery') const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), @@ -324,6 +336,7 @@ describe('model catalog prewarm', () => { const hasChatRecords = vi.fn(() => true) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), @@ -360,6 +373,7 @@ describe('model catalog prewarm', () => { const agents = ['codex', 'claude', 'grok', 'opencode', 'pi'] const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, hasChatRecords: () => true, @@ -378,8 +392,7 @@ describe('model catalog prewarm', () => { describe('one account, one catalog key', () => { function record(path: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key reads only provider, accountHome and location. - return { + return agentSessionRecordFixture({ sessionId: 'session-1', provider: 'grok', accountHome: HOME('GROK_HOME', path), @@ -389,7 +402,7 @@ describe('one account, one catalog key', () => { workspaceId: 'ws-1', workspaceKind: 'git-worktree' } - } as AgentSessionRecord + }) } it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => { @@ -398,6 +411,7 @@ describe('one account, one catalog key', () => { const launched = record(managedHome) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(launched), getRecord: () => launched, drivesRecord: () => true, resolveAccountHome: async () => HOME('GROK_HOME', managedHome) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts index 89bd3a7abc8..30958358d7b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { @@ -38,6 +39,7 @@ function rig(agent = 'grok') { ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => HOME, @@ -69,11 +71,16 @@ describe('a saved-only catalog read', () => { } const store = new AgentModelCatalogStore({ now: () => now }) const probe = vi.fn(async () => listing('grok-4.7')) + const resolveAccountHome = vi.fn(async () => HOME) const service = createAgentModelCatalogService({ store, - getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), + getConversation: (sessionId) => { + const saved = sessionId === record.sessionId ? record : undefined + return saved ? agentSessionConversationView(saved) : undefined + }, + getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => HOME, + resolveAccountHome, probes: { grok: probe } }) @@ -92,6 +99,7 @@ describe('a saved-only catalog read', () => { }) expect(probe).not.toHaveBeenCalled() + expect(resolveAccountHome).not.toHaveBeenCalled() await service.read({ agent: 'grok' }) expect(probe).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts index c1fa2b990a3..698096e04cf 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.test.ts @@ -101,6 +101,7 @@ describe('a picker read with the CLI missing', () => { let now = 1_000 const service = createAgentModelCatalogService({ store: new AgentModelCatalogStore({ now: () => now }), + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: root }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts index a012ee44b31..271a0af101b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-scope.ts @@ -1,8 +1,5 @@ import { homedir } from 'node:os' -import type { - AgentSessionAccountHome, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' import { isNativeLocalLocation } from '../../../shared/execution-host' import type { AgentModelCatalogService, @@ -14,13 +11,13 @@ import { type AgentModelCatalogLookup, type AgentModelCatalogResolvedProbe } from './agent-model-catalog-discovery' -import { sessionWorkspacePath } from './agent-model-catalog-workspace' +import { sessionWorkspacePath, type AgentModelCatalogRecord } from './agent-model-catalog-workspace' /** Discovery follows launch configuration, never session picks. */ export async function resolveAgentModelCatalogScope( deps: AgentModelCatalogServiceDeps, params: Parameters[0], - record: AgentSessionRecord | undefined, + record: AgentModelCatalogRecord | undefined, base: string, home: AgentSessionAccountHome | null ): Promise<{ @@ -37,10 +34,11 @@ export async function resolveAgentModelCatalogScope( return { base, fingerprint: base, exact: false } } const probe = deps.probes?.[params.agent] + const savedOnly = () => params.savedOnly || !!(record && !deps.getRecord(record.sessionId)) if (params.legacy) { const fingerprint = agentModelCatalogScopeFingerprint(base, 'legacy-account') const resolved = - params.savedOnly || !deps.store.probeDue(fingerprint) + savedOnly() || !deps.store.probeDue(fingerprint) ? null : await probe?.resolveLegacy?.(home).catch(() => null) return { @@ -61,9 +59,9 @@ export async function resolveAgentModelCatalogScope( probe?.resolve?.(home, cwd, lookup).catch(() => null) const scopeKey = (resolved: AgentModelCatalogResolvedProbe | null | undefined) => agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity) - let resolved = await resolve(params.savedOnly ? 'saved' : 'snapshot') + let resolved = await resolve(savedOnly() ? 'saved' : 'snapshot') // Rerun shells only for reads due to list; saved-only reads must remain passive. - if (!params.savedOnly && (!resolved || deps.store.probeDue(scopeKey(resolved)))) { + if (!savedOnly() && (!resolved || deps.store.probeDue(scopeKey(resolved)))) { resolved = await resolve('recover') } if (probe?.resolve && !resolved) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts index ed618a3b10f..c8784ff57fe 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service-contract.ts @@ -8,11 +8,16 @@ import type { AgentModelCatalogLiveListing, AgentModelCatalogProbe } from './agent-model-catalog-store' -import type { AgentModelCatalogWorkspaceDeps } from './agent-model-catalog-workspace' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import type { + AgentModelCatalogWorkspaceDeps, + AgentModelCatalogRecord +} from './agent-model-catalog-workspace' export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & { + getConversation: (sessionId: string) => AgentSessionConversationView | undefined getRecord: (sessionId: string) => AgentSessionRecord | undefined /** Unlaunchable records cannot authorize probing their account with a different host binary. */ - drivesRecord: (record: AgentSessionRecord) => boolean + drivesRecord: (record: AgentModelCatalogRecord) => boolean /** Share the launch resolver so recordless reads use the account a new chat would pin. */ resolveAccountHome: (agent: string) => Promise probes?: Readonly>> diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 1da82d39c33..c26d1a4eb2c 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { joinHostModelListingWait, isHostModelListingWaitInFlight @@ -17,8 +19,7 @@ import { } from './agent-model-catalog-store' function record(accountHomePath: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here. - return { + return agentSessionRecordFixture({ sessionId: 'session-1', provider: 'grok', accountHome: { variable: 'GROK_HOME', path: accountHomePath }, @@ -28,7 +29,7 @@ function record(accountHomePath: string): AgentSessionRecord { workspaceId: 'ws-1', workspaceKind: 'git-worktree' } - } as AgentSessionRecord + }) } function listing(id: string): AgentModelCatalogSuccess { @@ -58,6 +59,7 @@ describe('agent model catalog service', () => { const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a')) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(record('/homes/a')), getRecord: () => record('/homes/a'), drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected'), @@ -85,6 +87,7 @@ describe('agent model catalog service', () => { const drivesRecord = vi.fn(() => false) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(pinned), getRecord: () => pinned, drivesRecord, resolveAccountHome: async () => GROK_HOME('/homes/selected'), @@ -93,7 +96,7 @@ describe('agent model catalog service', () => { await service.read({ agent: 'grok', sessionId: 'session-1' }) - expect(drivesRecord).toHaveBeenCalledWith(pinned) + expect(drivesRecord).toHaveBeenCalledWith(agentSessionConversationView(pinned)) expect(probe).toHaveBeenCalledExactlyOnceWith(GROK_HOME('/homes/selected'), { signal: expect.any(AbortSignal) }) @@ -106,6 +109,7 @@ describe('agent model catalog service', () => { const probe = vi.fn(async () => listing('gpt-new')) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/new'), @@ -142,6 +146,7 @@ describe('agent model catalog service', () => { ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected') @@ -167,6 +172,7 @@ describe('agent model catalog service', () => { ) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(sessionRecord), getRecord: () => sessionRecord, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected') @@ -182,6 +188,7 @@ describe('agent model catalog service', () => { }) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(record('/homes/a')), getRecord: () => record('/homes/a'), drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/a'), @@ -203,6 +210,7 @@ describe('agent model catalog service', () => { const probe = vi.fn(async () => listing('gpt-a')) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => { @@ -231,6 +239,7 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore() const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected'), @@ -428,6 +437,7 @@ describe('agent model catalog service', () => { const store = new AgentModelCatalogStore() const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected') @@ -450,6 +460,7 @@ describe('agent model catalog service', () => { const probe = vi.fn(() => new Promise(() => {})) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected'), @@ -473,6 +484,7 @@ describe('agent model catalog service', () => { const workspaceMayOverrideDefaultModel = vi.fn(async () => mayOverride) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => GROK_HOME('/homes/selected'), @@ -540,6 +552,7 @@ describe('agent model catalog service', () => { const probe = vi.fn(async (home: AgentSessionAccountHome) => listing(JSON.stringify(home))) const service = createAgentModelCatalogService({ store, + getConversation: () => agentSessionConversationView(openCodeRecord(managed(PROFILE_A))), getRecord: () => openCodeRecord(managed(PROFILE_A)), drivesRecord: () => true, resolveAccountHome: async () => managed(PROFILE_B), @@ -561,6 +574,8 @@ describe('agent model catalog service', () => { const probe = vi.fn(async () => listing('never')) const service = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), + getConversation: () => + agentSessionConversationView(openCodeRecord(managed(PROFILE_A), 'Ubuntu')), getRecord: () => openCodeRecord(managed(PROFILE_A), 'Ubuntu'), drivesRecord: () => true, resolveAccountHome: async () => managed(PROFILE_B), @@ -609,6 +624,7 @@ it.each(['cold', 'warm', 'failure', 'stop'] as const)( ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index ab4a1ff5307..147b8a00ad3 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -4,24 +4,19 @@ import { } from './agent-model-catalog-lister' import { prewarmAgentModelCatalog, newChatCatalogKey } from './agent-model-catalog-prewarm' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' -import type { - AgentSessionAccountHome, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import { agentModelCatalogResultFromEntry, retainedAgentModelCatalogModels } from './agent-model-catalog-entry' -import type { - AgentModelCatalogEntry, - AgentModelCatalogLiveListing -} from './agent-model-catalog-store' +import type { AgentModelCatalogEntry } from './agent-model-catalog-store' import { catalogFolder, - recordConfiguredDefault, + createAgentModelCatalogLiveListingRecorder, sessionWorkspacePath, + type AgentModelCatalogRecord, workspaceKeepsListedDefault } from './agent-model-catalog-workspace' @@ -47,36 +42,12 @@ export function createAgentModelCatalogService( const listWith = agentModelCatalogProbeLister(lifetime.signal) // Serialize folder-scoped writes across asynchronous folder lookups. const liveWrites = new Map>() - const sessionCatalogKey = async (record: AgentSessionRecord): Promise => + const sessionCatalogKey = async (record: AgentModelCatalogRecord): Promise => agentModelCatalogFingerprintForRecord( record, await catalogFolder(deps, record.provider, undefined, record) ) - const saveLiveListing = ( - record: AgentSessionRecord, - listing: AgentModelCatalogLiveListing, - fingerprint: string - ): void => { - const { configuredDefault, ...listed } = listing - const saved = deps.store.recordSuccess( - fingerprint, - record.provider, - { ...listed, origin: 'live-session' }, - 'live' - ) - if (saved && configuredDefault !== undefined) { - // Preserve report order when learning configured defaults. - const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve()) - .then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault)) - .catch(() => {}) - .then(() => { - if (recordingDefaults.get(fingerprint) === recording) { - recordingDefaults.delete(fingerprint) - } - }) - recordingDefaults.set(fingerprint, recording) - } - } + const saveLiveListing = createAgentModelCatalogLiveListingRecorder(deps, recordingDefaults) return { peek(sessionId, verifiedOnly) { const key = sessionKeys.get(sessionId) @@ -93,11 +64,16 @@ export function createAgentModelCatalogService( return } void sessionCatalogKey(record) - .then((fingerprint) => deps.store.expireFailure(fingerprint)) + .then((fingerprint) => { + if (deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence) { + deps.store.expireFailure(fingerprint) + } + }) .catch(() => {}) }, async read(params) { - const record = params.sessionId ? deps.getRecord(params.sessionId) : undefined + const record = params.sessionId ? deps.getConversation(params.sessionId) : undefined + const savedOnly = params.savedOnly || !!(record && !deps.getRecord(record.sessionId)) const scoped = record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined const workspacePath = params.inSessionWorkspace @@ -125,7 +101,7 @@ export function createAgentModelCatalogService( } const scope = await resolveAgentModelCatalogScope( deps, - params, + { ...params, savedOnly }, scoped, fingerprint, probeHome @@ -152,6 +128,7 @@ export function createAgentModelCatalogService( const probe = deps.probes?.[params.agent] const list = scopedAgentModelCatalogLister({ legacy: params.legacy, + canList: () => !record || !!deps.getRecord(record.sessionId), admission, signal: lifetime.signal, resolved, @@ -221,8 +198,11 @@ export function createAgentModelCatalogService( } // Observed process exit suppresses background relaunch; explicit new chats may discover. if ( - params.savedOnly || - (!exact && scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence) + savedOnly || + (scoped && !deps.getRecord(scoped.sessionId)) || + (!exact && + scoped?.ownerObservation.claimStatus === 'released' && + scoped.ownerObservation.deathEvidence) ) { return answer(entry) } @@ -268,7 +248,7 @@ export function createAgentModelCatalogService( return answer(entry) }, async retainedLiveModelIds(sessionId, listing) { - const record = deps.getRecord(sessionId) + const record = deps.getConversation(sessionId) const entry = record && deps.drivesRecord(record) ? deps.store.get(await sessionCatalogKey(record)) @@ -286,7 +266,12 @@ export function createAgentModelCatalogService( } // Serialize reports so a slow folder lookup cannot overwrite a newer listing. const writing = (liveWrites.get(sessionId) ?? Promise.resolve()) - .then(async () => saveLiveListing(record, listing, await sessionCatalogKey(record))) + .then(async () => { + const fingerprint = await sessionCatalogKey(record) + if (deps.getRecord(sessionId)?.lease.runtimeFence === record.lease.runtimeFence) { + saveLiveListing(record, listing, fingerprint) + } + }) .catch((error: unknown) => { console.warn('[agent-model-catalog] could not save a chat model list:', error) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index 9802b6f3ccf..58ca707befc 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -57,6 +57,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { ) const service = createAgentModelCatalogService({ store, + getConversation: () => undefined, getRecord: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => ACCOUNT_HOME, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts index 7734ac7a14b..ab71dbcb5cb 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts @@ -1,14 +1,22 @@ +import type { + AgentModelCatalogLiveListing, + AgentModelCatalogStore +} from './agent-model-catalog-store' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { isNativeLocalLocation } from '../../../shared/execution-host' import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry' -import type { AgentModelCatalogStore } from './agent-model-catalog-store' + +export type AgentModelCatalogRecord = Pick< + AgentSessionRecord, + 'sessionId' | 'provider' | 'providerHandleChain' | 'accountHome' | 'location' | 'launchDirectory' +> export type AgentModelCatalogWorkspaceDeps = { store: AgentModelCatalogStore /** Project-scoped catalogs require a folder and cannot be prewarmed. */ listsInChatFolder?: ReadonlySet - recordWorkspacePath?: (record: AgentSessionRecord) => Promise + recordWorkspacePath?: (record: AgentModelCatalogRecord) => Promise /** Account-home config is already covered by discovery; only workspace layers can override it. */ workspaceMayOverrideDefaultModel?: (input: { agent: string @@ -39,7 +47,7 @@ export async function workspaceKeepsListedDefault( /** null means the host cannot inspect this session's workspace. */ export async function sessionWorkspacePath( deps: AgentModelCatalogWorkspaceDeps, - record: AgentSessionRecord + record: AgentModelCatalogRecord ): Promise { if (!isNativeLocalLocation(record.location) || !deps.recordWorkspacePath) { return null @@ -52,7 +60,7 @@ export async function catalogFolder( deps: AgentModelCatalogWorkspaceDeps, agent: string, workspacePath: string | null | undefined, - session: AgentSessionRecord | undefined + session: AgentModelCatalogRecord | undefined ): Promise { if (!deps.listsInChatFolder?.has(agent)) { return undefined @@ -66,7 +74,9 @@ export async function catalogFolder( /** Learn account defaults only without workspace overrides; null resolution retires stale facts. */ export async function recordConfiguredDefault( - deps: AgentModelCatalogWorkspaceDeps, + deps: AgentModelCatalogWorkspaceDeps & { + getRecord: (id: string) => AgentSessionRecord | undefined + }, record: AgentSessionRecord, fingerprint: string, choice: AgentModelCatalogConfiguredChoice | null @@ -86,5 +96,39 @@ export async function recordConfiguredDefault( ) { return } + if (deps.getRecord(record.sessionId)?.lease.runtimeFence !== record.lease.runtimeFence) { + return + } deps.store.recordConfiguredDefault(fingerprint, choice) } + +export function createAgentModelCatalogLiveListingRecorder( + deps: Parameters[0], + recordingDefaults: Map> +) { + return ( + record: AgentSessionRecord, + listing: AgentModelCatalogLiveListing, + fingerprint: string + ): void => { + const { configuredDefault, ...listed } = listing + const saved = deps.store.recordSuccess( + fingerprint, + record.provider, + { ...listed, origin: 'live-session' }, + 'live' + ) + if (saved && configuredDefault !== undefined) { + // Preserve report order when learning configured defaults. + const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve()) + .then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault)) + .catch(() => {}) + .then(() => { + if (recordingDefaults.get(fingerprint) === recording) { + recordingDefaults.delete(fingerprint) + } + }) + recordingDefaults.set(fingerprint, recording) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index e9bb1c7216f..eefc0760a50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -10,6 +10,7 @@ import { import { argumentProblemOf } from '../structured-agent-arguments-error' import { agentSessionRefusalFromReference, + isAgentSessionRefusalError, readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import type { @@ -67,6 +68,14 @@ export function claimStructuredAgentSessionAgent( sessionId: string, startedFor?: string ): StructuredAgentSessionStartClaim { + try { + context.deps.store.requireWritableRecord(sessionId) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { outcome: Promise.resolve({ ok: false, refusal: error.refusal }) } + } + throw error + } const session = context.sessions.get(sessionId) const child = session?.child if (!structuredAgentSessionStartStillQueued(session, startedFor)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 5ef114ed998..2ad32c339c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -12,7 +13,7 @@ export function openTestAttachConversation( ( await openStructuredAgentSessionConversationJournal( { journalDatabase, logger: recordingStructuredAgentSessionLogger().logger }, - record, + agentSessionConversationView(record), { acquisition: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index a3ba834e819..79cb8c859c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -128,7 +128,7 @@ export function admitAttachOrRefuse( } export function journalIdentityFor( - record: AgentSessionRecord, + record: Pick, params: AgentSessionAttachParams ): AgentSessionJournalIdentity { const active = activeProviderContext(record) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index ac2c1b5137e..1b220ec16f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -37,7 +37,7 @@ function channelOver( const channel = new StructuredAgentSessionBackgroundTaskChannel( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the channel reads only the record store's fence and the adapter's stop capability. { - store: { getRecord: () => null }, + store: { getConversation: () => null }, adapter: { backgroundTaskStops: stops } } as unknown as StructuredAgentSessionHostDeps, sessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index faedfa91ba9..7c960a983cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -48,7 +48,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { ) const result = readStructuredAgentSessionHistoryResult({ journal, - record: this.deps.store.getRecord(request.sessionId), + record: this.deps.store.getConversation(request.sessionId), request, scope, replaceNormalizedHistory, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 2e96a1ed95e..7a1084b61a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -93,6 +93,7 @@ function closeProviderRoot( if (!adapter.disposeSession && !adapter.closeSession) { return Promise.resolve(true) } + context.deps.store.requireWritableRecord(sessionId) return stopAgentSessionProviderRoot( async () => { const close = adapter.disposeSession ?? adapter.closeSession diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts index 1179ecbb934..005d34795b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts @@ -411,7 +411,7 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz if (result === 'new-session') { const opened = await openStructuredAgentSessionConversationJournal( host['deps'], - host['deps'].store.getRecord(SESSION)! + host['deps'].store.getConversation(SESSION)! ) sessions.set(SESSION, opened.session) currentJournal = opened.session.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts index 75f343916fb..2126a507297 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts @@ -19,7 +19,10 @@ import { } from './structured-agent-session-mutation-refusals' import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' import type { MutationCommandReceipt } from './structured-agent-session-mutation-plans' -import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' +import { + mutationTurnContext, + mutationReplayContext +} from './structured-agent-session-mutation-turn-context' import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' import { agentSessionOperationOutcomeUnknown, @@ -42,8 +45,7 @@ export async function admitCommandReceiptMutation( }) ) } - const record = store.getRecord(envelope.sessionId) - if (!record) { + if (store.selectWritableRecord(envelope.sessionId).kind === 'missing') { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } let read @@ -64,15 +66,19 @@ export async function admitCommandReceiptMutation( if (read.verdict !== 'absent') { return answerCommandReceipt(request, hostFingerprint, read) } - const prepared = await request.prepareSession?.('admit', record) + store.requireWritableRecord(envelope.sessionId) + const prepared = await request.prepareSession?.( + 'admit', + store.getConversation(envelope.sessionId)! + ) if (prepared && !prepared.ok) { return prepared } const journal = request.journal() - const current = store.getRecord(envelope.sessionId) - if (!journal || !current) { + if (!journal || store.selectWritableRecord(envelope.sessionId).kind === 'missing') { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } + const current = store.requireWritableRecord(envelope.sessionId) // Journal fences protect conversation writes; provider effects also require the lease. const leaseRefusal = plan.conversationWrite ? null : refuseUnlessWriterAdmitted(current.lease) if (leaseRefusal) { @@ -143,7 +149,7 @@ async function answerCommandReceipt( ) ) } - const record = request.store.getRecord(envelope.sessionId) + const record = request.store.getConversation(envelope.sessionId) if (!record) { return unknown(request) } @@ -152,11 +158,11 @@ async function answerCommandReceipt( return prepared } const journal = request.journal() - const current = request.store.getRecord(envelope.sessionId) + const current = request.store.getConversation(envelope.sessionId) if (!journal || !current) { return unknown(request) } - const context = mutationTurnContext(request, journal, current) + const context = mutationReplayContext(request, journal, current) const outcome = commandReceiptOutcome(receipt) const replay = resolveAgentSessionReplayOutcome({ operationId: envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index d8ff2f1bee6..1d79897730d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -80,7 +80,7 @@ export function createStructuredAgentSessionConversationLifetime( const closeContext = { sessions, serialize, closeConversation, deps } const hasOpenDispatch = (sessionId: string): boolean => { - const record = deps().store.getRecord(sessionId) + const record = deps().store.getConversation(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true } const providerHoldsDispatch = (sessionId: string): boolean => @@ -118,12 +118,12 @@ export function createStructuredAgentSessionConversationLifetime( executionHostId: ExecutionHostId ): Promise => { const matches = (): boolean => { - const location = deps().store.getRecord(sessionId)?.location + const location = deps().store.getConversation(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId } const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) // Pending creates have no record yet; their queued close waits for founding. - if (matches()) { + if (matches() && deps().store.getRecord(sessionId)) { const journal = sessions.get(sessionId)?.journal if (journal) { closeStructuredAgentSessionHandovers(journal) @@ -135,13 +135,15 @@ export function createStructuredAgentSessionConversationLifetime( return null } readRefusals.forget(sessionId) - starts.abort() - const record = deps().store.getRecord(sessionId) + const record = deps().store.getConversation(sessionId) if (!record) { return null } - const fence = record.lease.runtimeFence + const fence = record.currentFence const admittedSends = deps().store.admittedSendCount(sessionId) + if (deps().store.getRecord(sessionId)) { + starts.abort() + } const session = sessions.get(sessionId) const child = session?.child const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true @@ -167,7 +169,7 @@ export function createStructuredAgentSessionConversationLifetime( ) // Return the post-release fence for checks against a later restart. return { - fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, + fence: deps().store.getConversation(sessionId)?.currentFence ?? fence, stopped, admittedSends } @@ -183,7 +185,7 @@ export function createStructuredAgentSessionConversationLifetime( deliveryActive: host.deliveryActive, childWorkOpen: host.childWorkOpen, store: { - getRecord: (sessionId) => deps().store.getRecord(sessionId), + getConversation: (sessionId) => deps().store.getConversation(sessionId), replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId) }, hasOpenDispatch, @@ -274,7 +276,7 @@ export function createStructuredAgentSessionConversationLifetime( readRefusals.forget(sessionId) return open } - const record = deps().store.getRecord(sessionId) + const record = deps().store.getConversation(sessionId) if (!record) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' @@ -304,10 +306,14 @@ export function createStructuredAgentSessionConversationLifetime( closeStructuredAgentSessionHandovers(journal) } // Abort outside serialization so hung startup cannot block close. - host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + if (deps().store.selectWritableRecord(sessionId).kind !== 'unreadable') { + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + } return serialize(sessionId, async () => { readRefusals.forget(sessionId) - host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + if (deps().store.selectWritableRecord(sessionId).kind !== 'unreadable') { + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + } await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 1a2d836ab13..087346aabd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { attachFingerprintFields, journalIdentityFor, @@ -28,7 +28,7 @@ export type OpenedStructuredAgentSessionConversation = { } export type StructuredAgentSessionConversationOpenDeps = { - store: Pick + store: Pick journalDatabase: JournalHostDatabase logger: StructuredAgentSessionHostDeps['logger'] } @@ -57,7 +57,7 @@ export async function openStructuredAgentSessionConversation( if (open) { return open } - const record = context.deps.store.getRecord(sessionId) + const record = context.deps.store.getConversation(sessionId) if (!record) { return null } @@ -69,22 +69,22 @@ export async function openStructuredAgentSessionConversation( /** Caller adopts the unindexed journal; settlement needs store ownership history. */ export async function openStructuredAgentSessionConversationJournal( deps: StructuredAgentSessionConversationOpenDeps, - record: AgentSessionRecord, + record: AgentSessionConversationView, options?: StructuredAgentSessionConversationOpenOptions ): Promise export async function openStructuredAgentSessionConversationJournal( deps: Omit, - record: AgentSessionRecord, + record: AgentSessionConversationView, options: { acquisition: true } ): Promise export async function openStructuredAgentSessionConversationJournal( deps: Omit & Partial>, - record: AgentSessionRecord, + record: AgentSessionConversationView, options: StructuredAgentSessionConversationOpenOptions = {} ): Promise { const { sessionId } = record - const fence = record.lease.runtimeFence + const fence = record.currentFence const params = attachParamsForRecord(record, { clientOperationId: `read-restore:${sessionId}`, expectedRuntimeFence: fence @@ -127,8 +127,8 @@ export async function resettleOpenStructuredAgentSessionConversation( sessionId: string, session: StructuredAgentSessionHostSession | undefined ): Promise { - const record = deps.store.getRecord(sessionId) - if (session && record?.lease.deathEvidence) { + const record = deps.store.getConversation(sessionId) + if (session && record?.ownerObservation.deathEvidence) { await settleGoneGeneration(deps, record, session.journal) } } @@ -136,16 +136,16 @@ export async function resettleOpenStructuredAgentSessionConversation( async function settleGoneGeneration( deps: Pick & Partial>, - record: AgentSessionRecord, + record: AgentSessionConversationView, journal: AgentSessionJournal ): Promise { try { await settleStaleStructuredAgentSessionState({ journal, sessionId: record.sessionId, - fence: record.lease.runtimeFence, + fence: record.currentFence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null, + deathEvidence: record.ownerObservation.deathEvidence ?? null, replaced: deps.store?.replacedRuntime(record.sessionId), failureTextContext: structuredAgentSessionFailureWordsContext(record) }) @@ -160,7 +160,7 @@ async function settleGoneGeneration( } export function attachParamsForRecord( - record: AgentSessionRecord, + record: Pick, input: { clientOperationId: string expectedRuntimeFence: number 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 index 24e78e7681a..8bc7bd3d546 100644 --- 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 @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { isAgentSessionCreateAdmissionRefusal } from '../../../shared/agent-session-definitive-refusal' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' @@ -168,16 +169,17 @@ async function foundConversation( 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) - } - ) + : await openStructuredAgentSessionConversationJournal( + deps, + agentSessionConversationView(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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index bcd454e42a1..f750f6bbf4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -1,3 +1,4 @@ +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isHistoricalProviderTimelineItem } from '../agent-session-timeline/provider-timeline-history' import { currentRequestsProjection } from './agent-session-current-requests' // Derive operational work from lease, first-hand exit, replaced-runtime and journal evidence. @@ -29,7 +30,7 @@ export type StructuredAgentSessionCurrentWorkJournal = Pick< > export type StructuredAgentSessionWorkEvidence = { - record: Pick | null + record: Pick | null ended?: StructuredAgentSessionSeenEnd revision?: number replaced?: StructuredAgentSessionReplacedEnd | undefined @@ -50,7 +51,9 @@ type StructuredAgentSessionSeenEnd = { export function structuredAgentSessionLiveFence( evidence: StructuredAgentSessionWorkEvidence ): number | null { - const lease = evidence.record?.lease + const record = evidence.record + const lease = record?.ownerObservation + const fence = record?.currentFence if (!lease) { return 0 } @@ -58,22 +61,27 @@ export function structuredAgentSessionLiveFence( return null } if ( - structuredAgentSessionReplacedRuntimeEnded(lease, evidence.replaced) || + structuredAgentSessionReplacedRuntimeEnded( + { runtimeFence: fence ?? 0, claimStatus: lease.claimStatus }, + evidence.replaced + ) || structuredAgentSessionOwnerUnverifiable(evidence) ) { return null } const { ended } = evidence - return ended?.rootGone === true && ended.fence === lease.runtimeFence ? null : lease.runtimeFence + return ended?.rootGone === true && ended.fence === fence ? null : (fence ?? 0) } /** Unreconciled/recovering owners without a local child remain unverifiable, not current work. */ function structuredAgentSessionOwnerUnverifiable(evidence: StructuredAgentSessionWorkEvidence) { - const lease = evidence.record?.lease + const record = evidence.record + const lease = record?.ownerObservation + const fence = record?.currentFence return ( (lease?.unreconciled === true || lease?.handoffStage === 'recovering') && evidence.child !== undefined && - evidence.child?.fence !== lease.runtimeFence + evidence.child?.fence !== fence ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-damaged-runtime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-damaged-runtime.test.ts new file mode 100644 index 00000000000..3e8c7ea2f09 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-damaged-runtime.test.ts @@ -0,0 +1,239 @@ +import { + composeStructuredLaunch, + composeStructuredLaunchRead +} from '../../runtime/structured-agent-launch-composition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { afterEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { setOptionPlan } from './structured-agent-session-mutation-plans' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig | undefined +let restored: StructuredAgentSessionHost | undefined +afterEach(async () => { + await restored?.flushAllStreamedEvents() + await rig?.dispose() +}) + +it('replays a saved send and its conflict but refuses fresh send, Stop and metadata before any new work', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: 1, + options: { model: 'kept-model', effort: 'high' }, + now: 1800000000000 + }) + const body = hostTestMessage('saved message') + const envelope = rig.envelope({ body }, 'agentSession.send', hostTestOperationId()) + const params = { envelope, body, userSend: true as const } + const accepted = await rig.host.send(CALLER, params) + expect(accepted).toMatchObject({ ok: true }) + expect( + rig.store.readCommandReceipt({ kind: 'global' }, envelope.clientOperationId) + ).toMatchObject({ verdict: 'readable' }) + await rig.host.flushAllStreamedEvents() + damageFloor(rig.root) + const store = await openTestAgentSessionRecordStore(rig.root) + expect(store.readCommandReceipt({ kind: 'global' }, envelope.clientOperationId)).toMatchObject({ + verdict: 'readable' + }) + restored = new StructuredAgentSessionHost({ ...rig.host.deps, store }) + expect(await restored.readOptions(SESSION, { readsWithoutModel: true })).toMatchObject({ + current: { model: 'kept-model', effort: 'high' } + }) + const starts = rig.starts.mock.calls.length + const sends = rig.dispatch.mock.calls.length + expect(await restored.revealSession(SESSION)).toMatchObject({ sessionId: SESSION }) + expect(await restored.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + fence: store.getConversation(SESSION)?.currentFence, + value: { clientMessageId: envelope.clientOperationId } + }) + const changed = hostTestMessage('different message') + expect( + await restored.send(CALLER, { + ...params, + body: changed, + envelope: rig.envelope({ body: changed }, 'agentSession.send', envelope.clientOperationId) + }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) + expect( + await restored.send(CALLER, { + ...params, + envelope: rig.envelope({ body }, 'agentSession.send', hostTestOperationId()) + }) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } }) + expect( + await restored.cancel(CALLER, { + envelope: rig.envelope({}, 'agentSession.cancel', hostTestOperationId()) + }) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } }) + await expect(store.setConversationName(SESSION, 'changed')).rejects.toMatchObject({ + refusal: { details: { reason: 'recordUnreadable' } } + }) + expect(rig.starts).toHaveBeenCalledTimes(starts) + expect(rig.dispatch).toHaveBeenCalledTimes(sends) + expect((await restored.journalSnapshot(SESSION)).submissions).toHaveLength(1) + const close = vi.spyOn(restored.deps.adapter, 'closeSession') + const release = vi.spyOn(restored.deps.adapter, 'releaseAcquisition') + const closes = close.mock.calls.length + const releases = release.mock.calls.length + expect(await restored.sleepWorkspace('workspace-1', true)).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] + }) + expect(close).toHaveBeenCalledTimes(closes) + expect(release).toHaveBeenCalledTimes(releases) +}) + +it('answers a recorded ledger result without a preparation hook before requiring fresh authority', async () => { + rig = await createQueuedMessageTestRig() + const fields = { key: 'model', value: 'gpt-5' } + const envelope = rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const request = { + ...rig.host.deps, + callerKey: CALLER.callerKey, + envelope, + plan: setOptionPlan(fields), + journal: () => journal, + publish: () => {}, + now: () => 1800000000000 + } + expect(await admitAndRunAgentSessionMutation(request)).toMatchObject({ ok: true }) + await rig.store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, claimStatus: 'released', ownerProcess: null } + })) + const pendingEnvelope = { ...envelope, clientOperationId: hostTestOperationId() } + expect( + await rig.store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: pendingEnvelope, + hostFingerprint: pendingEnvelope.payloadFingerprint, + now: request.now(), + conversationWrite: true + }) + ).toMatchObject({ admission: { decision: 'admit' } }) + damageFloor(rig.root) + const store = await openTestAgentSessionRecordStore(rig.root) + expect(await admitAndRunAgentSessionMutation({ ...request, store })).toMatchObject({ + ok: true, + replayed: true, + value: fields + }) + expect( + await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope }) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: pendingEnvelope.clientOperationId, + outcome: { status: 'unknown' } + }) + expect( + await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_unknown' } }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: pendingEnvelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } + }) + expect( + await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict' } }) + const operation = { + callerKey: CALLER.callerKey, + envelope, + hostFingerprint: envelope.payloadFingerprint, + now: request.now() + } + expect(await store.admitMutationOperation(operation)).toMatchObject({ + admission: { decision: 'replay' }, + record: { currentFence: store.getConversation(SESSION)?.currentFence } + }) + expect( + await store.admitMutationOperation({ + ...operation, + hostFingerprint: 'changed', + envelope: { ...envelope, payloadFingerprint: 'changed' } + }) + ).toMatchObject({ + admission: { decision: 'refused', refusal: { code: 'agent_session_operation_conflict' } } + }) + await expect( + store.admitMutationOperation({ + ...operation, + envelope: { ...envelope, clientOperationId: hostTestOperationId() }, + conversationWrite: true + }) + ).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } }) + expect( + await store.admitMutationOperation({ + ...operation, + envelope: { ...envelope, sessionId: 'missing-chat' } + }) + ).toBeNull() +}) + +function damageFloor(root: string): void { + openTestJournalHostDatabase(root) + .db.prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run(SESSION) +} + +it('refuses launch and probe results when saved authority changes during preparation', async () => { + rig = await createQueuedMessageTestRig() + const identity = { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: null + } + let current = rig.store + const sources = { + store: { + selectWritableRecord: (id: string) => current.selectWritableRecord(id), + requireWritableRecord: (id: string) => current.requireWritableRecord(id), + pinLaunchDirectory: rig.store.pinLaunchDirectory + }, + resolveWorkspacePath: async () => rig!.root, + resolveBaseEnvironment: async () => ({}), + resolveAgentEnvironment: async () => ({}) + } + const part = async () => { + damageFloor(rig!.root) + current = await openTestAgentSessionRecordStore(rig!.root) + return { launch: { command: '/fixture/codex' }, env: {}, inheritedEnvToDelete: [] } + } + await expect( + composeStructuredLaunch( + CODEX_STRUCTURED_AGENT, + sources, + part + )({ identity, spawnToken: 'fixture-token' }) + ).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } }) + current = rig.store + await expect( + composeStructuredLaunchRead(CODEX_STRUCTURED_AGENT, sources, part)(identity) + ).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 4b24dfa2060..c4f1aea43bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Keep conversation/status lifetimes aligned: stale host-owned Working rows bypass freshness expiry. @@ -142,7 +143,10 @@ async function workingSession(): Promise<{ const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions, - getRecord: (sessionId) => records.get(sessionId) ?? null, + getConversation: (sessionId) => { + const record = records.get(sessionId) + return record ? agentSessionConversationView(record) : null + }, now: () => 1, statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), @@ -170,7 +174,7 @@ function attachContext( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here. return { deps: { - store: { getRecord: () => null }, + store: { getRecord: () => null, getConversation: () => null }, claimKeyId: 'key-1', journalDatabase: openTestJournalHostDatabase(root) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index f7c3d17f8aa..b541f731d29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -1,4 +1,4 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' import type { @@ -16,7 +16,7 @@ import { } from './agent-session-published-work' export function structuredAgentSessionProviderSessionMetadata( - record: AgentSessionRecord | null + record: AgentSessionConversationView | null ): AgentProviderSessionMetadata | undefined { const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null return head ? { key: 'session_id', id: head.handle.nativeId } : undefined @@ -24,7 +24,7 @@ export function structuredAgentSessionProviderSessionMetadata( export function readStructuredAgentSessionHistoryResult(input: { journal: AgentSessionJournal - record: AgentSessionRecord | null + record: AgentSessionConversationView | null request: AgentSessionHistoryRequest scope?: AgentSessionHistoryScope replaceNormalizedHistory?: boolean @@ -38,7 +38,7 @@ export function readStructuredAgentSessionHistoryResult(input: { }), input.work ) - const fence = input.record?.lease.runtimeFence + const fence = input.record?.currentFence const providerSession = structuredAgentSessionProviderSessionMetadata(input.record) if (fence === undefined) { return providerSession ? { ...result, providerSession } : result diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts index b8959d169d1..d1808690ba1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts @@ -9,7 +9,7 @@ import { import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export type StructuredAgentSessionCurrentWorkHost = { - store: Pick + store: Pick sessions: { get( sessionId: string @@ -43,7 +43,7 @@ export function heldStructuredAgentSessionCurrentWork( ): StructuredAgentSessionCurrentWork { const ended = session?.lastEndedChild return structuredAgentSessionCurrentWork(journal, { - record: store.getRecord(sessionId), + record: store.getConversation(sessionId), replaced: store.replacedRuntime(sessionId), child: session?.child ?? null, ...(ended ? { ended } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 52504805018..bf690f80d91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -90,7 +90,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { childWork: () => input.clientDelivery.readChildWork(sessionId), childWorkOpen: () => input.clientDelivery.childWorkOpen(sessionId), hasOpenDispatch: () => { - const record = deps.store.getRecord(sessionId) + const record = deps.store.getConversation(sessionId) return record !== null && deps.hasOpenDispatch?.(record) === true }, providerHoldsDispatch: () => @@ -123,7 +123,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { ? true : (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) ?? mode !== session?.child?.permissionMode), - stopAgent: input.stopOutgrownAgent, + stopAgent: (sessionId) => { + deps.store.requireWritableRecord(sessionId) + return input.stopOutgrownAgent(sessionId) + }, logger: deps.logger }) }, @@ -155,7 +158,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( - deps.store.getRecord(sessionId), + deps.store.getConversation(sessionId), sessions.get(sessionId)?.journal ), logger: deps.logger, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 1f171acbbf4..74afc4ea313 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -60,7 +60,7 @@ export type StructuredAgentSessionLifetimeContext = { } type ConversationCloseDeps = Pick & { - store: Pick + store: Pick } /** Keep personal unsent text as cards and mark reopen before close; quit leaves recovery to next open. @@ -176,12 +176,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } if ('quit' in ending && ending.quit) { - const record = context.deps.store.getRecord(sessionId) + const record = context.deps.store.getConversation(sessionId) await recordStructuredAgentSessionShutdownCut({ journal: session.journal, sessionId, fence: child.fence, - rowFence: Math.max(child.fence, record?.lease.runtimeFence ?? child.fence), + rowFence: Math.max(child.fence, record?.currentFence ?? child.fence), generation: child.generation ?? 'unknown', turnItemId: quitCuts, trigger: ending.quit, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index 586666be7e6..e9cdd4c6327 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -22,7 +22,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number currentWork: StructuredAgentSessionSubagentRowsDeps['currentWork'] deps: () => { - store: Pick + store: Pick< + AgentSessionRecordStore, + 'getConversation' | 'replacedRuntime' | 'listConversations' + > logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink @@ -58,8 +61,8 @@ export function createStructuredAgentSessionHostStatusFeed(args: { const feed = new StructuredAgentSessionStatusFeed({ sessions: args.sessions, starting: args.starting, - getRecord: (sessionId) => args.deps().store.getRecord(sessionId), - listRecords: () => args.deps().store.listRecords(), + getConversation: (sessionId) => args.deps().store.getConversation(sessionId), + listConversations: () => args.deps().store.listConversations(), replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId), now: args.now, logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index f495dbdc948..dad5d110788 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -65,7 +65,7 @@ export function createStructuredAgentSessionTabSurface( deps: { store: Pick< AgentSessionRecordStore, - 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord' + 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getConversation' > } }, @@ -78,7 +78,7 @@ export function createStructuredAgentSessionTabSurface( listSessionTabs: (): StructuredAgentSessionTab[] => [ ...listStructuredAgentSessionTabs(sessions), ...[...unopened].flatMap((sessionId) => { - const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId) + const record = sessions.has(sessionId) ? null : host.deps.store.getConversation(sessionId) return record ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 4e194d9cb42..0fde3b27a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -158,7 +158,9 @@ export type StructuredAgentSessionHostDeps = { idleSweep?: { intervalMs?: number; idleMs?: number } startupLimits?: Partial /** Absent means no dispatch ownership. */ - hasOpenDispatch?: (record: AgentSessionRecord) => boolean + hasOpenDispatch?: ( + record: Pick + ) => boolean /** Advisory tab-hidden notice; failures are logged. */ onSessionTabHidden?: (sessionId: string) => void /** Throws while a terminal may still write the record's head conversation; run before a spawn. */ 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 ce3c2535a38..bb7b6f88ba4 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 @@ -168,7 +168,7 @@ export class StructuredAgentSessionHost { loadedSessionIds = (): string[] => [...this.sessions].flatMap(([sessionId, { child }]) => (child ? [sessionId] : [])) hasLoadedProviders = (): boolean => this.loadedSessionIds().length > 0 - sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null + sessionAgent = (sessionId: string) => this.deps.store.getConversation(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 0d9ed7f5048..8a02402edf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -347,7 +347,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => false, - store: { getRecord: () => null, replacedRuntime: () => undefined }, + store: { getConversation: () => null, replacedRuntime: () => undefined }, childWorkOpen: () => false, hasOpenDispatch: () => false, providerHoldsDispatch: () => false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index b9678dff8c0..77e63b23db9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -15,7 +15,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- /** Use only the saved, folder-scoped catalog so startup never waits on a listing. */ async function listedAutoModeSupport( catalog: StructuredAgentSessionHostDeps['modelCatalog'], - record: AgentSessionRecord + record: Pick ): Promise { const listing = await catalog ?.read({ @@ -36,7 +36,7 @@ async function listedAutoModeSupport( /** null for unregistered agents or agents without permission modes. */ export async function structuredAgentSessionLaunchPermission( deps: Pick, - record: AgentSessionRecord + record: Pick ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts index 8be3a993c18..02e15111555 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { continuingStructuredAgentSessionInput, finalizingStructuredAgentSessionInput @@ -57,7 +58,7 @@ export async function settleStructuredAgentSessionLeftovers( const { journal, sessionId } = input const replaced = input.store.replacedRuntime?.(sessionId) const liveFence = structuredAgentSessionLiveFence({ - record, + record: agentSessionConversationView(record), replaced, ...(input.ended ? { ended: input.ended } : {}) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 746146325b1..490c5377555 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -1,3 +1,4 @@ +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' // Validate fingerprints centrally. Prepare ledger replays without admitting writes; first runs // prepare ownership before lease admission, while command-receipt plans use their own admission. @@ -11,7 +12,6 @@ import type { AgentSessionOperationDecision, AgentSessionOperationRow } from '../../../shared/agent-session-operation-ledger' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, @@ -36,8 +36,11 @@ import { agentSessionOperationOutcomeUnknown, resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' -import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' +import type { AgentSessionReplayContext } from './structured-agent-session-turns' +import { + mutationTurnContext, + mutationReplayContext +} from './structured-agent-session-mutation-turn-context' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { StructuredAgentRegistry } from './structured-agent-registry' import { admitCommandReceiptMutation } from './structured-agent-session-command-admission' @@ -69,7 +72,7 @@ export type AgentSessionMutationRequest = { /** Prepare between ledger evaluation and lease admission; return any terminal refusal. */ prepareSession?: ( ledger: Exclude, - record: AgentSessionRecord + record: AgentSessionConversationView ) => Promise publish: (journal: AgentSessionJournal) => void wakeDelivery?: (sessionId: string) => void @@ -78,6 +81,19 @@ export type AgentSessionMutationRequest = { export async function admitAndRunAgentSessionMutation( request: AgentSessionMutationRequest +): Promise> { + try { + return await admitAndRunAgentSessionMutationUnchecked(request) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return refuseAgentSessionMutation(error.refusal) + } + throw error + } +} + +async function admitAndRunAgentSessionMutationUnchecked( + request: AgentSessionMutationRequest ): Promise> { const { plan, envelope } = request const hostFingerprint = computeAgentSessionPayloadFingerprint({ @@ -92,34 +108,37 @@ export async function admitAndRunAgentSessionMutation( if (plan.commandReceipt) { return admitCommandReceiptMutation(request, hostFingerprint, plan.commandReceipt) } + const ledger = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!ledger) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + if (ledger.decision.decision === 'refused') { + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) + } + if (ledger.decision.decision === 'replay') { + const answered = await answerRecordedOperation( + request, + request.prepareSession, + ledger.decision.row, + ledger.record, + hostFingerprint + ) + if (answered !== 'rerun') { + return answered + } + } if (request.prepareSession) { - const ledger = request.store.evaluateMutationOperation({ - callerKey: request.callerKey, - envelope, - hostFingerprint, - now: request.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) - }) - if (!ledger) { - return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) - } - if (ledger.decision.decision === 'refused') { - return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) - } - if (ledger.decision.decision === 'replay') { - const answered = await answerRecordedOperation( - request, - request.prepareSession, - ledger.decision.row, - ledger.record, - hostFingerprint - ) - if (answered !== 'rerun') { - return answered - } - } - const record = request.store.getRecord(envelope.sessionId) ?? ledger.record - const prepared = await request.prepareSession('admit', record) + const record = request.store.requireWritableRecord(envelope.sessionId) + const prepared = await request.prepareSession( + 'admit', + request.store.getConversation(record.sessionId)! + ) if (!prepared.ok) { return prepared } @@ -157,10 +176,12 @@ export async function admitAndRunAgentSessionMutation( return refuseAgentSessionMutation(admission.refusal) } - const fence = record.lease.runtimeFence - const context = mutationTurnContext(request, journal, record) if (admission.decision === 'replay') { - const replayed = replayRecordedOperation(request, context, admission.row) + const replayed = replayRecordedOperation( + request, + mutationReplayContext(request, journal, record), + admission.row + ) if (replayed !== 'rerun') { return replayed } @@ -169,7 +190,7 @@ export async function admitAndRunAgentSessionMutation( envelope, hostFingerprint, ledger: { decision: 'admit', row: admission.row }, - lease: record.lease, + lease: request.store.requireWritableRecord(envelope.sessionId).lease, ...(plan.conversationWrite ? { conversationWrite: true } : {}) }) if (rerun.decision === 'refused') { @@ -177,6 +198,9 @@ export async function admitAndRunAgentSessionMutation( } } + const current = request.store.requireWritableRecord(envelope.sessionId) + const fence = current.lease.runtimeFence + const context = mutationTurnContext(request, journal, current) const outcome = await runSettledAgentSessionMutation({ store: request.store, // Global replay may cross callers; settle the original caller’s durable row. @@ -193,9 +217,9 @@ export async function admitAndRunAgentSessionMutation( /** Replay recorded failures directly; otherwise prepare/read without admission, rerunning only absent effects. */ async function answerRecordedOperation( request: AgentSessionMutationRequest, - prepareSession: NonNullable['prepareSession']>, + prepareSession: AgentSessionMutationRequest['prepareSession'], row: AgentSessionOperationRow, - record: AgentSessionRecord, + record: AgentSessionConversationView, hostFingerprint: string ): Promise | 'rerun'> { const { plan, envelope } = request @@ -209,8 +233,8 @@ async function answerRecordedOperation( return refuseAgentSessionMutation(replay.refusal) } } - const prepared = await prepareSession('replay', record) - if (!prepared.ok) { + const prepared = await prepareSession?.('replay', record) + if (prepared && !prepared.ok) { return prepared } const journal = request.journal() @@ -233,7 +257,7 @@ async function answerRecordedOperation( if (current.decision.decision === 'admit') { return 'rerun' } - const context = mutationTurnContext(request, journal, current.record) + const context = mutationReplayContext(request, journal, current.record) return replayRecordedOperation(request, context, current.decision.row) } @@ -249,7 +273,7 @@ export function replayRecordedOperation( > envelope: AgentSessionMutationEnvelope }, - context: AgentSessionTurnContext, + context: AgentSessionReplayContext, row: AgentSessionOperationRow ): AgentSessionMutationResult | 'rerun' { const replay = resolveAgentSessionReplayOutcome({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts index 5889181104d..3039df2b9ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts @@ -24,13 +24,16 @@ export function agentSessionMutationAdmitsNow( now: request.now(), ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) }) + const selected = request.store.selectWritableRecord(envelope.sessionId) return ( + selected.kind === 'writable' && evaluated !== null && + evaluated.decision.decision === 'admit' && admitAgentSessionMutation({ envelope, hostFingerprint, ledger: evaluated.decision, - lease: evaluated.record.lease, + lease: selected.record.lease, ...(plan.conversationWrite ? { conversationWrite: true } : {}) }).decision === 'admit' ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 78a2a4fb5e6..73b06152ebd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -33,6 +33,7 @@ import { performPrompt, performSend, type AgentSessionTurnContext, + type AgentSessionReplayContext, type TurnOutcome } from './structured-agent-session-turns' import { @@ -58,11 +59,11 @@ export type MutationPlan = { run: (ctx: AgentSessionTurnContext) => Promise> /** receipt is the accepted command receipt result, when declared. */ replay: ( - ctx: AgentSessionTurnContext, + ctx: AgentSessionReplayContext, outcome: AgentSessionOperationOutcome, receipt?: CommandReceiptResult ) => TValue | null - rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean + rerunWhenReplayMissing?: (ctx: AgentSessionReplayContext) => boolean recoverUnknownFromDurableState?: boolean } & ( | { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts index b6ea10d1790..373cb5a5733 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -1,7 +1,11 @@ +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { + AgentSessionReplayContext, + AgentSessionTurnContext +} from './structured-agent-session-turns' export function mutationTurnContext( request: Omit, 'plan'>, @@ -35,3 +39,19 @@ export function mutationTurnContext( now: () => request.now() } } + +export function mutationReplayContext( + request: Omit, 'plan'>, + journal: AgentSessionJournal, + record: AgentSessionConversationView +): AgentSessionReplayContext { + return { + sessionId: record.sessionId, + journal, + fence: record.currentFence, + agent: record.provider, + ...(request.currentWork ? { currentWork: request.currentWork } : {}), + ...(record.options ? { persistedOptions: record.options } : {}), + now: request.now + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts index 75584bc691f..82cfdf3063b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-never-sent.ts @@ -4,7 +4,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store /** Prove local unsent history; adoption, prior turns, rewind or a replacement journal defeat proof. */ export function structuredAgentSessionNeverSent( - record: AgentSessionRecord | null, + record: Pick | null, journal: Pick ): boolean { if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 4cf8446f76e..e81ecad5ba6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -1,3 +1,4 @@ +import { foundAgentSessionRecord } from '../../runtime/agent-session-record-founding' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' @@ -16,7 +17,26 @@ describe('an operation whose agent start throws', () => { const log = recordingStructuredAgentSessionLogger() const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') const context = { - deps: { logger: log.logger }, + deps: { + logger: log.logger, + store: { + requireWritableRecord: () => + foundAgentSessionRecord( + { + sessionId: SESSION, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/test/codex' }, + location: { + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree', + executionHostId: 'local', + wslDistro: null + } + }, + { claimKeyId: 'test-key', now: 0 } + ) + } + }, sessions: new Map(), publishStatus: vi.fn(), serialize: (_sessionId: string, task: () => Promise) => task(), @@ -30,7 +50,7 @@ describe('an operation whose agent start throws', () => { } const refused = await startStructuredAgentSessionAgentForOperation( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any member past its registration is read. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the store returns a complete record; the start throws at `reconcileLeases` before the remaining context is read. context as unknown as StructuredAgentSessionAttachContext, SESSION ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 7eda2eddc1f..b13e0628897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { structuredAgentSessionCapabilityView } from './structured-agent-session-option-models' import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' import { acquired, fakeClaude } from '../../claude/claude-structured-session-test-support' @@ -13,8 +15,10 @@ import type { StructuredAgentSessionLiveOptions } from './structured-agent-sessi import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' -import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import { @@ -39,10 +43,10 @@ import { const SESSION = 'session-1' -type RestingFacts = Pick, 'options' | 'provider'> & { - providerHandleChain?: Pick[] - rewind?: Pick -} +type RestingFacts = Pick< + Partial, + 'options' | 'provider' | 'providerHandleChain' | 'rewind' +> function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = false) { return { @@ -57,15 +61,14 @@ function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = fa } function restingRecord(): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. - return { + return agentSessionRecordFixture({ + sessionId: SESSION, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, providerHandleChain: [], location: { executionHostId: 'local', wslDistro: null }, - lease: { claimStatus: 'released', ownerProcess: null, runtimeFence: 1 }, options: {} - } as unknown as AgentSessionRecord + }) } describe('options at rest', () => { @@ -93,7 +96,11 @@ describe('options at rest', () => { deps: { adapter: {}, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, modelCatalog: { read } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -142,7 +149,11 @@ describe('options at rest', () => { }) }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, modelCatalog: { read: async () => ({ origin: 'probe', models: [listed], fetchedAt: 1 }) } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -166,6 +177,10 @@ describe('options at rest', () => { const probe = vi.fn(() => new Promise(() => {})) const modelCatalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), + getConversation: () => { + const saved = record + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: () => record, drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), @@ -184,7 +199,11 @@ describe('options at rest', () => { deps: { adapter: { readOptions: async () => withLiveCatalogListing(reader.read()) }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, modelCatalog }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -230,7 +249,15 @@ describe('options at rest', () => { }) describe('a chat at rest, read by the host', () => { - const created: RestingFacts['providerHandleChain'] = [{ origin: 'created' }] + const created = [ + { + origin: 'created', + linkId: 'link-1', + mintedAtFence: 1, + observedAt: 1, + handle: codexProviderHandle('provider-session-1') + } + ] satisfies AgentSessionRecord['providerHandleChain'] const turn: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'completed' } const fast = [{ value: 'priority', label: 'Fast' }] const flex = [...fast, { value: 'flex', label: 'Flex' }] @@ -263,7 +290,11 @@ describe('a chat at rest, read by the host', () => { deps: { adapter: liveOptions ? { readOptions: async () => liveOptions } : {}, agents: claudeAndCodexAgents(), - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, modelCatalog: catalog(models, verified) }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -275,11 +306,31 @@ describe('a chat at rest, read by the host', () => { it.each<[string, RestingFacts, AgentJournalItemBody[], boolean, string[]]>([ ['started here names its default as its own', {}, [], false, []], - ['taken from history', { providerHandleChain: [{ origin: 'adopted' }] }, [], true, ['model']], - // Context clear hides the newest turn, not its historical existence. + [ + 'taken from history', + { providerHandleChain: [{ ...created[0]!, origin: 'adopted' }] }, + [], + true, + ['model'] + ], + // A context clear hides the turn from the newest-turn read, not from history. ['with a turn before a context clear', {}, [turn], true, ['model']], - ['after a rewind', { rewind: { phase: 'completed' } }, [], true, ['model']], - // A replacement journal cannot prove the agent never ran. + [ + 'after a rewind', + { + rewind: { + phase: 'completed', + operationId: 'rewind-1', + callerKey: 'caller-1', + itemId: 'item-1', + expectedEpoch: 'epoch-1' + } + }, + [], + true, + ['model'] + ], + // A journal founded afresh proves nothing for a chat whose agent already ran. ['whose journal went missing', { providerHandleChain: created }, [], false, ['model']] ])('%s', async (_name, facts, items, wroteBeforeOpen, defaulted) => { const read = await readAtRest(facts, items, wroteBeforeOpen) @@ -370,7 +421,16 @@ describe('a chat at rest, read by the host', () => { ) expect(structuredAgentSessionSendSelection(state)).toEqual(model ? { model } : undefined) const resumed = await readAtRest( - { provider: 'claude', providerHandleChain: [{ origin: 'adopted' }] }, + { + provider: 'claude', + providerHandleChain: [ + { + ...created[0]!, + origin: 'adopted', + handle: claudeProviderHandle('provider-session-1', null) + } + ] + }, [], true, installed, @@ -479,7 +539,12 @@ describe('a chat at rest, read by the host', () => { ) { let record: AgentSessionRecord = { ...restingRecord(), options, provider } const store = { + getConversation: () => { + const saved = record + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: () => record, + requireWritableRecord: () => record, replaceSessionOptions: vi.fn(), reviseSessionOptions: async ( revise: (held: AgentSessionRecord, args: A) => AgentSessionRecord, @@ -565,7 +630,11 @@ describe('a chat at rest, read by the host', () => { deps: { adapter: { prepareReadOptions }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => record } + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), @@ -608,7 +677,11 @@ describe('a chat at rest, read by the host', () => { } }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => restingRecord() } + store: { + getRecord: () => restingRecord(), + getConversation: () => agentSessionConversationView(restingRecord()), + requireWritableRecord: () => restingRecord() + } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), @@ -641,7 +714,11 @@ describe('a chat at rest, read by the host', () => { deps: { adapter: { prepareReadOptions }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => restingRecord() } + store: { + getRecord: () => restingRecord(), + getConversation: () => agentSessionConversationView(restingRecord()), + requireWritableRecord: () => restingRecord() + } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), 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 fd431550ab0..2f305823882 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 @@ -41,7 +41,7 @@ async function readStructuredAgentSessionOptionsAtRest( sessionId: string, reader: StructuredAgentSessionOptionsReader ): Promise { - const record = deps.store.getRecord(sessionId) + const record = deps.store.getConversation(sessionId) if (!record) { throw new Error('agent_session_identity_required') } @@ -147,6 +147,7 @@ export async function readStructuredAgentSessionOptions( if (!child || child.phase === 'starting') { return { kind: 'rest' as const } } + store.requireWritableRecord(sessionId) const prepared = adapter.prepareReadOptions?.({ sessionId, fence: child.fence }) if (prepared) { return { kind: 'prepared' as const, child, prepared } @@ -167,6 +168,7 @@ export async function readStructuredAgentSessionOptions( ? await started.prepared.then((apply) => context.serialize(sessionId, async () => { const child = (await context.openConversation(sessionId))?.child + store.requireWritableRecord(sessionId) return child === started.child ? apply() : null }) ) @@ -179,13 +181,13 @@ export async function readStructuredAgentSessionOptions( const { catalogListing, ...answer } = live options = answer // Fill missing live listings from saved catalog state; absent records keep the child’s answer. - if (!catalogListing && !answer.listedModelIds && store.getRecord(sessionId)) { + if (!catalogListing && !answer.listedModelIds && store.getConversation(sessionId)) { const { listedModelIds } = await atRest() options = listedModelIds ? { ...answer, listedModelIds } : answer } if ( catalogListing && - !independentAgentModelCatalog(store.getRecord(sessionId)?.provider ?? '') + !independentAgentModelCatalog(store.getConversation(sessionId)?.provider ?? '') ) { // Check prior membership before saving: a launch-added row cannot verify itself. const listedModelIds = context.deps.modelCatalog @@ -200,7 +202,7 @@ export async function readStructuredAgentSessionOptions( } // Earlier reads may have closed/reopened the handle. const session = await context.conversation(sessionId) - const record = store.getRecord(sessionId) + const record = store.getConversation(sessionId) const phase = record?.rewind?.phase const agent = session.params.provider const capabilities = agents.capabilities(agent) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index 853ff44b8c1..e1c2a0ad3b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -6,11 +6,11 @@ export function structuredAgentSessionOwnerStatus( deps: Pick, sessionId: string ): AgentSessionHandoffStatus { - const record = deps.store.getRecord(sessionId) + const record = deps.store.getConversation(sessionId) if (!record) { throw new Error('agent_session_identity_required') } - const { handoffStage: stage, handoffOperationId: operationId } = record.lease + const { handoffStage: stage, handoffOperationId: operationId } = record.ownerObservation return { owner: 'native', direction: stage ? 'to-native' : null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index a1ca93faecd..e48c35de4f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -126,6 +126,7 @@ describe('a chat permission mode', () => { } const catalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), + getConversation: (id) => rig.store.getConversation(id) ?? undefined, getRecord: (id) => rig.store.getRecord(id) ?? undefined, drivesRecord: () => true, recordWorkspacePath: async () => rig.root, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index daa8ec2649c..3759c14b7fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -17,10 +17,10 @@ type ChildBearer = Pick, + store: Pick, sessionId: string ): number { - return store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + return store.getConversation(sessionId)?.currentFence ?? 0 } /** Index only successful attaches; failures have no child to remove. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 36bc90f85c6..d4b53cfdec3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -63,12 +63,12 @@ export function hostCanStartRecord( /** Include saved chats whose agents are no longer registered when bounding client audiences. */ export function knownAgentIds(deps: { agents: Pick - store: { listRecords: () => readonly Pick[] } + store: { listConversations: () => readonly Pick[] } }): readonly string[] { return [ ...new Set([ ...deps.agents.definitions().map(({ agent }) => agent), - ...deps.store.listRecords().map(({ provider }) => provider) + ...deps.store.listConversations().map(({ provider }) => provider) ]) ] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 8af50f2f9ad..fcc0e23af4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { activeProviderContext } from '../../../shared/agent-session-provider-context' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { createQueuedMessageTestRig, @@ -277,3 +281,47 @@ it("refuses /compact reusing another caller's id for a different conversation", expect((await rig.host.journalSnapshot(otherSessionId)).submissions).toHaveLength(0) expect(rig.compact).toHaveBeenCalledOnce() }) + +it('replays a completed compact after reopening an unreadable writer without compacting again', async () => { + const fields = { command: 'compact' as const } + const params = { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + } + expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + rig.finishCompact() + const { command } = rig.compact.mock.calls[0]![0] + await eventually(async () => + expect( + (await rig.host.journalSnapshot(SESSION)).items.find( + ({ itemId }) => itemId === agentJournalItemKey(command.identity) + )?.body + ).toMatchObject({ ...command.running, state: 'completed', outcome: 'success' }) + ) + expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ + ok: true, + replayed: true + }) + expect(rig.compact).toHaveBeenCalledOnce() + rig.host.stopDelivery() + await rig.host.flushAllStreamedEvents() + openTestJournalHostDatabase(rig.root) + .db.prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run(SESSION) + const store = await openTestAgentSessionRecordStore(rig.root) + const restored = new StructuredAgentSessionHost({ ...rig.host.deps, store }) + try { + expect(store.getRecord(SESSION)).toBeNull() + expect(await restored.conversationCommand(CALLER, params)).toMatchObject({ + ok: true, + replayed: true + }) + expect(rig.compact).toHaveBeenCalledOnce() + } finally { + restored.stopDelivery() + await restored.flushAllStreamedEvents() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 20f453a6ad3..7b5f24772e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -52,7 +52,7 @@ export type StructuredQueueGateInput = { } export function structuredQueueHold(input: StructuredQueueGateInput): StructuredQueueHold | null { - if (structuredAgentSessionSendBlock(input.record)) { + if (!input.record || structuredAgentSessionSendBlock(input.record)) { return 'blocked' } // Prompts outrank working because Send-now cannot override them. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index edc3f254042..ce648d337aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -6,6 +6,7 @@ import { type AgentSessionQueuedMessagePausedReason, type AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { queuePauseLiftOnItsWay, @@ -31,7 +32,9 @@ export type QueuePublication = { export type QueueSendGate = (journal: AgentSessionJournal) => StructuredQueueGateInput export function structuredQueueSendGate( - host: StructuredAgentSessionCurrentWorkHost, + host: StructuredAgentSessionCurrentWorkHost & { + store: Pick + }, sessionId: string ): QueueSendGate { return (journal) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index 92c801e6469..3e81f36abda 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -3,7 +3,9 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store /** Refused conversions replay as returned cards, avoiding duplicate Retry rows; null means never queued. */ export function queuedSendAnswer( - journal: Pick, + journal: Pick & { + queuedMessages: Pick + }, clientMessageId: string ): AgentSessionSendResult | null { const draft = journal.queuedMessages.get(clientMessageId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index a8ee315e4b2..9a79039e4f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -1,9 +1,14 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' 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 type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionConversationOpenDeps } from './structured-agent-session-conversation-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { closeTestJournalHostDatabases, @@ -17,9 +22,10 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand const SESSION_ID = 'codex_read_restore_fixture' const WORKSPACE_ID = 'repo-1::/tmp/workspace' -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal record the read path only reads; its fields match the record shape. -const RECORD = { - schemaVersion: 2, +const RECORD: AgentSessionRecord = { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: SESSION_ID, runtimeFence: 1 }) + ), sessionId: SESSION_ID, location: { executionHostId: 'local', @@ -28,24 +34,23 @@ const RECORD = { workspaceKind: 'git-worktree' }, provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, providerHandleChain: [ { - linkId: 'codex-1-thread-1', + linkId: 'link-1', handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 1, observedAt: 1 } - ], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - createdAt: 1, - updatedAt: 2, - lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 } -} as unknown as AgentSessionRecord + ] +} -const store = { - getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null) -} as unknown as AgentSessionRecordStore +const store: StructuredAgentSessionConversationOpenDeps['store'] = { + getConversation: (sessionId: string) => + sessionId === SESSION_ID ? agentSessionConversationView(RECORD) : null, + replacedRuntime: () => undefined +} let journalRoot: string const openDeps = () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 6d8f00f5ed4..30344ffb1ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -10,7 +10,7 @@ export async function restoreStructuredAgentSessionRead( deps: StructuredAgentSessionConversationOpenDeps, sessionId: string ): Promise { - const record = deps.store.getRecord(sessionId) + const record = deps.store.getConversation(sessionId) if (!record) { return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 95c5a8955bc..62557e7b18f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -2,7 +2,11 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { closeTestJournalHostDatabases, openTestJournalHostDatabase @@ -29,14 +33,15 @@ describe('StructuredAgentSessionReadableRestorer', () => { }) it('passes targeted records to the restore pool in visible-first order', async () => { - const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map( - (sessionId) => ({ sessionId }) as AgentSessionRecord + const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map((sessionId) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) ) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: { store: { getRecord: () => null, - listRecords: () => records, + getConversation: () => null, + listConversations: () => records.map(agentSessionConversationView), replacedRuntime: () => undefined }, journalDatabase: openTestJournalHostDatabase(stateDirectory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index dca41522027..6e27e961f08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -19,7 +19,7 @@ export class StructuredAgentSessionReadableRestorer { ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) : null const records = this.input.openDeps.store - .listRecords() + .listConversations() .filter((record) => !targetOrder || targetOrder.has(record.sessionId)) if (targetOrder) { records.sort( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-plan.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-plan.ts new file mode 100644 index 00000000000..707151ec70b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-plan.ts @@ -0,0 +1,9 @@ +import type { MutationPlan } from './structured-agent-session-mutation-plans' + +export type ReadyOperationPlan = Pick< + MutationPlan, + 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' +> & + Partial< + Pick, 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState'> + > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 4f5c3e39d72..9e80a1934ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -1,3 +1,4 @@ +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict, computeAgentSessionPayloadFingerprint @@ -16,8 +17,8 @@ import { replayRecordedOperation, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' -import type { MutationPlan } from './structured-agent-session-mutation-plans' -import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' +import type { ReadyOperationPlan } from './structured-agent-session-ready-operation-plan' +import { mutationReplayContext } from './structured-agent-session-mutation-turn-context' import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness' @@ -39,13 +40,6 @@ export { type ReadyOperationSelection } from './structured-agent-session-operation-child' -type ReadyOperationPlan = Pick< - MutationPlan, - 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' -> & - Partial< - Pick, 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState'> - > type QueuedReadyOperation = | { result: AgentSessionMutationResult } | { @@ -76,9 +70,7 @@ export async function runWithReadyStructuredAgentSessionOperation( plan: ReadyOperationPlan, run: (selection: ReadyOperationSelection) => Promise>, options: { - beforeAgent?: ( - record: AgentSessionRecord | null - ) => AgentSessionMutationSessionPreparation | null + beforeAgent?: (row: AgentSessionRecord | null) => AgentSessionMutationSessionPreparation | null goal?: boolean needsAgent?: (record: AgentSessionRecord | null) => boolean } = {} @@ -171,7 +163,7 @@ export async function runWithReadyStructuredAgentSessionOperation( if (current?.decision.decision !== 'replay') { return { result: await run({ ok: true, child: null, replay: true }) } } - const turn = mutationTurnContext(request, journal, current.record) + const turn = mutationReplayContext(request, journal, current.record) const answer = replayRecordedOperation( { envelope, plan: { ...plan, replay: plan.replay } }, turn, @@ -181,26 +173,33 @@ export async function runWithReadyStructuredAgentSessionOperation( return { result: await run({ ok: true, child: null, replay: true }) } } } else if (!evaluated || evaluated.decision.decision !== 'admit') { - return { - result: await run({ - ok: true, - child: null - }) - } + return { result: await run({ ok: true, child: null }) } } } + try { + context.deps.store.requireWritableRecord(envelope.sessionId) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { result: { ok: false as const, refusal: error.refusal } } + } + throw error + } const select = async () => { const opened = await openForWrite(context, envelope)() if (!opened.ok) { return opened } - const checked = options.beforeAgent?.(context.deps.store.getRecord(envelope.sessionId)) + const current = context.deps.store.requireWritableRecord(envelope.sessionId) + const checked = options.beforeAgent?.(current) if (checked && !checked.ok) { return checked } return selectStructuredAgentSessionOperationChild(context, envelope.sessionId) } const child = await select().catch((error: unknown) => { + if (isAgentSessionRefusalError(error)) { + return { ok: false as const, refusal: error.refusal } + } context.deps.logger.warn('selecting an agent for an operation failed', { scope: 'operation-agent-start', sessionId: envelope.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 5007ad6e31f..9f40521b66f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -73,7 +73,7 @@ async function sessionWithRunningTurn() { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions, - getRecord: () => null, + getConversation: () => null, now: () => clock, statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts index b2d235fff34..abf3851e2b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { @@ -230,9 +231,13 @@ describe('a server replaced mid-turn on the same execution host', () => { deps: Omit, record: AgentSessionRecord ) => { - void openStructuredAgentSessionConversationJournal(deps, record, { acquisition: true }) + void openStructuredAgentSessionConversationJournal( + deps, + agentSessionConversationView(record), + { acquisition: true } + ) // @ts-expect-error a reader open settles, so without the store its turns would stay unverifiable - void openStructuredAgentSessionConversationJournal(deps, record) + void openStructuredAgentSessionConversationJournal(deps, agentSessionConversationView(record)) } expect(open).toBeTypeOf('function') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 509755c50a1..73c0d4ec979 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -1,3 +1,6 @@ +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { @@ -47,7 +50,7 @@ it('files nothing for a chat the user moved on in before its attempt, and spends }) it('says so in the chat when the agent cannot start for the continuation', async () => { - const { host, acquire } = await interruptedRestart() + const { host, acquire, root } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) @@ -63,6 +66,21 @@ it('says so in the chat when the agent cannot start for the continuation', async }) // The failure note replaces the derived cut notice to avoid duplicate explanations. expect(await readerNotes(host)).toEqual(await statusNotes(host)) + await host.flushAllStreamedEvents() + openTestJournalHostDatabase(root) + .db.prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run(SESSION) + const store = await openTestAgentSessionRecordStore(root) + const restarted = new StructuredAgentSessionHost({ ...host.deps, store }) + const starts = acquire.mock.calls.length + expect(await restarted.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'refused', retryable: false } + ]) + expect(await restarted.restartResume.list()).toEqual([]) + expect(acquire).toHaveBeenCalledTimes(starts) + await restarted.flushAllStreamedEvents() }) // Successful continuation notes follow their own message; preserve the earlier cut notice. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index f85fb6afa68..4248ff1eecf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -5,7 +5,7 @@ import type { AgentSessionResumeFailureInput, AgentSessionResumeFailureRecord } from '../../runtime/agent-session-recovery-capsule-entries' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionResumeFailureOutcome, AgentSessionResumeMarker @@ -32,6 +32,7 @@ type FailureCapsule = Pick< export type StructuredAgentSessionRestartFailureLedger = { read: () => Promise + /** Saved failure rows a surface can show; retry eligibility is checked separately. */ list: () => Promise /** File failures or complete reservations, then reopen only rows still owned by this action. */ settle: ( @@ -62,7 +63,7 @@ export function continuationFailureOutcome( export function createStructuredAgentSessionRestartFailureLedger(deps: { capsule?: FailureCapsule - getRecord: (sessionId: string) => AgentSessionRecord | null + getRecord: (sessionId: string) => AgentSessionConversationView | null retryable: (marker: AgentSessionResumeMarker) => boolean /** Preserve newer-chat failures for compatible builds, hidden here. */ savedByNewerOrca: (sessionId: string) => boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 87a7a7c7121..9d206bbe087 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -1,8 +1,12 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { closeTestJournalHostDatabases, @@ -27,7 +31,16 @@ afterAll(() => { }) const NO_OPEN_DEPS = { - store: { getRecord: () => null, listRecords: () => [], replacedRuntime: () => undefined }, + store: { + getRecord: (sessionId: string) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), + getConversation: (sessionId: string) => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + ), + listConversations: () => [], + replacedRuntime: () => undefined + }, journalDatabase: openTestJournalHostDatabase(stateDirectory), logger: recordingStructuredAgentSessionLogger().logger } @@ -53,10 +66,7 @@ describe('restart journal restoration', () => { } } }) - const records = Array.from( - { length: 12 }, - (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord - ) + const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) const restoration = restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, @@ -94,11 +104,7 @@ describe('restart journal restoration', () => { turnsSeen.add(turns) return null }) - const records = Array.from( - { length: 12 }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord - ) + const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) await restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, @@ -146,8 +152,7 @@ describe('restart journal restoration', () => { }) await restoreStructuredAgentSessionsOnRestart({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - records: [{ sessionId: 'session-1' } as AgentSessionRecord], + records: [{ sessionId: 'session-1' }], openDeps: NO_OPEN_DEPS, reconcileAll: async () => true, reconcile: async () => true, @@ -166,11 +171,7 @@ describe('restart journal restoration', () => { }) describe('once lease bookkeeping fails in a pass', () => { - const records = Array.from( - { length: 8 }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord - ) + const records = Array.from({ length: 8 }, (_, index) => ({ sessionId: `session-${index}` })) const restore = ( bookkeeping: Pick< Parameters[0], @@ -244,11 +245,7 @@ describe('restart journal restoration', () => { }) const readable: string[] = [] const log = recordingStructuredAgentSessionLogger() - const records = Array.from( - { length: 12 }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord - ) + const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` })) await expect( restoreStructuredAgentSessionsOnRestart({ @@ -279,8 +276,7 @@ describe('restart journal restoration', () => { }) await restoreStructuredAgentSessionsOnRestart({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. - records: [{ sessionId: 'session-1' } as AgentSessionRecord], + records: [{ sessionId: 'session-1' }], openDeps: NO_OPEN_DEPS, reconcileAll: async () => true, reconcile: async () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index b16fa7eaef0..7071a3d06da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -14,7 +14,7 @@ const JOURNAL_RESTORE_CONCURRENCY = 4 export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { - store: Pick + store: Pick } // Reader bookkeeping cannot grant a writer and must not block reads. reconcileAll: () => Promise @@ -57,7 +57,9 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( } export async function restoreStructuredAgentSessionsOnRestart( - input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } + input: StructuredAgentSessionReadRestoreDeps & { + records: Pick[] + } ): Promise { if (input.records.length === 0) { return @@ -67,6 +69,7 @@ export async function restoreStructuredAgentSessionsOnRestart( const settleLeases = async (sessionId: string): Promise => { if ( settled && + input.openDeps.store.getRecord(sessionId) && !((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId))) ) { settled = false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 0db854a89d2..a28fbb498ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -65,7 +65,7 @@ export function createStructuredAgentSessionRestartResume( const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, - getRecord: deps.store.getRecord, + getRecord: deps.store.getConversation, childWork: surfaces.readChildWork, childWorkOpen: surfaces.childWorkOpen, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), @@ -91,7 +91,7 @@ export function createStructuredAgentSessionRestartResume( }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - getRecord: deps.store.getRecord, + getRecord: deps.store.getConversation, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, savedByNewerOrca: newerOrca.has, reveal: (markers) => revealMarkers(markers), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index b1a1fc70969..b373b54d4f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -21,7 +21,12 @@ export type StructuredAgentSessionRestartWitnesses = { export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> - getRecord: (sessionId: string) => AgentSessionRecord | null + getRecord: ( + sessionId: string + ) => Pick< + AgentSessionRecord, + 'provider' | 'providerHandleChain' | 'providerContextBoundary' + > | null childWork: WorkingAtStopInput['childWork'] childWorkOpen: WorkingAtStopInput['childWorkOpen'] capsule?: Pick diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 806a39ab89a..634d6ef7e41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -43,7 +44,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( - { store: { getRecord: () => stored } }, + { store: { getConversation: () => agentSessionConversationView(stored) } }, 'session-answered', open ) @@ -60,7 +61,11 @@ describe('the host answer a client acts on', () => { it('refuses a session this host holds no record for, opening nothing', async () => { const open = vi.fn(async () => undefined) await expect( - revealStructuredAgentSession({ store: { getRecord: () => null } }, 'session-absent', open) + revealStructuredAgentSession( + { store: { getConversation: () => null } }, + 'session-absent', + open + ) ).rejects.toThrow('agent_session_identity_required') expect(open).not.toHaveBeenCalled() }) @@ -71,7 +76,9 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } + store: { + getConversation: () => agentSessionConversationView(record('codex', 'workspace-1')) + } }, 'session-answered', open @@ -84,7 +91,9 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } + store: { + getConversation: () => agentSessionConversationView(record('codex', 'workspace-1')) + } }, 'session-answered', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 8d61b0ed11f..98346eb6d20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -15,11 +15,11 @@ import type { } from './structured-agent-session-host-types' export async function revealStructuredAgentSession( - deps: { store: Pick }, + deps: { store: Pick }, sessionId: string, openConversation: (sessionId: string) => Promise ): Promise { - const record = deps.store.getRecord(sessionId) + const record = deps.store.getConversation(sessionId) if (!record) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts index 67a8ee7cdec..c556808246c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts @@ -1,9 +1,16 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { expect, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' type RewindReplayFixture = { host: StructuredAgentSessionHost @@ -66,3 +73,43 @@ export async function expectRetainedPrefixRewindReplay({ expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) } + +export async function expectLargeRetainedPrefixRewind({ + host, + store, + rewind, + sink, + params +}: Pick & { + sink: () => StructuredAgentSessionEventSink +}) { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const events = sink() + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + for (let turn = 0; turn < 300; turn++) { + events.appendItem(message(`kept-${turn}`), hostTestMessage('x'.repeat(12_000)), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + for (const turnId of ['drop', 'tip']) { + events.appendItem(message(turnId), hostTestMessage(turnId), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + await host.flushStreamedEvents(HOST_TEST_SESSION) + + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) + expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) + const { items } = await host.journalSnapshot(HOST_TEST_SESSION) + expect(items.map(({ itemId }) => itemId)).toEqual( + Array.from({ length: 300 }, (_, turn) => agentJournalItemKey(message(`kept-${turn}`))) + ) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'completed' }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 1f28343e88f..85759863ab3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,4 +1,5 @@ import { + expectLargeRetainedPrefixRewind, expectSupersededRewindReplay, expectRetainedPrefixRewindReplay } from './structured-agent-session-rewind-replay.test-fixture' @@ -207,6 +208,27 @@ async function params(itemId: string, epoch?: string) { const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 } describe('host rewind', () => { + it('answers a saved rewind after floor damage without another provider rewind', async () => { + const request = await params(await seed()) + expect(await host.rewind(caller, request)).toMatchObject({ ok: true }) + await host.flushAllStreamedEvents() + openTestJournalHostDatabase(directory) + .db.prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run(HOST_TEST_SESSION) + const reopened = await openTestAgentSessionRecordStore(directory) + const restored = new StructuredAgentSessionHost({ ...host.deps, store: reopened }) + const effects = rewind.mock.calls.length + expect(await restored.rewind(caller, request)).toMatchObject({ + ok: true, + replayed: true, + fence: reopened.getConversation(HOST_TEST_SESSION)?.currentFence + }) + expect(rewind).toHaveBeenCalledTimes(effects) + await restored.flushAllStreamedEvents() + }) + it('replays the original ordinary rewind after another rewind replaces its record', () => expectSupersededRewindReplay({ host, store, rewind, recoverRewind, seed, params })) @@ -331,36 +353,8 @@ describe('host rewind', () => { expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) expect(rewind).toHaveBeenCalledTimes(1) }) - it('rewinds a chat whose kept part is far past the old 2 MB limit', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) - const message = (turnId: string) => ({ - provider: 'codex' as const, - threadId: HOST_TEST_THREAD, - turnId, - ordinal: 0 - }) - for (let turn = 0; turn < 300; turn++) { - sink.appendItem(message(`kept-${turn}`), hostTestMessage('x'.repeat(12_000)), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - for (const turnId of ['drop', 'tip']) { - sink.appendItem(message(turnId), hostTestMessage(turnId), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await host.flushStreamedEvents(HOST_TEST_SESSION) - - expect( - await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) - ).toMatchObject({ ok: true }) - expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) - const { items } = await host.journalSnapshot(HOST_TEST_SESSION) - expect(items.map(({ itemId }) => itemId)).toEqual( - Array.from({ length: 300 }, (_, turn) => agentJournalItemKey(message(`kept-${turn}`))) - ) - expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'completed' }) - }) + it('rewinds a chat whose kept part is far past the old 2 MB limit', () => + expectLargeRetainedPrefixRewind({ host, store, rewind, sink: () => sink, params })) it('replaces the epoch with the retained prefix and replays without another provider call', () => expectRetainedPrefixRewindReplay({ host, rewind, seed, params })) it('keeps when each kept message was first seen, not when the rewind re-read it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index b0a5093e6b7..e3b1cf6e409 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -41,8 +41,8 @@ export async function rewindStructuredAgentSession( } = context const fields = { itemId: params.itemId, expectedEpoch: params.expectedEpoch } const operation = { method: 'agentSession.rewind', fields } - // Forks need only conversation access; native rewinds require the provider. - const agent = store.getRecord(sessionId)?.provider + // A fork can be saved without a running agent; other rewinds require the provider itself. + const agent = store.getConversation(sessionId)?.provider const rewindsByFork = agent !== undefined && context.deps.agents.registration(agent)?.adapter.forkForRewind !== undefined @@ -82,14 +82,14 @@ export async function rewindStructuredAgentSession( return outcome.rewind } return completedStructuredRewind( - store.getRecord(sessionId)?.rewind, + store.getConversation(sessionId)?.rewind, caller.callerKey, clientOperationId ) }, run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) - const record = store.getRecord(sessionId)! + const record = store.requireWritableRecord(sessionId) const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent) if (!support?.supported) { return rewindRefusal(support?.reason ?? 'unsupported') @@ -147,6 +147,7 @@ export async function rewindStructuredAgentSession( } await persistRewindRecord(store, sessionId, ctx.fence, prepared) ctx.publish() + store.requireWritableRecord(sessionId) const provider = 'beforeTurnId' in cut ? await ctx.adapter.rewind!({ @@ -155,7 +156,7 @@ export async function rewindStructuredAgentSession( beforeTurnId: cut.beforeTurnId }) : await stopForFork(context, sessionId) - const fence = store.getRecord(sessionId)!.lease.runtimeFence + const fence = store.requireWritableRecord(sessionId).lease.runtimeFence if (!provider.ok) { const reason = provider.reason if (reason !== 'outcome-unknown') { @@ -208,7 +209,7 @@ export async function rewindStructuredAgentSession( return result.ok ? { ...result, - fence: store.getRecord(sessionId)!.lease.runtimeFence, + fence: store.getConversation(sessionId)?.currentFence ?? result.fence, cursor: context.sessions.get(sessionId)!.journal.cursor() } : result diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index eaf15c20b5c..8b48ed770f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -174,7 +174,7 @@ export function sendPreparation( /** Name the recorded agent and any command a failed start leaves to retry. */ export function structuredAgentSessionFailureWordsContext( - record: AgentSessionRecord | null, + record: Pick | null, journal?: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionFailureWordsContext { const command = journal && structuredAgentSessionAwaitedCommand(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts index c2f0187fd71..56ff19468e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts @@ -1,6 +1,7 @@ // Read eligibility after ownership recovery commits so startup uses the resolver’s current lease. import { + isAgentSessionRefusalError, refuse, type AgentSessionRefusalDetailsByCode, type AgentSessionWireRefusalCode @@ -25,7 +26,15 @@ export function structuredAgentSessionStartParams( params: NonNullable> startupOptions?: Readonly> } { - const record = context.deps.store.getRecord(sessionId) + let record + try { + record = context.deps.store.requireWritableRecord(sessionId) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { ok: false, refusal: error.refusal } + } + throw error + } if (!record) { return refusedStructuredAgentSessionStart( 'agent_session_identity_required', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 386040a05a6..aefb97cdd6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -42,7 +42,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { return new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), - getRecord: () => null, + getConversation: () => null, now: () => 1_000, statusSink: () => sink }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index b21541506b9..cf6178b79f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -46,7 +46,7 @@ it('publishes the provider child startup phase, not which child it is', async () const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions, - getRecord: () => null, + getConversation: () => null, now: () => 1 }) const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 437bcfb54f6..babcf2f1a6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -78,7 +78,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]), - getRecord: () => null, + getConversation: () => null, now: () => (now += 1), statusSink: () => sink, onChildWorkChanged: (sessionId) => changed.push(sessionId) @@ -355,7 +355,7 @@ describe('structured status feed over the real child-record store', () => { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), - getRecord: () => null, + getConversation: () => null, now: () => 1_000, statusSink: () => sink }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index 0cb4d7ce017..77fbe0250cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -62,7 +62,7 @@ async function openFeed() { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), - getRecord: () => null, + getConversation: () => null, now: () => clock }) const published: AgentSessionStatusSummary[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts index 13c08796921..700001b3a52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,7 +40,8 @@ async function namedFeed() { const sessions = new Map([['session', indexedStatusFeedSession({ journal })]]) const feed = new StructuredAgentSessionStatusFeed({ sessions, - getRecord: (id) => (id === record.sessionId ? record : null), + getConversation: (id) => + id === record.sessionId ? agentSessionConversationView(record) : null, namingFailure: () => failure, now: () => 100, logger: createStructuredAgentSessionLogger() @@ -112,8 +114,8 @@ it('publishes cold names from durable records across a host restart without a st const open = () => new StructuredAgentSessionStatusFeed({ sessions: new Map(), - getRecord: () => record, - listRecords: () => [record], + getConversation: () => agentSessionConversationView(record), + listConversations: () => [agentSessionConversationView(record)], now: () => 100, logger: createStructuredAgentSessionLogger() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts index a40fdc63299..d06b2f7dd6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts @@ -1,4 +1,4 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionStatusEvent, AgentSessionStatusSummary @@ -17,7 +17,10 @@ import { structuredSummaryConversationNaming } from './structured-agent-session- export type StatusFeedSession = { journal: AgentSessionJournal - params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } + params: { + location: AgentSessionConversationView['location'] + provider: AgentSessionConversationView['provider'] + } child?: Pick | null lastEndedChild?: Pick operationalRevision?: number @@ -27,8 +30,8 @@ export type StatusFeedSession = { export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap starting?: (sessionId: string) => boolean - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords?: () => AgentSessionRecord[] + getConversation: (sessionId: string) => AgentSessionConversationView | null + listConversations?: () => AgentSessionConversationView[] /** Absent means no replaced runtime. */ replacedRuntime?: (sessionId: string) => { fence: number } | undefined now: () => number @@ -66,8 +69,9 @@ export function structuredAgentSessionNamingSnapshot( deps: StructuredAgentSessionStatusFeedDeps, sessionId?: string ): AgentSessionStatusEvent { - const record = sessionId === undefined ? null : deps.getRecord(sessionId) - const records = sessionId === undefined ? (deps.listRecords?.() ?? []) : record ? [record] : [] + const record = sessionId === undefined ? null : deps.getConversation(sessionId) + const records = + sessionId === undefined ? (deps.listConversations?.() ?? []) : record ? [record] : [] const conversationNames = records.map((entry) => ({ sessionId: entry.sessionId, conversationName: entry.conversationName ?? null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts index 7e98972c55d..3011739bbb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts @@ -72,7 +72,7 @@ async function feedFor(location: AgentSessionExecutionLocation) { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, { journal, params: { location, provider: 'codex' } }]]), - getRecord: (sessionId) => store.getRecord(sessionId), + getConversation: (sessionId) => store.getConversation(sessionId), now: () => NOW }) const events: AgentSessionStatusEvent[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts index c96b76ec8a0..216d77efc74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -47,7 +48,7 @@ describe("the status feed's Stopping", () => { logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), - getRecord: () => agentSessionRecordFixture(), + getConversation: () => agentSessionConversationView(agentSessionRecordFixture()), now: () => 1_000 }) await journal.appendItem( @@ -85,7 +86,7 @@ describe("the status feed's Stopping", () => { logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), - getRecord: () => agentSessionRecordFixture(), + getConversation: () => agentSessionConversationView(agentSessionRecordFixture()), now: () => 1_000 }) const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1' } as const @@ -130,7 +131,7 @@ describe("the status feed's Stopping", () => { logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), - getRecord: () => agentSessionRecordFixture(), + getConversation: () => agentSessionConversationView(agentSessionRecordFixture()), now: () => 1_000 }) await journal.appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 24c021b8906..ae8d4bfe487 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -95,7 +96,9 @@ function feedFor( } } } as unknown as ReadonlyMap>, - getRecord: () => (record ? { ...agentSessionRecordFixture(), ...record } : null), + // A partial record still has a lease: the feed reads the conversation's fence off it. + getConversation: () => + record ? agentSessionConversationView({ ...agentSessionRecordFixture(), ...record }) : null, now: () => (now += 1) }) const events: AgentSessionStatusEvent[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 28a0fdc3a95..200a365fec3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -1,6 +1,5 @@ // Project host journals for all session lists, retaining settled rows across idle eviction and renderer reload. -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusEvent, AgentSessionStatusSummary @@ -65,7 +64,7 @@ export class StructuredAgentSessionStatusFeed { /** Opens with every session this host has projected, live ones re-read, then only changes. */ subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { // Naming completion must be observable even if a held journal cannot be read. - if (this.deps.namingActiveSessionIds || this.deps.listRecords) { + if (this.deps.namingActiveSessionIds || this.deps.listConversations) { this.emit(subscriber, structuredAgentSessionNamingSnapshot(this.deps)) } // Reproject before registering: existing readers get changes; the new reader gets one snapshot. @@ -172,7 +171,9 @@ export class StructuredAgentSessionStatusFeed { ): StructuredAgentSessionJournalProjection | null { const session = this.deps.sessions.get(sessionId) const source = journal ?? session?.journal - return source ? this.projections.read(source, this.deps.getRecord(sessionId), session) : null + return source + ? this.projections.read(source, this.deps.getConversation(sessionId), session) + : null } publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { @@ -181,7 +182,7 @@ export class StructuredAgentSessionStatusFeed { return } const source = journal ?? session.journal - const record = this.deps.getRecord(sessionId) + const record = this.deps.getConversation(sessionId) const projection = this.projections.read(source, record, session) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) const summary = structuredAgentSessionStatusSummary({ @@ -311,7 +312,7 @@ export class StructuredAgentSessionStatusFeed { private sink( summary: AgentSessionStatusSummary, - location?: AgentSessionRecord['location'] + location?: StatusFeedSession['params']['location'] ): void { try { this.ownership.publish(summary, location) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts index 17fb1da716f..c1a3db1f640 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -93,7 +94,10 @@ it.each([10, 100])('scopes %i old commands incrementally across clear', async (c expect(item).not.toHaveBeenCalled() const boundary = { operationId: 'clear', afterFence: 7, clearedAt: 101 } await journal.context.clear(boundary, { write: () => {}, committed: () => {} }) - const record = { ...agentSessionRecordFixture(), providerContextBoundary: boundary } + const record = agentSessionConversationView({ + ...agentSessionRecordFixture(), + providerContextBoundary: boundary + }) item.mockClear() floor.mockClear() expect(projections.read(journal, record).firstInputSubmissionKey).toBeNull() @@ -127,7 +131,7 @@ it('observes a first empty real input without broadcasting an unchanged visible const events: AgentSessionStatusEvent[] = [] const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([['session', indexedStatusFeedSession({ journal })]]), - getRecord: () => agentSessionRecordFixture(), + getConversation: () => agentSessionConversationView(agentSessionRecordFixture()), now: () => 100, logger: createStructuredAgentSessionLogger(), onStatusChanged: observer diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index dc59a8161c8..b68a9e8f970 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -2,7 +2,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import { isStructuredAgentSessionCommandEntry } from '../../../shared/structured-agent-session-command-entry' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -49,7 +49,7 @@ export class StructuredAgentSessionJournalProjections { read( journal: AgentSessionJournal, - record: AgentSessionRecord | null, + record: AgentSessionConversationView | null, seen?: { lastEndedChild?: StructuredAgentSessionWorkEvidence['ended'] operationalRevision?: number @@ -58,7 +58,7 @@ export class StructuredAgentSessionJournalProjections { ): StructuredAgentSessionJournalProjection { const cursor = journal.cursor() const contextId = record?.providerContextBoundary?.operationId ?? null - const fence = record?.lease.runtimeFence + const fence = record?.currentFence const ended = seen?.lastEndedChild const work = structuredAgentSessionCurrentWork(journal, { record, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 611b27f08f9..700901ec262 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -66,7 +66,7 @@ async function createFeed() { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions, - getRecord: () => null, + getConversation: () => null, now: () => 1_000, statusSink: () => statusSink }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 015052ecdae..00548fc6508 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -1,5 +1,5 @@ import { structuredAgentSessionRuntimePermission } from './structured-agent-session-ran-options' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' @@ -32,12 +32,15 @@ export function structuredAgentSessionStatusSummary({ }: { sessionId: string session: { - params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } + params: { + location: AgentSessionConversationView['location'] + provider: AgentSessionConversationView['provider'] + } child?: StructuredAgentSessionProviderChild | null restartResume?: AgentSessionStatusSummary['restartResume'] } journal: AgentSessionJournal - record: AgentSessionRecord | null + record: AgentSessionConversationView | null state: StructuredAgentSessionStatusState starting?: boolean stopping: boolean @@ -82,7 +85,7 @@ export function structuredAgentSessionStatusSummary({ /** The record's name, and the failure to replace it only while that is still the name. */ export function structuredSummaryConversationNaming( - record: AgentSessionRecord | null, + record: AgentSessionConversationView | null, failure: StructuredChatNamingFailure | undefined ): Pick { const name = record?.conversationName @@ -99,10 +102,10 @@ export function structuredSummaryConversationNaming( export function renamedStructuredStatusSummary( previous: AgentSessionStatusSummary, - deps: Pick, + deps: Pick, sessionId: string ): AgentSessionStatusSummary | null { - const record = deps.getRecord(sessionId) + const record = deps.getConversation(sessionId) const { conversationName: _name, namingError: _error, sleptAt: _sleptAt, ...rest } = previous const summary = { ...rest, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index c06934a4700..50c409d8403 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -67,7 +67,7 @@ async function openSession() { }) ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1, statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index f111317eb7f..fbef75d2305 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -256,7 +256,7 @@ describe('AgentSessionSubscribers', () => { } ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1_000 }) const subscribers = new AgentSessionSubscribers({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index f11681fcba4..b77152d1afc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -164,3 +164,13 @@ function requireSubmission( // Return this step’s answer; later journal settlement must not mutate it. return { ...submission } } + +export type AgentSessionReplayContext = Pick< + AgentSessionTurnContext, + 'sessionId' | 'fence' | 'currentWork' | 'agent' | 'persistedOptions' | 'now' +> & { + journal: Pick< + AgentSessionJournal, + 'cursor' | 'submission' | 'submissions' | 'epoch' | 'threadGoal' + > & { queuedMessages: Pick } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 1ac5374a11b..7c94d3018b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -163,7 +163,9 @@ type WorkingCandidateSession = { export function structuredAgentSessionWorkingAtStop(input: { sessionId: string session: WorkingCandidateSession | undefined - getRecord: (sessionId: string) => AgentSessionRecord | null + getRecord: ( + sessionId: string + ) => Pick | null childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined /** Include unrecorded live work rejected by bounded storage. */ childWorkOpen: (sessionId: string) => boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 5ee3450471b..654c9510fd2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { adapterAtPublishFor, @@ -36,7 +37,13 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin const sibling = 'session-beta' const siblingRecord = { ...record, sessionId: sibling } const getRecord = store.getRecord.bind(store) - vi.spyOn(store, 'listRecords').mockReturnValue([record, siblingRecord]) + vi.spyOn(store, 'listConversations').mockReturnValue( + [record, siblingRecord].map(agentSessionConversationView) + ) + const getConversation = store.getConversation + vi.spyOn(store, 'getConversation').mockImplementation((id) => + id === sibling ? agentSessionConversationView(siblingRecord) : getConversation(id) + ) vi.spyOn(store, 'getRecord').mockImplementation((id) => id === sibling ? siblingRecord : getRecord(id) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index 445fd2631ed..46e9cab67f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -27,6 +27,7 @@ export async function stopWorkspaceSleepChat(input: { const { deps, sessions, sessionId, child } = input let ownedStopped = false try { + deps.store.requireWritableRecord(sessionId) if (child) { await input.stopAgent(sessionId, { cause: 'user-close' }) } else if (input.owned) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 0dbe6379411..2f761530d8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -27,9 +27,9 @@ export async function sleepStructuredAgentSessionWorkspace( workspaceId: string, markAsleep = false ): Promise { - let records: ReturnType + let records: ReturnType try { - records = host.deps.store.listRecords() + records = host.deps.store.listConversations() } catch (error) { host.deps.logger.warn('workspace chat inventory failed', { scope: 'workspace-chat-inventory', @@ -54,17 +54,20 @@ export async function sleepStructuredAgentSessionWorkspace( LOCAL_EXECUTION_HOST_ID ) return host.tasks.serialize(sessionId, async () => { - const record = host.deps.store.getRecord(sessionId) + const record = host.deps.store.getConversation(sessionId) if ( !stop || !record || !belongs(record.location) || - record.lease.runtimeFence !== stop.fence || + record.currentFence !== stop.fence || host.deps.store.admittedSendCount(sessionId) !== stop.admittedSends ) { return null } - return settleWorkspaceSleepChat(host, sessionId, stop.stopped, markAsleep) + if (!stop.stopped) { + return { stopped: false as const, fence: record.currentFence } + } + return settleWorkspaceSleepChat(host, sessionId, true, markAsleep) }) }) ) @@ -83,7 +86,7 @@ export async function sleepStructuredAgentSessionWorkspace( stoppedSessionIds.push(sessionId) } else if ( stop.value && - host.deps.store.getRecord(sessionId)?.lease.runtimeFence === stop.value.fence + host.deps.store.getConversation(sessionId)?.currentFence === stop.value.fence ) { failures.push({ sessionId, reason: 'stop-unverifiable' }) } @@ -99,7 +102,8 @@ async function settleWorkspaceSleepChat( ): Promise { const current = host.deps.store.getRecord(sessionId) if (!current) { - return null + const conversation = host.deps.store.getConversation(sessionId) + return conversation ? { stopped: false, fence: conversation.currentFence } : null } if (!stopped) { return { stopped: false, fence: current.lease.runtimeFence } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 10d188b96fa..ae352e32c4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -201,7 +201,7 @@ describe('same-conversation clear', () => { expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(tab) expect(cleared.providerHandleChain).toEqual([]) - const ownership = listStructuredProviderSessionOwnership(store.listRecords()) + const ownership = listStructuredProviderSessionOwnership(store.listConversations()) expect(ownership).toEqual([]) expect( findConflictingStructuredAdoption({ @@ -258,6 +258,18 @@ describe('same-conversation clear', () => { expect(await clearRows()).toHaveLength(2) expect(store.listRecords()).toHaveLength(1) expect(adapter.acquire).toHaveBeenCalledTimes(1) + await host.flushAllStreamedEvents() + openTestJournalHostDatabase(generationRoot()) + .db.prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run(HOST_TEST_SESSION) + await openHost() + expect(await host.conversationCommand(caller, params)).toMatchObject({ + ok: true, + replayed: true + }) + expect(adapter.acquire).toHaveBeenCalledTimes(1) }) it('gives different callers distinct boundaries even when their operation IDs match', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 1042cd5a889..161f0eb9036 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -69,7 +69,7 @@ async function clearUnderSerialize( const { sessionId, clientOperationId } = envelope const store = context.deps.store const matching = () => { - const record = store.getRecord(sessionId)?.conversationCommand + const record = store.getConversation(sessionId)?.conversationCommand return record?.operationId === clientOperationId && record.callerKey === caller.callerKey ? record : null @@ -116,7 +116,7 @@ async function clearUnderSerialize( // No committed clear means no effects, so replay can safely rerun it. rerunWhenReplayMissing: () => true, run: async (ctx) => { - const record = store.getRecord(sessionId)! + const record = store.requireWritableRecord(sessionId) const blocked = conversationCommandBlocked( ctx, record, @@ -127,7 +127,7 @@ async function clearUnderSerialize( return { ok: false, refusal: blocked } } await context.stopAgent(sessionId, { cause: 'context-clear' }) - const stopped = store.getRecord(sessionId)! + const stopped = store.requireWritableRecord(sessionId) const fence = stopped.lease.runtimeFence const rechecked = conversationCommandBlocked( { ...ctx, fence }, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 72a03c4185a..8c48e0bed7b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -46,7 +46,7 @@ export async function runStructuredCompaction( const { sessionId, clientOperationId } = params.envelope // Legacy operation records must replay rather than execute again. const priorRecord = (): AgentSessionConversationCommandResult | null => { - const prior = context.deps.store.getRecord(sessionId)?.conversationCommand + const prior = context.deps.store.getConversation(sessionId)?.conversationCommand if (prior?.operationId !== clientOperationId || prior.callerKey !== caller.callerKey) { return null } @@ -88,7 +88,7 @@ export async function runStructuredCompaction( value: compactionReply( settled && 'submission' in settled.value ? settled.value.submission : undefined, { - ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + ...structuredAgentSessionFailureWordsContext(context.deps.store.getConversation(sessionId)), command: 'compact' } ) @@ -180,7 +180,7 @@ function acceptUnderSerialize( : { clientMessageId: value.submission.clientMessageId } } } - const record = context.deps.store.getRecord(ctx.sessionId) + const record = context.deps.store.requireWritableRecord(ctx.sessionId) const refusal = record && conversationCommandBlocked( diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index f51ecc2ffb5..2093e2711c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,6 +1,9 @@ -import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionOwnerObservation, + AgentSessionConversationView +} from '../../../shared/agent-session-conversation-view' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' +import { agentSessionOwnerObservationVerdict } from '../../../shared/agent-session-lease-adjudication' import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' import { keysMayMatch, @@ -14,7 +17,7 @@ export type StructuredProviderSessionOwnership = { provider: StructuredAgentId providerSessionId: string conversationName?: string - lease: AgentSessionLease + ownerObservation: AgentSessionOwnerObservation /** `head`: the conversation the chat continues now; `earlier`: one its chain held before. */ role: 'head' | 'earlier' key: ConversationKey @@ -22,7 +25,7 @@ export type StructuredProviderSessionOwnership = { /** Use registration-defined History row IDs for both ownership display and duplicate-adoption checks. */ export function listStructuredProviderSessionOwnership( - records: readonly AgentSessionRecord[] + records: readonly AgentSessionConversationView[] ): StructuredProviderSessionOwnership[] { return records.flatMap((record) => { const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory @@ -39,7 +42,7 @@ export function listStructuredProviderSessionOwnership( provider: record.provider, providerSessionId: history.rowSessionId(link), conversationName: record.conversationName, - lease: record.lease, + ownerObservation: record.ownerObservation, // A repeat resume link of the head's id is the head too. role: keysMayMatch(key, headKey) ? ('head' as const) : ('earlier' as const), key @@ -52,8 +55,8 @@ export function listStructuredProviderSessionOwnership( } /** Fail closed: only a lease released on proof its owner exited can't be writing. */ -export function agentSessionLeasePossiblyWriting(lease: AgentSessionLease): boolean { - return agentSessionLeaseOwnerVerdict(lease) !== 'exited' +export function agentSessionLeasePossiblyWriting(lease: AgentSessionOwnerObservation): boolean { + return agentSessionOwnerObservationVerdict(lease) !== 'exited' } /** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */ @@ -65,6 +68,6 @@ export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boole export function terminalResumeBlocked(holding: StructuredProviderSessionOwnership): boolean { return ( !sameIdTerminalResumeEnabled(holding.provider) || - (holding.role === 'head' && agentSessionLeasePossiblyWriting(holding.lease)) + (holding.role === 'head' && agentSessionLeasePossiblyWriting(holding.ownerObservation)) ) } diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 69530bc3a29..a2d4ee45e7b 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -63,7 +64,7 @@ function ownership( workspaceId: 'workspace-owner', provider: 'codex', providerSessionId: 'thread-1', - lease: agentSessionLeaseFixture(), + ownerObservation: agentSessionConversationView(agentSessionRecordFixture()).ownerObservation, role: 'head', ...overrides } @@ -238,7 +239,11 @@ describe('structuredAdoptionConflictError', () => { ])('calls %s an unknown owner rather than a conflict', (_label, leaseOverrides) => { expect( structuredAdoptionConflictError( - ownership({ lease: agentSessionLeaseFixture(leaseOverrides) }) + ownership({ + ownerObservation: agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture(leaseOverrides)) + ).ownerObservation + }) ).message ).toBe('agent_session_ownership_unknown') }) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index a073cc0cbd5..f5764dc9e7f 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -8,8 +8,9 @@ import type { AgentSessionProviderHandleLink, StructuredAgentId } from '../../shared/agent-session-provider-handle' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionOwnerObservation } from '../../shared/agent-session-conversation-view' +import { agentSessionOwnerObservationIsLive } from '../../shared/agent-session-lease-adjudication' import { agentSessionAccountHomesEqual, type AgentSessionAccountHome @@ -21,7 +22,7 @@ export type StructuredAgentSessionAdoptionOwnership = { /** Any chat may own the conversation; only agents with importers can adopt it. */ provider: StructuredAgentId providerSessionId: string - lease: AgentSessionLease + ownerObservation: AgentSessionOwnerObservation role: 'head' | 'earlier' } @@ -96,7 +97,7 @@ export function structuredAdoptionConflictError( ownership: StructuredAgentSessionAdoptionOwnership ): Error { return agentSessionRefusalError( - agentSessionLeaseAdmitsWriter(ownership.lease) + agentSessionOwnerObservationIsLive(ownership.ownerObservation) ? 'agent_session_conflict' : 'agent_session_ownership_unknown', { diff --git a/src/main/native-chat/structured-chat-naming-publication.test.ts b/src/main/native-chat/structured-chat-naming-publication.test.ts index ede8654fc0a..c6b1e262729 100644 --- a/src/main/native-chat/structured-chat-naming-publication.test.ts +++ b/src/main/native-chat/structured-chat-naming-publication.test.ts @@ -2,6 +2,7 @@ import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { expect, it, vi } from 'vitest' import { getDefaultSettings, getDefaultWorkspaceSession } from '../../shared/constants' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { listResult } from '../ai-vault/structured-session-ownership.test-support' @@ -60,6 +61,7 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)( const id = (index: number) => index < 0 ? 'unrelated' : index ? `population-${index}` : HOST_TEST_SESSION const get = store.getRecord + const getConversation = store.getConversation const providerId = (sessionId: string) => sessionId === HOST_TEST_SESSION ? HOST_TEST_THREAD : `renderer-provider-${sessionId}` const records = Array.from({ length: sizes.R }, (_, index) => ({ @@ -79,12 +81,22 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)( } })) const byId = new Map(records.map((record) => [record.sessionId, record])) + const conversations = records.map((record) => ({ + ...agentSessionConversationView(record), + get providerHandleChain() { + return record.providerHandleChain + } + })) + const conversationsById = new Map(conversations.map((view) => [view.sessionId, view])) const lookup = vi .spyOn(store, 'getRecord') .mockImplementation((id) => get(id) ?? byId.get(id) ?? null) + const conversationLookup = vi + .spyOn(store, 'getConversation') + .mockImplementation((id) => getConversation(id) ?? conversationsById.get(id) ?? null) const list = vi - .spyOn(store, 'listRecords') - .mockImplementation(() => [get(HOST_TEST_SESSION)!, ...records.slice(1)]) + .spyOn(store, 'listConversations') + .mockImplementation(() => [getConversation(HOST_TEST_SESSION)!, ...conversations.slice(1)]) for (let index = 0; index < sizes.J; index++) { naming.regenerate(id(index)) } @@ -158,6 +170,7 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)( ) try { lookup.mockClear() + conversationLookup.mockClear() list.mockClear() recordVisits = linkVisits = rowVisits = labelVisits = 0 deliveries.length = 0 @@ -165,8 +178,12 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)( const snapshots = deliveries.filter((event) => event.includes('namingActiveSessionIds')) expect(snapshots).toHaveLength(sizes.C) expect(JSON.parse(snapshots[0]!).namingActiveSessionIds).toHaveLength(sizes.J) - expect(lookup.mock.calls.length).toBeGreaterThanOrEqual(sizes.J + 1) - expect(lookup.mock.calls.length).toBeLessThanOrEqual(sizes.J + 10) + expect( + lookup.mock.calls.length + conversationLookup.mock.calls.length + ).toBeGreaterThanOrEqual(sizes.J + 1) + expect(lookup.mock.calls.length + conversationLookup.mock.calls.length).toBeLessThanOrEqual( + sizes.J + 10 + ) expect(list).not.toHaveBeenCalled() for (let index = 0; index < sizes.C; index++) { const response = JSON.parse(JSON.stringify(await rpc())) diff --git a/src/main/native-chat/structured-chat-naming.test.ts b/src/main/native-chat/structured-chat-naming.test.ts index 56b74790c03..9ac6a319471 100644 --- a/src/main/native-chat/structured-chat-naming.test.ts +++ b/src/main/native-chat/structured-chat-naming.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { OrchestrationDb } from '../runtime/orchestration/db' import { structuredWorkerOpenTaskTitle, @@ -366,7 +367,10 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid const failure = state.handle.failure(id) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map(), - getRecord: state.getRecord, + getConversation: () => { + const record = state.getRecord() + return record ? agentSessionConversationView(record) : null + }, now: () => 100, logger: state.deps.logger, namingActiveSessionIds: state.handle.activeSessionIds diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts index 64dcc18d69d..324dab504d6 100644 --- a/src/main/native-chat/structured-conversation-key.ts +++ b/src/main/native-chat/structured-conversation-key.ts @@ -27,7 +27,7 @@ export function conversationFamily(agent: string): string { } export function recordConversationKey( - record: AgentSessionRecord, + record: Pick, link: AgentSessionProviderHandleLink ): ConversationKey | null { const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts index 31f7ff4a21d..10451724194 100644 --- a/src/main/pi/rpc-launch-resolution.test.ts +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -85,7 +85,10 @@ async function setup() { observedAt: 100 }) const withPrior = (link: AgentSessionProviderHandleLink) => { - vi.spyOn(store, 'getRecord').mockReturnValue({ ...record, providerHandleChain: [link] }) + vi.spyOn(store, 'requireWritableRecord').mockReturnValue({ + ...record, + providerHandleChain: [link] + }) } return { store, record, workspace, resolver, resolveCommand, settings, prior, withPrior } } @@ -145,7 +148,7 @@ describe('Pi host launch resolution', () => { const h = await setup() const old = join(root, 'old-malformed.jsonl') await writeFile(old, 'not a Pi session header') - vi.spyOn(h.store, 'getRecord').mockReturnValue({ + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue({ ...h.record, providerHandleChain: [], providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 } @@ -255,12 +258,12 @@ describe('Pi host launch resolution', () => { if (!record) { throw new Error('record missing') } - vi.spyOn(h.store, 'getRecord').mockReturnValue({ + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue({ ...record, location: { ...record.location, executionHostId: 'ssh:remote' } }) await expect(h.resolver(identity)).rejects.toThrow('run on the local host') - vi.spyOn(h.store, 'getRecord').mockReturnValue({ + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue({ ...record, accountHome: { variable: 'CODEX_HOME', path: '/wrong' } }) @@ -279,7 +282,7 @@ describe('Pi launch folder of a floating chat', () => { location: { ...h.record.location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, ...(launchDirectory ? { launchDirectory } : {}) } - vi.spyOn(h.store, 'getRecord').mockReturnValue(floating) + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue(floating) const pinLaunchDirectory = vi.spyOn(h.store, 'pinLaunchDirectory').mockResolvedValue(floating) const resolveWorkspacePath = vi.fn(async () => moved) const resolver = createPiRpcLaunchResolver({ @@ -296,7 +299,7 @@ describe('Pi launch folder of a floating chat', () => { const h = await floatingSetup(join(root, 'workspace')) const file = join(root, 'pinned.jsonl') await writeFile(file, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`) - vi.spyOn(h.store, 'getRecord').mockReturnValue({ + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue({ ...h.floating, providerHandleChain: [h.prior(file)] }) @@ -324,7 +327,10 @@ describe('Pi launch folder of a floating chat', () => { it('keeps resolving a folder chat by its workspace, ignoring any recorded folder', async () => { const h = await setup() - vi.spyOn(h.store, 'getRecord').mockReturnValue({ ...h.record, launchDirectory: '/elsewhere' }) + vi.spyOn(h.store, 'requireWritableRecord').mockReturnValue({ + ...h.record, + launchDirectory: '/elsewhere' + }) await expect(h.resolver(identity)).resolves.toMatchObject({ cwd: h.workspace }) }) }) diff --git a/src/main/runtime/agent-session-founding-admission.ts b/src/main/runtime/agent-session-founding-admission.ts index 5dacff4f595..2574394e12c 100644 --- a/src/main/runtime/agent-session-founding-admission.ts +++ b/src/main/runtime/agent-session-founding-admission.ts @@ -14,7 +14,11 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session/ownership/agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-store-state' +import { + selectWritableAgentSessionRecord, + requireWritableAgentSessionRecord, + type AgentSessionStoreState +} from './agent-session-store-state' import { setAgentSessionTabVisibility } from './agent-session/agent-session-tab-table' /** Use create admission without requiring owner acquisition. */ @@ -39,10 +43,11 @@ export function commitAgentSessionFounding( reason: 'requestMalformed' }) } - if (state.unreadableRecords.has(request.sessionId)) { - throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + const selected = selectWritableAgentSessionRecord(state, request.sessionId) + if (selected.kind === 'unreadable') { + requireWritableAgentSessionRecord(state, request.sessionId) } - if (state.records.has(request.sessionId)) { + if (selected.kind === 'writable') { throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } try { diff --git a/src/main/runtime/agent-session-inventory-scale.test.ts b/src/main/runtime/agent-session-inventory-scale.test.ts new file mode 100644 index 00000000000..2a4b6d6dad8 --- /dev/null +++ b/src/main/runtime/agent-session-inventory-scale.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, it, vi } from 'vitest' +import { composeWorktreeHostIdentity } from '../../shared/worktree/host-qualified-identity' +import { searchHit, searchResults } from '../../shared/ai-vault-search-test-fixture' +import { listResult } from '../ai-vault/structured-session-ownership.test-support' +import { + assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSearchResponse, + projectStructuredAiVaultSessions +} from '../ai-vault/structured-session-ownership' +import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { StructuredAgentSessionReadableRestorer } from '../native-chat/agent-session-wire/structured-agent-session-readable-restorer' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + countRecordQueries, + countStoreWork, + journals, + scaleJournal, + scaleStore, + scaleDirectory, + scaleRecord +} from './agent-session-scale.test-fixture' +import { findWorkspaceReferences } from './runtime-reference-find' +import { parseWorkspaceReferenceUrl } from '../../shared/workspace-reference-identity' +import { createReferenceAgentIndex } from './runtime-reference-agents' +import { + seedTestAgentSessionRecordStore, + openTestAgentSessionRecordStore, + editPersistedTestAgentSessionStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' +import { OrchestrationDb } from './orchestration/db' +import { structuredSessionMailReach } from './orchestration/structured-session-mail-address' +import { resolveOrcaSessionParty } from './orchestration/orchestration-party' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' +import { + openAgentSessionRecordStoreOnce, + releaseAgentSessionRecordStore +} from './agent-session-record-store-slot' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { conversationRecordReader } from './orchestration/structured-session-lineage' +import { requireUnownedStructuredAgentSessionAdoption } from './structured-agent-session-create-adoption' +import { listStructuredSessionsForWorktree } from './structured-session-worktree-teardown' +import * as membership from './agent-session-store-state' +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 type { AgentSessionReserveRequest } from './agent-session/ownership/agent-session-reservation-admission' + +// Uses the complete host harness; discovery, mail reach and every store read execute unchanged. +async function inventoryRig(n: number) { + const rig = await scaleStore(n) + const host = new StructuredAgentSessionHost({ + ...hostTestState().host.deps, + store: rig.store, + journalDatabase: openTestJournalHostDatabase(rig.root) + }) + setStructuredAgentSessionHost(host) + return { ...rig, host } +} + +function reservation( + rig: Awaited>, + owner: string +): AgentSessionReserveRequest { + const row = rig.rows.find((row) => row.sessionId === owner)! + return { + sessionId: 'another-session', + location: row.location, + provider: 'claude', + accountHome: row.accountHome, + adoptedHandleLink: row.providerHandleChain[0], + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no process proof' }, + operation: { + callerKey: 'client-1', + operationId: '1800000000000-00000000000000000000000000000001', + fingerprint: 'new-adoption' + }, + now: 1_800_000_000_000 + } +} + +const referenceRuntime = { + getOrchestrationFleetAgentStatusSnapshot: () => [], + getAgentProviderSessionRows: () => [], + getTerminalLivenessVerdict: () => null +} + +function clearedRecord(sessionId: string, successor: string): AgentSessionRecord { + return { + ...scaleRecord(sessionId), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: successor, + operationId: `clear-${sessionId}`, + callerKey: 'client', + phase: 'committed' + } + } +} + +describe('real conversation inventory work', () => { + it.each([10, 100])('visits %i unreadable rows once per audience inventory', async (u) => { + const root = await scaleDirectory() + const healthy = Array.from({ length: 10 }, (_, i) => scaleRecord(`healthy-u-${i}`)) + const unreadable = Array.from({ length: u }, (_, i) => scaleRecord(`unreadable-u-${i}`)) + const retained = unreadable.filter((_, i) => i % 2 === 0) + await seedTestAgentSessionRecordStore(root, { + records: [...healthy, ...unreadable], + sessionTabs: [...healthy, ...unreadable].map(({ sessionId }) => ({ + sessionId, + tabId: `tab-${sessionId}` + })) + }) + await editPersistedTestAgentSessionStore(root, (state) => { + unreadable.forEach(({ sessionId }, i) => { + if (i % 2 === 0) { + Object.assign(state.records[sessionId].lease, { minimumNextFence: 'bad' }) + } else { + Object.assign(state.records[sessionId], { providerHandleChain: 'bad' }) + } + }) + }) + const raw = await readPersistedTestAgentSessionStore(root) + const store = await openTestAgentSessionRecordStore(root) + const host = new StructuredAgentSessionHost({ + ...hostTestState().host.deps, + store, + journalDatabase: openTestJournalHostDatabase(root) + }) + const ids = new Set(unreadable.map(({ sessionId }) => sessionId)) + const work = countStoreWork(store) + const inventories = vi.mocked(store.listConversations) + // Default spy executes native flatMap and its production callback on every dense input row. + const flatten = vi.spyOn(Array.prototype, 'flatMap') + try { + for (let repeat = 1; repeat <= 3; repeat++) { + expect(host.knownAgentIds()).toContain('claude') + expect(work.counts).toMatchObject({ + inventories: repeat, + views: 10 * repeat, + clones: 0, + decodes: 0 + }) + } + const scans = flatten.mock.contexts + .map((rows, i) => + Array.isArray(rows) && + rows.length === u && + rows.every( + (row: unknown) => + typeof row === 'object' && + row !== null && + 'raw' in row && + typeof row.raw === 'object' && + row.raw !== null && + 'sessionId' in row.raw && + typeof row.raw.sessionId === 'string' && + ids.has(row.raw.sessionId) + ) + ? i + : -1 + ) + .filter((i) => i >= 0) + expect(scans).toHaveLength(3) + for (const i of scans) { + expect(flatten.mock.contexts[i]).toMatchObject( + unreadable.map(({ sessionId }) => ({ raw: { sessionId } })) + ) + expect(flatten.mock.results[i]).toEqual({ + type: 'return', + value: retained.map(({ sessionId }) => store.getConversation(sessionId)) + }) + } + expect(inventories.mock.results.map((result) => result.value)).toEqual( + Array.from({ length: 3 }, () => + [...healthy, ...retained].map(({ sessionId }) => store.getConversation(sessionId)) + ) + ) + } finally { + flatten.mockRestore() + } + expect(store.getVisibleSessionTabIndex().sessionIds).toEqual( + [...healthy, ...retained].map(({ sessionId }) => sessionId) + ) + for (const [i, { sessionId }] of unreadable.entries()) { + expect(store.getConversation(sessionId)?.sessionId ?? null).toBe(i % 2 ? null : sessionId) + expect(store.getRecord(sessionId)).toBeNull() + expect(() => store.requireWritableRecord(sessionId)).toThrow() + } + await store.setConversationName(healthy[0]!.sessionId, 'unrelated update') + const after = await readPersistedTestAgentSessionStore(root) + for (const { sessionId } of unreadable) { + expect(after.records[sessionId]).toEqual(raw.records[sessionId]) + } + }) + + it('keeps global worker identity separate from candidate inventory and retains closure/loop outcomes', async () => { + const rootId = testOrcaSessionId('global-root') + const successor = testOrcaSessionId('visible-successor') + const globalRoot = await scaleDirectory() + const candidateRoot = await scaleDirectory() + const rows = [ + scaleRecord(successor), + scaleRecord('closed-chat'), + clearedRecord('loop-one', 'loop-two'), + clearedRecord('loop-two', 'loop-one') + ] + const sessionTabs = [ + { tabId: 'successor-tab', sessionId: successor }, + { tabId: 'loop-tab', sessionId: 'loop-one' } + ] + await seedTestAgentSessionRecordStore(globalRoot, { + records: [clearedRecord(rootId, successor), ...rows], + sessionTabs + }) + await seedTestAgentSessionRecordStore(candidateRoot, { records: rows, sessionTabs }) + const globalStore = await openTestAgentSessionRecordStore(globalRoot) + const candidateStore = await openTestAgentSessionRecordStore(candidateRoot) + const host = new StructuredAgentSessionHost({ + ...hostTestState().host.deps, + store: globalStore, + journalDatabase: openTestJournalHostDatabase(globalRoot) + }) + setStructuredAgentSessionHost(host) + const db = new OrchestrationDb(':memory:') + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(rootId) + try { + const run = db.createRun({ + objective: 'identity control', + coordinatorHandle: null, + coordinatorPaneKey: null + }) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'saved worker' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(rootId), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(db.getDispatchContextById(dispatch.id)?.assignee_orca_session_id).toBe(rootId) + const reader = conversationRecordReader(candidateStore) + const found = () => + [...createReferenceAgentIndex(referenceRuntime, db, reader).values()].flat() + expect(candidateStore.getConversation(rootId)).toBeNull() + expect(found()).toMatchObject([{ sessionId: successor, liveness: 'unverifiable' }]) + expect(found()[0]?.mailbox).toBeUndefined() + expect( + structuredSessionMailReach(reader, candidateStore.getConversation(successor)!, db) + ).toEqual({ kind: 'ended', reason: 'worker-identity-lost' }) + structuredWorkerIdentities.register({ + handle, + paneKey, + sessionId: rootId, + agent: 'claude', + processIncarnation: structuredWorkerProcessIncarnation(rootId), + worktreeId: 'workspace-1', + hostScope: { kind: 'local', hostId: 'local' } + }) + expect(resolveOrcaSessionParty(successor, db)).toMatchObject({ + orcaSessionId: rootId, + terminalHandle: handle + }) + expect(found()[0]?.mailbox).toBe(`orca_session_id:${successor}`) + expect( + structuredSessionMailReach(reader, candidateStore.getConversation('closed-chat')!, db) + ).toEqual({ kind: 'ended', reason: 'closed' }) + expect( + structuredSessionMailReach(reader, candidateStore.getConversation('loop-one')!, db) + ).toMatchObject({ kind: 'unverifiable', reason: expect.stringContaining('loops') }) + await candidateStore.setSessionTabVisibility(successor, false) + expect(found()).toEqual([]) + } finally { + structuredWorkerIdentities.forget(handle) + db.close() + } + }) + + it('uses the opened cold inventory without importing its predecessor into absent global identity', async () => { + const root = await scaleDirectory() + const predecessor = testOrcaSessionId('cold-root') + const successor = testOrcaSessionId('cold-successor') + await seedTestAgentSessionRecordStore(root, { + records: [clearedRecord(predecessor, successor), scaleRecord(successor)], + sessionTabs: [{ tabId: 'cold-tab', sessionId: successor }] + }) + const opened = await openAgentSessionRecordStoreOnce({ + stateDirectory: root, + hostId: 'local', + logger: createStructuredAgentSessionLogger() + }) + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'cold identity', + coordinatorHandle: null, + coordinatorPaneKey: null + }) + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'predecessor' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + processIncarnation: structuredWorkerProcessIncarnation(predecessor), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + for (const index of [ + createReferenceAgentIndex(referenceRuntime, db), + createReferenceAgentIndex(referenceRuntime, db, conversationRecordReader(opened.store)) + ]) { + expect([...index.values()].flat()).toMatchObject([ + { + sessionId: successor, + mailbox: `orca_session_id:${successor}`, + liveness: 'unverifiable' + } + ]) + } + expect(resolveOrcaSessionParty(successor, db)).toMatchObject({ + orcaSessionId: successor, + terminalHandle: null + }) + } finally { + db.close() + const released = await releaseAgentSessionRecordStore() + released?.journalDatabase.close() + } + }) + + it.each([10, 100])( + 'finds all references at %i rows without allocating membership payloads', + async (n) => { + const rig = await inventoryRig(n) + const work = countStoreWork(rig.store) + const queries = countRecordQueries(rig.root) + const visits = vi.spyOn(membership, 'hasAgentSessionConversation') + let index: ReturnType = new Map() + const result = await findWorkspaceReferences( + { query: 'STA-1234' }, + { + workspaces: ['workspace-1', 'other'].map((id) => ({ + id, + hostId: 'local' as const, + name: id, + repo: 'repo', + kind: 'worktree' as const, + isArchived: false, + linkedItems: [parseWorkspaceReferenceUrl('https://linear.app/acme/issue/STA-1234')] + })), + agents: (workspaceKeys) => { + index = createReferenceAgentIndex( + referenceRuntime, + null, + conversationRecordReader(rig.store), + workspaceKeys + ) + return index + } + } + ) + expect(result.matches.flatMap((match) => match.agents)).toHaveLength(n + 1) + const found = [...index.values()].flat() + expect(found.map((row) => row.sessionId).sort()).toEqual( + rig.rows + .filter((row) => !['closed-chat', 'malformed-chat'].includes(row.sessionId)) + .map((row) => row.sessionId) + .sort() + ) + expect(index.get(composeWorktreeHostIdentity('local', 'workspace-1'))).toHaveLength(n / 2 + 1) + expect(found.every((row) => row.liveness === 'unverifiable')).toBe(true) + expect(found.find((row) => row.sessionId === 'damaged-chat')?.sessionIds).toContain( + 'provider-damaged-chat-0' + ) + expect(work.counts).toMatchObject({ + views: 4 * n + 2, + inventories: 2, + keyed: 2 * (n + 1), + tabReads: n + 2, + clones: 0, + decodes: 0 + }) + // The nested membership walk predates this change; only its discarded allocations are removed. + expect(visits).toHaveBeenCalledTimes((n + 2) * (n + 2)) + expect(queries()).toEqual({ executions: 0, rows: 0 }) + work.reset() + visits.mockClear() + expect(rig.store.getVisibleSessionTabIndex().sessionIds).toHaveLength(n + 1) + await rig.store.showSessionTabs([ + 'closed-chat', + 'damaged-chat', + 'malformed-chat', + 'missing-chat' + ]) + expect(rig.store.getVisibleSessionTabIndex().sessionIds).toContain('closed-chat') + expect(rig.store.getVisibleSessionTabIndex().sessionIds).not.toContain('malformed-chat') + expect(work.counts.views).toBe(0) + await expect(rig.store.setSessionTabVisibility('malformed-chat', true)).rejects.toThrow() + expect(work.counts.views).toBe(0) + } + ) + + it.each([10, 100])('keeps history and both adoption boundaries linear at %i rows', async (n) => { + const rig = await inventoryRig(n) + const work = countStoreWork(rig.store) + const queries = countRecordQueries(rig.root) + const healthy = n + 1 // closed is readable; damaged reuses its retained view. + for (const size of [1, 10]) { + const rows = Array.from({ length: size }, (_, i) => ({ + ...listResult().sessions[0]!, + agent: 'claude' as const, + sessionId: i ? 'unowned-provider' : 'provider-damaged-chat-0' + })) + work.reset() + const listed = projectStructuredAiVaultSessions( + { ...listResult(), sessions: rows }, + () => true + ) + expect(listed.sessions[0]?.structuredSession?.sessionId).toBe('damaged-chat') + expect(listed.sessions).toHaveLength(size) + expect(work.counts).toMatchObject({ inventories: 1, views: healthy, clones: 0 }) + work.reset() + const hits = rows.map((row) => ({ + ...searchHit(), + agent: row.agent, + sessionId: row.sessionId + })) + const searched = projectStructuredAiVaultSearchResponse( + { ...searchResults(), hits }, + () => true + ) + expect(searched).toMatchObject({ + kind: 'results', + hits: [{ structuredSession: { sessionId: 'damaged-chat' } }, ...hits.slice(1)] + }) + expect(work.counts).toMatchObject({ inventories: 1, views: healthy, clones: 0 }) + } + work.reset() + await expect( + assertLegacyAiVaultResumeCommandAllowed( + 'claude --resume provider-damaged-chat-0', + async () => {} + ) + ).rejects.toThrow() + expect(work.counts).toMatchObject({ inventories: 1, views: healthy }) + work.reset() + await assertLegacyAiVaultResumeCommandAllowed('echo ordinary-command', async () => { + throw new Error('irrelevant command installed host') + }) + expect(work.counts.inventories).toBe(0) + for (const owner of [rig.rows[0]!.sessionId, 'damaged-chat']) { + work.reset() + expect(() => + requireUnownedStructuredAgentSessionAdoption({ + host: rig.host, + agent: 'claude', + providerSessionId: `provider-${owner}-0`, + selfSessionId: 'another-session' + }) + ).toThrow() + expect(work.counts).toMatchObject({ inventories: 1, views: healthy }) + work.reset() + await expect(rig.store.reserveOwner(reservation(rig, owner))).rejects.toThrow() + expect(work.counts.views).toBe(healthy) + expect(rig.store.getRecord('another-session')).toBeNull() + } + expect(queries()).toEqual({ executions: 0, rows: 0 }) + }) + + it.each([10, 100])( + 'restores selected journals and scopes lifecycle/audience inventory at %i rows', + async (n) => { + const rig = await inventoryRig(n) + for (const id of ['damaged-chat', rig.rows[0]!.sessionId, rig.rows[1]!.sessionId]) { + await (await scaleJournal(rig.root, id)).close() + } + const work = countStoreWork(rig.store) + const queries = countRecordQueries(rig.root) + const restored: string[] = [] + const restorer = new StructuredAgentSessionReadableRestorer({ + openDeps: { + store: rig.store, + journalDatabase: openTestJournalHostDatabase(rig.root), + logger: rig.host.deps.logger + }, + reconcileAll: async () => false, + reconcile: async () => false, + resolveRecovery: async () => false, + serialize: async (_id, task) => task(), + hasSession: () => false, + onReadable: (id, opened) => { + restored.push(id) + journals.track(opened.session.journal) + } + }) + await restorer.restore(['damaged-chat', rig.rows[0]!.sessionId]) + expect(restored).toEqual(['damaged-chat', rig.rows[0]!.sessionId]) + expect(work.counts).toMatchObject({ inventories: 1, views: n + 2, keyed: 2, clones: 0 }) + const restoredWork = { ...work.counts } + await restorer.restore() + expect(work.counts).toEqual(restoredWork) + work.reset() + const scoped = listStructuredSessionsForWorktree('workspace-1', { + resolvedConnectionId: null + }) + expect(scoped.members).toHaveLength(n / 2 + 2) + expect(scoped.live).toEqual([]) + expect(scoped.members.some((row) => row.sessionId === 'damaged-chat')).toBe(true) + expect(work.counts).toMatchObject({ inventories: 1, views: n + 1 + n / 2 + 1, clones: 0 }) + work.reset() + const slept = await rig.host.sleepWorkspace('workspace-1') + expect(slept.stoppedSessionIds).not.toContain('damaged-chat') + expect(slept.failures?.some((row) => row.sessionId === 'damaged-chat')).toBe(true) + expect(work.counts.inventories).toBe(1) + // One inventory and six checked observations per selected healthy member. + expect(work.counts.views).toBe(n + 1 + 6 * (n / 2 + 1)) + expect(rig.store.getConversation(rig.rows[1]!.sessionId)?.location.workspaceId).toBe('other') + work.reset() + expect(rig.host.knownAgentIds()).toContain('claude') + expect(work.counts).toMatchObject({ inventories: 1, views: n + 1, clones: 0 }) + expect(queries()).toEqual({ executions: 0, rows: 0 }) + expect(hostTestState().acquire).not.toHaveBeenCalled() + } + ) + it.each([10, 100])( + 'prewarms only eligible uncached catalogs with %i real stored chats', + async (n) => { + const rig = await inventoryRig(n) + const work = countStoreWork(rig.store) + const probe = vi.fn(async () => ({ + models: [{ id: 'saved-model', label: 'Saved', isDefault: true, efforts: [] }], + origin: 'probe' as const, + complete: true + })) + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getConversation: (id) => rig.store.getConversation(id) ?? undefined, + getRecord: (id) => rig.store.getRecord(id) ?? undefined, + drivesRecord: () => true, + resolveAccountHome: async () => rig.rows[0]!.accountHome, + hasChatRecords: (agent) => + rig.store.listConversations().some((row) => row.provider === agent), + probes: { claude: probe, codex: probe, opencode: probe }, + listsInChatFolder: new Set(['opencode']) + }) + await catalog.prewarm() + expect(work.counts).toMatchObject({ inventories: 2, views: 2 * (n + 1), clones: 0 }) + expect(probe).toHaveBeenCalledTimes(1) + work.reset() + await catalog.prewarm() + expect(work.counts).toMatchObject({ inventories: 1, views: n + 1, clones: 0 }) + expect(probe).toHaveBeenCalledTimes(1) + catalog.stop() + work.reset() + await catalog.prewarm() + expect(work.counts.views).toBe(0) + expect(probe).toHaveBeenCalledTimes(1) + } + ) +}) diff --git a/src/main/runtime/agent-session-publication-scale.test.ts b/src/main/runtime/agent-session-publication-scale.test.ts new file mode 100644 index 00000000000..d9bb285c76e --- /dev/null +++ b/src/main/runtime/agent-session-publication-scale.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import * as journalReducer from '../native-chat/agent-session-journal/journal-reducer' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../shared/agent-session-wire' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { StatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-deps' +import type { StructuredAgentSessionHostSession } from '../native-chat/agent-session-wire/structured-agent-session-host-types' +import { AgentSessionSubscribers } from '../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { + readQueuePublication, + structuredQueueSendGate +} from '../native-chat/agent-session-wire/structured-agent-session-queued-publication' +import { heldStructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-host-current-work' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { + countStoreWork, + scaleDirectory, + scaleJournal, + scaleRecord +} from './agent-session-scale.test-fixture' + +// Publisher count, unrelated records, history and cards are independent dimensions. +describe('real-store status and queue publications', () => { + it.each([ + [10, 10, 1, 1, 1], + [100, 10, 1, 1, 1], + [10, 100, 1, 1, 1], + [10, 10, 10, 1, 1], + [10, 10, 1, 10, 1], + [10, 10, 1, 1, 10] + ])( + '%i publishers, %i inactive rows, %i history items, %i cards and %i submissions', + async (n, inactive, history, cards, sends) => { + const root = await scaleDirectory() + const records = Array.from({ length: n }, (_, i) => + scaleRecord(`active-${String(i).padStart(5, '0')}`) + ) + await seedTestAgentSessionRecordStore(root, { + records: [ + ...records, + ...Array.from({ length: inactive }, (_, i) => scaleRecord(`inactive-${i}`)) + ] + }) + const store = await openTestAgentSessionRecordStore(root) + const sessions = new Map< + string, + Pick & + Pick< + StructuredAgentSessionHostSession, + 'child' | 'lastEndedChild' | 'operationalRevision' + > + >() + const counts = { + snapshots: 0, + snapshotItems: 0, + snapshotSubmissions: 0, + submissions: 0, + submissionRows: 0, + itemReads: 0, + lists: 0, + listRows: 0, + serializations: 0, + serializedRows: 0 + } + for (const row of records) { + const journal = await scaleJournal(root, row.sessionId) + for (let i = 0; i < history; i++) { + await journal.appendItem( + { provider: 'claude', sessionId: `provider-${row.sessionId}-0`, uuid: `item-${i}` }, + hostTestMessage(`saved-${i}`), + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + for (let i = 0; i < cards; i++) { + const card = { + messageId: `card-${i}`, + fingerprint: `fp-${i}`, + hostInstance: 'scale-runtime', + body: hostTestMessage(`draft-${i}`) + } + await journal.queuedMessages.insert(card) + } + for (let i = 0; i < sends; i++) { + const clientMessageId = `send-${i}` + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body: hostTestMessage(`sent-${i}`), + fence: 7 + }) + await journal.resolveDispatch({ + clientMessageId, + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: `provider-${row.sessionId}-0`, + uuid: `send-${i}` + }, + fence: 7 + }) + } + expect(journal.submissions()).toHaveLength(sends) + expect( + journal.submissions().every((submission) => submission.dispatchState === 'accepted') + ).toBe(true) + sessions.set(row.sessionId, { + journal, + child: null, + params: { location: row.location, provider: row.provider } + }) + const submissions = journal.submissions + vi.spyOn(journal, 'submissions').mockImplementation(() => { + const value = submissions() + counts.submissions++ + counts.submissionRows += value.length + return value + }) + const item = journal.item + vi.spyOn(journal, 'item').mockImplementation((id) => { + counts.itemReads++ + return item(id) + }) + const list = journal.queuedMessages.list.bind(journal.queuedMessages) + vi.spyOn(journal.queuedMessages, 'list').mockImplementation(() => { + const value = list() + counts.lists++ + counts.listRows += value.length + return value + }) + } + const render = journalReducer.renderJournalState + vi.spyOn(journalReducer, 'renderJournalState').mockImplementation((state) => { + const value = render(state) + counts.snapshots++ + counts.snapshotItems += value.items.length + counts.snapshotSubmissions += value.submissions.length + return value + }) + const originalStringify = JSON.stringify + vi.spyOn(JSON, 'stringify').mockImplementation((value, replacer, space) => { + if ( + Array.isArray(value) && + value.length > 0 && + value.every((row) => typeof row === 'object' && row !== null && 'messageId' in row) + ) { + counts.serializations++ + counts.serializedRows += value.length + } + return originalStringify(value, replacer, space) + }) + const work = countStoreWork(store) + const statusEvents: AgentSessionStatusEvent[] = [] + const streamEvents: AgentSessionSubscribeEvent[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getConversation: store.getConversation, + now: () => 1_800_000_000_000, + logger: createStructuredAgentSessionLogger() + }) + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (id, journal) => feed.publish(id, journal), + readCurrentWork: (id, journal) => + heldStructuredAgentSessionCurrentWork(store, id, journal, sessions.get(id)), + readQueuePublication: (id) => { + const journal = sessions.get(id)?.journal + return journal + ? readQueuePublication(journal, structuredQueueSendGate({ store, sessions }, id)) + : undefined + } + }) + const disposeStatus = feed.subscribe({ + id: 'all-status', + emit: (event) => statusEvents.push(event) + }) + const disposers = records.map((row) => + subscribers.open({ + id: row.sessionId, + sessionId: row.sessionId, + journal: sessions.get(row.sessionId)!.journal, + fence: 7, + emit: (event) => streamEvents.push(event) + }) + ) + expect(statusEvents[0]).toMatchObject({ + type: 'snapshot', + sessions: expect.arrayContaining( + records.map((row) => expect.objectContaining({ sessionId: row.sessionId })) + ) + }) + expect(streamEvents.filter((event) => event.type === 'snapshot')).toHaveLength(n) + expect( + streamEvents + .filter((event) => event.type === 'snapshot') + .every((event) => 'queuedMessages' in event && event.queuedMessages?.length === cards) + ).toBe(true) + // Status projection, hydration and initial current-work restatement each read once. + expect(counts.snapshots).toBe(3 * n) + expect(counts.snapshotItems).toBe(3 * n * (history + sends)) + expect(counts.snapshotSubmissions).toBe(3 * n * sends) + expect(counts.serializations).toBe(n) + expect(counts.serializedRows).toBe(n * cards) + const warm = { ...counts } + work.reset() + statusEvents.length = 0 + streamEvents.length = 0 + for (let event = 0; event < 3; event++) { + for (const [id, session] of sessions) { + subscribers.publish(id, session.journal) + } + } + // Each publish derives queue pauses twice and checks current work independently. + expect(counts).toEqual({ + ...warm, + lists: warm.lists + 12 * n, + listRows: warm.listRows + 12 * n * cards, + submissions: warm.submissions + 12 * n, + submissionRows: warm.submissionRows + 12 * n * sends + }) + expect(work.counts).toMatchObject({ views: 9 * n, inventories: 0, clones: 0, decodes: 0 }) + expect(statusEvents).toEqual([]) + expect(streamEvents).toEqual([]) + const changed = records[0]!.sessionId + await store.setConversationName(changed, 'Visible update') + subscribers.publish(changed, sessions.get(changed)!.journal) + expect(statusEvents).toMatchObject([ + { type: 'status', session: { sessionId: changed, conversationName: 'Visible update' } } + ]) + const journal = sessions.get(changed)!.journal + const card = { + messageId: 'new-card', + fingerprint: 'new', + hostInstance: 'scale-runtime', + body: hostTestMessage('new draft') + } + await journal.queuedMessages.insert(card) + subscribers.publish(changed, journal) + expect(counts.serializations).toBe(warm.serializations + 1) + expect( + streamEvents.some( + (event) => + 'queuedMessages' in event && + event.queuedMessages?.some((row) => row.messageId === 'new-card') + ) + ).toBe(true) + disposeStatus() + disposers.forEach((dispose) => dispose()) + sessions.clear() + const ended = [statusEvents.length, streamEvents.length] + work.reset() + subscribers.publish(changed, journal) + expect([statusEvents.length, streamEvents.length]).toEqual(ended) + expect(work.counts.views).toBe(0) + expect(work.counts.inventories).toBe(0) + expect(subscribers.subscriberCountForTests(changed)).toBe(0) + } + ) +}) diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index 50cac71c698..4e167deec5c 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -1,3 +1,7 @@ +import { + AGENT_SESSION_FOUNDING_FENCE, + agentSessionNeverStarted +} from '../../shared/agent-session-conversation-view' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../shared/agent-chat-permission-mode' import { AGENT_SESSION_RECORD_SCHEMA_VERSION, @@ -38,21 +42,8 @@ export function agentSessionRecordIdentityFields( } } -export const AGENT_SESSION_FOUNDING_FENCE = 1 - /** No reservation, owner or provider handle means no agent ever started. */ -export function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { - const { lease } = record - return ( - lease.claimStatus === 'released' && - lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && - lease.minimumNextFence === undefined && - lease.ownerProcess === null && - lease.reservedSpawnToken === null && - lease.handoffStage === null && - record.providerHandleChain.length === 0 - ) -} +export const structuredSessionNeverStarted = agentSessionNeverStarted /** An empty handle chain starts a fresh conversation on first send rather than resuming one. */ export function foundAgentSessionRecord( diff --git a/src/main/runtime/agent-session-record-launch-directory.ts b/src/main/runtime/agent-session-record-launch-directory.ts index 76ae770b94c..ed7cd170929 100644 --- a/src/main/runtime/agent-session-record-launch-directory.ts +++ b/src/main/runtime/agent-session-record-launch-directory.ts @@ -14,6 +14,8 @@ export function pinAgentSessionRecordLaunchDirectory( } /** Only floating ids need pinned paths; worktree/folder ids already name durable places. */ -export function agentSessionPinnedLaunchDirectory(record: AgentSessionRecord): string | undefined { +export function agentSessionPinnedLaunchDirectory( + record: Pick +): string | undefined { return isFloatingWorkspaceId(record.location.workspaceId) ? record.launchDirectory : undefined } diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 2c188c12019..ed0263fcc56 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -1,9 +1,11 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' // Load session/ledger rows once and persist only changed rows inside the journal transaction. // Unreadable records remain present and immutable; every mutation of their ids is refused. import { AGENT_SESSION_RECORD_SCHEMA_VERSION, isPersistedAgentSessionRecord, + isPersistedAgentSessionObservationRecord, type AgentSessionLease } from '../../shared/agent-session-record' import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' @@ -108,9 +110,20 @@ export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionSt lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } }) } else { + const decoded = + isPersistedAgentSessionObservationRecord(value) && value.sessionId === sessionId + ? decodePersistedAgentSessionRecord(structuredClone(value)).record + : undefined + const conversation = decoded + ? agentSessionConversationView({ + ...decoded, + lease: { ...decoded.lease, unreconciled: true } + }) + : undefined state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), - raw: value + raw: value, + ...(conversation ? { conversation: Object.freeze(conversation) } : {}) }) } } diff --git a/src/main/runtime/agent-session-record-store-damaged.test.ts b/src/main/runtime/agent-session-record-store-damaged.test.ts new file mode 100644 index 00000000000..a82b5a889a1 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-damaged.test.ts @@ -0,0 +1,329 @@ +import { nextStructuredQueuedMessage } from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' +import { heldStructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-host-current-work' +import { AgentSessionSubscribers } from '../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { openStructuredAgentSessionConversationJournal } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' +import { readStructuredAgentSessionHistoryResult } from '../native-chat/agent-session-wire/structured-agent-session-history-result' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AgentSessionConversationView } from '../../shared/agent-session-conversation-view' +import type { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { findConflictingStructuredAdoption } from '../native-chat/structured-agent-session-history-adoption' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from 'vitest' +import type { + AgentSessionExecutionLocation, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session/ownership/agent-session-reservation-admission' +import { + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' + +const NOW = 1_800_000_000_000 +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +const INDETERMINATE = { outcome: 'indeterminate', reason: 'no answer' } as const + +let counter = 0 + +function operationId(now = NOW): string { + counter += 1 + return `${now}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +function processIdentity( + overrides: Partial = {} +): AgentSessionProcessIdentity { + return { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-a', + ...overrides + } +} + +function handleLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'link-1', + handle: claudeProviderHandle('provider-session-1', 'leaf-1'), + origin: 'created', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +let directory: string + +async function open(hostId = 'local'): Promise { + return openTestAgentSessionRecordStore(directory, { hostId }) +} + +/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ +async function establishOwner( + store: AgentSessionRecordStore, + overrides: Partial = {} +): Promise { + const reserved = await store.reserveOwner(reserveRequest(overrides)) + const fence = reserved.record.lease.runtimeFence + const sessionId = reserved.record.sessionId + await store.commitProcessIdentity({ + sessionId, + fence, + process: processIdentity({ spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' }), + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: handleLink({ mintedAtFence: fence }), + now: NOW + }) +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('damaged saved conversation observations', () => { + it('retains the durable claim for a damaged owner in preflight and transactional adoption', async () => { + const first = await open() + await establishOwner(first) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + Object.assign(persisted.records['session-alpha'].lease, { minimumNextFence: 'bad' }) + }) + const store = await open() + const ownership = listStructuredProviderSessionOwnership(store.listConversations()) + expect( + findConflictingStructuredAdoption({ + agent: 'claude', + providerSessionId: 'provider-session-1', + selfSessionId: 'session-beta', + ownership + })?.sessionId + ).toBe('session-alpha') + const adopted = reserveRequest({ sessionId: 'session-beta', adoptedHandleLink: handleLink() }) + await expect(store.founding.commit(adopted)).rejects.toMatchObject({ + refusal: { + details: { reason: 'conversationHeldElsewhere' } + } + }) + await expect(store.reserveOwner(adopted)).rejects.toMatchObject({ + refusal: { + details: { reason: 'conversationHeldElsewhere' } + } + }) + expect(store.getConversation('session-beta')).toBeNull() + }) + + it('does not manufacture never-started or runtime authority from a present malformed floor', async () => { + const first = await open() + await first.founding.commit(reserveRequest()) + expect(first.getConversation('session-alpha')?.neverStarted).toBe(true) + const healthyOwner = first.getConversation('session-alpha')?.ownerObservation + expect(Object.keys(healthyOwner ?? {}).sort()).toEqual( + [ + 'claimStatus', + 'handoffStage', + 'handoffOperationId', + 'ownerProcess', + 'reservedSpawnToken', + 'provenHandleLinkId', + 'leaseDeadlineAt', + 'lastRenewedAt', + 'deathEvidence', + 'unreconciled' + ].sort() + ) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + Object.assign(persisted.records['session-alpha'], { futureConversationField: 'retained' }) + Object.assign(persisted.records['session-alpha'].lease, { + minimumNextFence: null, + futureAuthorityField: 'private' + }) + }) + const store = await open() + expect(store.getConversation('session-alpha')?.neverStarted).toBe(false) + expect(store.getConversation('session-alpha')).toHaveProperty( + 'futureConversationField', + 'retained' + ) + expect(store.getConversation('session-alpha')?.ownerObservation).toEqual({ + ...healthyOwner, + unreconciled: true + }) + expectTypeOf().not.toExtend() + expectTypeOf().not.toExtend< + Parameters[0] + >() + }) + + it.each([9, 'bad'])( + 'reopens the same saved journal for floor %j and proves automatic receipt admission independently of pauses', + async (minimumNextFence) => { + const first = await open() + await establishOwner(first) + const logger = recordingStructuredAgentSessionLogger().logger + const original = await openStructuredAgentSessionConversationJournal( + { + store: first, + journalDatabase: openTestJournalHostDatabase(directory), + logger + }, + first.getConversation('session-alpha')! + ) + await original.session.journal.appendSubmission({ + clientMessageId: operationId(), + origin: 'client', + payloadFingerprint: 'saved', + body: hostTestMessage('saved question'), + fence: 1, + handoverRecorded: true + }) + const savedCard = { + messageId: 'kept-card', + fingerprint: 'card', + body: hostTestMessage('waiting message'), + hostInstance: 'earlier-runtime' + } + await original.session.journal.queuedMessages.insert(savedCard) + await original.session.journal.close() + await editPersistedTestAgentSessionStore(directory, (persisted) => { + Object.assign(persisted.records['session-alpha'].lease, { minimumNextFence }) + const owner = persisted.records['session-alpha'].lease.ownerProcess + if (owner) { + owner.runtime = 'previous-runtime' + } + }) + const store = await open() + const record = store.getConversation('session-alpha')! + const reopened = await openStructuredAgentSessionConversationJournal( + { + store, + journalDatabase: openTestJournalHostDatabase(directory), + logger + }, + record + ) + const journal = reopened.session.journal + try { + const history = readStructuredAgentSessionHistoryResult({ + journal, + record, + request: { sessionId: 'session-alpha', direction: 'tail' } + }) + expect(history.page.fence).toBe(1) + expect(history.providerSession).toMatchObject({ id: 'provider-session-1' }) + expect(journal.submissions()).toMatchObject([{ dispatchState: 'rejected' }]) + expect(store.replacedRuntime('session-alpha')).toMatchObject({ fence: 1 }) + expect(record.ownerObservation.deathEvidence).toBeNull() + const subscriber = new AgentSessionSubscribers() + const frames: unknown[] = [] + const unsubscribe = subscriber.open({ + id: 'saved-view', + sessionId: 'session-alpha', + journal, + fence: record.currentFence, + emit: (frame) => frames.push(frame) + }) + expect(frames).toMatchObject([{ type: 'snapshot', page: { fence: 1 } }]) + unsubscribe() + // Lift the old-card pause so strict record validity is the only admission difference. + await journal.appendQueueResume(1) + const work = heldStructuredAgentSessionCurrentWork( + store, + 'session-alpha', + journal, + reopened.session + ) + expect(work.working()).toBe(false) + const card = journal.queuedMessages.list().find((row) => row.state === 'waiting') + if (!card) { + throw new Error('Expected a preserved waiting card') + } + const next = nextStructuredQueuedMessage({ + journal, + work, + record: store.getRecord('session-alpha') + }) + expect(next?.messageId ?? null).toBe(minimumNextFence === 'bad' ? null : card.messageId) + // Direct receipt admission: publication is not authoritative proof. + + const consume = { + messageId: card.messageId, + expect: 'waiting' as const, + settledByOp: null, + hostInstance: 'current-runtime', + yieldsToPause: true as const + } + const fresh = journal.appendSubmission( + { + clientMessageId: operationId(), + origin: 'host', + payloadFingerprint: card.fingerprint, + body: card.body, + fence: 1, + handoverRecorded: true + }, + consume, + store.submissionReceipt('session-alpha') + ) + if (minimumNextFence === 'bad') { + await expect(fresh).rejects.toMatchObject({ + refusal: { details: { reason: 'recordUnreadable' } } + }) + expect(journal.queuedMessages.get(card.messageId)?.state).toBe('waiting') + expect(journal.submissions()).toHaveLength(1) + } else { + await fresh + expect(journal.queuedMessages.get(card.messageId)?.state).toBe('dispatched') + expect(journal.submissions()).toHaveLength(2) + } + } finally { + await journal.close() + } + } + ) +}) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 33bb4d9e8f5..51370c9d3e1 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -720,6 +720,72 @@ describe('host and workspace isolation', () => { }) describe('claim keys and unreadable rows', () => { + it.each(['bad', {}, null, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'preserves a damaged next-fence floor %j through refusal and unrelated writes', + async (minimumNextFence) => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + let damaged: PersistedAgentSessionRecord | undefined + await editPersistedTestAgentSessionStore(directory, (persisted) => { + damaged = persisted.records['session-alpha'] + Object.assign(damaged.lease, { minimumNextFence }) + }) + const reopened = await open() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + expect(reopened.getRecord('session-alpha')).toBeNull() + expect(reopened.getConversation('session-alpha')).toMatchObject({ + currentFence: 1, + neverStarted: false, + ownerObservation: { unreconciled: true } + }) + expect(reopened.getConversation('session-alpha')).not.toHaveProperty('lease') + expect(reopened.getVisibleSessionTabIndex().sessionIds).toEqual(['session-alpha']) + await expect(reopened.setConversationName('session-alpha', 'new')).rejects.toMatchObject({ + refusal: { details: { reason: 'recordUnreadable' } } + }) + await expect( + reopened.renewLease({ sessionId: 'session-alpha', fence: 1, childProbe: UNUSED, now: NOW }) + ).rejects.toThrow('execution_owner_reconciling') + await expect(reopened.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { details: { reason: 'recordUnreadable' } } + }) + await expect(reopened.founding.commit(reserveRequest())).rejects.toMatchObject({ + refusal: { details: { reason: 'recordUnreadable' } } + }) + await establishOwner(reopened, { sessionId: 'session-beta', claimKeyId: 'key-2' }) + expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) + const persisted = await readPersistedTestAgentSessionStore(directory) + expect(persisted.records['session-alpha']).toEqual(damaged) + expect(persisted.sessionTabs).toContainEqual({ + tabId: 'tab-alpha', + sessionId: 'session-alpha' + }) + } + ) + + it('uses a legacy floor for the next reservation after restarting a released chat', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + Object.assign(persisted.records['session-alpha'].lease, { + claimStatus: 'released', + reservedSpawnToken: null, + minimumNextFence: 9 + }) + }) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) + const reserved = await reopened.reserveOwner( + reserveRequest({ expectedFence: 1, probe: UNUSED }) + ) + expect(reserved.record.lease.runtimeFence).toBe(9) + expect((await open()).getRecord('session-alpha')?.lease).toMatchObject({ + runtimeFence: 9, + minimumNextFence: 9 + }) + }) + it('keeps a retired claim key verifiable for the retention window', async () => { const store = await open() await store.retireClaimKey('key-1', NOW) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index ca6b645df75..df25d64f32a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,7 +1,6 @@ /** Single-writer records/operation ledger in the host chat-journal database. */ import { backfillAgentSessionPermissionModes } from './agent-session/ownership/agent-session-permission-backfill' -import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts' import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory' import { @@ -60,7 +59,14 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session/ownership/agent-session-reservation-admission' -import { heldAgentSessionIds, type AgentSessionStoreState } from './agent-session-store-state' +import { + getAgentSessionConversation, + selectWritableAgentSessionRecord, + listAgentSessionConversations, + requireWritableAgentSessionRecord, + heldAgentSessionIds, + type AgentSessionStoreState +} from './agent-session-store-state' import { agentSessionVisibleTabIndex, listVisibleAgentSessionIds, @@ -122,6 +128,13 @@ export class AgentSessionRecordStore { getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null + getConversation = (sessionId: string) => getAgentSessionConversation(this.state, sessionId) + listConversations = () => listAgentSessionConversations(this.state) + selectWritableRecord = (sessionId: string) => + selectWritableAgentSessionRecord(this.state, sessionId) + requireWritableRecord = (sessionId: string) => + requireWritableAgentSessionRecord(this.state, sessionId) + listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] /** Replaced runtimes captured at load; absent for sessions this runtime created. */ @@ -245,9 +258,7 @@ export class AgentSessionRecordStore { ): Promise => this.mutate(sessionId, transition) /** Reconcile loaded leases before writer admission. Newer databases keep verdicts in memory only and cannot grant writers. */ - async reconcileOnRestart( - args: AgentSessionRestartProbeArgs - ): Promise> { + async reconcileOnRestart(args: AgentSessionRestartProbeArgs) { const probes = await collectAgentSessionRestartProbes(this, args) return this.transact((draft) => applyAgentSessionRestartProbes(draft, probes, args.now), { inMemoryWhenReadOnly: true @@ -317,12 +328,7 @@ export class AgentSessionRecordStore { apply: (record: AgentSessionRecord) => AgentSessionRecord ): Promise { return this.transact((draft) => { - const record = draft.records.get(sessionId) - if (!record) { - throw draft.unreadableRecords.has(sessionId) - ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) - : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) - } + const record = requireWritableAgentSessionRecord(draft, sessionId) const next = apply(record) draft.records.set(sessionId, next) return next diff --git a/src/main/runtime/agent-session-scale.test-fixture.ts b/src/main/runtime/agent-session-scale.test-fixture.ts new file mode 100644 index 00000000000..159b35733b9 --- /dev/null +++ b/src/main/runtime/agent-session-scale.test-fixture.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, vi } from 'vitest' +import * as views from '../../shared/agent-session-conversation-view' +import * as handles from '../../shared/agent-session-provider-handle' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { + openTestJournalHostDatabase, + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + seedTestAgentSessionRecordStore, + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' + +const roots: string[] = [] +export const journals = createTrackedJournalOpener() +export const stringify = JSON.stringify.bind(JSON) + +export function scaleRecord(sessionId: string, workspaceId = 'workspace-1', links = 1) { + const row = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId, provenHandleLinkId: `link-${links - 1}` }) + ) + return { + ...row, + location: { ...row.location, workspaceId }, + options: { model: 'saved-model' }, + providerHandleChain: Array.from({ length: links }, (_, i) => ({ + ...row.providerHandleChain[0]!, + linkId: `link-${i}`, + origin: i === 0 ? ('created' as const) : ('resumed' as const), + handle: claudeProviderHandle(`provider-${sessionId}-0`, i ? `leaf-${i}` : null) + })) + } +} + +export function scaleJournal(root: string, sessionId: string) { + return journals.open({ + stateDirectory: root, + identity: { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle(`provider-${sessionId}-0`, null) + } + }) +} + +export async function scaleDirectory() { + const root = await mkdtemp(join(tmpdir(), 'orca-session-scale-')) + roots.push(root) + return root +} + +export async function scaleStore(n: number) { + const root = await scaleDirectory() + const rows = Array.from({ length: n }, (_, i) => + scaleRecord(`chat-${String(i).padStart(5, '0')}`, i % 2 ? 'other' : 'workspace-1') + ) + rows.push(scaleRecord('damaged-chat'), scaleRecord('closed-chat'), scaleRecord('malformed-chat')) + await seedTestAgentSessionRecordStore(root, { + records: rows, + sessionTabs: rows + .filter((row) => row.sessionId !== 'closed-chat') + .map((row) => ({ tabId: `tab-${row.sessionId}`, sessionId: row.sessionId })) + }) + await editPersistedTestAgentSessionStore(root, (state) => { + Object.assign(state.records['damaged-chat'].lease, { minimumNextFence: 'bad' }) + Object.assign(state.records['malformed-chat'], { providerHandleChain: 'bad' }) + }) + const store = await openTestAgentSessionRecordStore(root) + return { root, store, rows } +} + +/** Transparent counters; every observed call still executes the production operation. */ +export function countStoreWork(store?: AgentSessionRecordStore) { + const counts = { + views: 0, + clones: 0, + cloneBytes: 0, + decodes: 0, + links: 0, + inventories: 0, + keyed: 0, + tabReads: 0 + } + const view = views.agentSessionConversationView + vi.spyOn(views, 'agentSessionConversationView').mockImplementation((record) => { + counts.views++ + return view(record) + }) + const decode = handles.decodePersistedAgentSessionProviderHandleChain + vi.spyOn(handles, 'decodePersistedAgentSessionProviderHandleChain').mockImplementation( + (value) => { + counts.decodes++ + counts.links += Array.isArray(value) ? value.length : 0 + return decode(value) + } + ) + const clone = globalThis.structuredClone + vi.spyOn(globalThis, 'structuredClone').mockImplementation((value) => { + counts.clones++ + counts.cloneBytes += Buffer.byteLength(stringify(value)) + return clone(value) + }) + if (store) { + const inventory = store.listConversations + vi.spyOn(store, 'listConversations').mockImplementation(() => { + counts.inventories++ + return inventory() + }) + const get = store.getConversation + vi.spyOn(store, 'getConversation').mockImplementation((id) => { + counts.keyed++ + return get(id) + }) + const tabs = store.getVisibleSessionTabIndex + vi.spyOn(store, 'getVisibleSessionTabIndex').mockImplementation(() => { + counts.tabReads++ + return tabs() + }) + } + return { + counts, + reset: () => { + for (const key of Object.keys(counts)) { + Reflect.set(counts, key, 0) + } + } + } +} + +export function countRecordQueries(root: string) { + let executions = 0 + let rows = 0 + const db = openTestJournalHostDatabase(root).db + const observed = new WeakSet() + const prepare = db.prepare.bind(db) + vi.spyOn(db, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql) + if ( + sql === 'SELECT session_id, record_json FROM agent_session_records ORDER BY rowid' && + !observed.has(statement) + ) { + observed.add(statement) + const all = statement.all.bind(statement) + vi.spyOn(statement, 'all').mockImplementation((...args) => { + executions++ + const result = all(...args) + rows += result.length + return result + }) + } + return statement + }) + return () => ({ executions, rows }) +} + +afterEach(async () => { + setStructuredAgentSessionHost(null) + vi.restoreAllMocks() + await journals.closeAll() + closeTestJournalHostDatabases() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/agent-session-store-load-scale.test.ts b/src/main/runtime/agent-session-store-load-scale.test.ts new file mode 100644 index 00000000000..2589be55067 --- /dev/null +++ b/src/main/runtime/agent-session-store-load-scale.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + countRecordQueries, + countStoreWork, + scaleJournal, + scaleDirectory, + scaleRecord, + stringify +} from './agent-session-scale.test-fixture' +import { + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' + +// Width, link count and malformed bytes vary independently; no clock-dependent budget. +describe('SQLite conversation reopen work', () => { + it.each([ + [10, 1, 16], + [100, 1, 16], + [10, 10, 16], + [10, 1, 64_000] + ])('decodes %i damaged rows with %i links and %i floor bytes once', async (n, links, bytes) => { + const root = await scaleDirectory() + const records = Array.from({ length: n }, (_, i) => + scaleRecord(`damaged-${i}`, 'workspace-1', links) + ) + await seedTestAgentSessionRecordStore(root, { + records: [...records, scaleRecord('healthy-chat'), scaleRecord('invalid-chat')] + }) + await editPersistedTestAgentSessionStore(root, (state) => { + for (const record of records) { + Object.assign(state.records[record.sessionId].lease, { + minimumNextFence: { bad: 'x'.repeat(bytes) } + }) + } + Object.assign(state.records['invalid-chat'], { providerHandleChain: 'bad' }) + }) + const raw = await readPersistedTestAgentSessionStore(root) + const expectedBytes = records.reduce( + (sum, row) => sum + Buffer.byteLength(stringify(raw.records[row.sessionId])), + 0 + ) + const journal = await scaleJournal(root, 'damaged-0') + await journal.appendItem( + { provider: 'claude', sessionId: 'provider-damaged-0-0', uuid: 'saved-question' }, + hostTestMessage('saved question'), + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + const work = countStoreWork() + const queries = countRecordQueries(root) + const store = await openTestAgentSessionRecordStore(root) + expect(queries()).toEqual({ executions: 1, rows: n + 2 }) + expect(work.counts).toMatchObject({ + clones: n, + cloneBytes: expectedBytes, + views: n + 1, + decodes: 4 * n + 5, + links: 4 * n * links + 2 + }) + const loaded = { ...work.counts } + for (let repeat = 0; repeat < 3; repeat++) { + for (const row of records) { + expect(store.getConversation(row.sessionId)?.options?.model).toBe('saved-model') + expect(store.getRecord(row.sessionId)).toBeNull() + expect(() => store.requireWritableRecord(row.sessionId)).toThrow() + } + } + expect(store.getConversation('invalid-chat')).toBeNull() + expect(store.listHeldSessionIds()).toContain('invalid-chat') + expect(work.counts).toEqual(loaded) + await store.setConversationName('healthy-chat', 'renamed') + expect(work.counts.clones).toBe(n) + expect(queries()).toEqual({ executions: 1, rows: n + 2 }) + const after = await readPersistedTestAgentSessionStore(root) + for (const row of records) { + expect(after.records[row.sessionId]).toEqual(raw.records[row.sessionId]) + } + expect(after.records['healthy-chat'].conversationName).toBe('renamed') + const reopened = await scaleJournal(root, 'damaged-0') + expect(reopened.snapshot().items.map((item) => item.body)).toContainEqual( + hostTestMessage('saved question') + ) + expect(store.getConversation('damaged-0')?.providerHandleChain[0]?.handle.nativeId).toBe( + 'provider-damaged-0-0' + ) + }) +}) diff --git a/src/main/runtime/agent-session-store-state.ts b/src/main/runtime/agent-session-store-state.ts index 2942e59f6d6..35fc9dd657e 100644 --- a/src/main/runtime/agent-session-store-state.ts +++ b/src/main/runtime/agent-session-store-state.ts @@ -1,3 +1,8 @@ +import { + agentSessionConversationView, + type AgentSessionConversationView +} from '../../shared/agent-session-conversation-view' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' @@ -10,7 +15,10 @@ export type AgentSessionStoreState = { operations: Map retiredClaimKeys: RetiredAgentSessionClaimKey[] /** Rows this build cannot validate, kept with a durable refusal reason. */ - unreadableRecords: Map + unreadableRecords: Map< + string, + { reason: string; raw: unknown; conversation?: AgentSessionConversationView } + > /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | null /** Prior runtime endings, read at load; missing/null evidence attributes no death to Orca. */ @@ -20,3 +28,50 @@ export type AgentSessionStoreState = { export function heldAgentSessionIds(state: AgentSessionStoreState): string[] { return [...state.records.keys(), ...state.unreadableRecords.keys()] } + +export function getAgentSessionConversation( + state: AgentSessionStoreState, + id: string +): AgentSessionConversationView | null { + const record = state.records.get(id) + return record + ? agentSessionConversationView(record) + : (state.unreadableRecords.get(id)?.conversation ?? null) +} + +export function hasAgentSessionConversation(state: AgentSessionStoreState, id: string): boolean { + return state.records.has(id) || !!state.unreadableRecords.get(id)?.conversation +} + +export function listAgentSessionConversations( + state: AgentSessionStoreState +): AgentSessionConversationView[] { + return [...state.records.values()] + .map(agentSessionConversationView) + .concat( + [...state.unreadableRecords.values()].flatMap((row) => + row.conversation ? [row.conversation] : [] + ) + ) +} + +export function selectWritableAgentSessionRecord(state: AgentSessionStoreState, id: string) { + const record = state.records.get(id) + return record + ? { kind: 'writable' as const, record } + : { kind: state.unreadableRecords.has(id) ? ('unreadable' as const) : ('missing' as const) } +} + +/** The shared authority boundary for any fresh effect, distinct from conversation presence. */ +export function requireWritableAgentSessionRecord( + state: AgentSessionStoreState, + id: string +): AgentSessionRecord { + const selected = selectWritableAgentSessionRecord(state, id) + if (selected.kind === 'writable') { + return selected.record + } + throw selected.kind === 'unreadable' + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) +} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index a64eafec526..30508c42e87 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -5,7 +5,10 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' -import type { AgentSessionStoreState } from './agent-session-store-state' +import { + requireWritableAgentSessionRecord, + type AgentSessionStoreState +} from './agent-session-store-state' import { insertAgentSessionOperationRowsIfAbsent, writeAgentSessionStoreRows @@ -149,7 +152,7 @@ export class AgentSessionStoreTransactions { // The ledger and wake share one draft as well as one commit. staged = this.stage((draft) => { apply(draft) - const record = admitted ? draft.records.get(admitted) : undefined + const record = admitted ? requireWritableAgentSessionRecord(draft, admitted) : undefined if (record?.sleptAt !== undefined) { const { sleptAt: _sleptAt, ...awake } = record draft.records.set(record.sessionId, { diff --git a/src/main/runtime/agent-session/agent-session-replaced-runtime.ts b/src/main/runtime/agent-session/agent-session-replaced-runtime.ts index c3d32abca90..c3c8ae4e6a7 100644 --- a/src/main/runtime/agent-session/agent-session-replaced-runtime.ts +++ b/src/main/runtime/agent-session/agent-session-replaced-runtime.ts @@ -2,7 +2,10 @@ // Attribute only runtimes this host replaced; terminal claims and unknown owners provide no such evidence. import type { AgentSessionOrcaStopCause } from '../../../shared/agent-session-orca-stop' -import type { AgentSessionStoreState } from '../agent-session-store-state' +import { + listAgentSessionConversations, + type AgentSessionStoreState +} from '../agent-session-store-state' export type AgentSessionReplacedRuntime = { /** The highest fence a replaced runtime granted; every lower fence was granted before it. */ @@ -18,7 +21,9 @@ export function agentSessionReplacedRuntimes( args: { hostId: string; incarnation: string } ): ReadonlyMap { const replaced = new Map() - for (const [sessionId, { lease }] of state.records) { + for (const { sessionId, ownerObservation: lease, currentFence } of listAgentSessionConversations( + state + )) { const owner = lease.ownerProcess // Exclude terminal claims, other hosts and this process's reinstalled runtime, which may still own a live transport. if ( @@ -29,7 +34,7 @@ export function agentSessionReplacedRuntimes( } const runtimeEnd = owner?.runtime ? state.runtimeEnds?.get(owner.runtime) : undefined replaced.set(sessionId, { - fence: lease.runtimeFence, + fence: currentFence, ...(owner ? { lastProvenAliveAt: lease.lastRenewedAt } : {}), ...(runtimeEnd ? { runtimeEnd } : {}) }) diff --git a/src/main/runtime/agent-session/agent-session-tab-table.ts b/src/main/runtime/agent-session/agent-session-tab-table.ts index 6a36bc81f6f..6ee848c52f8 100644 --- a/src/main/runtime/agent-session/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session/agent-session-tab-table.ts @@ -1,6 +1,11 @@ +import { isAgentSessionSurfaceTabId } from '../../../shared/agent-session-surface-tab-id' +import type { AgentSessionReserveRequest } from './ownership/agent-session-reservation-admission' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' -import type { AgentSessionStoreState } from '../agent-session-store-state' +import { + hasAgentSessionConversation, + type AgentSessionStoreState +} from '../agent-session-store-state' /** Tab membership defines visibility; tab and session ids form a one-to-one mapping. */ export class AgentSessionTabTable { @@ -76,7 +81,9 @@ export class AgentSessionTabTable { } export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] { - return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) + return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => + hasAgentSessionConversation(state, sessionId) + ) } export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { @@ -92,7 +99,7 @@ export function setAgentSessionTabVisibility( visible: boolean, tabId?: string ): void { - if (visible && !state.records.has(sessionId)) { + if (visible && !hasAgentSessionConversation(state, sessionId)) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() @@ -108,10 +115,29 @@ export function showAgentSessionTabs( sessionIds: readonly string[] ): void { for (const sessionId of sessionIds) { - if (state.records.has(sessionId)) { + if (hasAgentSessionConversation(state, sessionId)) { setAgentSessionTabVisibility(state, sessionId, true) } } } export type PersistedAgentSessionTab = { tabId: string; sessionId: string } + +/** Check tab-id conflicts before spawn; claim only on publication so failed creates leave no restorable tab. */ +export function assertReservedTabUnheld( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): void { + if (request.surfaceTabId === undefined) { + return + } + if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + if (holder !== undefined && holder !== request.sessionId) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) + } +} diff --git a/src/main/runtime/agent-session/ownership/agent-session-lease-renewal.ts b/src/main/runtime/agent-session/ownership/agent-session-lease-renewal.ts index 9572057d774..fd995c09e23 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-lease-renewal.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-lease-renewal.ts @@ -1,7 +1,10 @@ import type { AgentSessionOwnerProbe } from '../../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' import { renewAgentSessionLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from '../../agent-session-store-state' +import { + requireWritableAgentSessionRecord, + type AgentSessionStoreState +} from '../../agent-session-store-state' export type AgentSessionLeaseRenewal = { sessionId: string @@ -17,14 +20,7 @@ export function renewAgentSessionLeases( defaultLeaseTtlMs: number ): AgentSessionRecord[] { return renewals.map((args) => { - const record = state.records.get(args.sessionId) - if (!record) { - throw new Error( - state.unreadableRecords.has(args.sessionId) - ? 'execution_owner_reconciling' - : 'agent_session_identity_required' - ) - } + const record = requireWritableAgentSessionRecord(state, args.sessionId) const renewed = renewAgentSessionLease({ record, fence: args.fence, diff --git a/src/main/runtime/agent-session/ownership/agent-session-operation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-operation-admission.ts index 39cc12600cb..284dc017e05 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-operation-admission.ts @@ -1,3 +1,10 @@ +import { + getAgentSessionConversation, + selectWritableAgentSessionRecord, + requireWritableAgentSessionRecord, + type AgentSessionStoreState +} from '../../agent-session-store-state' +import type { AgentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import { agentSessionOperationKey, agentSessionOperationExpiry, @@ -18,8 +25,6 @@ import { type AgentSessionMutationAdmission } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../../shared/agent-session-record' -import type { AgentSessionStoreState } from '../../agent-session-store-state' import { AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' export type AgentSessionOperationAdmission = { @@ -82,7 +87,7 @@ export type AgentSessionMutationOperationAdmission = { export type AgentSessionMutationOperationDecision = { admission: AgentSessionMutationAdmission - record: AgentSessionRecord + record: AgentSessionConversationView } | null type EvaluatedOperationRows = { rows: OperationRows; decision: AgentSessionOperationDecision } @@ -146,10 +151,10 @@ export function admitAgentSessionGlobalOperationRow( /** Evaluate mutation replay before acquiring an owner; writes nothing, checks no lease, null means no record. */ export function evaluateAgentSessionMutationOperation( - state: Pick, + state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission -): { decision: AgentSessionOperationDecision; record: AgentSessionRecord } | null { - const record = state.records.get(args.envelope.sessionId) +): { decision: AgentSessionOperationDecision; record: AgentSessionConversationView } | null { + const record = getAgentSessionConversation(state, args.envelope.sessionId) if (!record) { return null } @@ -176,8 +181,11 @@ export function admitAgentSessionMutationOperation( state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission ): AgentSessionMutationOperationDecision { - const record = state.records.get(args.envelope.sessionId) + const record = getAgentSessionConversation(state, args.envelope.sessionId) if (!record) { + if (selectWritableAgentSessionRecord(state, args.envelope.sessionId).kind === 'unreadable') { + requireWritableAgentSessionRecord(state, args.envelope.sessionId) + } return null } const operation = mutationOperation(args) @@ -188,7 +196,7 @@ export function admitAgentSessionMutationOperation( envelope: args.envelope, hostFingerprint: args.hostFingerprint, ledger: ledger.decision, - lease: record.lease, + lease: () => requireWritableAgentSessionRecord(state, args.envelope.sessionId).lease, ...(args.conversationWrite ? { conversationWrite: true } : {}) }) if (ledger.decision.decision === 'admit' && admission.decision === 'refused') { diff --git a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts index 5e128514908..89b4e653e39 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-reservation-admission.ts @@ -27,7 +27,6 @@ import { type AgentSessionRecord } from '../../../../shared/agent-session-record' import { isAgentSessionLaunchArgs } from '../../../../shared/agent-session-launch-args' -import { isAgentSessionSurfaceTabId } from '../../../../shared/agent-session-surface-tab-id' import { agentSessionProviderHandleRoot } from '../../../../shared/agent-session-provider-handle-encoding' import type { StructuredAgentId, @@ -37,12 +36,19 @@ import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from '../../agent-session-store-state' +import { + listAgentSessionConversations, + selectWritableAgentSessionRecord, + requireWritableAgentSessionRecord, + type AgentSessionStoreState +} from '../../agent-session-store-state' import { agentSessionRecordIdentityFields } from '../../agent-session-record-founding' import { agentSessionAccountHomesEqual, type AgentSessionAccountHome } from '../../../../shared/agent-session-account-home' +import { assertReservedTabUnheld } from '../agent-session-tab-table' +export { assertReservedTabUnheld } from '../agent-session-tab-table' export type AgentSessionReserveRequest = { /** Host-resolved floating directory committed with the first owner reservation. */ @@ -107,12 +113,11 @@ export function requireAgentSessionRecordForReplay( sessionId: string ): AgentSessionRecord { const replayedId = row.outcome.status === 'succeeded' ? row.outcome.sessionId : sessionId - const record = state.records.get(replayedId) - if (!record) { + if (selectWritableAgentSessionRecord(state, replayedId).kind === 'missing') { // Missing recorded effects cannot be replayed; running again would spawn twice. throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'recordMissing' }) } - return record + return requireWritableAgentSessionRecord(state, replayedId) } export function admitPendingAgentSessionReservationReplay( @@ -169,17 +174,18 @@ export function applyAgentSessionReservation( } // Check inside the transaction: concurrent adoptions mint different session ids, so CAS alone cannot prevent two writers on one provider conversation. assertAdoptedConversationUnowned(state, request) - const existing = state.records.get(request.sessionId) - if (!existing) { - if (state.unreadableRecords.has(request.sessionId)) { - throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) - } + const selected = selectWritableAgentSessionRecord(state, request.sessionId) + if (selected.kind === 'unreadable') { + requireWritableAgentSessionRecord(state, request.sessionId) + } + if (selected.kind !== 'writable') { if (request.expectedFence !== null) { throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } } + const existing = selected.record if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || existing.provider !== request.provider || @@ -222,7 +228,7 @@ export function assertAdoptedConversationUnowned( return } const root = agentSessionProviderHandleRoot(adopted.handle) - for (const record of state.records.values()) { + for (const record of listAgentSessionConversations(state)) { if (record.sessionId === request.sessionId) { continue } @@ -244,25 +250,6 @@ export function assertAdoptedConversationUnowned( } } -/** Check tab-id conflicts before spawn; claim only on publication so failed creates leave no restorable tab. */ -export function assertReservedTabUnheld( - state: AgentSessionStoreState, - request: AgentSessionReserveRequest -): void { - if (request.surfaceTabId === undefined) { - return - } - if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'requestMalformed' - }) - } - const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) - if (holder !== undefined && holder !== request.sessionId) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) - } -} - function createAgentSessionRecord( request: AgentSessionReserveRequest, reservation: AgentSessionReservation diff --git a/src/main/runtime/agent-session/structured-agent-session-close.test.ts b/src/main/runtime/agent-session/structured-agent-session-close.test.ts index aacd9baad88..4927589e572 100644 --- a/src/main/runtime/agent-session/structured-agent-session-close.test.ts +++ b/src/main/runtime/agent-session/structured-agent-session-close.test.ts @@ -1,3 +1,8 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' // Unproven close must restore previously visible tabs so running conversations remain in the durable restore index. import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,24 +21,11 @@ const SESSION = 'session-1' const TAB_ID = 'tab-of-session-1' function record(sessionId: string): AgentSessionRecord { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) return { - sessionId, - provider: 'claude', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'repo_1::/tmp/wt-a', - workspaceKind: 'folder' - }, - lease: { - sessionId, - runtimeKind: 'native', - claimStatus: 'live', - handoffStage: null, - runtimeFence: 1, - deathEvidence: null - } - } as unknown as AgentSessionRecord + ...record, + location: { ...record.location, workspaceId: 'repo_1::/tmp/wt-a', workspaceKind: 'folder' } + } } type HostOptions = { @@ -88,7 +80,8 @@ function installHost(options: HostOptions = {}) { deps: { logger: log.logger, store: { - getRecord: (id: string) => (id === SESSION ? entry : null), + getConversation: (id: string) => + id === SESSION ? agentSessionConversationView(entry) : null, getSessionTabId: (id: string) => { if (options.indexThrows) { throw new Error('visible tab index unreadable') diff --git a/src/main/runtime/orca-runtime-desktop-tab-names.test.ts b/src/main/runtime/orca-runtime-desktop-tab-names.test.ts index d004ddb37a0..dfca83c4e2b 100644 --- a/src/main/runtime/orca-runtime-desktop-tab-names.test.ts +++ b/src/main/runtime/orca-runtime-desktop-tab-names.test.ts @@ -32,7 +32,7 @@ it("publishes the desktop's chat tab name over the saved name, and the saved nam setStructuredAgentSessionHost({ deps: { store: { - getRecord: () => ({ + getConversation: () => ({ location: { workspaceId: TEST_WORKTREE_ID }, conversationName: 'auth/login' }) diff --git a/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts b/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts index 441601090f7..3df239a9452 100644 --- a/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts +++ b/src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts @@ -1,3 +1,10 @@ +import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../shared/agent-session-record' import { afterEach, describe, expect, it, vi } from 'vitest' import type { PtyProcessInfo } from '../providers/pty-process-info' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -95,15 +102,15 @@ describe('structured worker incarnation liveness', () => { const INCARNATION = `structured:${SESSION}` const LOCAL_SCOPE = JSON.stringify({ kind: 'local', hostId: 'local' }) - function installHost(lease: Record): void { - setStructuredAgentSessionHost({ - hasSession: () => false, - deps: { - store: { - getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null }, lease }) - } - } - } as never) + function installHost(lease: Partial): void { + const { host, store } = hostTestState() + vi.spyOn(host, 'hasSession').mockReturnValue(false) + vi.spyOn(store, 'getConversation').mockImplementation(() => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ ...lease, sessionId: SESSION })) + ) + ) + setStructuredAgentSessionHost(host) } afterEach(() => { diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index b6c126f61f0..2b5bb462b20 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { prepareAdoptedTranscript } from '../native-chat/agent-session-wire/structured-agent-session-adopted-import' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -106,7 +107,7 @@ describe('structured agent-session create intent', () => { const record = agentSessionRecordFixture() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: early ownership admission only reads the host's record list. setStructuredAgentSessionHost({ - deps: { store: { listRecords: () => [record] } } + deps: { store: { listConversations: () => [agentSessionConversationView(record)] } } } as unknown as StructuredAgentSessionHost) const runtime = new OrcaRuntimeService() const support = vi @@ -139,7 +140,7 @@ describe('structured agent-session create intent', () => { const record = agentSessionRecordFixture() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: early ownership admission only reads the host's record list. setStructuredAgentSessionHost({ - deps: { store: { listRecords: () => [record] } } + deps: { store: { listConversations: () => [agentSessionConversationView(record)] } } } as unknown as StructuredAgentSessionHost) const runtime = new OrcaRuntimeService() const writers = vi.spyOn(runtime, 'assertStructuredAdoptionTerminalWritersExited') diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 9b5ec6523cf..fa3399705ce 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -19,7 +19,10 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ deps: { store: { - getRecord: () => ({ location: { workspaceId: 'workspace-1' }, conversationName: name }) + getConversation: () => ({ + location: { workspaceId: 'workspace-1' }, + conversationName: name + }) } }, conversationReplacements: () => [] diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index fb949ea8385..04b7d9c2f15 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -16,6 +16,7 @@ import type { PtyProcessInfo } from '../../providers/pty-provider-contract' import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' import type { RuntimePtyController } from '../runtime-pty-controller-contract' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { AgentSessionRecordStore } from '../agent-session-record-store' import { agentSessionLeaseFixture, @@ -178,7 +179,7 @@ describe('resumeAgentSessionInWorkspace', () => { chat.providerHandleChain[0]!.handle.nativeId = SESSION.id let nativeOpen = true const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) - store.listRecords = () => [chat] + store.listConversations = () => [agentSessionConversationView(chat)] store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: nativeOpen ? [chat.sessionId] : [] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a8ea5a137dd..2d74248d146 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,3 +1,4 @@ +import { conversationRecordReader } from './orchestration/structured-session-lineage' import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { publishWorkspaceViewChanges } from './runtime-workspace-view-change-events' @@ -153,7 +154,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { createReferenceAgentIndex( this, this.getOrchestrationDbIfAvailable(), - await this.openAgentSessionRecordStore(), + conversationRecordReader(await this.openAgentSessionRecordStore()), workspaceKeys, referenceConnectionHosts(this.listRepos()) ) diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index c36f78eb6d4..40c7ce30d1b 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -5,6 +5,37 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { Store } from '../persistence/loading-store/store' +import { createSqliteTestStore, closeTestStores } from '../persistence/persistence-test-harness' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' +import { parseWorkspaceReferenceUrl } from '../../shared/workspace-reference-identity' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { isOrchestrationRetryRequestId } from '../../shared/orchestration-retry-request-id' +import { + CALLER, + hostTestState, + hostTestRecoveryCapsuleSettled, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + hostTestAttachParams, + hostTestOperationId +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { abandonStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host-test-abandon' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + openTestJournalHostDatabase, + closeTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStoreText +} from './agent-session-record-store-test-harness' +import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' + const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js') const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip @@ -389,3 +420,326 @@ describeIfBuilt('orca orchestration task readiness subprocess', () => { } }, 30_000) }) + +describeIfBuilt('native caller subprocess over durable records', () => { + // Real CLI subprocess round trips include store reopen, retry/refusal checks and IPC cleanup. + it('saves and replays the native caller result, then separates damaged admission from reference reads', async () => { + const [{ OrcaRuntimeService }, { OrchestrationDb }, { OrcaRuntimeRpcServer }] = + await loadRuntimeModules() + const rig = hostTestState() + const sessionId = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') + const invalidId = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') + const absentId = testOrcaSessionId('11111111-2222-4333-8444-555555555555') + const repoPath = join(rig.root, 'workspace') + const workspaceId = `cli_repo::${repoPath}` + const profile = createSqliteTestStore(Store, { dataFile: join(rig.root, 'cli-data.json') }) + profile.addRepo({ + id: 'cli_repo', + path: repoPath, + displayName: 'CLI fixture', + badgeColor: 'gray', + addedAt: 1 + }) + profile.setWorktreeMeta(hostWorktreeTarget({ id: workspaceId, hostId: 'local' }), { + displayName: 'CLI fixture', + linkedItems: [ + { + ...parseWorkspaceReferenceUrl('https://linear.app/acme/issue/STA-1234'), + origins: [ + { + kind: 'observed', + hostId: 'local', + agent: 'codex', + sessionId, + tabId: structuredAgentSessionTabId(sessionId) + } + ] + } + ] + }) + const runtime = new OrcaRuntimeService(profile) + const db = new OrchestrationDb(join(rig.root, 'orchestration.db')) + runtime.setOrchestrationDb(db) + let host = rig.host + let store = rig.store + setStructuredAgentSessionHost(host) + // Install the complete test host; all admission, SQLite, RPC and reference logic stays real. + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => store) + const terminalLookup = vi.spyOn(runtime, 'getTerminalPaneKey') + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath: rig.root }) + const nativeEnv = { + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_TERMINAL_HANDLE: 'term_stale_cli', + ORCA_PANE_KEY: 'tab_stale:11111111-1111-4111-8111-111111111111', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: '' + } + const command = [ + 'orchestration', + 'run-create', + '--objective', + 'native CLI saved result', + '--json' + ] + const state = () => ({ + runs: db.db.prepare('SELECT * FROM runs ORDER BY id').all(), + mail: db.db.prepare('SELECT * FROM messages ORDER BY id').all(), + receipts: db.db + .prepare('SELECT * FROM mutation_receipts ORDER BY caller_fingerprint, request_id') + .all(), + dispatches: db.db.prepare('SELECT * FROM dispatch_contexts ORDER BY id').all() + }) + try { + const params = hostTestAttachParams(null, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '0'.repeat(64) + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' + } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await store.setSessionTabVisibility(sessionId, true) + expect(store.getRecord(sessionId)?.lease).toMatchObject({ + claimStatus: 'live', + unreconciled: false, + handoffStage: null + }) + await server.start() + const created = await runBuiltCli(rig.root, command, nativeEnv) + expect(created.exitCode, created.stderr).toBe(0) + const result = resultOf(JSON.parse(created.stdout)) + const runId = idOf(result.run) + if (!isRecord(result.mutation) || typeof result.mutation.requestId !== 'string') { + throw new Error(`Missing actual mutation request ID: ${created.stdout}`) + } + const requestId = result.mutation.requestId + expect(result.mutation).toMatchObject({ replayed: false }) + expect(db.getRun(runId)).toMatchObject({ + objective: 'native CLI saved result', + coordinator_orca_session_id: sessionId, + coordinator_handle: null, + coordinator_pane_key: null + }) + expect( + terminalLookup.mock.calls.every( + ([handle]) => handle === formatOrcaSessionAddress(sessionId) + ) + ).toBe(true) + expect( + terminalLookup.mock.results.every( + (result) => result.type === 'return' && result.value === null + ) + ).toBe(true) + const saved = state() + expect(saved.receipts).toHaveLength(1) + expect(saved.receipts[0]).toMatchObject({ + request_id: requestId, + method: 'orchestration.runCreate', + state: 'completed' + }) + const retry = await runBuiltCli( + rig.root, + [...command, '--retry-request', requestId], + nativeEnv + ) + expect(retry.exitCode, retry.stderr).toBe(0) + expect(resultOf(JSON.parse(retry.stdout))).toMatchObject({ + run: { id: runId }, + mutation: { requestId, replayed: true } + }) + expect(state()).toEqual(saved) + const wrong = await runBuiltCli( + rig.root, + [...command, '--from', formatOrcaSessionAddress(invalidId)], + nativeEnv + ) + expect(wrong.exitCode).toBe(1) + expect(JSON.parse(wrong.stdout)).toMatchObject({ + ok: false, + error: { code: 'consumer_fenced', data: { effectsApplied: false } } + }) + expect(state()).toEqual(saved) + + await abandonStructuredAgentSessionHost(host) + await hostTestRecoveryCapsuleSettled() + const nativeDb = openTestJournalHostDatabase(rig.root).db + const before = nativeDb + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(sessionId) + expect(before).toBeDefined() + nativeDb + .prepare(` + UPDATE agent_session_records + SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') + WHERE session_id = ? + `) + .run(sessionId) + const damaged = nativeDb + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(sessionId) + if (typeof before?.record_json !== 'string' || typeof damaged?.record_json !== 'string') { + throw new Error('Expected persisted native record JSON') + } + const original: unknown = JSON.parse(before.record_json) + if (!isRecord(original) || !isRecord(original.lease)) { + throw new Error('Expected checked original record and lease') + } + expect(JSON.parse(damaged.record_json)).toEqual({ + ...original, + lease: { ...original.lease, minimumNextFence: 'bad' } + }) + // A second visible raw row must be excluded because its conversation cannot be checked. + nativeDb + .prepare(` + INSERT INTO agent_session_records (session_id, record_json) + SELECT ?, json_set(record_json, '$.sessionId', ?, '$.lease.sessionId', ?, + '$.providerHandleChain', 'bad') + FROM agent_session_records WHERE session_id = ? + `) + .run(invalidId, invalidId, invalidId, sessionId) + nativeDb + .prepare(` + INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, 1) + `) + .run(structuredAgentSessionTabId(invalidId), invalidId) + closeTestJournalHostDatabase(rig.root) + store = await openTestAgentSessionRecordStore(rig.root) + host = new StructuredAgentSessionHost({ + ...rig.host.deps, + store, + journalDatabase: openTestJournalHostDatabase(rig.root) + }) + replaceHostTestState({ host, store }) + setStructuredAgentSessionHost(host) + expect(store.getConversation(sessionId)?.sessionId).toBe(sessionId) + expect(store.getRecord(sessionId)).toBeNull() + expect(store.getConversation(invalidId)).toBeNull() + expect(store.getVisibleSessionTabIndex().sessionIds).toEqual([sessionId]) + const raw = await readPersistedTestAgentSessionStoreText(rig.root) + const acquisitions = rig.acquire.mock.calls.length + const dispatches = rig.dispatch.mock.calls.length + const refused = await runBuiltCli( + rig.root, + ['orchestration', 'run-create', '--objective', 'fresh damaged caller', '--json'], + nativeEnv + ) + expect(refused.exitCode).toBe(1) + const refusedPayload: unknown = JSON.parse(refused.stdout) + if ( + !isRecord(refusedPayload) || + !isRecord(refusedPayload.error) || + !isRecord(refusedPayload.error.data) || + !isOrchestrationRetryRequestId(refusedPayload.error.data.orchestrationRequestId) + ) { + throw new Error('Expected fresh mutation refusal with a valid request ID') + } + const failedRequestId = refusedPayload.error.data.orchestrationRequestId + expect(refusedPayload).toMatchObject({ + ok: false, + error: { + code: 'execution_owner_reconciling', + message: `execution_owner_reconciling Orchestration mutation request ID: ${failedRequestId}.`, + data: { + orchestrationRequestId: failedRequestId, + refusal: { + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' } + } + } + } + }) + // Caller admission precedes receipt replay even when the old mutation was completed. + const damagedRetry = await runBuiltCli( + rig.root, + [...command, '--retry-request', requestId], + nativeEnv + ) + expect(damagedRetry.exitCode).toBe(1) + const damagedRetryPayload: unknown = JSON.parse(damagedRetry.stdout) + if ( + !isRecord(damagedRetryPayload) || + !isRecord(damagedRetryPayload.error) || + !isRecord(damagedRetryPayload.error.data) || + !isOrchestrationRetryRequestId(damagedRetryPayload.error.data.orchestrationRequestId) + ) { + throw new Error('Expected retry mutation refusal with a valid request ID') + } + expect(damagedRetryPayload.error.data.orchestrationRequestId).toBe(requestId) + expect(damagedRetryPayload).toMatchObject({ + ok: false, + error: { + code: 'execution_owner_reconciling', + message: `execution_owner_reconciling Orchestration mutation request ID: ${requestId}.`, + data: { + orchestrationRequestId: requestId, + refusal: { + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' } + } + } + } + }) + const absent = await runBuiltCli(rig.root, command, { + ...nativeEnv, + ORCA_AGENT_SESSION_ID: absentId + }) + expect(absent.exitCode).toBe(1) + expect(JSON.parse(absent.stdout)).toMatchObject({ + ok: false, + error: { code: 'session_caller_unknown', data: { effectsApplied: false } } + }) + const found = await runBuiltCli( + rig.root, + ['reference', 'find', '--query', 'STA-1234', '--json'], + { ...nativeEnv, ORCA_AGENT_SESSION_ID: '', ORCA_TERMINAL_HANDLE: '', ORCA_PANE_KEY: '' } + ) + expect(found.exitCode, found.stderr).toBe(0) + const references = resultOf(JSON.parse(found.stdout)) + expect(references).toMatchObject({ kind: 'reference_matches', truncated: false }) + if (!Array.isArray(references.matches) || references.matches.length !== 1) { + throw new Error(`Expected the saved issue workspace: ${found.stdout}`) + } + expect(references.matches[0]).toMatchObject({ + workspace: { id: workspaceId }, + agents: [ + { + sessionId, + agent: 'codex', + liveness: 'unverifiable', + linked: true, + mailbox: formatOrcaSessionAddress(sessionId) + } + ] + }) + expect(state()).toEqual(saved) + expect(await readPersistedTestAgentSessionStoreText(rig.root)).toBe(raw) + expect(rig.acquire).toHaveBeenCalledTimes(acquisitions) + expect(rig.dispatch).toHaveBeenCalledTimes(dispatches) + expect( + terminalLookup.mock.calls.every( + ([handle]) => handle === formatOrcaSessionAddress(sessionId) + ) + ).toBe(true) + expect( + terminalLookup.mock.results.every( + (result) => result.type === 'return' && result.value === null + ) + ).toBe(true) + } finally { + await server.stop() + await abandonStructuredAgentSessionHost(host) + await hostTestRecoveryCapsuleSettled() + setStructuredAgentSessionHost(null) + await closeTestStores() + db.close() + vi.restoreAllMocks() + } + }, 60_000) +}) diff --git a/src/main/runtime/orchestration/canonical-orca-session-id.ts b/src/main/runtime/orchestration/canonical-orca-session-id.ts index acc9137e0f3..ff2808eb45a 100644 --- a/src/main/runtime/orchestration/canonical-orca-session-id.ts +++ b/src/main/runtime/orchestration/canonical-orca-session-id.ts @@ -8,7 +8,7 @@ import { /** Address by the /clear root to preserve Runs and mail; without records, retain the supplied id. */ export function canonicalOrcaSessionId( orcaSessionId: OrcaSessionId, - store: AgentSessionRecordReader | null = readAgentSessionRecordStore() + store: Pick | null = readAgentSessionRecordStore() ): OrcaSessionId { if (!store) { return orcaSessionId diff --git a/src/main/runtime/orchestration/chat-assignee.test.ts b/src/main/runtime/orchestration/chat-assignee.test.ts index 5b1ac6252a5..bf374c9eeee 100644 --- a/src/main/runtime/orchestration/chat-assignee.test.ts +++ b/src/main/runtime/orchestration/chat-assignee.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' @@ -9,8 +10,9 @@ const CHAT = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') function store(record: AgentSessionRecord, visible: boolean): AgentSessionRecordReader { return { - getRecord: (sessionId) => (sessionId === record.sessionId ? record : null), - listRecords: () => [record], + getRecord: (sessionId) => + sessionId === record.sessionId ? agentSessionConversationView(record) : null, + listRecords: () => [agentSessionConversationView(record)], getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible ? [CHAT] : [] }) } } diff --git a/src/main/runtime/orchestration/chat-assignee.ts b/src/main/runtime/orchestration/chat-assignee.ts index 2817b6e2939..5f0d77b5f8c 100644 --- a/src/main/runtime/orchestration/chat-assignee.ts +++ b/src/main/runtime/orchestration/chat-assignee.ts @@ -1,4 +1,4 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { ORCA_SESSION_ADDRESS_PREFIX, parseOrcaSessionAddress, @@ -23,7 +23,7 @@ export function chatAssigneeSessionId( export type ChatAssigneeObservation = /** `session` is the conversation's live session: its `/clear` successor, if it has one. */ - | { status: 'live'; session: AgentSessionRecord } + | { status: 'live'; session: AgentSessionConversationView } | { status: 'exited'; reason: string } | { status: 'unverifiable'; reason: string } diff --git a/src/main/runtime/orchestration/chat-worker-error-operation-counts.test-support.ts b/src/main/runtime/orchestration/chat-worker-error-operation-counts.test-support.ts index 1cd2f31c9ac..68dcf931206 100644 --- a/src/main/runtime/orchestration/chat-worker-error-operation-counts.test-support.ts +++ b/src/main/runtime/orchestration/chat-worker-error-operation-counts.test-support.ts @@ -75,8 +75,8 @@ export async function countChatFailureOperations( return result } ) - const list = store.listRecords.bind(store) - vi.spyOn(store, 'listRecords').mockImplementation(() => { + const list = store.listConversations.bind(store) + vi.spyOn(store, 'listConversations').mockImplementation(() => { const records = list() counts.lineageCopied += records.length return records.map((record) => ({ @@ -87,7 +87,9 @@ export async function countChatFailureOperations( } })) }) - vi.spyOn(lineage, 'readAgentSessionRecordStore').mockReturnValue(store) + vi.spyOn(lineage, 'readAgentSessionRecordStore').mockReturnValue( + lineage.conversationRecordReader(store) + ) const visit = journal.visitItems.bind(journal) vi.spyOn(journal, 'visitItems').mockImplementation((callback) => visit((...args) => { diff --git a/src/main/runtime/orchestration/orchestration-party-location.test.ts b/src/main/runtime/orchestration/orchestration-party-location.test.ts index 248eaf443e5..e5844943a1f 100644 --- a/src/main/runtime/orchestration/orchestration-party-location.test.ts +++ b/src/main/runtime/orchestration/orchestration-party-location.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -57,7 +58,13 @@ function clearedChat(): AgentSessionRecordReader { ], [LIVE, record(LIVE)] ]) - return { getRecord: (id) => records.get(id) ?? null, listRecords: () => [...records.values()] } + return { + getRecord: (id) => { + const record = records.get(id) + return record ? agentSessionConversationView(record) : null + }, + listRecords: () => [...records.values()].map(agentSessionConversationView) + } } /** This run issued `term_now` for PANE; any other handle is from an earlier run. */ diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts index f1e055929c9..2f647d76fe7 100644 --- a/src/main/runtime/orchestration/orchestration-party.ts +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -11,6 +11,7 @@ import { resolveStructuredWorkerIdentityForRoot } from '../structured-worker-authority' import { canonicalOrcaSessionId } from './canonical-orca-session-id' +import type { AgentSessionRecordReader } from './structured-session-lineage' import type { OrchestrationDb } from './db' import { mailboxAddressOf, type OrchestrationCallerIdentity } from './orchestration-caller-identity' import { OrchestrationError } from './orchestration-error' @@ -40,9 +41,10 @@ export const ORCHESTRATION_TARGET_PARAM: Readonly< /** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ export function resolveOrcaSessionParty( orcaSessionId: OrcaSessionId, - db: OrchestrationDb | null | undefined + db: OrchestrationDb | null | undefined, + store?: Pick | null ): OrchestrationSessionParty { - const id = canonicalOrcaSessionId(orcaSessionId) + const id = canonicalOrcaSessionId(orcaSessionId, store) const worker = resolveStructuredWorkerIdentityForRoot(id, db) if (!worker && db && isRecordedStructuredWorkerSession(id, db)) { // Without its handle, one worker would split into two parties and bind as a chat. diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 20a4bce1bc7..c32616d1eb2 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -1,3 +1,8 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem, @@ -83,7 +88,7 @@ describe('structured mailbox pointer host', () => { it("reads what the session's sends settled as", async () => { const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] hostRef.current = { - deps: { store: { getRecord: () => null } }, + deps: { store: { getConversation: () => null } }, journalSnapshot: () => ({ items: [], submissions }), currentWork: () => null } @@ -110,7 +115,11 @@ describe('structured mailbox pointer host', () => { hostRef.current = { deps: { store: { - getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } }) + getConversation: () => + agentSessionConversationView({ + ...agentSessionRecordFixture(), + providerContextBoundary: { operationId: 'clear-1', afterFence: 2, clearedAt: 1_000 } + }) } }, journalSnapshot: () => ({ @@ -131,7 +140,11 @@ describe('structured mailbox pointer host', () => { hostRef.current = { deps: { store: { - getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } }) + getConversation: () => + agentSessionConversationView({ + ...agentSessionRecordFixture(), + providerContextBoundary: { operationId: 'clear-1', afterFence: 2, clearedAt: 1_000 } + }) } }, journalSnapshot: () => ({ @@ -281,9 +294,18 @@ describe('structured mailbox pointer host', () => { }) it('reads the runtime fence off the durable record', () => { - hostRef.current = { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 9 } }) } } } + hostRef.current = { + deps: { + store: { + getConversation: () => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ runtimeFence: 9 })) + ) + } + } + } expect(createStructuredMailboxPointerHost().currentFence('s1')).toBe(9) - hostRef.current = { deps: { store: { getRecord: () => null } } } + hostRef.current = { deps: { store: { getConversation: () => null } } } expect(createStructuredMailboxPointerHost().currentFence('s1')).toBeNull() }) }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 4a87a084597..22c1170ce97 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -45,7 +45,7 @@ async function readPointerSessionFacts( return null } const host = getStructuredAgentSessionHost() - const boundary = host?.deps.store.getRecord(sessionId)?.providerContextBoundary + const boundary = host?.deps.store.getConversation(sessionId)?.providerContextBoundary return { submissions: boundary ? snapshot.submissions.filter((submission) => submission.fence > boundary.afterFence) @@ -86,12 +86,12 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo currentFence(sessionId) { return ( - getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null + getStructuredAgentSessionHost()?.deps.store.getConversation(sessionId)?.currentFence ?? null ) }, currentContextClearOperationId(sessionId) { - return getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) + return getStructuredAgentSessionHost()?.deps.store.getConversation(sessionId) ?.providerContextBoundary?.operationId }, diff --git a/src/main/runtime/orchestration/structured-session-lineage.ts b/src/main/runtime/orchestration/structured-session-lineage.ts index e3491124cfe..d1b5a6e4695 100644 --- a/src/main/runtime/orchestration/structured-session-lineage.ts +++ b/src/main/runtime/orchestration/structured-session-lineage.ts @@ -1,23 +1,36 @@ /** Resolve /clear successors from committed durable pointers on each read; clearing rewrites no callers. */ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { structuredWorkerHostScope } from '../structured-worker-identity' export type AgentSessionRecordReader = { - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords: () => AgentSessionRecord[] + getRecord: (sessionId: string) => AgentSessionConversationView | null + listRecords: () => AgentSessionConversationView[] /** Absent on a store that predates tab visibility; every session then counts as open. */ getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } } /** Null until the agent-session host is installed; callers that must see records ensure it first. */ export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { - return getStructuredAgentSessionHost()?.deps.store ?? null + const store = getStructuredAgentSessionHost()?.deps.store + return store ? conversationRecordReader(store) : null +} + +export function conversationRecordReader(store: { + getConversation: AgentSessionRecordReader['getRecord'] + listConversations: AgentSessionRecordReader['listRecords'] + getVisibleSessionTabIndex: NonNullable +}): AgentSessionRecordReader { + return { + getRecord: store.getConversation, + listRecords: store.listConversations, + getVisibleSessionTabIndex: store.getVisibleSessionTabIndex + } } /** The session a committed `/clear` continued this one in, if any. */ -export function clearedInto(record: AgentSessionRecord): string | null { +export function clearedInto(record: AgentSessionConversationView): string | null { const command = record.conversationCommand return command?.command === 'clear' && command.phase === 'committed' ? (command.replacementSessionId ?? null) @@ -27,7 +40,7 @@ export function clearedInto(record: AgentSessionRecord): string | null { /** The session running a lineage now, its record, and every session of the lineage up to it. */ export type RunningStructuredSession = { readonly sessionId: string - readonly record: AgentSessionRecord + readonly record: AgentSessionConversationView readonly lineage: readonly string[] } @@ -52,7 +65,7 @@ export function resolveLineageRunningSession( } let current = sessionId for (;;) { - let record: AgentSessionRecord | null + let record: AgentSessionConversationView | null try { record = store.getRecord(current) } catch { @@ -89,7 +102,7 @@ export function resolveLineageRunningSession( export function lineageLiveSession( store: AgentSessionRecordReader, sessionId: string -): AgentSessionRecord | null { +): AgentSessionConversationView | null { const running = resolveLineageRunningSession(store, sessionId) return running.kind === 'unverifiable' ? null : running.record } diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index d2b7d5c474e..d8d5c6bd663 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -10,7 +10,7 @@ * points its mail starts it again. For mail, a conversation has ended only when its chat was closed. */ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import type { OrchestrationDb } from './db' @@ -22,7 +22,7 @@ import { } from './structured-session-lineage' export type OrcaAgentSessionLookup = - | { kind: 'found'; record: AgentSessionRecord } + | { kind: 'found'; record: AgentSessionConversationView } /** The id is a provider's own session id, which rotates on `/clear`; this names the Orca id. */ | { kind: 'provider-id'; orcaSessionId: string } | { kind: 'unknown' } @@ -43,7 +43,7 @@ export function lookupOrcaAgentSession( export type StructuredSessionMailReach = /** `session` is the conversation's live session, the one its mail reaches now. */ - | { kind: 'reachable'; session: AgentSessionRecord } + | { kind: 'reachable'; session: AgentSessionConversationView } | { kind: 'other-host' } /** The lineage names a session with no record, or loops: not being able to look, not an end. */ | { kind: 'unverifiable'; reason: string } @@ -52,8 +52,9 @@ export type StructuredSessionMailReach = /** Whether mail to `record`'s conversation can reach the session that runs it now. */ export function structuredSessionMailReach( store: AgentSessionRecordReader, - record: AgentSessionRecord, - db: OrchestrationDb | null | undefined + record: AgentSessionConversationView, + db: OrchestrationDb | null | undefined, + canonicalStore?: Pick | null ): StructuredSessionMailReach { const running = resolveLineageRunningSession(store, record.sessionId) if (running.kind === 'unverifiable') { @@ -63,7 +64,10 @@ export function structuredSessionMailReach( return { kind: 'other-host' } } const live = running.record - if (isOrcaSessionId(live.sessionId) && !addressableSessionParty(live.sessionId, db)) { + if ( + isOrcaSessionId(live.sessionId) && + !addressableSessionParty(live.sessionId, db, canonicalStore) + ) { // Lost worker identity cannot act through the caller resolver and therefore cannot read mail. return { kind: 'ended', reason: 'worker-identity-lost' } } @@ -78,10 +82,11 @@ export function structuredSessionMailReach( /** Exclude only proven lost worker identity, which the party resolver refuses in every role. */ export function addressableSessionParty( sessionId: OrcaSessionId, - db: OrchestrationDb | null | undefined + db: OrchestrationDb | null | undefined, + store?: Pick | null ): OrchestrationSessionParty | null { try { - return resolveOrcaSessionParty(sessionId, db) + return resolveOrcaSessionParty(sessionId, db, store) } catch (error) { if (error instanceof OrchestrationError && error.code === CODES.notLive) { return null diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 045ab38617e..d78b87d9bc9 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -67,8 +68,11 @@ function installStore(record: AgentSessionRecord | null, visible = true): Store hostRef.current = { deps: { store: { - getRecord: (sessionId: string) => store.records.get(sessionId) ?? null, - listRecords: () => [...store.records.values()], + getConversation: (sessionId: string) => { + const record = store.records.get(sessionId) + return record ? agentSessionConversationView(record) : null + }, + listConversations: () => [...store.records.values()].map(agentSessionConversationView), getVisibleSessionTabIndex: () => store.visible } } diff --git a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts index c058bb12015..d6454bd241f 100644 --- a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts +++ b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts @@ -1,5 +1,10 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../../shared/agent-session-record' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' const hostRef: { current: unknown } = { current: null } @@ -24,16 +29,21 @@ abstract class MailboxTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey } } -function installRecord(lease: { runtimeKind: string; claimStatus: string }): void { +function installRecord(lease: Pick): void { hostRef.current = { deps: { store: { - getRecord: (sessionId: string) => - ({ - sessionId, - location: { executionHostId: 'local', wslDistro: null }, - lease: { ...lease, runtimeFence: 1, deathEvidence: null } - }) as unknown as AgentSessionRecord + getConversation: (sessionId: string) => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ...lease, + sessionId, + runtimeFence: 1, + deathEvidence: null + }) + ) + ) } }, hasSession: () => true diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index d1a67a96a23..14eccfa15cc 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -1,6 +1,8 @@ +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionLease } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -27,7 +29,9 @@ const { const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const RUNNING: RunningStructuredSession = { sessionId: SESSION_ID, - record: agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION_ID })), + record: agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION_ID })) + ), lineage: [SESSION_ID] } @@ -58,23 +62,34 @@ function transcript(count: number): AgentJournalRenderItem[] { function installHost(options: { items?: AgentJournalRenderItem[] - lease?: { runtimeKind: string; claimStatus: string } + lease?: Pick hasSession?: boolean tabListed?: boolean }): void { - const lease = options.lease ?? { runtimeKind: 'native', claimStatus: 'live' } + const lease = options.lease ?? { runtimeKind: 'native' as const, claimStatus: 'live' as const } hostRef.current = { deps: { store: { // No committed /clear: each session is its own lineage's root. - listRecords: () => [], - getRecord: (sessionId: string) => - ({ - sessionId, + listConversations: () => [], + getVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [SESSION_ID] : [] + }), + getConversation: (sessionId: string) => { + const base = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId, ...lease, runtimeFence: 1 }) + ) + return agentSessionConversationView({ + ...base, provider: 'codex', - location: { executionHostId: 'local', wslDistro: null }, - lease: { ...lease, runtimeFence: 1, deathEvidence: null } - }) as unknown as AgentSessionRecord + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + providerHandleChain: base.providerHandleChain.map((link) => ({ + ...link, + handle: codexProviderHandle('worker') + })) + }) + } } }, hasSession: () => options.hasSession ?? true, diff --git a/src/main/runtime/project-removal-structured-sessions.test.ts b/src/main/runtime/project-removal-structured-sessions.test.ts index bf5c434eba9..2ffe057fca7 100644 --- a/src/main/runtime/project-removal-structured-sessions.test.ts +++ b/src/main/runtime/project-removal-structured-sessions.test.ts @@ -1,8 +1,13 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' /** Project removal must close structured sessions and retire chat tabs itself; PTY sweeps cannot see them. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import type { RepoHostTarget } from '../../shared/repo-host-identity' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' const hostRef: { current: unknown } = { current: null } @@ -13,23 +18,16 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') const { TEST_REPO_ID, TEST_WORKTREE_ID, store } = await import('./orca-runtime-test-fixtures.spec') -type FakeRecord = { - sessionId: string - provider: 'claude' | 'codex' - location: { executionHostId: string; workspaceId: string } - lease: { claimStatus: string; deathEvidence: unknown } +function record( + sessionId: string, + workspaceId: string, + executionHostId: ExecutionHostId = 'local' +) { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { ...record, location: { ...record.location, executionHostId, workspaceId } } } -function record(sessionId: string, workspaceId: string, executionHostId = 'local'): FakeRecord { - return { - sessionId, - provider: 'claude', - location: { executionHostId, workspaceId }, - lease: { claimStatus: 'live', deathEvidence: null } - } -} - -function installHost(records: FakeRecord[], detached: string[] = []) { +function installHost(records: ReturnType[], detached: string[] = []) { const held = new Set( records.map((entry) => entry.sessionId).filter((id) => !detached.includes(id)) ) @@ -38,8 +36,11 @@ function installHost(records: FakeRecord[], detached: string[] = []) { hostRef.current = { deps: { store: { - listRecords: () => records, - getRecord: (sessionId: string) => records.find((entry) => entry.sessionId === sessionId), + listConversations: () => records.map(agentSessionConversationView), + getConversation: (sessionId: string) => { + const entry = records.find((entry) => entry.sessionId === sessionId) + return entry ? agentSessionConversationView(entry) : null + }, getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null) }, logger: { warn: () => {} } diff --git a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts index c1eab28da0a..51e9c8606d3 100644 --- a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import '../unused-default-rpc-methods.test-fixture' import { afterEach, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' @@ -22,7 +23,9 @@ it('projects the serving record before returning paired-host row identity', asyn { ...record.providerHandleChain[0]!, handle: claudeProviderHandle('provider-session', null) } ] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. - setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + setStructuredAgentSessionHost({ + deps: { store: { listConversations: () => [agentSessionConversationView(record)] } } + } as never) const row: AiVaultSession = { id: 'local:claude:provider-session', executionHostId: 'local', @@ -92,7 +95,9 @@ it('owns indexed search hits only for a client that can open the native chat', a { ...record.providerHandleChain[0]!, handle: claudeProviderHandle('provider-session', null) } ] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. - setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + setStructuredAgentSessionHost({ + deps: { store: { listConversations: () => [agentSessionConversationView(record)] } } + } as never) const hit = { agent: 'claude' as const, sessionId: 'provider-session', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index 022cadcaf10..481b289e946 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -1,3 +1,9 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../../../shared/agent-session-record' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { StructuredWorkerIdentity } from '../../structured-worker-identity' @@ -41,29 +47,30 @@ const ITEMS: AgentJournalRenderItem[] = [ function installHost(options: { items?: AgentJournalRenderItem[] hasSession?: boolean - claimStatus?: string - deathEvidence?: unknown - record?: unknown + claimStatus?: AgentSessionLease['claimStatus'] + deathEvidence?: AgentSessionLease['deathEvidence'] + record?: null close?: () => Promise setSessionTabVisibility?: () => Promise historyThrows?: boolean tabListed?: boolean }) { const record = - options.record === undefined - ? { - location: { executionHostId: 'local', wslDistro: null }, - lease: { - runtimeKind: 'native', + options.record === null + ? null + : agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: IDENTITY.sessionId, claimStatus: options.claimStatus ?? 'live', deathEvidence: options.deathEvidence ?? null, runtimeFence: 3 - } - } - : options.record + }) + ) let closed = false hostRef.current = { - deps: { store: { getRecord: () => record } }, + deps: { + store: { getConversation: () => (record ? agentSessionConversationView(record) : null) } + }, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: options.tabListed ? [IDENTITY.sessionId] : [] @@ -150,7 +157,7 @@ describe('structured worker stop', () => { expect(close).not.toHaveBeenCalled() }) - it.each([{ hasSession: false }, { claimStatus: 'conflicted' }])( + it.each([{ hasSession: false }, { claimStatus: 'conflicted' as const }])( 'retains without positive exit evidence: %j', async (options) => { installHost({ ...options, close: async () => {} }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts index d6dea6dbf39..6d8814c1f79 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts @@ -1,3 +1,10 @@ +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { emptyAgentSessionBatch } from '../../../native-chat/agent-session-wire/agent-session-empty-batch' +import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' /** Coalesce parked-mail journal nudges without changing delivery; each gate reads dispatch, unread mail and host facts, so per-batch evaluation is expensive. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,39 +21,58 @@ vi.mock('./structured-agent-session-create', () => ({ }) })) -type JournalEmit = (event: { type: string }) => void +type JournalEventType = 'batch' | 'reset' | 'snapshot' +type JournalEmit = (type: JournalEventType) => void /** Captures the redrive subscription so the test can drive journal batches by hand. */ -function installHost(): { emit: (type: string) => void; unsubscribed: () => boolean } { +function installHost(): { emit: (type: JournalEventType) => void; unsubscribed: () => boolean } { let emitter: JournalEmit | null = null let disposed = false - setStructuredAgentSessionHost({ - hasSession: () => true, - hold: async () => {}, - release: () => {}, - subscribe: (subscription: { emit: JournalEmit }) => { - emitter = subscription.emit - return () => { - disposed = true - } - }, - deps: { - store: { - getRecord: () => ({ - provider: 'claude', - location: { executionHostId: 'local', wslDistro: null }, - lease: { - runtimeKind: 'native', - claimStatus: 'live', - deathEvidence: null, - runtimeFence: 1 - } + const { host, store } = hostTestState() + vi.spyOn(host, 'hasSession').mockReturnValue(true) + vi.spyOn(host, 'subscribe').mockImplementation(async (subscription) => { + const cursor = { epoch: 'redrive', sequence: 0 } + const page: AgentSessionHistoryPage = { + sessionId: subscription.sessionId, + epoch: cursor.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false + } + emitter = (type) => { + if (type === 'batch') { + subscription.emit({ + type, + sessionId: subscription.sessionId, + batch: emptyAgentSessionBatch(cursor) }) + } else if (type === 'reset') { + subscription.emit({ + type, + sessionId: subscription.sessionId, + reset: 'epoch_changed', + page, + fence: 1 + }) + } else { + subscription.emit({ type, sessionId: subscription.sessionId, page, fence: 1 }) } } - } as never) + return () => { + disposed = true + } + }) + vi.spyOn(store, 'requireWritableRecord').mockImplementation((sessionId) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) + ) + setStructuredAgentSessionHost(host) return { - emit: (type: string) => emitter?.({ type }), + emit: (type: JournalEventType) => emitter?.(type), unsubscribed: () => disposed } } diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 54efb7f5d5e..8ad3981e336 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,3 +1,6 @@ +import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture' +import type { AgentSessionExecutionLocation } from '../../../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' @@ -24,8 +27,12 @@ const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/wor const { structuredWorkerIdentities } = await import('../../structured-worker-identity') const { sealedChildEnv } = await import('../../structured-session-child-env.test-support') -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. -function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) { +function installHost( + location: Pick = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null + } +) { const dispose = vi.fn() const subscribe = vi.fn(async () => dispose) hostRef.current = { @@ -33,10 +40,8 @@ function installHost(location = { executionHostId: 'local', wslDistro: null as s close: async () => {}, deps: { store: { - getRecord: () => ({ - location, - lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } - }) + requireWritableRecord: (sessionId: string) => + agentSessionRecordFixture({ sessionId, location }) } }, subscribe @@ -210,12 +215,7 @@ describe('structured worker session', () => { }) it('refuses a session pinned to a non-local execution host', async () => { - installHost() - ;(hostRef.current as { deps: { store: { getRecord: () => unknown } } }).deps.store.getRecord = - () => ({ - location: { executionHostId: 'ssh-1', wslDistro: null }, - lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } - }) + installHost({ executionHostId: toSshExecutionHostId('ssh-1'), wslDistro: null }) await expect( createStructuredWorkerSession({ runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, @@ -246,7 +246,19 @@ describe('structured worker dispatch preamble', () => { function hostWithSubmission(submission: Settled, delivered?: Settled): PreambleHost { return { - deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, + deps: { + store: { + selectWritableRecord: (sessionId) => ({ + kind: 'writable', + record: agentSessionRecordFixture({ sessionId }) + }), + requireWritableRecord: (sessionId) => + agentSessionRecordFixture({ + sessionId, + lease: { ...agentSessionRecordFixture({ sessionId }).lease, runtimeFence: 7 } + }) + } + }, send: async () => ({ ok: true, replayed: false, diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 716cff96e72..e189b3b1a01 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -116,7 +116,7 @@ export async function createStructuredWorkerSession(args: { ) } const host = requireInstalledHost() - const record = host.deps.store.getRecord(sessionId) + const record = host.deps.store.requireWritableRecord(sessionId) if (!record || !structuredWorkerHostScope(record.location)) { throw new OrchestrationError( 'agent_unconfigured', @@ -178,7 +178,12 @@ function reasonClause(reason: string | null | undefined): string { /** What a preamble send reads of the host. */ type StructuredWorkerPreambleHost = StructuredAgentTurnHost & { - deps: { store: { getRecord: (sessionId: string) => { lease: { runtimeFence: number } } | null } } + deps: { + store: Pick< + StructuredAgentSessionHost['deps']['store'], + 'selectWritableRecord' | 'requireWritableRecord' + > + } } /** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had @@ -199,10 +204,10 @@ export async function sendStructuredWorkerPreamble(args: { blocks: [{ type: 'text', text: args.preamble }], from: args.from } - const fence = args.host.deps.store.getRecord(args.sessionId)?.lease.runtimeFence - if (fence === undefined) { + if (args.host.deps.store.selectWritableRecord(args.sessionId).kind === 'missing') { throw new Error('The structured worker session has no durable record to dispatch into.') } + const fence = args.host.deps.store.requireWritableRecord(args.sessionId).lease.runtimeFence const outcome = await sendAgentTurn({ kind: 'structured-session', host: args.host, diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 3183e7a9f0b..e6664b3c1b2 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -1,3 +1,8 @@ +import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' /** Post-create authority/preamble failure must retire both live and durable chat tabs; dropping only the dispatch hold restores an unowned dead tab after restart. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -42,31 +47,19 @@ const WORKTREE = 'wt_1' function installHost() { const closed: string[] = [] const visibility: [string, boolean][] = [] - setStructuredAgentSessionHost({ - setSessionTabVisibility: async (sessionId: string, visible: boolean) => { - visibility.push([sessionId, visible]) - }, - close: async (sessionId: string) => { - closed.push(sessionId) - }, - hasSession: () => true, - hold: async () => {}, - release: () => {}, - subscribe: () => () => {}, - deps: { - store: { - getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, - lease: { - runtimeKind: 'native', - claimStatus: 'live', - deathEvidence: null, - runtimeFence: 1 - } - }) - } - } - } as never) + const { host, store } = hostTestState() + vi.spyOn(host, 'setSessionTabVisibility').mockImplementation(async (sessionId, visible) => { + visibility.push([sessionId, visible]) + }) + vi.spyOn(host, 'close').mockImplementation(async (sessionId) => { + closed.push(sessionId) + }) + vi.spyOn(host, 'hasSession').mockReturnValue(true) + vi.spyOn(host, 'subscribe').mockResolvedValue(() => {}) + vi.spyOn(store, 'requireWritableRecord').mockImplementation((sessionId) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) + ) + setStructuredAgentSessionHost(host) return { closed, visibility } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index 2260e69160f..9d386e39edf 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -1,3 +1,8 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' /** Both worker modes receive identical orchestration instructions after identity/surface normalization, and structured workers can dispatch sub-workers through session authority. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -71,18 +76,12 @@ function installStructuredCoordinator(handle: string, sessionId: string): string hasSession: () => true, deps: { store: { - getRecord: () => ({ - provider: 'claude', - location: { executionHostId: 'local', wslDistro: null }, - lease: { - runtimeKind: 'native', - claimStatus: 'live', - deathEvidence: null, - runtimeFence: 1 - } - }), + getConversation: () => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) + ), // No committed /clear: each session is its own lineage's root. - listRecords: () => [] + listConversations: () => [] } } } diff --git a/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts index 36d4e82748c..7c2e86693dc 100644 --- a/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts +++ b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts @@ -1,4 +1,4 @@ -import type { AgentSessionRecord } from '../../../../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../../../../shared/agent-session-conversation-view' import { formatOrcaSessionAddress, type OrcaSessionId @@ -28,7 +28,7 @@ export async function admitChatAssignee( runtime: Pick, sessionId: OrcaSessionId, db: OrchestrationDb -): Promise { +): Promise { // A failed install leaves no store, which the refusal below names. await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) const store = readAgentSessionRecordStore() diff --git a/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts index 52acb2b2e24..215ea4b8419 100644 --- a/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts +++ b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts @@ -1,3 +1,4 @@ +import { conversationRecordReader } from '../../../orchestration/structured-session-lineage' /** Queue the chat assignee's task as a composer card while busy; derive operation id from Dispatch so repeat sends replay instead of duplicating cards. */ import { createHash } from 'node:crypto' @@ -41,7 +42,9 @@ export async function sendChatTask(args: { const sessionId = chatAssigneeSessionId(args.dispatch.assignee_handle) const host = getStructuredAgentSessionHost() const observed = - sessionId && host ? observeChatAssignee(sessionId, args.db, host.deps.store) : null + sessionId && host + ? observeChatAssignee(sessionId, args.db, conversationRecordReader(host.deps.store)) + : null if (!host || observed?.status !== 'live') { throw new OrchestrationError( 'dispatch_preamble_undelivered', @@ -64,7 +67,7 @@ export async function sendChatTask(args: { // As a person's message is: a busy chat queues it as a card, sent when the queue reaches it. delivery: 'queue', operationId: chatTaskOperationId(args.dispatch), - expectedRuntimeFence: observed.session.lease.runtimeFence + expectedRuntimeFence: observed.session.currentFence } }) switch (outcome.kind) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts index 4310c89d004..0d971d6a4b8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts @@ -1,3 +1,7 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../../../shared/agent-session-record.test-fixture' /** Placement must discard structured sessions created before a throw, because callers receive no placement to tear down. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -54,11 +58,14 @@ beforeEach(() => { discard.mockClear() surfaced.sessionId = null structuredWorkerIdentities.clear() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worker create reads only `subscribe` and the record's location. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host supplies subscribe and a complete writable record for the newly minted id. setStructuredAgentSessionHost({ subscribe: () => () => {}, deps: { - store: { getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null } }) } + store: { + requireWritableRecord: (sessionId: string) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + } } } as never) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts index e1bd7885b05..c6180d50e84 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts @@ -1,3 +1,7 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../../../shared/agent-session-record.test-fixture' /** Shared-executor starts must preserve durable effects/stage order, ask host support once and fail refused chats. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -98,11 +102,14 @@ function place( } function installHost() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worker create reads only `subscribe` and the record's location. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host supplies subscribe and a complete writable record for the newly minted id. setStructuredAgentSessionHost({ subscribe: () => () => {}, deps: { - store: { getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null } }) } + store: { + requireWritableRecord: (sessionId: string) => + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + } } } as never) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index 4f1d0bcd881..af5f75b613f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -1,3 +1,7 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../../../shared/agent-session-record.test-fixture' import { describe, expect, it, vi } from 'vitest' vi.mock('../../../../orchestration/preamble', () => ({ @@ -20,8 +24,16 @@ function settle(delivered: 'accepted' | undefined) { })), markWorkerDispatchReady: vi.fn(() => ({ state: 'ready', stage: 'ready' })) } + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: 's1', runtimeFence: 1 }) + ) const host = { - deps: { store: { getRecord: () => ({ lease: { runtimeFence: 1 } }) } }, + deps: { + store: { + selectWritableRecord: () => ({ kind: 'writable' as const, record }), + requireWritableRecord: () => record + } + }, send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission: { dispatchState: 'pending', reason: null } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index a070e8624df..e71171d78b9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import '../unused-default-rpc-methods.test-fixture' // The catalog read behind the picker: which directory a named worktree runs in on this host. @@ -38,7 +39,15 @@ describe('agentSession.modelCatalog', () => { record = null setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: { read }, store: { getRecord: () => record }, adapter: {} } + deps: { + modelCatalog: { read }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, + adapter: {} + } }) ) }) @@ -72,6 +81,10 @@ describe('agentSession.modelCatalog', () => { } const catalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), + getConversation: () => { + const saved = record ?? undefined + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: () => record ?? undefined, drivesRecord: () => true, recordWorkspacePath: async () => '/repo/work', @@ -87,7 +100,15 @@ describe('agentSession.modelCatalog', () => { }) setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: catalog, store: { getRecord: () => record }, adapter: {} } + deps: { + modelCatalog: catalog, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, + adapter: {} + } }) ) try { @@ -188,7 +209,11 @@ describe('agentSession.modelCatalog before anything built the host', () => { const installHost = vi.fn(async () => { setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: { read }, store: { getRecord: () => null }, adapter: {} } + deps: { + modelCatalog: { read }, + store: { getRecord: () => null, getConversation: () => null }, + adapter: {} + } }) ) }) @@ -250,7 +275,15 @@ describe('agentSession.modelCatalog for a chat on a model its CLI stopped listin beforeEach(() => { setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: { read }, store: { getRecord: () => record }, adapter: {} } + deps: { + modelCatalog: { read }, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, + adapter: {} + } }) ) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 1cf58ac344a..4410c36129e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -64,7 +64,7 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ ? requireStructuredHost(ctx) : await requireInstalledStructuredHost(ctx, params.sessionId) const record = - params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId) + params.sessionId === undefined ? null : host.deps.store.getConversation(params.sessionId) const coherent = ctx.clientKind === undefined || ctx.clientCapabilities?.includes(AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY) === true diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index 55b40ce917b..e5eed35cdc1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -104,6 +104,7 @@ async function chatAtRest(repoConfig: { ) const modelCatalog = createAgentModelCatalogService({ store, + getConversation: (sessionId) => rig.store.getConversation(sessionId) ?? undefined, getRecord: (sessionId) => rig.store.getRecord(sessionId) ?? undefined, drivesRecord: () => true, resolveAccountHome: async () => rig.store.getRecord(SESSION)!.accountHome, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts index 10317a8dbf5..1d8f4f43e48 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts @@ -1,42 +1,215 @@ import { afterEach, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' -import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import * as naming from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed-deps' +import { StructuredAgentSessionStatusFeed } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { countRecordQueries, countStoreWork } from '../../agent-session-scale.test-fixture' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-abandon' +import { + hostTestState, + replaceHostTestState +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../agent-session-record-store-test-harness' import { call, clearStructuredHostStub, - hostCalls, - installStructuredHostStub, STRUCTURED_CLIENT } from './structured-agent-session-rpc.test-fixture' afterEach(clearStructuredHostStub) -it('filters naming notes by the same record audience as status rows', async () => { - installStructuredHostStub() - const host = getStructuredAgentSessionHost() - if (!host) { - throw new Error('Missing test host') - } - vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId: string) => - sessionId === 'missing' - ? null - : { ...agentSessionRecordFixture(), provider: sessionId === 'pi' ? 'pi' : 'codex' } - ) - hostCalls.subscribeStatus.mockImplementation( - (subscriber: StructuredAgentSessionStatusSubscriber) => { - subscriber.emit({ - type: 'snapshot', - sessions: [], - conversationNames: ['codex', 'pi', 'missing'].map((sessionId) => ({ - sessionId, - conversationName: 'Name' +it.each([10, 100])('filters cold naming notes with linear work for %i healthy rows', async (h) => { + const rig = hostTestState() + await abandonStructuredAgentSessionHost(rig.host) + const healthyIds = Array.from({ length: h - 1 }, (_, i) => `healthy-codex-${i}`) + const records = [...healthyIds, 'session-codex', 'session-pi', 'session-raw-only'].map( + (sessionId): AgentSessionRecord => { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { + ...record, + provider: sessionId === 'session-pi' ? 'pi' : 'codex', + conversationName: 'Name', + conversationNameSource: 'user', + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { + transport: sessionId === 'session-pi' ? 'pi' : 'codex-app-server', + agent: sessionId === 'session-pi' ? 'pi' : 'codex', + nativeId: `provider-${sessionId}` + } })) - }) - return () => {} + } } ) - expect(await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT)).toMatchObject({ - result: { conversationNames: [{ sessionId: 'codex', conversationName: 'Name' }] } + await seedTestAgentSessionRecordStore(rig.root, { records }) + const database = openTestJournalHostDatabase(rig.root) + database.db + .prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?" + ) + .run('session-codex') + database.db + .prepare( + "UPDATE agent_session_records SET record_json = json_set(record_json, '$.providerHandleChain', 'invalid') WHERE session_id = ?" + ) + .run('session-raw-only') + const rawRows = database.db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY session_id') + .all() + closeTestJournalHostDatabase(rig.root) + const store = await openTestAgentSessionRecordStore(rig.root) + const reopened = openTestJournalHostDatabase(rig.root) + const host = new StructuredAgentSessionHost({ + ...rig.host.deps, + store, + journalDatabase: reopened }) + replaceHostTestState({ store, host }) + setStructuredAgentSessionHost(host) + let closeStream = (): void => {} + try { + expect(database.isClosed).toBe(true) + expect(store.getRecord('session-codex')).toBeNull() + expect(store.getConversation('session-codex')).toMatchObject({ + provider: 'codex', + conversationName: 'Name' + }) + expect(() => store.requireWritableRecord('session-codex')).toThrowError( + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'recordUnreadable' } }) + }) + ) + expect(store.getConversation('session-pi')).toMatchObject({ + provider: 'pi', + conversationName: 'Name' + }) + expect(store.getConversation('session-raw-only')).toBeNull() + expect(store.getConversation('session-missing')).toBeNull() + expect( + store + .listConversations() + .map((record) => record.sessionId) + .sort() + ).toEqual([...healthyIds, 'session-codex', 'session-pi'].sort()) + expect(rawRows.map((row) => row.session_id)).toEqual( + [...healthyIds, 'session-codex', 'session-pi', 'session-raw-only'].sort() + ) + expect(reopened.db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([]) + expect(host.hasSession('session-codex')).toBe(false) + expect(host.readStatusSummary('session-codex')).toBeUndefined() + const work = countStoreWork(store) + const queries = countRecordQueries(rig.root) + // Observe the actual cached loader statement even if a future caller reuses it directly. + reopened.db.prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + const names = vi.spyOn(naming, 'structuredAgentSessionNamingSnapshot') + const flatten = vi.spyOn(Array.prototype, 'flatMap') + const unsubscribe = vi.spyOn(StructuredAgentSessionStatusFeed.prototype, 'unsubscribe') + for (let attempt = 1; attempt <= 3; attempt++) { + work.reset() + names.mockClear() + flatten.mockClear() + const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT, { + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + closeStream = cleanup + } + }) + const measured = { ...work.counts } + const measuredQueries = queries() + closeStream() + closeStream = (): void => {} + expect(unsubscribe).toHaveBeenCalledTimes(attempt) + expect(measured).toEqual({ + inventories: 1, + keyed: h + 1, + views: 2 * h, + clones: 0, + cloneBytes: 0, + decodes: 0, + links: 0, + tabReads: 0 + }) + expect(measuredQueries).toEqual({ executions: 0, rows: 0 }) + expect(names).toHaveBeenCalledTimes(1) + expect(names.mock.results[0]).toEqual({ + type: 'return', + value: { + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [...healthyIds, 'session-pi', 'session-codex'].map((sessionId) => ({ + sessionId, + conversationName: 'Name' + })) + } + }) + const scans = flatten.mock.contexts.filter( + (rows) => + Array.isArray(rows) && + rows.length === 2 && + rows.every( + (row: unknown) => + typeof row === 'object' && + row !== null && + 'raw' in row && + typeof row.raw === 'object' && + row.raw !== null && + 'sessionId' in row.raw && + (row.raw.sessionId === 'session-codex' || row.raw.sessionId === 'session-raw-only') + ) + ) + expect(scans).toEqual([ + [ + { + raw: expect.objectContaining({ sessionId: 'session-codex' }), + conversation: expect.objectContaining({ sessionId: 'session-codex' }), + reason: expect.any(String) + }, + { + raw: expect.objectContaining({ sessionId: 'session-raw-only' }), + reason: expect.any(String) + } + ] + ]) + expect(reply).toMatchObject({ + result: { + sessions: [], + namingActiveSessionIds: [], + conversationNames: [...healthyIds, 'session-codex'].map((sessionId) => ({ + sessionId, + conversationName: 'Name' + })) + } + }) + } + flatten.mockRestore() + expect(records.every(({ sessionId }) => !host.hasSession(sessionId))).toBe(true) + expect(records.every(({ sessionId }) => host.readStatusSummary(sessionId) === undefined)).toBe( + true + ) + expect(host.hasSession('session-codex')).toBe(false) + expect(host.readStatusSummary('session-codex')).toBeUndefined() + expect(rig.acquire).not.toHaveBeenCalled() + expect(rig.dispatch).not.toHaveBeenCalled() + expect( + reopened.db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY session_id') + .all() + ).toEqual(rawRows) + expect(reopened.db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([]) + } finally { + closeStream() + await abandonStructuredAgentSessionHost(host) + } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc-client.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc-client.test-fixture.ts new file mode 100644 index 00000000000..a79ddb31b2d --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc-client.test-fixture.ts @@ -0,0 +1,19 @@ +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' + +export const STRUCTURED_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] +} +export const STRUCTURED_MOBILE_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] +} 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 0b74e6b37e7..9e12cbda773 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 @@ -10,10 +10,6 @@ import { type StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { OrcaRuntimeService } from '../../orca-runtime' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' import type { RpcAnyMethodDeclaration, RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' @@ -125,7 +121,7 @@ const statusFeed = () => } ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1_000 }) @@ -211,7 +207,11 @@ export function hostStub(): StructuredAgentSessionHost { subscribeTurnCompletions: vi.fn(() => () => undefined), unsubscribe: vi.fn() }) - const store = { getOperationRow: vi.fn(() => null), getRecord: vi.fn(() => null) } + const store = { + getOperationRow: vi.fn(() => null), + getRecord: vi.fn(() => null), + getConversation: vi.fn(() => null) + } // Not a call: the logger the host hands a runtime caller that reports for it. Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger, store }) return hostCalls as unknown as StructuredAgentSessionHost @@ -303,20 +303,10 @@ export async function openStream( return replies } -export const STRUCTURED_CLIENT = { - clientKind: 'runtime' as const, - clientCapabilities: [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY - ] -} -export const STRUCTURED_MOBILE_CLIENT = { - clientKind: 'mobile' as const, - clientCapabilities: [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY - ] -} +export { + STRUCTURED_CLIENT, + STRUCTURED_MOBILE_CLIENT +} from './structured-agent-session-rpc-client.test-fixture' /** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */ export function installStructuredHostStub(): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index 61cd8b6bdf5..f9592695f35 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -69,7 +69,7 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ ...(event.conversationNames ? { conversationNames: event.conversationNames.filter((note) => { - const record = host.deps.store.getRecord(note.sessionId) + const record = host.deps.store.getConversation(note.sessionId) return record && clientReadsStructuredSessionAgent(ctx, record.provider) }) } @@ -77,7 +77,7 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ ...(event.namingActiveSessionIds ? { namingActiveSessionIds: event.namingActiveSessionIds.filter((sessionId) => { - const record = host.deps.store.getRecord(sessionId) + const record = host.deps.store.getConversation(sessionId) return record && clientReadsStructuredSessionAgent(ctx, record.provider) }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts index 82d14d15755..b194cf520ce 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts @@ -1,10 +1,14 @@ +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' import '../unused-default-rpc-methods.test-fixture' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { nativeChatScratchFolderFor } from '../../../native-chat/native-chat-scratch-folder' import { nativeChatVisualRevision } from '../../../native-chat/native-chat-visual-file-read' @@ -39,11 +43,14 @@ let record: AgentSessionRecord | null beforeEach(async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-rpc-')) - record = { ...agentSessionRecordFixture(), sessionId: SESSION } + record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION })) setStructuredAgentSessionHost( Object.assign(hostStub(), { deps: { - store: { getRecord: (id: string) => (record?.sessionId === id ? record : null) } + store: { + getConversation: (id: string) => + record?.sessionId === id ? agentSessionConversationView(record) : null + } } }) ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts index 277e4af0703..cad19dec1ab 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -19,7 +19,7 @@ export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ params: ReadVisualParams, handler: async (params, ctx): Promise => { const host = await requireInstalledStructuredHost(ctx) - const record = host.deps.store.getRecord(params.sessionId) + const record = host.deps.store.getConversation(params.sessionId) if (!record) { return { ok: false, error: 'session_not_found' } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index bac50adbfe3..261d64c1a86 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -67,7 +67,6 @@ import { } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { structuredSessionNeverStarted } from '../../agent-session-record-founding' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { @@ -153,7 +152,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (ORCA_LAYOUT_OWNER_IS_MAIN) { noteStructuredAgentSessionClose( params.sessionId, - (getStructuredAgentSessionHost()?.deps.store.getRecord(params.sessionId) ?? null) !== null + (getStructuredAgentSessionHost()?.deps.store.getConversation(params.sessionId) ?? + null) !== null ) } const host = requireStructuredCleanupHost(ctx) @@ -196,17 +196,17 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ } try { const records = store - .listRecords() + .listConversations() .filter( (record) => record.location.workspaceId === params.workspaceId && record.location.executionHostId === LOCAL_EXECUTION_HOST_ID ) const stoppedSessionIds = records - .filter(structuredSessionNeverStarted) + .filter((record) => record.neverStarted) .map((record) => record.sessionId) const failures = records - .filter((record) => !structuredSessionNeverStarted(record)) + .filter((record) => !record.neverStarted) .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } } catch (error) { diff --git a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts index 21174cb8dc8..a6c7f00cf59 100644 --- a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts @@ -1,3 +1,8 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' /** Mutate journals between reads to catch in-place revisions and mid-array insertions missed by oldest-item-only cursor identity; both can silently omit or duplicate output. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -51,7 +56,12 @@ function approval(itemId: string, resolved: boolean, revision = 1): AgentJournal function installJournal(items: AgentJournalRenderItem[]): void { hostRef.current = { deps: { - store: { getRecord: () => ({ location: { executionHostId: 'local', wslDistro: null } }) } + store: { + getConversation: () => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: IDENTITY.sessionId })) + ) + } }, hasSession: () => true, history: () => ({ page: { items, hasOlder: false } }) diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index 2617725a9d0..4b6d1c93185 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -1,3 +1,9 @@ +import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' /** An absent structured host cannot issue a close; receipts must not claim closed_agent_terminal and falsely tell coordinators its tab is gone. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -87,19 +93,16 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { it('keeps a restarted worker unsettled when close finds no attached session', async () => { const dispatchId = startStructuredWorker() const close = vi.fn(async () => {}) - setStructuredAgentSessionHost({ - close, - setSessionTabVisibility: async () => {}, - hasSession: () => false, - deps: { - store: { - getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, - lease: { runtimeKind: 'native', claimStatus: 'live', deathEvidence: null } - }) - } - } - } as never) + const { host, store } = hostTestState() + vi.spyOn(host, 'close').mockImplementation(close) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + vi.spyOn(host, 'hasSession').mockReturnValue(false) + vi.spyOn(store, 'getConversation').mockImplementation(() => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION })) + ) + ) + setStructuredAgentSessionHost(host) await expect(call('orchestration.workerStop', { dispatch: dispatchId })).resolves.toMatchObject( { diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index d2e95da438d..ed8a5a7d36d 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -1,3 +1,10 @@ +import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../../../shared/agent-session-record' /** Every settlement must clear durable restore and prune live worker chat tabs; leftover tabs can reattach released sessions outside orchestration accounting. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,7 +34,7 @@ const SESSION = 'session-1' const HANDLE = 'structworker_11111111-1111-4111-a111-111111111111' const HOST_SCOPE = { kind: 'local', hostId: 'local' } as const -function installHost(options: { closeThrows?: boolean; lease?: Record } = {}) { +function installHost(options: { closeThrows?: boolean; lease?: Partial } = {}) { let attached = true const setSessionTabVisibility = vi.fn(async () => {}) const close = vi.fn(async () => { @@ -36,29 +43,27 @@ function installHost(options: { closeThrows?: boolean; lease?: Record attached, - hold: async () => {}, - release: () => {}, - subscribe: () => () => {}, - deps: { - store: { - getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, - lease: options.lease ?? { - runtimeKind: 'native', - claimStatus: attached ? 'live' : 'released', - deathEvidence: attached - ? null - : { kind: 'exit-observed', detail: 'closed', observedAt: 1 }, - runtimeFence: 2 - } + const { host, store } = hostTestState() + vi.spyOn(host, 'setSessionTabVisibility').mockImplementation(setSessionTabVisibility) + vi.spyOn(host, 'close').mockImplementation(close) + vi.spyOn(host, 'hasSession').mockImplementation(() => attached) + vi.spyOn(host, 'subscribe').mockResolvedValue(() => {}) + vi.spyOn(store, 'getConversation').mockImplementation(() => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION, + claimStatus: attached ? 'live' : 'released', + deathEvidence: attached + ? null + : { kind: 'exit-observed', detail: 'closed', observedAt: 1 }, + runtimeFence: 2, + ...options.lease }) - } - } - } as never) + ) + ) + ) + setStructuredAgentSessionHost(host) return { close, setSessionTabVisibility } } @@ -231,6 +236,9 @@ describe('structured worker release retires the chat tab', () => { runtimeFence: 2 } }) + vi.spyOn(hostTestState().host, 'history').mockRejectedValue( + new Error('The closed chat journal is unavailable') + ) const identity = registerIdentity() const resource = { id: 'resource-2', diff --git a/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts b/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts index 27c8bd29c28..0d8728e3887 100644 --- a/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts @@ -75,16 +75,10 @@ describe('a database an earlier build wrote session: addresses into', () => let seeded: { toX: string; fromX: string } beforeEach(() => { - h = createSessionCallerHarness(hostRef) - hostRef.current = { - deps: { - store: { - getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, - listRecords: () => [...h.records.values()], - getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [SESSION_X, SESSION_Y] }) - } - } - } + h = createSessionCallerHarness(hostRef, () => ({ + present: true, + sessionIds: [SESSION_X, SESSION_Y] + })) seeded = seedEarlierBuildRows(h.db) h.db.migrate() }) diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts index a9194e1064b..b8fd22644cd 100644 --- a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { vi } from 'vitest' import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -61,7 +62,13 @@ export type SessionCallerHarness = { close: () => void } -export function createSessionCallerHarness(hostRef: SessionHostRef): SessionCallerHarness { +export function createSessionCallerHarness( + hostRef: SessionHostRef, + getVisibleSessionTabIndex: () => { present: boolean; sessionIds: string[] } = () => ({ + present: false, + sessionIds: [] + }) +): SessionCallerHarness { const db = new OrchestrationDb(':memory:') const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) @@ -80,7 +87,20 @@ export function createSessionCallerHarness(hostRef: SessionHostRef): SessionCall deps: { store: { getRecord: (sessionId: string) => records.get(sessionId) ?? null, - listRecords: () => [...records.values()] + getConversation: (id: string) => { + const record = records.get(id) + return record ? agentSessionConversationView(record) : null + }, + listConversations: () => [...records.values()].map(agentSessionConversationView), + requireWritableRecord: (id: string) => { + const record = records.get(id) + if (!record) { + throw new Error('Missing test record') + } + return record + }, + listRecords: () => [...records.values()], + getVisibleSessionTabIndex } } } diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index b27f2efa320..40c24eb8272 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -21,7 +21,9 @@ import { resolveOrchestrationParty } from '../orchestration/orchestration-party' import { lookupOrcaAgentSession } from '../orchestration/structured-session-mail-address' -import { readAgentSessionRecordStore } from '../orchestration/structured-session-lineage' +import { conversationRecordReader } from '../orchestration/structured-session-lineage' +import type { AgentSessionRecordStore } from '../agent-session-record-store' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' @@ -133,10 +135,10 @@ async function readSessionRecord( runtime: OrcaRuntimeService, sessionId: string ): Promise { - let store: ReturnType + let store: AgentSessionRecordStore | null try { await runtime.ensureStructuredAgentSessionHost() - store = readAgentSessionRecordStore() + store = getStructuredAgentSessionHost()?.deps.store ?? null } catch { store = null } @@ -147,9 +149,9 @@ async function readSessionRecord( NO_EFFECTS ) } - const found = lookupOrcaAgentSession(store, sessionId) + const found = lookupOrcaAgentSession(conversationRecordReader(store), sessionId) if (found.kind === 'found') { - return found.record + return store.requireWritableRecord(found.record.sessionId) } if (found.kind === 'provider-id') { throw new OrchestrationError( diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index bbbbaf40e0a..e4b50559854 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -34,17 +34,8 @@ describe('a send addressed to an agent session', () => { let visible: string[] beforeEach(() => { - h = createSessionCallerHarness(hostRef) + h = createSessionCallerHarness(hostRef, () => ({ present: true, sessionIds: visible })) visible = [SESSION_X, SESSION_Y] - hostRef.current = { - deps: { - store: { - getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, - listRecords: () => [...h.records.values()], - getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible }) - } - } - } }) afterEach(() => { diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts index 84e4a17a1f2..835f9c5e67f 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.test.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' // Name senders from production-owned Dispatch tasks, mirrored workspace chat/terminal tabs and PTY records. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -149,7 +150,13 @@ beforeEach(() => { records = new Map([[CHAT, { ...base, location: { ...base.location, workspaceId: WORKTREE } }]]) hostRef.current = { deps: { - store: { getRecord: (id: string) => records.get(id) ?? null, listRecords: () => [] } + store: { + getConversation: (id: string) => { + const record = records.get(id) + return record ? agentSessionConversationView(record) : null + }, + listConversations: () => [] + } } } }) diff --git a/src/main/runtime/runtime-reference-agents.test.ts b/src/main/runtime/runtime-reference-agents.test.ts index ed64e3b2222..b941b018abf 100644 --- a/src/main/runtime/runtime-reference-agents.test.ts +++ b/src/main/runtime/runtime-reference-agents.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' @@ -64,8 +65,11 @@ function runtime(rows: FleetAgentStatusEvidence[]): ReferenceAgentSource { function sessionStore(records: AgentSessionRecord[]) { return { - listRecords: () => records, - getRecord: (id: string) => records.find((record) => record.sessionId === id) ?? null + listRecords: () => records.map(agentSessionConversationView), + getRecord: (id: string) => { + const record = records.find((record) => record.sessionId === id) + return record ? agentSessionConversationView(record) : null + } } } diff --git a/src/main/runtime/runtime-reference-agents.ts b/src/main/runtime/runtime-reference-agents.ts index ececf03dc52..d540a25ec6d 100644 --- a/src/main/runtime/runtime-reference-agents.ts +++ b/src/main/runtime/runtime-reference-agents.ts @@ -19,6 +19,7 @@ import type { OrchestrationDb } from './orchestration/db' import { structuredSessionMailReach } from './orchestration/structured-session-mail-address' import { clearedInto, + conversationRecordReader, readAgentSessionRecordStore, type AgentSessionRecordReader } from './orchestration/structured-session-lineage' @@ -51,7 +52,7 @@ export function createReferenceAgentIndex( runtime: ReferenceAgentSource, db: OrchestrationDb | null, sessions: AgentSessionRecordReader | null = readAgentSessionRecordStore() ?? - peekOpenedAgentSessionRecordStore(), + coldConversationReader(), workspaceKeys?: ReadonlySet, connectionHosts?: ReadonlyMap ): ReferenceAgentIndex { @@ -64,6 +65,10 @@ export function createReferenceAgentIndex( index.set(key, entries) } const visible = sessions?.getVisibleSessionTabIndex?.() + let canonicalRecords: ReturnType | undefined + const canonicalStore = { + listRecords: () => (canonicalRecords ??= readAgentSessionRecordStore()?.listRecords() ?? []) + } for (const record of sessions?.listRecords() ?? []) { if ( clearedInto(record) || @@ -84,7 +89,7 @@ export function createReferenceAgentIndex( const local = structuredWorkerHostScope(record.location) !== null const reachable = local && sessions - ? structuredSessionMailReach(sessions, record, db).kind === 'reachable' + ? structuredSessionMailReach(sessions, record, db, canonicalStore).kind === 'reachable' : false append(record.location.workspaceId, { hostId: record.location.executionHostId, @@ -181,3 +186,8 @@ export function createReferenceAgentIndex( } return index } + +function coldConversationReader(): AgentSessionRecordReader | null { + const store = peekOpenedAgentSessionRecordStore() + return store ? conversationRecordReader(store) : null +} diff --git a/src/main/runtime/runtime-reference-integration.test.ts b/src/main/runtime/runtime-reference-integration.test.ts index 6fa5b8ddaf2..a6f79c34e82 100644 --- a/src/main/runtime/runtime-reference-integration.test.ts +++ b/src/main/runtime/runtime-reference-integration.test.ts @@ -13,7 +13,7 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' vi.mock('./agent-session-record-store-slot', () => ({ peekOpenedAgentSessionRecordStore: () => null, openAgentSessionRecordStoreOnce: async () => ({ - store: { listRecords: () => [], getRecord: () => null } + store: { listConversations: () => [], getConversation: () => null } }) })) diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 89167cb9133..ab0340a291f 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -79,7 +80,7 @@ function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSumma const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), - getRecord: () => agentSessionRecordFixture(), + getConversation: () => agentSessionConversationView(agentSessionRecordFixture()), now: () => 1_000 }) const events: AgentSessionStatusEvent[] = [] diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 9f741db9bb2..9ff6ec42499 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -102,7 +102,7 @@ async function awaitingApproval() { const feed = new StructuredAgentSessionStatusFeed({ logger: createStructuredAgentSessionLogger(), sessions, - getRecord: () => null, + getConversation: () => null, now: () => Date.now(), statusSink: () => ({ publish: (summary, subject) => { diff --git a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts index a3ccba2fc80..9f30759bc70 100644 --- a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts @@ -117,7 +117,7 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or /** The desktop tab's rename, else the chat's saved name; null leaves the agent's default label. */ private structuredConversationTabName(workspaceId: string, sessionId: string): string | null { - const record = getStructuredAgentSessionHost()?.deps?.store?.getRecord(sessionId) + const record = getStructuredAgentSessionHost()?.deps?.store?.getConversation(sessionId) const customLabel = this.getWorkspaceSessionForWorktree(workspaceId)?.unifiedTabs?.[ workspaceId ]?.find((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)?.customLabel diff --git a/src/main/runtime/structured-agent-launch-composition.test-support.ts b/src/main/runtime/structured-agent-launch-composition.test-support.ts index 4791bc12acd..60cebb0233a 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -1,5 +1,7 @@ // Build agent launch resolvers with production composition over test doubles so tests exercise the shared path. +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' import type { AcpLaunchSpec } from '../acp/acp-launch-specs' @@ -33,7 +35,9 @@ import { export const TEST_SPAWN_TOKEN = 'test-spawn-token' -export type LaunchTestSources = Pick & { +export type LaunchTestSources = { + store: Pick & + Partial> resolveWorkspacePath?: (workspaceId: string) => Promise /** The base env; none is an empty one. */ resolveEnvironment?: () => Promise @@ -53,7 +57,28 @@ function launchSources(deps: LaunchTestSources): StructuredAgentLaunchSources { const resolveEnvironment = deps.resolveEnvironment const resolveBaseEnvironment = async () => definedEnv((await resolveEnvironment?.()) ?? {}) return { - store: deps.store, + store: { + pinLaunchDirectory: (id, directory) => deps.store.pinLaunchDirectory(id, directory), + selectWritableRecord: (id) => { + if (deps.store.selectWritableRecord) { + return deps.store.selectWritableRecord(id) + } + const record = deps.store.getRecord(id) + return record ? { kind: 'writable', record } : { kind: 'missing' } + }, + requireWritableRecord: (id) => { + if (deps.store.requireWritableRecord) { + return deps.store.requireWritableRecord(id) + } + const record = deps.store.getRecord(id) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + return record + } + }, resolveWorkspacePath: deps.resolveWorkspacePath ?? (async (id) => `/repos/${id}`), resolveBaseEnvironment, resolveAgentEnvironment: async (agent) => ({ diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index 89caff5e610..6a41f64fe8e 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -19,7 +19,10 @@ import { sealStructuredSessionChild } from './structured-session-child-env' /** Where a launch's shared inputs come from. */ export type StructuredAgentLaunchSources = { - store: Pick + store: Pick< + AgentSessionRecordStore, + 'selectWritableRecord' | 'requireWritableRecord' | 'pinLaunchDirectory' + > resolveWorkspacePath: (workspaceId: string) => Promise resolveBaseEnvironment: () => Promise> /** The base environment with the user's overlay for `agent` laid over it. */ @@ -106,10 +109,10 @@ function admittedRecord( store: StructuredAgentLaunchSources['store'], sessionId: string ): AgentSessionRecord { - const record = store.getRecord(sessionId) - if (!record) { + if (store.selectWritableRecord(sessionId).kind === 'missing') { throw new Error(`no durable agent-session record for ${sessionId}`) } + const record = store.requireWritableRecord(sessionId) if (record.provider !== definition.agent) { throw new Error(`session ${sessionId} is a ${record.provider} session`) } @@ -180,6 +183,7 @@ export function composeStructuredLaunch( identity, permissionMode ) + admittedRecord(definition, sources.store, identity.sessionId) const child = sealStructuredSessionChild({ sessionId: identity.sessionId, spawnToken, @@ -209,6 +213,7 @@ export function composeStructuredLaunchRead( part, identity ) + admittedRecord(definition, sources.store, identity.sessionId) return { ...launch, cwd, env, envToDelete: [...inheritedEnvToDelete] } } } diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index 7744566459c..a83693c79fd 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' // Capture native-chat children at the spawn boundary with production wiring; launch-path refactors must preserve these snapshots. import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' @@ -464,6 +465,19 @@ function eventSink(): StructuredAgentSessionEventSink { function adapterContext(): StructuredAgentAdapterContext { const store = { getRecord: (sessionId: string) => records.get(sessionId) ?? null, + selectWritableRecord: (sessionId: string) => { + const record = records.get(sessionId) + return record ? { kind: 'writable' as const, record } : { kind: 'missing' as const } + }, + requireWritableRecord: (sessionId: string) => { + const record = records.get(sessionId) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + return record + }, pinLaunchDirectory: async (sessionId: string, directory: string) => { pins.push({ sessionId, directory }) const record = records.get(sessionId) diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts index b9bcf57cf29..5023b2b73e0 100644 --- a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { describe, expect, it } from 'vitest' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -64,8 +66,7 @@ const GROK_LISTING = grokModelCatalogFromState({ }) function grokRecord(): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only provider, accountHome and location. - return { + return agentSessionRecordFixture({ sessionId: 'grok-session', provider: 'grok', accountHome: HOMES.grok, @@ -75,7 +76,7 @@ function grokRecord(): AgentSessionRecord { workspaceId: 'ws-1', workspaceKind: 'git-worktree' } - } as AgentSessionRecord + }) } function service( @@ -93,6 +94,10 @@ function service( } return createAgentModelCatalogService({ store, + getConversation: (sessionId) => { + const saved = records.find((record) => record.sessionId === sessionId) + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (sessionId) => records.find((record) => record.sessionId === sessionId), drivesRecord: () => true, resolveAccountHome: async (agent) => HOMES[agent]!, diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 04992a5eea2..78ef27a742e 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -64,7 +64,7 @@ export function registeredModelCatalogDiscovery( /** Hydrate once and build catalog service only with a known selected account; otherwise answer unknown rather than guess its key. */ export async function modelCatalogHostDeps(input: { - store: Pick + store: Pick agents: Pick registrations: readonly CatalogRegistration[] deps: StructuredAgentModelCatalogContext['deps'] @@ -77,6 +77,7 @@ export async function modelCatalogHostDeps(input: { } const modelCatalog = createAgentModelCatalogService({ store: agentModelCatalogStore, + getConversation: (id) => input.store.getConversation(id) ?? undefined, getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, @@ -86,7 +87,7 @@ export async function modelCatalogHostDeps(input: { agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel, hasChatRecords: (agent) => - input.store.listRecords().some((record) => record.provider === agent), + input.store.listConversations().some((record) => record.provider === agent), ...registeredModelCatalogDiscovery(input.registrations, { deps, environment: input.environment diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 18c894148e4..5399513eafc 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -1,3 +1,5 @@ +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' // Before start, new-chat options must match the workspace catalog's first model/effort frame so the picker stays stable. import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -153,8 +155,8 @@ function newChatRecord( options: Record ): AgentSessionRecord { const providerHandleChain: AgentSessionRecord['providerHandleChain'] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service and the at-rest read touch only these fields. - return { + + return agentSessionRecordFixture({ sessionId: `${agent}-new`, provider: agent, accountHome: HOMES[agent]!, @@ -167,7 +169,7 @@ function newChatRecord( workspaceKind: 'git-worktree' }, options - } as AgentSessionRecord + }) } /** A host whose catalog for `agent`'s account is warm, holding one new chat that never sent. */ @@ -187,6 +189,10 @@ function host(agent: string, record: AgentSessionRecord) { } const modelCatalog = createAgentModelCatalogService({ store, + getConversation: (sessionId) => { + const saved = sessionId === record.sessionId ? record : undefined + return saved ? agentSessionConversationView(saved) : undefined + }, getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), drivesRecord: () => true, resolveAccountHome: async (name) => HOMES[name]!, @@ -222,7 +228,16 @@ function host(agent: string, record: AgentSessionRecord) { } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the at-rest read touches only these members. const context = { - deps: { adapter: {}, agents: AGENTS, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: {}, + agents: AGENTS, + store: { + getRecord: () => record, + getConversation: () => (record ? agentSessionConversationView(record) : null), + requireWritableRecord: () => record + }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index ee566be8f90..c0f6bb4d397 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -90,7 +90,7 @@ export function requireUnownedStructuredAgentSessionAdoption(input: { const conflict = input.host ? findConflictingStructuredAdoption({ ...input, - ownership: listStructuredProviderSessionOwnership(input.host.deps.store.listRecords()) + ownership: listStructuredProviderSessionOwnership(input.host.deps.store.listConversations()) }) : null if (conflict) { diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts index 6ac3b6374e0..88e40db7e8d 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -203,9 +203,9 @@ describe("a chat assignee's liveness, stop and close", () => { const { dispatchId } = await dispatchToChat() const cleared = await clearChat(PEER_CHAT) expect(cleared).toBe(PEER_CHAT) - const getRecord = host.deps.store.getRecord.bind(host.deps.store) - vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId) => - sessionId === cleared ? null : getRecord(sessionId) + const getConversation = host.deps.store.getConversation.bind(host.deps.store) + vi.spyOn(host.deps.store, 'getConversation').mockImplementation((sessionId) => + sessionId === cleared ? null : getConversation(sessionId) ) expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index bb99a36538b..2fe626501b1 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { IPtyProvider } from '../providers/pty-provider-contract' @@ -83,6 +84,11 @@ function installHost(options: { deps: { store: { listRecords: () => options.records, + listConversations: () => options.records.map(agentSessionConversationView), + getConversation: (id: string) => { + const row = options.records.find((entry) => entry.sessionId === id) + return row ? agentSessionConversationView(row) : null + }, getRecord: () => null, getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null) } diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 71c5e8ab068..f08b4a48e5a 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -84,9 +84,9 @@ function listFencedStructuredSessions( if (!host) { return { members: [], live: [] } } - let records: ReturnType + let records: ReturnType try { - records = host.deps.store.listRecords() + records = host.deps.store.listConversations() } catch { return { members: [], live: [] } } diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index 290d6b6bd75..006c3b61ecb 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -1,3 +1,9 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // Open Dispatches retain active work; chats at rest remain mail recipients until tab retirement. Process liveness and close proof remain separate. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -33,28 +39,36 @@ const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const LOCAL = JSON.stringify({ kind: 'local', hostId: 'local' }) function record(lease: Partial): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial record; the ownership and liveness reads touch only lease, location and provider. + const base = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: SESSION, runtimeFence: 3, ...lease }) + ) return { - sessionId: SESSION, + ...base, provider: 'codex', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'wt_1', - workspaceKind: 'git-worktree' - }, - lease: { claimStatus: 'live', deathEvidence: null, runtimeFence: 3, ...lease } - } as AgentSessionRecord + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + providerHandleChain: base.providerHandleChain.map((link) => ({ + ...link, + handle: codexProviderHandle('at-rest') + })), + location: { ...base.location, workspaceId: 'wt_1' } + } } /** The installed session as the resolved running session the custody reads take. */ function running(current: AgentSessionRecord): RunningStructuredSession { - return { sessionId: SESSION, record: current, lineage: [SESSION] } + return { sessionId: SESSION, record: agentSessionConversationView(current), lineage: [SESSION] } } function installHost(current: AgentSessionRecord | null, tabs: string[]) { const host = { - deps: { store: { getRecord: () => current } }, + deps: { + store: { + getRecord: () => current, + getConversation: () => (current ? agentSessionConversationView(current) : null), + listConversations: () => (current ? [agentSessionConversationView(current)] : []), + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }) + } + }, hasSession: () => false, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }), setSessionTabVisibility: vi.fn(async (_id: string, visible: boolean) => { diff --git a/src/main/runtime/structured-worker-authority.test.ts b/src/main/runtime/structured-worker-authority.test.ts index aa510ba76e6..540003dc601 100644 --- a/src/main/runtime/structured-worker-authority.test.ts +++ b/src/main/runtime/structured-worker-authority.test.ts @@ -21,7 +21,7 @@ function installRecordProvider(provider: string | null): void { hostRef.current = { deps: { store: { - getRecord: () => + getConversation: () => provider ? { provider, location: { executionHostId: 'local', wslDistro: null } } : null } } diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index ef0b883bc39..a7776ae5ae8 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -1,7 +1,7 @@ /** Rehydrate worker identity from durable rows after restart; records, tab index and orchestration state decide custody. * Mail reaches agents at rest and starts them, so routing ignores process liveness. Resolve clear successors for every worker-level operation. */ -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../shared/agent-session-conversation-view' import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../shared/orchestration-session-caller-codes' import type { RuntimeTerminalState } from '../../shared/runtime-types' @@ -19,7 +19,6 @@ import { structuredSessionTabRetired, structuredWorkerAddressable } from './structured-worker-custody' -import { structuredSessionNeverStarted } from './agent-session-record-founding' import { isTuiAgent } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' import { isStructuredWorkerHandle } from '../../shared/structured-worker-handle' @@ -46,9 +45,11 @@ export type StructuredWorkerHold = refusal: OrchestrationError } -export function readStructuredAgentSessionRecord(sessionId: string): AgentSessionRecord | null { +export function readStructuredAgentSessionRecord( + sessionId: string +): AgentSessionConversationView | null { try { - return getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) ?? null + return getStructuredAgentSessionHost()?.deps.store.getConversation(sessionId) ?? null } catch { return null } @@ -216,7 +217,7 @@ export function structuredSessionCloseSettled(sessionId: string): boolean { return ( status === 'exited' || (status === 'unverifiable' && - readStructuredAgentSessionRecord(sessionId)?.lease.claimStatus === 'released') + readStructuredAgentSessionRecord(sessionId)?.ownerObservation.claimStatus === 'released') ) } @@ -247,19 +248,19 @@ export function observeStructuredSession(sessionId: string): StructuredWorkerObs reason: 'The structured agent-session host is not installed in this runtime generation.' } } - const record = host.deps.store.getRecord(sessionId) + const record = host.deps.store.getConversation(sessionId) if (!record) { return { status: 'unverifiable', reason: 'No durable record backs this structured session.' } } - if (record.lease.claimStatus === 'released' && record.lease.deathEvidence) { + if (record.ownerObservation.claimStatus === 'released' && record.ownerObservation.deathEvidence) { return { status: 'exited' } } - if (structuredSessionNeverStarted(record) && structuredSessionTabRetired(host, sessionId)) { + if (record.neverStarted && structuredSessionTabRetired(host, sessionId)) { // A never-reserved session has no death evidence; with its tab retired nothing can start it. // hasSession only means an open conversation, which any history read creates. return { status: 'exited' } } - if (host.hasSession(sessionId) && record.lease.claimStatus === 'live') { + if (host.hasSession(sessionId) && record.ownerObservation.claimStatus === 'live') { return { status: 'live' } } return { diff --git a/src/main/runtime/structured-worker-cleared-session.test.ts b/src/main/runtime/structured-worker-cleared-session.test.ts index 5f4c6301edb..2211a2fea14 100644 --- a/src/main/runtime/structured-worker-cleared-session.test.ts +++ b/src/main/runtime/structured-worker-cleared-session.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' /** Worker-level reads/operations follow clear successors and the full lineage; unresolved successors remain unverifiable. */ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -40,8 +41,7 @@ const { openTestAgentSessionRecordStore } = await import('./agent-session-record-store-test-harness') const { OrcaRuntimeService } = await import('./orca-runtime') const { OrchestrationDb } = await import('./orchestration/db') -const { ORCHESTRATION_METHODS } = await import('./rpc/methods/orchestration') -const { eraseRpcMethods } = await import('./rpc/core') +const { rpcRuntime } = await import('./structured-worker-rpc.test-support') const { structuredWorkerOwesWork } = await import('./structured-worker-custody') const { AGENT_SESSION_NOT_ATTACHED } = await import('../native-chat/agent-session-wire/structured-agent-session-mutation-admission') @@ -53,7 +53,6 @@ const { } = await import('./structured-worker-identity') type Db = InstanceType -type Runtime = InstanceType const MINTED = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const SUCCESSOR = 'clear-a1b2c3d4e5f60718293a4b5c6d7e8f9012345678' @@ -183,6 +182,18 @@ function installHost(): void { deps: { store: { getRecord: (id: string) => records.get(id) ?? null, + getConversation: (id: string) => { + const record = records.get(id) + return record ? agentSessionConversationView(record) : null + }, + listConversations: () => [...records.values()].map(agentSessionConversationView), + requireWritableRecord: (id: string) => { + const record = records.get(id) + if (!record) { + throw new Error('Missing test record') + } + return record + }, listRecords: () => [...records.values()], getVisibleSessionTabIndex: () => ({ present: tabIndexPresent, sessionIds: visibleTabs }), getSessionTabId: () => null @@ -298,28 +309,6 @@ function startWorkerDispatch( return dispatch.id } -/** A real runtime over `db` with the orchestration RPCs, as a coordinator calls them. */ -function rpcRuntime(db: Db): { - runtime: Runtime - call: (name: string, params: Record) => Promise -} { - const runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(db) - vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) - return { - runtime, - call: async (name, params) => { - const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( - (candidate) => candidate.name === name - ) - if (!method?.params) { - throw new Error(`Method not found: ${name}`) - } - return method.handler(method.params.parse(params), { runtime }) - } - } -} - /** The coordinator releases a worker whose Dispatch succeeded. */ function releaseSucceeded(dispatchId: string): Promise { db.db @@ -873,8 +862,17 @@ describe('a running session that cannot be verified is refused, never declared e expect(observeStructuredWorker(identity).status).toBe('unverifiable') expect(structuredSessionMailTarget(MINTED, null)).toBeNull() const mintedRecord = records.get(MINTED) - const store = { getRecord: (id: string) => records.get(id) ?? null, listRecords: () => [] } - expect(mintedRecord && structuredSessionMailReach(store, mintedRecord, null)).toMatchObject({ + const store = { + getRecord: (id: string) => { + const record = records.get(id) + return record ? agentSessionConversationView(record) : null + }, + listRecords: () => [] + } + expect( + mintedRecord && + structuredSessionMailReach(store, agentSessionConversationView(mintedRecord), null) + ).toMatchObject({ kind: 'unverifiable' }) await expectRefused(identity, 'session_caller_not_live') diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts index e84dad8a1bb..df793c26a83 100644 --- a/src/main/runtime/structured-worker-custody.ts +++ b/src/main/runtime/structured-worker-custody.ts @@ -1,7 +1,7 @@ /** Custody uses worker-terminal state rather than process liveness: mail can start an agent at rest. * Only released ends addressability; pending/unknown releases still route. Reclaimable workers owe no work and may rest. */ -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionConversationView } from '../../shared/agent-session-conversation-view' import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display' import { isOrcaSessionId } from '../../shared/orca-session-address' import { canonicalOrcaSessionId } from './orchestration/canonical-orca-session-id' @@ -27,7 +27,7 @@ export function structuredWorkerOwned(running: RunningStructuredSession): boolea } return structuredWorkerRecordIsCurrent( running.record, - running.record.lease.claimStatus === 'released' && + running.record.ownerObservation.claimStatus === 'released' && structuredSessionTabListed(host, running.sessionId) ) } @@ -96,7 +96,7 @@ export function structuredWorkerAddressable( /** Any pending/dispatched task on this worker incarnation keeps it working, including supervised stops in doubt and later tasks. Clear successors inherit root obligations; reclaimable workers may rest. */ export function structuredWorkerOwesWork( db: OrchestrationDb | null, - record: AgentSessionRecord + record: Pick ): boolean { const incarnation = db && ownedWorkerIncarnation(db, record) return ( @@ -114,7 +114,7 @@ export function structuredWorkerOwesWork( /** The first task this worker still owes, which names its chat. */ export function structuredWorkerOpenTaskTitle( db: OrchestrationDb | null, - record: AgentSessionRecord + record: Pick ): { title: string } | null { const incarnation = db && ownedWorkerIncarnation(db, record) if (!db || !incarnation) { @@ -143,7 +143,10 @@ export function structuredWorkerOpenTaskTitle( : null } -function ownedWorkerIncarnation(db: OrchestrationDb, record: AgentSessionRecord): string | null { +function ownedWorkerIncarnation( + db: OrchestrationDb, + record: Pick +): string | null { const hostScope = structuredWorkerHostScope(record.location) if (!hostScope) { return null diff --git a/src/main/runtime/structured-worker-hook-attestation.test.ts b/src/main/runtime/structured-worker-hook-attestation.test.ts index f00e5c5edbe..d8680014485 100644 --- a/src/main/runtime/structured-worker-hook-attestation.test.ts +++ b/src/main/runtime/structured-worker-hook-attestation.test.ts @@ -1,3 +1,8 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' const hostRef: { current: unknown } = { current: null } @@ -30,10 +35,12 @@ function registerStructuredWorker(): string { hostRef.current = { deps: { store: { - getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, - lease: { runtimeKind: 'native', claimStatus: 'live', runtimeFence: 1 } - }) + getConversation: () => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: SESSION_ID, runtimeFence: 1 }) + ) + ) } } } diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 558d385cc7f..fbec8986076 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { describe, expect, it, beforeEach } from 'vitest' import { isTerminalLeafId, parsePaneKey } from '../../shared/stable-pane-id' import { @@ -171,9 +172,20 @@ describe('structured worker identity', () => { }) it('keeps a recovered session current across a fence bump', () => { - // Recovery bumps the runtime fence without changing the worker; identity cannot depend on that fence. - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }), false)).toBe(true) - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }), false)).toBe(true) + // The host bumps the fence on its own transparent crash recovery; fencing identity on it + // would wedge the SAME worker as identity_unproven forever. + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ runtimeFence: 1 })), + false + ) + ).toBe(true) + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ runtimeFence: 9 })), + false + ) + ).toBe(true) expect(structuredWorkerProcessIncarnation(SESSION_ID)).toBe( structuredWorkerProcessIncarnation(SESSION_ID) ) @@ -181,14 +193,35 @@ describe('structured worker identity', () => { it('refuses a conflicted session, and a released one whose chat tab is gone', () => { // A terminal owner an older build recorded loads conflicted; it is not this worker. - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }), true)).toBe(false) - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), false)).toBe(false) + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ claimStatus: 'conflicted' })), + true + ) + ).toBe(false) + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ claimStatus: 'released' })), + false + ) + ).toBe(false) expect(structuredWorkerRecordIsCurrent(null, true)).toBe(false) }) it('keeps a released session with a listed tab: that worker is at rest, not gone', () => { - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), true)).toBe(true) - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'live' }), false)).toBe(true) + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ claimStatus: 'released' })), + true + ) + ).toBe(true) + // A live lease is owned whether or not a tab shows it. + expect( + structuredWorkerRecordIsCurrent( + agentSessionConversationView(record({ claimStatus: 'live' })), + false + ) + ).toBe(true) }) }) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index ff2ecb57906..cad6ab55ace 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -1,11 +1,9 @@ +import type { AgentSessionConversationView } from '../../shared/agent-session-conversation-view' /** Structured workers have no PTY; register their bearer handle, pane key and host scope as orchestration authority. * Random handles and leaves prevent session ids exposed in tab ids from yielding caller-supplied keys that read/consume worker mail. */ import { randomUUID } from 'node:crypto' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredAgentSessionPaneKey, @@ -111,14 +109,14 @@ export function structuredWorkerHostScope( /** Judge ownership, not liveness: a released lease stays current while its persisted chat tab is listed. */ export function structuredWorkerRecordIsCurrent( - record: AgentSessionRecord | null | undefined, + record: AgentSessionConversationView | null | undefined, tabListed: boolean ): boolean { return Boolean( record && // Why: a conflicted claim may name a terminal an older build recorded as owner, not this worker. - record.lease.claimStatus !== 'conflicted' && - (record.lease.claimStatus !== 'released' || tabListed) && + record.ownerObservation.claimStatus !== 'conflicted' && + (record.ownerObservation.claimStatus !== 'released' || tabListed) && structuredWorkerHostScope(record.location) ) } diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index b95d176e52d..5484f3b7b7e 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -1,3 +1,9 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../shared/agent-session-record' import { beforeEach, describe, expect, it, vi } from 'vitest' const hostRef: { current: unknown } = { current: null } @@ -21,7 +27,7 @@ const getLivePaneKey = prototype.getLiveTerminalPaneKey const resolveActiveTerminal = prototype.resolveActiveTerminal function installRecord( - lease: { runtimeKind: string; claimStatus: string } | null, + lease: Pick | null, tabListed = false ): void { hostRef.current = lease @@ -32,10 +38,17 @@ function installRecord( }), deps: { store: { - getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, - lease: { ...lease, runtimeFence: 1, deathEvidence: null } - }) + getConversation: () => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ...lease, + sessionId: SESSION_ID, + runtimeFence: 1, + deathEvidence: null + }) + ) + ) } }, hasSession: () => lease.claimStatus === 'live' diff --git a/src/main/runtime/structured-worker-rpc.test-support.ts b/src/main/runtime/structured-worker-rpc.test-support.ts new file mode 100644 index 00000000000..6e40516cf5f --- /dev/null +++ b/src/main/runtime/structured-worker-rpc.test-support.ts @@ -0,0 +1,30 @@ +import { vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { OrchestrationDb } from './orchestration/db' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { eraseRpcMethods } from './rpc/core' + +type Db = InstanceType +type Runtime = InstanceType + +/** A real runtime over `db` with the orchestration RPCs, as a coordinator calls them. */ +export function rpcRuntime(db: Db): { + runtime: Runtime + call: (name: string, params: Record) => Promise +} { + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + return { + runtime, + call: async (name, params) => { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) + if (!method?.params) { + throw new Error(`Method not found: ${name}`) + } + return method.handler(method.params.parse(params), { runtime }) + } + } +} diff --git a/src/main/runtime/structured-worker-takeover-pane-key.test.ts b/src/main/runtime/structured-worker-takeover-pane-key.test.ts index d2a247a5871..f82b41b055c 100644 --- a/src/main/runtime/structured-worker-takeover-pane-key.test.ts +++ b/src/main/runtime/structured-worker-takeover-pane-key.test.ts @@ -1,3 +1,4 @@ +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionLease } from '../../shared/agent-session-record' import { @@ -26,11 +27,18 @@ function installRecord(lease: Pick - agentSessionRecordFixture( - agentSessionLeaseFixture({ ...lease, sessionId, runtimeFence: 1, deathEvidence: null }) + getConversation: (sessionId: string) => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ...lease, + sessionId, + runtimeFence: 1, + deathEvidence: null + }) + ) ), - listRecords: () => [] + listConversations: () => [] } }, hasSession: () => true diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 4d0778b6be2..4e49e4cb428 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -1,6 +1,11 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import type { AgentSessionLease } from '../../shared/agent-session-record' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../shared/agent-session-record' const hostRef: { current: unknown } = { current: null } @@ -31,19 +36,24 @@ function message(id: string, text: string): AgentJournalRenderItem { function installHost(options: { items?: readonly AgentJournalRenderItem[] | 'unreadable' hasOlder?: boolean - lease?: { runtimeKind: string; claimStatus: string } + lease?: Pick hasSession?: boolean }): void { const lease = options.lease ?? { runtimeKind: 'native', claimStatus: 'live' } hostRef.current = { deps: { store: { - getRecord: (sessionId: string) => - ({ - sessionId, - location: { executionHostId: 'local', wslDistro: null }, - lease: { ...lease, runtimeFence: 1, deathEvidence: null } - }) as unknown as AgentSessionRecord + getConversation: (sessionId: string) => + agentSessionConversationView( + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ...lease, + sessionId, + runtimeFence: 1, + deathEvidence: null + }) + ) + ) } }, hasSession: () => options.hasSession ?? true, diff --git a/src/main/runtime/structured-worker-terminal-refusal.test.ts b/src/main/runtime/structured-worker-terminal-refusal.test.ts index 6ec18eea5c4..0368cde5bcc 100644 --- a/src/main/runtime/structured-worker-terminal-refusal.test.ts +++ b/src/main/runtime/structured-worker-terminal-refusal.test.ts @@ -1,5 +1,9 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' const hostRef: { current: unknown } = { current: null } @@ -21,17 +25,10 @@ function installRecord(): void { hostRef.current = { deps: { store: { - getRecord: (sessionId: string) => - ({ - sessionId, - location: { executionHostId: 'local', wslDistro: null }, - lease: { - runtimeKind: 'native', - claimStatus: 'live', - runtimeFence: 1, - deathEvidence: null - } - }) as unknown as AgentSessionRecord + getConversation: (sessionId: string) => + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 })) + ) } }, hasSession: () => true diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts index 631c079ff88..aa84f9e13ad 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts @@ -1,5 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest' import { runNativeChatCliFork } from './native-chat-cli-fork-run' +import type * as LaunchFeedback from '../right-sidebar/ai-vault-session-launch-feedback' import type { NativeChatCliForkPreparation, NativeChatCliForkSubject @@ -52,10 +53,10 @@ vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-launch', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.error } })) vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch })) -vi.mock('../right-sidebar/ai-vault-session-launch-feedback', () => ({ - notifyAiVaultSessionQueued: mocks.notify, - notifyAiVaultSessionPreparationFailure: mocks.error -})) +vi.mock('../right-sidebar/ai-vault-session-launch-feedback', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, notifyAiVaultSessionQueued: mocks.notify } +}) const subject: NativeChatCliForkSubject = { kind: 'chat', @@ -151,3 +152,53 @@ it('reveals the successful source workspace after another workspace was selected expect(mocks.notify).not.toHaveBeenCalled() expect(mocks.error).toHaveBeenCalledTimes(1) }) + +it.each([ + { + agent: 'claude' as const, + message: 'execution_owner_reconciling', + expected: 'Orca could not prepare this Claude conversation. Start a new chat to continue.' + }, + { + agent: 'codex' as const, + message: + "Error invoking remote method 'aiVault:prepareSessionResume': Error: execution_owner_reconciling", + expected: 'Orca could not prepare this Codex conversation. Start a new chat to continue.' + }, + { + agent: 'claude' as const, + message: 'agent_session_conflict', + expected: 'Update Orca on the host that runs this chat to resume it in a new CLI.' + }, + { + agent: 'codex' as const, + message: + "Error invoking remote method 'aiVault:prepareSessionResume': Error: agent_session_ownership_unknown", + expected: 'Update Orca on the host that runs this chat to resume it in a new CLI.' + }, + { + agent: 'claude' as const, + message: 'The session host is unavailable. Reconnect it and retry resume.', + expected: 'The session host is unavailable. Reconnect it and retry resume.' + } +])( + 'shows actionable $agent preparation feedback for $message and releases pending', + async ({ agent, message, expected }) => { + const onComplete = vi.fn() + mocks.prepare.mockRejectedValue(new Error(message)) + await runNativeChatCliFork({ ...args, agent, onComplete }) + expect(mocks.error).toHaveBeenCalledExactlyOnceWith(expected) + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + expect(onComplete).toHaveBeenCalledTimes(1) + expect(mocks.listeners.size).toBe(0) + + await runNativeChatCliFork({ ...args, agent, onComplete }) + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(mocks.error.mock.calls).toEqual([[expected], [expected]]) + expect(mocks.launch).not.toHaveBeenCalled() + expect(onComplete).toHaveBeenCalledTimes(2) + expect(mocks.listeners.size).toBe(0) + } +) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c2f7df9bcd1..f6fe67bddb8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1442,7 +1442,8 @@ }, "aiVaultResumeRefusal": { "openInChat": "This {{value0}} conversation is open in a chat. Refresh Session History to open it.", - "ownershipUnknown": "Orca could not confirm who owns this {{value0}} conversation. Refresh Session History and try again." + "ownershipUnknown": "Orca could not confirm who owns this {{value0}} conversation. Refresh Session History and try again.", + "preparationRefused": "Orca could not prepare this {{value0}} conversation. Start a new chat to continue." }, "composerCreateHandoff": { "reopen": "Reopen", diff --git a/src/renderer/src/lib/ai-vault-resume-refusal.ts b/src/renderer/src/lib/ai-vault-resume-refusal.ts index 23e3902e593..b85245914be 100644 --- a/src/renderer/src/lib/ai-vault-resume-refusal.ts +++ b/src/renderer/src/lib/ai-vault-resume-refusal.ts @@ -24,6 +24,13 @@ export function describeAiVaultResumeFailure(error: unknown, agent: AiVaultAgent { value0: aiVaultAgentLabel(agent) } ) } + if (code === 'execution_owner_reconciling') { + return translate( + 'auto.lib.aiVaultResumeRefusal.preparationRefused', + 'Orca could not prepare this {{value0}} conversation. Start a new chat to continue.', + { value0: aiVaultAgentLabel(agent) } + ) + } return ( message ?? translate( diff --git a/src/shared/agent-session-conversation-view.ts b/src/shared/agent-session-conversation-view.ts new file mode 100644 index 00000000000..213d67c4643 --- /dev/null +++ b/src/shared/agent-session-conversation-view.ts @@ -0,0 +1,58 @@ +import type { AgentSessionLease, AgentSessionRecord } from './agent-session-record' + +export type AgentSessionConversationView = Readonly> +export type AgentSessionOwnerObservation = AgentSessionConversationView['ownerObservation'] + +type ConversationFacts = Omit & { + lease: Omit & { minimumNextFence?: unknown } +} + +export const AGENT_SESSION_FOUNDING_FENCE = 1 + +export function agentSessionNeverStarted({ + lease, + providerHandleChain +}: ConversationFacts): boolean { + return ( + lease.claimStatus === 'released' && + lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && + lease.minimumNextFence === undefined && + lease.ownerProcess === null && + lease.reservedSpawnToken === null && + lease.handoffStage === null && + providerHandleChain.length === 0 + ) +} + +export function agentSessionConversationView(record: ConversationFacts) { + const { lease, ...conversation } = record + const { + claimStatus, + handoffStage, + handoffOperationId, + ownerProcess, + reservedSpawnToken, + provenHandleLinkId, + leaseDeadlineAt, + lastRenewedAt, + deathEvidence, + unreconciled + } = lease + return { + ...conversation, + currentFence: lease.runtimeFence, + neverStarted: agentSessionNeverStarted(record), + ownerObservation: { + claimStatus, + handoffStage, + handoffOperationId, + ownerProcess, + reservedSpawnToken, + provenHandleLinkId, + leaseDeadlineAt, + lastRenewedAt, + deathEvidence, + unreconciled + } + } as const +} diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index a71e9d7a81a..1470dcd8e5a 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -1,3 +1,4 @@ +import type { AgentSessionOwnerObservation } from './agent-session-conversation-view' /** * Single-writer lease adjudication. * @@ -106,7 +107,13 @@ function deathEvidenceFor( /** `exited` only for a lease released on death evidence; one recovery released without proof, like * anything held or mid-handoff, may still be running. */ export function agentSessionLeaseOwnerVerdict(lease: AgentSessionLease): AgentSessionOwnerVerdict { - if (agentSessionLeaseAdmitsWriter(lease)) { + return agentSessionOwnerObservationVerdict(lease) +} + +export function agentSessionOwnerObservationVerdict( + lease: AgentSessionOwnerObservation +): AgentSessionOwnerVerdict { + if (agentSessionOwnerObservationIsLive(lease)) { return 'live' } return lease.claimStatus === 'released' && @@ -125,6 +132,10 @@ export function agentSessionLeaseIsReleased(lease: AgentSessionLease): boolean { /** True when the recorded owner may write right now. Used by every mutating path in later parts. */ export function agentSessionLeaseAdmitsWriter(lease: AgentSessionLease): boolean { + return agentSessionOwnerObservationIsLive(lease) +} + +export function agentSessionOwnerObservationIsLive(lease: AgentSessionOwnerObservation): boolean { return ( !lease.unreconciled && lease.handoffStage === null && diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index 7ffd1421030..6477f5abe1a 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -25,12 +25,24 @@ export type PersistedAgentSessionLease = Omit & { lease: PersistedAgentSessionLease + sleptAt?: unknown providerHandleChain: PersistedAgentSessionProviderHandleLink[] } +/** Checked stored facts before the independent next-grant floor has been validated. */ +export type PersistedAgentSessionObservationLease = Omit< + PersistedAgentSessionLease, + 'minimumNextFence' +> & { + minimumNextFence?: unknown +} +export type PersistedAgentSessionObservationRecord = Omit & { + lease: PersistedAgentSessionObservationLease +} + export function isPersistedAgentSessionRuntimeKind( value: unknown ): value is PersistedAgentSessionRuntimeKind { @@ -55,13 +67,18 @@ function isLegacyHandoffStage( return stage === 'preparing' || stage === 'old-owner-stopped' || stage === 'manual-recovery' } -export function leaseCarriesLegacyHandoffValues(lease: PersistedAgentSessionLease): boolean { +export function leaseCarriesLegacyHandoffValues( + lease: PersistedAgentSessionObservationLease +): boolean { return lease.runtimeKind === 'tui' || isLegacyHandoffStage(lease.handoffStage) } /** Identity for every lease this build writes. */ -export function normalizeLegacyHandoffLease(lease: PersistedAgentSessionLease): AgentSessionLease { - const { runtimeKind, handoffStage } = lease +export function normalizeLegacyHandoffLease( + lease: L +): Omit & + Pick { + const { runtimeKind, handoffStage }: PersistedAgentSessionObservationLease = lease // Why: every one of these awaited proof about an owner, which is what `recovering` resolves. const stage = isLegacyHandoffStage(handoffStage) ? 'recovering' : handoffStage if (runtimeKind === 'native') { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 7dfec1f5d41..08e2e7648a7 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -70,7 +70,7 @@ export function admitAgentSessionMutation(input: { hostFingerprint: string /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ ledger: AgentSessionOperationDecision - lease: AgentSessionLease + lease: AgentSessionLease | (() => AgentSessionLease) /** A write to the conversation, not to the provider child: a send is accepted and a Stop * withdraws queued messages whoever owns the child, so the lease does not admit them. */ conversationWrite?: true @@ -86,10 +86,11 @@ export function admitAgentSessionMutation(input: { if (ledger.decision === 'replay') { return { decision: 'replay', row: ledger.row } } + const currentLease = typeof lease === 'function' ? lease() : lease if (input.conversationWrite) { return { decision: 'admit', row: ledger.row } } - const leaseRefusal = refuseUnlessWriterAdmitted(lease) + const leaseRefusal = refuseUnlessWriterAdmitted(currentLease) if (leaseRefusal) { return { decision: 'refused', refusal: leaseRefusal } } diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index 90e05cb33bf..564ed63e11b 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -3,8 +3,6 @@ import type { AgentSessionLease } from './agent-session-record' -export function nextAgentSessionFence( - lease: Pick -): number { +export function nextAgentSessionFence(lease: AgentSessionLease): number { return Math.max(lease.runtimeFence + 1, lease.minimumNextFence ?? 0) } diff --git a/src/shared/agent-session-options-replacement.ts b/src/shared/agent-session-options-replacement.ts new file mode 100644 index 00000000000..c7575301ad3 --- /dev/null +++ b/src/shared/agent-session-options-replacement.ts @@ -0,0 +1,7 @@ +/** Replaces the provider options or sets the permission mode, never both, so neither write + * touches the other. */ +export type AgentSessionOptionsReplacement = { + sessionId: string + fence: number + now: number +} & ({ options: Readonly> } | { permissionMode: string }) diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index ab0d5013a53..ffef0512060 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { decodePersistedAgentSessionProviderHandleChain, encodePersistedAgentSessionProviderHandleChain, @@ -17,7 +17,12 @@ import { decodePersistedAgentSessionProviderHandle, encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' -import { isPersistedAgentSessionRecord } from './agent-session-record' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord, + type AgentSessionLease +} from './agent-session-record' +import { normalizeLegacyHandoffLease } from './agent-session-legacy-handoff-lease' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -98,6 +103,18 @@ const STORED_CODEX_ROW = JSON.stringify({ }) describe('a record row older builds wrote', () => { + it.each([undefined, 0, 6, 7, 9, Number.MAX_SAFE_INTEGER])( + 'keeps a valid legacy next-fence floor %s readable', + (minimumNextFence) => { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ minimumNextFence })) + const stored = JSON.parse(JSON.stringify(encodeAgentSessionRecord(record))) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + expect(decodePersistedAgentSessionRecord(stored).record.lease.minimumNextFence).toBe( + minimumNextFence + ) + } + ) + it.each([ ['Claude', STORED_CLAUDE_ROW], ['Codex', STORED_CODEX_ROW] @@ -109,9 +126,22 @@ describe('a record row older builds wrote', () => { } const { record, normalized } = decodePersistedAgentSessionRecord(stored) expect(normalized).toBe(false) + expectTypeOf(record).toMatchTypeOf() expect(JSON.stringify(encodeAgentSessionRecord(record))).toBe(row) }) + it.each([undefined, 25, 0, -1, '25', null, Number.NaN, Number.POSITIVE_INFINITY])( + 'normalizes a saved sleep value %s without hiding the conversation', + (sleptAt) => { + const stored = { ...encodeAgentSessionRecord(agentSessionRecordFixture()), sleptAt } + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + const decoded = decodePersistedAgentSessionRecord(stored) + expectTypeOf(decoded.record).toMatchTypeOf() + expect(decoded.record.sleptAt).toBe(sleptAt === 25 ? 25 : undefined) + expect(decoded.normalized).toBe(sleptAt !== undefined && sleptAt !== 25) + } + ) + it('hands shared code only the neutral handle', () => { const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) if (!isPersistedAgentSessionRecord(stored)) { @@ -128,11 +158,33 @@ describe('a record row older builds wrote', () => { const record = agentSessionRecordFixture() const stored = encodeAgentSessionRecord(record) expect(decodePersistedAgentSessionRecord(stored)).toEqual({ record, normalized: false }) - const legacy = { ...stored, lease: { ...stored.lease, runtimeKind: 'tui' as const } } - expect(decodePersistedAgentSessionRecord(legacy)).toEqual({ - record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, + const lease = { + ...stored.lease, + runtimeKind: 'tui' as const, + handoffStage: 'preparing' as const, + claimStatus: 'live' as const + } + const normalized = normalizeLegacyHandoffLease(lease) + expectTypeOf(normalized.runtimeKind).toEqualTypeOf() + expectTypeOf(normalized.handoffStage).toEqualTypeOf() + expectTypeOf(normalized.claimStatus).toEqualTypeOf() + const decoded = decodePersistedAgentSessionRecord({ ...stored, lease }) + expectTypeOf(decoded.record).toMatchTypeOf() + expect(decoded).toEqual({ + record: { + ...record, + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'recovering' } + }, normalized: true }) + const unknownFloor: unknown = 'bad' + const observed = decodePersistedAgentSessionRecord({ + ...stored, + lease: { ...lease, minimumNextFence: unknownFloor } + }) + expectTypeOf(observed.record.lease.minimumNextFence).toEqualTypeOf() + expectTypeOf(observed.record).not.toMatchTypeOf() + expect(observed.record.lease.minimumNextFence).toBe('bad') }) }) diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts index 0a8dd896065..14c7aa347f3 100644 --- a/src/shared/agent-session-record-stored-form.ts +++ b/src/shared/agent-session-record-stored-form.ts @@ -3,7 +3,8 @@ import { leaseCarriesLegacyHandoffValues, normalizeLegacyHandoffLease, - type PersistedAgentSessionRecord + type PersistedAgentSessionRecord, + type PersistedAgentSessionObservationLease } from './agent-session-legacy-handoff-lease' import { decodePersistedAgentSessionProviderHandleChain, @@ -12,10 +13,9 @@ import { import type { AgentSessionRecord } from './agent-session-record' /** Report decode changes needing writeback; handle encoding alone is unchanged across builds and needs none. */ -export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionRecord): { - record: AgentSessionRecord - normalized: boolean -} { +export function decodePersistedAgentSessionRecord( + record: Omit & { lease: L } +) { const providerHandleChain = decodePersistedAgentSessionProviderHandleChain( record.providerHandleChain ) diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 7d28894c100..d728e9a18d5 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -4,7 +4,8 @@ import { isAgentSessionConversationName } from './agent-session/agent-session-co import { isPersistedAgentSessionHandoffStage, isPersistedAgentSessionRuntimeKind, - type PersistedAgentSessionLease, + type PersistedAgentSessionObservationLease, + type PersistedAgentSessionObservationRecord, type PersistedAgentSessionRecord } from './agent-session-legacy-handoff-lease' /** Identity pins location, provider and account home independently of terminal tabs; the lease separately grants one process writer authority. */ @@ -30,6 +31,7 @@ import { } from './agent-session-account-home' export type { AgentSessionAccountHome } from './agent-session-account-home' +export type { AgentSessionOptionsReplacement } from './agent-session-options-replacement' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -144,14 +146,6 @@ export type AgentSessionRecord = { updatedAt: number } -/** Replaces the provider options or sets the permission mode, never both, so neither write - * touches the other. */ -export type AgentSessionOptionsReplacement = { - sessionId: string - fence: number - now: number -} & ({ options: Readonly> } | { permissionMode: string }) - const MAX_ID_LENGTH = 512 /** A death evidence's `detail` past this fails a load, so whoever writes one cuts it here. */ export const MAX_AGENT_SESSION_DEATH_DETAIL_CHARS = MAX_ID_LENGTH @@ -168,13 +162,10 @@ export function isAgentSessionId(value: unknown): value is string { } /** NUL cannot occur in a host id, distro name, or workspace id, so no component can forge a join. */ -const SCOPE_KEY_SEPARATOR = '\u0000' /** Separate native/WSL/SSH workspace copies so one host cannot adjudicate another's lease. */ export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string { - return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join( - SCOPE_KEY_SEPARATOR - ) + return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join('\u0000') } export function agentSessionExecutionLocationsEqual( @@ -274,16 +265,23 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath ) } -function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSessionLease { - if (typeof value !== 'object' || value === null) { +function isStoredRecordObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isPersistedAgentSessionObservationLease( + value: unknown +): value is PersistedAgentSessionObservationLease { + if (!isStoredRecordObject(value)) { return false } - const lease = value as Partial + const lease = value return ( isAgentSessionId(lease.sessionId) && isPersistedAgentSessionRuntimeKind(lease.runtimeKind) && + typeof lease.runtimeFence === 'number' && Number.isSafeInteger(lease.runtimeFence) && - (lease.runtimeFence as number) >= 0 && + lease.runtimeFence >= 0 && (lease.handoffStage === null || isPersistedAgentSessionHandoffStage(lease.handoffStage)) && (lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) && (lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) && @@ -304,56 +302,70 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe } /** Stored identity is independent of registrations; availability is checked only at start. */ -export function isPersistedAgentSessionRecord( +export function isPersistedAgentSessionObservationRecord( value: unknown -): value is PersistedAgentSessionRecord { - if (typeof value !== 'object' || value === null) { +): value is PersistedAgentSessionObservationRecord { + if (!isStoredRecordObject(value)) { return false } - const record = value as Partial - const fieldsValid = - record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && - isAgentSessionId(record.sessionId) && - isAgentSessionExecutionLocation(record.location) && - isStructuredAgentId(record.provider) && - isAgentSessionAccountHome(record.accountHome) && - (record.launchDirectory === undefined || - isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) && - (record.options === undefined || isAgentSessionOptions(record.options)) && - (record.permissionMode === undefined || - isBoundedString(record.permissionMode, MAX_ID_LENGTH)) && - (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && - (record.providerContextBoundary === undefined || - isAgentSessionProviderContextBoundary(record.providerContextBoundary)) && - (record.conversationCommand === undefined || - isAgentSessionConversationCommandRecord(record.conversationCommand)) && - (record.conversationName === undefined || - isAgentSessionConversationName(record.conversationName)) && - (record.conversationNameSource === undefined || record.conversationNameSource === 'user') && - (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && - !Object.hasOwn(record, 'launchEnv') && - isPersistedAgentSessionLease(record.lease) && - record.lease.sessionId === record.sessionId && - Number.isSafeInteger(record.createdAt) && - Number.isSafeInteger(record.updatedAt) - if (!fieldsValid) { + const record = value + if ( + !( + record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && + isAgentSessionId(record.sessionId) && + isAgentSessionExecutionLocation(record.location) && + isStructuredAgentId(record.provider) && + isAgentSessionAccountHome(record.accountHome) && + (record.launchDirectory === undefined || + isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) && + (record.options === undefined || isAgentSessionOptions(record.options)) && + (record.permissionMode === undefined || + isBoundedString(record.permissionMode, MAX_ID_LENGTH)) && + (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && + (record.providerContextBoundary === undefined || + isAgentSessionProviderContextBoundary(record.providerContextBoundary)) && + (record.conversationCommand === undefined || + isAgentSessionConversationCommandRecord(record.conversationCommand)) && + (record.conversationName === undefined || + isAgentSessionConversationName(record.conversationName)) && + (record.conversationNameSource === undefined || record.conversationNameSource === 'user') && + (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && + !Object.hasOwn(record, 'launchEnv') && + isPersistedAgentSessionObservationLease(record.lease) && + record.lease.sessionId === record.sessionId && + Number.isSafeInteger(record.createdAt) && + Number.isSafeInteger(record.updatedAt) + ) + ) { return false } - const validated = record as AgentSessionRecord // The row holds stored handles; validate the chain they decode to. - const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) + const chain = decodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) const head = chain?.at(-1) return ( chain !== null && // Chain validation already enforces one namespace. - (!head || head.handle.agent === validated.provider) && - (validated.providerContextBoundary === undefined || - validated.providerContextBoundary.afterFence <= validated.lease.runtimeFence) && - (validated.lease.claimStatus !== 'live' || - (validated.lease.ownerProcess !== null && + (!head || head.handle.agent === record.provider) && + (record.providerContextBoundary === undefined || + record.providerContextBoundary.afterFence <= record.lease.runtimeFence) && + (record.lease.claimStatus !== 'live' || + (record.lease.ownerProcess !== null && // Null: the owner's process is live and its provider has not answered with a handle yet. - (validated.lease.provenHandleLinkId === null || - (head?.linkId === validated.lease.provenHandleLinkId && - head.mintedAtFence === validated.lease.runtimeFence)))) + (record.lease.provenHandleLinkId === null || + (head?.linkId === record.lease.provenHandleLinkId && + head.mintedAtFence === record.lease.runtimeFence)))) + ) +} + +/** Writable records require the complete observation and a safe next-grant floor. */ +export function isPersistedAgentSessionRecord( + value: unknown +): value is PersistedAgentSessionRecord { + if (!isPersistedAgentSessionObservationRecord(value)) { + return false + } + const floor = value.lease.minimumNextFence + return ( + floor === undefined || (typeof floor === 'number' && Number.isSafeInteger(floor) && floor >= 0) ) } diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 94cd2541e5e..2e75fd2ca43 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -26,6 +26,7 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ getStructuredAgentSessionLaunchSelection: () => null })) +import { agentSessionConversationView } from '../../src/shared/agent-session-conversation-view' import { agentSessionRecordFixture } from '../../src/main/native-chat/agent-session-record-test-fixture' import type { SessionOptionDescriptor } from '../../src/shared/native-chat-session-options' import { createClaudeModelCatalogProbe } from '../../src/main/claude/claude-model-catalog-probe' @@ -112,6 +113,7 @@ async function hostCatalog(listingLands: Promise) { const modelCatalog = createAgentModelCatalogService({ store, getRecord: () => record, + getConversation: () => agentSessionConversationView(record), drivesRecord: () => true, resolveAccountHome: async () => record.accountHome, recordWorkspacePath: async () => join(dir, 'project'), @@ -148,7 +150,10 @@ async function hostCatalog(listingLands: Promise) { deps: { adapter: {}, agents: claudeAndCodexDeclared(), - store: { getRecord: () => record }, + store: { + getRecord: () => record, + getConversation: () => agentSessionConversationView(record) + }, modelCatalog }, serialize: (_sessionId: string, task: () => Promise) => task(), diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index cf1e11146f2..d6c9d5c0da3 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -151,7 +151,7 @@ async function openHost() { }) ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1 }) const events: AgentSessionStatusEvent[] = [] diff --git a/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts index 47ec2f4ee69..b1711fe3a05 100644 --- a/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts +++ b/tests/e2e/cross-version-wire/host-model-catalog-released-phone.unit.test.ts @@ -116,6 +116,7 @@ it.each(['success', 'budget'] as const)( const service = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), getRecord: () => undefined, + getConversation: () => undefined, drivesRecord: () => true, resolveAccountHome: async () => home, probes: { claude: scoped } @@ -124,7 +125,7 @@ it.each(['success', 'budget'] as const)( Object.assign(hostStub(), { deps: { modelCatalog: service, - store: { getRecord: () => undefined }, + store: { getRecord: () => undefined, getConversation: () => undefined }, adapter: {} } }) 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 b2ad9169ab8..01f8dbc6119 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 @@ -118,7 +118,7 @@ export function installableHost( // 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, getOperationRow: () => null } + store: { getRecord: () => null, getConversation: () => null, getOperationRow: () => null } }, restartResume: { list: hostCalls.restartResumableList, diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 847d15b6dfe..4e070981aa3 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -134,7 +134,7 @@ type SendAdmissionModules = { plan: unknown envelope: SentMessage['envelope'] journal: () => undefined - store: { getRecord: () => null } + store: { getRecord: () => null; selectWritableRecord: () => { kind: 'missing' } } }) => Promise } @@ -149,7 +149,7 @@ async function admitSend( plan: build.sendPlan(sent), envelope: sent.envelope, journal: () => undefined, - store: { getRecord: () => null } + store: { getRecord: () => null, selectWritableRecord: () => ({ kind: 'missing' }) } }) } diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index 83f059ea821..50353f94517 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -148,7 +148,7 @@ describe('structured session cursor/body regression', () => { } ] ]), - getRecord: () => null, + getConversation: () => null, now: () => 1_000, onStatusChanged: (summary) => { hostSummary = summary