mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/reflect-get-proxy-trap-exception
This commit is contained in:
@@ -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) {
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+151
-119
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+140
@@ -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>
|
||||
)
|
||||
}
|
||||
+183
@@ -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)
|
||||
})
|
||||
})
|
||||
+221
@@ -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', () => {
|
||||
|
||||
+109
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
+37
-15
@@ -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
|
||||
|
||||
+166
@@ -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: [] })
|
||||
)
|
||||
})
|
||||
})
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+78
-29
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+22
-16
@@ -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)
|
||||
|
||||
+10
-10
@@ -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)) {
|
||||
|
||||
+6
-49
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user