diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts new file mode 100644 index 00000000000..5e2f4de9b70 --- /dev/null +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -0,0 +1,83 @@ +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' +import type { ClaudeRewindAttempt } from './claude-structured-rewind' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { + cancelClaudeAcquisitionAttempt, + type ClaudeAcquisitionAttempt, + type ClaudeAcquisitionRegistry, + type ClaudeAcquireCallbacks, + type ClaudeSession, + type ClaudeSessionExit, + type ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' +import { + claudeAcquisitionCleanupError, + closeClaudePublishedSessionForDeps +} from './claude-structured-session-close' + +export async function resolveClaudeAcquisitionLaunch(args: { + input: StructuredAgentSessionAcquireInput + deps: ClaudeStructuredSessionAdapterDeps + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + callbacks: ClaudeAcquireCallbacks + previous: ClaudeAcquisitionAttempt | undefined + attempt: ClaudeAcquisitionAttempt + rewind: ClaudeRewindAttempt +}): Promise { + const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt, rewind } = args + const sessionId = input.identity.sessionId + return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => { + if (previous && !(await cancelClaudeAcquisitionAttempt(previous))) { + acquisitions.restoreIfCurrent(sessionId, attempt, previous) + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude acquisition for session ${sessionId} could not be stopped`) + ) + } + acquisitions.assertCurrent(sessionId, attempt) + let resumeSession = sessions.get(sessionId) + if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude session ${sessionId} could not be stopped`) + ) + } + const retainedExit = exits.get(sessionId) + if (retainedExit) { + const firstProof = retainedExit.closePromise ? await retainedExit.closePromise : false + const proven = firstProof || (await retainedExit.connection.close().catch(() => false)) + if (!proven) { + throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error) + } + // The superseded child must settle before its durable resume identity is reused. + await callbacks.settleExit(sessionId, retainedExit) + resumeSession ??= retainedExit.session + } + acquisitions.assertCurrent(sessionId, attempt) + const launchIdentity = resumeSession + ? { + ...input.identity, + providerHandle: { + kind: 'claude' as const, + sessionId: resumeSession.providerSessionId, + leafUuid: resumeSession.leafUuid + } + } + : input.identity + const launch = await deps + .resolveLaunch({ identity: launchIdentity }) + .catch((error: unknown) => { + throw error instanceof AgentSessionPreSpawnError + ? error + : new AgentSessionPreSpawnError(error) + }) + rewind.applyLaunch(launch, deps) + acquisitions.assertCurrent(sessionId, attempt) + return launch + }) +} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 8870a0e1daa..eb7bc9b7251 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,6 +1,5 @@ import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind' import { - AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, type AgentSessionAcquisition, type StructuredAgentSessionAcquireInput @@ -37,7 +36,6 @@ import { } from './claude-structured-session-acquisition-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' import { - cancelClaudeAcquisitionAttempt, mintClaudeAcquisitionGeneration, type ClaudeAcquisitionRegistry, type ClaudeSession, @@ -45,12 +43,10 @@ import { type ClaudeStructuredSessionAdapterDeps, type ClaudeAcquireCallbacks } from './claude-structured-session-state' -import { - claudeAcquisitionCleanupError, - closeClaudePublishedSessionForDeps, - resolveClaudeAcquisitionError -} from './claude-structured-session-close' +import { resolveClaudeAcquisitionError } from './claude-structured-session-close' import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' +import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' +import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 @@ -148,96 +144,68 @@ export async function acquireClaudeSession({ }) try { - if (previous && !(await cancelClaudeAcquisitionAttempt(previous))) { - acquisitions.restoreIfCurrent(sessionId, attempt, previous) - throw new AgentSessionAcquisitionExitUnprovenError( - new Error(`claude acquisition for session ${sessionId} could not be stopped`) - ) - } - acquisitions.assertCurrent(sessionId, attempt) - let resumeSession = sessions.get(sessionId) - if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) { - throw new AgentSessionAcquisitionExitUnprovenError( - new Error(`claude session ${sessionId} could not be stopped`) - ) - } - // A first-hand exit that has not yet proved its full tree still owns a cleanup - // obligation; never let a new acquisition hide that evidence by omission. - const retainedExit = exits.get(sessionId) - if (retainedExit) { - const firstProof = retainedExit.closePromise ? await retainedExit.closePromise : false - const proven = firstProof || (await retainedExit.connection.close().catch(() => false)) - if (!proven) { - throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error) - } - // The old child is superseded by this acquisition. Settle its lifecycle - // before discarding the retained proof so its cursor and callbacks are - // cleaned up exactly once. - await callbacks.settleExit(sessionId, retainedExit) - resumeSession ??= retainedExit.session - } - acquisitions.assertCurrent(sessionId, attempt) - // Both close paths persist their final leaf, so launch validates that durable head. - const launchIdentity = resumeSession - ? { - ...input.identity, - providerHandle: { - kind: 'claude' as const, - sessionId: resumeSession.providerSessionId, - leafUuid: resumeSession.leafUuid - } - } - : input.identity - const launch = await deps - .resolveLaunch({ identity: launchIdentity }) - .catch((error: unknown) => { - throw error instanceof AgentSessionPreSpawnError - ? error - : new AgentSessionPreSpawnError(error) - }) - rewind.applyLaunch(launch, deps) + const launch = await resolveClaudeAcquisitionLaunch({ + input, + deps, + sessions, + acquisitions, + exits, + callbacks, + previous, + attempt, + rewind + }) expectedProviderSessionId = launch.providerSessionId observedLeafUuid = launch.resumeLeafUuid - acquisitions.assertCurrent(sessionId, attempt) const open = deps.openConnection ?? openClaudeStreamJsonConnection - const connection = await open( - { - pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, - options: launch.options, - cwd: launch.cwd, - env: { - ...launch.env, - [CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken, - // Compared against what the child would otherwise inherit, so the record's - // account home still wins over a diverging overlay without a needless pin. - // (`process` is shadowed by a local later in this function, so it is not named here.) - ...claudeConfigDirEnvPatch(launch.claudeConfigDir, launch.env ? { env: launch.env } : {}) - } - }, - { - onMessage, - canUseTool, - onUserDialog, - onFault: (error) => { - if (!attempt.published) { - initDeadline.reject(error) + const connection = await withAgentSessionCreatePhase('spawn', input.recordPhase, () => + open( + { + pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + options: launch.options, + cwd: launch.cwd, + env: { + ...launch.env, + [CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken, + // Compared against what the child would otherwise inherit, so the record's + // account home still wins over a diverging overlay without a needless pin. + // (`process` is shadowed by a local later in this function, so it is not named here.) + ...claudeConfigDirEnvPatch( + launch.claudeConfigDir, + launch.env ? { env: launch.env } : {} + ) } }, - onExit: (error) => { - if (!attempt.published) { - initDeadline.reject(error) + { + onMessage, + canUseTool, + onUserDialog, + onFault: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + }, + onExit: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + callbacks.handleExit(sessionId, attempt, error) } - callbacks.handleExit(sessionId, attempt, error) } - } + ) ) attempt.connection = connection acquisitions.assertCurrent(sessionId, attempt) initDeadline.start() - const [initialization, init] = await Promise.all([ - requestClaudeInitialization(connection, sessionId, initTimeoutMs), - initDeadline.promise - ]) + const [initialization, init] = await withAgentSessionCreatePhase( + 'init', + input.recordPhase, + () => + Promise.all([ + requestClaudeInitialization(connection, sessionId, initTimeoutMs), + initDeadline.promise + ]) + ) const models = readClaudeModels(initialization) callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, { type: 'options', sessionId, models }) @@ -274,36 +242,43 @@ export async function acquireClaudeSession({ if (connection.closed) { throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`) } - const publication = createClaudeSessionPublication({ - connection, - init, - initialization, - claudeConfigDir: launch.claudeConfigDir, - leafUuid: observedLeafUuid, - fence: input.fence, - effort: readClaudeSettingsEffort(settings), - ...claudeStructuredSessionPublicationOptions(acquisitionOptions), - resumed: launch.resumed, - prompts, - translator, - events: input.events, - process, - acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), - options: acquisitionOptions.options, - capabilities: readClaudeCapabilities(init, initialization), - ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), - observedAt: deps.now?.() ?? Date.now() - }) + const publication = await withAgentSessionCreatePhase('publish', input.recordPhase, async () => + createClaudeSessionPublication({ + connection, + init, + initialization, + claudeConfigDir: launch.claudeConfigDir, + leafUuid: observedLeafUuid, + fence: input.fence, + effort: readClaudeSettingsEffort(settings), + ...claudeStructuredSessionPublicationOptions(acquisitionOptions), + resumed: launch.resumed, + prompts, + translator, + events: input.events, + process, + acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), + options: acquisitionOptions.options, + capabilities: readClaudeCapabilities(init, initialization), + ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), + observedAt: deps.now?.() ?? Date.now() + }) + ) + const acquired: AgentSessionAcquisition = publication.acquisition liveSession = publication.session - await restoreClaudeStructuredSessionOptions(liveSession, deps.requestTimeoutMs) + await withAgentSessionCreatePhase('restore_options', input.recordPhase, () => + restoreClaudeStructuredSessionOptions(liveSession!, deps.requestTimeoutMs) + ) acquisitions.assertCurrent(sessionId, attempt) acquisitions.deleteIfCurrent(sessionId, attempt) - sessions.set(sessionId, liveSession) - attempt.published = true - for (const event of attempt.buffered.splice(0)) { - event() - } - return publication.acquisition + await withAgentSessionCreatePhase('publish', input.recordPhase, async () => { + sessions.set(sessionId, liveSession!) + attempt.published = true + for (const event of attempt.buffered.splice(0)) { + event() + } + }) + return acquired } catch (error) { initDeadline.clear() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 0de5049a71c..46bda78cacd 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -11,8 +11,42 @@ import { identityFor } from './claude-structured-session-test-support' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { closeClaudeSession } from './claude-structured-session-close' +import { ClaudeAcquisitionRegistry } from './claude-structured-session-state' describe('Claude published session close lifecycle', () => { + it('reports a proven root exit when published-session close cannot prove descendants', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const connection = claude.connections[0]! + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + connection.close = vi.fn<() => Promise>().mockResolvedValue(false) + + await expect(adapter.closeSession('session-1')).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + }) + + it('reports the same root-exit verdict while cancelling acquisition', async () => { + const claude = fakeClaude({ + unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } + }) + const acquisitions = new ClaudeAcquisitionRegistry() + const { attempt } = acquisitions.start('session-1', new ClaudePromptRegistry()) + attempt.connection = await claude.openConnection({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo' + }) + + await expect( + closeClaudeSession({ sessionId: 'session-1', sessions: new Map(), acquisitions }) + ).rejects.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + it('ends the session even when the durable handle write rejects', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 52097f7ee1e..431d6e38ab8 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -98,6 +98,14 @@ async function finalizeClaudePublishedSession( prompt.settle(null) } if ((await session.connection.close()) !== true) { + const cleanupError = claudeAcquisitionCleanupError( + session.connection, + new Error('provider close unproven') + ) + // Why: the owner can release proven root-exit/processless sessions; genuinely unknown exits retry. + if (!(cleanupError instanceof AgentSessionAcquisitionExitUnprovenError)) { + throw cleanupError + } return false } if (session.backgroundTasks.clear()) { @@ -263,6 +271,14 @@ export async function closeClaudeSession(input: { }): Promise { const attempt = input.acquisitions.get(input.sessionId) if (!(await cancelClaudeAcquisitionAttempt(attempt))) { + const cleanupError = claudeAcquisitionCleanupError( + attempt?.connection, + new Error('acquisition cancel unproven') + ) + // Why: cancellation must preserve the same actionable verdict as published-session close. + if (!(cleanupError instanceof AgentSessionAcquisitionExitUnprovenError)) { + throw cleanupError + } return false } if (attempt) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 18849ea1202..78bf7cf2808 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -16,6 +16,7 @@ import { type AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach } from './structured-agent-session-attach-flow' +import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -221,6 +222,7 @@ describe('structured session acquisition options', () => { }) const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } + const recordPhase = vi.fn() const created = await performAttach({ store, @@ -235,11 +237,14 @@ describe('structured session acquisition options', () => { callerKey: 'client-1', params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, + recordPhase, onAttached: () => {} }) expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith(expect.objectContaining({ options })) + expect(sessionAdapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ options, recordPhase }) + ) expect(store.getRecord(SESSION)?.options).toEqual(options) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index ad6cd2433e4..87c63454b24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -9,6 +9,7 @@ import { import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' +import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ @@ -43,14 +44,17 @@ export async function acquireOwner( // Retries must recover the original reservation, not mint a second child. spawnToken, ...(record.options ? { options: record.options } : {}), - ...(input.eventSink ? { events: input.eventSink } : {}) - }) - const options = await readNativeSessionOptions({ - adapter: input.adapter, - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) + ...(input.eventSink ? { events: input.eventSink } : {}), + ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}) }) + const options = await withAgentSessionCreatePhase('restore_options', input.recordPhase, () => + readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + ) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 81ef7f79062..e5daa9991d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -30,6 +30,7 @@ import type { } from '../../../shared/agent-session-wire' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { constructor( @@ -139,6 +140,7 @@ export type StructuredAgentSessionAcquireInput = { options?: Readonly> /** Provider events may begin before acquisition returns. */ events?: StructuredAgentSessionEventSink + recordPhase?: AgentSessionCreatePhaseRecorder } export type StructuredAgentSessionSetOptionInput = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index e9ed9367c66..3abfd42b8aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -38,6 +38,10 @@ import { importAdoptedTranscript, prepareAdoptedTranscript } from './structured-agent-session-adopted-import' +import { + withAgentSessionCreatePhase, + type AgentSessionCreatePhaseRecorder +} from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' export type AttachFlowInput = { @@ -49,6 +53,7 @@ export type AttachFlowInput = { callerKey: string params: AgentSessionAttachParams now: () => number + recordPhase?: AgentSessionCreatePhaseRecorder /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ @@ -102,15 +107,17 @@ export async function performAttach( return preparedTranscript } try { - const reserved = await store.reserveOwner( - reserveRequestFor({ - sessionId, - params, - authority: input.authority, - callerKey: input.callerKey, - fingerprint: admitted.fingerprint, - now: input.now() - }) + const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => + store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) ) record = reserved.record replayed = reserved.disposition === 'replayed' @@ -153,7 +160,9 @@ export async function performAttach( ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) }) if (!agentSessionLeaseAdmitsWriter(record.lease)) { - const acquired = await acquireOwner(input, record) + const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => + acquireOwner(input, record) + ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration acquiredOwner = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index eec3841a08c..c85536ff679 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -28,6 +28,12 @@ import { forgetStructuredAgentSession } from './structured-agent-session-host-li import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + addAgentSessionCreatePhaseAttributes, + withAgentSessionCreatePhase, + withAgentSessionSpan, + type AgentSessionCreatePhaseRecorder +} from '../../observability/agent-session-instrumentation' export function attachStructuredAgentSession( context: StructuredAgentSessionAttachContext, @@ -37,135 +43,161 @@ export function attachStructuredAgentSession( rewind?: StructuredAgentSessionAcquireInput['rewind'] ): Promise> { const sessionId = params.envelope.sessionId - const attaching = context.serialize(sessionId, async () => { - if (admitRecoveryTicket && !admitRecoveryTicket()) { - return refuseAgentSessionMutation({ - code: 'agent_session_checkpoint_stale', - message: 'The provider-exit recovery ticket is no longer current.' - }) - } - const unreconciled = await context.reconcileLeases(sessionId) - if (unreconciled) { - return refuseAgentSessionMutation(unreconciled) - } - await context.runtimeState.resolveRecovery(sessionId) - // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers - // settled when the record has none pending, so every attach can ask unconditionally. - const settled = await retryPendingStructuredAgentSessionSettlement({ - deps: context.deps, - sessions: context.sessions, - sessionId, - params, - now: () => context.now() - }) - if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) - } - const eventSink = context.runtimeState.eventSinkFor(sessionId) - const attached = await performAttach({ - rewind, - store: context.deps.store, - adapter: context.deps.adapter, - journalRoot: context.deps.journalRoot, - eventSink: eventSink.sink, - onAcquiring: async () => { - const barrier = await eventSink.drained() - if (!barrier.ok) { - throw barrier.error - } - eventSink.unbind() - }, - authority: { - spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), - claimKeyId: context.deps.claimKeyId, - handoffOperationId: params.envelope.clientOperationId, - probe: await context.runtimeState.probeOwner(sessionId), - ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), - ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) - }, - callerKey, - params, - now: () => context.now(), - // Site 9: this closes the PRIOR map entry it drops, never the provisional - // journal — it has no reference to that one. `onAttached` owns that. - onAttachFailed: async () => { - await forgetStructuredAgentSession(context, sessionId) - eventSink.close() - context.runtimeState.discardEventSink(sessionId) - }, - onAttached: async (attached, acquisitionGeneration, acquiredOwner) => { - const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 - const previous = context.sessions.get(sessionId) - const previousFence = previous?.fence - // Site 8: the provisional journal has no owner until the map takes it, - // and the barrier below throws by design. - try { - if (acquiredOwner) { - // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleSessionStateOnAcquire({ - journal: attached.journal, - sessionId, - fence, - acquisitionGeneration - }) + const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => + context.serialize(sessionId, async () => { + if (admitRecoveryTicket && !admitRecoveryTicket()) { + return refuseAgentSessionMutation({ + code: 'agent_session_checkpoint_stale', + message: 'The provider-exit recovery ticket is no longer current.' + }) + } + const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => + context.reconcileLeases(sessionId) + ) + if (unreconciled) { + return refuseAgentSessionMutation(unreconciled) + } + await withAgentSessionCreatePhase('resolve_recovery', recordPhase, () => + context.runtimeState.resolveRecovery(sessionId) + ) + // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers + // settled when the record has none pending, so every attach can ask unconditionally. + const settled = await withAgentSessionCreatePhase('settlement_retry', recordPhase, () => + retryPendingStructuredAgentSessionSettlement({ + deps: context.deps, + sessions: context.sessions, + sessionId, + params, + now: () => context.now() + }) + ) + if (!settled) { + return refuseAgentSessionMutation({ + code: 'agent_session_ownership_unknown', + message: 'The provider-exit terminal journal settlement is still pending; retry attach.' + }) + } + const eventSink = context.runtimeState.eventSinkFor(sessionId) + const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => + context.runtimeState.probeOwner(sessionId) + ) + const attached = await performAttach({ + rewind, + store: context.deps.store, + adapter: context.deps.adapter, + journalRoot: context.deps.journalRoot, + eventSink: eventSink.sink, + onAcquiring: async () => { + const barrier = await eventSink.drained() + if (!barrier.ok) { + throw barrier.error } - await bindAndDrain(eventSink, attached.journal, fence, (activity) => - context.subscribers.publish(sessionId, attached.journal, activity) - ) - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error - } - // Site 10: a `set` over a live entry would orphan its handle — and a - // close that REJECTED did not release it. The replacement is therefore - // ABORTED rather than completed over a handle nothing can reach again: - // `previous` stays indexed, so teardown still owns it and can retry. - if (previous && previous.journal !== attached.journal) { + eventSink.unbind() + }, + authority: { + spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: context.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe, + ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), + ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) + }, + callerKey, + params, + now: () => context.now(), + recordPhase, + // Site 9: this closes the PRIOR map entry it drops, never the provisional + // journal — it has no reference to that one. `onAttached` owns that. + onAttachFailed: async () => { + await forgetStructuredAgentSession(context, sessionId) + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + }, + onAttached: async (attached, acquisitionGeneration, acquiredOwner) => { + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previous = context.sessions.get(sessionId) + const previousFence = previous?.fence + // Site 8: the provisional journal has no owner until the map takes it, + // and the barrier below throws by design. try { - await previous.journal.close() + if (acquiredOwner) { + // Before the drain: the buffered events are the new child's, never a stale row's. + await settleStaleSessionStateOnAcquire({ + journal: attached.journal, + sessionId, + fence, + acquisitionGeneration + }) + } + await bindAndDrain(eventSink, attached.journal, fence, (activity) => + context.subscribers.publish(sessionId, attached.journal, activity) + ) } catch (error) { await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) throw error } - } - context.sessions.set(sessionId, { - journal: attached.journal, - params, - fence, - hasProviderChild: true, - acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null - }) - if (!rewind) { - await recoverStructuredRewind( - context.deps.store, - sessionId, - attached.journal, + // Site 10: a `set` over a live entry would orphan its handle — and a + // close that REJECTED did not release it. The replacement is therefore + // ABORTED rather than completed over a handle nothing can reach again: + // `previous` stays indexed, so teardown still owns it and can retry. + if (previous && previous.journal !== attached.journal) { + try { + await previous.journal.close() + } catch (error) { + await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) + throw error + } + } + context.sessions.set(sessionId, { + journal: attached.journal, + params, fence, - context.deps.adapter, - context.now - ) - } - await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) - if (attached.recovery) { - context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) - } else if (previousFence !== undefined && previousFence !== fence) { - context.subscribers.snapshot(sessionId, attached.journal, fence) - } else { - context.subscribers.publish(sessionId, attached.journal) + hasProviderChild: true, + acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null + }) + if (!rewind) { + await recoverStructuredRewind( + context.deps.store, + sessionId, + attached.journal, + fence, + context.deps.adapter, + context.now + ) + } + await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) + if (attached.recovery) { + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } } + }) + // Why: a failed attach that left no session behind must not strand a bound sink; the runtime + // caches one per session id and would hand this same closed instance to the next attempt. + if (!attached.ok && !context.sessions.has(sessionId)) { + eventSink.close() + context.runtimeState.discardEventSink(sessionId) } + return attached }) - // Why: a failed attach that left no session behind must not strand a bound sink; the runtime - // caches one per session id and would hand this same closed instance to the next attempt. - if (!attached.ok && !context.sessions.has(sessionId)) { - eventSink.close() - context.runtimeState.discardEventSink(sessionId) - } - return attached - }) + const attaching = + params.envelope.expectedRuntimeFence === null + ? withAgentSessionSpan(async (span) => { + const startedAtMs = Date.now() + const phases: Parameters[0][] = [] + try { + return await run((timing) => phases.push(timing)) + } finally { + addAgentSessionCreatePhaseAttributes(span, { + totalDurationMs: Math.max(0, Date.now() - startedAtMs), + phases + }) + } + }) + : run() return context.tasks.trackAttach(attaching) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts new file mode 100644 index 00000000000..a7ca961d13f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { evictHeldStructuredAgentSession } from './structured-agent-session-host-lifetime' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +const NOW = 1_788_727_031_330 +const roots: string[] = [] +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Claude root-exit eviction', () => { + it('releases a captured live claim after the provider root exits', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const claude = fakeClaude({ + unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } + }) + const adapter = adapterFor(claude) + const reservation = await store.reserveOwner({ + sessionId: 'session-1', + location: { + executionHostId: 'local', + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-1', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + const fence = reservation.record.lease.runtimeFence + const acquisition = await adapter.acquire({ + identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, + fence, + spawnToken: 'spawn-1' + }) + await store.commitProcessIdentity({ + sessionId: 'session-1', + fence, + process: acquisition.process, + now: NOW + }) + await store.proveOwner({ + sessionId: 'session-1', + fence, + link: acquisition.link, + now: NOW + }) + const journal = await journals.open({ + identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, + journalDir: join(root, 'journal') + }) + const close = vi.spyOn(journal, 'close') + const params: AgentSessionAttachParams = { + envelope: { + sessionId: 'session-1', + clientOperationId: `${NOW}-00000000000000000000000000000001`, + expectedRuntimeFence: fence, + payloadFingerprint: 'create' + }, + location: { + executionHostId: 'local', + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + runtimeKind: 'native', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + } + const sessions = new Map([ + [ + 'session-1', + { + journal, + params, + fence, + hasProviderChild: true, + acquisitionGeneration: acquisition.acquisitionGeneration ?? null + } + ] + ]) + const deps = { store, adapter, journalRoot: root, claimKeyId: 'key-1' } + const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) + + claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) + await expect( + evictHeldStructuredAgentSession( + { + deps, + runtimeState, + sessions, + now: () => NOW + 30 * 60_000, + forgetStatus: vi.fn() + }, + 'session-1' + ) + ).resolves.toBeUndefined() + + expect(store.getRecord('session-1')?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'exit-observed' } + }) + expect(sessions.size).toBe(0) + expect(close).toHaveBeenCalledOnce() + // Why: releasing the root-owned lease does not claim unverifiable descendants stopped. + await expect(adapter.closeSession('session-1')).rejects.toThrow('provider exited') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 90b50d3cb90..36cecd44807 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -5,6 +5,10 @@ import { STRUCTURED_AGENT_SESSION_EVICTION_STEPS, type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' +import { + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError +} from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' function context(): StructuredAgentSessionEvictionContext & { order: string[] } { @@ -141,6 +145,28 @@ describe('rows the provider emits while closing', () => { // `closeSession` returning false means the adapter could not prove the child exited and has kept // the session indexed on purpose so a retry can reach it. describe('a child that will not stop', () => { + it.each([ + new AgentSessionAcquisitionRootExitObservedError(new Error('root exited')), + new AgentSessionPreSpawnError(new Error('spawn failed')) + ])('continues eviction after an actionable provider verdict', async (error) => { + const ctx = context() + ctx.adapter.closeSession = vi.fn(async () => { + throw error + }) + + await evictStructuredAgentSession(ctx) + + expect(ctx.order).toEqual([ + 'drained', + 'settleWork', + 'unbind', + 'close', + 'discardSink', + 'releaseLease', + 'forget' + ]) + }) + it('aborts without forgetting the session, so the next close is a real retry', async () => { const ctx = context() ctx.adapter.closeSession = vi.fn(async () => false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 7264ca4a338..04840c18d5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -17,7 +17,11 @@ // reach it; forgetting it anyway stranded the process forever and reported success. Leaving the // session in place is what makes the next close a real retry instead of a no-op. -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' export type StructuredAgentSessionEvictionContext = { @@ -59,9 +63,19 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession if (stop) { - const stopped = await stop.call(context.adapter, context.sessionId) - if (stopped !== true) { - throw new Error('provider child exit was not proven') + try { + const stopped = await stop.call(context.adapter, context.sessionId) + if (stopped !== true) { + throw new Error('provider child exit was not proven') + } + } catch (error) { + // Why: lease ownership follows the provider root; known-live descendants still throw unproven. + if ( + !(error instanceof AgentSessionAcquisitionRootExitObservedError) && + !(error instanceof AgentSessionPreSpawnError) + ) { + throw error + } } } context.onProviderChildStopped?.() diff --git a/src/main/observability/agent-session-instrumentation.ts b/src/main/observability/agent-session-instrumentation.ts new file mode 100644 index 00000000000..85ecd4f0d45 --- /dev/null +++ b/src/main/observability/agent-session-instrumentation.ts @@ -0,0 +1,83 @@ +import { withSpan, type ActiveSpan } from './tracer' + +export type AgentSessionCreatePhase = + | 'reconcile_leases' + | 'resolve_recovery' + | 'settlement_retry' + | 'probe_owner' + | 'reserve_owner' + | 'acquire_owner' + | 'auth_settle' + | 'spawn' + | 'init' + | 'restore_options' + | 'publish' + +export type AgentSessionCreatePhaseTiming = { + readonly phase: AgentSessionCreatePhase + readonly startedAtMs: number + readonly durationMs: number +} + +export type AgentSessionCreatePhaseRecorder = (timing: AgentSessionCreatePhaseTiming) => void + +/** Wrap the rare user-created structured session; no sampling is needed for this event. */ +export async function withAgentSessionSpan(fn: (span: ActiveSpan) => Promise): Promise { + return withSpan('agentSession.create', fn, { attributes: { kind: 'agent-session' } }) +} + +export async function withAgentSessionCreatePhase( + phase: AgentSessionCreatePhase, + record: AgentSessionCreatePhaseRecorder | undefined, + fn: () => Promise +): Promise { + const startedAtMs = Date.now() + try { + return await fn() + } finally { + record?.({ phase, startedAtMs, durationMs: Math.max(0, Date.now() - startedAtMs) }) + } +} + +/** Records the closed create vocabulary without copying branch, path, prompt, or session content. */ +export function addAgentSessionCreatePhaseAttributes( + span: ActiveSpan, + timing: { + totalDurationMs: number + phases: readonly AgentSessionCreatePhaseTiming[] + } +): void { + span.setAttribute('agent_session.create.total_ms', Math.round(timing.totalDurationMs)) + const phaseDurations = new Map() + for (const phase of timing.phases) { + phaseDurations.set(phase.phase, (phaseDurations.get(phase.phase) ?? 0) + phase.durationMs) + } + for (const [phase, durationMs] of phaseDurations) { + span.setAttribute(`agent_session.create.phase.${phase}_ms`, Math.round(durationMs)) + } + const intervals = [...timing.phases] + .map(({ startedAtMs, durationMs }) => [startedAtMs, startedAtMs + durationMs] as const) + .sort((left, right) => left[0] - right[0]) + let coveredMs = 0 + let openedAt: number | null = null + let closesAt = 0 + for (const [start, end] of intervals) { + if (openedAt === null) { + openedAt = start + closesAt = end + } else if (start <= closesAt) { + closesAt = Math.max(closesAt, end) + } else { + coveredMs += closesAt - openedAt + openedAt = start + closesAt = end + } + } + if (openedAt !== null) { + coveredMs += closesAt - openedAt + } + span.setAttribute( + 'agent_session.create.unattributed_ms', + Math.max(0, Math.round(timing.totalDurationMs - coveredMs)) + ) +} diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 452b5cd155c..62894c0ef9c 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -6,6 +6,10 @@ import { addWorktreeCreatePhaseAttributes, withGitSpan } from './instrumentation' +import { + addAgentSessionCreatePhaseAttributes, + withAgentSessionSpan +} from './agent-session-instrumentation' type SpanRecord = { readonly name: string @@ -249,3 +253,36 @@ describe('addWorktreeCreatePhaseAttributes', () => { expect(attributes['worktree.create.prepared_checkout']).toBeUndefined() }) }) + +describe('agentSession.create tracing', () => { + it('emits one span with the closed phase vocabulary and no user content attributes', async () => { + await withAgentSessionSpan(async (span) => { + addAgentSessionCreatePhaseAttributes(span, { + totalDurationMs: 66, + phases: [ + { phase: 'reconcile_leases', startedAtMs: 0, durationMs: 1 }, + { phase: 'resolve_recovery', startedAtMs: 1, durationMs: 2 }, + { phase: 'settlement_retry', startedAtMs: 3, durationMs: 3 }, + { phase: 'probe_owner', startedAtMs: 6, durationMs: 4 }, + { phase: 'reserve_owner', startedAtMs: 10, durationMs: 5 }, + { phase: 'acquire_owner', startedAtMs: 15, durationMs: 6 }, + { phase: 'auth_settle', startedAtMs: 21, durationMs: 7 }, + { phase: 'spawn', startedAtMs: 28, durationMs: 8 }, + { phase: 'init', startedAtMs: 36, durationMs: 9 }, + { phase: 'restore_options', startedAtMs: 45, durationMs: 10 }, + { phase: 'publish', startedAtMs: 55, durationMs: 11 } + ] + }) + }) + + const records = sink.records.filter((record) => record.name === 'agentSession.create') + expect(records).toHaveLength(1) + const attributes = records[0]!.attributes + expect(attributes['agent_session.create.phase.reconcile_leases_ms']).toBe(1) + expect(attributes['agent_session.create.phase.publish_ms']).toBe(11) + expect(attributes['agent_session.create.unattributed_ms']).toBe(0) + expect(Object.keys(attributes).some((key) => /path|branch|prompt|content/i.test(key))).toBe( + false + ) + }) +}) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 61da9021a5f..3d1f6d33954 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -2,8 +2,8 @@ // // Every other release in the wire needs a probe, because every other release is about a process // somebody else started and nobody watched die. This one is different: the host stopped its own -// child through the adapter and the adapter proved the exit before this runs, so the evidence is -// `exit-observed` rather than an adjudicated absence. +// lease-owning provider root through the adapter. Its observed exit is sufficient because the +// lease follows that root, even when descendants remain `unverifiable`. // // The fence still moves. A released lease at the old fence would let a mutation a client queued // against the dead generation land on the next one. diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts index a207244cdb9..a86e9901835 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -30,7 +30,9 @@ describe('launchDashboardAgent', () => { vi.clearAllMocks() mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) - mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-terminal', tabId: 'tab-1' } + }) }) it('activates a folder or git workspace on its execution host before launching', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx new file mode 100644 index 00000000000..7fce2abc9ea --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx @@ -0,0 +1,48 @@ +import { RotateCcw } from 'lucide-react' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +export function NativeChatDeliveryRetry({ + outbox, + blockedClientMessageId, + retry +}: { + outbox: readonly StructuredAgentSessionOutboxEntry[] + blockedClientMessageId: string | null + retry: (clientMessageId: string) => void +}): React.JSX.Element | null { + // Why: only the head can hold the queue, so Retry must never name or resend a later entry. + const head = outbox[0] + const retryable = + head && (head.state === 'unconfirmed' || head.clientMessageId === blockedClientMessageId) + ? head + : null + if (!retryable) { + return null + } + return ( +
+ + {retryable.state === 'unconfirmed' + ? translate( + 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', + 'Message delivery is unconfirmed.' + ) + : translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + )} + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx new file mode 100644 index 00000000000..23b52615ee9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -0,0 +1,35 @@ +import { RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' + +export function NativeChatLaunchRetry({ + lifecycle, + onRetry +}: { + lifecycle: StructuredAgentSessionLaunchLifecycle | null + onRetry: () => void +}): React.JSX.Element | null { + if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { + return null + } + const message = + lifecycle === 'failed' + ? translate( + 'auto.components.native.chat.NativeChatLaunchRetry.failed', + 'Chat could not be started.' + ) + : translate( + 'auto.components.native.chat.NativeChatLaunchRetry.unknown', + 'Chat connection could not be confirmed.' + ) + return ( +
+ {message} + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx new file mode 100644 index 00000000000..e2fd6f65d8c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import { + TRANSCRIPT_LENGTH, + list, + marker, + scrollTranscript, + session, + stubLayout, + windowState +} from './NativeChatMessageList.windowing-test-support' + +afterEach(cleanup) + +describe('revealing a diff from a turn rollup', () => { + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.restoreAllMocks() + }) + + function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } + } + + const patch = '@@ -1 +1 @@\n-before\n+after' + const items: AgentJournalRenderItem[] = [ + journalItem( + 'user', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] }, + 1 + ), + journalItem( + 'diff', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 2 + ), + ...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + journalItem( + `tail-${index}`, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `marker-${index}` }] }, + index + 3 + ) + ) + ] + + it('lets a rail jump supersede a previously revealed diff', () => { + const withPrompts = [ + ...items.slice(0, 2), + journalItem( + 'user-2', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] }, + 3 + ), + journalItem( + 'user-3', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] }, + 4 + ), + ...items.slice(2) + ].map((item, index) => ({ ...item, sequence: index + 1 })) + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + scrollTranscript(container, 6000) + expect(screen.getByText('Edited file')).toBeInTheDocument() + scrollTo.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) + fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) + expect(scrollTo).toHaveBeenCalledTimes(1) + expect(screen.queryByText('Edited file')).toBeNull() + + scrollTranscript(container, 0) + scrollTo.mockClear() + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + expect(scrollTo).toHaveBeenCalledTimes(1) + }) +}) + +// The rail borrows the reveal's pin to reach a row the window has left behind. +// Borrowing the pin means it also has to give it back: the request is what +// outranks a later reveal, and slots is rebuilt every render, so an effect that +// merely watched it would re-scroll forever. +describe('jumping to a message from the rail', () => { + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function userMarker(index: number): NativeChatMessage { + return { + id: `message-${index}`, + role: 'user', + blocks: [{ type: 'text', text: `prompt-${index}` }], + timestamp: index + 1, + source: 'transcript' + } + } + + const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + index % 10 === 0 ? userMarker(index) : marker(index) + ) + + /** Open the hover panel through the trigger and click the first prompt. */ + function jumpToFirstPrompt(): void { + fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) + act(() => { + vi.advanceTimersByTime(300) + }) + fireEvent.click(screen.getByRole('button', { name: 'prompt-0' })) + act(() => { + vi.advanceTimersByTime(300) + }) + } + + it('scrolls once for a selection, not again on every later render', () => { + vi.useFakeTimers() + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container, rerender } = render(list(conversation)) + scrollTranscript(container, 6000) + + jumpToFirstPrompt() + expect(scrollTo).toHaveBeenCalled() + + // A streaming turn re-renders constantly with the same messages. The jump is + // spent; nothing here may drag the reader back to the row they left. + scrollTo.mockClear() + rerender(list(conversation)) + rerender(list(conversation)) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('releases the pin once the jump is spent', () => { + vi.useFakeTimers() + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container } = render(list(conversation)) + scrollTranscript(container, 6000) + + jumpToFirstPrompt() + expect(scrollTo).toHaveBeenCalled() + + // The request is spent as soon as the scroll is issued, so the row it pinned + // is not held in the window afterwards. A pin still standing here would also + // still outrank a diff reveal, which shares the same slot. + expect(windowState(container).indexes).not.toContain(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing-test-support.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing-test-support.tsx new file mode 100644 index 00000000000..316827b5138 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing-test-support.tsx @@ -0,0 +1,221 @@ +// @vitest-environment happy-dom + +import { fireEvent } from '@testing-library/react' +import { vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { + estimateNativeChatRowHeight, + nativeChatRowContentMetrics +} from './native-chat-row-height-estimate' + +const VIEWPORT_PX = 600 + +export const TRANSCRIPT_LENGTH = 200 + +/** Everything the document holds below the last row: the transcript column's + * trailing chrome and the scroll root's bottom padding. Non-zero on purpose — + * the document's bottom sits past the window's last row, which is exactly where + * a pin computed from the virtualizer's totals and one computed from the + * document disagree. */ +const BELOW_TRANSCRIPT_PX = 24 +let belowTranscriptPx = BELOW_TRANSCRIPT_PX + +/** Everything the document holds above the spacer: the scroll root's top gutter, + * and the "load earlier" block whenever there is older history to page in. This + * is the virtualizer's `scrollMargin`, and it is the larger half of the gap + * between the document's end and the end the virtualizer computes. */ +let aboveTranscriptPx = 0 + +/** Heights the stubbed layout reports per row index, when a case wants a row to + * measure as something other than its estimate. Empty means "every row at its + * estimate", which is what every non-growth case wants. */ +let measuredRowHeights: readonly number[] = [] + +export function marker(index: number): NativeChatMessage { + return { + id: `message-${index}`, + role: 'assistant', + blocks: [{ type: 'text', text: `marker-${index}` }], + timestamp: index + 1, + source: 'transcript' + } +} + +const ROW_PX = estimateNativeChatRowHeight(nativeChatRowContentMetrics(marker(0)), { + hasReceipt: false, + hasStatus: false, + hasTurnDiff: false +}) + +/** Replace a layout property on every element, and hand back the undo. */ +function overrideLayoutProperty(name: string, descriptor: PropertyDescriptor): () => void { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name) + Object.defineProperty(HTMLElement.prototype, name, { configurable: true, ...descriptor }) + return () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, name, original) + } else { + Reflect.deleteProperty(HTMLElement.prototype, name) + } + } +} + +/** The spacer's reserved height, which is the transcript's whole rendered height: + * windowed rows are absolutely positioned inside it, so a row growing in place + * reaches the document only through the height the window reserves for it. */ +function reservedTranscriptHeight(root: ParentNode): number { + const spacer = root.querySelector('[data-native-chat-window]') + return spacer ? Number.parseFloat(spacer.style.height) || 0 : 0 +} + +// The virtualizer measures with `offsetHeight` — not `clientHeight`, not a +// bounding rect — so that is the one thing a DOM without layout has to answer +// for windowing to engage at all. Rows report the height their own estimate +// predicted, which keeps the totals exact and independent of which rows happen +// to have been mounted long enough to be measured; `measuredRowHeights` is how a +// case says a row measures as something else. +// +// `scrollGeometry` additionally gives the scroll root a document to scroll: a +// height, a viewport, and a `scrollTop` that clamps the way a real one does. +// Off by default, because a transcript with a real document opens pinned to its +// bottom and the cases above are about where the window sits, not where it lands. +export function stubLayout({ + scrollGeometry = false, + offsetChain = false, + viewportHeight = () => VIEWPORT_PX +}: { + scrollGeometry?: boolean + /** Give the spacer an `offsetTop` and a chain to walk up to the scroll root, + * so `scrollMargin` can be something other than zero. */ + offsetChain?: boolean + viewportHeight?: () => number +} = {}): () => void { + const scrollTops = new WeakMap() + const restores = [ + overrideLayoutProperty('offsetHeight', { + get(this: HTMLElement): number { + if (this.hasAttribute('data-native-chat-scroll')) { + return viewportHeight() + } + if (this.hasAttribute('data-native-chat-window')) { + return reservedTranscriptHeight(this.parentElement ?? this) + } + const index = this.dataset.index + if (index !== undefined) { + return measuredRowHeights[Number(index)] ?? ROW_PX + } + // The transcript column: as tall as the window it wraps, plus what sits + // under it. This is the element the list observes for streamed growth. + return this.classList.contains('max-w-4xl') + ? reservedTranscriptHeight(this) + belowTranscriptPx + : 0 + } + }) + ] + if (scrollGeometry) { + restores.push( + overrideLayoutProperty('clientHeight', { + get(this: HTMLElement): number { + return this.hasAttribute('data-native-chat-scroll') ? viewportHeight() : 0 + } + }), + overrideLayoutProperty('scrollHeight', { + get(this: HTMLElement): number { + return this.hasAttribute('data-native-chat-scroll') + ? aboveTranscriptPx + reservedTranscriptHeight(this) + belowTranscriptPx + : 0 + } + }), + overrideLayoutProperty('scrollTop', { + get(this: HTMLElement): number { + return scrollTops.get(this) ?? 0 + }, + set(this: HTMLElement, value: number): void { + // A browser clamps; without this `scrollTop = scrollHeight` would park + // the view past the end and every distance-from-bottom would read 0. + const max = Math.max(0, this.scrollHeight - this.clientHeight) + scrollTops.set(this, Math.min(Math.max(0, value), max)) + } + }) + ) + } + if (offsetChain) { + restores.push( + overrideLayoutProperty('offsetTop', { + get(this: HTMLElement): number { + return this.hasAttribute('data-native-chat-window') ? aboveTranscriptPx : 0 + } + }), + // happy-dom has no `offsetParent` at all, so production's walk to the + // scroll root ends before it starts and every margin reads zero. + overrideLayoutProperty('offsetParent', { + get(this: HTMLElement): HTMLElement | null { + return this.parentElement?.closest('[data-native-chat-scroll]') ?? null + } + }) + ) + } + return () => { + for (const restore of restores.toReversed()) { + restore() + } + } +} + +export function session(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +export function list(messages: NativeChatMessage[]): React.JSX.Element { + return ( + + ) +} + +/** Reads the window, and refuses to pass if there is no window to read. + * + * Without this a change to the usability gate would quietly send every case + * below down the whole-transcript path, where "fewer rows than messages" is + * false but every other assertion still holds. */ +export function windowState(container: HTMLElement): { totalSize: number; indexes: number[] } { + const spacer = container.querySelector('[data-native-chat-window]') + if (!spacer) { + throw new Error('transcript is not windowed: no spacer, every row is mounted') + } + const totalSize = Number.parseFloat(spacer.style.height) + if (!(totalSize > 0)) { + throw new Error(`transcript reserved no height (${spacer.style.height})`) + } + return { + totalSize, + indexes: Array.from(container.querySelectorAll('[data-index]')) + .map((row) => Number(row.dataset.index)) + .sort((left, right) => left - right) + } +} + +/** happy-dom fires no scroll event for an assignment to `scrollTop`. */ +export function scrollTranscript(container: HTMLElement, top: number): void { + const scroller = container.querySelector('[data-native-chat-scroll]') + if (!scroller) { + throw new Error('no transcript scroll root') + } + scroller.scrollTop = top + fireEvent.scroll(scroller) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx index 0caadd20c21..c6a13c7ed83 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -201,125 +201,6 @@ describe('revealing a diff from a turn rollup', () => { // Pinned, not paged to: the window is still a window. expect(windowState(container).indexes.length).toBeLessThanOrEqual(mountedBefore + 2) }) - - it('lets a rail jump supersede a previously revealed diff', () => { - const withPrompts = [ - ...items.slice(0, 2), - journalItem( - 'user-2', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] }, - 3 - ), - journalItem( - 'user-3', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] }, - 4 - ), - ...items.slice(2) - ].map((item, index) => ({ ...item, sequence: index + 1 })) - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container } = render( - - ) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - scrollTranscript(container, 6000) - expect(screen.getByText('Edited file')).toBeInTheDocument() - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) - expect(scrollTo).toHaveBeenCalledTimes(1) - expect(screen.queryByText('Edited file')).toBeNull() - - scrollTranscript(container, 0) - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(scrollTo).toHaveBeenCalledTimes(1) - }) -}) - -// The rail borrows the reveal's pin to reach a row the window has left behind. -// Borrowing the pin means it also has to give it back: the request is what -// outranks a later reveal, and `slots` is rebuilt every render, so an effect that -// merely watched it would re-scroll forever. -describe('jumping to a message from the rail', () => { - let restoreLayout = (): void => {} - beforeEach(() => { - restoreLayout = stubLayout() - }) - afterEach(() => { - restoreLayout() - vi.useRealTimers() - vi.restoreAllMocks() - }) - - function userMarker(index: number): NativeChatMessage { - return { - id: `message-${index}`, - role: 'user', - blocks: [{ type: 'text', text: `prompt-${index}` }], - timestamp: index + 1, - source: 'transcript' - } - } - - const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => - index % 10 === 0 ? userMarker(index) : marker(index) - ) - - /** Open the hover panel through the trigger and click the first prompt. */ - function jumpToFirstPrompt(): void { - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - act(() => { - vi.advanceTimersByTime(300) - }) - fireEvent.click(screen.getByRole('button', { name: 'prompt-0' })) - act(() => { - vi.advanceTimersByTime(300) - }) - } - - it('scrolls once for a selection, not again on every later render', () => { - vi.useFakeTimers() - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container, rerender } = render(list(conversation)) - scrollTranscript(container, 6000) - - jumpToFirstPrompt() - expect(scrollTo).toHaveBeenCalled() - - // A streaming turn re-renders constantly with the same messages. The jump is - // spent; nothing here may drag the reader back to the row they left. - scrollTo.mockClear() - rerender(list(conversation)) - rerender(list(conversation)) - expect(scrollTo).not.toHaveBeenCalled() - }) - - it('releases the pin once the jump is spent', () => { - vi.useFakeTimers() - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container } = render(list(conversation)) - scrollTranscript(container, 6000) - - jumpToFirstPrompt() - expect(scrollTo).toHaveBeenCalled() - - // The request is spent as soon as the scroll is issued, so the row it pinned - // is not held in the window afterwards. A pin still standing here would also - // still outrank a diff reveal, which shares the same slot. - expect(windowState(container).indexes).not.toContain(0) - }) }) describe('transcript with a hidden scroll root', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx new file mode 100644 index 00000000000..1550b3316e9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +function sessionView(): React.JSX.Element { + return ( + + ) +} + +describe('NativeChatStructuredSession launch lifecycle', () => { + afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() + }) + + it('shows the ordinary usable chat without a startup label while launch is pending', () => { + mocks.launchLifecycle = 'pending' + render(sessionView()) + + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(mocks.controllerProps).toMatchObject({ transportEnabled: false }) + expect(screen.queryByText(/Starting (Claude|Codex) chat/i)).toBeNull() + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + }) + + it.each([ + ['failed', 'Chat could not be started.'], + ['visibility-unknown', 'Chat connection could not be confirmed.'] + ] as const)('offers launch Retry for %s without naming the provider', (lifecycle, message) => { + mocks.launchLifecycle = lifecycle + render(sessionView()) + + expect(screen.getByText(message)).toBeTruthy() + expect(screen.queryByText(/Starting (Claude|Codex) chat/i)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') + }) + + it('keeps the durable outbox parked until publication, then dispatches it once', async () => { + mocks.mode = 'outbox' + mocks.launchLifecycle = 'visibility-unknown' + mocks.call.mockResolvedValue({ + ok: true, + value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } + }) + const { rerender } = render(sessionView()) + const send = mocks.composerProps?.structuredTransport?.send + if (typeof send !== 'function') { + throw new Error('Structured composer transport was not installed') + } + + expect(send('queued while launching', [])).toBe(true) + expect(mocks.call).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mocks.call).not.toHaveBeenCalled() + + mocks.launchLifecycle = 'published' + rerender(sessionView()) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(mocks.call).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.send', + expect.objectContaining({ envelope: expect.objectContaining({ sessionId: 'session-1' }) }) + ) + }) + + it.each([null, 'published'] as const)( + 'enables provider transport for lifecycle %s', + (lifecycle) => { + mocks.launchLifecycle = lifecycle + render(sessionView()) + + expect(mocks.controllerProps).toMatchObject({ transportEnabled: true }) + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 345ad5bbef2..d4d3a76b5ca 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,13 +1,21 @@ import { forwardRef, useImperativeHandle, useRef } from 'react' -import { vi, type Mock } from 'vitest' +import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' +import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' +import type { + SessionOptionSetResult, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' -// Why: a named spy type keeps the harness's inferred return type portable across the test files. -type StructuredSessionSpy = Mock +type StopBackgroundTaskSpy = (sessionId: string, taskId?: string) => unknown + +function nullable(): T | null { + return null +} type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean @@ -28,8 +36,11 @@ const initialApprovalCardProps: NativeChatApprovalCardProps | null = null */ export function createStructuredSessionMocks() { const mocks = { - call: vi.fn() as StructuredSessionSpy, - fileLinkClick: vi.fn() as StructuredSessionSpy, + call: vi.fn<(...args: never[]) => unknown>(), + fileLinkClick: vi.fn<(...args: never[]) => unknown>(), + launchLifecycle: nullable(), + retryLaunch: vi.fn<(...args: never[]) => unknown>(), + controllerProps: nullable<{ transportEnabled?: boolean }>(), mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', messages: null as null | unknown[], @@ -42,10 +53,10 @@ export function createStructuredSessionMocks() { approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], - respond: vi.fn() as StructuredSessionSpy, - cancel: vi.fn() as StructuredSessionSpy, - handlePasteEvent: vi.fn() as StructuredSessionSpy, - pasteFromClipboard: vi.fn() as StructuredSessionSpy, + respond: vi.fn<(...args: never[]) => unknown>(), + cancel: vi.fn<(...args: never[]) => unknown>(), + handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), + pasteFromClipboard: vi.fn<(...args: never[]) => unknown>(), submissions: [] as unknown[], monitoringBackgroundTasks: false, showBackgroundTasks: false, @@ -55,7 +66,7 @@ export function createStructuredSessionMocks() { supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], settledBackgroundTasks: [] as AgentSessionBackgroundTask[], - stopBackgroundTask: vi.fn() as StructuredSessionSpy + stopBackgroundTask: vi.fn() } const moduleFactories = { @@ -69,11 +80,13 @@ export function createStructuredSessionMocks() { useStructuredAgentSession: (props: { sessionId: string target: { kind: 'local' } | { kind: 'environment'; environmentId: string } + transportEnabled?: boolean }) => { + mocks.controllerProps = props const outbox = useStructuredAgentSessionOutbox({ sessionId: props.sessionId, target: props.target, - fence: 1, + fence: props.transportEnabled === false ? null : 1, submissions: mocks.submissions as never }) return { @@ -99,7 +112,7 @@ export function createStructuredSessionMocks() { error: outbox.error, hasOlder: false, loadingOlder: false, - loadOlder: vi.fn() as StructuredSessionSpy, + loadOlder: vi.fn<() => Promise>(), prompts: mocks.promptItems, outbox: outbox.outbox, blockedClientMessageId: outbox.blockedClientMessageId, @@ -135,15 +148,21 @@ export function createStructuredSessionMocks() { ], optionSurface: { getSnapshot: () => [], - setOption: vi.fn() as StructuredSessionSpy, - invokeAction: vi.fn() as StructuredSessionSpy, + setOption: + vi.fn<(id: string, value: SessionOptionValue) => Promise>(), + invokeAction: vi.fn<(id: string) => Promise>(), subscribe: () => () => {} }, - setStructuredOption: vi.fn() as StructuredSessionSpy + setStructuredOption: + vi.fn<(id: string, value: SessionOptionValue) => Promise>() } } } }, + structuredAgentSessionLaunch: () => ({ + retryStructuredAgentSessionLaunch: mocks.retryLaunch, + useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle + }), useNativeChatFontScale: () => ({ useNativeChatFontScale: () => ({ scale: 1 }) }), @@ -197,6 +216,9 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() + mocks.launchLifecycle = null + mocks.retryLaunch.mockReset() + mocks.controllerProps = null mocks.mode = 'static' mocks.status = 'ready' mocks.messages = null diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 823f01a3fe5..94d6fb78e29 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -354,7 +354,7 @@ describe('NativeChatStructuredSession', () => { let finishFirst!: (value: unknown) => void let finishSecond!: (value: unknown) => void mocks.stopBackgroundTask.mockImplementation( - (_sessionId: string, taskId: string) => + (_sessionId: string, taskId?: string) => new Promise((resolve) => { if (taskId === 'task-one') { finishFirst = resolve diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 3908c42fa28..acf80594d66 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,10 +1,8 @@ import { useMemo, useRef, useState } from 'react' -import { RotateCcw } from 'lucide-react' import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { Button } from '@/components/ui/button' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' import { NativeChatEmptyState } from './NativeChatEmptyState' @@ -17,12 +15,14 @@ import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useStructuredAgentSession } from './use-structured-agent-session' -import { translate } from '@/i18n/i18n' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' +import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' +import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' +import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry' type StoppingBackgroundTasks = { sessionId: string @@ -41,7 +41,15 @@ function encodeQuestionAnswer(questionId: string, answer: string): string { export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { - const controller = useStructuredAgentSession(props) + const fileLinkContext = useNativeChatFileLinkContext(props.tabId) + const provisionalLaunch = useNativeChatProvisionalLaunch( + fileLinkContext?.worktreeId, + props.sessionId + ) + const controller = useStructuredAgentSession({ + ...props, + transportEnabled: provisionalLaunch.transportEnabled + }) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -105,7 +113,6 @@ export function NativeChatStructuredSession( ) const viewState = selectNativeChatViewState(session) const fontScale = useNativeChatFontScale(viewState.kind === 'ready') - const fileLinkContext = useNativeChatFileLinkContext(props.tabId) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( fileLinkContext, @@ -146,17 +153,6 @@ export function NativeChatStructuredSession( } ] : []) - // Only the head of the outbox is ever dispatched, so it is the only entry a - // Retry can act on and the only one whose state can be holding the queue. - // Scanning past it named a message the user was not looking at and re-sent - // one from earlier in the session while their newest sat behind it. - const outboxHead = controller.outbox[0] ?? null - const retryableOutboxEntry = - outboxHead && - (outboxHead.state === 'unconfirmed' || - outboxHead.clientMessageId === controller.blockedClientMessageId) - ? outboxHead - : null const structuredTransport = useMemo( () => ({ send: (text: string, attachments: readonly { id: string; path: string }[]): boolean => @@ -307,33 +303,15 @@ export function NativeChatStructuredSession( onCancel={cancelPrompt} /> ) : null} - {retryableOutboxEntry ? ( -
- - {retryableOutboxEntry.state === 'unconfirmed' - ? translate( - 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', - 'Message delivery is unconfirmed.' - ) - : translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - )} - - -
- ) : null} + + {controller.error || composerError ? (

{controller.error ?? composerError} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts new file mode 100644 index 00000000000..33bc3a27f62 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -0,0 +1,136 @@ +import type { + AgentSessionMutationResult, + AgentSessionSendResult +} from '../../../../shared/agent-session-wire' +import { + disposeStructuredAgentSessionSendFailure, + disposeStructuredAgentSessionSendResult, + type StructuredAgentSessionSendDisposition +} from '../../../../shared/structured-agent-session-send-disposition' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + structuredAgentSessionSendRequest, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' +import { + getStructuredAgentLaunchPromptDispatch, + shareStructuredAgentLaunchPromptDispatch +} from '@/lib/structured-agent-session-launch-prompt' + +type MutableRef = { current: T } + +function isDesktopDeliveryUnknown(error: unknown): boolean { + const text = error instanceof Error ? `${error.name}:${error.message}` : String(error) + return /timeout|disconnect|connection|closed|unavailable|cutover/i.test(text) +} + +export function hasInFlightLaunchDispatch( + entry: StructuredAgentSessionOutboxEntry, + fence: number | null +): boolean { + return Boolean( + entry.source === 'launch' && + getStructuredAgentLaunchPromptDispatch( + entry.sessionId, + entry.clientMessageId, + fence ?? undefined + ) + ) +} + +export function readMountedStructuredAgentSessionOutbox( + sessionId: string, + fence: number | null, + read: ( + sessionId: string, + options: { recoverDispatching: boolean } + ) => StructuredAgentSessionOutboxEntry[] +): StructuredAgentSessionOutboxEntry[] { + return read(sessionId, { recoverDispatching: false }).map((entry) => + entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) + ? { ...entry, state: 'unconfirmed' as const } + : entry + ) +} + +export function dispatchStructuredAgentSessionOutboxEntry(args: { + next: StructuredAgentSessionOutboxEntry + persisted: readonly StructuredAgentSessionOutboxEntry[] + sessionId: string + target: RuntimeClientTarget + fence: number + dispatchGeneration: number + dispatchGenerationRef: MutableRef + dispatchingRef: MutableRef + blockedIdRef: MutableRef + outboxRef: MutableRef + setOutbox: (entries: StructuredAgentSessionOutboxEntry[]) => void + setError: (error: string | null) => void + applyDisposition: (disposition: StructuredAgentSessionSendDisposition) => void + createOperationId: () => string +}): { promise: Promise; started: boolean } { + const start = async (): Promise => { + args.dispatchingRef.current = true + const staged = [ + { ...args.next, state: 'dispatching' as const, lastAttemptAt: Date.now() }, + ...args.persisted.slice(1) + ] + if (!writeOutbox(args.sessionId, staged)) { + args.dispatchingRef.current = false + args.blockedIdRef.current = args.next.clientMessageId + args.setError('Message could not be saved to the outbox') + return false + } + args.outboxRef.current = staged + args.setOutbox(staged) + try { + const result = await callStructuredAgentSession< + AgentSessionMutationResult + >(args.target, 'agentSession.send', structuredAgentSessionSendRequest(args.next, args.fence)) + if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { + return false + } + args.applyDisposition( + disposeStructuredAgentSessionSendResult({ + entries: args.outboxRef.current, + entry: args.next, + blockedClientMessageId: args.blockedIdRef.current, + result, + createOperationId: args.createOperationId + }) + ) + return result.ok + ? result.value.submission.dispatchState === 'accepted' || + result.value.submission.dispatchState === 'pending' + : false + } catch (caught) { + if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { + return false + } + args.applyDisposition( + disposeStructuredAgentSessionSendFailure({ + entries: args.outboxRef.current, + entry: args.next, + blockedClientMessageId: args.blockedIdRef.current, + cause: caught, + isDeliveryUnknown: isDesktopDeliveryUnknown + }) + ) + return false + } finally { + if (args.dispatchGenerationRef.current === args.dispatchGeneration) { + args.dispatchingRef.current = false + } + } + } + return args.next.source === 'launch' + ? shareStructuredAgentLaunchPromptDispatch( + args.next.sessionId, + args.next.clientMessageId, + args.fence, + start + ) + : { promise: start(), started: true } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index b823bfe3fa2..f6eac6ee288 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -11,7 +11,11 @@ function storageKey(sessionId: string): string { return `${OUTBOX_PREFIX}${encodeURIComponent(sessionId)}` } -export function readOutbox(sessionId: string): StructuredAgentSessionOutboxEntry[] { +export function readOutbox( + sessionId: string, + options: { recoverDispatching?: boolean } = {} +): StructuredAgentSessionOutboxEntry[] { + const recoverDispatching = options.recoverDispatching !== false try { const value = JSON.parse(localStorage.getItem(storageKey(sessionId)) ?? '[]') return Array.isArray(value) @@ -19,7 +23,9 @@ export function readOutbox(sessionId: string): StructuredAgentSessionOutboxEntry .map((entry) => parseStructuredAgentSessionOutboxEntry(entry, sessionId)) .filter((entry): entry is StructuredAgentSessionOutboxEntry => entry !== null) .map((entry) => - entry.state === 'dispatching' ? { ...entry, state: 'unconfirmed' as const } : entry + recoverDispatching && entry.state === 'dispatching' + ? { ...entry, state: 'unconfirmed' as const } + : entry ) .sort((left, right) => left.queuedAt - right.queuedAt) : [] @@ -48,13 +54,16 @@ export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, text: string ): StructuredAgentSessionOutboxEntry | null { - const entry = createStructuredAgentSessionOutboxEntry({ - clientMessageId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), - sessionId, - text, - attachments: [], - queuedAt: Date.now() - }) + const entry = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + sessionId, + text, + attachments: [], + queuedAt: Date.now() + }), + source: 'launch' as const + } return writeOutbox(sessionId, [...readOutbox(sessionId), entry]) ? entry : null } diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts new file mode 100644 index 00000000000..95a2c05236f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react' +import { + retryStructuredAgentSessionLaunch, + useStructuredAgentSessionLaunchLifecycle +} from '@/lib/structured-agent-session-launch' + +export function useNativeChatProvisionalLaunch( + worktreeId: string | null | undefined, + sessionId: string +) { + const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) + const retry = useCallback(() => { + if (worktreeId) { + retryStructuredAgentSessionLaunch(worktreeId, sessionId) + } + }, [sessionId, worktreeId]) + return { + lifecycle, + retry, + transportEnabled: lifecycle === null || lifecycle === 'published' + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 6f071e1a16b..40b4cee6e6e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -5,7 +5,7 @@ // every result is discarded unless the runtime fence it was issued against is // still the current one. -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import * as conversationCommands from './structured-conversation-command-send' import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' @@ -24,13 +24,19 @@ export type StructuredAgentSessionMutate = ( export function useStructuredAgentSessionMutate(args: { sessionId: string target: RuntimeClientTarget + enabled?: boolean /** Read at settle time, not at call time: the fence can move while a request * is in flight, and a result from the previous fence is not this session's. */ stateRef: { current: { fence: number | null } } }): { mutate: StructuredAgentSessionMutate; writeError: string | null } { - const { sessionId, stateRef, target } = args + const { enabled = true, sessionId, stateRef, target } = args const [writeError, setWriteError] = useState(null) const operationIds = useRef(new Map()) + const enabledRef = useRef(enabled) + useEffect(() => { + // Why: update the gate after commit so render stays free of ref mutations. + enabledRef.current = enabled + }, [enabled]) const mutate = useCallback( async ( @@ -39,7 +45,7 @@ export function useStructuredAgentSessionMutate(args: { fields: Record, operationIdOverride?: string | null ): Promise => { - if (stateRef.current.fence === null) { + if (!enabled || !enabledRef.current || stateRef.current.fence === null) { return null } const targetFence = stateRef.current.fence @@ -63,7 +69,7 @@ export function useStructuredAgentSessionMutate(args: { ...fields }) } catch (error) { - if (stateRef.current.fence === targetFence) { + if (enabledRef.current && stateRef.current.fence === targetFence) { setWriteError(error instanceof Error ? error.message : 'Request was not sent') } return null @@ -75,12 +81,12 @@ export function useStructuredAgentSessionMutate(args: { ) { operationIds.current.delete(key) } - if (stateRef.current.fence === targetFence) { + if (enabledRef.current && stateRef.current.fence === targetFence) { setWriteError(result.refusal.message) } return null } - if (stateRef.current.fence !== targetFence) { + if (!enabledRef.current || stateRef.current.fence !== targetFence) { return null } if (!conversationCommands.isUnconfirmedConversationCommand(fingerprintMethod, result.value)) { @@ -89,7 +95,7 @@ export function useStructuredAgentSessionMutate(args: { setWriteError(null) return result.value }, - [sessionId, stateRef, target] + [enabled, sessionId, stateRef, target] ) return { mutate, writeError } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts new file mode 100644 index 00000000000..34dad47e495 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionPicks, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' +import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +export function useStructuredAgentSessionOptions(args: { + agent: AgentType + sessionId: string + target: RuntimeClientTarget + transportEnabled: boolean + providerVisible: boolean + fence: number | null + turnId: string | null + mutate: StructuredAgentSessionMutate +}) { + const { agent, fence, mutate, providerVisible, sessionId, target, transportEnabled, turnId } = + args + const [conversationSupport, setConversationSupport] = useState<{ + sessionId: string + commands: readonly AgentSessionConversationCommand[] + } | null>(null) + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent) + ) + const optionStateRef = useRef(optionState) + const activeOptionRecordRef = useRef(optionState.record) + const pendingOptionRef = useRef(null) + const optionMutationGeneration = useRef(0) + const updateOptionState = useCallback( + (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { + const next = update(optionStateRef.current) + optionStateRef.current = next + setOptionState(next) + }, + [] + ) + const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent) + optionMutationGeneration.current += 1 + pendingOptionRef.current = null + optionStateRef.current = next + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, fence, sessionId, transportEnabled]) + + // Refresh options each turn to confirm which model the provider actually selected. + useEffect(() => { + if (!providerVisible || !optionCatalog) { + return + } + let stale = false + const readGeneration = optionMutationGeneration.current + void callStructuredAgentSession(target, 'agentSession.options', { + sessionId + }) + .then((result) => { + if (!stale && optionMutationGeneration.current === readGeneration) { + setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => {}) + return () => { + stale = true + } + }, [fence, optionCatalog, providerVisible, sessionId, target, turnId, updateOptionState]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + const visibleOptionSnapshot = useMemo( + () => (transportEnabled ? optionSnapshot : []), + [optionSnapshot, transportEnabled] + ) + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => { + const currentState = optionStateRef.current + const encoded = encodeStructuredAgentSessionOptionValue(id, value) + if ( + !transportEnabled || + pendingOptionRef.current !== null || + !optionCatalog || + encoded === null || + !canSetStructuredAgentSessionOption(currentState, id, value) + ) { + return false + } + const targetRecord = currentState.record + const mutationGeneration = ++optionMutationGeneration.current + pendingOptionRef.current = id + updateOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value: encoded } + ) + if ( + result && + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + ) { + const committed = result.options ?? { [id]: encoded } + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, committed) + : current + ) + const picks = structuredAgentSessionOptionPicks(currentState, committed) + if (picks.length > 0) { + void enqueueSessionOptionSettingsWrite(target, { type: 'apply-picks', agent, picks }) + } + if (!transportEnabled) { + return false + } + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if ( + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + ) { + updateOptionState((latest) => + latest.record === targetRecord + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) + } + return Boolean(result) + } finally { + if ( + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + ) { + pendingOptionRef.current = null + updateOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + } + }, + [agent, mutate, optionCatalog, sessionId, target, transportEnabled, updateOptionState] + ) + const setOption = useCallback( + async (id: string, value: string | boolean) => { + await setStructuredOption(id, value) + return { snapshot: structuredAgentSessionOptionSnapshot(optionStateRef.current) } + }, + [setStructuredOption] + ) + const optionSurface = useMemo( + () => ({ + getSnapshot: () => visibleOptionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: visibleOptionSnapshot }), + subscribe: () => () => {} + }), + [setOption, visibleOptionSnapshot] + ) + + return { + conversationCommands: + transportEnabled && conversationSupport?.sessionId === sessionId + ? conversationSupport.commands + : [], + optionSnapshot: visibleOptionSnapshot, + optionSurface, + setStructuredOption + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index c57ebe1b384..a0b94657e79 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' const mocks = vi.hoisted(() => ({ call: vi.fn() @@ -16,6 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' const LOCAL_TARGET = { kind: 'local' } as const @@ -134,6 +136,69 @@ describe('useStructuredAgentSessionOutbox', () => { }) }) + it('does not redispatch a launch prompt settled before the mounted outbox gets its fence', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + if (!stagedEntry) { + throw new Error('fixture outbox entry was not persisted') + } + mocks.call.mockResolvedValue(acceptedResultFor(stagedEntry.clientMessageId, 1)) + const initialProps: { fence: number | null } = { fence: null } + const { result, rerender } = renderHook( + ({ fence }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence, + submissions: [] + }), + { initialProps } + ) + expect(result.current.outbox).toHaveLength(1) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this' }, + stagedEntry + }) + ).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(mocks.call).toHaveBeenCalledOnce() + + rerender({ fence: 1 }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(mocks.call).toHaveBeenCalledOnce() + }) + + it('joins a launch prompt dispatch already in flight when the outbox mounts', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + if (!stagedEntry) { + throw new Error('fixture outbox entry was not persisted') + } + const admission = deferred>() + mocks.call.mockReturnValueOnce(admission.promise) + const delivery = settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this' }, + stagedEntry + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + expect(result.current.outbox[0]?.state).toBe('dispatching') + + await act(async () => admission.resolve(acceptedResultFor(stagedEntry.clientMessageId, 1))) + await expect(delivery).resolves.toEqual({ delivered: true, failureNotified: false }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(mocks.call).toHaveBeenCalledOnce() + }) + it('requeues across a fence change and ignores the stale settlement', async () => { const first = deferred>() const second = deferred>() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 7934fc4759d..32368a56cf4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -1,24 +1,20 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationResult, - AgentSessionSendResult -} from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createStructuredAgentSessionOutboxEntry, reconcileStructuredAgentSessionOutbox, - structuredAgentSessionSendRequest, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { - disposeStructuredAgentSessionSendFailure, - disposeStructuredAgentSessionSendResult, - type StructuredAgentSessionSendDisposition -} from '../../../../shared/structured-agent-session-send-disposition' +import type { StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' +import { + dispatchStructuredAgentSessionOutboxEntry, + hasInFlightLaunchDispatch, + readMountedStructuredAgentSessionOutbox +} from './structured-agent-session-outbox-dispatch' +import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt' export function structuredSessionOperationId(): string { return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) @@ -31,11 +27,6 @@ const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000 * Retry, because the entry leaves `unconfirmed` -- pre-existing, not closed here. */ const UNCONFIRMED_PROBE_MAX_DELAY_MS = 16_000 -function isDesktopDeliveryUnknown(error: unknown): boolean { - const text = error instanceof Error ? `${error.name}:${error.message}` : String(error) - return /timeout|disconnect|connection|closed|unavailable|cutover/i.test(text) -} - export function useStructuredAgentSessionOutbox(args: { sessionId: string target: RuntimeClientTarget @@ -45,7 +36,7 @@ export function useStructuredAgentSessionOutbox(args: { const { fence, sessionId, submissions, target } = args const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` const [outbox, setOutbox] = useState(() => - readOutbox(sessionId) + readMountedStructuredAgentSessionOutbox(sessionId, fence, readOutbox) ) const outboxRef = useRef(outbox) const outboxSessionRef = useRef(sessionId) @@ -78,9 +69,13 @@ export function useStructuredAgentSessionOutbox(args: { useEffect(() => { const sessionChanged = outboxSessionRef.current !== sessionId outboxSessionRef.current = sessionId - const current = sessionChanged ? readOutbox(sessionId) : outboxRef.current + const current = sessionChanged + ? readMountedStructuredAgentSessionOutbox(sessionId, fence, readOutbox) + : outboxRef.current const next = current.map((entry) => - entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry + entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) + ? { ...entry, state: 'queued' as const } + : entry ) if ( sessionChanged || @@ -138,9 +133,32 @@ export function useStructuredAgentSessionOutbox(args: { useEffect(() => { const next = outbox[0] + if (!next || next.sessionId !== sessionId) { + return + } + const launchDispatch = + next.source === 'launch' + ? getStructuredAgentLaunchPromptDispatch( + next.sessionId, + next.clientMessageId, + fence ?? undefined + ) + : undefined + if (launchDispatch) { + const persisted = readOutbox(sessionId, { recoverDispatching: false }) + const persistedHead = persisted[0] + if (persistedHead?.state !== next.state) { + outboxRef.current = persisted + setOutbox(persisted) + } + void launchDispatch.then(() => { + const latest = readOutbox(sessionId, { recoverDispatching: false }) + outboxRef.current = latest + setOutbox(latest) + }) + return + } if ( - !next || - next.sessionId !== sessionId || next.state !== 'queued' || fence === null || dispatchingRef.current || @@ -148,58 +166,45 @@ export function useStructuredAgentSessionOutbox(args: { ) { return } - dispatchingRef.current = true - const dispatchGeneration = dispatchGenerationRef.current - const staged = [ - { ...next, state: 'dispatching' as const, lastAttemptAt: Date.now() }, - ...outbox.slice(1) - ] - if (!writeOutbox(sessionId, staged)) { - dispatchingRef.current = false - blockedIdRef.current = next.clientMessageId - setError('Message could not be saved to the outbox') + // A launch settlement may have already admitted this entry and cleared its in-flight marker + // before this effect observes the queued React snapshot. Storage is the shared ownership + // record; only dispatch when the persisted head is still queued. + const persisted = readOutbox(sessionId, { recoverDispatching: false }) + const persistedHead = persisted[0] + if ( + persistedHead?.clientMessageId !== next.clientMessageId || + persistedHead.state !== 'queued' + ) { + outboxRef.current = persisted + setOutbox(persisted) return } - outboxRef.current = staged - setOutbox(staged) - void callStructuredAgentSession>( + const dispatchGeneration = dispatchGenerationRef.current + const dispatch = dispatchStructuredAgentSessionOutboxEntry({ + next: persistedHead, + persisted, + sessionId, target, - 'agentSession.send', - structuredAgentSessionSendRequest(next, fence) - ) - .then((result) => { - if (dispatchGenerationRef.current !== dispatchGeneration) { - return - } - applyDisposition( - disposeStructuredAgentSessionSendResult({ - entries: outboxRef.current, - entry: next, - blockedClientMessageId: blockedIdRef.current, - result, - createOperationId: structuredSessionOperationId - }) - ) - }) - .catch((caught) => { - if (dispatchGenerationRef.current !== dispatchGeneration) { - return - } - applyDisposition( - disposeStructuredAgentSessionSendFailure({ - entries: outboxRef.current, - entry: next, - blockedClientMessageId: blockedIdRef.current, - cause: caught, - isDeliveryUnknown: isDesktopDeliveryUnknown - }) - ) - }) - .finally(() => { - if (dispatchGenerationRef.current === dispatchGeneration) { - dispatchingRef.current = false - } + fence, + dispatchGeneration, + dispatchGenerationRef, + dispatchingRef, + blockedIdRef, + outboxRef, + setOutbox, + setError, + applyDisposition, + createOperationId: structuredSessionOperationId + }) + if (!dispatch.started) { + // The launch settlement owns this entry. Its storage mutation does not update this hook's + // local state, so mirror the settled state once the shared admission finishes. + void dispatch.promise.then(() => { + const latest = readOutbox(sessionId, { recoverDispatching: false }) + outboxRef.current = latest + setOutbox(latest) }) + } }, [applyDisposition, fence, outbox, sessionId, target]) // A transport-side unknown may never have reached the host, and nothing else diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx new file mode 100644 index 00000000000..721e61b3078 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + hold: vi.fn<(args: { enabled?: boolean }) => void>(), + read: vi.fn<(args: { isVisible?: boolean }) => void>(), + outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), + send: vi.fn<(text: string) => boolean>(), + retry: vi.fn<(clientMessageId: string) => void>() +})) + +let readState: StructuredAgentSessionState + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./use-structured-agent-session-hold', () => ({ + useStructuredAgentSessionHold: (args: { enabled?: boolean }) => mocks.hold(args) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: (args: { isVisible?: boolean }) => { + mocks.read(args) + return { + state: readState, + loadingOlder: false, + loadOlder: vi.fn<() => Promise>() + } + } +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => 'operation-1', + useStructuredAgentSessionOutbox: (args: { + fence: number | null + submissions: readonly unknown[] + }) => { + mocks.outbox(args) + return { + outbox: [], + blockedClientMessageId: null, + error: null, + send: mocks.send, + retry: mocks.retry + } + } +})) + +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: vi.fn<(target: unknown, mutation: unknown) => Promise>() +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +const LOCAL_TARGET = { kind: 'local' } as const +const OPTIONS = { + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [{ value: 'medium', label: 'Medium' }] + } + ], + current: { model: 'gpt-live', effort: 'medium' } +} + +function sessionState(): StructuredAgentSessionState { + return { + epoch: 'epoch-1', + cursor: null, + fence: 3, + items: [], + submissions: [], + retainedItemLimit: 1_024, + hasOlder: true, + status: 'error', + error: 'cached transport error', + handoff: null, + commands: [{ name: 'provider-command', kind: 'command' }] + } +} + +describe('useStructuredAgentSession provisional launch gate', () => { + beforeEach(() => { + vi.clearAllMocks() + readState = sessionState() + mocks.send.mockReturnValue(true) + mocks.call.mockResolvedValue(OPTIONS) + }) + + it('keeps local sends usable while withholding every provider surface', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true, + transportEnabled: false + }) + ) + + expect(mocks.hold).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false })) + expect(mocks.read).toHaveBeenLastCalledWith(expect.objectContaining({ isVisible: false })) + expect(mocks.outbox).toHaveBeenLastCalledWith( + expect.objectContaining({ fence: null, submissions: [] }) + ) + expect(result.current).toMatchObject({ + status: 'ready', + error: null, + hasOlder: false, + loadingOlder: false, + journalItems: [], + prompts: [], + conversationCommands: [], + optionSnapshot: [] + }) + expect(result.current.sessionCommands).toBeUndefined() + expect(result.current.optionSurface.getSnapshot()).toEqual([]) + expect(result.current.send('queued while launching')).toBe(true) + expect(mocks.send).toHaveBeenCalledWith('queued while launching') + + await act(async () => { + await result.current.cancel('turn-1') + await result.current.stopBackgroundTask('task-1') + expect(await result.current.setStructuredOption('model', 'gpt-live')).toBe(false) + }) + + expect(mocks.call).not.toHaveBeenCalled() + }) + + it('activates provider surfaces after publication without repeating option discovery', async () => { + const { rerender } = renderHook( + ({ transportEnabled }: { transportEnabled: boolean }) => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true, + transportEnabled + }), + { initialProps: { transportEnabled: false } } + ) + + expect(mocks.call).not.toHaveBeenCalled() + rerender({ transportEnabled: true }) + + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith(LOCAL_TARGET, 'agentSession.options', { + sessionId: 'session-1' + }) + ) + expect(mocks.call).toHaveBeenCalledTimes(1) + expect(mocks.hold).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: true })) + expect(mocks.read).toHaveBeenLastCalledWith(expect.objectContaining({ isVisible: true })) + expect(mocks.outbox).toHaveBeenLastCalledWith( + expect.objectContaining({ fence: 3, submissions: [] }) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts new file mode 100644 index 00000000000..c2c93788a04 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { + activeStructuredAgentSessionTurnId, + hasUnansweredStructuredAgentSessionDispatch +} from '../../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity' +import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' + +const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] +const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] + +export function useStructuredAgentSessionTransportState( + state: StructuredAgentSessionState, + enabled: boolean +) { + const journalItems = enabled ? state.items : NO_JOURNAL_ITEMS + const submissions = enabled ? state.submissions : NO_SUBMISSIONS + const fence = enabled ? state.fence : null + const turnId = activeStructuredAgentSessionTurnId(journalItems) + const isWorking = + turnId !== null || hasUnansweredStructuredAgentSessionDispatch(submissions, fence) + const turnActivity = useMemo( + () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), + [enabled, journalItems, state.activity, turnId] + ) + const turnTiming = useStructuredAgentTurnTiming( + { + items: journalItems, + submissions, + ...(enabled ? { hostClock: state.hostClock } : {}) + }, + turnId + ) + return { + journalItems, + submissions, + fence, + turnId, + isWorking, + turnActivity, + turnTiming, + backgroundTasks: structuredSessionBackgroundTasksView( + enabled ? state.backgroundTasks : null, + turnId + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts new file mode 100644 index 00000000000..58570f9de0a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' + +export function useStructuredAgentSessionTransport(args: { + sessionId: string + target: RuntimeClientTarget + isVisible: boolean + enabled: boolean +}) { + const { enabled, isVisible, sessionId, target } = args + const providerVisible = isVisible && enabled + useStructuredAgentSessionHold({ + sessionId, + target, + surface: 'desktop-chat', + enabled: providerVisible + }) + const read = useStructuredAgentSessionRead({ sessionId, target, isVisible: providerVisible }) + const stateRef = useRef(read.state) + const mutation = useStructuredAgentSessionMutate({ + sessionId, + target, + stateRef, + enabled + }) + useEffect(() => { + stateRef.current = read.state + }, [read.state]) + return { ...read, ...mutation, providerVisible } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 020647de089..cfc07d3e4d5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -1,49 +1,22 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import * as conversationCommands from './structured-conversation-command-send' -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult, - AgentSessionPromptResult -} from '../../../../shared/agent-session-wire' +import { useRef } from 'react' +import * as structuredConversationCommands from './structured-conversation-command-send' +import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' -import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command' import type { AgentType } from '../../../../shared/agent-status-types' -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' -import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' -import { - applyStructuredAgentSessionOptions, - canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionPicks, - structuredAgentSessionOptionSnapshot, - type StructuredAgentSessionOptionState -} from '../../../../shared/structured-agent-session-options' -import { - activeStructuredAgentSessionTurnId, - hasUnansweredStructuredAgentSessionDispatch -} from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { - callStructuredAgentSession, - supportsStructuredAgentSessionPromptCancel -} from '@/runtime/structured-agent-session-client' -import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' -import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client' import { pendingStructuredSessionPrompts, type StructuredPromptItem } from './structured-agent-session-message-projection' -import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' -import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity' -import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' -import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' -import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' +import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' +import { useStructuredAgentSessionTransport } from './use-structured-agent-session-transport' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -54,202 +27,55 @@ export function useStructuredAgentSession(args: { target: RuntimeClientTarget agent: AgentType isVisible: boolean + transportEnabled?: boolean }) { - const { agent, isVisible, sessionId, target } = args - // Declared first: the hold is what gives a restored session its provider child back, and the - // read below is useless for sending until it lands. - useStructuredAgentSessionHold({ sessionId, target, surface: 'desktop-chat', enabled: isVisible }) - const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead(args) - const stateRef = useRef(state) - const { mutate, writeError } = useStructuredAgentSessionMutate({ sessionId, target, stateRef }) - const [conversationSupport, setConversationSupport] = useState<{ - sessionId: string - commands: readonly AgentSessionConversationCommand[] - } | null>(null) + const { agent, isVisible, sessionId, target, transportEnabled = true } = args + const { state, loadingOlder, loadOlder, mutate, writeError, providerVisible } = + useStructuredAgentSessionTransport({ + sessionId, + target, + isVisible, + enabled: transportEnabled + }) const commandPending = useRef(false) - const [optionState, setOptionState] = useState(() => - createStructuredAgentSessionOptionState(agent) - ) - const optionStateRef = useRef(optionState) - const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) - const optionMutationGeneration = useRef(0) - const updateOptionState = useCallback( - (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { - const next = update(optionStateRef.current) - optionStateRef.current = next - setOptionState(next) - }, - [] - ) - const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const transportState = useStructuredAgentSessionTransportState(state, transportEnabled) + const { conversationCommands, optionSnapshot, optionSurface, setStructuredOption } = + useStructuredAgentSessionOptions({ + agent, + sessionId, + target, + transportEnabled, + providerVisible, + fence: state.fence, + turnId: transportState.turnId, + mutate + }) const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, - fence: state.fence, - submissions: state.submissions + fence: transportState.fence, + submissions: transportState.submissions }) - useEffect(() => { - stateRef.current = state - }, [state]) - - useEffect(() => { - const next = createStructuredAgentSessionOptionState(agent) - optionMutationGeneration.current += 1 - pendingOptionRef.current = null - optionStateRef.current = next - activeOptionRecordRef.current = next.record - setOptionState(next) - }, [agent, sessionId, state.fence]) - - // Refresh options each turn to confirm which model the provider actually selected. - const turnId = activeStructuredAgentSessionTurnId(state.items) - // A dispatch the provider has not answered is already work; Claude's running row trails the - // send by seconds, and only a provider-minted turn is cancellable, so the two stay separate. - const isWorking = - turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence) - const turnActivity = useMemo( - () => selectStructuredAgentTurnActivity(state.items, turnId, state.activity), - [state.activity, state.items, turnId] - ) - const turnTiming = useStructuredAgentTurnTiming(state, turnId) - const backgroundTasks = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId) - - useEffect(() => { - if (!isVisible || !optionCatalog) { - return - } - let stale = false - const readGeneration = optionMutationGeneration.current - void callStructuredAgentSession(target, 'agentSession.options', { - sessionId - }) - .then((result) => { - if (!stale && optionMutationGeneration.current === readGeneration) { - setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) - : current - ) - } - }) - .catch(() => {}) - return () => { - stale = true - } - }, [isVisible, optionCatalog, sessionId, state.fence, target, turnId, updateOptionState]) - - const optionSnapshot = useMemo( - () => structuredAgentSessionOptionSnapshot(optionState), - [optionState] - ) - const setStructuredOption = useCallback( - async (id: string, value: string | boolean): Promise => { - const currentState = optionStateRef.current - const encoded = encodeStructuredAgentSessionOptionValue(id, value) - if ( - pendingOptionRef.current !== null || - !optionCatalog || - encoded === null || - !canSetStructuredAgentSessionOption(currentState, id, value) - ) { - return false - } - const targetRecord = currentState.record - const mutationGeneration = ++optionMutationGeneration.current - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) - try { - const result = await mutate( - 'agentSession.setOption', - 'agentSession.setOption', - { key: id, value: encoded } - ) - if ( - result && - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - ) { - const committed = result.options ?? { [id]: encoded } - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) - : current - ) - const picks = structuredAgentSessionOptionPicks(currentState, committed) - if (picks.length > 0) { - void enqueueSessionOptionSettingsWrite(target, { - type: 'apply-picks', - agent, - picks - }) - } - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if ( - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - ) { - updateOptionState((latest) => - latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest - ) - } - }) - .catch(() => {}) - } - return Boolean(result) - } finally { - if ( - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - ) { - pendingOptionRef.current = null - updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } - : current - ) - } - } - }, - [agent, mutate, optionCatalog, sessionId, target, updateOptionState] - ) - const setOption = useCallback( - async (id: string, value: string | boolean) => { - await setStructuredOption(id, value) - return { snapshot: structuredAgentSessionOptionSnapshot(optionStateRef.current) } - }, - [setStructuredOption] - ) - const optionSurface = useMemo( - () => ({ - getSnapshot: () => optionSnapshot, - setOption, - invokeAction: async () => ({ snapshot: optionSnapshot }), - subscribe: () => () => {} - }), - [optionSnapshot, setOption] - ) - - const prompts = pendingStructuredSessionPrompts(state.items) + const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const { outbox } = outboxController - const messages = useStructuredAgentSessionMessages(state.items, outbox, state.submissions) + const messages = useStructuredAgentSessionMessages( + transportState.journalItems, + outbox, + transportState.submissions + ) return { - conversationCommands: - conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [], + conversationCommands, runConversationCommand: (command: AgentSessionConversationCommand) => - conversationCommands.sendStructuredConversationCommand({ + structuredConversationCommands.sendStructuredConversationCommand({ command, pending: commandPending, - blocked: Boolean(turnId || prompts.length || backgroundTasks.isMonitoring || outbox.length), + blocked: Boolean( + transportState.turnId || + prompts.length || + transportState.backgroundTasks.isMonitoring || + outbox.length + ), send: (command) => mutate( 'agentSession.conversationCommand', @@ -257,12 +83,14 @@ export function useStructuredAgentSession(args: { { command } ) }), - journalItems: state.items, + journalItems: transportState.journalItems, messages, - status: state.status, - error: state.error ?? writeError ?? outboxController.error, - hasOlder: state.hasOlder, - loadingOlder, + status: transportEnabled ? state.status : 'ready', + error: transportEnabled + ? (state.error ?? writeError ?? outboxController.error) + : outboxController.error, + hasOlder: transportEnabled && state.hasOlder, + loadingOlder: transportEnabled && loadingOlder, loadOlder, prompts, outbox, @@ -270,12 +98,12 @@ export function useStructuredAgentSession(args: { send: (...input: Parameters) => !commandPending.current && outboxController.send(...input), retry: outboxController.retry, - isWorking, - workingStartedAt: turnTiming.workingStartedAt, - settledTurns: turnTiming.settledTurns, - turnActivity, - backgroundTasks, - turnId, + isWorking: transportState.isWorking, + workingStartedAt: transportState.turnTiming.workingStartedAt, + settledTurns: transportState.turnTiming.settledTurns, + turnActivity: transportState.turnActivity, + backgroundTasks: transportState.backgroundTasks, + turnId: transportState.turnId, cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate constructs the payload // fingerprint and operation id: older hosts reject the strict prompt field. @@ -302,7 +130,7 @@ export function useStructuredAgentSession(args: { ), optionSnapshot, optionSurface, - sessionCommands: state.commands ?? undefined, + sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined, setStructuredOption } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index d23652db43b..f0c307ae0ca 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -1,16 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' + +type BeginArgs = { beforeOpen?: (sessionId: string) => boolean | void } +type Launch = { + sessionId: string + settlement: Promise + tab: { id: string } +} const mocks = vi.hoisted(() => ({ - settleStructuredAgentLaunch: vi.fn(), - prepareAiVaultSessionForResume: vi.fn(), - activateAndRevealWorktree: vi.fn(), - activateAndRevealFolderWorkspace: vi.fn(), - toastError: vi.fn(), + beginStructuredAgentSessionProvisionalLaunch: vi.fn<(args: BeginArgs) => Launch | null>(), + prepareAiVaultSessionForResume: vi.fn<() => Promise<{ sessionId: string }>>(), + activateAndRevealWorktree: vi.fn<(worktreeId: string) => unknown>(), + activateAndRevealFolderWorkspace: vi.fn<(workspaceId: string) => unknown>(), + toastError: vi.fn<(message: string) => void>(), activeWorktreeId: 'other-worktree' })) -vi.mock('@/lib/structured-agent-launch-settlement', () => ({ - settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch +vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ + beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch })) vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume @@ -26,52 +35,92 @@ vi.mock('@/store', () => ({ import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch' -const session = { agent: 'codex', sessionId: 'vault-1', filePath: '/x' } as never +const session: AiVaultSession = { + id: 'vault-1', + executionHostId: 'local', + agent: 'codex', + sessionId: 'vault-1', + title: 'Vault session', + cwd: '/x', + branch: null, + model: null, + filePath: '/x', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2025-01-01T00:00:00.000Z', + messageCount: 1, + totalTokens: 1, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'resume', + subagent: null +} describe('resumeAiVaultSessionInNewChat', () => { beforeEach(() => { vi.clearAllMocks() mocks.prepareAiVaultSessionForResume.mockResolvedValue({ sessionId: 'provider-1' }) + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { + args.beforeOpen?.('session-1') + return { + sessionId: 'session-1', + tab: { id: 'agent-session:session-1' }, + settlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) + } + }) }) - it('adopts the prepared conversation with no legacy fallback and reveals the workspace', async () => { - mocks.settleStructuredAgentLaunch.mockResolvedValue({ kind: 'structured', sessionId: 's' }) + it('reveals the workspace and opens chat before provider settlement', async () => { + let settle!: (value: StructuredAgentLaunchSettlement) => void + const settlement = new Promise((resolve) => { + settle = resolve + }) + mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { + args.beforeOpen?.('session-1') + return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement } + }) await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') - expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( - 'worktree-1', - 'codex', - { resumeFrom: { providerSessionId: 'provider-1' } }, - {} + expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ resumeFrom: { providerSessionId: 'provider-1' } }), + hooks: {} + }) ) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1') expect(mocks.toastError).not.toHaveBeenCalled() + settle({ kind: 'structured', sessionId: 'session-1' }) }) - it('toasts the conflict message when the launch fails with that code', async () => { - mocks.settleStructuredAgentLaunch.mockResolvedValue({ - kind: 'failed', - error: Object.assign(new Error('held'), { code: 'agent_session_conflict' }) + it('toasts a conflict reported by the eventual settlement', async () => { + const error = Object.assign(new Error('held'), { code: 'agent_session_conflict' }) + mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ + sessionId: 'session-1', + tab: { id: 'agent-session:session-1' }, + settlement: Promise.resolve({ kind: 'failed', error }) }) await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') - - expect(mocks.toastError).toHaveBeenCalledWith( - 'Another chat is already holding this conversation.' + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith( + 'Another chat is already holding this conversation.' + ) ) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) - it('stays silent on an unknown outcome so the launch layer can reconcile it', async () => { - mocks.settleStructuredAgentLaunch.mockResolvedValue({ - kind: 'visibility-unknown', - sessionId: 's' + it('keeps unknown outcomes silent for reconciliation', async () => { + mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ + sessionId: 'session-1', + tab: { id: 'agent-session:session-1' }, + settlement: Promise.resolve({ kind: 'visibility-unknown', sessionId: 'session-1' }) }) await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') - + await Promise.resolve() expect(mocks.toastError).not.toHaveBeenCalled() - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index dd5b39d3ff5..01d286f4630 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -11,14 +11,14 @@ import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -export function activateAiVaultResumeWorkspace(workspaceId: string): void { +export function activateAiVaultResumeWorkspace(workspaceId: string): boolean { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { - activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) - return + return activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) !== false } - activateAndRevealWorktree(workspaceId) + return activateAndRevealWorktree(workspaceId) !== false } /** Adopt a vault conversation into a new structured chat. The route was decided by the @@ -34,22 +34,28 @@ export async function resumeAiVaultSessionInNewChat( // Codex rows can live under a shared legacy home; the same preparation the terminal resume // runs re-pins them, and its result is what names the conversation the host will look for. const preparedSession = await prepareAiVaultSessionForResume(session) - const settlement = await adoptAgentSessionLaunchVerdict({ + const plan = adoptAgentSessionLaunchVerdict({ route: 'structured-native-chat', agent, worktreeId, resumeFrom: { providerSessionId: preparedSession.sessionId } - }).launch({}) - if (settlement?.kind === 'failed') { - notifyAiVaultSessionResumeInChatFailure(settlement.error) - return - } - // Why: an unknown outcome is not a failure; the launch layer reconciles it on the next attempt. - if (settlement?.kind !== 'structured') { - return - } - if (useAppStore.getState().activeWorktreeId !== worktreeId) { - activateAiVaultResumeWorkspace(worktreeId) + }) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan, + hooks: {}, + beforeOpen: () => { + if (useAppStore.getState().activeWorktreeId !== worktreeId) { + return activateAiVaultResumeWorkspace(worktreeId) + } + return true + } + }) + if (launch) { + void launch.settlement.then((settlement) => { + if (settlement.kind === 'failed') { + notifyAiVaultSessionResumeInChatFailure(settlement.error) + } + }) } } catch (error) { notifyAiVaultSessionResumeInChatFailure(error) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts index 64f191c007a..ee3542f746a 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -57,7 +57,7 @@ describe('runSourceControlAgentActionStart', () => { it('waits for deferred prompt delivery before confirming a source-control launch', async () => { mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) @@ -84,7 +84,7 @@ describe('runSourceControlAgentActionStart', () => { const onLaunchAccepted = vi.fn() const onLaunchAborted = vi.fn() mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult @@ -110,7 +110,7 @@ describe('runSourceControlAgentActionStart', () => { it('fires onLaunchAccepted exactly once and only when a tab was created', async () => { const onLaunchAccepted = vi.fn() mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) @@ -136,7 +136,7 @@ describe('runSourceControlAgentActionStart', () => { const onLaunchAccepted = vi.fn() const onLaunchAborted = vi.fn() mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true }) @@ -157,7 +157,7 @@ describe('runSourceControlAgentActionStart', () => { const originalConsole = console vi.stubGlobal('console', { ...originalConsole, error: vi.fn() }) mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.reject(new Error('boom')) @@ -189,7 +189,7 @@ describe('runSourceControlAgentActionStart', () => { it('keeps the source-control dialog open when deferred prompt delivery fails', async () => { mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false }) @@ -206,7 +206,7 @@ describe('runSourceControlAgentActionStart', () => { it('does not show a generic start failure when deferred delivery already notified the user', async () => { mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true }) @@ -226,7 +226,7 @@ describe('runSourceControlAgentActionStart', () => { const consoleError = vi.fn() vi.stubGlobal('console', { ...originalConsole, error: consoleError }) mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.reject(error) @@ -247,7 +247,7 @@ describe('runSourceControlAgentActionStart', () => { it('keeps non-deferred tab launches immediate', async () => { mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true }) @@ -338,7 +338,7 @@ describe('runSourceControlAgentActionStart', () => { vi.stubGlobal('console', { ...originalConsole, error: consoleError }) mocks.onSaveAgentDefault.mockRejectedValue(new Error('settings not loaded')) mocks.launchAgentInNewTab.mockReturnValue({ - tabId: 'tab-1', + surface: { kind: 'local-terminal', tabId: 'tab-1' }, startupPlan: {} as never, pasteDraftAfterLaunch: true, promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index b9b86e93228..201ca43b03b 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -105,8 +105,8 @@ export async function runSourceControlAgentActionStart({ launchSource }) launched = Boolean(result) - if (result?.tabId) { - focusTerminalTabSurface(result.tabId) + if (result?.surface.kind === 'local-terminal') { + focusTerminalTabSurface(result.surface.tabId) } // Why: lets callers park launch-scoped state before submit-after-ready finishes // (can take tens of seconds); host mutations still wait for delivery below. diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 1ba24f6ca83..ec1a7169825 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -170,8 +170,8 @@ export async function launchSourceControlRecoveryAgentWithDefault({ return false } - if (result.tabId) { - focusTerminalTabSurface(result.tabId) + if (result.surface.kind === 'local-terminal') { + focusTerminalTabSurface(result.surface.tabId) } toast.success(copy.success) return true diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 77ff1c193b8..7185f453351 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -19,6 +19,7 @@ import { toFolderWorkspaceLinkedTask } from './folder-workspace-composer-helpers' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' import { @@ -206,59 +207,30 @@ export async function submitFolderWorkspaceCreate({ : undefined onOpenChange(false) try { - let activation = activateAndRevealFolderWorkspace(workspace.id, { - agent: quickAgent, - ...(!structuredLaunch && startup ? { startup } : {}), - ...(structuredLaunch ? { providesInitialSurface: true } : {}), - runtimeEnvironmentId - }) - let structuredLaunchAccepted = structuredLaunch - const settlement = - plan?.route === 'structured-native-chat' - ? await plan.launch( - { - legacyFallback: async () => { - if (pendingFirstAgentMessageRename) { - await useAppStore - .getState() - .updateFolderWorkspace(workspace.id, { pendingFirstAgentMessageRename: true }) - .catch(() => undefined) - } - await preflightAgentTrust({ - agent: quickAgent, - workspacePath: workspace.folderPath, - connectionId: workspace.connectionId ?? projectGroup.connectionId - }) - const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { - agent: quickAgent, - ...(startup ? { startup } : {}), - runtimeEnvironmentId - }) - return { - activation: fallbackActivation, - primaryTabId: - fallbackActivation === false ? null : fallbackActivation.primaryTabId - } - } - }, - { worktreeId: folderWorkspaceKey(workspace.id) } - ) - : null - if (settlement) { - // Why: the workspace exists either way. Unknown keeps reporting false and failed true, as - // the boolean did before the loop was shared; the launch layer owns the failure toast. - if (settlement.kind === 'visibility-unknown') { - return false - } - if (settlement.kind === 'failed' || settlement.kind === 'cancelled') { - return true - } - if (settlement.kind === 'refused-then-legacy') { - structuredLaunchAccepted = false - // Why: this flow's own fallback always activates; `??` only satisfies the shared type. - activation = settlement.activation ?? false - } + const activationHolder: { + value: ReturnType + } = { value: false } + const revealWorkspace = (): boolean => { + activationHolder.value = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, + ...(!structuredLaunch && startup ? { startup } : {}), + ...(structuredLaunch ? { providesInitialSurface: true } : {}), + runtimeEnvironmentId + }) + return activationHolder.value !== false } + const structuredLaunchAccepted = structuredLaunch + if (plan?.route === 'structured-native-chat') { + beginStructuredAgentSessionProvisionalLaunch({ + plan, + hooks: {}, + target: { worktreeId: folderWorkspaceKey(workspace.id) }, + beforeOpen: revealWorkspace + }) + } else { + revealWorkspace() + } + const activation = activationHolder.value if ( !structuredLaunchAccepted && quickAgent && diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 6d5b523c2af..026944575c8 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -153,17 +153,15 @@ function QuickLaunchAgentMenuItemsInner({ ) return } - if (!result.tabId) { - // Why: paired web clients create the tab on the host; focus follows the - // next session-tabs snapshot instead of a local tab id. + if (result.surface.kind !== 'local-terminal') { return } - onFocusTerminal(result.tabId) + onFocusTerminal(result.surface.tabId) // Why: launch success means the terminal session exists. Agent readiness // can lag behind on slow machines, and prompt paste flows already own // their own readiness timeout once a PTY exists. - const launchedTabId = result.tabId + const launchedTabId = result.surface.tabId void waitForTerminalPty(launchedTabId, 5000).then((hasPty) => { if (hasPty) { return @@ -207,12 +205,6 @@ function QuickLaunchAgentMenuItemsInner({ const label = entry?.label ?? agent const isStructuredLaunchPending = isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' - const pendingLabel = translate( - 'components.native-chat.structuredSessionLaunchPending', - 'Starting {{value0}} chat…', - { value0: label } - ) - const menuLabel = isStructuredLaunchPending ? pendingLabel : label const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( @@ -221,22 +213,18 @@ function QuickLaunchAgentMenuItemsInner({ disabled={isStructuredLaunchPending} onSelect={() => runLaunch(agent)} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - title={ - isStructuredLaunchPending - ? pendingLabel - : translate( - 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', - 'Launch {{value0}} in a new terminal', - { value0: label } - ) - } + title={translate( + 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', + 'Launch {{value0}} in a new terminal', + { value0: label } + )} > {isStructuredLaunchPending ? (