Merge remote-tracking branch 'origin/main' into brennanb2025/reflect-get-proxy-trap-exception

This commit is contained in:
Brennan Benson
2026-09-15 15:26:16 -07:00
124 changed files with 5498 additions and 3915 deletions
@@ -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<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
exits: Map<string, ClaudeSessionExit>
callbacks: ClaudeAcquireCallbacks
previous: ClaudeAcquisitionAttempt | undefined
attempt: ClaudeAcquisitionAttempt
rewind: ClaudeRewindAttempt
}): Promise<ClaudeStructuredLaunch> {
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
})
}
@@ -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({
@@ -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<boolean>>().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[] = []
@@ -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<boolean> {
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) {
@@ -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<AgentSessionCreatePhaseRecorder>()
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)
})
@@ -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,
@@ -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<Record<string, string>>
/** Provider events may begin before acquisition returns. */
events?: StructuredAgentSessionEventSink
recordPhase?: AgentSessionCreatePhaseRecorder
}
export type StructuredAgentSessionSetOptionInput = {
@@ -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
@@ -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<AgentSessionMutationResult<AgentSessionAttachResult>> {
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<AgentSessionCreatePhaseRecorder>[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)
}
@@ -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<string, StructuredAgentSessionHostSession>([
[
'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')
})
})
@@ -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)
@@ -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?.()
@@ -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<T>(fn: (span: ActiveSpan) => Promise<T>): Promise<T> {
return withSpan('agentSession.create', fn, { attributes: { kind: 'agent-session' } })
}
export async function withAgentSessionCreatePhase<T>(
phase: AgentSessionCreatePhase,
record: AgentSessionCreatePhaseRecorder | undefined,
fn: () => Promise<T>
): Promise<T> {
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<AgentSessionCreatePhase, number>()
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))
)
}
@@ -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
)
})
})
@@ -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.
@@ -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', () => {
@@ -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 (
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 py-1 text-xs text-muted-foreground">
<span>
{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.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => retry(retryable.clientMessageId)}
>
<RotateCcw className="size-3" />
{translate('auto.components.native.chat.NativeChatStructuredSession.a5e7f14068', 'Retry')}
</Button>
</div>
)
}
@@ -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 (
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 py-1 text-xs text-destructive">
<span>{message}</span>
<Button type="button" variant="ghost" size="xs" onClick={onRetry}>
<RotateCcw className="size-3" />
{translate('auto.components.native.chat.NativeChatLaunchRetry.retry', 'Retry')}
</Button>
</div>
)
}
@@ -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(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
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)
})
})
@@ -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<HTMLElement>('[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<HTMLElement, number>()
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<HTMLElement>('[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 (
<NativeChatMessageList
session={session(messages)}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
}
/** 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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-native-chat-scroll]')
if (!scroller) {
throw new Error('no transcript scroll root')
}
scroller.scrollTop = top
fireEvent.scroll(scroller)
}
@@ -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(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
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', () => {
@@ -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 (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
}
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()
}
)
})
@@ -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>(): 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<StructuredAgentSessionLaunchLifecycle>(),
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<StopBackgroundTaskSpy>()
}
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<void>>(),
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<SessionOptionSetResult>>(),
invokeAction: vi.fn<(id: string) => Promise<SessionOptionSetResult>>(),
subscribe: () => () => {}
},
setStructuredOption: vi.fn() as StructuredSessionSpy
setStructuredOption:
vi.fn<(id: string, value: SessionOptionValue) => Promise<boolean>>()
}
}
}
},
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
@@ -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
@@ -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<NativeChatStructuredViewProps, 'mode'>
): 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 ? (
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 py-1 text-xs text-muted-foreground">
<span>
{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.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => controller.retry(retryableOutboxEntry.clientMessageId)}
>
<RotateCcw className="size-3" />
{translate(
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
'Retry'
)}
</Button>
</div>
) : null}
<NativeChatDeliveryRetry
outbox={controller.outbox}
blockedClientMessageId={controller.blockedClientMessageId}
retry={controller.retry}
/>
<NativeChatLaunchRetry
lifecycle={provisionalLaunch.lifecycle}
onRetry={provisionalLaunch.retry}
/>
{controller.error || composerError ? (
<p className="mx-auto w-full max-w-4xl px-4 py-1 text-xs text-destructive">
{controller.error ?? composerError}
@@ -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<T> = { 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<number>
dispatchingRef: MutableRef<boolean>
blockedIdRef: MutableRef<string | null>
outboxRef: MutableRef<StructuredAgentSessionOutboxEntry[]>
setOutbox: (entries: StructuredAgentSessionOutboxEntry[]) => void
setError: (error: string | null) => void
applyDisposition: (disposition: StructuredAgentSessionSendDisposition) => void
createOperationId: () => string
}): { promise: Promise<boolean>; started: boolean } {
const start = async (): Promise<boolean> => {
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<AgentSessionSendResult>
>(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 }
}
@@ -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
}
@@ -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'
}
}
@@ -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 = <T>(
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<string | null>(null)
const operationIds = useRef(new Map<string, string>())
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 <T>(
@@ -39,7 +45,7 @@ export function useStructuredAgentSessionMutate(args: {
fields: Record<string, unknown>,
operationIdOverride?: string | null
): Promise<T | null> => {
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 }
@@ -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<string | null>(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<AgentSessionOptionsResult>(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<boolean> => {
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<AgentSessionOptionResult>(
'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<AgentSessionOptionsResult>(
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<SessionOptionsSurface>(
() => ({
getSnapshot: () => visibleOptionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: visibleOptionSnapshot }),
subscribe: () => () => {}
}),
[setOption, visibleOptionSnapshot]
)
return {
conversationCommands:
transportEnabled && conversationSupport?.sessionId === sessionId
? conversationSupport.commands
: [],
optionSnapshot: visibleOptionSnapshot,
optionSurface,
setStructuredOption
}
}
@@ -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<ReturnType<typeof acceptedResultFor>>()
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<ReturnType<typeof acceptedResult>>()
const second = deferred<ReturnType<typeof acceptedResult>>()
@@ -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<StructuredAgentSessionOutboxEntry[]>(() =>
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<AgentSessionMutationResult<AgentSessionSendResult>>(
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
@@ -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<unknown>>(),
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<void>>()
}
}
}))
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<void>>()
}))
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: [] })
)
})
})
@@ -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
)
}
}
@@ -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 }
}
@@ -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<string | null>(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<AgentSessionOptionsResult>(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<boolean> => {
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<AgentSessionOptionResult>(
'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<AgentSessionOptionsResult>(
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<SessionOptionsSurface>(
() => ({
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<AgentSessionConversationCommandResult>(
'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<typeof outboxController.send>) =>
!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
}
}
@@ -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<StructuredAgentLaunchSettlement>
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<StructuredAgentLaunchSettlement>((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()
})
})
@@ -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)
@@ -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 })
@@ -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.
@@ -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
@@ -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<typeof activateAndRevealFolderWorkspace>
} = { 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 &&
@@ -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 ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
<AgentIcon agent={agent} size={14} />
)}
<span className="flex-1">{menuLabel}</span>
<span className="flex-1">{label}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
@@ -36,7 +36,6 @@ import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empt
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { translate } from '@/i18n/i18n'
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
@@ -390,15 +389,6 @@ function TabBarCreateEntrySession({
id={resultOptionDomId(index)}
option={option}
selected={index === activeSelectedIndex}
labelOverride={
option.kind === 'agent' && isStructuredLaunchPending(option.option.agent)
? translate(
'components.native-chat.structuredSessionLaunchPending',
'Starting {{value0}} chat…',
{ value0: option.option.label }
)
: undefined
}
disabled={
disabled ||
pending ||
@@ -238,8 +238,8 @@ export function useTabBarCreateMenuController({
)
return
}
if (result.tabId) {
queueTerminalTabFocusAfterNewTabMenuClose(result.tabId)
if (result.surface.kind === 'local-terminal') {
queueTerminalTabFocusAfterNewTabMenuClose(result.surface.tabId)
return
}
if (shouldQueueTerminalFocusAfterMenuClose(result)) {
@@ -117,69 +117,27 @@ beforeEach(() => {
})
describe('structured agent-session close ordering', () => {
it('disposes the owner before asking the host to remove the canonical tab', async () => {
const order: string[] = []
mocks.closeStructuredAgentSession.mockImplementation(async () => {
order.push('agent-close')
return 'closed'
})
mocks.callRuntimeRpc.mockImplementation(async () => {
order.push('tab-close')
return { ok: true }
})
mocks.closeUnifiedTab.mockImplementation(() => order.push('local-remove'))
it('removes the local tab synchronously while host retirement runs independently', async () => {
const { closeItem } = useTabGroupTabCloseCommands({
worktreeId: 'wt-1',
groupTabs: [AGENT_TAB]
})
closeItem(AGENT_TAB.id)
await vi.waitFor(() => expect(order).toEqual(['agent-close', 'tab-close', 'local-remove']))
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id)
})
it('drops an unadopted launch draft seed once the tab is removed', async () => {
it('returns immediately for an unadopted launch tab', async () => {
const { closeItem } = useTabGroupTabCloseCommands({
worktreeId: 'wt-1',
groupTabs: [AGENT_TAB]
})
closeItem(AGENT_TAB.id)
await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id))
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith(
'structured-agent-session-session-1'
)
expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id)
})
it('keeps an unadopted launch draft seed when owner disposal fails', async () => {
mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable'))
const { closeItem } = useTabGroupTabCloseCommands({
worktreeId: 'wt-1',
groupTabs: [AGENT_TAB]
})
closeItem(AGENT_TAB.id)
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
})
it('keeps the tab available when owner disposal fails, so close can be retried', async () => {
mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable'))
const { closeItem } = useTabGroupTabCloseCommands({
worktreeId: 'wt-1',
groupTabs: [AGENT_TAB]
})
closeItem(AGENT_TAB.id)
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(mocks.closeUnifiedTab).not.toHaveBeenCalled()
})
it('cancels reconciling launches before a bulk close', async () => {
it('closes reconciling launches through the same synchronous path during bulk close', async () => {
const { closeMany } = useTabGroupTabCloseCommands({
worktreeId: 'wt-1',
groupTabs: [AGENT_TAB]
@@ -187,7 +145,6 @@ describe('structured agent-session close ordering', () => {
closeMany([AGENT_TAB.id])
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id))
expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id)
})
})
@@ -214,7 +214,7 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', null)
})
it('closes the durable native owner from the real structured tab close action', async () => {
it('routes durable native owner close through the unified tab action', async () => {
const agentTab = {
id: 'structured-agent-session-codex-session-1',
entityId: 'codex-session-1',
@@ -249,18 +249,8 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
model.commands.closeItem(agentTab.id)
await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(agentTab.id))
expect(mocks.callRuntimeRpc.mock.calls).toEqual([
[{ kind: 'local' }, 'agentSession.close', { sessionId: 'codex-session-1' }],
[
{ kind: 'local' },
'session.tabs.close',
{
worktree: 'id:wt-1',
tabId: 'agent-session:codex-session-1',
reason: 'user'
}
]
])
// Why: the unified store action owns cancellation and host retirement for every close path.
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it('falls back to a local shell when the typed remote-create outcome is unavailable', async () => {
@@ -1,27 +1,8 @@
import { toast } from 'sonner'
import type { Tab } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { requestEditorFileClose } from '../editor/editor-autosave'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { closeWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-close'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { withLocalSessionTabCloseOwner } from '@/runtime/local-session-tab-close-owner'
import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close'
import { cancelStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { clearStructuredAgentLaunchDraft } from '@/lib/structured-agent-session-launch-draft'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { translate } from '@/i18n/i18n'
function reportStructuredSessionCloseError(error: unknown): void {
toast.error(
translate(
'components.native-chat.structuredSessionCloseFailed',
'Could not close this chat session'
),
{ description: error instanceof Error ? error.message : String(error) }
)
}
export function createWorkspaceTabCloseCommands({
worktreeId,
@@ -74,38 +55,11 @@ export function createWorkspaceTabCloseCommands({
if (!item) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (item.contentType === 'agent-session') {
// Cancel pending creation and retire the host session before removing its tab.
cancelStructuredAgentLaunch(worktreeId, item.entityId)
const target = getActiveRuntimeTarget({
activeRuntimeEnvironmentId: runtimeEnvironmentId
})
void closeStructuredAgentSession(target, item.entityId)
.then(() => {
const closeHostTab = () =>
callRuntimeRpc(target, 'session.tabs.close', {
worktree: toRuntimeWorktreeSelector(worktreeId),
tabId: `agent-session:${item.entityId}`,
reason: 'user'
})
return target.kind === 'local'
? withLocalSessionTabCloseOwner(worktreeId, item.id, closeHostTab)
: closeHostTab()
})
.then(() => {
closeUnifiedTab(item.id)
// Why: cancel above drops the seed only while the launch is still pending; a settled
// launch whose composer never adopted it would otherwise keep it until worktree removal.
clearStructuredAgentLaunchDraft(item.entityId)
if (!opts?.skipEmptyCheck) {
leaveWorktreeIfEmpty()
}
})
.catch(reportStructuredSessionCloseError)
closeUnifiedTab(item.id)
if (!opts?.skipEmptyCheck) {
leaveWorktreeIfEmpty()
}
return
}
if (item.contentType === 'terminal') {
@@ -95,10 +95,13 @@ describe('forkAgentSessionFromPane', () => {
id: 'wt-fork'
}
})
mockLaunchAgentInNewTab.mockReturnValue({
tabId: 'tab-2',
startupPlan: {},
pasteDraftAfterLaunch: true
mockLaunchAgentInNewTab.mockImplementation((args) => {
args.beforeSurfaceOpen?.({ kind: 'local-terminal' })
return {
surface: { kind: 'local-terminal', tabId: 'tab-2' },
startupPlan: {},
pasteDraftAfterLaunch: true
}
})
mockWriteClipboardText.mockResolvedValue(undefined)
mockMarkTrusted.mockResolvedValue(undefined)
@@ -161,105 +164,73 @@ describe('forkAgentSessionFromPane', () => {
)
})
it('waits for a structured settlement before announcing the fork and seeds no shell', async () => {
it('announces the provisional chat without waiting for structured settlement', async () => {
store.agentStatusByPaneKey = {
[`tab-1:${LEAF_ID}`]: { agentType: 'codex' }
}
let settle!: (settlement: unknown) => void
mockLaunchAgentInNewTab.mockReturnValue({
tabId: null,
const result = {
surface: {
kind: 'local-agent-session',
tabId: 'structured-agent-session-session-1',
sessionId: 'session-1'
},
startupPlan: {},
pasteDraftAfterLaunch: false,
structuredSettlement: new Promise((resolve) => (settle = resolve))
})
structuredSettlement: new Promise(() => {})
}
mockLaunchAgentInNewTab.mockImplementationOnce(
(args: {
beforeSurfaceOpen?: (surface: { kind: 'local-agent-session'; sessionId: string }) => void
}) => {
args.beforeSurfaceOpen?.({ kind: 'local-agent-session', sessionId: 'session-1' })
return result
}
)
const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork')
const fork = forkAgentSessionFromPane({
await forkAgentSessionFromPane({
pane: makePane('User: compare OAuth options'),
tabId: 'tab-1',
worktreeId: 'wt-1',
groupId: 'group-1'
})
await vi.waitFor(() => expect(mockActivateAndRevealWorktree).toHaveBeenCalled())
expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
sidebarRevealBehavior: 'auto',
providesInitialSurface: true
})
expect(mockToast.success).not.toHaveBeenCalled()
settle({ kind: 'structured', sessionId: 'session-1' })
await fork
expect(mockToast.success).toHaveBeenCalledWith(
'Top-level session fork opened in a new workspace'
)
})
it('announces the fork when a refused structured launch fell back to a terminal tab', async () => {
store.agentStatusByPaneKey = {
[`tab-1:${LEAF_ID}`]: { agentType: 'codex' }
}
mockLaunchAgentInNewTab.mockReturnValue({
tabId: null,
startupPlan: {},
pasteDraftAfterLaunch: false,
structuredSettlement: Promise.resolve({ kind: 'refused-then-legacy', primaryTabId: 'tab-2' })
})
const { startAgentSessionFork, prepareAgentSessionForkFromPane } =
await import('./terminal-agent-session-fork')
const prepared = prepareAgentSessionForkFromPane({
pane: makePane('User: compare OAuth options'),
tabId: 'tab-1',
worktreeId: 'wt-1',
groupId: null
})
await expect(startAgentSessionFork(prepared!)).resolves.toBe(true)
expect(mockToast.success).toHaveBeenCalledOnce()
expect(mockWriteClipboardText).not.toHaveBeenCalled()
})
it('copies context when the refusal fallback opened no terminal tab', async () => {
store.agentStatusByPaneKey = {
[`tab-1:${LEAF_ID}`]: { agentType: 'codex' }
}
mockLaunchAgentInNewTab.mockReturnValue({
tabId: null,
startupPlan: {},
pasteDraftAfterLaunch: false,
structuredSettlement: Promise.resolve({ kind: 'refused-then-legacy', primaryTabId: null })
})
const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork')
await forkAgentSessionFromPane({
pane: makePane('Assistant: current implementation notes'),
tabId: 'tab-1',
worktreeId: 'wt-1',
groupId: null
})
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockWriteClipboardText).toHaveBeenCalledWith(
expect.stringContaining('Assistant: current implementation notes')
)
})
it.each([
['failed', { kind: 'failed', error: new Error('boom') }, true],
['cancelled', { kind: 'cancelled', sessionId: 'session-1' }, true],
['visibility-unknown', { kind: 'visibility-unknown', sessionId: 'session-1' }, false]
])(
'closes the dialog without a success toast on a %s structured settlement',
async (_kind, settlement, copiesContext) => {
'keeps the provisional chat open on a later %s structured settlement',
async (_kind, settlement, _keepsOpen) => {
store.agentStatusByPaneKey = {
[`tab-1:${LEAF_ID}`]: { agentType: 'codex' }
}
mockLaunchAgentInNewTab.mockReturnValue({
tabId: null,
const result = {
surface: {
kind: 'local-agent-session',
tabId: 'structured-agent-session-session-1',
sessionId: 'session-1'
},
startupPlan: {},
pasteDraftAfterLaunch: false,
structuredSettlement: Promise.resolve(settlement)
})
}
mockLaunchAgentInNewTab.mockImplementationOnce(
(args: {
beforeSurfaceOpen?: (surface: { kind: 'local-agent-session'; sessionId: string }) => void
}) => {
args.beforeSurfaceOpen?.({ kind: 'local-agent-session', sessionId: 'session-1' })
return result
}
)
const { startAgentSessionFork, prepareAgentSessionForkFromPane } =
await import('./terminal-agent-session-fork')
@@ -271,8 +242,10 @@ describe('forkAgentSessionFromPane', () => {
})
// Why: the worktree already exists; a false return would keep the dialog open for a second fork.
await expect(startAgentSessionFork(prepared!)).resolves.toBe(true)
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockWriteClipboardText).toHaveBeenCalledTimes(copiesContext ? 1 : 0)
expect(mockToast.success).toHaveBeenCalledWith(
'Top-level session fork opened in a new workspace'
)
expect(mockWriteClipboardText).not.toHaveBeenCalled()
expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
sidebarRevealBehavior: 'auto',
providesInitialSurface: true
@@ -16,6 +16,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { translate } from '@/i18n/i18n'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
type ForkAgentSessionFromPaneArgs = {
pane: ManagedPane
@@ -229,11 +230,19 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
return copyAgentSessionForkContext(fork)
}
await preflightAgentTrust({
const agentSessionLaunchPlan = planAgentSessionLaunch(useAppStore.getState(), {
agent: fork.agent,
workspacePath: created.worktree.path,
connectionId: sourceRepo?.connectionId
workspace: { kind: 'git-worktree', worktreeId: forkWorktreeId },
prompt: fork.prompt,
promptDelivery: 'draft'
})
if (agentSessionLaunchPlan.route !== 'structured-native-chat') {
await preflightAgentTrust({
agent: fork.agent,
workspacePath: created.worktree.path,
connectionId: sourceRepo?.connectionId
})
}
const launchPlatform = getForkAgentLaunchPlatform({
repo: sourceRepo,
worktreePath: created.worktree.path,
@@ -245,34 +254,16 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
prompt: fork.prompt,
promptDelivery: 'draft',
launchSource: 'terminal_context_menu',
agentSessionLaunchPlan,
beforeSurfaceOpen: (surface) =>
activateAndRevealWorktree(forkWorktreeId, {
sidebarRevealBehavior: 'auto',
...(surface.kind === 'local-agent-session' ? { providesInitialSurface: true } : {})
}) !== false,
...(launchPlatform ? { launchPlatform } : {})
})
if (!result?.structuredSettlement) {
if (!result) {
activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
if (!result) {
return copyAgentSessionForkContext(fork)
}
notifyForkOpened()
return true
}
// Why: the fresh worktree has no tabs yet; without the opt-out activation seeds a shell beside
// the structured tab that is still on its way.
activateAndRevealWorktree(forkWorktreeId, {
sidebarRevealBehavior: 'auto',
providesInitialSurface: true
})
const settlement = await result.structuredSettlement
// Why: a refusal whose terminal fallback opened nothing is the structured twin of a null launch.
if (settlement.kind === 'refused-then-legacy' && settlement.primaryTabId === null) {
return copyAgentSessionForkContext(fork)
}
// Why: the worktree already exists, so a false return would keep the dialog open and a second
// click would create another one. Unknown already shows the launch badge; failed hands the
// user the context the way a null launch does.
if (settlement.kind === 'visibility-unknown') {
return true
}
if (settlement.kind === 'failed' || settlement.kind === 'cancelled') {
return copyAgentSessionForkContext(fork)
}
notifyForkOpened()
@@ -193,20 +193,14 @@ describe('tab.close uses the unified active tab', () => {
}
it.each(['darwin', 'win32', 'linux'] as const)(
'closes native chat from its composer on %s',
'routes native chat close from its composer through the unified tab action on %s',
async (platform) => {
expect(close(platform).defaultPrevented).toBe(true)
await vi.waitFor(() => expect(closeUnifiedTab).toHaveBeenCalledWith(tab.id))
expect(mocks.closeStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'chat-session'
)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'session.tabs.close', {
worktree: `id:${worktreeId}`,
tabId: 'agent-session:chat-session',
reason: 'user'
})
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledTimes(1)
// Why: the unified store action owns cancellation and host retirement for every close path.
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
}
)
@@ -53,6 +53,10 @@ export function useTerminalBulkCloseActions(controller: TerminalCloseController)
closeTerminalTab(unifiedTab.entityId, { skipRunningProcessConfirm: true })
continue
}
if (unifiedTab?.contentType === 'agent-session') {
state.closeUnifiedTab(unifiedTab.id)
continue
}
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
closeTab(id)
} else if (
@@ -38,7 +38,7 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { useAppStore } from '@/store'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch'
import { beginFullCreationStructuredLaunch } from './full-creation-structured-launch'
import { finalizeFullCreation } from './full-creation-finalization'
import { buildFullCreationIssueCommand } from './full-creation-issue-command'
import { buildFullCreationStartup } from './full-creation-startup'
@@ -189,11 +189,6 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
)
const worktree = result.worktree
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
const issueCommand = buildFullCreationIssueCommand({
shouldRun: submitShouldRunIssueAutomation && issueCommandTrustDecision === 'run',
template: confirmedIssueCommandTemplate,
@@ -217,40 +212,43 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
telemetry: composerTelemetry
})
const initialActivation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
agent: tuiAgent,
setup: result.setup,
defaultTabs: result.defaultTabs,
issueCommand,
...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}),
...(!structuredLaunch && startup ? { startup } : {}),
...(structuredLaunch ? { providesInitialSurface: true } : {})
})
const settlement = await settleFullCreationStructuredLaunch({
plan: launchPlan,
agent: tuiAgent,
worktreeId: worktree.id,
startup,
pendingFirstAgentMessageRename,
applyWorktreeMeta
})
// Why: both leave the workspace revealed and the composer text intact; the launch layer has
// already toasted a failure, and an unknown outcome reconciles on the next click.
if (settlement?.kind === 'visibility-unknown' || settlement?.kind === 'failed') {
setSidebarOpen(true)
onCreated?.()
return
const activationHolder: { value: ReturnType<typeof activateAndRevealWorktree> } = {
value: false
}
const structuredLaunchAccepted = settlement?.kind === 'structured'
// Why: the workspace was already activated before launch; the fallback's activation, when
// present, supersedes it.
const activation =
settlement?.kind === 'refused-then-legacy'
? (settlement.activation ?? initialActivation)
: initialActivation
const revealWorkspace = (): boolean => {
activationHolder.value = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
agent: tuiAgent,
setup: result.setup,
defaultTabs: result.defaultTabs,
issueCommand,
...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}),
...(!structuredLaunch && startup ? { startup } : {}),
...(structuredLaunch ? { providesInitialSurface: true } : {})
})
return activationHolder.value !== false
}
if (structuredLaunch) {
try {
beginFullCreationStructuredLaunch({
plan: launchPlan,
worktreeId: worktree.id,
beforeOpen: revealWorkspace
})
} catch (error) {
// Why: a failed reveal must not turn a structured route into a legacy terminal; the
// completed workspace remains usable and the launch surface can be retried there.
console.error('full creation: structured chat surface failed', worktree.id, error)
}
}
if (!structuredLaunch) {
revealWorkspace()
}
const structuredLaunchAccepted = structuredLaunch
const activation = activationHolder.value
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
if (!structuredLaunchAccepted && startupPlan) {
const optionScopeKey =
@@ -1,103 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
settleStructuredAgentLaunch: vi.fn(),
activateAndRevealWorktree: vi.fn(),
activateStructuredAgentSessionById: vi.fn()
}))
vi.mock('@/lib/structured-agent-launch-settlement', () => ({
settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
vi.mock('@/lib/structured-agent-session-tab-activation', () => ({
activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById
}))
import {
adoptAgentSessionLaunchVerdict,
type AgentSessionLaunchVerdict
} from '@/lib/agent-session-launch-plan'
import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch'
/** Planned before the worktree existed, so the verdict names no workspace. */
const plan = (overrides: Partial<AgentSessionLaunchVerdict> = {}) =>
adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
agent: 'codex',
prompt: 'Fix the route',
promptDelivery: 'auto-submit',
...overrides
})
const baseArgs = {
plan: plan(),
agent: 'codex' as const,
worktreeId: 'worktree-1',
startup: { command: 'codex' } as never,
pendingFirstAgentMessageRename: true,
applyWorktreeMeta: vi.fn().mockResolvedValue(undefined)
type BeginArgs = {
plan: unknown
target: { worktreeId: string }
beforeOpen?: (sessionId: string) => boolean | void
}
describe('settleFullCreationStructuredLaunch', () => {
beforeEach(() => vi.clearAllMocks())
const mocks = vi.hoisted(() => ({
beginStructuredAgentSessionProvisionalLaunch:
vi.fn<(args: BeginArgs) => { sessionId: string; tab: { id: string } } | null>()
}))
it('skips the loop when the route is not structured', async () => {
await expect(
settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ route: 'terminal-tui' }) })
).resolves.toBeNull()
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch
}))
import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan'
import { beginFullCreationStructuredLaunch } from './full-creation-structured-launch'
const plan = adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
agent: 'codex',
prompt: 'Fix the route',
promptDelivery: 'auto-submit'
})
describe('beginFullCreationStructuredLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
args.beforeOpen?.('session-1')
return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' } }
})
})
it('hands the loop the prompt and activates the structured tab when ready', async () => {
mocks.settleStructuredAgentLaunch.mockImplementation(
async (_worktreeId, _agent, _options, hooks) => {
hooks.onStructuredReady('session-1')
return { kind: 'structured', sessionId: 'session-1' }
}
)
it('allocates the final identity before revealing and opening the chat surface', () => {
const order: string[] = []
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
order.push('begin')
args.beforeOpen?.('session-1')
order.push('open')
return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' } }
})
await expect(
settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ promptDelivery: 'draft' }) })
).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' })
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
'worktree-1',
'codex',
{ prompt: 'Fix the route', promptDelivery: 'draft' },
expect.anything()
)
expect(mocks.activateStructuredAgentSessionById).toHaveBeenCalledWith({
const launch = beginFullCreationStructuredLaunch({
plan,
worktreeId: 'worktree-1',
sessionId: 'session-1'
beforeOpen: (sessionId) => {
order.push(`reveal:${sessionId}`)
return true
}
})
expect(launch).toMatchObject({ sessionId: 'session-1', tab: { id: 'agent-session:session-1' } })
expect(order).toEqual(['begin', 'reveal:session-1', 'open'])
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith({
plan,
hooks: {},
target: { worktreeId: 'worktree-1' },
beforeOpen: expect.any(Function)
})
})
it('marks the rename flag and opens the startup terminal as the legacy fallback', async () => {
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' })
mocks.settleStructuredAgentLaunch.mockImplementation(
async (_worktreeId, _agent, _options, hooks) => ({
kind: 'refused-then-legacy',
...(await hooks.legacyFallback())
})
)
it('returns no surface when reveal or ownership is refused', () => {
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue(null)
await expect(settleFullCreationStructuredLaunch(baseArgs)).resolves.toEqual({
kind: 'refused-then-legacy',
activation: { primaryTabId: 'fallback-tab' },
primaryTabId: 'fallback-tab'
})
expect(baseArgs.applyWorktreeMeta).toHaveBeenCalledWith('worktree-1', {
pendingFirstAgentMessageRename: true
})
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
sidebarRevealBehavior: 'auto',
agent: 'codex',
createNewTerminalForStartup: true,
startup: baseArgs.startup
})
expect(
beginFullCreationStructuredLaunch({ plan, worktreeId: 'worktree-1', beforeOpen: vi.fn() })
).toBeNull()
})
})
@@ -1,43 +1,21 @@
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement'
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
beginStructuredAgentSessionProvisionalLaunch,
type StructuredAgentSessionProvisionalLaunch
} from '@/lib/structured-agent-session-provisional-tab'
/** Full-create dialog: the structured launch plus what this flow did before structured chat
* existed. Returns null when the plan's route is not structured. */
export async function settleFullCreationStructuredLaunch(args: {
* existed. Returns null when no visible surface can be owned. */
export function beginFullCreationStructuredLaunch(args: {
/** Planned before the worktree existed; `worktreeId` names the one that was created. */
plan: AgentSessionLaunchPlan
agent: TuiAgent
worktreeId: string
startup: WorktreeStartupPayload | undefined
pendingFirstAgentMessageRename: boolean
applyWorktreeMeta: (
worktreeId: string,
meta: { pendingFirstAgentMessageRename: boolean }
) => Promise<void>
}): Promise<StructuredAgentLaunchSettlement | null> {
return args.plan.launch(
{
legacyFallback: async () => {
if (args.pendingFirstAgentMessageRename) {
await args
.applyWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true })
.catch(() => undefined)
}
const activation = activateAndRevealWorktree(args.worktreeId, {
sidebarRevealBehavior: 'auto',
agent: args.agent,
createNewTerminalForStartup: true,
...(args.startup ? { startup: args.startup } : {})
})
return { activation, primaryTabId: activation === false ? null : activation.primaryTabId }
},
onStructuredReady: (sessionId) =>
activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId })
},
{ worktreeId: args.worktreeId }
)
beforeOpen: (sessionId: string) => boolean | void
}): StructuredAgentSessionProvisionalLaunch | null {
return beginStructuredAgentSessionProvisionalLaunch({
plan: args.plan,
hooks: {},
target: { worktreeId: args.worktreeId },
beforeOpen: args.beforeOpen
})
}
+9
View File
@@ -2571,6 +2571,15 @@
}
},
"worktree": {
"creation": {
"flow": {
"structured": {
"launch": {
"unknown": "Could not confirm whether {{value0}} chat opened. Retry to check again."
}
}
}
},
"palette": {
"search": {
"0b01ff98d2": "Port",
+6 -4
View File
@@ -828,7 +828,7 @@
"flow": {
"structured": {
"launch": {
"unknown": "Could not confirm whether Codex chat opened. Retry to check again."
"unknown": "Could not confirm whether {{value0}} chat opened. Retry to check again."
}
}
}
@@ -16906,6 +16906,11 @@
"1f772bb5d0": "Message delivery is unconfirmed.",
"93ef441197": "Message was not sent.",
"a5e7f14068": "Retry"
},
"NativeChatLaunchRetry": {
"failed": "Chat could not be started.",
"unknown": "Chat connection could not be confirmed.",
"retry": "Retry"
}
}
},
@@ -17284,7 +17289,6 @@
"launchPromptNotDelivered": "Not delivered — check the terminal",
"structuredSessionCloseFailed": "Could not close this chat session",
"structuredSessionLaunchFailed": "Could not open {{value0}} chat",
"structuredSessionLaunchPending": "Starting {{value0}} chat…",
"structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable.",
"handoff": {
"stage": {
@@ -17318,8 +17322,6 @@
"retry": "Retry",
"details": "Details"
},
"structuredSessionFellBackToTerminal": "Structured chat isn't available",
"structuredSessionFellBackToTerminalDescription": "Orca tried to open a {{value0}} terminal instead.",
"structuredSessionLaunchFailedDescription": "Orca could not open a structured {{value0}} chat. See the logs for details.",
"subagents": {
"state": {
@@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({
buildAgentLaunchRouteInput: vi.fn(),
resolveAgentLaunchRoute: vi.fn(),
structuredAgentLaunchSupported: vi.fn(),
settleStructuredAgentLaunch: vi.fn()
beginStructuredAgentLaunchSettlement: vi.fn()
}))
vi.mock('@/lib/agent-launch-route-input', () => ({
@@ -16,7 +16,7 @@ vi.mock('@/lib/agent-launch-routing', () => ({
structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported
}))
vi.mock('@/lib/structured-agent-launch-settlement', () => ({
settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch
beginStructuredAgentLaunchSettlement: mocks.beginStructuredAgentLaunchSettlement
}))
import {
@@ -36,7 +36,10 @@ describe('planAgentSessionLaunch', () => {
mocks.buildAgentLaunchRouteInput.mockReturnValue(ROUTE_INPUT)
mocks.resolveAgentLaunchRoute.mockReturnValue('structured-native-chat')
mocks.structuredAgentLaunchSupported.mockReturnValue(true)
mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED)
mocks.beginStructuredAgentLaunchSettlement.mockReturnValue({
sessionId: 'session-1',
settlement: Promise.resolve(STRUCTURED)
})
})
it('decides the route once, from the builder input, and never again on launch', async () => {
@@ -74,7 +77,7 @@ describe('planAgentSessionLaunch', () => {
})
await expect(plan.launch(hooks)).resolves.toBe(STRUCTURED)
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'folder:ws-1',
'claude',
{
@@ -94,7 +97,7 @@ describe('planAgentSessionLaunch', () => {
})
await plan.launch(hooks)
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'folder:ws-1',
'codex',
{},
@@ -113,7 +116,8 @@ describe('planAgentSessionLaunch', () => {
expect(plan.route).toBe(route)
await expect(plan.launch(hooks)).resolves.toBeNull()
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
expect(plan.begin(hooks)).toBeNull()
expect(mocks.beginStructuredAgentLaunchSettlement).not.toHaveBeenCalled()
}
)
@@ -124,7 +128,8 @@ describe('planAgentSessionLaunch', () => {
})
await expect(plan.launch(hooks)).resolves.toBeNull()
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
expect(plan.begin(hooks)).toBeNull()
expect(mocks.beginStructuredAgentLaunchSettlement).not.toHaveBeenCalled()
})
it('launches into the workspace created after planning when the target names one', async () => {
@@ -136,7 +141,7 @@ describe('planAgentSessionLaunch', () => {
})
await plan.launch(hooks, { worktreeId: 'wt-created' })
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'wt-created',
'codex',
{ prompt: 'Fix it', promptDelivery: 'auto-submit' },
@@ -151,7 +156,7 @@ describe('planAgentSessionLaunch', () => {
})
await expect(plan.launch(hooks)).rejects.toThrow(/workspace/)
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
expect(mocks.beginStructuredAgentLaunchSettlement).not.toHaveBeenCalled()
})
})
@@ -174,7 +179,7 @@ describe('structuredAgentSessionLaunchFeasible', () => {
})
).toBe(supported)
expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled()
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
expect(mocks.beginStructuredAgentLaunchSettlement).not.toHaveBeenCalled()
})
it('builds the input from the named settings, not the store copy', () => {
@@ -199,7 +204,10 @@ describe('structuredAgentSessionLaunchFeasible', () => {
describe('adoptAgentSessionLaunchVerdict', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED)
mocks.beginStructuredAgentLaunchSettlement.mockReturnValue({
sessionId: 'session-1',
settlement: Promise.resolve(STRUCTURED)
})
})
it('re-enters a persisted verdict without resolving the route again', async () => {
@@ -215,7 +223,7 @@ describe('adoptAgentSessionLaunchVerdict', () => {
expect(mocks.buildAgentLaunchRouteInput).not.toHaveBeenCalled()
expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled()
expect(mocks.structuredAgentLaunchSupported).not.toHaveBeenCalled()
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith(
'wt-recovered',
'codex',
{ prompt: 'Fix it', promptDelivery: 'draft' },
@@ -231,6 +239,6 @@ describe('adoptAgentSessionLaunchVerdict', () => {
})
await expect(plan.launch(hooks)).resolves.toBeNull()
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
expect(mocks.beginStructuredAgentLaunchSettlement).not.toHaveBeenCalled()
})
})
@@ -14,7 +14,8 @@ import {
} from '@/lib/agent-launch-routing'
import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode'
import {
settleStructuredAgentLaunch,
beginStructuredAgentLaunchSettlement,
type StructuredAgentLaunchHandle,
type StructuredAgentLaunchHooks,
type StructuredAgentLaunchSettlement
} from '@/lib/structured-agent-launch-settlement'
@@ -52,6 +53,11 @@ export type AgentSessionLaunchTarget = {
}
export type AgentSessionLaunchPlan = Readonly<AgentSessionLaunchVerdict> & {
/** Begins the launch and exposes its durable identity before host acquisition settles. */
begin(
hooks: StructuredAgentLaunchHooks,
target?: AgentSessionLaunchTarget
): StructuredAgentLaunchHandle | null
/** Runs the structured settle loop for this plan. Null when the route is not structured. */
launch(
hooks: StructuredAgentLaunchHooks,
@@ -68,30 +74,35 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured
}
}
function beginStructuredPlanLaunch(
verdict: AgentSessionLaunchVerdict,
hooks: StructuredAgentLaunchHooks,
target?: AgentSessionLaunchTarget
): StructuredAgentLaunchHandle | null {
if (verdict.route !== 'structured-native-chat' || !isAgentSessionHandleProvider(verdict.agent)) {
return null
}
const worktreeId = target?.worktreeId ?? verdict.worktreeId
if (!worktreeId) {
throw new Error('A structured agent launch needs the workspace it targets.')
}
return beginStructuredAgentLaunchSettlement(
worktreeId,
verdict.agent,
structuredLaunchOptions(verdict),
hooks
)
}
/** Re-enter with a verdict decided earlier; the route is data here and is never re-resolved. */
export function adoptAgentSessionLaunchVerdict(
verdict: AgentSessionLaunchVerdict
): AgentSessionLaunchPlan {
return {
...verdict,
launch: async (hooks, target) => {
if (
verdict.route !== 'structured-native-chat' ||
!isAgentSessionHandleProvider(verdict.agent)
) {
return null
}
const worktreeId = target?.worktreeId ?? verdict.worktreeId
if (!worktreeId) {
throw new Error('A structured agent launch needs the workspace it targets.')
}
return settleStructuredAgentLaunch(
worktreeId,
verdict.agent,
structuredLaunchOptions(verdict),
hooks
)
}
begin: (hooks, target) => beginStructuredPlanLaunch(verdict, hooks, target),
launch: async (hooks, target) =>
beginStructuredPlanLaunch(verdict, hooks, target)?.settlement ?? null
}
}
@@ -144,7 +144,12 @@ describe('startFixChecksAgent', () => {
mocks.activateAndRevealWorktree.mockReturnValue(true)
mocks.findGithubPrWorkspaceAttachment.mockReturnValue(null)
mocks.getConnectionId.mockReturnValue(null)
mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' })
mocks.launchAgentInNewTab.mockImplementation(
(args: { beforeSurfaceOpen?: (surface: { kind: 'local-terminal' }) => boolean | void }) => {
args.beforeSurfaceOpen?.({ kind: 'local-terminal' })
return { surface: { kind: 'local-terminal', tabId: 'tab-1' } }
}
)
mocks.launchWorkItemDirect.mockResolvedValue(true)
mocks.pickSourceControlLaunchAgent.mockImplementation(({ detectedAgents }) => {
return detectedAgents.includes('codex') ? 'codex' : null
+21 -17
View File
@@ -186,16 +186,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
toast.error(agentArgsPlan.error)
return false
}
// launchAgentInNewTab below creates the surface; seeding here would add a stray shell.
if (!activateAndRevealWorktree(targetWorktreeId, { providesInitialSurface: true })) {
toast.error(
translate(
'auto.lib.fix.checks.agent.launch.03c1d61f83',
'Unable to open the workspace attached to these checks.'
)
)
return false
}
let revealFailed = false
const result = launchAgentInNewTab({
agent,
worktreeId: targetWorktreeId,
@@ -204,19 +195,32 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
agentArgs: recipe.agentArgs,
promptDelivery: 'submit-after-ready',
launchPlatform,
launchSource: args.launchSource
launchSource: args.launchSource,
beforeSurfaceOpen: () => {
// Why: the launcher owns the initial surface, so revealing must not seed a sibling shell.
const revealed = activateAndRevealWorktree(targetWorktreeId, {
providesInitialSurface: true
})
revealFailed = revealed === false
return !revealFailed
}
})
if (!result) {
toast.error(
translate(
'auto.lib.fix.checks.agent.launch.fb6c294e85',
'Could not build the agent launch command.'
)
revealFailed
? translate(
'auto.lib.fix.checks.agent.launch.03c1d61f83',
'Unable to open the workspace attached to these checks.'
)
: translate(
'auto.lib.fix.checks.agent.launch.fb6c294e85',
'Could not build the agent launch command.'
)
)
return false
}
if (result.tabId) {
focusTerminalTabSurface(result.tabId)
if (result.surface.kind === 'local-terminal') {
focusTerminalTabSurface(result.surface.tabId)
}
return true
}
@@ -1,209 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
StructuredAgentLaunchHooks,
StructuredAgentLaunchSettlement
} from './structured-agent-launch-settlement'
import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement'
type BeginArgs = {
plan: unknown
targetGroupId?: string
beforeOpen?: (sessionId: string) => boolean | void
}
type Launch = {
sessionId: string
tab: { id: string }
settlement: Promise<StructuredAgentLaunchSettlement>
promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }>
}
const mocks = vi.hoisted(() => ({
settleStructuredAgentLaunch: vi.fn()
beginStructuredAgentSessionProvisionalLaunch: vi.fn<(args: BeginArgs) => Launch | null>()
}))
vi.mock('@/lib/structured-agent-launch-settlement', () => ({
settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch
vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch
}))
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
import { launchAgentInStructuredNewTab } from './launch-agent-in-new-tab-structured'
type Delivery = 'auto-submit' | 'submit-after-ready' | 'draft'
const structuredPlan = (prompt: string, promptDelivery: Delivery, onPromptDelivered?: () => void) =>
const structuredPlan = (prompt: string, promptDelivery: Delivery) =>
adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
agent: 'codex',
worktreeId: 'wt-1',
prompt,
promptDelivery,
...(onPromptDelivered ? { onPromptDelivered } : {})
promptDelivery
})
const delivered = { delivered: true, failureNotified: false }
const undelivered = { delivered: false, failureNotified: true }
/** Mirrors the shared loop: a refusal runs the caller's fallback once and settles with its result. */
function settleWith(settlement: StructuredAgentLaunchSettlement | 'refusal') {
mocks.settleStructuredAgentLaunch.mockImplementation(
async (
_worktreeId: string,
_agent: string,
_options: unknown,
hooks: StructuredAgentLaunchHooks
) => {
if (settlement !== 'refusal') {
return settlement
}
const fallback = await hooks.legacyFallback?.()
return fallback
? { kind: 'refused-then-legacy', ...fallback }
: { kind: 'failed', error: null }
}
)
}
describe('launchAgentInStructuredNewTab', () => {
let consoleError: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
consoleError.mockRestore()
})
it('hands the launch to the shared settle loop and follows the structured delivery', async () => {
const promptDeliveryResult = Promise.resolve(delivered)
settleWith({ kind: 'structured', sessionId: 'session-1', promptDeliveryResult })
const legacyLaunch = vi.fn()
const onPromptDelivered = vi.fn()
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'submit-after-ready', onPromptDelivered),
legacyLaunch
})
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
'wt-1',
'codex',
{ prompt: 'Fix it', promptDelivery: 'submit-after-ready', onPromptDelivered },
expect.objectContaining({ legacyFallback: expect.any(Function) })
)
await expect(result.structuredSettlement).resolves.toEqual({
kind: 'structured',
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation(() => ({
sessionId: 'session-1',
promptDeliveryResult
})
await expect(result.promptDeliveryResult).resolves.toEqual(delivered)
expect(legacyLaunch).not.toHaveBeenCalled()
expect(consoleError).not.toHaveBeenCalled()
})
it('runs the terminal launch exactly once on refusal and reports its delivery', async () => {
settleWith('refusal')
const legacyDelivery = Promise.resolve(delivered)
const legacyLaunch = vi.fn(() => ({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: legacyDelivery
tab: { id: 'agent-session:session-1' },
settlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' })
}))
})
afterEach(() => consoleError.mockRestore())
it('returns the usable chat surface immediately and settles in the background', async () => {
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'submit-after-ready'),
legacyLaunch
targetGroupId: 'group-1'
})
await expect(result.structuredSettlement).resolves.toEqual({
kind: 'refused-then-legacy',
primaryTabId: 'tab-1',
promptDeliveryResult: legacyDelivery
})
await expect(result.promptDeliveryResult).resolves.toBe(delivered)
expect(legacyLaunch).toHaveBeenCalledOnce()
})
it('counts an argv-carried prompt as delivered when the terminal launch returns no promise', async () => {
settleWith('refusal')
const legacyLaunch = vi.fn(() => ({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: false
}))
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'auto-submit'),
legacyLaunch
})
await expect(result.promptDeliveryResult).resolves.toEqual(delivered)
await expect(result.structuredSettlement).resolves.toMatchObject({ primaryTabId: 'tab-1' })
})
it('reports a notified failure when the terminal launch has no startup plan', async () => {
settleWith('refusal')
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'auto-submit'),
legacyLaunch: () => null
})
await expect(result.promptDeliveryResult).resolves.toEqual(undelivered)
await expect(result.structuredSettlement).resolves.toMatchObject({
kind: 'refused-then-legacy',
primaryTabId: null
})
})
it('logs a failed settlement without re-entering the terminal launch', async () => {
const error = new Error('boom')
settleWith({ kind: 'failed', error })
const legacyLaunch = vi.fn()
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'submit-after-ready'),
legacyLaunch
})
await expect(result.structuredSettlement).resolves.toEqual({ kind: 'failed', error })
await expect(result.promptDeliveryResult).resolves.toEqual(undelivered)
expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error)
expect(legacyLaunch).not.toHaveBeenCalled()
})
it('treats a thrown settle loop as a failed settlement', async () => {
const error = new Error('intent unavailable')
mocks.settleStructuredAgentLaunch.mockRejectedValue(error)
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'submit-after-ready'),
legacyLaunch: vi.fn()
})
await expect(result.structuredSettlement).resolves.toEqual({ kind: 'failed', error })
await expect(result.promptDeliveryResult).resolves.toEqual(undelivered)
expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error)
})
it('surfaces an unknown outcome silently and never falls back', async () => {
settleWith({ kind: 'visibility-unknown', sessionId: 'session-1' })
const legacyLaunch = vi.fn()
const result = launchAgentInStructuredNewTab({
plan: structuredPlan('Fix it', 'submit-after-ready'),
legacyLaunch
})
await expect(result.structuredSettlement).resolves.toEqual({
kind: 'visibility-unknown',
expect(result).toMatchObject({ sessionId: 'session-1', tabId: 'agent-session:session-1' })
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith(
expect.objectContaining({ plan: expect.anything(), targetGroupId: 'group-1', hooks: {} })
)
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'structured',
sessionId: 'session-1'
})
await expect(result.promptDeliveryResult).resolves.toEqual(undelivered)
expect(consoleError).not.toHaveBeenCalled()
expect(legacyLaunch).not.toHaveBeenCalled()
})
it.each([
['no prompt', '', 'auto-submit' as const],
['a draft prompt', 'Fix it', 'draft' as const]
])('exposes no delivery promise for %s', async (_label, prompt, promptDelivery) => {
settleWith({ kind: 'structured', sessionId: 'session-1' })
const result = launchAgentInStructuredNewTab({
plan: structuredPlan(prompt, promptDelivery),
legacyLaunch: vi.fn()
it('reports failed settlement without opening a terminal fallback', async () => {
const error = new Error('boom')
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({
sessionId: 'session-1',
tab: { id: 'agent-session:session-1' },
settlement: Promise.resolve({ kind: 'failed', error })
})
expect(result.promptDeliveryResult).toBeUndefined()
await expect(result.structuredSettlement).resolves.toMatchObject({ kind: 'structured' })
const result = launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'auto-submit') })
await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'failed', error })
expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error)
})
it('returns null when the route cannot begin', () => {
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue(null)
expect(
launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'auto-submit') })
).toBeNull()
})
it('does not expose a delivery promise for drafts', () => {
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({
sessionId: 'session-1',
tab: { id: 'agent-session:session-1' },
settlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }),
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})
const result = launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'draft') })
expect(result?.promptDeliveryResult).toBeUndefined()
})
})
@@ -1,77 +1,52 @@
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
import type { LaunchAgentInNewTabResult } from '@/lib/launch-agent-in-new-tab'
import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
export type StructuredNewTabLaunchArgs = {
/** Planned on the structured route with an already-trimmed prompt; empty means no prompt. */
plan: AgentSessionLaunchPlan
/** The terminal-backed launch with the same arguments. Runs at most once, on definitive refusal. */
legacyLaunch: () => LaunchAgentInNewTabResult
targetGroupId?: string
/** Lets a workspace reveal itself after ID allocation but before tab ownership. */
beforeOpen?: (sessionId: string) => boolean | void
}
export type StructuredNewTabLaunch = {
sessionId: string
tabId: string
structuredSettlement: Promise<StructuredAgentLaunchSettlement>
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
}
const UNDELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: true }
function promptDeliveryFromSettlement(
settlement: StructuredAgentLaunchSettlement
): Promise<StructuredPromptDeliveryResult> {
if (settlement.kind === 'structured' || settlement.kind === 'refused-then-legacy') {
return settlement.promptDeliveryResult ?? Promise.resolve(UNDELIVERED)
}
return Promise.resolve(UNDELIVERED)
}
/**
* The new-tab launcher's structured branch. Returns synchronously so `launchAgentInNewTab` keeps
* its signature; the settlement carries what the launch actually did, and `promptDeliveryResult`
* follows it so a refusal reports the terminal fallback's delivery, not the refused structured one.
* its signature; the settlement carries what the structured launch actually did.
*/
export function launchAgentInStructuredNewTab(
args: StructuredNewTabLaunchArgs
): StructuredNewTabLaunch {
const hasPrompt = Boolean(args.plan.prompt)
const structuredSettlement = args.plan
.launch({
legacyFallback: async () => {
const fallback = args.legacyLaunch()
// Why: a legacy launch with no delivery promise still delivered an argv-carried or draft
// prompt; only a null launch (no startup plan) is a failure.
const promptDeliveryResult =
fallback?.promptDeliveryResult ??
(hasPrompt
? Promise.resolve({ delivered: Boolean(fallback), failureNotified: fallback === null })
: undefined)
return {
primaryTabId: fallback?.tabId ?? null,
...(promptDeliveryResult ? { promptDeliveryResult } : {})
}
}
})
.then(
(settlement): StructuredAgentLaunchSettlement =>
settlement ?? {
kind: 'failed',
error: new Error('Launch planned off the structured route')
},
(error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error })
)
): StructuredNewTabLaunch | null {
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: args.plan,
hooks: {},
...(args.beforeOpen ? { beforeOpen: args.beforeOpen } : {}),
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {})
})
if (!launch) {
return null
}
const structuredSettlement = launch.settlement
void structuredSettlement.then((settlement) => {
// Why: unknown already shows the launch badge and failed already toasted; this is the log
// line the old fire-and-forget fallback claim kept.
if (settlement.kind === 'failed') {
console.error('Structured agent launch failed', settlement.error)
}
})
return {
sessionId: launch.sessionId,
tabId: launch.tab.id,
structuredSettlement,
// Why: draft mode has no delivery event; the composer adopts the text and the user sends it.
...(hasPrompt && args.plan.promptDelivery !== 'draft'
? { promptDeliveryResult: structuredSettlement.then(promptDeliveryFromSettlement) }
// Why: draft mode has no delivery event; the composer owns the text until the user sends it.
...(launch.promptDeliveryResult && args.plan.promptDelivery !== 'draft'
? { promptDeliveryResult: launch.promptDeliveryResult }
: {})
}
}
@@ -85,7 +85,12 @@ describe('launchAgentInNewTab paired web runtime', () => {
groupId: 'group-1'
})
expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false }))
expect(result).toEqual(
expect.objectContaining({
surface: { kind: 'host-published' },
pasteDraftAfterLaunch: false
})
)
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
@@ -113,7 +118,12 @@ describe('launchAgentInNewTab paired web runtime', () => {
groupId: 'group-1'
})
expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false }))
expect(result).toEqual(
expect.objectContaining({
surface: { kind: 'host-published' },
pasteDraftAfterLaunch: false
})
)
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
@@ -482,7 +482,8 @@ describe('launchAgentInNewTab', () => {
agentArgs: '--permission-mode plan'
})
expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false }))
expect(result?.surface).toEqual({ kind: 'host-published' })
expect(result?.pasteDraftAfterLaunch).toBe(false)
expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith(
expect.objectContaining({
launchAgent: 'claude',
@@ -829,7 +830,6 @@ describe('launchAgentInNewTab', () => {
delivered: false,
failureNotified: true
})
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('marks a cancelled submit-after-ready launch notified when the user switched worktrees', async () => {
@@ -852,7 +852,6 @@ describe('launchAgentInNewTab', () => {
delivered: false,
failureNotified: true
})
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('leaves a genuine launch failure unnotified so the caller surfaces it', async () => {
+59 -29
View File
@@ -31,7 +31,10 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na
import { launchAgentInStructuredNewTab } from '@/lib/launch-agent-in-new-tab-structured'
import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement'
import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import {
planAgentSessionLaunch,
type AgentSessionLaunchPlan
} from '@/lib/agent-session-launch-plan'
export type LaunchAgentInNewTabArgs = {
agent: TuiAgent
@@ -53,24 +56,35 @@ export type LaunchAgentInNewTabArgs = {
launchPlatform?: NodeJS.Platform
/** Called after the prompt is actually delivered to the agent input path. */
onPromptDelivered?: () => void
/** Keeps a preflighted route authoritative across workspace creation. */
agentSessionLaunchPlan?: AgentSessionLaunchPlan
/** Lets a workspace reveal itself before the selected surface opens. */
beforeSurfaceOpen?: (
surface:
| { kind: 'local-terminal' }
| { kind: 'local-agent-session'; sessionId: string }
| { kind: 'host-published' }
) => boolean | void
}
export type AgentLaunchSurface =
| { kind: 'local-terminal'; tabId: string }
| { kind: 'local-agent-session'; tabId: string; sessionId: string }
| { kind: 'host-published' }
export type LaunchAgentInNewTabResult = {
tabId: string | null
surface: AgentLaunchSurface
startupPlan: AgentStartupPlan
pasteDraftAfterLaunch: boolean
/** The host will publish and focus a structured tab asynchronously. */
focusAfterMenuClose?: 'structured-session'
promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }>
/** Structured route only: what the launch did once it settled, including whether the terminal
* fallback ran. The call itself stays synchronous. */
/** Structured route only: what the launch did once it settled. The call stays synchronous. */
structuredSettlement?: Promise<StructuredAgentLaunchSettlement>
} | null
export function shouldQueueTerminalFocusAfterMenuClose(
result: NonNullable<LaunchAgentInNewTabResult>
): boolean {
return result.tabId === null && result.focusAfterMenuClose !== 'structured-session'
return result.surface.kind === 'host-published'
}
/**
@@ -83,10 +97,7 @@ export function shouldQueueTerminalFocusAfterMenuClose(
*
* Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt).
*/
function launchAgentInNewTabInternal(
args: LaunchAgentInNewTabArgs,
forceLegacy = false
): LaunchAgentInNewTabResult {
function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult {
const {
agent,
worktreeId,
@@ -98,7 +109,9 @@ function launchAgentInNewTabInternal(
launchSource,
quickCommandLabel,
launchPlatform,
onPromptDelivered
onPromptDelivered,
agentSessionLaunchPlan,
beforeSurfaceOpen
} = args
const store = useAppStore.getState()
const worktree = store.allWorktrees?.().find((entry: { id: string }) => entry.id === worktreeId)
@@ -169,6 +182,9 @@ function launchAgentInNewTabInternal(
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId)
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) {
return null
}
const webHostDelivery = launchAgentInWebHostTab({
agent,
worktreeId,
@@ -187,7 +203,7 @@ function launchAgentInNewTabInternal(
onPromptDelivered
})
return {
tabId: null,
surface: { kind: 'host-published' },
startupPlan,
pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null,
...(pasteDraftAfterLaunch !== null && promptDelivery === 'submit-after-ready'
@@ -196,28 +212,39 @@ function launchAgentInNewTabInternal(
}
}
// Why: the legacy re-entry is the plan's own fallback; deciding a route again would loop.
const plan = forceLegacy
? null
: planAgentSessionLaunch(store, {
agent,
workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId },
prompt: trimmedPrompt,
promptDelivery: viewModePromptDelivery,
tuiCustomization: { cwd: initialCwd, agentArgs },
initialSessionOptions: startupPlan.sessionOptions,
onPromptDelivered
})
const plan =
agentSessionLaunchPlan ??
planAgentSessionLaunch(store, {
agent,
workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId },
prompt: trimmedPrompt,
promptDelivery: viewModePromptDelivery,
tuiCustomization: { cwd: initialCwd, agentArgs },
initialSessionOptions: startupPlan.sessionOptions,
onPromptDelivered
})
if (plan?.route === 'structured-native-chat') {
const structured = launchAgentInStructuredNewTab({
plan,
legacyLaunch: () => launchAgentInNewTabInternal(args, true)
...(beforeSurfaceOpen
? {
beforeOpen: (sessionId: string) =>
beforeSurfaceOpen({ kind: 'local-agent-session', sessionId })
}
: {}),
...(groupId ? { targetGroupId: groupId } : {})
})
if (!structured) {
return null
}
return {
tabId: null,
surface: {
kind: 'local-agent-session',
tabId: structured.tabId,
sessionId: structured.sessionId
},
startupPlan,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session',
structuredSettlement: structured.structuredSettlement,
...(structured.promptDeliveryResult
? { promptDeliveryResult: structured.promptDeliveryResult }
@@ -225,6 +252,9 @@ function launchAgentInNewTabInternal(
}
}
if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) {
return null
}
// Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render.
// Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit.
const tab = store.createTab(worktreeId, groupId, undefined, {
@@ -306,7 +336,7 @@ function launchAgentInNewTabInternal(
persistAgentLaunchTabOrder(worktreeId, tab.id)
return {
tabId: tab.id,
surface: { kind: 'local-terminal', tabId: tab.id },
startupPlan,
pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null,
...(promptDeliveryResult ? { promptDeliveryResult } : {})
@@ -41,7 +41,7 @@ describe('launchAgentSessionContinuation', () => {
store.ensureRemoteDetectedAgents.mockResolvedValue(['claude', 'codex'])
store.ensureRuntimeDetectedAgents.mockResolvedValue(['claude', 'codex'])
launchAgentInNewTab.mockReturnValue({
tabId: 'tab-new',
surface: { kind: 'local-terminal', tabId: 'tab-new' },
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})
vi.stubGlobal('window', {
@@ -116,7 +116,7 @@ describe('launchAgentSessionContinuation', () => {
it('distinguishes prompt delivery failure from terminal launch failure', async () => {
launchAgentInNewTab.mockReturnValue({
tabId: 'tab-new',
surface: { kind: 'local-terminal', tabId: 'tab-new' },
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false })
})
const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation')
@@ -1,14 +1,39 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockCreateTab = vi.fn()
const mockCreateUnifiedTab = vi.fn<
(
worktreeId: string,
contentType: string,
init: {
id: string
entityId: string
targetGroupId?: string
agentSessionAgent?: string
label?: string
}
) => {
id: string
entityId: string
contentType: string
worktreeId: string
groupId: string
agentSessionAgent?: string
label?: string
}
>()
const mockSetTabViewMode = vi.fn()
const mockWaitForAgentReady = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockCreateStructuredCodexSessionLaunchIntent = vi.fn()
const mockAbandonStructuredAgentSessionLaunchIntent = vi.fn()
const mockRetryStructuredAgentSessionLaunchIntent =
vi.fn<
(intent: ReturnType<typeof structuredLaunchIntent>) => ReturnType<typeof structuredLaunchIntent>
>()
const mockLaunchStructuredCodexSession = vi.fn()
const mockRefreshLocalStructuredSessionTabs = vi.fn()
const mockToastError = vi.fn()
@@ -16,6 +41,17 @@ const mockCallStructuredAgentSession = vi.fn()
const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1']
let hostCapabilities: readonly string[] | null = STRUCTURED_HOST_CAPABILITIES
type UnifiedTabFixture = {
id: string
contentType: string
entityId: string
worktreeId: string
groupId: string
agentSessionAgent?: string
label?: string
}
const emptyUnifiedTabsByWorktree: Record<string, UnifiedTabFixture[]> = {}
function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') {
return {
sessionId,
@@ -62,19 +98,19 @@ const store = {
detectedWorktreesByRepo: {},
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
unifiedTabsByWorktree: {} as Record<
string,
{ contentType: string; entityId: string; worktreeId: string }[]
>,
unifiedTabsByWorktree: emptyUnifiedTabsByWorktree,
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
createTab: mockCreateTab,
createUnifiedTab: mockCreateUnifiedTab,
closeTab: vi.fn(),
queueTabStartupCommand: vi.fn(),
setActiveTabType: vi.fn(),
focusGroup: vi.fn(),
activateTab: vi.fn(),
setTabViewMode: mockSetTabViewMode,
setTabBarOrder: vi.fn(),
setAgentStatus: vi.fn(),
@@ -105,6 +141,7 @@ vi.mock('@/lib/launch-structured-agent-session', () => {
return {
createStructuredAgentSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
abandonStructuredAgentSessionLaunchIntent: mockAbandonStructuredAgentSessionLaunchIntent,
retryStructuredAgentSessionLaunchIntent: mockRetryStructuredAgentSessionLaunchIntent,
launchStructuredAgentSession: mockLaunchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError
}
@@ -132,16 +169,50 @@ describe('structured chat adoption guard on the launch path', () => {
beforeEach(() => {
vi.clearAllMocks()
store.unifiedTabsByWorktree = {
'wt-1': [{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }]
'wt-1': [
{
id: 'structured-agent-session-codex-session-1',
contentType: 'agent-session',
entityId: 'codex-session-1',
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
}
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }]
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockCreateUnifiedTab.mockImplementation((worktreeId, contentType, init) => {
const tab = {
id: init.id,
entityId: init.entityId,
contentType,
worktreeId,
groupId: init.targetGroupId ?? 'group-1',
...(init.agentSessionAgent ? { agentSessionAgent: init.agentSessionAgent } : {}),
...(init.label ? { label: init.label } : {})
}
store.unifiedTabsByWorktree[worktreeId] = [
...(store.unifiedTabsByWorktree[worktreeId] ?? []),
tab
]
return tab
})
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) =>
structuredLaunchIntent(worktreeId)
)
mockRetryStructuredAgentSessionLaunchIntent.mockImplementation((intent) => ({
...intent,
params: {
...intent.params,
envelope: {
...intent.params.envelope,
clientOperationId: `${intent.params.envelope.clientOperationId}-retry`
}
}
}))
mockLaunchStructuredCodexSession.mockResolvedValue({
sessionId: 'codex-session-1',
fence: 1
@@ -163,6 +234,15 @@ describe('structured chat adoption guard on the launch path', () => {
store.settings.nativeChatSessionOptions = undefined
})
afterEach(async () => {
const { cancelStructuredAgentLaunch, retireStructuredAgentSessionLaunchCancellationTombstone } =
await import('./structured-agent-session-launch')
for (const sessionId of ['codex-session-1', 'codex-session-2']) {
cancelStructuredAgentLaunch('wt-1', sessionId)
retireStructuredAgentSessionLaunchCancellationTombstone('wt-1', sessionId)
}
})
it('takes the structured path when the chat-default view is selected', async () => {
const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } =
await import('./launch-agent-in-new-tab')
@@ -170,9 +250,12 @@ describe('structured chat adoption guard on the launch path', () => {
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(result).toMatchObject({
tabId: null,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session'
surface: {
kind: 'local-agent-session',
tabId: 'structured-agent-session-codex-session-1',
sessionId: 'codex-session-1'
},
pasteDraftAfterLaunch: false
})
expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false)
await expect(result?.structuredSettlement).resolves.toEqual({
@@ -199,7 +282,9 @@ describe('structured chat adoption guard on the launch path', () => {
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
})
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex')
expect(mockCreateTab).not.toHaveBeenCalled()
})
@@ -209,7 +294,9 @@ describe('structured chat adoption guard on the launch path', () => {
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
})
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'claude')
expect(mockCreateTab).not.toHaveBeenCalled()
})
@@ -223,23 +310,26 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockCreateTab).toHaveBeenCalled()
})
it('fails a Claude launch closed to the terminal when the host declines create support', async () => {
it('keeps a declined Claude launch on the structured path', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported')
const refusal = new StructuredAgentSessionCreateRefusalError(
'structured_agent_session_unsupported'
)
mockLaunchStructuredCodexSession.mockRejectedValueOnce(refusal)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null })
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'refused-then-legacy',
primaryTabId: 'tab-1'
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
})
expect(mockCreateTab).toHaveBeenCalledOnce()
expect(mockToastError).not.toHaveBeenCalled()
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'failed',
error: refusal
})
expect(mockCreateTab).not.toHaveBeenCalled()
await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledOnce())
})
it.each([[], null])(
@@ -264,7 +354,7 @@ describe('structured chat adoption guard on the launch path', () => {
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(result?.tabId).toBe('tab-1')
expect(result?.surface).toEqual({ kind: 'local-terminal', tabId: 'tab-1' })
expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled()
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
@@ -274,42 +364,33 @@ describe('structured chat adoption guard on the launch path', () => {
)
})
it('falls back to the preserved terminal launch on a definitive refusal', async () => {
it('does not open a terminal on a definitive refusal', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
)
const refusal = new StructuredAgentSessionCreateRefusalError('provider unavailable')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(refusal)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false })
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'refused-then-legacy',
primaryTabId: 'tab-1'
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' },
pasteDraftAfterLaunch: false
})
expect(mockCreateTab).toHaveBeenCalledOnce()
expect(mockCreateTab).toHaveBeenCalledWith(
'wt-1',
undefined,
undefined,
expect.objectContaining({ launchAgent: 'codex' })
)
expect(mockToastError).not.toHaveBeenCalled()
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'failed',
error: refusal
})
expect(mockCreateTab).not.toHaveBeenCalled()
await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledOnce())
})
it('logs a fallback that throws and never re-enters the terminal launch', async () => {
it('reports no prompt delivery from a definitive refusal', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
)
const tabError = new Error('no tab surface')
mockCreateTab.mockImplementationOnce(() => {
throw tabError
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
@@ -319,44 +400,15 @@ describe('structured chat adoption guard on the launch path', () => {
promptDelivery: 'submit-after-ready'
})
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'failed',
error: tabError
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mockCreateTab).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', tabError)
consoleError.mockRestore()
})
it('reports prompt delivery from the definitive-refusal terminal fallback', async () => {
const { StructuredAgentSessionCreateRefusalError } =
await import('./launch-structured-agent-session')
mockLaunchStructuredCodexSession.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('provider unavailable')
)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'start this task',
promptDelivery: 'submit-after-ready'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
await expect(result?.structuredSettlement).resolves.toMatchObject({
kind: 'refused-then-legacy',
primaryTabId: 'tab-1'
kind: 'failed'
})
expect(mockCreateTab).toHaveBeenCalledOnce()
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledOnce()
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
})
it('coalesces repeated structured launches for one worktree while the host is starting', async () => {
@@ -370,8 +422,8 @@ describe('structured chat adoption guard on the launch path', () => {
const first = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
const second = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
expect(first).toMatchObject({ focusAfterMenuClose: 'structured-session' })
expect(second).toMatchObject({ focusAfterMenuClose: 'structured-session' })
expect(first).toMatchObject({ surface: { kind: 'local-agent-session' } })
expect(second).toMatchObject({ surface: { kind: 'local-agent-session' } })
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
resolveLaunch({ sessionId: 'codex-session-1', fence: 1 })
})
@@ -395,7 +447,13 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
store.unifiedTabsByWorktree['wt-1'] = [
{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }
{
id: 'structured-agent-session-codex-session-1',
contentType: 'agent-session',
entityId: 'codex-session-1',
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
resolveRefresh([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
@@ -420,7 +478,13 @@ describe('structured chat adoption guard on the launch path', () => {
.mockImplementationOnce(() => {
// The inventory refresh also publishes the host snapshot into the renderer projection.
store.unifiedTabsByWorktree['wt-1'] = [
{ contentType: 'agent-session', entityId: firstIntent.sessionId, worktreeId: 'wt-1' }
{
id: `structured-agent-session-${firstIntent.sessionId}`,
contentType: 'agent-session',
entityId: firstIntent.sessionId,
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
return Promise.resolve([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: firstIntent.sessionId }] }
@@ -450,7 +514,13 @@ describe('structured chat adoption guard on the launch path', () => {
// A successful retry must release the reservation so a later launch can start normally.
store.unifiedTabsByWorktree['wt-1'] = [
{ contentType: 'agent-session', entityId: secondIntent.sessionId, worktreeId: 'wt-1' }
{
id: `structured-agent-session-${secondIntent.sessionId}`,
contentType: 'agent-session',
entityId: secondIntent.sessionId,
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3))
@@ -468,7 +538,9 @@ describe('structured chat adoption guard on the launch path', () => {
prompt: 'start this task'
})
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
expect(result).toMatchObject({
surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
@@ -134,13 +134,13 @@ describe('structured agent session launch', () => {
}
)
it('fails closed when the create support probe cannot be answered', async () => {
it('keeps an unanswered create support probe recoverable', async () => {
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable'))
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
StructuredAgentSessionCreateUnknownOutcomeError
)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
@@ -206,7 +206,7 @@ describe('structured agent session launch', () => {
launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
@@ -220,7 +220,7 @@ describe('structured agent session launch', () => {
launchStructuredAgentSession(
createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
).rejects.toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError)
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
})
@@ -19,7 +19,7 @@ import {
recordWebSessionFocusIntent,
resolveWebSessionVisibleTabId
} from '@/runtime/web-session-focus-intent'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-owner'
export type StructuredAgentSessionLaunchIntent = {
sessionId: string
@@ -39,9 +39,8 @@ class StructuredAgentSessionCreateError extends Error {
}
/**
* The host proved it created nothing, so a caller may open a legacy terminal instead. The class
* itself is the verdict: `launchStructuredAgentSession` is the only place that decides it, against
* the shared allowlist, so no consumer has to remember to re-check a code.
* The host proved it created nothing. The class itself is the verdict:
* `launchStructuredAgentSession` is the only place that decides it against the shared allowlist.
*/
export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError {
constructor(message: string, code: string = 'structured_agent_session_unsupported') {
@@ -93,6 +92,15 @@ export function createStructuredAgentSessionLaunchIntent(
resumeFrom?: StructuredAgentSessionResumeSource
): StructuredAgentSessionLaunchIntent {
const sessionId = createStructuredAgentSessionId(agent, () => crypto.randomUUID())
return buildStructuredAgentSessionLaunchIntent(worktreeId, agent, sessionId, resumeFrom)
}
function buildStructuredAgentSessionLaunchIntent(
worktreeId: string,
agent: AgentSessionHandleProvider,
sessionId: string,
resumeFrom?: StructuredAgentSessionResumeSource
): StructuredAgentSessionLaunchIntent {
const state = useAppStore.getState()
recordWebSessionFocusIntent(
{ environmentId: LOCAL_STRUCTURED_SESSION_OWNER },
@@ -115,6 +123,54 @@ export function createStructuredAgentSessionLaunchIntent(
}
}
/** A definitive refusal consumed its operation id, but the provisional tab still owns its session. */
export function retryStructuredAgentSessionLaunchIntent(
intent: StructuredAgentSessionLaunchIntent
): StructuredAgentSessionLaunchIntent {
return buildStructuredAgentSessionLaunchIntent(
intent.worktreeId,
intent.agent,
intent.sessionId,
intent.params.resumeFrom
)
}
/** Rebuild a reload-surviving intent with the caller's current worktree selector. */
export function restoreStructuredAgentSessionLaunchIntent(args: {
worktreeId: string
sessionId: string
agent: AgentSessionHandleProvider
clientOperationId: string
payloadFingerprint: string
expectedRuntimeFence: number | null
resumeFrom?: StructuredAgentSessionResumeSource
}): StructuredAgentSessionLaunchIntent {
const state = useAppStore.getState()
recordWebSessionFocusIntent(
{ environmentId: LOCAL_STRUCTURED_SESSION_OWNER },
args.worktreeId,
`agent-session:${args.sessionId}`,
undefined,
resolveWebSessionVisibleTabId(state, args.worktreeId)
)
return {
sessionId: args.sessionId,
worktreeId: args.worktreeId,
agent: args.agent,
params: {
envelope: {
sessionId: args.sessionId,
clientOperationId: args.clientOperationId,
expectedRuntimeFence: args.expectedRuntimeFence,
payloadFingerprint: args.payloadFingerprint
},
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent: args.agent,
...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {})
}
}
}
export function abandonStructuredAgentSessionLaunchIntent(
intent: StructuredAgentSessionLaunchIntent
): void {
@@ -139,14 +195,20 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function runtimeErrorCode(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
return error.code
}
return 'runtime_unavailable'
}
/**
* Whether the executing host supports creating this session — retrying only while the host cannot
* yet resolve the worktree.
*
* "Could not answer" and "answered no" are different states and only the second is a verdict.
* Collapsing them sends a launch to the terminal because a selector was a beat late, which is
* indistinguishable to the user from the gate refusing them. The retry is narrowed to that one
* transient code so every other failure still refuses on the first ask.
* The unknown branch remains on the chat surface for reconciliation instead of becoming a
* terminal fallback.
*/
async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): Promise<boolean> {
for (let attempt = 0; ; attempt += 1) {
@@ -159,14 +221,22 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P
return support.supported === true
} catch (error) {
const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt]
if (
retryDelayMs === undefined ||
!hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE)
) {
// An unanswered probe is still not a yes.
if (retryDelayMs === undefined) {
// A selector that never appears is a definitive local refusal.
return false
}
await delay(retryDelayMs)
if (hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE)) {
await delay(retryDelayMs)
continue
}
const code = runtimeErrorCode(error)
if (isDefinitiveAgentSessionCreateRefusal(code)) {
return false
}
throw new StructuredAgentSessionCreateUnknownOutcomeError(
error instanceof Error ? error.message : String(error),
code
)
}
}
}
@@ -1,34 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionLaunchPlan } from './agent-session-launch-plan'
type BeginArgs = { beforeOpen: (sessionId: string) => boolean | void }
const mocks = vi.hoisted(() => ({
settleStructuredAgentLaunch: vi.fn(),
activateAndRevealWorktree: vi.fn(),
preflightAgentTrust: vi.fn()
beginStructuredAgentSessionProvisionalLaunch:
vi.fn<(args: BeginArgs) => { sessionId: string; tab: { id: string } } | null>(),
preflightAgentTrust: vi.fn<(args: unknown) => Promise<void>>()
}))
vi.mock('@/lib/structured-agent-launch-settlement', () => ({
settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
vi.mock('@/lib/agent-trust-preflight', () => ({
preflightAgentTrust: mocks.preflightAgentTrust
}))
vi.mock('@/lib/native-chat-transcript-readability', () => ({
isNativeChatTranscriptLocalReadable: vi.fn(() => true)
vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch
}))
vi.mock('@/lib/agent-trust-preflight', () => ({ preflightAgentTrust: mocks.preflightAgentTrust }))
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
import {
markDirectWorkItemAgentTrusted,
settleDirectWorkItemStructuredLaunch
beginDirectWorkItemStructuredLaunch,
markDirectWorkItemAgentTrusted
} from './launch-work-item-direct-agent-routing'
const structuredPlan = adoptAgentSessionLaunchVerdict({
const structuredPlan: AgentSessionLaunchPlan = adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
agent: 'codex',
worktreeId: 'worktree-1',
@@ -36,131 +28,72 @@ const structuredPlan = adoptAgentSessionLaunchVerdict({
promptDelivery: 'draft'
})
const baseArgs = {
plan: structuredPlan,
worktreeId: 'worktree-1',
workspacePath: '/repo/worktree',
connectionId: null,
primaryTabId: null,
startupPlan: null,
launchSource: 'task_page' as const
}
describe('settleDirectWorkItemStructuredLaunch', () => {
beforeEach(() => vi.clearAllMocks())
it('preserves the editable delivery mode for the default-agent PR launch', async () => {
mocks.settleStructuredAgentLaunch.mockResolvedValue({
kind: 'structured',
sessionId: 'draft-session'
describe('beginDirectWorkItemStructuredLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.preflightAgentTrust.mockResolvedValue(undefined)
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
args.beforeOpen('session-1')
return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' } }
})
await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({
completed: true,
structuredLaunch: true,
visibilityUnknown: false,
failed: false,
primaryTabId: null
})
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
'worktree-1',
'codex',
{ prompt: 'Fix the route', promptDelivery: 'draft' },
expect.anything()
)
})
it('runs trust preflight and the legacy terminal as the refusal fallback', async () => {
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' })
mocks.settleStructuredAgentLaunch.mockImplementation(
async (_worktreeId, _agent, _options, hooks) => ({
kind: 'refused-then-legacy',
...(await hooks.legacyFallback())
})
)
it('opens the provisional chat synchronously and preserves the requested tab id', () => {
const order: string[] = []
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
order.push('begin')
args.beforeOpen('session-1')
order.push('open')
return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' } }
})
await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({
completed: false,
structuredLaunch: false,
visibilityUnknown: false,
failed: false,
primaryTabId: 'fallback-tab'
})
expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({
agent: 'codex',
workspacePath: '/repo/worktree',
connectionId: null
})
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(
'worktree-1',
expect.objectContaining({ sidebarRevealBehavior: 'auto', createNewTerminalForStartup: true })
)
expect(
beginDirectWorkItemStructuredLaunch({
plan: structuredPlan,
primaryTabId: null,
beforeOpen: (sessionId) => {
order.push(`reveal:${sessionId}`)
return true
}
})
).toEqual({ completed: true, structuredLaunch: true, primaryTabId: 'agent-session:session-1' })
expect(order).toEqual(['begin', 'reveal:session-1', 'open'])
})
it('reports an unknown outcome without starting a fallback terminal', async () => {
mocks.settleStructuredAgentLaunch.mockResolvedValue({
kind: 'visibility-unknown',
sessionId: 'session-1'
})
it('does not claim completion when the provisional opener is refused', () => {
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue(null)
await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({
completed: false,
structuredLaunch: true,
visibilityUnknown: true,
failed: false,
primaryTabId: null
})
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
expect(
beginDirectWorkItemStructuredLaunch({
plan: structuredPlan,
primaryTabId: 'setup-shell-tab',
beforeOpen: vi.fn()
})
).toEqual({ completed: false, structuredLaunch: true, primaryTabId: 'setup-shell-tab' })
})
it.each([
['failed', { kind: 'failed', error: new Error('x') }],
['cancelled', { kind: 'cancelled', sessionId: 'session-1' }]
])(
'drops the pre-launch tab on a %s settlement so nothing is pasted into it',
async (_kind, settlement) => {
mocks.settleStructuredAgentLaunch.mockResolvedValue(settlement)
await expect(
settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' })
).resolves.toEqual({
completed: false,
structuredLaunch: true,
visibilityUnknown: false,
failed: true,
primaryTabId: null
it('skips structured opening for non-structured routes', () => {
expect(
beginDirectWorkItemStructuredLaunch({
plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, route: 'legacy-native-chat' }),
primaryTabId: null,
beforeOpen: vi.fn()
})
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
}
)
it('skips the loop when the route is not structured', async () => {
await expect(
settleDirectWorkItemStructuredLaunch({
...baseArgs,
plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, route: 'legacy-native-chat' })
})
).resolves.toEqual({
completed: false,
structuredLaunch: false,
visibilityUnknown: false,
failed: false,
primaryTabId: null
})
expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled()
).toEqual({ completed: false, structuredLaunch: false, primaryTabId: null })
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).not.toHaveBeenCalled()
})
})
describe('markDirectWorkItemAgentTrusted', () => {
beforeEach(() => vi.clearAllMocks())
it('marks trust before a legacy terminal launch', async () => {
it('preflights trust only for the legacy terminal route', async () => {
await markDirectWorkItemAgentTrusted({
structuredLaunch: false,
agent: 'codex',
workspacePath: '/repo/worktree',
connectionId: 'ssh-1'
})
expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({
agent: 'codex',
workspacePath: '/repo/worktree',
@@ -168,14 +101,13 @@ describe('markDirectWorkItemAgentTrusted', () => {
})
})
it('leaves trust to the refusal fallback on the structured route', async () => {
it('leaves trust to the structured provider', async () => {
await markDirectWorkItemAgentTrusted({
structuredLaunch: true,
agent: 'codex',
workspacePath: '/repo/worktree',
connectionId: null
})
expect(mocks.preflightAgentTrust).not.toHaveBeenCalled()
})
})
@@ -1,18 +1,13 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { LaunchSource } from '../../../shared/telemetry-events'
import type { AppState } from '@/store/types'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
buildDirectWorkItemAgentStartupPlan,
buildDirectWorkItemStartupOpts
} from '@/lib/launch-work-item-direct-agent'
import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent'
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
export function buildDirectWorkItemStartup(args: {
agent: TuiAgent | null
@@ -84,8 +79,7 @@ export async function resolveDirectWorkItemAgent(args: {
}
}
/** Why: kept apart from the refusal fallback's preflight because it runs before
* launch on the legacy route only; structured chat has no TUI trust menu. */
/** Why: runs only before the legacy route; structured chat has no TUI trust menu. */
export async function markDirectWorkItemAgentTrusted(args: {
structuredLaunch: boolean
agent: TuiAgent | null
@@ -102,101 +96,35 @@ export async function markDirectWorkItemAgentTrusted(args: {
})
}
export async function settleDirectWorkItemStructuredLaunch(args: {
export function beginDirectWorkItemStructuredLaunch(args: {
plan: AgentSessionLaunchPlan | null
worktreeId: string
workspacePath: string
connectionId: string | null
primaryTabId: string | null
startupPlan: AgentStartupPlan | null
launchSource: LaunchSource
}): Promise<{
beforeOpen: (sessionId: string) => boolean | void
}): {
completed: boolean
structuredLaunch: boolean
visibilityUnknown: boolean
/** The structured launch ended without a surface; there is nothing for the legacy path to finish. */
failed: boolean
primaryTabId: string | null
}> {
} {
const { plan } = args
const notLaunched = (structuredLaunch: boolean) => ({
completed: false,
structuredLaunch,
visibilityUnknown: false,
failed: false,
primaryTabId: args.primaryTabId
})
if (plan?.route !== 'structured-native-chat') {
return notLaunched(false)
}
const { agent } = plan
// Why no tab: the pre-launch tab is the setup shell or default tab, never an agent tab, so
// handing it back would paste the prompt there.
const withoutAgentSurface = {
completed: false,
structuredLaunch: true,
visibilityUnknown: false,
failed: true,
primaryTabId: null
}
let settlement: Awaited<ReturnType<typeof plan.launch>>
try {
settlement = await plan.launch({
legacyFallback: async () => {
await preflightAgentTrust({
agent,
workspacePath: args.workspacePath,
connectionId: args.connectionId
})
const activation = activateAndRevealWorktree(args.worktreeId, {
sidebarRevealBehavior: 'auto',
createNewTerminalForStartup: true,
...buildDirectWorkItemStartupOpts(
agent,
args.startupPlan,
args.launchSource,
plan.promptDelivery === 'draft' ? plan.prompt : undefined
)
})
return { activation, primaryTabId: activation === false ? null : activation.primaryTabId }
}
})
} catch {
// Why: this runs outside the caller's try, so an escaped throw would surface as an unhandled
// rejection rather than the failure the caller already knows how to report.
return withoutAgentSurface
}
if (!settlement) {
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan,
hooks: {},
beforeOpen: args.beforeOpen
})
if (!launch) {
return notLaunched(true)
}
switch (settlement.kind) {
case 'structured':
return {
completed: true,
structuredLaunch: true,
visibilityUnknown: false,
failed: false,
primaryTabId: args.primaryTabId
}
case 'refused-then-legacy':
return {
completed: false,
structuredLaunch: false,
visibilityUnknown: false,
failed: false,
primaryTabId: settlement.primaryTabId
}
case 'visibility-unknown':
return {
completed: false,
structuredLaunch: true,
visibilityUnknown: true,
failed: false,
primaryTabId: args.primaryTabId
}
case 'failed':
case 'cancelled':
// Why: the launch layer already toasted the failure.
return withoutAgentSurface
return {
completed: true,
structuredLaunch: true,
primaryTabId: launch.tab.id
}
}
@@ -123,13 +123,13 @@ vi.mock('@/lib/launch-work-item-direct-agent-routing', async () => {
)
return {
...actual,
settleDirectWorkItemStructuredLaunch: vi.fn(actual.settleDirectWorkItemStructuredLaunch)
beginDirectWorkItemStructuredLaunch: vi.fn(actual.beginDirectWorkItemStructuredLaunch)
}
})
import { launchWorkItemDirect } from './launch-work-item-direct'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { settleDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { pickTuiAgent } from '../../../shared/tui-agent-selection'
@@ -561,11 +561,9 @@ describe('launchWorkItemDirect', () => {
// Why: activation seeded a plain shell (`tab-1`); a failed structured launch hands back no tab,
// so the PR body must not reach that shell where the Claude readiness heuristic would submit it
// — and callers hang irreversible follow-up work off a `true`, so this must not report success.
vi.mocked(settleDirectWorkItemStructuredLaunch).mockResolvedValueOnce({
vi.mocked(beginDirectWorkItemStructuredLaunch).mockReturnValueOnce({
completed: false,
structuredLaunch: true,
visibilityUnknown: false,
failed: true,
primaryTabId: null
})
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
@@ -587,8 +585,8 @@ describe('launchWorkItemDirect', () => {
})
).resolves.toBe(false)
expect(settleDirectWorkItemStructuredLaunch).toHaveBeenCalledWith(
expect.objectContaining({ primaryTabId: 'tab-1' })
expect(beginDirectWorkItemStructuredLaunch).toHaveBeenCalledWith(
expect.objectContaining({ primaryTabId: null, beforeOpen: expect.any(Function) })
)
expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled()
expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled()
+34 -30
View File
@@ -34,7 +34,7 @@ import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-typ
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { settleDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation'
import {
planAgentSessionLaunch,
@@ -165,6 +165,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
let effectiveAgent: TuiAgent | null = null
let draftLaunchedNatively = false
let plan: AgentSessionLaunchPlan | null = null
let structuredLaunchCompleted = false
const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId)
let startupPlanFailed = false
try {
@@ -229,26 +230,44 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
startupPlanFailed = launchPreparation.startupPlanFailed
plan = launchPreparation.plan
const activation = activateAndRevealWorktree(worktreeId, {
sidebarRevealBehavior: 'auto',
setup: result.setup,
defaultTabs: result.defaultTabs,
...(launchPreparation.structuredLaunch
? { providesInitialSurface: true }
: buildDirectWorkItemStartupOpts(
effectiveAgent,
startupPlan,
launchSource,
promptDelivery === 'draft' ? draftContent : undefined
))
const activationHolder: { value: ReturnType<typeof activateAndRevealWorktree> } = {
value: false
}
const revealWorkspace = (): boolean => {
activationHolder.value = activateAndRevealWorktree(worktreeId, {
sidebarRevealBehavior: 'auto',
setup: result.setup,
defaultTabs: result.defaultTabs,
...(launchPreparation.structuredLaunch
? { providesInitialSurface: true }
: buildDirectWorkItemStartupOpts(
effectiveAgent,
startupPlan,
launchSource,
promptDelivery === 'draft' ? draftContent : undefined
))
})
return activationHolder.value !== false
}
const structuredResult = beginDirectWorkItemStructuredLaunch({
plan,
primaryTabId: null,
beforeOpen: revealWorkspace
})
if (!structuredResult.structuredLaunch) {
revealWorkspace()
}
const activation = activationHolder.value
if (!activation) {
// Worktree vanished between create and activate — extremely unlikely but
// worth handling explicitly rather than silently dropping the draft.
toast.error(workspaceActivationErrorMessage())
return false
}
primaryTabId = activation.primaryTabId
structuredLaunchCompleted = structuredResult.completed
primaryTabId = structuredResult.completed
? structuredResult.primaryTabId
: activation.primaryTabId
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create workspace.'
toast.error(message)
@@ -257,24 +276,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
store.setSidebarOpen(true)
const structuredResult = await settleDirectWorkItemStructuredLaunch({
plan,
worktreeId,
workspacePath: worktreePath,
connectionId: repoConnectionId,
primaryTabId,
startupPlan,
launchSource
})
if (structuredResult.visibilityUnknown || structuredResult.failed) {
// Why: callers hang irreversible follow-up work off a `true` here, so a structured launch that
// opened no surface must not report the workspace as started.
return false
}
if (structuredResult.completed) {
if (structuredLaunchCompleted) {
return true
}
primaryTabId = structuredResult.primaryTabId
if (startupPlanFailed) {
toast.error(agentLaunchCommandErrorMessage())
@@ -11,13 +11,13 @@ import {
type OnboardingFolderAgentStartup
} from '@/lib/onboarding-folder-agent-startup'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
export type OnboardingFolderAgentLaunch = {
agent: TuiAgent | null
/** Planned before the folder workspace row exists; null when no default agent applies. */
plan: AgentSessionLaunchPlan | null
startup?: OnboardingFolderAgentStartup
fallbackStartup?: OnboardingFolderAgentStartup
}
/** Why: lives beside the launch, not the startup builder, because the store root imports that
@@ -47,7 +47,7 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: {
return {
agent,
plan,
...(plan.route === 'structured-native-chat' ? { fallbackStartup: startup } : { startup })
...(plan.route === 'structured-native-chat' ? {} : { startup })
}
}
@@ -71,18 +71,14 @@ export async function revealOnboardingFolderWithAgentLaunch(args: {
})
const { plan } = args.launch
const structured = plan?.route === 'structured-native-chat'
reveal(args.launch.startup, structured)
if (!structured) {
reveal(args.launch.startup)
return
}
// Why: the outcome is not consumed; the workspace is already revealed and the launch layer toasts.
await plan.launch(
{
legacyFallback: async () => {
const activation = reveal(args.launch.fallbackStartup)
return { activation, primaryTabId: activation === false ? null : activation.primaryTabId }
}
},
{ worktreeId: args.worktreeId }
)
beginStructuredAgentSessionProvisionalLaunch({
plan,
hooks: {},
target: { worktreeId: args.worktreeId },
beforeOpen: () => reveal(undefined, true) !== false
})
}
@@ -138,8 +138,6 @@ export type PendingWorktreeCreation = {
loaderVisible: boolean
error?: string
provisioningLog?: string
/** Existing worktree whose uncertain structured launch must be reconciled instead of recreated. */
structuredLaunchRecoveryWorktreeId?: string
request: WorktreeCreationRequest
}
@@ -170,7 +168,7 @@ export function findPendingLinkedWorkItemCreationId(
* loader and the sidebar row so the two never drift. Caller handles the error
* case; this only covers the in-progress states. */
export function getCreationProgressLabel(
entry: Pick<PendingWorktreeCreation, 'phase' | 'indeterminate'>
entry: Pick<PendingWorktreeCreation, 'phase' | 'indeterminate' | 'request'>
): string {
if (entry.phase === 'provisioning-vm') {
return 'Provisioning VM…'
@@ -120,7 +120,9 @@ describe('runQuickCommandInNewTab', () => {
})
it('launches agent quick commands through the programmatic agent prompt path', () => {
mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-agent' })
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'tab-agent' }
})
mockState.unifiedTabsByWorktree['repo::worktree'] = [
{ entityId: 'tab-agent', contentType: 'terminal', groupId: 'group-1' }
]
@@ -152,7 +154,9 @@ describe('runQuickCommandInNewTab', () => {
it('falls back to the active group when context-menu group resolution is missing', () => {
mockState.activeGroupIdByWorktree['repo::worktree'] = 'active-group'
mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-agent' })
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'tab-agent' }
})
const result = runQuickCommandInNewTab({
command: {
@@ -183,10 +187,11 @@ describe('runQuickCommandInNewTab', () => {
it('records history while a structured agent quick command publishes asynchronously', () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: null,
startupPlan: {} as never,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session'
surface: {
kind: 'local-agent-session',
tabId: 'agent-session:codex-session-1',
sessionId: 'codex-session-1'
}
})
const result = runQuickCommandInNewTab({
@@ -202,7 +207,7 @@ describe('runQuickCommandInNewTab', () => {
historyId: 'runtime:local\u0000agent-review'
})
expect(result).toBeNull()
expect(result).toEqual({ tabId: 'agent-session:codex-session-1' })
expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith(
'group-1',
'runtime:local\u0000agent-review'
@@ -211,10 +216,11 @@ describe('runQuickCommandInNewTab', () => {
it('uses the active group for structured history when the caller has no group', () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: null,
startupPlan: {} as never,
pasteDraftAfterLaunch: false,
focusAfterMenuClose: 'structured-session'
surface: {
kind: 'local-agent-session',
tabId: 'agent-session:codex-session-1',
sessionId: 'codex-session-1'
}
})
mockState.activeGroupIdByWorktree['repo::worktree'] = 'active-group'
@@ -71,17 +71,17 @@ export function runQuickCommandInNewTab({
launchSource: 'quick_command',
quickCommandLabel: command.label
})
if (result?.tabId) {
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, result.tabId, groupId)
if (
result?.surface.kind === 'local-terminal' ||
result?.surface.kind === 'local-agent-session'
) {
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, result.surface.tabId, groupId)
if (launchedGroupId) {
useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, historyId)
}
return { tabId: result.tabId }
return { tabId: result.surface.tabId }
}
// Structured launches publish their tab asynchronously and therefore do not
// return a local tab id; preserve quick-command recency immediately using
// the caller's group (or its active group fallback).
if (result?.focusAfterMenuClose === 'structured-session') {
if (result?.surface.kind === 'host-published') {
const launchedGroupId = resolveQuickCommandLaunchGroupId(worktreeId, groupId)
if (launchedGroupId) {
useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, historyId)
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { glob } from 'tinyglobby'
const REPO_ROOT = join(import.meta.dirname, '../../../..')
const THIS_FILE = 'src/renderer/src/lib/structured-agent-launch-no-terminal-fallback.test.ts'
describe('structured launch routing', () => {
it('has no production path from structured launch to a legacy terminal', async () => {
const files = await glob(['src/renderer/src/**/*.ts', 'src/renderer/src/**/*.tsx'], {
cwd: REPO_ROOT,
ignore: ['**/*.test.ts', '**/*.test.tsx', THIS_FILE]
})
const legacyPaths = files.filter((file) => {
const source = readFileSync(join(REPO_ROOT, file), 'utf8')
return [
'legacyFallback:',
'claimDefinitiveRefusalFallback',
"'refused-then-legacy'",
"'deadline-then-legacy'",
'STRUCTURED_AGENT_LAUNCH_DEADLINE_MS'
].some((marker) => source.includes(marker))
})
expect(legacyPaths).toEqual([])
})
})
@@ -1,28 +0,0 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { glob } from 'tinyglobby'
const REPO_ROOT = join(import.meta.dirname, '../../../..')
const CENSUS_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts'
const LOOP_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement.ts'
// Why: every structured entrypoint reaches the settle loop through the planner, which decided
// its route and delivery mode first. A second caller is a bypass of that decision, not a new
// entrypoint; entrypoints add a plan, never a loop call.
const SETTLE_LOOP_CALLERS = ['src/renderer/src/lib/agent-session-launch-plan.ts']
describe('structured launch settle loop caller census', () => {
it('pins every production settleStructuredAgentLaunch caller', async () => {
const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], {
cwd: REPO_ROOT,
ignore: ['**/*.test.ts', '**/*.test.tsx', CENSUS_FILE, LOOP_FILE]
})
const callers = files
.filter((file) =>
readFileSync(join(REPO_ROOT, file), 'utf8').includes('settleStructuredAgentLaunch(')
)
.sort()
expect(callers).toEqual([...SETTLE_LOOP_CALLERS].sort())
})
})
@@ -15,7 +15,10 @@ vi.mock('@/lib/launch-structured-agent-session', () => ({
}))
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { settleStructuredAgentLaunch } from './structured-agent-launch-settlement'
import {
beginStructuredAgentLaunchSettlement,
settleStructuredAgentLaunch
} from './structured-agent-launch-settlement'
type FakeLaunch = {
launchResult: Promise<unknown>
@@ -23,35 +26,16 @@ type FakeLaunch = {
promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }>
}
/** Mirrors the callers layer: the claim runs the callback once the launch is refused and resolves
* with whether it ran; a non-refusal settlement resolves it false without running it. */
function fakeLaunch(args: FakeLaunch) {
const releaseCallerAfterUnknownOutcome = vi.fn(() => true)
const claimDefinitiveRefusalFallback = vi.fn((fallback: () => Promise<void>) =>
args.launchResult.then(
() => false,
(error) =>
error instanceof StructuredAgentSessionCreateRefusalError
? Promise.resolve()
.then(fallback)
.then(() => true)
: false
)
)
mocks.startStructuredAgentLaunch.mockReturnValue({
sessionId: 'session-1',
launchResult: args.launchResult,
...(args.promptDeliveryResult ? { promptDeliveryResult: args.promptDeliveryResult } : {}),
isVisibilityUnknown: () => args.visibilityUnknown === true,
releaseCallerAfterUnknownOutcome,
claimDefinitiveRefusalFallback
releaseCallerAfterUnknownOutcome
})
return { releaseCallerAfterUnknownOutcome, claimDefinitiveRefusalFallback }
}
const fallbackResult = {
activation: { primaryTabId: 'fallback-tab' },
primaryTabId: 'fallback-tab'
return { releaseCallerAfterUnknownOutcome }
}
/** A caller-side cancel signal: `fire` is what the caller's store subscription would abort on. */
@@ -79,115 +63,42 @@ describe('settleStructuredAgentLaunch', () => {
promptDeliveryResult
})
const onStructuredReady = vi.fn()
const legacyFallback = vi.fn()
await expect(
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{ prompt: 'Fix' },
{
legacyFallback,
onStructuredReady
}
)
settleStructuredAgentLaunch('worktree-1', 'codex', { prompt: 'Fix' }, { onStructuredReady })
).resolves.toEqual({ kind: 'structured', sessionId: 'session-1', promptDeliveryResult })
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {
prompt: 'Fix'
})
expect(onStructuredReady).toHaveBeenCalledWith('session-1')
expect(legacyFallback).not.toHaveBeenCalled()
})
it('runs the legacy fallback exactly once after a definitive refusal', async () => {
it('returns the session identity before the launch settles', async () => {
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
fakeLaunch({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
launchResult: new Promise((resolve) => {
resolveLaunch = resolve
})
})
const legacyFallback = vi.fn().mockResolvedValue(fallbackResult)
const onStructuredReady = vi.fn()
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback, onStructuredReady })
).resolves.toEqual({ kind: 'refused-then-legacy', ...fallbackResult })
expect(legacyFallback).toHaveBeenCalledOnce()
expect(onStructuredReady).not.toHaveBeenCalled()
})
const handle = beginStructuredAgentLaunchSettlement('worktree-1', 'codex', {}, {})
it('carries a fallback that opened a tab without activating a workspace', async () => {
fakeLaunch({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
})
const promptDeliveryResult = Promise.resolve({ delivered: true, failureNotified: false })
const legacyFallback = vi
.fn()
.mockResolvedValue({ primaryTabId: 'new-tab', promptDeliveryResult })
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback })
).resolves.toEqual({
kind: 'refused-then-legacy',
primaryTabId: 'new-tab',
promptDeliveryResult
expect(handle.sessionId).toBe('session-1')
resolveLaunch({ sessionId: 'session-1', fence: 1 })
await expect(handle.settlement).resolves.toEqual({
kind: 'structured',
sessionId: 'session-1'
})
})
it('fails a refusal that has no legacy equivalent without claiming a fallback', async () => {
it('keeps a structured refusal on the structured failure path', async () => {
const error = new StructuredAgentSessionCreateRefusalError('unsupported')
const { claimDefinitiveRefusalFallback } = fakeLaunch({ launchResult: Promise.reject(error) })
fakeLaunch({ launchResult: Promise.reject(error) })
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
kind: 'failed',
error
})
// Why: a claimed no-op would tell the launch layer a terminal fallback was attempted.
expect(claimDefinitiveRefusalFallback).not.toHaveBeenCalled()
})
it('reports the surface of a legacy fallback that finished before the cancel', async () => {
fakeLaunch({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
})
const cancellation = fakeCancellation()
let finishFallback!: () => void
const legacyFallback = vi.fn(
() =>
new Promise<typeof fallbackResult>((resolve) => {
finishFallback = () => resolve(fallbackResult)
})
)
const settlement = settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{ legacyFallback, signal: cancellation.signal }
)
await vi.waitFor(() => expect(legacyFallback).toHaveBeenCalledOnce())
cancellation.fire()
finishFallback()
// Why: the fallback's terminal outlives the cancel, so its tab is the caller's real surface.
await expect(settlement).resolves.toEqual({
kind: 'cancelled',
sessionId: 'session-1',
fallback: fallbackResult
})
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
'worktree-1',
'session-1'
)
})
it('fails when the legacy fallback itself throws', async () => {
const fallbackError = new Error('no terminal')
fakeLaunch({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
})
const legacyFallback = vi.fn().mockRejectedValue(fallbackError)
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback })
).resolves.toEqual({ kind: 'failed', error: fallbackError })
})
it('reports an unknown outcome, releases the caller, and never runs the fallback', async () => {
@@ -195,25 +106,23 @@ describe('settleStructuredAgentLaunch', () => {
launchResult: Promise.reject(new Error('connection lost')),
visibilityUnknown: true
})
const legacyFallback = vi.fn()
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback })
).resolves.toEqual({ kind: 'visibility-unknown', sessionId: 'session-1' })
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
kind: 'visibility-unknown',
sessionId: 'session-1'
})
expect(releaseCallerAfterUnknownOutcome).toHaveBeenCalledOnce()
expect(legacyFallback).not.toHaveBeenCalled()
})
it('fails a non-refusal error whose outcome is known', async () => {
const error = new Error('boom')
const { releaseCallerAfterUnknownOutcome } = fakeLaunch({ launchResult: Promise.reject(error) })
const legacyFallback = vi.fn()
await expect(
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback })
).resolves.toEqual({ kind: 'failed', error })
await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({
kind: 'failed',
error
})
expect(releaseCallerAfterUnknownOutcome).not.toHaveBeenCalled()
expect(legacyFallback).not.toHaveBeenCalled()
})
it('returns cancelled after a successful launch without activating', async () => {
@@ -234,24 +143,20 @@ describe('settleStructuredAgentLaunch', () => {
expect(onStructuredReady).not.toHaveBeenCalled()
})
it('returns cancelled after a refusal without running the fallback', async () => {
it('lets cancellation win over a refusal', async () => {
fakeLaunch({
launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
})
const legacyFallback = vi.fn().mockResolvedValue(fallbackResult)
await expect(
settleStructuredAgentLaunch(
'worktree-1',
'codex',
{},
{
legacyFallback,
signal: fakeCancellation(true).signal
}
)
).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' })
expect(legacyFallback).not.toHaveBeenCalled()
})
it('cancels the launch eagerly, once, before the launch settles', async () => {
@@ -6,14 +6,6 @@ import {
type StructuredAgentLaunchOptions
} from '@/lib/structured-agent-session-launch'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import type { ActivateAndRevealResult } from '@/lib/worktree-activation'
export type StructuredAgentLegacyFallbackResult = {
/** Absent when the fallback opened a tab in an already-active workspace instead of activating one. */
activation?: ActivateAndRevealResult | false
primaryTabId: string | null
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
}
export type StructuredAgentLaunchSettlement =
| {
@@ -21,40 +13,32 @@ export type StructuredAgentLaunchSettlement =
sessionId: string
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
}
| ({ kind: 'refused-then-legacy' } & StructuredAgentLegacyFallbackResult)
| {
kind: 'cancelled'
sessionId: string
/** The legacy surface the refusal fallback had already opened when the cancel arrived; it
* outlives the cancel, so the caller must report its tab rather than the pre-launch one. */
fallback?: StructuredAgentLegacyFallbackResult
}
| { kind: 'visibility-unknown'; sessionId: string }
| { kind: 'failed'; error: unknown }
export type StructuredAgentLaunchHooks = {
/** What this flow did before structured chat existed: activate with a startup payload, set the
* first-message rename flag, run trust preflight. Runs at most once, only on definitive refusal.
* Resume has no legacy equivalent, so a refusal without this hook settles as `failed`. */
legacyFallback?: () => Promise<StructuredAgentLegacyFallbackResult>
onStructuredReady?: (sessionId: string) => void
/** Abort the moment the caller abandons the launch. The loop cancels on the event, not only by
* polling after awaits, so a staged prompt is discarded before it can reach the provider. */
signal?: AbortSignal
}
/**
* The one start / claim-refusal-fallback / await / branch loop every structured entrypoint shares.
* Callers decide the route before calling and consume the settlement; they never touch the launch
* handle themselves.
*/
export async function settleStructuredAgentLaunch(
export type StructuredAgentLaunchHandle = {
sessionId: string
settlement: Promise<StructuredAgentLaunchSettlement>
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
cancel: () => void
}
async function settleStartedStructuredAgentLaunch(
worktreeId: string,
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions,
launch: ReturnType<typeof startStructuredAgentLaunch>,
hooks: StructuredAgentLaunchHooks
): Promise<StructuredAgentLaunchSettlement> {
const launch = startStructuredAgentLaunch(worktreeId, agent, options)
const signal = hooks.signal
let cancelRequested = false
const isCancelled = (): boolean => cancelRequested || signal?.aborted === true
@@ -70,25 +54,9 @@ export async function settleStructuredAgentLaunch(
if (isCancelled()) {
cancelLaunch()
}
// Why: a holder, not a `let`: TS narrows a closure-assigned local to its initial null.
const fallback: { result: StructuredAgentLegacyFallbackResult | null } = { result: null }
const legacyFallback = hooks.legacyFallback
// Why: the claim resolves after the callback settles, so awaiting it below is what serialises
// "refused" and "the legacy surface is up". The callback returns nothing so that wait ends at
// activation, not at the end of a legacy paste that may be minutes away. Without a hook there is
// nothing to claim: a claimed no-op reads to the launch layer as "a terminal was attempted".
const refusalFallback = legacyFallback
? launch.claimDefinitiveRefusalFallback(async () => {
if (isCancelled()) {
return
}
fallback.result = await legacyFallback()
})
: null
const cancelled = (): StructuredAgentLaunchSettlement => ({
kind: 'cancelled',
sessionId: launch.sessionId,
...(fallback.result ? { fallback: fallback.result } : {})
sessionId: launch.sessionId
})
try {
const receipt = await launch.launchResult
@@ -106,27 +74,10 @@ export async function settleStructuredAgentLaunch(
return cancelled()
}
if (error instanceof StructuredAgentSessionCreateRefusalError) {
if (!refusalFallback) {
return { kind: 'failed', error }
}
const ran = await refusalFallback.then(
(value) => value,
(fallbackError: unknown) => ({ fallbackError })
)
if (isCancelled()) {
return cancelled()
}
if (typeof ran !== 'boolean') {
return { kind: 'failed', error: ran.fallbackError }
}
return ran && fallback.result
? { kind: 'refused-then-legacy', ...fallback.result }
: { kind: 'failed', error }
return { kind: 'failed', error }
}
if (launch.isVisibilityUnknown()) {
// Why: nobody awaits this caller once it returns, so a stale fallback closure must not fire
// if a later retry on the same identity reconciles into a refusal. The launch state itself
// stays pending so the badge shows "unknown" and the next click still reconciles.
// Why: the state stays pending for the unknown badge and retry, but this caller is done.
launch.releaseCallerAfterUnknownOutcome()
return { kind: 'visibility-unknown', sessionId: launch.sessionId }
}
@@ -135,3 +86,29 @@ export async function settleStructuredAgentLaunch(
signal?.removeEventListener('abort', cancelLaunch)
}
}
/** Exposes the durable identity before host acquisition so its chat can render immediately. */
export function beginStructuredAgentLaunchSettlement(
worktreeId: string,
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions,
hooks: StructuredAgentLaunchHooks
): StructuredAgentLaunchHandle {
const launch = startStructuredAgentLaunch(worktreeId, agent, options)
return {
sessionId: launch.sessionId,
settlement: settleStartedStructuredAgentLaunch(worktreeId, launch, hooks),
cancel: () => cancelStructuredAgentLaunch(worktreeId, launch.sessionId),
...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {})
}
}
/** Compatibility wrapper for callers that do not need the provisional identity. */
export function settleStructuredAgentLaunch(
worktreeId: string,
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions,
hooks: StructuredAgentLaunchHooks
): Promise<StructuredAgentLaunchSettlement> {
return beginStructuredAgentLaunchSettlement(worktreeId, agent, options, hooks).settlement
}
@@ -1,16 +1,8 @@
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import {
settleStructuredAgentLaunchPrompt,
type StructuredPromptDeliveryResult
} from '@/lib/structured-agent-session-launch-prompt'
import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
export type StructuredRefusalFallback = () =>
| void
| StructuredPromptDeliveryResult
| Promise<void | StructuredPromptDeliveryResult>
export type StructuredAgentLaunchOptions = {
prompt?: string
promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft'
@@ -22,110 +14,24 @@ export type StructuredAgentLaunchOptions = {
export type StructuredLaunchCaller = {
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
refusalFallback: {
callback: StructuredRefusalFallback | null
promise: Promise<boolean>
resolve: (ran: boolean) => void
reject: (error: unknown) => void
promptDeliveryPromise: Promise<StructuredPromptDeliveryResult | null>
resolvePromptDelivery: (result: StructuredPromptDeliveryResult | null) => void
started: boolean
settled: boolean
ran: boolean
}
}
export type StructuredLaunchCallerGroup = {
outcome: 'pending' | 'published' | 'failed' | 'refused' | 'unknown' | 'cancelled'
outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled'
entries: Set<StructuredLaunchCaller>
promptDeliveryResults: Set<Promise<StructuredPromptDeliveryResult>>
refusalSettlement: {
promise: Promise<boolean>
resolve: (ran: boolean) => void
reject: (error: unknown) => void
settled: boolean
failure: { error: unknown } | null
}
onSettled: () => void
}
export function createStructuredLaunchCallerGroup(): StructuredLaunchCallerGroup {
const refusalSettlement = Promise.withResolvers<boolean>()
return {
outcome: 'pending',
entries: new Set(),
promptDeliveryResults: new Set(),
refusalSettlement: {
promise: refusalSettlement.promise,
resolve: refusalSettlement.resolve,
reject: refusalSettlement.reject,
settled: false,
failure: null
},
onSettled: () => {}
}
}
function settleCallerWithoutFallback(caller: StructuredLaunchCaller): void {
if (caller.refusalFallback.settled) {
return
}
caller.refusalFallback.settled = true
caller.refusalFallback.resolve(false)
caller.refusalFallback.resolvePromptDelivery(null)
}
function finalizeRefusalSettlement(group: StructuredLaunchCallerGroup): void {
if (
group.outcome !== 'refused' ||
group.refusalSettlement.settled ||
[...group.entries].some((caller) => !caller.refusalFallback.settled)
) {
return
}
group.refusalSettlement.settled = true
if (group.refusalSettlement.failure) {
group.refusalSettlement.reject(group.refusalSettlement.failure.error)
} else {
group.refusalSettlement.resolve([...group.entries].some((caller) => caller.refusalFallback.ran))
}
group.onSettled()
}
function runCallerRefusalFallback(
group: StructuredLaunchCallerGroup,
caller: StructuredLaunchCaller
): void {
if (caller.refusalFallback.started || caller.refusalFallback.settled) {
return
}
caller.refusalFallback.started = true
const fallback = caller.refusalFallback.callback
if (!fallback) {
settleCallerWithoutFallback(caller)
finalizeRefusalSettlement(group)
return
}
void Promise.resolve()
.then(fallback)
.then(
(result) => {
caller.refusalFallback.ran = true
caller.refusalFallback.resolve(true)
caller.refusalFallback.resolvePromptDelivery(result ?? null)
},
(error) => {
group.refusalSettlement.failure ??= { error }
caller.refusalFallback.reject(error)
caller.refusalFallback.resolvePromptDelivery(null)
}
)
.finally(() => {
caller.refusalFallback.settled = true
finalizeRefusalSettlement(group)
})
}
function trackPromptDelivery(
group: StructuredLaunchCallerGroup,
promptDeliveryResult: Promise<StructuredPromptDeliveryResult>
@@ -144,89 +50,31 @@ export function addStructuredLaunchCaller(args: {
options: StructuredAgentLaunchOptions
stagedEntry: StructuredAgentSessionOutboxEntry | null
}): StructuredLaunchCaller {
const fallback = Promise.withResolvers<boolean>()
const fallbackPromptDelivery = Promise.withResolvers<StructuredPromptDeliveryResult | null>()
const caller: StructuredLaunchCaller = {
refusalFallback: {
callback: null,
promise: fallback.promise,
resolve: fallback.resolve,
reject: fallback.reject,
promptDeliveryPromise: fallbackPromptDelivery.promise,
resolvePromptDelivery: fallbackPromptDelivery.resolve,
started: false,
settled: false,
ran: false
}
}
const caller: StructuredLaunchCaller = {}
args.group.entries.add(caller)
const promptDeliveryResult = settleStructuredAgentLaunchPrompt({
launchResult: args.launchResult,
options: args.options,
stagedEntry: args.stagedEntry
})
caller.promptDeliveryResult = promptDeliveryResult?.catch(async (error) => {
if (error instanceof StructuredAgentSessionCreateRefusalError) {
return (
(await caller.refusalFallback.promptDeliveryPromise) ?? {
delivered: false,
failureNotified: true
}
)
}
return { delivered: false, failureNotified: true }
})
caller.promptDeliveryResult = promptDeliveryResult?.catch(() => ({
delivered: false,
failureNotified: true
}))
if (caller.promptDeliveryResult) {
trackPromptDelivery(args.group, caller.promptDeliveryResult)
}
if (['published', 'failed', 'cancelled'].includes(args.group.outcome)) {
settleCallerWithoutFallback(caller)
} else if (args.group.outcome === 'refused') {
queueMicrotask(() => runCallerRefusalFallback(args.group, caller))
}
return caller
}
export function settleStructuredLaunchCallersWithoutFallback(
export function settleStructuredLaunchCallers(
group: StructuredLaunchCallerGroup,
outcome: 'published' | 'failed' | 'cancelled'
): void {
group.outcome = outcome
for (const caller of group.entries) {
settleCallerWithoutFallback(caller)
}
if (!group.refusalSettlement.settled) {
group.refusalSettlement.settled = true
group.refusalSettlement.resolve(false)
}
group.onSettled()
}
export function settleStructuredLaunchCallersWithFallback(
group: StructuredLaunchCallerGroup
): void {
if (group.outcome === 'refused') {
return
}
group.outcome = 'refused'
for (const caller of group.entries) {
runCallerRefusalFallback(group, caller)
}
finalizeRefusalSettlement(group)
}
export function claimStructuredLaunchCallerFallback(
group: StructuredLaunchCallerGroup,
caller: StructuredLaunchCaller,
fallback: StructuredRefusalFallback
): Promise<boolean> {
caller.refusalFallback.callback ??= fallback
if (group.outcome === 'refused') {
runCallerRefusalFallback(group, caller)
}
return caller.refusalFallback.promise
}
export function releaseStructuredLaunchCallerAfterUnknownOutcome(
group: StructuredLaunchCallerGroup,
caller: StructuredLaunchCaller
@@ -234,7 +82,6 @@ export function releaseStructuredLaunchCallerAfterUnknownOutcome(
if (group.outcome !== 'unknown' || !group.entries.delete(caller)) {
return false
}
settleCallerWithoutFallback(caller)
group.onSettled()
return true
}
@@ -245,7 +92,6 @@ export function structuredLaunchCallersHavePendingWork(
return (
group.outcome === 'pending' ||
group.outcome === 'unknown' ||
group.promptDeliveryResults.size > 0 ||
(group.outcome === 'refused' && !group.refusalSettlement.settled)
group.promptDeliveryResults.size > 0
)
}
@@ -0,0 +1,159 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
import type { StructuredLaunchState } from './structured-agent-session-launch-registry'
import {
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
retireAbsentStructuredAgentSessionLaunchCancellationTombstones,
resetStructuredAgentLaunchRegistryForTests,
setStructuredLaunchState
} from './structured-agent-session-launch-registry'
import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
const WORKTREE_ID = 'repo-1::worktree-1'
const SESSION_ID = 'session-close-race'
function latePublication(): RuntimeMobileSessionTabsResult {
return {
worktree: WORKTREE_ID,
publicationEpoch: 'epoch-late',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `agent-session:${SESSION_ID}`,
activeTabType: 'agent-session',
tabs: [
{
type: 'agent-session',
id: `agent-session:${SESSION_ID}`,
title: 'Codex Chat',
sessionId: SESSION_ID,
agent: 'codex',
isActive: true
}
]
}
}
describe('structured launch cancellation retirement', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentLaunchRegistryForTests()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
runtime: {
call: vi.fn().mockResolvedValue({ ok: true, result: {} })
}
}
})
})
it('keeps a late publication suppressed until the cancelled launch settles', async () => {
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
const launchPromise = new Promise<{ sessionId: string; fence: number }>((resolve) => {
resolveLaunch = resolve
})
setStructuredLaunchState({
identity: `codex:${WORKTREE_ID}`,
intent: {
worktreeId: WORKTREE_ID,
sessionId: SESSION_ID,
agent: 'codex',
params: {
envelope: {
sessionId: SESSION_ID,
clientOperationId: 'operation-close-race',
expectedRuntimeFence: null,
payloadFingerprint: 'fingerprint-close-race'
},
worktree: `id:${WORKTREE_ID}`,
agent: 'codex'
}
},
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
},
promise: launchPromise,
visibilityUnknown: false,
cancelled: false
} satisfies StructuredLaunchState)
const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory()
expect(
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel)
).toBe(false)
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID)
const afterCancel = beginStructuredAgentSessionAuthoritativeInventory()
expect(
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterCancel)
).toBe(false)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
resolveLaunch({ sessionId: SESSION_ID, fence: 1 })
await Promise.resolve()
const suppressed = suppressCancelledStructuredSessionTabs(latePublication(), { kind: 'local' })
expect(suppressed.tabs).toEqual([])
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
expect(
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel)
).toBe(false)
const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory()
expect(
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterSettlement)
).toBe(true)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
false
)
})
it('drains a restored cancellation before a newer inventory retires it', async () => {
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID)
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
const close = Promise.withResolvers<void>()
const call = vi.fn(({ method }: { method: string }) => {
if (method === 'agentSession.close') {
return close.promise.then(() => ({ ok: true, result: { ok: true } }))
}
return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } })
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { runtime: { call } }
})
await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
expect(call.mock.calls.map(([request]) => request.method)).toEqual([
'agentSession.close',
'session.tabs.listAll'
])
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
// The close shares the host's session lane with create, so settlement drains a late attach.
close.resolve()
await close.promise
await new Promise((resolve) => setTimeout(resolve, 0))
await refreshLocalStructuredSessionTabs()
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
false
)
expect(
call.mock.calls.filter(([request]) => request.method === 'agentSession.close')
).toHaveLength(1)
})
})
@@ -0,0 +1,146 @@
import {
hasStructuredAgentLaunchCancellationTombstonePersisted,
markStructuredAgentLaunchCancelledPersisted,
readStructuredAgentLaunchCancellationTombstoneSessionIds,
retireAbsentStructuredAgentLaunchCancellationTombstonesPersisted,
retireStructuredAgentLaunchCancellationTombstonePersisted
} from './structured-agent-session-launch-persistence'
type CancellationRetirement = {
retireAfterInventory: number | null
cleanupStarted: boolean
restored: boolean
}
const cancellationRetirementBySessionId = new Map<string, CancellationRetirement>()
let authoritativeInventorySequence = 0
function restoreCancellationRetirementFences(): void {
for (const sessionId of readStructuredAgentLaunchCancellationTombstoneSessionIds()) {
if (!cancellationRetirementBySessionId.has(sessionId)) {
cancellationRetirementBySessionId.set(sessionId, {
// A tombstone loaded after reload has no proof that an old create settled.
retireAfterInventory: null,
cleanupStarted: false,
restored: true
})
}
}
}
export function resetStructuredAgentLaunchCancellationForTests(): void {
cancellationRetirementBySessionId.clear()
authoritativeInventorySequence = 0
}
/** Captured when an inventory request starts so a cancellation can reject older replies. */
export function beginStructuredAgentSessionAuthoritativeInventory(): number {
restoreCancellationRetirementFences()
authoritativeInventorySequence += 1
return authoritativeInventorySequence
}
/** Claims restored tombstones for best-effort host cleanup before an authoritative census. */
export function claimStructuredAgentLaunchCancellationCleanups(): readonly string[] {
restoreCancellationRetirementFences()
const claimed: string[] = []
for (const [sessionId, retirement] of cancellationRetirementBySessionId) {
if (retirement.restored && !retirement.cleanupStarted) {
retirement.cleanupStarted = true
claimed.push(sessionId)
}
}
return claimed
}
export function settleStructuredAgentLaunchCancellationCleanup(
sessionId: string,
succeeded: boolean
): void {
const retirement = cancellationRetirementBySessionId.get(sessionId)
if (!retirement) {
return
}
if (!succeeded) {
retirement.cleanupStarted = false
return
}
retirement.restored = false
// Inventories already in flight may have observed the pre-cleanup state.
retirement.retireAfterInventory = authoritativeInventorySequence + 1
}
export function startStructuredAgentLaunchCancellationCleanup(
cleanup: (sessionId: string) => Promise<unknown>
): void {
for (const sessionId of claimStructuredAgentLaunchCancellationCleanups()) {
void cleanup(sessionId).then(
() => settleStructuredAgentLaunchCancellationCleanup(sessionId, true),
(error: unknown) => {
settleStructuredAgentLaunchCancellationCleanup(sessionId, false)
console.warn('[structured-agent-launch] restored cancellation cleanup failed', error)
}
)
}
}
export function markStructuredAgentLaunchCancellation(
sessionId: string,
alreadyCancelled: boolean,
launchPromise?: Promise<unknown>
): void {
markStructuredAgentLaunchCancelledPersisted(sessionId)
if (launchPromise) {
const retirement: CancellationRetirement = {
retireAfterInventory: null,
cleanupStarted: false,
restored: false
}
cancellationRetirementBySessionId.set(sessionId, retirement)
const armRetirement = (): void => {
if (cancellationRetirementBySessionId.get(sessionId) === retirement) {
// Inventories started before the create settled cannot prove that it will not publish.
retirement.retireAfterInventory = authoritativeInventorySequence + 1
}
}
void launchPromise.then(armRetirement, armRetirement)
} else if (!alreadyCancelled) {
// No in-memory launch remains; the user close already issued best-effort host cleanup.
cancellationRetirementBySessionId.set(sessionId, {
retireAfterInventory: authoritativeInventorySequence + 1,
cleanupStarted: false,
restored: false
})
}
}
export function retireStructuredAgentLaunchCancellation(sessionId: string): void {
retireStructuredAgentLaunchCancellationTombstonePersisted(sessionId)
cancellationRetirementBySessionId.delete(sessionId)
}
export function retireAbsentStructuredAgentLaunchCancellations(
publishedSessionIds: ReadonlySet<string>,
authoritativeInventory: number
): boolean {
restoreCancellationRetirementFences()
const retainedSessionIds = new Set(publishedSessionIds)
for (const [sessionId, retirement] of cancellationRetirementBySessionId) {
if (
retirement.retireAfterInventory === null ||
authoritativeInventory < retirement.retireAfterInventory
) {
retainedSessionIds.add(sessionId)
}
}
const changed =
retireAbsentStructuredAgentLaunchCancellationTombstonesPersisted(retainedSessionIds)
if (changed) {
for (const sessionId of cancellationRetirementBySessionId.keys()) {
if (!hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId)) {
cancellationRetirementBySessionId.delete(sessionId)
}
}
}
return changed
}
@@ -2,7 +2,6 @@ import { toast } from 'sonner'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import { translate } from '@/i18n/i18n'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import {
StructuredAgentSessionLaunchCancelledError,
type StructuredAgentLaunchReceipt
@@ -11,34 +10,13 @@ import {
/** Why one toast per launch, not per caller: coalesced callers share the launch and its failure. */
export function trackStructuredLaunchFailureToast(
agent: AgentSessionHandleProvider,
launchResult: Promise<StructuredAgentLaunchReceipt>,
refusalSettlement: Promise<boolean>
launchResult: Promise<StructuredAgentLaunchReceipt>
): void {
void launchResult.catch(async (error) => {
if (error instanceof StructuredAgentSessionLaunchCancelledError) {
return
}
const agentLabel = structuredAgentLabel(agent)
if (
error instanceof StructuredAgentSessionCreateRefusalError &&
(await refusalSettlement.catch(() => false))
) {
// Why: the callback proves the fallback was attempted, not that its terminal became visible.
toast.message(
translate(
'components.native-chat.structuredSessionFellBackToTerminal',
"Structured chat isn't available"
),
{
description: translate(
'components.native-chat.structuredSessionFellBackToTerminalDescription',
'Orca tried to open a {{value0}} terminal instead.',
{ value0: agentLabel }
)
}
)
return
}
// Why: the raw error carries errnos and absolute paths; it belongs in the log, not the toast.
console.warn('[native-chat] structured launch failed', error)
toast.error(
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import {
hasStructuredAgentLaunchCancellationTombstonePersisted,
readStructuredAgentLaunchRecord,
resetStructuredAgentLaunchPersistenceForTests,
retireStructuredAgentLaunchCancellationTombstonePersisted,
writeStructuredAgentLaunchRecord,
markStructuredAgentLaunchCancelledPersisted
} from './structured-agent-session-launch-persistence'
describe('structured agent launch persistence', () => {
beforeEach(() => {
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
})
it('normalizes pending launches after a renderer reload', () => {
localStorage.setItem(
'orca:structuredAgentLaunches:v1',
JSON.stringify([
{
sessionId: 'codex_session',
agent: 'codex',
lifecycle: 'pending',
clientOperationId: 'operation-1',
payloadFingerprint: 'fingerprint-1',
expectedRuntimeFence: null
}
])
)
expect(readStructuredAgentLaunchRecord('codex_session')).toMatchObject({
lifecycle: 'visibility-unknown',
clientOperationId: 'operation-1'
})
})
it('stores only content-free identity and preserves operation identity', () => {
writeStructuredAgentLaunchRecord({
sessionId: 'claude_session',
agent: 'claude',
lifecycle: 'visibility-unknown',
clientOperationId: 'operation-2',
payloadFingerprint: 'fingerprint-2',
expectedRuntimeFence: null,
resumeFrom: { providerSessionId: 'provider-thread' }
})
const raw = localStorage.getItem('orca:structuredAgentLaunches:v1') ?? ''
expect(raw).toContain('claude_session')
expect(raw).toContain('operation-2')
expect(raw).not.toContain('prompt')
expect(raw).not.toContain('branch')
expect(raw).not.toContain('path')
expect(readStructuredAgentLaunchRecord('claude_session')?.clientOperationId).toBe('operation-2')
})
it('persists cancellation tombstones by session id and retires them', () => {
markStructuredAgentLaunchCancelledPersisted('codex_session')
expect(hasStructuredAgentLaunchCancellationTombstonePersisted('codex_session')).toBe(true)
expect(localStorage.getItem('orca:structuredAgentLaunchCancelledSessions:v1')).toBe(
'["codex_session"]'
)
expect(retireStructuredAgentLaunchCancellationTombstonePersisted('codex_session')).toBe(true)
expect(hasStructuredAgentLaunchCancellationTombstonePersisted('codex_session')).toBe(false)
})
})
@@ -0,0 +1,199 @@
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed'
export type StructuredAgentLaunchPersistedRecord = {
sessionId: string
agent: AgentSessionHandleProvider
lifecycle: StructuredAgentLaunchPersistedLifecycle
clientOperationId: string
payloadFingerprint: string
expectedRuntimeFence: number | null
resumeFrom?: StructuredAgentSessionResumeSource
}
const LAUNCH_STORAGE_KEY = 'orca:structuredAgentLaunches:v1'
const TOMBSTONE_STORAGE_KEY = 'orca:structuredAgentLaunchCancelledSessions:v1'
const records = new Map<string, StructuredAgentLaunchPersistedRecord>()
const tombstones = new Set<string>()
let loaded = false
function validRecord(value: unknown): value is StructuredAgentLaunchPersistedRecord {
if (!value || typeof value !== 'object') {
return false
}
if (
!('sessionId' in value) ||
!('agent' in value) ||
!('lifecycle' in value) ||
!('clientOperationId' in value) ||
!('payloadFingerprint' in value) ||
!('expectedRuntimeFence' in value)
) {
return false
}
const {
sessionId,
agent,
lifecycle,
clientOperationId,
payloadFingerprint,
expectedRuntimeFence
} = value
const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined
return (
typeof sessionId === 'string' &&
sessionId.length > 0 &&
(agent === 'claude' || agent === 'codex') &&
(lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') &&
typeof clientOperationId === 'string' &&
typeof payloadFingerprint === 'string' &&
(expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') &&
(resumeFrom === undefined ||
(typeof resumeFrom === 'object' &&
resumeFrom !== null &&
'providerSessionId' in resumeFrom &&
typeof resumeFrom.providerSessionId === 'string'))
)
}
function load(): void {
if (loaded) {
return
}
loaded = true
if (typeof localStorage === 'undefined') {
return
}
try {
const stored = JSON.parse(localStorage.getItem(LAUNCH_STORAGE_KEY) ?? '[]')
if (Array.isArray(stored)) {
for (const value of stored) {
if (validRecord(value)) {
records.set(value.sessionId, {
...value,
// A renderer reload cannot prove a pending request was delivered.
lifecycle: value.lifecycle === 'pending' ? 'visibility-unknown' : value.lifecycle
})
}
}
}
const storedTombstones = JSON.parse(localStorage.getItem(TOMBSTONE_STORAGE_KEY) ?? '[]')
if (Array.isArray(storedTombstones)) {
for (const value of storedTombstones) {
if (typeof value === 'string' && value.length > 0 && value.length <= 256) {
tombstones.add(value)
}
}
}
} catch {
console.warn('[structured-agent-launch] could not read persisted launch state')
}
}
function writeRecords(): void {
if (typeof localStorage === 'undefined') {
return
}
try {
if (records.size === 0) {
localStorage.removeItem(LAUNCH_STORAGE_KEY)
} else {
localStorage.setItem(LAUNCH_STORAGE_KEY, JSON.stringify([...records.values()]))
}
} catch {
// Why: persistence is recovery bookkeeping and must never block a launch.
console.warn('[structured-agent-launch] could not persist launch state')
}
}
function writeTombstones(): void {
if (typeof localStorage === 'undefined') {
return
}
try {
if (tombstones.size === 0) {
localStorage.removeItem(TOMBSTONE_STORAGE_KEY)
} else {
localStorage.setItem(TOMBSTONE_STORAGE_KEY, JSON.stringify([...tombstones]))
}
} catch {
// Why: persistence is recovery bookkeeping and must never block close.
console.warn('[structured-agent-launch] could not persist cancellation tombstones')
}
}
export function readStructuredAgentLaunchRecord(
sessionId: string
): StructuredAgentLaunchPersistedRecord | undefined {
load()
return records.get(sessionId)
}
export function writeStructuredAgentLaunchRecord(
record: StructuredAgentLaunchPersistedRecord
): void {
load()
records.set(record.sessionId, record)
writeRecords()
}
export function deleteStructuredAgentLaunchRecord(sessionId: string): void {
load()
if (records.delete(sessionId)) {
writeRecords()
}
}
export function markStructuredAgentLaunchCancelledPersisted(sessionId: string): void {
load()
records.delete(sessionId)
tombstones.add(sessionId)
writeRecords()
writeTombstones()
}
export function hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId: string): boolean {
load()
return tombstones.has(sessionId)
}
export function readStructuredAgentLaunchCancellationTombstoneSessionIds(): readonly string[] {
load()
return [...tombstones]
}
export function retireStructuredAgentLaunchCancellationTombstonePersisted(
sessionId: string
): boolean {
load()
const removed = tombstones.delete(sessionId)
if (removed) {
writeTombstones()
}
return removed
}
export function retireAbsentStructuredAgentLaunchCancellationTombstonesPersisted(
publishedSessionIds: ReadonlySet<string>
): boolean {
load()
let changed = false
for (const sessionId of tombstones) {
if (!publishedSessionIds.has(sessionId)) {
tombstones.delete(sessionId)
changed = true
}
}
if (changed) {
writeTombstones()
}
return changed
}
export function resetStructuredAgentLaunchPersistenceForTests(): void {
records.clear()
tombstones.clear()
loaded = false
}
@@ -27,6 +27,58 @@ export type StructuredLaunchPromptOptions = {
type LaunchReceipt = { sessionId: string; fence: number }
type SharedDispatchStart = {
promise: Promise<boolean>
started: boolean
}
// A provisional chat can mount before its launch settlement runs. Both paths own the same
// persisted entry, so share the in-flight admission by operation id instead of issuing two RPCs.
const inFlightDispatches = new Map<string, Promise<boolean>>()
function dispatchKey(sessionId: string, clientMessageId: string, fence: number): string {
return `${sessionId}:${clientMessageId}:${fence}`
}
export function getStructuredAgentLaunchPromptDispatch(
sessionId: string,
clientMessageId: string,
fence?: number
): Promise<boolean> | undefined {
if (fence !== undefined) {
return inFlightDispatches.get(dispatchKey(sessionId, clientMessageId, fence))
}
const prefix = `${sessionId}:${clientMessageId}:`
for (const [key, promise] of inFlightDispatches) {
if (key.startsWith(prefix)) {
return promise
}
}
return undefined
}
export function shareStructuredAgentLaunchPromptDispatch(
sessionId: string,
clientMessageId: string,
fence: number,
start: () => Promise<boolean>
): SharedDispatchStart {
const key = dispatchKey(sessionId, clientMessageId, fence)
const existing = inFlightDispatches.get(key)
if (existing) {
return { promise: existing, started: false }
}
const promise = Promise.resolve().then(start)
inFlightDispatches.set(key, promise)
const clear = (): void => {
if (inFlightDispatches.get(key) === promise) {
inFlightDispatches.delete(key)
}
}
void promise.then(clear, clear)
return { promise, started: true }
}
function mutateEntry(
entry: StructuredAgentSessionOutboxEntry,
update: StructuredAgentSessionLaunchPromptMutation
@@ -101,7 +153,14 @@ export function settleStructuredAgentLaunchPrompt(args: {
if (!args.stagedEntry) {
return { delivered: false, failureNotified: true }
}
const delivered = await dispatchStructuredLaunchPrompt(args.stagedEntry, receipt)
const entry = args.stagedEntry
const dispatch = shareStructuredAgentLaunchPromptDispatch(
entry.sessionId,
entry.clientMessageId,
receipt.fence,
() => dispatchStructuredLaunchPrompt(entry, receipt)
)
const delivered = await dispatch.promise
if (delivered) {
args.options.onPromptDelivered?.()
}
@@ -6,7 +6,6 @@ import {
} from '@/lib/launch-structured-agent-session'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { useAppStore } from '@/store'
export type StructuredAgentLaunchReceipt = { sessionId: string; fence: number }
@@ -32,10 +31,7 @@ function throwIfLaunchCancelled(state: StructuredLaunchRecoveryState): void {
}
async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Promise<void> {
if (hasAdoptedStructuredSession(state.intent)) {
return
}
const snapshots = await refreshLocalStructuredSessionTabs()
const snapshots = await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
throwIfLaunchCancelled(state)
const published = snapshots.some(
(snapshot) =>
@@ -44,24 +40,11 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro
(tab) => tab.type === 'agent-session' && tab.sessionId === state.intent.sessionId
)
)
if (!published && !hasAdoptedStructuredSession(state.intent)) {
if (!published) {
throw new Error('structured session tab publication unavailable')
}
}
function hasAdoptedStructuredSession(intent: StructuredAgentSessionLaunchIntent): boolean {
return Boolean(
useAppStore
.getState()
.unifiedTabsByWorktree[intent.worktreeId]?.some(
(tab) =>
tab.contentType === 'agent-session' &&
tab.entityId === intent.sessionId &&
tab.worktreeId === intent.worktreeId
)
)
}
async function recoverPublishedSessionReceipt(
state: StructuredLaunchRecoveryState
): Promise<StructuredAgentLaunchReceipt> {
@@ -1,212 +0,0 @@
// @vitest-environment happy-dom
// The duplicate-session guard: which create refusals may open a legacy terminal beside the chat.
// Deliberately exercises the real `launch-structured-agent-session`, because the classification
// under test lives there — mocking it out would assert nothing.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
refresh: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), message: vi.fn() }
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, options?: { value0?: string }) =>
fallback.replace('{{value0}}', options?.value0 ?? '')
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }],
getAgentLabel: () => 'Codex'
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
LOCAL_STRUCTURED_SESSION_OWNER: 'local',
refreshLocalStructuredSessionTabs: mocks.refresh
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ unifiedTabsByWorktree: {}, clearNativeChatLaunchDraft: () => {} }),
subscribe: () => () => {}
}
}))
import {
StructuredAgentSessionCreateRefusalError,
StructuredAgentSessionCreateUnknownOutcomeError
} from '@/lib/launch-structured-agent-session'
import {
getStructuredAgentLaunchStatus,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
type CreateReply = { ok: boolean; refusal?: { code: string; message: string } }
/** Replies to every `agentSession.create` in turn, repeating the last reply thereafter. */
function replyToCreates(...replies: CreateReply[]): void {
let index = 0
mocks.call.mockImplementation(async (_target: unknown, method: string, params: unknown) => {
if (method === 'agentSession.createSupport') {
return { supported: true }
}
if (method !== 'agentSession.create') {
return { ok: true, page: { fence: 1 } }
}
const reply = replies[Math.min(index, replies.length - 1)]
index += 1
if (!reply.ok) {
return reply
}
const sessionId = (params as { envelope: { sessionId: string } }).envelope.sessionId
return { ok: true, replayed: index > 1, fence: 1, value: { sessionId, fence: 1 } }
})
}
function refused(code: string): CreateReply {
return { ok: false, refusal: { code, message: `create refused: ${code}` } }
}
function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult {
return {
worktree: worktreeId,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'agent-session',
id: 'tab-1',
title: 'Codex',
sessionId,
agent: 'codex',
isActive: true
}
]
}
}
async function flushLaunchSettlement(): Promise<void> {
for (let i = 0; i < 20; i += 1) {
await Promise.resolve()
}
}
describe('legacy terminal fallback after a refused structured create', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mocks.refresh.mockResolvedValue([])
})
it.each(['agent_session_operation_unknown', 'agent_session_ownership_unknown'])(
'opens no sibling terminal when the host answers %s',
async (code) => {
const worktreeId = `wt-${code}`
const legacyTerminals: string[] = []
replyToCreates(refused(code))
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
void launch.claimDefinitiveRefusalFallback(() => {
legacyTerminals.push('legacy-terminal')
})
await expect(launch.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateUnknownOutcomeError
)
await flushLaunchSettlement()
// The host may already hold the session, so the user keeps exactly one thing: no chat it
// could confirm, and no terminal beside a session it could not rule out.
expect(legacyTerminals).toEqual([])
expect(launch.isVisibilityUnknown()).toBe(true)
expect(toast.error).toHaveBeenCalledOnce()
}
)
it('adopts the session an unknown outcome had already created, without a sibling', async () => {
const worktreeId = 'wt-unknown-then-published'
const legacyTerminals: string[] = []
replyToCreates(refused('agent_session_operation_unknown'), { ok: true })
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
const fallbackRan = launch.claimDefinitiveRefusalFallback(() => {
legacyTerminals.push('legacy-terminal')
})
mocks.refresh
.mockResolvedValueOnce([])
.mockResolvedValue([publishedSnapshot(worktreeId, launch.sessionId)])
await expect(launch.launchResult).resolves.toEqual({
sessionId: launch.sessionId,
fence: 1
})
await expect(fallbackRan).resolves.toBe(false)
await flushLaunchSettlement()
expect(legacyTerminals).toEqual([])
expect(toast.error).not.toHaveBeenCalled()
})
it('opens exactly one legacy terminal when the refusal is on the definitive allowlist', async () => {
const worktreeId = 'wt-unsupported'
const legacyTerminals: string[] = []
replyToCreates(refused('structured_agent_session_unsupported'))
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
const fallbackRan = launch.claimDefinitiveRefusalFallback(() => {
legacyTerminals.push('legacy-terminal')
})
await expect(launch.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await expect(fallbackRan).resolves.toBe(true)
await flushLaunchSettlement()
expect(legacyTerminals).toEqual(['legacy-terminal'])
// A proven "nothing was created" needs no replay, so the terminal is the only surface open.
expect(
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create')
).toHaveLength(1)
expect(launch.isVisibilityUnknown()).toBe(false)
})
it('opens exactly one legacy terminal when an older runtime has no create method', async () => {
const legacyTerminals: string[] = []
mocks.call.mockRejectedValue(
new RuntimeRpcCallError({
id: 'rpc-old-runtime',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: agentSession.create' }
})
)
const launch = startStructuredAgentLaunch('wt-old-runtime', 'codex')
const fallbackRan = launch.claimDefinitiveRefusalFallback(() => {
legacyTerminals.push('legacy-terminal')
})
await expect(launch.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await expect(fallbackRan).resolves.toBe(true)
expect(legacyTerminals).toEqual(['legacy-terminal'])
expect(mocks.call).toHaveBeenCalledOnce()
expect(getStructuredAgentLaunchStatus('wt-old-runtime', 'codex')).toBe('idle')
})
})
@@ -0,0 +1,298 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery'
import type {
StructuredAgentLaunchOptions,
StructuredLaunchCallerGroup
} from './structured-agent-session-launch-callers'
import {
deleteStructuredAgentLaunchRecord,
hasStructuredAgentLaunchCancellationTombstonePersisted,
readStructuredAgentLaunchRecord,
writeStructuredAgentLaunchRecord,
type StructuredAgentLaunchPersistedRecord
} from './structured-agent-session-launch-persistence'
import {
markStructuredAgentLaunchCancellation,
resetStructuredAgentLaunchCancellationForTests,
retireAbsentStructuredAgentLaunchCancellations,
retireStructuredAgentLaunchCancellation
} from './structured-agent-session-launch-cancellation'
export type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
/** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */
promptDelivery: StructuredAgentLaunchOptions['promptDelivery']
callers: StructuredLaunchCallerGroup
}
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
export type StructuredAgentSessionLaunchLifecycle =
| 'pending'
| 'visibility-unknown'
| 'failed'
| 'published'
| 'cancelled'
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchesBySessionId = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
export function resetStructuredAgentLaunchRegistryForTests(): void {
pendingStructuredLaunchesByIdentity.clear()
structuredLaunchesBySessionId.clear()
structuredLaunchListeners.clear()
resetStructuredAgentLaunchCancellationForTests()
}
export function notifyStructuredLaunchListeners(): void {
for (const state of pendingStructuredLaunchesByIdentity.values()) {
persistStructuredLaunchState(state)
}
for (const listener of structuredLaunchListeners) {
listener()
}
}
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
// Why keyed by agent: one worktree can hold a Claude and a Codex launch at once.
// Why keyed by conversation: a resume must not coalesce onto an unrelated blank launch.
export function structuredLaunchIdentity(
worktreeId: string,
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): string {
return resumeFrom
? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}`
: `${agent}:${worktreeId}`
}
export function getStructuredLaunchState(identity: string): StructuredLaunchState | undefined {
return pendingStructuredLaunchesByIdentity.get(identity)
}
export function getStructuredLaunchStateBySessionId(
sessionId: string
): StructuredLaunchState | undefined {
return structuredLaunchesBySessionId.get(sessionId)
}
export function setStructuredLaunchState(state: StructuredLaunchState): void {
pendingStructuredLaunchesByIdentity.set(state.identity, state)
structuredLaunchesBySessionId.set(state.intent.sessionId, state)
persistStructuredLaunchState(state)
}
export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchState): boolean {
if (pendingStructuredLaunchesByIdentity.get(state.identity) !== state) {
return false
}
pendingStructuredLaunchesByIdentity.delete(state.identity)
if (structuredLaunchesBySessionId.get(state.intent.sessionId) === state) {
structuredLaunchesBySessionId.delete(state.intent.sessionId)
}
deleteStructuredAgentLaunchRecord(state.intent.sessionId)
return true
}
function persistStructuredLaunchState(state: StructuredLaunchState): void {
const lifecycle = launchStateLifecycle(state)
if (lifecycle === 'published' || lifecycle === 'cancelled') {
deleteStructuredAgentLaunchRecord(state.intent.sessionId)
return
}
const { envelope, resumeFrom } = state.intent.params
const record: StructuredAgentLaunchPersistedRecord = {
sessionId: state.intent.sessionId,
agent: state.intent.agent,
lifecycle,
clientOperationId: envelope.clientOperationId,
payloadFingerprint: envelope.payloadFingerprint,
expectedRuntimeFence: envelope.expectedRuntimeFence,
...(resumeFrom ? { resumeFrom } : {})
}
writeStructuredAgentLaunchRecord(record)
}
export function getPersistedStructuredAgentLaunchRecord(
sessionId: string
): StructuredAgentLaunchPersistedRecord | undefined {
return readStructuredAgentLaunchRecord(sessionId)
}
export function structuredLaunchStates(): IterableIterator<StructuredLaunchState> {
return pendingStructuredLaunchesByIdentity.values()
}
function launchStateLifecycle(state: StructuredLaunchState): StructuredAgentSessionLaunchLifecycle {
if (state.cancelled || state.callers.outcome === 'cancelled') {
return 'cancelled'
}
if (state.callers.outcome === 'published') {
return 'published'
}
if (state.visibilityUnknown || state.callers.outcome === 'unknown') {
return 'visibility-unknown'
}
return state.callers.outcome === 'failed' ? 'failed' : 'pending'
}
function matchesLaunchWorktree(
state: StructuredLaunchState | undefined,
worktreeId: string
): boolean {
return state?.intent.worktreeId === worktreeId
}
export function getStructuredAgentSessionLaunchLifecycle(
worktreeId: string,
sessionId: string
): StructuredAgentSessionLaunchLifecycle | null {
if (hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, sessionId)) {
return 'cancelled'
}
const state = getStructuredLaunchStateBySessionId(sessionId)
if (state && matchesLaunchWorktree(state, worktreeId)) {
return launchStateLifecycle(state)
}
return getPersistedStructuredAgentLaunchRecord(sessionId)?.lifecycle ?? null
}
export function useStructuredAgentSessionLaunchLifecycle(
worktreeId: string,
sessionId: string
): StructuredAgentSessionLaunchLifecycle | null {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId),
() => null
)
}
export function shouldRetainStructuredAgentSessionLaunchTab(
worktreeId: string,
sessionId: string
): boolean {
const lifecycle = getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId)
return lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed'
}
export function markStructuredAgentSessionLaunchPublished(
worktreeId: string,
sessionId: string
): boolean {
const state = getStructuredLaunchStateBySessionId(sessionId)
if (!state) {
const persisted = getPersistedStructuredAgentLaunchRecord(sessionId)
if (!persisted) {
return false
}
deleteStructuredAgentLaunchRecord(sessionId)
notifyStructuredLaunchListeners()
return true
}
if (!matchesLaunchWorktree(state, worktreeId) || state.cancelled) {
return false
}
if (state.callers.outcome === 'published') {
return true
}
state.callers.outcome = 'published'
deleteStructuredAgentLaunchRecord(sessionId)
state.callers.onSettled()
notifyStructuredLaunchListeners()
return true
}
function markStructuredAgentSessionLaunchCancelledInternal(
worktreeId: string,
sessionId: string,
notify: boolean
): boolean {
const alreadyCancelled = hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId)
const state = getStructuredLaunchStateBySessionId(sessionId)
if (matchesLaunchWorktree(state, worktreeId) && state) {
markStructuredAgentLaunchCancellation(sessionId, alreadyCancelled, state.promise)
state.cancelled = true
state.callers.outcome = 'cancelled'
// The tombstone is the durable authority; drop the in-memory launch so bulk closes cannot
// retain a dead promise for the lifetime of the renderer.
deleteStructuredLaunchStateIfCurrent(state)
} else if (!alreadyCancelled) {
markStructuredAgentLaunchCancellation(sessionId, alreadyCancelled)
}
if (!alreadyCancelled && notify) {
notifyStructuredLaunchListeners()
}
return !alreadyCancelled
}
export function markStructuredAgentSessionLaunchCancelled(
worktreeId: string,
sessionId: string
): boolean {
return markStructuredAgentSessionLaunchCancelledInternal(worktreeId, sessionId, true)
}
/** Bulk workspace purges run inside a store updater; persist cancellation without notifying React. */
export function markStructuredAgentSessionLaunchCancelledSilently(
worktreeId: string,
sessionId: string
): boolean {
return markStructuredAgentSessionLaunchCancelledInternal(worktreeId, sessionId, false)
}
export function hasStructuredAgentSessionLaunchCancellationTombstone(
_worktreeId: string,
sessionId: string
): boolean {
return hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId)
}
export function retireStructuredAgentSessionLaunchCancellationTombstone(
worktreeId: string,
sessionId: string
): boolean {
if (!hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, sessionId)) {
return false
}
retireStructuredAgentLaunchCancellation(sessionId)
notifyStructuredLaunchListeners()
return true
}
export function retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
publishedSessionIds: ReadonlySet<string>,
authoritativeInventory: number
): boolean {
const changed = retireAbsentStructuredAgentLaunchCancellations(
publishedSessionIds,
authoritativeInventory
)
if (changed) {
notifyStructuredLaunchListeners()
}
return changed
}
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch for this pair, including adopted conversations, means a chat is starting here.
const states = [
getStructuredLaunchState(structuredLaunchIdentity(worktreeId, agent)),
...[...pendingStructuredLaunchesByIdentity.entries()]
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (states.length === 0) {
return 'idle'
}
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery'
const mocks = vi.hoisted(() => ({
abandonIntent: vi.fn(),
callStructuredAgentSession: vi.fn(),
launch: vi.fn(),
restoreIntent: vi.fn(),
retryIntent: vi.fn(),
seedDraft: vi.fn(),
clearDraft: vi.fn(),
refresh: vi.fn()
}))
vi.mock('@/lib/launch-structured-agent-session', () => {
class StructuredAgentSessionCreateRefusalError extends Error {}
return {
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
createStructuredAgentSessionLaunchIntent: vi.fn(),
launchStructuredAgentSession: mocks.launch,
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
StructuredAgentSessionCreateRefusalError
}
})
vi.mock('@/lib/structured-agent-session-launch-recovery', async () => {
const actual = await vi.importActual<typeof RecoveryModule>(
'@/lib/structured-agent-session-launch-recovery'
)
return { ...actual, launchAndReconcile: vi.fn(actual.launchAndReconcile) }
})
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
refreshLocalStructuredSessionTabs: mocks.refresh
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.callStructuredAgentSession
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
seedNativeChatLaunchDraft: mocks.seedDraft,
clearNativeChatLaunchDraft: mocks.clearDraft
})
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentLabel: (agent: string) => (agent === 'codex' ? 'Codex' : 'Claude')
}))
import { retryStructuredAgentSessionLaunch } from './structured-agent-session-launch'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
function launchIntent(worktreeId: string, sessionId: string): StructuredAgentSessionLaunchIntent {
return {
worktreeId,
sessionId,
agent: 'codex',
params: {
envelope: {
sessionId,
clientOperationId: 'operation-reloaded',
expectedRuntimeFence: null,
payloadFingerprint: 'fingerprint-reloaded'
},
worktree: `id:${worktreeId}`,
agent: 'codex'
}
}
}
function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult {
return {
worktree: worktreeId,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'agent-session',
id: 'tab-1',
title: 'Codex',
sessionId,
agent: 'codex',
isActive: true
}
]
}
}
async function flushLaunchSettlement(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await Promise.resolve()
}
}
describe('structured agent launch reload recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentLaunchRegistryForTests()
mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, page: { fence: 4 } })
mocks.restoreIntent.mockImplementation(
(args: {
worktreeId: string
sessionId: string
agent: 'claude' | 'codex'
clientOperationId: string
payloadFingerprint: string
expectedRuntimeFence: number | null
}) => ({
...launchIntent(args.worktreeId, args.sessionId),
agent: args.agent,
params: {
...launchIntent(args.worktreeId, args.sessionId).params,
agent: args.agent,
envelope: {
sessionId: args.sessionId,
clientOperationId: args.clientOperationId,
payloadFingerprint: args.payloadFingerprint,
expectedRuntimeFence: args.expectedRuntimeFence
}
}
})
)
})
it('retries a reload-interrupted launch with the persisted operation identity', async () => {
const worktreeId = 'wt-reload'
const sessionId = 'codex-reload-session'
localStorage.setItem(
'orca:structuredAgentLaunches:v1',
JSON.stringify([
{
sessionId,
agent: 'codex',
lifecycle: 'pending',
clientOperationId: 'operation-reloaded',
payloadFingerprint: 'fingerprint-reloaded',
expectedRuntimeFence: null
}
])
)
mocks.launch.mockResolvedValueOnce({ sessionId, fence: 4 })
mocks.refresh
.mockResolvedValueOnce([])
.mockResolvedValueOnce([publishedSnapshot(worktreeId, sessionId)])
expect(retryStructuredAgentSessionLaunch(worktreeId, sessionId)).toBe(true)
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledWith(
expect.objectContaining({
sessionId,
worktreeId,
params: expect.objectContaining({
envelope: expect.objectContaining({
clientOperationId: 'operation-reloaded',
payloadFingerprint: 'fingerprint-reloaded'
})
})
})
)
})
})
@@ -0,0 +1,44 @@
import { restoreStructuredAgentSessionLaunchIntent } from './launch-structured-agent-session'
import {
createStructuredLaunchCallerGroup,
type StructuredLaunchCallerGroup
} from './structured-agent-session-launch-callers'
import {
getPersistedStructuredAgentLaunchRecord,
setStructuredLaunchState,
structuredLaunchIdentity,
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
export function restorePersistedStructuredLaunchState(
worktreeId: string,
sessionId: string
): StructuredLaunchState | undefined {
const record = getPersistedStructuredAgentLaunchRecord(sessionId)
if (!record) {
return undefined
}
const intent = restoreStructuredAgentSessionLaunchIntent({
worktreeId,
sessionId: record.sessionId,
agent: record.agent,
clientOperationId: record.clientOperationId,
payloadFingerprint: record.payloadFingerprint,
expectedRuntimeFence: record.expectedRuntimeFence,
...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {})
})
const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup()
const state: StructuredLaunchState = {
identity: structuredLaunchIdentity(worktreeId, record.agent, record.resumeFrom),
intent,
promptDelivery: 'draft',
promise: Promise.resolve({ sessionId: record.sessionId, fence: 0 }),
visibilityUnknown: record.lifecycle === 'visibility-unknown',
cancelled: false,
onVisibilityChanged: undefined,
callers
}
callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown'
setStructuredLaunchState(state)
return state
}
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
getStructuredAgentLaunchStatus,
subscribeStructuredAgentLaunchStatus
} from './structured-agent-session-launch-registry'
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): ReturnType<typeof getStructuredAgentLaunchStatus> {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
() => 'idle'
)
}
@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery'
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
const mocks = vi.hoisted(() => ({
abandonIntent: vi.fn(),
callStructuredAgentSession: vi.fn(),
createIntent: vi.fn(),
retryIntent: vi.fn(),
restoreIntent: vi.fn(),
launch: vi.fn(),
seedDraft: vi.fn(),
clearDraft: vi.fn(),
@@ -27,6 +30,8 @@ vi.mock('@/lib/launch-structured-agent-session', () => {
class StructuredAgentSessionCreateRefusalError extends Error {}
return {
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
launchStructuredAgentSession: mocks.launch,
StructuredAgentSessionCreateRefusalError
@@ -77,17 +82,19 @@ vi.mock('@/lib/agent-catalog', () => ({
]
}))
import {
StructuredAgentSessionCreateRefusalError,
type StructuredAgentSessionLaunchIntent
} from '@/lib/launch-structured-agent-session'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { launchAndReconcile } from '@/lib/structured-agent-session-launch-recovery'
import {
cancelStructuredAgentLaunch,
getStructuredAgentSessionLaunchLifecycle,
hasStructuredAgentSessionLaunchCancellationTombstone,
retryStructuredAgentSessionLaunch,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
function launchIntent(
worktreeId: string,
@@ -141,12 +148,24 @@ describe('startStructuredAgentLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
resetStructuredAgentLaunchPersistenceForTests()
resetStructuredAgentLaunchRegistryForTests()
mocks.rendererTabs = {}
mocks.listeners.clear()
mocks.createIntent.mockImplementation((worktreeId: string, agent: 'claude' | 'codex') => {
const intent = launchIntent(worktreeId, `${agent}-session-${worktreeId}`)
return { ...intent, agent, params: { ...intent.params, agent } }
})
mocks.retryIntent.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => ({
...intent,
params: {
...intent.params,
envelope: {
...intent.params.envelope,
clientOperationId: `${intent.params.envelope.clientOperationId}-retry`
}
}
}))
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
page: { fence: 1 }
@@ -206,7 +225,7 @@ describe('startStructuredAgentLaunch', () => {
expect(readOutbox(intent.sessionId)).toEqual([])
})
it('clears the draft seed when the launch is definitively refused', async () => {
it('preserves the draft seed when the launch is definitively refused', async () => {
const worktreeId = 'wt-draft-refused'
const intent = launchIntent(worktreeId, 'refused-draft-session')
mocks.createIntent.mockReturnValueOnce(intent)
@@ -222,10 +241,11 @@ describe('startStructuredAgentLaunch', () => {
await flushLaunchSettlement()
expect(mocks.seedDraft).toHaveBeenCalledOnce()
expect(mocks.clearDraft).toHaveBeenCalledWith('structured-agent-session-refused-draft-session')
expect(mocks.clearDraft).not.toHaveBeenCalled()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBe('failed')
})
it('clears the draft seed when the launch fails with a known outcome', async () => {
it('preserves the draft seed when the launch fails with a known outcome', async () => {
const worktreeId = 'wt-draft-failed'
const intent = launchIntent(worktreeId, 'failed-draft-session')
mocks.createIntent.mockReturnValueOnce(intent)
@@ -241,7 +261,8 @@ describe('startStructuredAgentLaunch', () => {
await flushLaunchSettlement()
expect(mocks.seedDraft).toHaveBeenCalledOnce()
expect(mocks.clearDraft).toHaveBeenCalledWith('structured-agent-session-failed-draft-session')
expect(mocks.clearDraft).not.toHaveBeenCalled()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBe('failed')
})
it('clears the draft seed when the launch is cancelled', async () => {
@@ -292,13 +313,14 @@ describe('startStructuredAgentLaunch', () => {
it('keeps a Claude and a Codex launch in the same worktree apart', async () => {
const worktreeId = 'wt-two-agents'
mocks.launch.mockImplementation(async (intent: StructuredAgentSessionLaunchIntent) => {
mocks.rendererTabs[worktreeId] = [
...(mocks.rendererTabs[worktreeId] ?? []),
{ contentType: 'agent-session', entityId: intent.sessionId, worktreeId }
]
return { sessionId: intent.sessionId, fence: 1 }
})
mocks.launch.mockImplementation(async (intent: StructuredAgentSessionLaunchIntent) => ({
sessionId: intent.sessionId,
fence: 1
}))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, `claude-session-${worktreeId}`),
publishedSnapshot(worktreeId, `codex-session-${worktreeId}`)
])
const claude = startStructuredAgentLaunch(worktreeId, 'claude')
const codex = startStructuredAgentLaunch(worktreeId, 'codex')
@@ -317,16 +339,16 @@ describe('startStructuredAgentLaunch', () => {
it('names the refused agent in the launch failure toast', async () => {
const worktreeId = 'wt-claude-toast'
mocks.launch.mockRejectedValue(new Error('boom'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
startStructuredAgentLaunch(worktreeId, 'claude')
await flushLaunchSettlement()
expect(toast.error).toHaveBeenCalledWith('Could not open Claude chat', expect.anything())
expect(toast.message).not.toHaveBeenCalled()
})
it('completes from the host-emitted projection without listing inventory', async () => {
it('requires authoritative inventory even when a matching local tab exists', async () => {
const worktreeId = 'wt-host-frame'
const intent = launchIntent(worktreeId, 'session-host-frame')
mocks.createIntent.mockReturnValueOnce(intent)
@@ -339,12 +361,17 @@ describe('startStructuredAgentLaunch', () => {
}
return { sessionId: intent.sessionId, fence: 1 }
})
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledOnce()
expect(refreshLocalStructuredSessionTabs).not.toHaveBeenCalled()
expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledWith(undefined, {
authoritative: true
})
expect(toast.error).not.toHaveBeenCalled()
})
@@ -478,29 +505,6 @@ describe('startStructuredAgentLaunch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
it('does not claim a terminal opened when a definitive refusal fallback only settled', async () => {
const worktreeId = 'wt-refused-fallback-toast'
const intent = launchIntent(worktreeId)
const fallback = vi.fn().mockResolvedValue({ delivered: false, failureNotified: true })
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('refused'))
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
void launch.claimDefinitiveRefusalFallback(fallback)
await expect(launch.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await flushLaunchSettlement()
expect(toast.error).not.toHaveBeenCalled()
expect(toast.message).toHaveBeenCalledWith(
"Structured chat isn't available",
expect.objectContaining({
description: 'Orca tried to open a Codex terminal instead.'
})
)
})
it('keeps the raw error out of the failure toast', async () => {
const worktreeId = 'wt-no-raw-error-in-toast'
const intent = launchIntent(worktreeId)
@@ -566,16 +570,12 @@ describe('startStructuredAgentLaunch', () => {
const worktreeId = 'wt-replay-unknown'
const intent = launchIntent(worktreeId)
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValueOnce(new Error('offline')).mockImplementationOnce(async () => {
mocks.rendererTabs[worktreeId] = [
{ contentType: 'agent-session', entityId: intent.sessionId, worktreeId }
]
for (const listener of mocks.listeners) {
listener({ unifiedTabsByWorktree: mocks.rendererTabs })
}
return { sessionId: intent.sessionId, fence: 1 }
})
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValueOnce([]).mockResolvedValueOnce([])
mocks.launch
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([publishedSnapshot(worktreeId, intent.sessionId)])
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
@@ -589,13 +589,11 @@ describe('startStructuredAgentLaunch', () => {
it('reuses the queued prompt without a second delivery after unknown recovery', async () => {
const worktreeId = 'wt-unknown-prompt-retry'
const intent = launchIntent(worktreeId)
const firstFallback = vi.fn()
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'only once' })
const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback)
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
@@ -605,8 +603,6 @@ describe('startStructuredAgentLaunch', () => {
const retry = startStructuredAgentLaunch(worktreeId, 'codex')
await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
await expect(firstFallbackResult).resolves.toBe(false)
expect(firstFallback).not.toHaveBeenCalled()
expect(readOutbox(intent.sessionId)).toEqual([
expect.objectContaining({
body: expect.objectContaining({ blocks: [{ type: 'text', text: 'only once' }] })
@@ -619,39 +615,9 @@ describe('startStructuredAgentLaunch', () => {
)
})
it('runs only the retry fallback when unknown recovery is refused', async () => {
const worktreeId = 'wt-unknown-refusal-retry'
const intent = launchIntent(worktreeId)
const firstFallback = vi.fn()
const retryFallback = vi.fn()
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const first = startStructuredAgentLaunch(worktreeId, 'codex')
const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback)
await expect(first.launchResult).rejects.toThrow('offline')
expect(first.releaseCallerAfterUnknownOutcome()).toBe(true)
mocks.launch.mockRejectedValueOnce(
new StructuredAgentSessionCreateRefusalError('structured launch disabled')
)
const retry = startStructuredAgentLaunch(worktreeId, 'codex')
const retryFallbackResult = retry.claimDefinitiveRefusalFallback(retryFallback)
await expect(retry.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await expect(firstFallbackResult).resolves.toBe(false)
await expect(retryFallbackResult).resolves.toBe(true)
expect(firstFallback).not.toHaveBeenCalled()
expect(retryFallback).toHaveBeenCalledOnce()
})
it('never starts a sibling fallback for a post-attach unknown refusal', async () => {
it('keeps a post-attach unknown outcome reserved for reconciliation', async () => {
const worktreeId = 'wt-post-attach-unknown'
const intent = launchIntent(worktreeId)
const fallback = vi.fn()
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValue(
Object.assign(new Error('The chat may already exist.'), {
@@ -661,29 +627,25 @@ describe('startStructuredAgentLaunch', () => {
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
const launch = startStructuredAgentLaunch(worktreeId, 'codex')
const fallbackResult = launch.claimDefinitiveRefusalFallback(fallback)
await expect(launch.launchResult).rejects.toMatchObject({
code: 'agent_session_operation_unknown'
})
expect(launch.isVisibilityUnknown()).toBe(true)
expect(launch.releaseCallerAfterUnknownOutcome()).toBe(true)
await expect(fallbackResult).resolves.toBe(false)
expect(fallback).not.toHaveBeenCalled()
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledTimes(2)
})
it('releases a definitively refused intent so a new click can create a new identity', async () => {
it('retries a definitively refused launch with the same session and a new operation', async () => {
const worktreeId = 'wt-refused'
const first = launchIntent(worktreeId, 'session-first')
const second = launchIntent(worktreeId, 'session-second')
mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
mocks.createIntent.mockReturnValueOnce(first)
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: second.sessionId, fence: 1 })
.mockResolvedValueOnce({ sessionId: first.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, second.sessionId)
publishedSnapshot(worktreeId, first.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex')
@@ -691,34 +653,96 @@ describe('startStructuredAgentLaunch', () => {
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.retryIntent).toHaveBeenCalledWith(first)
expect(mocks.launch.mock.calls[0]?.[0]).toBe(first)
expect(mocks.launch.mock.calls[1]?.[0]).toBe(second)
expect(mocks.launch.mock.calls[1]?.[0]).toMatchObject({ sessionId: first.sessionId })
expect(mocks.launch.mock.calls[1]?.[0].params.envelope.clientOperationId).not.toBe(
first.params.envelope.clientOperationId
)
expect(toast.error).toHaveBeenCalledOnce()
})
it('abandons the focus intent when durable prompt staging refuses the launch', async () => {
it('does not stage the preserved launch prompt again on retry', async () => {
const worktreeId = 'wt-refused-prompt-retry'
const intent = launchIntent(worktreeId, 'session-refused-prompt-retry')
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'only once',
promptDelivery: 'draft'
})
await flushLaunchSettlement()
expect(readOutbox(intent.sessionId)).toEqual([])
const retry = startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'only once',
promptDelivery: 'draft'
})
await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
expect(readOutbox(intent.sessionId)).toEqual([])
expect(mocks.seedDraft).toHaveBeenCalledOnce()
})
it('retries a resumed launch by session id without reconstructing its identity', async () => {
const worktreeId = 'wt-resume-inline-retry'
const intent = {
...launchIntent(worktreeId, 'session-resume-inline-retry'),
params: {
...launchIntent(worktreeId, 'session-resume-inline-retry').params,
resumeFrom: { providerSessionId: 'provider-session-1' }
}
}
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'provider-session-1' }
})
await flushLaunchSettlement()
expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.retryIntent).toHaveBeenCalledWith(intent)
})
it('preserves the launch identity when durable prompt staging refuses', async () => {
const worktreeId = 'wt-stage-refused'
const intent = launchIntent(worktreeId)
const fallback = vi.fn()
mocks.createIntent.mockReturnValueOnce(intent)
const storageFailure = vi.spyOn(localStorage, 'setItem').mockImplementationOnce(() => {
throw new Error('storage unavailable')
})
const result = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'start this task' })
const fallbackResult = result.claimDefinitiveRefusalFallback(fallback)
await expect(result.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await expect(fallbackResult).resolves.toBe(true)
await expect(result.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mocks.launch).not.toHaveBeenCalled()
expect(mocks.abandonIntent).toHaveBeenCalledWith(intent)
expect(mocks.abandonIntent).not.toHaveBeenCalled()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBe('failed')
storageFailure.mockRestore()
})
it('runs each caller fallback and preserves its delivery result after refusal', async () => {
it('reports every coalesced prompt as undelivered after refusal', async () => {
const worktreeId = 'wt-refused-coalesced-prompts'
const intent = launchIntent(worktreeId)
let rejectLaunch!: (error: unknown) => void
@@ -729,36 +753,21 @@ describe('startStructuredAgentLaunch', () => {
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const firstFallback = vi.fn().mockResolvedValue({
delivered: true,
failureNotified: false
})
const secondFallback = vi.fn().mockResolvedValue({
delivered: false,
failureNotified: true
})
const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback)
const secondFallbackResult = second.claimDefinitiveRefusalFallback(secondFallback)
expect(readOutbox(intent.sessionId)).toHaveLength(2)
rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported'))
await expect(first.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await expect(firstFallbackResult).resolves.toBe(true)
await expect(secondFallbackResult).resolves.toBe(true)
await expect(first.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
delivered: false,
failureNotified: true
})
await expect(second.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(firstFallback).toHaveBeenCalledOnce()
expect(secondFallback).toHaveBeenCalledOnce()
expect(readOutbox(intent.sessionId)).toEqual([])
expect(readOutbox(intent.sessionId)).toHaveLength(2)
})
it('delivers a coalesced caller the way the launch it joined already decided', async () => {
@@ -800,40 +809,6 @@ describe('startStructuredAgentLaunch', () => {
)
})
it('never seeds a draft onto a launch that was already refused', async () => {
const worktreeId = 'wt-refused-coalesced-draft'
const intent = launchIntent(worktreeId, 'refused-coalesced-session')
let rejectLaunch!: (error: unknown) => void
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockImplementationOnce(
() => new Promise((_resolve, reject) => (rejectLaunch = reject))
)
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'first prompt',
promptDelivery: 'draft'
})
// An unfinished fallback keeps the refused launch reserved, so the next caller coalesces onto it.
void first.claimDefinitiveRefusalFallback(() => new Promise<void>(() => {}))
rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported'))
await expect(first.launchResult).rejects.toBeInstanceOf(
StructuredAgentSessionCreateRefusalError
)
await flushLaunchSettlement()
mocks.seedDraft.mockClear()
expect(mocks.createIntent).toHaveBeenCalledOnce()
startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'PR #1 context',
promptDelivery: 'draft'
})
// Why: nothing clears a seed written onto a refused launch, so it would outlive every tab.
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.seedDraft).not.toHaveBeenCalled()
expect(readOutbox(intent.sessionId)).toEqual([])
})
it('cancels a close-racing launch without retrying or toasting', async () => {
const worktreeId = 'wt-close-race'
const intent = launchIntent(worktreeId, 'session-close-race')
@@ -847,6 +822,13 @@ describe('startStructuredAgentLaunch', () => {
startStructuredAgentLaunch(worktreeId, 'codex')
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)).toBe(
true
)
const persistedTombstones =
localStorage.getItem('orca:structuredAgentLaunchCancelledSessions:v1') ?? ''
expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId))
expect(persistedTombstones).not.toContain(worktreeId)
resolveRefresh([])
await flushLaunchSettlement()
@@ -1,9 +1,9 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import {
abandonStructuredAgentSessionLaunchIntent,
createStructuredAgentSessionLaunchIntent,
retryStructuredAgentSessionLaunchIntent,
StructuredAgentSessionCreateRefusalError
} from '@/lib/launch-structured-agent-session'
import {
@@ -13,36 +13,47 @@ import {
import {
launchAndReconcile,
reconcileUnknownLaunch,
type StructuredAgentLaunchReceipt,
type StructuredLaunchRecoveryState
type StructuredAgentLaunchReceipt
} from '@/lib/structured-agent-session-launch-recovery'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import {
addStructuredLaunchCaller,
claimStructuredLaunchCallerFallback,
createStructuredLaunchCallerGroup,
releaseStructuredLaunchCallerAfterUnknownOutcome,
settleStructuredLaunchCallersWithFallback,
settleStructuredLaunchCallersWithoutFallback,
settleStructuredLaunchCallers,
structuredLaunchCallersHavePendingWork,
type StructuredAgentLaunchOptions,
type StructuredLaunchCaller,
type StructuredLaunchCallerGroup,
type StructuredRefusalFallback
type StructuredLaunchCaller
} from '@/lib/structured-agent-session-launch-callers'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import * as launchDraft from './structured-agent-session-launch-draft'
import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast'
import {
deleteStructuredLaunchStateIfCurrent,
getStructuredLaunchState,
getStructuredLaunchStateBySessionId,
markStructuredAgentSessionLaunchCancelled,
notifyStructuredLaunchListeners,
retireStructuredAgentSessionLaunchCancellationTombstone,
setStructuredLaunchState,
structuredLaunchIdentity,
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
/** Fixed by the caller that opened this launch; a joiner delivers its text the same way. Without
* that, two entrypoints racing one identity seed the composer AND submit. */
promptDelivery: StructuredAgentLaunchOptions['promptDelivery']
callers: StructuredLaunchCallerGroup
}
export {
getStructuredAgentLaunchStatus,
getStructuredAgentSessionLaunchLifecycle,
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
retireStructuredAgentSessionLaunchCancellationTombstone,
shouldRetainStructuredAgentSessionLaunchTab,
subscribeStructuredAgentLaunchStatus,
useStructuredAgentSessionLaunchLifecycle,
type StructuredAgentLaunchStatus,
type StructuredAgentSessionLaunchLifecycle
} from './structured-agent-session-launch-registry'
export { useStructuredAgentLaunchStatus } from './structured-agent-session-launch-status'
type StructuredLaunchStateResult = {
state: StructuredLaunchState
@@ -55,69 +66,6 @@ export type StructuredAgentLaunchResult = {
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
isVisibilityUnknown: () => boolean
releaseCallerAfterUnknownOutcome: () => boolean
claimDefinitiveRefusalFallback: (fallback: StructuredRefusalFallback) => Promise<boolean>
}
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
function notifyStructuredLaunchListeners(): void {
for (const listener of structuredLaunchListeners) {
listener()
}
}
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch for this pair, not just the blank one: adopting launches carry the conversation in
// their identity, and a caller asking "is a chat starting here" means all of them.
const states = [
pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)),
...[...pendingStructuredLaunchesByIdentity.entries()]
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (states.length === 0) {
return 'idle'
}
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
() => 'idle'
)
}
// Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared
// key would hand the second caller the first agent's intent.
//
// Why keyed by the adopted conversation as well: a joining caller is handed the EXISTING intent and
// contributes only its prompt, so without this a resume that arrives while a blank launch is pending
// would be silently dropped — the user would get a blank chat, or another row's conversation, with
// no error. A launch that adopts a conversation is a different launch.
function launchIdentity(
worktreeId: string,
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): string {
return resumeFrom
? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}`
: `${agent}:${worktreeId}`
}
/** What the outbox must carry: a draft goes to the composer seed instead. */
@@ -137,27 +85,28 @@ function joinLaunchDelivery(
}
function cleanupLaunchState(state: StructuredLaunchState): void {
if (pendingStructuredLaunchesByIdentity.get(state.identity) === state) {
pendingStructuredLaunchesByIdentity.delete(state.identity)
if (deleteStructuredLaunchStateIfCurrent(state)) {
notifyStructuredLaunchListeners()
}
}
function maybeCleanupLaunchState(state: StructuredLaunchState): void {
if (structuredLaunchCallersHavePendingWork(state.callers)) {
if (state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers)) {
return
}
cleanupLaunchState(state)
}
function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void {
if (state.callers.outcome === 'refused') {
function settleStructuredLaunchRefusal(state: StructuredLaunchState): void {
if (state.callers.outcome !== 'pending' && state.callers.outcome !== 'unknown') {
return
}
abandonStructuredAgentSessionLaunchIntent(state.intent)
discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId)
launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId)
settleStructuredLaunchCallersWithFallback(state.callers)
retireStructuredAgentSessionLaunchCancellationTombstone(
state.intent.worktreeId,
state.intent.sessionId
)
settleStructuredLaunchCallers(state.callers, 'failed')
notifyStructuredLaunchListeners()
}
function trackLaunchSettlement(
@@ -169,20 +118,27 @@ function trackLaunchSettlement(
if (state.promise !== promise) {
return
}
settleStructuredLaunchCallersWithoutFallback(state.callers, 'published')
maybeCleanupLaunchState(state)
settleStructuredLaunchCallers(state.callers, 'published')
notifyStructuredLaunchListeners()
},
(error) => {
if (state.promise !== promise || state.cancelled) {
if (state.promise !== promise) {
return
}
if (state.cancelled) {
if (error instanceof StructuredAgentSessionCreateRefusalError) {
retireStructuredAgentSessionLaunchCancellationTombstone(
state.intent.worktreeId,
state.intent.sessionId
)
}
return
}
if (error instanceof StructuredAgentSessionCreateRefusalError) {
settleDefinitiveRefusalFallback(state)
settleStructuredLaunchRefusal(state)
} else if (!state.visibilityUnknown) {
settleStructuredLaunchCallersWithoutFallback(state.callers, 'failed')
// Why: the seed lives under a tab that will never open; unknown keeps it for the retry.
launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId)
maybeCleanupLaunchState(state)
settleStructuredLaunchCallers(state.callers, 'failed')
notifyStructuredLaunchListeners()
} else {
state.callers.outcome = 'unknown'
notifyStructuredLaunchListeners()
@@ -191,43 +147,53 @@ function trackLaunchSettlement(
)
}
function resetStructuredLaunchCallers(state: StructuredLaunchState): void {
state.callers = createStructuredLaunchCallerGroup()
state.callers.onSettled = () => maybeCleanupLaunchState(state)
}
function restartStructuredLaunchState(state: StructuredLaunchState): void {
const wasVisibilityUnknown = state.visibilityUnknown
if (!wasVisibilityUnknown) {
state.intent = retryStructuredAgentSessionLaunchIntent(state.intent)
}
resetStructuredLaunchCallers(state)
state.callers.outcome = 'pending'
state.promise = wasVisibilityUnknown ? reconcileUnknownLaunch(state) : launchAndReconcile(state)
trackLaunchSettlement(state, state.promise)
trackStructuredLaunchFailureToast(state.intent.agent, state.promise)
notifyStructuredLaunchListeners()
}
function structuredAgentLaunchState(
worktreeId: string,
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = launchIdentity(worktreeId, agent, options.resumeFrom)
const existing = pendingStructuredLaunchesByIdentity.get(identity)
const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom)
const existing = getStructuredLaunchState(identity)
if (existing) {
if (existing.visibilityUnknown) {
existing.callers.outcome = 'pending'
existing.promise = reconcileUnknownLaunch(existing)
trackLaunchSettlement(existing, existing.promise)
trackStructuredLaunchFailureToast(
existing.intent.agent,
existing.promise,
existing.callers.refusalSettlement.promise
)
notifyStructuredLaunchListeners()
const retrying = existing.visibilityUnknown || existing.callers.outcome === 'failed'
if (retrying) {
restartStructuredLaunchState(existing)
}
const joined = joinLaunchDelivery(options, existing.promptDelivery)
const refusedAlready = existing.callers.outcome === 'refused'
const text = outboxPromptText(joined)
const stagedPrompt =
text && !refusedAlready
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
: null
// Why: a refused launch is already settled, so nothing would ever clear a new seed — it would
// live on under a tab that never opens.
if (!refusedAlready) {
// Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice.
const text = retrying ? '' : outboxPromptText(joined)
const stagedPrompt = text
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
: null
if (!retrying) {
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
}
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
const callerOptions = retrying ? joinedWithoutPrompt : joined
return {
state: existing,
caller: addStructuredLaunchCaller({
group: existing.callers,
launchResult: existing.promise,
options: joined,
options: callerOptions,
stagedEntry: stagedPrompt
})
}
@@ -270,14 +236,10 @@ function structuredAgentLaunchState(
options,
stagedEntry: stagedPrompt
})
pendingStructuredLaunchesByIdentity.set(identity, state)
setStructuredLaunchState(state)
notifyStructuredLaunchListeners()
trackLaunchSettlement(state, state.promise)
trackStructuredLaunchFailureToast(
state.intent.agent,
state.promise,
state.callers.refusalSettlement.promise
)
trackStructuredLaunchFailureToast(state.intent.agent, state.promise)
return {
state,
caller
@@ -285,16 +247,11 @@ function structuredAgentLaunchState(
}
export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean {
const state = [...pendingStructuredLaunchesByIdentity.values()].find(
(candidate) =>
candidate.intent.worktreeId === worktreeId && candidate.intent.sessionId === sessionId
)
const state = getStructuredLaunchStateBySessionId(sessionId)
if (!state) {
return false
}
state.cancelled = true
settleStructuredLaunchCallersWithoutFallback(state.callers, 'cancelled')
cleanupLaunchState(state)
markStructuredAgentSessionLaunchCancelled(worktreeId, sessionId)
discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId)
launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId)
abandonStructuredAgentSessionLaunchIntent(state.intent)
@@ -314,8 +271,20 @@ export function startStructuredAgentLaunch(
...(caller.promptDeliveryResult ? { promptDeliveryResult: caller.promptDeliveryResult } : {}),
isVisibilityUnknown: () => state.visibilityUnknown,
releaseCallerAfterUnknownOutcome: () =>
releaseStructuredLaunchCallerAfterUnknownOutcome(state.callers, caller),
claimDefinitiveRefusalFallback: (fallback) =>
claimStructuredLaunchCallerFallback(state.callers, caller, fallback)
releaseStructuredLaunchCallerAfterUnknownOutcome(state.callers, caller)
}
}
export function retryStructuredAgentSessionLaunch(worktreeId: string, sessionId: string): boolean {
const state =
getStructuredLaunchStateBySessionId(sessionId) ??
restorePersistedStructuredLaunchState(worktreeId, sessionId)
if (
state?.intent.worktreeId !== worktreeId ||
(!state.visibilityUnknown && state.callers.outcome !== 'failed')
) {
return false
}
restartStructuredLaunchState(state)
return true
}
@@ -0,0 +1,93 @@
import type { Tab } from '../../../shared/tab-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label'
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
import type {
AgentSessionLaunchPlan,
AgentSessionLaunchTarget
} from '@/lib/agent-session-launch-plan'
import type {
StructuredAgentLaunchHandle,
StructuredAgentLaunchHooks
} from '@/lib/structured-agent-launch-settlement'
import { useAppStore } from '@/store'
export type StructuredAgentSessionProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab }
export function openStructuredAgentSessionProvisionalTab(args: {
worktreeId: string
sessionId: string
agent: 'claude' | 'codex'
targetGroupId?: string
activate?: boolean
}): Tab {
const state = useAppStore.getState()
const tabId = structuredAgentSessionTabId(args.sessionId)
const existing = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find(
(candidate) =>
candidate.id === tabId &&
candidate.contentType === 'agent-session' &&
candidate.entityId === args.sessionId
)
if (existing) {
if (args.activate !== false) {
state.focusGroup(args.worktreeId, existing.groupId)
state.activateTab(existing.id, { worktreeId: args.worktreeId })
state.setActiveTabType('agent-session', args.worktreeId)
}
return existing
}
const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', {
id: tabId,
entityId: args.sessionId,
executionHostId: LOCAL_EXECUTION_HOST_ID,
agentSessionAgent: args.agent,
label: defaultAgentChatLabel(args.agent),
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
activate: args.activate !== false
})
if (args.activate !== false) {
state.setActiveTabType('agent-session', args.worktreeId)
}
return tab
}
/** Binds the synchronous launch identity to a chat tab before the caller yields. */
export function beginStructuredAgentSessionProvisionalLaunch(args: {
plan: AgentSessionLaunchPlan
hooks: StructuredAgentLaunchHooks
target?: AgentSessionLaunchTarget
targetGroupId?: string
activate?: boolean
/** Lets workspace flows reveal between final identity allocation and tab ownership. */
beforeOpen?: (sessionId: string) => boolean | void
}): StructuredAgentSessionProvisionalLaunch | null {
const handle = args.plan.begin(args.hooks, args.target)
if (!handle) {
return null
}
const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId
if (!worktreeId || (args.plan.agent !== 'claude' && args.plan.agent !== 'codex')) {
throw new Error('A provisional structured launch needs its workspace and provider.')
}
try {
if (args.beforeOpen?.(handle.sessionId) === false) {
handle.cancel()
return null
}
return {
...handle,
tab: openStructuredAgentSessionProvisionalTab({
worktreeId,
sessionId: handle.sessionId,
agent: args.plan.agent,
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
...(args.activate !== undefined ? { activate: args.activate } : {})
})
}
} catch (error) {
// Why: a launch without its owning surface would strand a late publication.
handle.cancel()
throw error
}
}
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { glob } from 'tinyglobby'
const REPO_ROOT = join(import.meta.dirname, '../../../..')
describe('structured launch copy', () => {
it('never exposes the removed starting-chat phase or label', async () => {
const files = await glob(
['src/renderer/src/**/*.ts', 'src/renderer/src/**/*.tsx', 'src/renderer/src/i18n/**/*.json'],
{
cwd: REPO_ROOT,
ignore: ['**/*.test.ts', '**/*.test.tsx']
}
)
const offenders = files.filter((file) => {
const source = readFileSync(join(REPO_ROOT, file), 'utf8')
return (
source.includes('starting-chat') ||
source.includes('Starting chat…') ||
/Starting (?:\{\{value0\}\}|Claude|Codex) chat…/.test(source)
)
})
expect(offenders).toEqual([])
})
})
@@ -4,10 +4,6 @@ import { describe, expect, it } from 'vitest'
const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow-execute.ts'), 'utf8')
const PREFLIGHT_SOURCE = readFileSync(join(__dirname, 'agent-trust-preflight.ts'), 'utf8')
const STRUCTURED_SOURCE = readFileSync(
join(__dirname, 'worktree-creation-structured-session.ts'),
'utf8'
)
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -18,7 +14,7 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
}
describe('worktree creation flow agent trust preflight', () => {
it('forwards the repo SSH connection id when pre-marking agent trust', () => {
it('forwards the repo SSH connection id when pre-marking terminal agent trust', () => {
const preflight = PREFLIGHT_SOURCE
const createFlow = sourceBetween(
FLOW_SOURCE,
@@ -32,7 +28,5 @@ describe('worktree creation flow agent trust preflight', () => {
expect(createFlow).toContain('repo.id === worktree.repoId')
expect(createFlow).toContain('await preflightAgentTrust({')
expect(createFlow).toContain('connectionId: repoConnectionId')
expect(STRUCTURED_SOURCE).toContain('await preflightAgentTrust({')
expect(STRUCTURED_SOURCE).toContain('workspacePath: worktree.path')
})
})

Some files were not shown because too many files have changed in this diff Show More