diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts new file mode 100644 index 00000000000..b46e9d26fbd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host-test-support.ts @@ -0,0 +1,225 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + claudeAndCodexDeclared, + NO_STRUCTURED_AGENTS +} from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract' + +export const CALLER = { callerKey: 'client-1' } + +export const eventually = (assertion: () => void | Promise, interval = 0): Promise => + vi.waitFor(assertion, { timeout: 10_000, interval }) + +let root: string +export let store: AgentSessionRecordStore +export let host: StructuredAgentSessionHost +export let acquire: Mock +export let dispatch: Mock +export let closeSession: Mock> +let setOption: Mock +export const providerStarted: Mock = vi.fn() +export const recordLiveListing: Mock = vi.fn() +const openGates = new Map void>() + +export async function proveOpen(model = 'default'): Promise { + const generation = `generation-${acquire.mock.calls.length}` + await eventually(() => expect(openGates.has(generation)).toBe(true)) + openGates.get(generation)!(model) + openGates.delete(generation) +} + +export function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: input.fence, + observedAt: NOW + } + } +} + +export const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => + acquisition(input) + +type StartupHostOptions = { + startupLimits?: Partial + supportsGoals?: boolean +} + +async function startHost({ startupLimits, supportsGoals }: StartupHostOptions = {}): Promise { + host = new StructuredAgentSessionHost({ + agents: supportsGoals ? claudeAndCodexDeclared() : NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + open: ({ acquisitionGeneration }) => + new Promise((resolve) => { + openGates.set(acquisitionGeneration!, (model) => + resolve({ reportedOptions: { model, reportedPermissionMode: 'ask' } }) + ) + }), + acquire, + dispatch, + closeSession, + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption + }, + modelCatalog: { + peek: () => null, + read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing, + retainedLiveModelIds: vi.fn(async () => []), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + idleSweep: { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 }, + ...(startupLimits === undefined ? {} : { startupLimits }), + now: () => NOW + }) +} + +export async function restartWith( + next: StructuredAgentSessionAdapter['acquire'], + options: StartupHostOptions = {} +): Promise { + await host.close(SESSION, 'evict') + await host.flushAllStreamedEvents() + acquire.mockImplementation(next) + closeSession.mockClear() + await startHost(options) + acquire.mockClear() +} + +export function registerStartupAttemptHostTests(): void { + beforeEach(async () => { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + root = await mkdtemp(join(tmpdir(), 'orca-startup-attempt-')) + resetHostTestOperationIds() + providerStarted.mockReset() + recordLiveListing.mockReset() + openGates.clear() + acquire = vi.fn(async (input) => acquisition(input)) + dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${dispatch.mock.calls.length}`, + ordinal: dispatch.mock.calls.length + } + })) + closeSession = vi.fn(async () => true) + setOption = vi.fn(async () => undefined) + store = await openTestAgentSessionRecordStore(root) + await startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + }) + + afterEach(async () => { + try { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + } finally { + vi.useRealTimers() + } + }) +} + +function sendParams(text: string, clientOperationId = hostTestOperationId()) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +export async function accept(text: string, clientOperationId?: string): Promise { + const params = sendParams(text, clientOperationId) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +export async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +export async function heldBehindStart(text: string, interval = 0): Promise { + const id = await accept(text) + await eventually( + async () => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('starting'), + interval + ) + return id +} + +export function stop() { + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId: 'turn-none' } + }) + }, + turnId: 'turn-none' + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 059ac09eaf5..c774e8cc96d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -1,230 +1,52 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error' import { withProviderRequestTimeout } from '../../provider-process/provider-request-timeout' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { - claudeAndCodexDeclared, - NO_STRUCTURED_AGENTS -} from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds + hostTestOperationId } from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract' +import { + CALLER, + accept, + acquisition, + acquire, + closeSession, + dispatch, + eventually, + heldBehindStart, + host, + proveOpen, + providerStarted, + publishFirst, + restartWith as restartHostWith, + stop, + store, + submission, + registerStartupAttemptHostTests +} from './structured-agent-session-startup-attempt-host-test-support' -const CALLER = { callerKey: 'client-1' } - -const eventually = (assertion: () => void | Promise, interval = 0): Promise => - vi.waitFor(assertion, { timeout: 10_000, interval }) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let dispatch: Mock -let closeSession: Mock> -let setOption: Mock let startupLimits: Partial | undefined let supportsGoals = false -const providerStarted = vi.fn() -const recordLiveListing = vi.fn() -const openGates = new Map void>() -async function proveOpen(model = 'default'): Promise { - const generation = `generation-${acquire.mock.calls.length}` - await eventually(() => expect(openGates.has(generation)).toBe(true)) - openGates.get(generation)!(model) - openGates.delete(generation) -} - -function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: input.fence, - observedAt: NOW - } - } -} - -const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) - -async function startHost(): Promise { - host = new StructuredAgentSessionHost({ - agents: supportsGoals ? claudeAndCodexDeclared() : NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - open: ({ acquisitionGeneration }) => - new Promise((resolve) => { - openGates.set(acquisitionGeneration!, (model) => - resolve({ reportedOptions: { model, reportedPermissionMode: 'ask' } }) - ) - }), - acquire, - dispatch, - closeSession, - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption - }, - modelCatalog: { - peek: () => null, - read: vi.fn(async () => ({ origin: 'unknown' as const })), - recordLiveListing, - retainedLiveModelIds: vi.fn(async () => []), - prewarm: vi.fn(async () => {}), - stop: vi.fn(), - providerStarted - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - idleSweep: { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 }, - ...(startupLimits === undefined ? {} : { startupLimits }), - now: () => NOW - }) -} - -async function restartWith(next: StructuredAgentSessionAdapter['acquire']): Promise { - await host.close(SESSION, 'evict') - await host.flushAllStreamedEvents() - acquire.mockImplementation(next) - closeSession.mockClear() - await startHost() - acquire.mockClear() -} - -beforeEach(async () => { - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] - }) - root = await mkdtemp(join(tmpdir(), 'orca-startup-attempt-')) - resetHostTestOperationIds() +beforeEach(() => { startupLimits = undefined supportsGoals = false - providerStarted.mockReset() - recordLiveListing.mockReset() - openGates.clear() - acquire = vi.fn(async (input) => acquisition(input)) - dispatch = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${dispatch.mock.calls.length}`, - ordinal: dispatch.mock.calls.length - } - })) - closeSession = vi.fn(async () => true) - setOption = vi.fn(async () => undefined) - store = await openTestAgentSessionRecordStore(root) - await startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) +registerStartupAttemptHostTests() -afterEach(async () => { - try { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) - } finally { - vi.useRealTimers() - } -}) - -function sendParams(text: string, clientOperationId = hostTestOperationId()) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - -async function accept(text: string, clientOperationId?: string): Promise { - const params = sendParams(text, clientOperationId) - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - return params.envelope.clientOperationId -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function heldBehindStart(text: string, interval = 0): Promise { - const id = await accept(text) - await eventually( - async () => - expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting'), - interval - ) - return id -} - -function stop() { - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: { turnId: 'turn-none' } - }) - }, - turnId: 'turn-none' - }) +async function restartWith(next: StructuredAgentSessionAdapter['acquire']): Promise { + await restartHostWith(next, { startupLimits, supportsGoals }) } const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopped'), { @@ -272,59 +94,6 @@ describe('a send while the agent starts', () => { }) }) -it.each([false, true])( - 'keeps current-generation reports across readiness (replaced=%s)', - async (replaced) => { - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - permissionMode: 'own', - now: NOW - }) - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - const fence = store.getRecord(SESSION)!.lease.runtimeFence - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - const report = ( - reportedOptions: { reportedPermissionMode?: string | null }, - generation = acquisitionGeneration, - reportFence = fence - ) => - host.handleAdapterEvent({ - type: 'options-reported', - sessionId: SESSION, - fence: reportFence, - acquisitionGeneration: generation, - reportedOptions - }) - const early = report({ reportedPermissionMode: 'accept-edits' }) - expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() - if (replaced) { - await host.close(SESSION, 'evict') - await early - await proveOpen() - await host.flushStreamedEvents(SESSION) - expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() - return - } - await proveOpen() - await early - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - const current = () => host.readStatusSummary(SESSION)?.runtimePermission - expect(current()).toEqual({ fence, mode: 'accept-edits' }) - await report({ reportedPermissionMode: 'full-access' }, 'foreign') - await report({ reportedPermissionMode: 'full-access' }, acquisitionGeneration, fence + 1) - await report({}) - expect(current()).toEqual({ fence, mode: 'accept-edits' }) - await report({ reportedPermissionMode: null }) - expect(current()).toEqual({ fence, mode: null }) - expect(store.getRecord(SESSION)?.permissionMode).toBe('own') - expect(dispatch).toHaveBeenCalledTimes(1) - await host.close(SESSION, 'evict') - expect(current()).toBeNull() - } -) - describe('a start that ends before it proves itself', () => { it.each([false, true])( 'settles only its own message after startup failure (acquisition refusal: %s)', @@ -486,64 +255,6 @@ describe('a start that ends before it proves itself', () => { }) }) -describe('what a started child reports', () => { - it('keeps provider reports out of the saved next-message choice', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - await proveOpen('reported') - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await host.handleAdapterEvent({ - type: 'options-reported', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - reportedOptions: { model: 'from-settings' } - }) - await host.flushStreamedEvents(SESSION) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - }) - - it('preserves a later accepted pick when an older child reports options', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - options: { model: 'picked' }, - now: NOW - }) - await proveOpen('reported') - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await host.handleAdapterEvent({ - type: 'options-reported', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - reportedOptions: { model: 'from-settings' } - }) - await host.flushStreamedEvents(SESSION) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('saves a settings readback that arrives before open proves the start', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const listing = { models: [], configuredDefault: { modelId: 'opus' } } - const reported = host.handleAdapterEvent({ - type: 'options-reported', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - reportedOptions: { model: 'opus' }, - catalogListing: listing - }) - await proveOpen() - await reported - - await eventually(() => expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing)) - }) -}) - function startupAttemptOpen(): boolean { return host.collaboratorsForTests().runtimeState.startupAttempts.isOpen(SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-reports.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-reports.test.ts new file mode 100644 index 00000000000..e041e8b9aad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-reports.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { + acquire, + dispatch, + eventually, + heldBehindStart, + host, + proveOpen, + publishFirst, + recordLiveListing, + restartWith, + store, + submission, + registerStartupAttemptHostTests +} from './structured-agent-session-startup-attempt-host-test-support' + +registerStartupAttemptHostTests() + +it.each([false, true])( + 'keeps current-generation reports across readiness (replaced=%s)', + async (replaced) => { + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'own', + now: NOW + }) + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + const fence = store.getRecord(SESSION)!.lease.runtimeFence + const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + const report = ( + reportedOptions: { reportedPermissionMode?: string | null }, + generation = acquisitionGeneration, + reportFence = fence + ) => + host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: reportFence, + acquisitionGeneration: generation, + reportedOptions + }) + const early = report({ reportedPermissionMode: 'accept-edits' }) + expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() + if (replaced) { + await host.close(SESSION, 'evict') + await early + await proveOpen() + await host.flushStreamedEvents(SESSION) + expect(host.readStatusSummary(SESSION)?.runtimePermission).toBeNull() + return + } + await proveOpen() + await early + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + const current = () => host.readStatusSummary(SESSION)?.runtimePermission + expect(current()).toEqual({ fence, mode: 'accept-edits' }) + await report({ reportedPermissionMode: 'full-access' }, 'foreign') + await report({ reportedPermissionMode: 'full-access' }, acquisitionGeneration, fence + 1) + await report({}) + expect(current()).toEqual({ fence, mode: 'accept-edits' }) + await report({ reportedPermissionMode: null }) + expect(current()).toEqual({ fence, mode: null }) + expect(store.getRecord(SESSION)?.permissionMode).toBe('own') + expect(dispatch).toHaveBeenCalledTimes(1) + await host.close(SESSION, 'evict') + expect(current()).toBeNull() + } +) + +describe('what a started child reports', () => { + it('keeps provider reports out of the saved next-message choice', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + await proveOpen('reported') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + await host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reportedOptions: { model: 'from-settings' } + }) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options).toBeUndefined() + }) + + it('preserves a later accepted pick when an older child reports options', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'picked' }, + now: NOW + }) + await proveOpen('reported') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + await host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reportedOptions: { model: 'from-settings' } + }) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) + }) + + it('saves a settings readback that arrives before open proves the start', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + const listing = { models: [], configuredDefault: { modelId: 'opus' } } + const reported = host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reportedOptions: { model: 'opus' }, + catalogListing: listing + }) + await proveOpen() + await reported + + await eventually(() => expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing)) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts index 6904bd6e2ca..16571ca0740 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -31,6 +31,20 @@ export function structuredStatusChildWork( return { children, ...(tasks ? { backgroundTasks: tasks } : {}) } } +/** Exclude per-tick usage from global broadcasts; chat background channels retain it. */ +export function structuredStatusChildWorkFields( + views: readonly AgentChildWorkView[] | undefined, + provider: StructuredAgentId +): StructuredStatusChildWork { + const { children, backgroundTasks } = structuredStatusChildWork(views, provider) + return { + ...(backgroundTasks && backgroundTasks.length > 0 + ? { backgroundTasks: backgroundTasks.map(({ totalTokens: _tokens, ...task }) => task) } + : {}), + ...(children ? { children } : {}) + } +} + export function structuredStatusChildrenEqual( a: readonly AgentChildWorkView[] | undefined, b: readonly AgentChildWorkView[] | undefined 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 2e274026317..28a0fdc3a95 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 @@ -22,7 +22,7 @@ import { retainedStructuredStatusSummaries, structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary' -import { structuredStatusChildWork } from './structured-agent-session-status-child-work' +import { structuredStatusChildWorkFields } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, type StructuredAgentSessionJournalProjection @@ -244,16 +244,7 @@ export class StructuredAgentSessionStatusFeed { sessionId: string, provider: StatusFeedSession['params']['provider'] ): Pick { - const { children, backgroundTasks } = structuredStatusChildWork( - this.readChildWork(sessionId), - provider - ) - return { - ...(backgroundTasks && backgroundTasks.length > 0 - ? { backgroundTasks: backgroundTasks.map(({ totalTokens: _tokens, ...task }) => task) } - : {}), - ...(children ? { children } : {}) - } + return structuredStatusChildWorkFields(this.readChildWork(sessionId), provider) } /** Publish evidence then refold parent/summary; sink failure must not block other publications. */ diff --git a/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx b/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx index 18ee4cded61..d7762d35e97 100644 --- a/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx +++ b/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx @@ -41,7 +41,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' import { applyLocalStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply' -import { readStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' +import { readStructuredAgentLaunchRecord } from '@/lib/agent-session/launch/structured-agent-session-launch-persistence' const mock = vi.hoisted(() => ({ rpc: vi.fn(),