mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
refactor(native-chat): run quick create through the shared settle loop
Quick create was the last entrypoint driving the launch handle itself, because its cancel lifecycle is real: when the creation is abandoned the structured launch must be cancelled immediately so a staged prompt never reaches the provider. The shared loop now takes a cancellation hook with an eager subscription plus a post-await check; it cancels the launch once, unsubscribes on settle, and reports cancelled without running the fallback. Quick create keeps its two-branch legacy fallback and retire-on-late-cancel. Also updates the surface-caller census for the onboarding launch module that step 2 introduced.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
startStructuredAgentLaunch: vi.fn()
|
||||
startStructuredAgentLaunch: vi.fn(),
|
||||
cancelStructuredAgentLaunch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/structured-agent-session-launch', () => ({
|
||||
startStructuredAgentLaunch: mocks.startStructuredAgentLaunch
|
||||
startStructuredAgentLaunch: mocks.startStructuredAgentLaunch,
|
||||
cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => ({
|
||||
@@ -52,6 +54,29 @@ const fallbackResult = {
|
||||
primaryTabId: 'fallback-tab'
|
||||
}
|
||||
|
||||
/** A caller-side cancel signal: `fire` is what the caller's store subscription would call. */
|
||||
function fakeCancellation(initiallyCancelled = false) {
|
||||
let cancelled = initiallyCancelled
|
||||
const unsubscribe = vi.fn()
|
||||
const listeners: (() => void)[] = []
|
||||
return {
|
||||
unsubscribe,
|
||||
fire: () => {
|
||||
cancelled = true
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
},
|
||||
hook: {
|
||||
isCancelled: () => cancelled,
|
||||
subscribe: vi.fn((onCancel: () => void) => {
|
||||
listeners.push(onCancel)
|
||||
return unsubscribe
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('settleStructuredAgentLaunch', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
@@ -173,10 +198,10 @@ describe('settleStructuredAgentLaunch', () => {
|
||||
{},
|
||||
{
|
||||
onStructuredReady,
|
||||
isCancelled: () => true
|
||||
cancellation: fakeCancellation(true).hook
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ kind: 'cancelled' })
|
||||
).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' })
|
||||
expect(onStructuredReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -193,10 +218,71 @@ describe('settleStructuredAgentLaunch', () => {
|
||||
{},
|
||||
{
|
||||
legacyFallback,
|
||||
isCancelled: () => true
|
||||
cancellation: fakeCancellation(true).hook
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ kind: 'cancelled' })
|
||||
).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' })
|
||||
expect(legacyFallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels the launch eagerly, once, before the launch settles', async () => {
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
fakeLaunch({
|
||||
launchResult: new Promise((resolve) => {
|
||||
resolveLaunch = resolve
|
||||
})
|
||||
})
|
||||
const cancellation = fakeCancellation()
|
||||
const onStructuredReady = vi.fn()
|
||||
|
||||
const settlement = settleStructuredAgentLaunch(
|
||||
'worktree-1',
|
||||
'codex',
|
||||
{},
|
||||
{ onStructuredReady, cancellation: cancellation.hook }
|
||||
)
|
||||
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
|
||||
cancellation.fire()
|
||||
cancellation.fire()
|
||||
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
|
||||
'worktree-1',
|
||||
'session-1'
|
||||
)
|
||||
expect(cancellation.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
resolveLaunch({ sessionId: 'session-1', fence: 1 })
|
||||
await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' })
|
||||
expect(onStructuredReady).not.toHaveBeenCalled()
|
||||
expect(cancellation.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('honours a cancellation that fired before the loop subscribed', async () => {
|
||||
fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) })
|
||||
const cancellation = fakeCancellation(true)
|
||||
|
||||
const settlement = settleStructuredAgentLaunch(
|
||||
'worktree-1',
|
||||
'codex',
|
||||
{},
|
||||
{ cancellation: cancellation.hook }
|
||||
)
|
||||
expect(cancellation.hook.subscribe).toHaveBeenCalledOnce()
|
||||
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
|
||||
'worktree-1',
|
||||
'session-1'
|
||||
)
|
||||
await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' })
|
||||
expect(cancellation.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('unsubscribes from the cancel signal once a launch settles without cancelling', async () => {
|
||||
fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) })
|
||||
const cancellation = fakeCancellation()
|
||||
|
||||
await expect(
|
||||
settleStructuredAgentLaunch('worktree-1', 'codex', {}, { cancellation: cancellation.hook })
|
||||
).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' })
|
||||
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
|
||||
expect(cancellation.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import {
|
||||
cancelStructuredAgentLaunch,
|
||||
startStructuredAgentLaunch,
|
||||
type StructuredAgentLaunchOptions
|
||||
} from '@/lib/structured-agent-session-launch'
|
||||
@@ -21,17 +22,24 @@ export type StructuredAgentLaunchSettlement =
|
||||
promptDeliveryResult?: Promise<StructuredPromptDeliveryResult>
|
||||
}
|
||||
| ({ kind: 'refused-then-legacy' } & StructuredAgentLegacyFallbackResult)
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'cancelled'; sessionId: string }
|
||||
| { kind: 'visibility-unknown'; sessionId: string }
|
||||
| { kind: 'failed'; error: unknown }
|
||||
|
||||
export type StructuredAgentLaunchCancellation = {
|
||||
isCancelled: () => boolean
|
||||
/** Fires the moment the caller abandons the launch. The loop cancels eagerly on it so a staged
|
||||
* prompt is discarded before it can reach the provider; a token read only after awaits is late. */
|
||||
subscribe: (onCancel: () => void) => () => void
|
||||
}
|
||||
|
||||
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
|
||||
isCancelled?: () => boolean
|
||||
cancellation?: StructuredAgentLaunchCancellation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,8 +53,21 @@ export async function settleStructuredAgentLaunch(
|
||||
options: StructuredAgentLaunchOptions,
|
||||
hooks: StructuredAgentLaunchHooks
|
||||
): Promise<StructuredAgentLaunchSettlement> {
|
||||
const isCancelled = (): boolean => hooks.isCancelled?.() === true
|
||||
const launch = startStructuredAgentLaunch(worktreeId, agent, options)
|
||||
let cancelRequested = false
|
||||
const isCancelled = (): boolean => cancelRequested || hooks.cancellation?.isCancelled() === true
|
||||
const cancelLaunch = (): void => {
|
||||
if (cancelRequested) {
|
||||
return
|
||||
}
|
||||
cancelRequested = true
|
||||
cancelStructuredAgentLaunch(worktreeId, launch.sessionId)
|
||||
}
|
||||
const unsubscribe = hooks.cancellation?.subscribe(cancelLaunch)
|
||||
// Why: the caller may have been abandoned between its own check and this subscription.
|
||||
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 }
|
||||
// Why: the claim resolves after the callback settles, so awaiting it below is what serialises
|
||||
@@ -58,10 +79,14 @@ export async function settleStructuredAgentLaunch(
|
||||
}
|
||||
fallback.result = await hooks.legacyFallback()
|
||||
})
|
||||
const cancelled = (): StructuredAgentLaunchSettlement => ({
|
||||
kind: 'cancelled',
|
||||
sessionId: launch.sessionId
|
||||
})
|
||||
try {
|
||||
const receipt = await launch.launchResult
|
||||
if (isCancelled()) {
|
||||
return { kind: 'cancelled' }
|
||||
return cancelled()
|
||||
}
|
||||
hooks.onStructuredReady?.(receipt.sessionId)
|
||||
return {
|
||||
@@ -71,7 +96,7 @@ export async function settleStructuredAgentLaunch(
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCancelled()) {
|
||||
return { kind: 'cancelled' }
|
||||
return cancelled()
|
||||
}
|
||||
if (error instanceof StructuredAgentSessionCreateRefusalError) {
|
||||
const ran = await refusalFallback.then(
|
||||
@@ -79,7 +104,7 @@ export async function settleStructuredAgentLaunch(
|
||||
(fallbackError: unknown) => ({ fallbackError })
|
||||
)
|
||||
if (isCancelled()) {
|
||||
return { kind: 'cancelled' }
|
||||
return cancelled()
|
||||
}
|
||||
if (typeof ran !== 'boolean') {
|
||||
return { kind: 'failed', error: ran.fallbackError }
|
||||
@@ -96,5 +121,7 @@ export async function settleStructuredAgentLaunch(
|
||||
return { kind: 'visibility-unknown', sessionId: launch.sessionId }
|
||||
}
|
||||
return { kind: 'failed', error }
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest'
|
||||
const SURFACE_PROVIDING_CALLERS = [
|
||||
'src/renderer/src/components/editor/check-annotation-open.ts',
|
||||
'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx',
|
||||
'src/renderer/src/components/sidebar/NonGitFolderDialog.tsx',
|
||||
'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts',
|
||||
'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts',
|
||||
@@ -19,7 +18,7 @@ const SURFACE_PROVIDING_CALLERS = [
|
||||
'src/renderer/src/lib/launch-work-item-direct.ts',
|
||||
'src/renderer/src/lib/worktree-creation-flow-execute.ts',
|
||||
'src/renderer/src/lib/workspace-port-actions.ts',
|
||||
'src/renderer/src/store/repos/repo-add-actions.ts'
|
||||
'src/renderer/src/lib/onboarding-folder-agent-launch.ts'
|
||||
]
|
||||
|
||||
// The activation seam itself: declares the option and forwards it into the tombstone gate.
|
||||
|
||||
@@ -10,7 +10,11 @@ const mocks = vi.hoisted(() => ({
|
||||
cancelStructuredAgentLaunch: vi.fn(),
|
||||
closeStructuredAgentSession: vi.fn(),
|
||||
callRuntimeRpc: vi.fn(),
|
||||
activateStructuredAgentSessionById: vi.fn()
|
||||
activateStructuredAgentSessionById: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn(),
|
||||
preflightAgentTrust: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -47,23 +51,65 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-trust-preflight', () => ({
|
||||
preflightAgentTrust: vi.fn()
|
||||
preflightAgentTrust: mocks.preflightAgentTrust
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => ({
|
||||
StructuredAgentSessionCreateRefusalError: class extends Error {}
|
||||
}))
|
||||
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { launchStructuredWorktreeSession } from './worktree-creation-structured-session'
|
||||
|
||||
const request = {
|
||||
repoId: 'repo-1',
|
||||
name: 'routing-recovery',
|
||||
setupDecision: 'run' as const,
|
||||
agent: 'codex' as const,
|
||||
pendingFirstAgentMessageRename: true,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: 'Fix the route',
|
||||
quickTelemetry: null
|
||||
}
|
||||
|
||||
const idle = { accepted: true, cancelled: false, visibilityUnknown: false }
|
||||
|
||||
/** Mirrors the callers layer: a refusal runs the claimed fallback once and resolves true. */
|
||||
function refusedLaunch(sessionId = 'session-refused') {
|
||||
const launchResult = Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId,
|
||||
launchResult,
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn((fallback: () => Promise<void>) =>
|
||||
launchResult.catch(() =>
|
||||
Promise.resolve()
|
||||
.then(fallback)
|
||||
.then(() => true)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function storeWithWorktree() {
|
||||
mocks.state = {
|
||||
pendingWorktreeCreations: { 'creation-1': {} },
|
||||
allWorktrees: () => [{ id: 'worktree-1', path: '/tmp/worktree-1' }],
|
||||
repos: [{ id: 'repo-1', connectionId: 'ssh-1' }],
|
||||
updateWorktreeMeta: mocks.updateWorktreeMeta
|
||||
} as unknown as typeof mocks.state
|
||||
}
|
||||
|
||||
describe('launchStructuredWorktreeSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -71,6 +117,259 @@ describe('launchStructuredWorktreeSession', () => {
|
||||
mocks.listener = null
|
||||
mocks.closeStructuredAgentSession.mockResolvedValue('closed')
|
||||
mocks.callRuntimeRpc.mockResolvedValue(undefined)
|
||||
mocks.updateWorktreeMeta.mockResolvedValue(undefined)
|
||||
mocks.preflightAgentTrust.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('activates the structured session once it is published', async () => {
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
|
||||
})
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
).resolves.toEqual({ ...idle, activation: false, primaryTabId: null })
|
||||
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {
|
||||
prompt: 'Fix the route'
|
||||
})
|
||||
expect(mocks.activateStructuredAgentSessionById).toHaveBeenCalledExactlyOnceWith({
|
||||
worktreeId: 'worktree-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
|
||||
expect(mocks.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not activate a published session when the user has moved on', async () => {
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
|
||||
})
|
||||
|
||||
await launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: false,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: 'tab-existing'
|
||||
})
|
||||
expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries an unknown launch with no prompt so the outbox is not re-staged', async () => {
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
|
||||
})
|
||||
|
||||
await launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null,
|
||||
recoverUnknownLaunch: true
|
||||
})
|
||||
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {})
|
||||
})
|
||||
|
||||
it('returns cancelled without starting a launch when the creation is already gone', async () => {
|
||||
mocks.state = { pendingWorktreeCreations: {} }
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
).resolves.toEqual({ ...idle, cancelled: true, activation: false, primaryTabId: null })
|
||||
expect(mocks.startStructuredAgentLaunch).not.toHaveBeenCalled()
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards the launch eagerly when the creation is dismissed while it is pending', async () => {
|
||||
const launchResult = new Promise<never>(() => {})
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
launchResult,
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn(() => launchResult)
|
||||
})
|
||||
|
||||
void launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = { pendingWorktreeCreations: {} }
|
||||
mocks.listener?.(mocks.state)
|
||||
mocks.listener?.(mocks.state)
|
||||
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
|
||||
'worktree-1',
|
||||
'session-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to an activated terminal after a definitive refusal', async () => {
|
||||
storeWithWorktree()
|
||||
refusedLaunch()
|
||||
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'terminal-tab' })
|
||||
const startup = { kind: 'agent', agent: 'codex', prompt: 'Fix the route' }
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: startup as never,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...idle,
|
||||
accepted: false,
|
||||
activation: { primaryTabId: 'terminal-tab' },
|
||||
primaryTabId: 'terminal-tab'
|
||||
})
|
||||
expect(mocks.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', {
|
||||
pendingFirstAgentMessageRename: true
|
||||
})
|
||||
expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({
|
||||
agent: 'codex',
|
||||
workspacePath: '/tmp/worktree-1',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
createNewTerminalForStartup: true,
|
||||
startup
|
||||
})
|
||||
expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled()
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
expect(mocks.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('seeds a background terminal after a refusal when the user has moved on', async () => {
|
||||
storeWithWorktree()
|
||||
refusedLaunch()
|
||||
mocks.ensureWorktreeHasInitialTerminal.mockReturnValue('background-tab')
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: false,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...idle,
|
||||
accepted: false,
|
||||
activation: false,
|
||||
primaryTabId: 'background-tab'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
mocks.state,
|
||||
'worktree-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ activateCreatedTabs: false, createNewTerminalForStartup: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('stops the fallback mid-way when the creation is dismissed and retires nothing', async () => {
|
||||
storeWithWorktree()
|
||||
refusedLaunch()
|
||||
mocks.preflightAgentTrust.mockImplementation(async () => {
|
||||
mocks.state = { ...mocks.state, pendingWorktreeCreations: {} }
|
||||
mocks.listener?.(mocks.state)
|
||||
})
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...idle,
|
||||
accepted: false,
|
||||
cancelled: true,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a known failure as accepted with the caller surface untouched', async () => {
|
||||
mocks.startStructuredAgentLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
launchResult: Promise.reject(new Error('boom')),
|
||||
isVisibilityUnknown: () => false,
|
||||
releaseCallerAfterUnknownOutcome: vi.fn(),
|
||||
claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false))
|
||||
})
|
||||
|
||||
await expect(
|
||||
launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
fallbackStartupOpt: undefined,
|
||||
activation: { primaryTabId: 'tab-1' } as never,
|
||||
primaryTabId: 'tab-1'
|
||||
})
|
||||
).resolves.toEqual({ ...idle, activation: { primaryTabId: 'tab-1' }, primaryTabId: 'tab-1' })
|
||||
expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled()
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels and retires a session when its pending creation is dismissed', async () => {
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useAppStore } from '@/store'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
|
||||
import {
|
||||
cancelStructuredAgentLaunch,
|
||||
startStructuredAgentLaunch
|
||||
} from '@/lib/structured-agent-session-launch'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
settleStructuredAgentLaunch,
|
||||
type StructuredAgentLegacyFallbackResult
|
||||
} from '@/lib/structured-agent-launch-settlement'
|
||||
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
|
||||
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
|
||||
@@ -23,6 +22,17 @@ export type WorktreeCreationStructuredSessionResult = {
|
||||
primaryTabId: string | null
|
||||
}
|
||||
|
||||
type LaunchStructuredWorktreeSessionArgs = {
|
||||
creationId: string
|
||||
request: WorktreeCreationRequest
|
||||
worktreeId: string
|
||||
shouldActivateOnCompletion: boolean
|
||||
fallbackStartupOpt: WorktreeStartupPayload | undefined
|
||||
activation: ActivateAndRevealResult | false
|
||||
primaryTabId: string | null
|
||||
recoverUnknownLaunch?: boolean
|
||||
}
|
||||
|
||||
async function retireCancelledStructuredSession(
|
||||
worktreeId: string,
|
||||
sessionId: string
|
||||
@@ -36,95 +46,48 @@ async function retireCancelledStructuredSession(
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function launchStructuredWorktreeSession(args: {
|
||||
creationId: string
|
||||
request: WorktreeCreationRequest
|
||||
worktreeId: string
|
||||
shouldActivateOnCompletion: boolean
|
||||
fallbackStartupOpt: WorktreeStartupPayload | undefined
|
||||
activation: ActivateAndRevealResult | false
|
||||
primaryTabId: string | null
|
||||
recoverUnknownLaunch?: boolean
|
||||
}): Promise<WorktreeCreationStructuredSessionResult> {
|
||||
let { activation, primaryTabId } = args
|
||||
let accepted = true
|
||||
let visibilityUnknown = false
|
||||
const agent = args.request.agent
|
||||
if (!isAgentSessionHandleProvider(agent)) {
|
||||
return { accepted, cancelled: false, visibilityUnknown, activation, primaryTabId }
|
||||
}
|
||||
if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) {
|
||||
return { accepted, cancelled: true, visibilityUnknown, activation, primaryTabId }
|
||||
}
|
||||
|
||||
const launch = startStructuredAgentLaunch(
|
||||
args.worktreeId,
|
||||
agent,
|
||||
args.recoverUnknownLaunch
|
||||
? {}
|
||||
: {
|
||||
prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt,
|
||||
...(args.request.launchDraftPrompt ? { promptDelivery: 'draft' as const } : {})
|
||||
}
|
||||
)
|
||||
let cancelled = false
|
||||
const cancelLaunch = (): void => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
cancelled = true
|
||||
cancelStructuredAgentLaunch(args.worktreeId, launch.sessionId)
|
||||
}
|
||||
const unsubscribe = useAppStore.subscribe((state) => {
|
||||
if (!state.pendingWorktreeCreations[args.creationId]) {
|
||||
cancelLaunch()
|
||||
}
|
||||
})
|
||||
if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) {
|
||||
cancelLaunch()
|
||||
}
|
||||
const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => {
|
||||
accepted = false
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (args.request.pendingFirstAgentMessageRename) {
|
||||
await useAppStore
|
||||
.getState()
|
||||
.updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const worktree = useAppStore
|
||||
/** What quick create did before structured chat: rename flag, trust preflight, then a terminal. */
|
||||
async function openLegacyWorktreeSurface(
|
||||
args: LaunchStructuredWorktreeSessionArgs,
|
||||
isCancelled: () => boolean
|
||||
): Promise<StructuredAgentLegacyFallbackResult> {
|
||||
const unchanged = { activation: args.activation, primaryTabId: args.primaryTabId }
|
||||
if (args.request.pendingFirstAgentMessageRename) {
|
||||
await useAppStore
|
||||
.getState()
|
||||
.allWorktrees?.()
|
||||
.find((candidate) => candidate.id === args.worktreeId)
|
||||
if (args.request.agent && worktree?.path) {
|
||||
const repoConnectionId = useAppStore
|
||||
.getState()
|
||||
.repos.find((repo) => repo.id === args.request.repoId)?.connectionId
|
||||
await preflightAgentTrust({
|
||||
agent: args.request.agent,
|
||||
workspacePath: worktree.path,
|
||||
connectionId: repoConnectionId
|
||||
})
|
||||
}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (args.shouldActivateOnCompletion) {
|
||||
const fallbackActivation = activateAndRevealWorktree(args.worktreeId, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
createNewTerminalForStartup: true,
|
||||
...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {})
|
||||
})
|
||||
activation = fallbackActivation
|
||||
primaryTabId = fallbackActivation === false ? null : fallbackActivation.primaryTabId
|
||||
return
|
||||
}
|
||||
primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
.updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (isCancelled()) {
|
||||
return unchanged
|
||||
}
|
||||
const worktree = useAppStore
|
||||
.getState()
|
||||
.allWorktrees?.()
|
||||
.find((candidate) => candidate.id === args.worktreeId)
|
||||
if (args.request.agent && worktree?.path) {
|
||||
const repoConnectionId = useAppStore
|
||||
.getState()
|
||||
.repos.find((repo) => repo.id === args.request.repoId)?.connectionId
|
||||
await preflightAgentTrust({
|
||||
agent: args.request.agent,
|
||||
workspacePath: worktree.path,
|
||||
connectionId: repoConnectionId
|
||||
})
|
||||
}
|
||||
if (isCancelled()) {
|
||||
return unchanged
|
||||
}
|
||||
if (args.shouldActivateOnCompletion) {
|
||||
const activation = activateAndRevealWorktree(args.worktreeId, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
createNewTerminalForStartup: true,
|
||||
...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {})
|
||||
})
|
||||
return { activation, primaryTabId: activation === false ? null : activation.primaryTabId }
|
||||
}
|
||||
return {
|
||||
primaryTabId: ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
args.worktreeId,
|
||||
args.fallbackStartupOpt,
|
||||
@@ -133,35 +96,73 @@ export async function launchStructuredWorktreeSession(args: {
|
||||
undefined,
|
||||
{ activateCreatedTabs: false, createNewTerminalForStartup: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const receipt = await launch.launchResult
|
||||
if (cancelled) {
|
||||
await retireCancelledStructuredSession(args.worktreeId, launch.sessionId)
|
||||
return { accepted, cancelled, visibilityUnknown, activation, primaryTabId }
|
||||
}
|
||||
if (args.shouldActivateOnCompletion) {
|
||||
activateStructuredAgentSessionById({
|
||||
worktreeId: args.worktreeId,
|
||||
sessionId: receipt.sessionId
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
await retireCancelledStructuredSession(args.worktreeId, launch.sessionId)
|
||||
return { accepted, cancelled, visibilityUnknown, activation, primaryTabId }
|
||||
}
|
||||
if (error instanceof StructuredAgentSessionCreateRefusalError) {
|
||||
await refusalFallback
|
||||
} else {
|
||||
visibilityUnknown = launch.isVisibilityUnknown()
|
||||
if (visibilityUnknown) {
|
||||
launch.releaseCallerAfterUnknownOutcome()
|
||||
export async function launchStructuredWorktreeSession(
|
||||
args: LaunchStructuredWorktreeSessionArgs
|
||||
): Promise<WorktreeCreationStructuredSessionResult> {
|
||||
const { activation, primaryTabId } = args
|
||||
const settled = { accepted: true, cancelled: false, visibilityUnknown: false }
|
||||
const agent = args.request.agent
|
||||
if (!isAgentSessionHandleProvider(agent)) {
|
||||
return { ...settled, activation, primaryTabId }
|
||||
}
|
||||
const isCancelled = (): boolean =>
|
||||
!useAppStore.getState().pendingWorktreeCreations[args.creationId]
|
||||
if (isCancelled()) {
|
||||
return { ...settled, cancelled: true, activation, primaryTabId }
|
||||
}
|
||||
let refused = false
|
||||
const settlement = await settleStructuredAgentLaunch(
|
||||
args.worktreeId,
|
||||
agent,
|
||||
args.recoverUnknownLaunch
|
||||
? {}
|
||||
: {
|
||||
prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt,
|
||||
...(args.request.launchDraftPrompt ? { promptDelivery: 'draft' as const } : {})
|
||||
},
|
||||
{
|
||||
cancellation: {
|
||||
isCancelled,
|
||||
subscribe: (onCancel) =>
|
||||
useAppStore.subscribe((state) => {
|
||||
if (!state.pendingWorktreeCreations[args.creationId]) {
|
||||
onCancel()
|
||||
}
|
||||
})
|
||||
},
|
||||
legacyFallback: () => {
|
||||
refused = true
|
||||
return openLegacyWorktreeSurface(args, isCancelled)
|
||||
},
|
||||
onStructuredReady: (sessionId) => {
|
||||
if (args.shouldActivateOnCompletion) {
|
||||
activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
unsubscribe()
|
||||
)
|
||||
switch (settlement.kind) {
|
||||
case 'cancelled':
|
||||
// Why: a refusal means no session exists on the host, so there is nothing to retire.
|
||||
if (!refused) {
|
||||
await retireCancelledStructuredSession(args.worktreeId, settlement.sessionId)
|
||||
}
|
||||
return { ...settled, accepted: !refused, cancelled: true, activation, primaryTabId }
|
||||
case 'refused-then-legacy':
|
||||
return {
|
||||
...settled,
|
||||
accepted: false,
|
||||
activation: settlement.activation ?? activation,
|
||||
primaryTabId: settlement.primaryTabId
|
||||
}
|
||||
case 'visibility-unknown':
|
||||
return { ...settled, visibilityUnknown: true, activation, primaryTabId }
|
||||
case 'structured':
|
||||
case 'failed':
|
||||
// Why: a failed launch has always reported as accepted here; the launch layer toasts it.
|
||||
return { ...settled, activation, primaryTabId }
|
||||
}
|
||||
return { accepted, cancelled, visibilityUnknown, activation, primaryTabId }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user