mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(native-chat): send launch prompts and worker tasks through the retrying start (#33832)
* Make opening launch prompts own the retrying chat start * test: observe launch inventory through surface publication * fix: keep blank chat launch prompts unsent * fix: bound launch seed filtering and update CLI replay fixture * fix: preserve encoded launch selections after filtering
This commit is contained in:
@@ -58,10 +58,6 @@ function harness(options: {
|
||||
calls.push('createTerminalAgent')
|
||||
return { handle: 'term_1', ...carried(args.startupPrompt) }
|
||||
})
|
||||
const deliverStructuredPrompt = vi.fn(async () => {
|
||||
calls.push('deliverStructuredPrompt')
|
||||
return 'msg-1'
|
||||
})
|
||||
const deliverTerminalPrompt = vi.fn(async (_args: { signal?: AbortSignal }) => {
|
||||
calls.push('deliverTerminalPrompt')
|
||||
return true
|
||||
@@ -84,7 +80,6 @@ function harness(options: {
|
||||
surfaces: {
|
||||
createStructuredSession,
|
||||
createTerminalAgent,
|
||||
deliverStructuredPrompt,
|
||||
deliverTerminalPrompt
|
||||
},
|
||||
workspaces: { createWorktree, createRecoveryShell },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor'
|
||||
import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories'
|
||||
import type { AgentLaunchSurfaceFactory } from './agent-launch-surface-factories'
|
||||
import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
import { readCreatedWorkspaceLaunch } from './agent-launch-created-workspace'
|
||||
@@ -20,7 +21,6 @@ function harness(options: {
|
||||
lineCarriesPrompt?: boolean
|
||||
worktreeCreateError?: Error
|
||||
onStructuredCreate?: () => void
|
||||
onStructuredPromptDelivery?: () => void
|
||||
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
|
||||
worktreeOnlyFactory?: boolean
|
||||
}) {
|
||||
@@ -63,14 +63,23 @@ function harness(options: {
|
||||
}
|
||||
return options.createSupport ?? { supported: true }
|
||||
})
|
||||
const createStructuredSession = vi.fn(async () => {
|
||||
calls.push('createStructuredSession')
|
||||
options.onStructuredCreate?.()
|
||||
if (options.structuredCreateError) {
|
||||
throw options.structuredCreateError
|
||||
const createStructuredSession = vi.fn(
|
||||
async (args: Parameters<AgentLaunchSurfaceFactory['createStructuredSession']>[0]) => {
|
||||
calls.push('createStructuredSession')
|
||||
options.onStructuredCreate?.()
|
||||
if (options.structuredCreateError) {
|
||||
throw options.structuredCreateError
|
||||
}
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
handle: 'handle_structured',
|
||||
fence: 4,
|
||||
...(args.firstMessage && options.deliveredMessageId !== null
|
||||
? { firstMessage: { clientMessageId: 'msg-1' } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 }
|
||||
})
|
||||
)
|
||||
const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => {
|
||||
calls.push('createTerminalAgent')
|
||||
if (options.terminalCreateError) {
|
||||
@@ -78,11 +87,6 @@ function harness(options: {
|
||||
}
|
||||
return { handle: 'term_1', ...carried(args.startupPrompt) }
|
||||
})
|
||||
const deliverStructuredPrompt = vi.fn(async () => {
|
||||
calls.push('deliverStructuredPrompt')
|
||||
options.onStructuredPromptDelivery?.()
|
||||
return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId
|
||||
})
|
||||
const deliverTerminalPrompt = vi.fn(async () => {
|
||||
calls.push('deliverTerminalPrompt')
|
||||
return options.terminalPromptDelivered ?? true
|
||||
@@ -101,7 +105,6 @@ function harness(options: {
|
||||
createRecoveryShell,
|
||||
createStructuredSession,
|
||||
createTerminalAgent,
|
||||
deliverStructuredPrompt,
|
||||
deliverTerminalPrompt,
|
||||
run: (intent: AgentLaunchIntent) =>
|
||||
executeAgentLaunch({
|
||||
@@ -111,7 +114,6 @@ function harness(options: {
|
||||
surfaces: {
|
||||
createStructuredSession,
|
||||
createTerminalAgent,
|
||||
deliverStructuredPrompt,
|
||||
deliverTerminalPrompt
|
||||
},
|
||||
workspaces: options.worktreeOnlyFactory
|
||||
@@ -128,17 +130,17 @@ const CREATE_INTENT: AgentLaunchIntent = {
|
||||
}
|
||||
|
||||
describe('a structured launch that creates its own worktree', () => {
|
||||
it('keeps its live inventory through publication and clears it before prompt delivery', async () => {
|
||||
it('keeps its live inventory through publication and clears it after launch', async () => {
|
||||
let duringCreate: unknown
|
||||
let duringPrompt: unknown
|
||||
let duringPublication: unknown
|
||||
const h = harness({
|
||||
onStructuredCreate: () => (duringCreate = readCreatedWorkspaceLaunch(h.runtime, 'wt-new')),
|
||||
onStructuredPromptDelivery: () =>
|
||||
(duringPrompt = readCreatedWorkspaceLaunch(h.runtime, 'wt-new'))
|
||||
onSurfacePublished: () =>
|
||||
(duringPublication = readCreatedWorkspaceLaunch(h.runtime, 'wt-new'))
|
||||
})
|
||||
await h.run({ ...CREATE_INTENT, prompt: { text: 'start', delivery: 'submit' } })
|
||||
expect(duringCreate).toMatchObject({ agent: 'claude', launchId: expect.any(String) })
|
||||
expect(duringPrompt).toBeUndefined()
|
||||
expect(duringPublication).toEqual(duringCreate)
|
||||
expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -426,20 +428,22 @@ describe('the prompt receipt', () => {
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createStructuredSession',
|
||||
'deliverStructuredPrompt'
|
||||
'createStructuredSession'
|
||||
])
|
||||
expect(h.deliverStructuredPrompt).toHaveBeenCalledWith({
|
||||
sessionId: 'sess-1',
|
||||
fence: 4,
|
||||
prompt: SUBMIT
|
||||
})
|
||||
expect(h.createStructuredSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
firstMessage: {
|
||||
clientMessageId: expect.any(String),
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: SUBMIT.text }] }
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('under-claims as not delivered when nothing was committed', async () => {
|
||||
it('keeps a missing commit receipt unconfirmed', async () => {
|
||||
const h = harness({ deliveredMessageId: null })
|
||||
const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT })
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' })
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'unconfirmed' })
|
||||
})
|
||||
|
||||
it('leaves a draft with the caller, because the host has no composer to hold one', async () => {
|
||||
@@ -449,7 +453,7 @@ describe('the prompt receipt', () => {
|
||||
prompt: { text: 'do the thing', delivery: 'draft' }
|
||||
})
|
||||
expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' })
|
||||
expect(h.deliverStructuredPrompt).not.toHaveBeenCalled()
|
||||
expect(h.createStructuredSession.mock.calls[0]?.[0].firstMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the receipt when no prompt was requested', async () => {
|
||||
@@ -790,21 +794,13 @@ describe('the surface is published as the launch stands, before its prompt is de
|
||||
expect(launch.deliverTerminalPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a chat before its first message is committed', async () => {
|
||||
it('publishes a chat with its first message receipt', async () => {
|
||||
const { launch, published } = publishing({})
|
||||
|
||||
const result = await launch.run(PROMPTED_EXISTING)
|
||||
|
||||
expect(launch.calls).toEqual([
|
||||
'createSupport',
|
||||
'createStructuredSession',
|
||||
'published',
|
||||
'deliverStructuredPrompt'
|
||||
])
|
||||
expect(published[0]).toEqual({
|
||||
...result,
|
||||
prompt: { delivery: 'submit', outcome: 'unconfirmed' }
|
||||
})
|
||||
expect(launch.calls).toEqual(['createSupport', 'createStructuredSession', 'published'])
|
||||
expect(published[0]).toEqual(result)
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './agent-launch-warnings'
|
||||
import {
|
||||
argvLaunchPrompt,
|
||||
launchPromptCreateInputs,
|
||||
deliverTerminalLaunchPrompt,
|
||||
HANDED_TO_TERMINAL,
|
||||
promptReceipt,
|
||||
@@ -40,11 +41,7 @@ import {
|
||||
type AgentLaunchStructuredSurface,
|
||||
type AgentLaunchWorkspaceFactory
|
||||
} from './agent-launch-surface-factories'
|
||||
import type {
|
||||
AgentLaunchExecution,
|
||||
AgentLaunchPublishedSurface,
|
||||
AgentLaunchSurfaceExecution
|
||||
} from './agent-launch-execution'
|
||||
import type { AgentLaunchExecution, AgentLaunchSurfaceExecution } from './agent-launch-execution'
|
||||
import {
|
||||
beginCreatedWorkspaceLaunch,
|
||||
recoverCreatedWorkspace
|
||||
@@ -210,10 +207,7 @@ export async function executeAgentLaunch(
|
||||
}
|
||||
}
|
||||
|
||||
function published(
|
||||
execution: AgentLaunchExecution,
|
||||
surface: AgentLaunchPublishedSurface
|
||||
): AgentLaunchPublishedSurface {
|
||||
function published(execution: AgentLaunchExecution, surface: AgentLaunchResult): AgentLaunchResult {
|
||||
warnStructuredLaunchDowngrade(execution.intent.agent, surface.receipt)
|
||||
execution.onSurfacePublished?.(surface)
|
||||
return surface
|
||||
@@ -257,7 +251,7 @@ async function resolveWorkspace(
|
||||
: rest
|
||||
}
|
||||
|
||||
/** Keep the structured surface’s creation fence for prompt delivery. */
|
||||
/** Keep the structured create receipt for prompt settlement. */
|
||||
export type CreatedSurface = {
|
||||
outcome: AgentLaunchResult['outcome']
|
||||
warning?: string
|
||||
@@ -279,6 +273,7 @@ async function createSurface(
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
|
||||
...(reservedTabId ? { tabId: reservedTabId } : {}),
|
||||
...launchPromptCreateInputs(intent, execution.signal),
|
||||
...(intent.prompt ? {} : { withoutPrompt: true as const })
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation'
|
||||
import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-send-mutation'
|
||||
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
|
||||
import type {
|
||||
AgentLaunchIntent,
|
||||
AgentLaunchPromptDisposal,
|
||||
@@ -9,6 +13,28 @@ import type { CreatedSurface } from './agent-launch-executor'
|
||||
import type { AgentLaunchSurfaceExecution } from './agent-launch-execution'
|
||||
import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factories'
|
||||
|
||||
function structuredLaunchSubmitText(intent: Pick<AgentLaunchIntent, 'prompt'>): string | undefined {
|
||||
return intent.prompt?.delivery === 'submit' && intent.prompt.text.trim().length > 0
|
||||
? intent.prompt.text
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function launchPromptCreateInputs(
|
||||
intent: Pick<AgentLaunchIntent, 'prompt'>,
|
||||
signal?: AbortSignal
|
||||
): { firstMessage?: StructuredAgentSessionFirstMessage; signal?: AbortSignal } {
|
||||
const text = structuredLaunchSubmitText(intent)
|
||||
return text !== undefined
|
||||
? {
|
||||
firstMessage: {
|
||||
clientMessageId: createStructuredAgentSessionOperationId(randomUUID),
|
||||
body: structuredAgentSessionSendBody(text, [])
|
||||
},
|
||||
...(signal ? { signal } : {})
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' }
|
||||
const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' }
|
||||
const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' }
|
||||
@@ -16,8 +42,14 @@ const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' }
|
||||
/** Persist owed submits as unconfirmed: a crash during delivery must not invite duplicate turns. */
|
||||
export function settledAtCreation(
|
||||
intent: Pick<AgentLaunchIntent, 'prompt'>,
|
||||
created: { promptRodeLaunchCommand?: boolean }
|
||||
created: { promptRodeLaunchCommand?: boolean; structured?: AgentLaunchStructuredSurface }
|
||||
): AgentLaunchPromptDisposal {
|
||||
if (created.structured?.firstMessage) {
|
||||
return { outcome: 'journaled', messageId: created.structured.firstMessage.clientMessageId }
|
||||
}
|
||||
if (created.structured) {
|
||||
return structuredLaunchSubmitText(intent) === undefined ? NOT_DELIVERED : UNCONFIRMED
|
||||
}
|
||||
if (created.promptRodeLaunchCommand) {
|
||||
return HANDED_TO_TERMINAL
|
||||
}
|
||||
@@ -29,8 +61,7 @@ export async function settleLaunchPromptDisposal(
|
||||
created: CreatedSurface
|
||||
): Promise<AgentLaunchPromptDisposal> {
|
||||
if (created.structured) {
|
||||
const messageId = await deliverStructuredLaunchPrompt(execution, created.structured)
|
||||
return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED
|
||||
return settledAtCreation(execution.intent, created)
|
||||
}
|
||||
if (created.promptRodeLaunchCommand) {
|
||||
return HANDED_TO_TERMINAL
|
||||
@@ -38,24 +69,6 @@ export async function settleLaunchPromptDisposal(
|
||||
return deliverTerminalLaunchPrompt(execution, created.outcome.handle, { freshLaunch: true })
|
||||
}
|
||||
|
||||
/** Drafts belong in the client composer, so only submits reach the transcript. */
|
||||
async function deliverStructuredLaunchPrompt(
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
structured: AgentLaunchStructuredSurface
|
||||
): Promise<string | null> {
|
||||
const { intent, surfaces } = execution
|
||||
if (!intent.prompt || intent.prompt.delivery !== 'submit' || execution.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
(await surfaces.deliverStructuredPrompt?.({
|
||||
sessionId: structured.sessionId,
|
||||
fence: structured.fence,
|
||||
prompt: intent.prompt
|
||||
})) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** PTY writes prove handoff, not consumption; drafts remain undelivered because acceptance is unobservable. */
|
||||
export async function deliverTerminalLaunchPrompt(
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
|
||||
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
/** Inject creation so orchestration can add subscriptions without duplicating launch sequencing. */
|
||||
@@ -14,6 +15,8 @@ export type AgentLaunchSurfaceFactory = {
|
||||
tabId?: string
|
||||
/** Keep chat idle until the caller submits its first message. */
|
||||
withoutPrompt?: true
|
||||
firstMessage?: StructuredAgentSessionFirstMessage
|
||||
signal?: AbortSignal
|
||||
}): Promise<AgentLaunchStructuredSurface>
|
||||
createTerminalAgent(args: {
|
||||
worktreeId: string
|
||||
@@ -37,12 +40,6 @@ export type AgentLaunchSurfaceFactory = {
|
||||
warning?: string
|
||||
promptRodeLaunchCommand?: boolean
|
||||
}>
|
||||
/** Return the committed message ID, or null on failure; failed delivery must not fail a running launch. */
|
||||
deliverStructuredPrompt?(args: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
prompt: AgentLaunchPrompt
|
||||
}): Promise<string | null>
|
||||
/** Paste into a live PTY; return false on failure without failing the running launch. */
|
||||
deliverTerminalPrompt?(args: {
|
||||
handle: string
|
||||
@@ -55,11 +52,12 @@ export type AgentLaunchSurfaceFactory = {
|
||||
}): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Carry the creation lease fence into the prompt envelope without rereading the session. */
|
||||
/** The created chat and its committed opening submission. */
|
||||
export type AgentLaunchStructuredSurface = {
|
||||
sessionId: string
|
||||
handle: string
|
||||
fence: number
|
||||
firstMessage?: { clientMessageId: string }
|
||||
/** Older hosts may omit the host-owned tab ID. */
|
||||
tabId?: string
|
||||
}
|
||||
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { commitStructuredAgentSessionLaunchPrompt } from '../../runtime/rpc/methods/agent-launch-structured-prompt'
|
||||
import {
|
||||
accepted,
|
||||
attachParams,
|
||||
CALLER,
|
||||
hostTestState
|
||||
} from './structured-agent-session-host-test-harness'
|
||||
import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data'
|
||||
|
||||
let host: StructuredAgentSessionHost
|
||||
|
||||
/** Model adapter ordering: acquire publishes liveness; dispatch requires it. */
|
||||
function modelAdapterLiveness(registerOnAcquire: boolean): void {
|
||||
const { acquire, dispatch } = hostTestState()
|
||||
const live = new Set<string>()
|
||||
const acquireChild = acquire.getMockImplementation()!
|
||||
acquire.mockImplementation(async (input) => {
|
||||
const acquisition = await acquireChild(input)
|
||||
if (registerOnAcquire) {
|
||||
live.add(input.identity.sessionId)
|
||||
}
|
||||
return acquisition
|
||||
})
|
||||
dispatch.mockImplementation(async ({ sessionId }) => {
|
||||
if (!live.has(sessionId)) {
|
||||
throw new Error(`no live structured session for ${sessionId}`)
|
||||
}
|
||||
return accepted()
|
||||
})
|
||||
}
|
||||
|
||||
/** Match launch-surface ordering: create, then send at its returned fence. */
|
||||
async function launchAndDeliver(): Promise<{
|
||||
messageId: string | null
|
||||
dispatchState: string
|
||||
}> {
|
||||
const send = vi.spyOn(host, 'send')
|
||||
const created = await host.attach(CALLER, attachParams())
|
||||
if (!created.ok) {
|
||||
throw new Error(`expected a create, got ${created.refusal.code}`)
|
||||
}
|
||||
const messageId = await commitStructuredAgentSessionLaunchPrompt({
|
||||
host,
|
||||
caller: CALLER,
|
||||
sessionId: created.value.sessionId,
|
||||
fence: created.value.fence,
|
||||
text: 'fix the failing test'
|
||||
})
|
||||
const sent = await send.mock.results[0]!.value
|
||||
if (!sent.ok) {
|
||||
return { messageId, dispatchState: `refused:${sent.refusal.code}:${sent.refusal.message}` }
|
||||
}
|
||||
let dispatchState = sent.value.submission.dispatchState
|
||||
await vi.waitFor(async () => {
|
||||
dispatchState =
|
||||
(await host.journalSnapshot(created.value.sessionId)).submissions.find(
|
||||
(entry) => entry.clientMessageId === sent.value.clientMessageId
|
||||
)?.dispatchState ?? 'missing'
|
||||
expect(dispatchState).not.toBe('pending')
|
||||
})
|
||||
return { messageId, dispatchState }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;({ host } = hostTestState())
|
||||
// Match operation-ID timestamps to the pinned host clock to avoid stale-send refusal.
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
})
|
||||
|
||||
describe('a launch prompt sent the instant the create resolves', () => {
|
||||
it('reaches a live provider, because attach returns only after acquire published it', async () => {
|
||||
modelAdapterLiveness(true)
|
||||
|
||||
await expect(launchAndDeliver()).resolves.toEqual({
|
||||
messageId: expect.any(String),
|
||||
dispatchState: 'accepted'
|
||||
})
|
||||
expect(hostTestState().dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Negative control: early acquire resolution would strand an accepted message.
|
||||
it('would commit a row and strand it if acquire ever resolved before publication', async () => {
|
||||
modelAdapterLiveness(false)
|
||||
|
||||
await expect(launchAndDeliver()).resolves.toEqual({
|
||||
messageId: expect.any(String),
|
||||
dispatchState: 'unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,20 @@ import {
|
||||
import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { RpcContext } from '../core'
|
||||
import { methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture'
|
||||
import { AgentSessionPreSpawnError } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { CodexAppServerRequestError } from '../../../codex/app-server/codex-app-server-request-error'
|
||||
import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor'
|
||||
import { agentLaunchSurfaceFactory } from './agent-launch-surfaces'
|
||||
import * as structuredCreate from './structured-agent-session-create'
|
||||
import {
|
||||
methodNamed,
|
||||
rpcContext,
|
||||
runtimeStub,
|
||||
setAgentLaunchRecordStore
|
||||
} from './agent-launch.test-fixture'
|
||||
|
||||
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
|
||||
const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
|
||||
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
|
||||
|
||||
// The phone: it renders either surface and sends a new chat's first message itself.
|
||||
@@ -32,15 +43,30 @@ beforeEach(async () => {
|
||||
// The launch mints its operation id from the wall clock; the rig's host reads a fixed one.
|
||||
vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] })
|
||||
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
|
||||
const acquire = rig.adapter.acquire.getMockImplementation()!
|
||||
rig.adapter.acquire.mockImplementation(async (input) => {
|
||||
const acquired = await acquire(input)
|
||||
return {
|
||||
...acquired,
|
||||
link: {
|
||||
...acquired.link!,
|
||||
origin: rig.store.getRecord(input.identity.sessionId)?.providerHandleChain.length
|
||||
? 'resumed'
|
||||
: 'created'
|
||||
}
|
||||
}
|
||||
})
|
||||
setStructuredAgentSessionHost(rig.host)
|
||||
setAgentLaunchRecordStore(rig.store)
|
||||
})
|
||||
afterEach(async () => {
|
||||
setAgentLaunchRecordStore(null)
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rig.dispose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function launch(caller: Partial<RpcContext>, prompt?: string) {
|
||||
function launchRuntime(folder = false) {
|
||||
const runtime = {
|
||||
...runtimeStub(),
|
||||
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => ({
|
||||
@@ -49,7 +75,7 @@ function launch(caller: Partial<RpcContext>, prompt?: string) {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
workspaceKind: folder ? 'folder' : 'git-worktree'
|
||||
},
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
@@ -57,31 +83,321 @@ function launch(caller: Partial<RpcContext>, prompt?: string) {
|
||||
options: { model: 'gpt-5.6-sol', effort: 'medium' },
|
||||
runtimeKind: 'native'
|
||||
})),
|
||||
selectCreatedMobileSessionTabForClient: vi.fn(() => true),
|
||||
publishStructuredAgentSessionTab: vi.fn(async () => {})
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
function launch(caller: Partial<RpcContext>, prompt?: string, delivery = 'submit') {
|
||||
const params = AGENT_LAUNCH.params.parse({
|
||||
agent: 'codex',
|
||||
target: { kind: 'existing', worktree: 'id:wt-7' },
|
||||
sessionId: SESSION,
|
||||
...(prompt ? { prompt: { text: prompt, delivery: 'submit' } } : {})
|
||||
...(prompt ? { prompt: { text: prompt, delivery } } : {})
|
||||
})
|
||||
return AGENT_LAUNCH.handler(params, rpcContext(runtime, caller))
|
||||
return AGENT_LAUNCH.handler(params, rpcContext(launchRuntime(), caller))
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ name: 'a bare launch from the phone waits', caller: PHONE, startsAtCreation: false },
|
||||
{
|
||||
name: 'a launch from the phone with a prompt starts',
|
||||
name: 'a draft launch keeps eager creation',
|
||||
caller: PHONE,
|
||||
prompt: 'fix the build',
|
||||
delivery: 'draft',
|
||||
startsAtCreation: true
|
||||
},
|
||||
{ name: 'a bare launch from the CLI starts', caller: CLI, startsAtCreation: true }
|
||||
])('$name', async ({ caller, prompt, startsAtCreation }) => {
|
||||
const result = await launch(caller, prompt)
|
||||
])('$name', async ({ caller, prompt, delivery, startsAtCreation }) => {
|
||||
const result = await launch(caller, prompt, delivery)
|
||||
|
||||
expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION })
|
||||
expect(rig.store.getRecord(SESSION)).toBeDefined()
|
||||
expect(result.prompt).toEqual(
|
||||
prompt ? { delivery: 'draft', outcome: 'not-delivered' } : undefined
|
||||
)
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([])
|
||||
// At rest, nothing is spawned until the chat's first message.
|
||||
expect(rig.adapter.acquire.mock.calls.length > 0).toBe(startsAtCreation)
|
||||
})
|
||||
|
||||
async function child(sessionId = SESSION) {
|
||||
await vi.waitFor(() =>
|
||||
expect(rig.host.collaboratorsForTests().sessions.get(sessionId)?.child).toBeTruthy()
|
||||
)
|
||||
const current = rig.host.collaboratorsForTests().sessions.get(sessionId)!.child!
|
||||
if (!current.generation) {
|
||||
throw new Error('expected a published child')
|
||||
}
|
||||
return { sessionId, fence: current.fence, acquisitionGeneration: current.generation }
|
||||
}
|
||||
|
||||
async function endChild(sessionId = SESSION) {
|
||||
const current = await child(sessionId)
|
||||
await rig.host.handleAdapterEvent({
|
||||
type: 'ended',
|
||||
...current,
|
||||
reason: 'exited during initialize',
|
||||
cause: 'unexpected-exit'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(rig.host.collaboratorsForTests().sessions.get(sessionId)?.child).toBeNull()
|
||||
)
|
||||
}
|
||||
|
||||
function holdStarts() {
|
||||
const acquire = rig.adapter.acquire.getMockImplementation()!
|
||||
rig.adapter.acquire.mockImplementation(rig.heldStart(acquire))
|
||||
}
|
||||
|
||||
it('a whitespace-only submit creates an eager empty chat without sending a turn', async () => {
|
||||
const result = await launch(PHONE, ' \t\n ')
|
||||
expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION })
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' })
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([])
|
||||
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the opening prompt owns retry for a legacy-capability CLI caller', async () => {
|
||||
holdStarts()
|
||||
const result = await launch(CLI, 'fix the build')
|
||||
expect(result.prompt).toEqual({
|
||||
delivery: 'submit',
|
||||
outcome: 'journaled',
|
||||
messageId: expect.any(String)
|
||||
})
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
|
||||
{
|
||||
clientMessageId: result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined,
|
||||
source: { kind: 'user' },
|
||||
dispatchState: 'pending'
|
||||
}
|
||||
])
|
||||
await endChild()
|
||||
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2))
|
||||
await rig.proveStart(await child())
|
||||
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
|
||||
expect(rig.adapter.dispatch.mock.calls[0]?.[0].clientMessageId).toBe(
|
||||
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined
|
||||
)
|
||||
const snapshot = await rig.host.journalSnapshot(SESSION)
|
||||
expect(snapshot.submissions).toMatchObject([{ dispatchState: 'accepted' }])
|
||||
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects one requester with one failure row when all five children exit', async () => {
|
||||
holdStarts()
|
||||
const result = await launch(CLI, 'fix the build')
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(attempt))
|
||||
await endChild()
|
||||
}
|
||||
await vi.waitFor(async () =>
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
|
||||
{
|
||||
clientMessageId:
|
||||
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined,
|
||||
dispatchState: 'rejected'
|
||||
}
|
||||
])
|
||||
)
|
||||
const snapshot = await rig.host.journalSnapshot(SESSION)
|
||||
expect(snapshot.submissions).toHaveLength(1)
|
||||
expect(
|
||||
snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error')
|
||||
).toHaveLength(1)
|
||||
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['spawn', 'open'] as const)(
|
||||
'keeps a %s failure out of the accepted launch reply',
|
||||
async (stage) => {
|
||||
if (stage === 'spawn') {
|
||||
rig.adapter.acquire.mockRejectedValueOnce(
|
||||
new AgentSessionPreSpawnError(Object.assign(new Error('busy'), { code: 'EAGAIN' }))
|
||||
)
|
||||
} else {
|
||||
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValueOnce(
|
||||
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
|
||||
)
|
||||
}
|
||||
const result = await launch(CLI, 'fix the build')
|
||||
expect(result.prompt?.outcome).toBe('journaled')
|
||||
if (stage === 'spawn') {
|
||||
await vi.waitFor(async () =>
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
|
||||
{ dispatchState: 'rejected' }
|
||||
])
|
||||
)
|
||||
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
|
||||
} else {
|
||||
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('replays a lost reply for a prompted folder launch without another message or child', async () => {
|
||||
const runtime = launchRuntime(true)
|
||||
const context = rpcContext(runtime, {
|
||||
clientKind: 'mobile',
|
||||
pairedDeviceId: 'old-phone',
|
||||
clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY]
|
||||
})
|
||||
const params = AGENT_LAUNCH_REPLAY.params.parse({
|
||||
agent: 'codex',
|
||||
target: { kind: 'existing', worktree: 'id:folder:fw-1' },
|
||||
sessionId: SESSION,
|
||||
operationId: `${HOST_TEST_NOW}-000000000000000000000000000000ee`,
|
||||
prompt: { text: 'fix the build', delivery: 'submit' },
|
||||
sessionOptions: {
|
||||
model: 'explicit-model',
|
||||
effort: 'high',
|
||||
serviceTier: 'fast',
|
||||
fastMode: 'true'
|
||||
}
|
||||
})
|
||||
const first = await AGENT_LAUNCH_REPLAY.handler(params, context)
|
||||
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
|
||||
const replay = await AGENT_LAUNCH_REPLAY.handler(params, context)
|
||||
expect(replay).toEqual(first)
|
||||
expect(first.prompt?.outcome).toBe('journaled')
|
||||
expect(runtime.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledOnce()
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
|
||||
expect(rig.store.getRecord(SESSION)?.options).toEqual({
|
||||
model: 'explicit-model',
|
||||
effort: 'high',
|
||||
serviceTier: 'fast'
|
||||
})
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps an explicit Claude Fast %s over the opposite saved selection',
|
||||
async (fastMode) => {
|
||||
const runtime = launchRuntime()
|
||||
const resolve = runtime.resolveStructuredAgentSessionCreateIntent.getMockImplementation()!
|
||||
runtime.resolveStructuredAgentSessionCreateIntent.mockImplementation(async (params) => ({
|
||||
...(await resolve(params)),
|
||||
provider: 'claude',
|
||||
agent: 'claude',
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' },
|
||||
options: { model: 'claude-opus-4-6', effort: 'medium', fastMode: String(!fastMode) }
|
||||
}))
|
||||
const created = await agentLaunchSurfaceFactory(
|
||||
rpcContext(runtime, PHONE)
|
||||
).createStructuredSession({
|
||||
worktreeId: 'wt-7',
|
||||
agent: 'claude',
|
||||
withoutPrompt: true,
|
||||
options: { model: 'claude-opus-4-6', fastMode }
|
||||
})
|
||||
expect(rig.store.getRecord(created.sessionId)?.options).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
effort: 'medium',
|
||||
fastMode: String(fastMode)
|
||||
})
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('omits unsupported-only Codex Fast overrides and retains saved defaults', async () => {
|
||||
const runtime = launchRuntime()
|
||||
const create = vi.spyOn(structuredCreate, 'createStructuredAgentSessionForWorktree')
|
||||
const created = await agentLaunchSurfaceFactory(
|
||||
rpcContext(runtime, PHONE)
|
||||
).createStructuredSession({
|
||||
worktreeId: 'wt-7',
|
||||
agent: 'codex',
|
||||
withoutPrompt: true,
|
||||
options: { fastMode: true }
|
||||
})
|
||||
expect(create.mock.calls[0]?.[0]).not.toHaveProperty('options')
|
||||
expect(rig.store.getRecord(created.sessionId)?.options).toEqual({
|
||||
model: 'gpt-5.6-sol',
|
||||
effort: 'medium'
|
||||
})
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
create.mockRestore()
|
||||
})
|
||||
|
||||
it.each(['before', 'after'] as const)(
|
||||
'cancellation %s the create commit respects its receipt',
|
||||
async (when) => {
|
||||
holdStarts()
|
||||
const controller = new AbortController()
|
||||
const runtime = launchRuntime()
|
||||
const context = rpcContext(runtime, CLI)
|
||||
if (when === 'before') {
|
||||
const resolve = runtime.resolveStructuredAgentSessionCreateIntent.getMockImplementation()!
|
||||
runtime.resolveStructuredAgentSessionCreateIntent.mockImplementationOnce(async (input) => {
|
||||
controller.abort()
|
||||
return resolve(input)
|
||||
})
|
||||
} else {
|
||||
runtime.publishStructuredAgentSessionTab.mockImplementationOnce(async () =>
|
||||
controller.abort()
|
||||
)
|
||||
}
|
||||
const running = executeAgentLaunch({
|
||||
runtime,
|
||||
signal: controller.signal,
|
||||
surfaces: agentLaunchSurfaceFactory(context),
|
||||
intent: {
|
||||
agent: 'codex',
|
||||
sessionId: SESSION,
|
||||
target: { kind: 'existing', worktree: 'wt-7' },
|
||||
prompt: { text: 'keep my text', delivery: 'submit' }
|
||||
}
|
||||
})
|
||||
if (when === 'before') {
|
||||
await expect(running).rejects.toThrow('request_aborted')
|
||||
expect(rig.store.getRecord(SESSION)).toBeNull()
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
const result = await running
|
||||
expect(result.prompt?.outcome).toBe('journaled')
|
||||
await child()
|
||||
await rig.host.close(SESSION, 'user-close')
|
||||
await rig.host.revealSession(SESSION)
|
||||
const snapshot = await rig.host.journalSnapshot(SESSION)
|
||||
expect(snapshot.submissions).toMatchObject([
|
||||
{
|
||||
source: { kind: 'user' },
|
||||
keptAsQueuedMessageId:
|
||||
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined
|
||||
}
|
||||
])
|
||||
expect(
|
||||
rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list()
|
||||
).toHaveLength(1)
|
||||
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('a tab publication failure after commit never calls the prompt undelivered', async () => {
|
||||
const runtime = launchRuntime()
|
||||
runtime.publishStructuredAgentSessionTab.mockRejectedValueOnce(
|
||||
new Error('publication unavailable')
|
||||
)
|
||||
await expect(
|
||||
AGENT_LAUNCH.handler(
|
||||
AGENT_LAUNCH.params.parse({
|
||||
agent: 'codex',
|
||||
sessionId: SESSION,
|
||||
target: { kind: 'existing', worktree: 'id:wt-7' },
|
||||
prompt: { text: 'keep my text', delivery: 'submit' }
|
||||
}),
|
||||
rpcContext(runtime, CLI)
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
|
||||
{ source: { kind: 'user' }, dispatchState: 'pending' }
|
||||
])
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -25,11 +25,6 @@ const deliverTerminalPrompt = vi.hoisted(() => vi.fn(async () => true))
|
||||
vi.mock('./agent-launch-terminal-prompt', () => ({
|
||||
deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt
|
||||
}))
|
||||
const commitChatPrompt = vi.hoisted(() => vi.fn(async () => 'message-1'))
|
||||
vi.mock('./agent-launch-structured-prompt', () => ({
|
||||
commitStructuredAgentSessionLaunchPrompt: commitChatPrompt
|
||||
}))
|
||||
|
||||
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
|
||||
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
|
||||
const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
|
||||
@@ -70,7 +65,6 @@ function chatActivation(): unknown {
|
||||
|
||||
beforeEach(() => {
|
||||
deliverTerminalPrompt.mockClear()
|
||||
commitChatPrompt.mockClear()
|
||||
createStructuredSession
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, value: { sessionId: 'sess-1' } })
|
||||
@@ -104,10 +98,7 @@ describe('a paired client launching into an existing workspace', () => {
|
||||
})
|
||||
|
||||
// Prompt delivery can take a minute; caller navigation must not wait.
|
||||
it.each([
|
||||
['terminal', {}, deliverTerminalPrompt],
|
||||
['chat', STRUCTURED_PREFERENCE, commitChatPrompt]
|
||||
] as const)(
|
||||
it.each([['terminal', {}, deliverTerminalPrompt]] as const)(
|
||||
'selects the new %s for the caller before its prompt is delivered',
|
||||
async (_surface, settings, deliver) => {
|
||||
const runtime = selectionRuntime({ settings, terminalPaneKey: PANE_KEY })
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/** Use client-shaped message identity/fingerprint and return an id only for a proven commit; claiming an uncommitted send loses text. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'trusted-local:runtime' }
|
||||
|
||||
function hostWith(
|
||||
send: ReturnType<typeof vi.fn>,
|
||||
journalSnapshot: ReturnType<typeof vi.fn> = vi.fn(() => ({ submissions: [] })),
|
||||
log = recordingStructuredAgentSessionLogger()
|
||||
): StructuredAgentSessionHost {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members this module reaches; any other would throw rather than read a wrong value.
|
||||
return {
|
||||
send,
|
||||
journalSnapshot,
|
||||
deps: { logger: log.logger }
|
||||
} as unknown as StructuredAgentSessionHost
|
||||
}
|
||||
|
||||
function commit(host: StructuredAgentSessionHost | null, text = 'do the thing') {
|
||||
return commitStructuredAgentSessionLaunchPrompt({
|
||||
host,
|
||||
caller: CALLER,
|
||||
sessionId: 'sess-1',
|
||||
fence: 4,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
describe('committing a launch prompt', () => {
|
||||
it('sends the entry as its own client message id and names the committed row', async () => {
|
||||
const send = vi.fn(async (_caller, params) => ({
|
||||
ok: true as const,
|
||||
value: { clientMessageId: params.envelope.clientOperationId, submission: {} }
|
||||
}))
|
||||
|
||||
const messageId = await commit(hostWith(send))
|
||||
|
||||
const [caller, params] = send.mock.calls[0]
|
||||
expect(caller).toEqual(CALLER)
|
||||
expect(messageId).toBe(params.envelope.clientOperationId)
|
||||
expect(params.envelope).toMatchObject({ sessionId: 'sess-1', expectedRuntimeFence: 4 })
|
||||
expect(params.body).toEqual(structuredAgentSessionSendBody('do the thing', []))
|
||||
// A restart keeps a launch's first prompt like a person's message, so the send says it is one.
|
||||
expect(params.personsMessage).toBe(true)
|
||||
// The host recomputes and compares this, so a launch send must fingerprint like a client send.
|
||||
expect(params.envelope.payloadFingerprint).toBe(
|
||||
structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: 'sess-1',
|
||||
fields: { body: params.body }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('claims nothing when the send is refused', async () => {
|
||||
const send = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
refusal: { code: 'agent_session_operation_invalid', message: 'no' }
|
||||
}))
|
||||
await expect(commit(hostWith(send))).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('recovers a committed row when settlement throws after append', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
let clientMessageId = ''
|
||||
const send = vi.fn(
|
||||
async (_caller: unknown, params: { envelope: { clientOperationId: string } }) => {
|
||||
clientMessageId = params.envelope.clientOperationId
|
||||
throw new Error('host gone')
|
||||
}
|
||||
)
|
||||
const journalSnapshot = vi.fn((_sessionId) => ({
|
||||
submissions: [{ clientMessageId }]
|
||||
}))
|
||||
await expect(commit(hostWith(send, journalSnapshot))).resolves.toEqual(clientMessageId)
|
||||
})
|
||||
|
||||
it('claims nothing, and does not fail the launch, when no row was committed', async () => {
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const failure = new Error('host gone')
|
||||
const send = vi.fn(async () => {
|
||||
throw failure
|
||||
})
|
||||
await expect(commit(hostWith(send, undefined, log))).resolves.toBeNull()
|
||||
expect(log.entries.map((entry) => entry.fields)).toEqual([
|
||||
{ scope: 'launch-prompt', sessionId: 'sess-1', error: failure }
|
||||
])
|
||||
})
|
||||
|
||||
it('sends nothing when there is no host or no text', async () => {
|
||||
const send = vi.fn()
|
||||
await expect(commit(null)).resolves.toBeNull()
|
||||
await expect(commit(hostWith(send), ' ')).resolves.toBeNull()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
/** Send through the clients' body/envelope builders and the journal's existing submission row; a second host queue could disagree.
|
||||
* The renderer still delivers its own prompts until its launcher uses agent.launch. */
|
||||
|
||||
import {
|
||||
structuredAgentSessionMessageSendMutation,
|
||||
structuredAgentSessionSendBody
|
||||
} from '../../../../shared/structured-agent-session-send-mutation'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
|
||||
|
||||
/** Return an id only for a proven journal commit, reconciling throws after append to avoid duplicate turns.
|
||||
* Do not wait for provider dispatch: the submission owns whether the provider took the committed turn. */
|
||||
export async function commitStructuredAgentSessionLaunchPrompt(args: {
|
||||
host: StructuredAgentSessionHost | null
|
||||
caller: StructuredAgentSessionCaller
|
||||
sessionId: string
|
||||
fence: number
|
||||
text: string
|
||||
}): Promise<string | null> {
|
||||
if (!args.host || args.text.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
const clientMessageId = createStructuredAgentSessionOperationId(randomUUID)
|
||||
try {
|
||||
const result = await args.host.send(args.caller, {
|
||||
...structuredAgentSessionMessageSendMutation({
|
||||
sessionId: args.sessionId,
|
||||
clientOperationId: clientMessageId,
|
||||
expectedRuntimeFence: args.fence,
|
||||
body: structuredAgentSessionSendBody(args.text, [])
|
||||
}),
|
||||
// A person's first prompt, sent for them: kept as a card if a restart or a close comes first.
|
||||
personsMessage: true
|
||||
})
|
||||
return result.ok ? result.value.clientMessageId : null
|
||||
} catch (error) {
|
||||
// Settlement can fail after append; reconcile the journal before asking the caller to resend.
|
||||
try {
|
||||
const committed = (await args.host.journalSnapshot(args.sessionId)).submissions.find(
|
||||
(submission) => submission.clientMessageId === clientMessageId
|
||||
)
|
||||
if (committed) {
|
||||
return clientMessageId
|
||||
}
|
||||
} catch {
|
||||
// The host may have gone away before the snapshot; the caller retains the text in that case.
|
||||
}
|
||||
args.host.deps.logger.warn("sending a created chat's launch prompt failed", {
|
||||
scope: 'launch-prompt',
|
||||
sessionId: args.sessionId,
|
||||
error
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { RpcContext } from '../core'
|
||||
import { STARTUP_CWD_FALLBACK_SENTENCE } from '../../../../shared/startup-cwd-fallback'
|
||||
import { structuredCallerFor } from './structured-agent-session-gate'
|
||||
import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create'
|
||||
import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt'
|
||||
import { structuredAgentRuntimeMetadata } from '../../structured-agent-runtime-metadata'
|
||||
import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt'
|
||||
import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists'
|
||||
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
|
||||
@@ -54,15 +54,24 @@ export function agentLaunchSurfaceFactory(
|
||||
options,
|
||||
sessionId: requested,
|
||||
tabId,
|
||||
withoutPrompt
|
||||
withoutPrompt,
|
||||
firstMessage,
|
||||
signal
|
||||
}) => {
|
||||
const sessionId = requested ?? createStructuredAgentSessionId(agent, randomUUID)
|
||||
const seeded = narrowStructuredLaunchSeedOptions(options)
|
||||
const definition = structuredAgentRuntimeMetadata(agent)?.definition
|
||||
const supported = Object.fromEntries(
|
||||
Object.entries(narrowStructuredLaunchSeedOptions(options) ?? {}).filter(([key]) =>
|
||||
definition?.restingOptions.acceptsKey(key)
|
||||
)
|
||||
)
|
||||
const seeded = Object.keys(supported).length > 0 ? supported : undefined
|
||||
// A client that creates chats without starting them starts this one on its first message.
|
||||
const atRest =
|
||||
withoutPrompt === true &&
|
||||
context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) ===
|
||||
true
|
||||
firstMessage !== undefined ||
|
||||
(withoutPrompt === true &&
|
||||
context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) ===
|
||||
true)
|
||||
const created = await createStructuredAgentSessionForWorktree({
|
||||
runtime: context.runtime,
|
||||
ensureHost: async () => {
|
||||
@@ -85,6 +94,8 @@ export function agentLaunchSurfaceFactory(
|
||||
...(seeded ? { options: seeded } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(atRest ? { atRest: true as const } : {}),
|
||||
...(firstMessage ? { firstMessage } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
activate:
|
||||
!callerPresentsSurface &&
|
||||
(presentation === undefined || (presentation === 'focused' && movesHostWindow))
|
||||
@@ -103,19 +114,10 @@ export function agentLaunchSurfaceFactory(
|
||||
sessionId: created.value.sessionId,
|
||||
handle: structuredAgentSessionTabId(created.value.sessionId),
|
||||
fence: created.value.fence,
|
||||
...(created.value.firstMessage ? { firstMessage: created.value.firstMessage } : {}),
|
||||
...(created.value.tabId ? { tabId: created.value.tabId } : {})
|
||||
}
|
||||
},
|
||||
deliverStructuredPrompt: async ({ sessionId, fence, prompt }) =>
|
||||
commitStructuredAgentSessionLaunchPrompt({
|
||||
host: getStructuredAgentSessionHost(),
|
||||
caller: operationCallerKey
|
||||
? { callerKey: operationCallerKey }
|
||||
: structuredCallerFor(context),
|
||||
sessionId,
|
||||
fence,
|
||||
text: prompt.text
|
||||
}),
|
||||
createTerminalAgent: async ({
|
||||
worktreeId,
|
||||
agent,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createRestTestRig,
|
||||
type RestTestRig
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig'
|
||||
import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { CodexAppServerRequestError } from '../../../codex/app-server/codex-app-server-request-error'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import {
|
||||
createStructuredWorkerSession,
|
||||
releaseStructuredWorkerSession
|
||||
} from './orchestration-structured-worker-session'
|
||||
import { deliverWorkerDispatchPreamble } from './orchestration/worker/deliver-worker-dispatch-preamble'
|
||||
import { tearDownFailedWorkerStart } from './orchestration/worker/failed-worker-start-teardown'
|
||||
import { structuredWorkerIdentities } from '../../structured-worker-identity'
|
||||
import { rpcContext, runtimeStub } from './agent-launch.test-fixture'
|
||||
|
||||
let rig: RestTestRig
|
||||
let db: OrchestrationDb
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] })
|
||||
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
|
||||
const acquire = rig.adapter.acquire.getMockImplementation()!
|
||||
rig.adapter.acquire.mockImplementation(async (input) => {
|
||||
const acquired = await acquire(input)
|
||||
return {
|
||||
...acquired,
|
||||
link: {
|
||||
...acquired.link!,
|
||||
origin: rig.store.getRecord(input.identity.sessionId)?.providerHandleChain.length
|
||||
? 'resumed'
|
||||
: 'created'
|
||||
}
|
||||
}
|
||||
})
|
||||
setStructuredAgentSessionHost(rig.host)
|
||||
db = new OrchestrationDb(':memory:')
|
||||
})
|
||||
afterEach(async () => {
|
||||
releaseStructuredWorkerSession('dispatch-retry')
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rig.dispose()
|
||||
db.close()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function createWorker() {
|
||||
const workerRuntime = {
|
||||
...runtimeStub(),
|
||||
resolveStructuredAgentSessionCreateIntent: async (params: { envelope: unknown }) => ({
|
||||
envelope: params.envelope,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
|
||||
options: { model: 'gpt-5.6-sol', effort: 'medium' },
|
||||
runtimeKind: 'native'
|
||||
}),
|
||||
publishStructuredAgentSessionTab: async () => {},
|
||||
getNestedWorkerMaxDepth: () => 2,
|
||||
getTerminalOrchestrationCliCommand: () => 'orca',
|
||||
orchestrationSenderNames: { nameOf: () => 'Coordinator' },
|
||||
forgetStructuredSessionMail: vi.fn(),
|
||||
retireStructuredAgentSessionTabFromSnapshot: vi.fn(() => true)
|
||||
}
|
||||
const runtime = rpcContext(workerRuntime, {}).runtime
|
||||
const created = await createStructuredWorkerSession({
|
||||
runtime,
|
||||
worktreeId: 'wt-7',
|
||||
agent: 'codex',
|
||||
dispatchId: 'dispatch-retry',
|
||||
onJournalActivity: () => {}
|
||||
})
|
||||
const deliverPreamble = () =>
|
||||
deliverWorkerDispatchPreamble({
|
||||
runtime,
|
||||
db,
|
||||
structuredSession: created,
|
||||
terminalHandle: created.identity.handle,
|
||||
dispatchId: 'dispatch-retry',
|
||||
dispatchDepth: 1,
|
||||
runId: 'run-retry',
|
||||
taskId: 'task-retry',
|
||||
taskSpec: 'fix the build',
|
||||
coordinatorHandle: 'term_coordinator',
|
||||
devMode: false,
|
||||
requestId: 'request-retry',
|
||||
readinessTimeoutMs: 2000
|
||||
})
|
||||
return { runtime, created, workerRuntime, deliverPreamble }
|
||||
}
|
||||
|
||||
it('starts only for its agent-sourced preamble and observes it after a transient open failure', async () => {
|
||||
const { created, deliverPreamble } = await createWorker()
|
||||
const sessionId = created.identity.sessionId
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
expect((await rig.host.journalSnapshot(sessionId)).submissions).toEqual([])
|
||||
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValueOnce(
|
||||
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
|
||||
)
|
||||
const result = await deliverPreamble()
|
||||
expect(result.structuredTurnStart).toEqual({ verdict: 'observed' })
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
|
||||
expect(rig.adapter.dispatch).toHaveBeenCalledOnce()
|
||||
const snapshot = await rig.host.journalSnapshot(sessionId)
|
||||
expect(snapshot.submissions).toMatchObject([
|
||||
{
|
||||
dispatchState: 'accepted',
|
||||
source: { kind: 'agent' }
|
||||
}
|
||||
])
|
||||
expect(snapshot.submissions).toHaveLength(1)
|
||||
const [input] = rig.adapter.dispatch.mock.calls[0]!
|
||||
expect(input).toMatchObject({
|
||||
body: {
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [{ name: 'Coordinator' }],
|
||||
orchestration: { message: 'task', dispatchId: 'dispatch-retry' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a preamble as undelivered after startup exhaustion and retires the failed worker', async () => {
|
||||
const { runtime, created, workerRuntime, deliverPreamble } = await createWorker()
|
||||
const sessionId = created.identity.sessionId
|
||||
expect(rig.store.listVisibleSessionIds()).toContain(sessionId)
|
||||
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValue(
|
||||
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
|
||||
)
|
||||
await expect(deliverPreamble()).rejects.toMatchObject({ code: 'dispatch_preamble_undelivered' })
|
||||
expect(rig.adapter.acquire).toHaveBeenCalledTimes(5)
|
||||
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
const snapshot = await rig.host.journalSnapshot(sessionId)
|
||||
expect(snapshot.submissions).toHaveLength(1)
|
||||
expect(snapshot.submissions).toMatchObject([
|
||||
{ dispatchState: 'rejected', source: { kind: 'agent' } }
|
||||
])
|
||||
await tearDownFailedWorkerStart({
|
||||
runtime,
|
||||
structuredSession: created,
|
||||
dispatchId: 'dispatch-retry'
|
||||
})
|
||||
expect(structuredWorkerIdentities.getBySessionId(sessionId)).toBeNull()
|
||||
expect(rig.host.hasSession(sessionId)).toBe(false)
|
||||
expect(rig.store.listVisibleSessionIds()).not.toContain(sessionId)
|
||||
expect(workerRuntime.forgetStructuredSessionMail).toHaveBeenCalledWith(sessionId)
|
||||
expect(workerRuntime.retireStructuredAgentSessionTabFromSnapshot).toHaveBeenCalledWith(sessionId)
|
||||
})
|
||||
@@ -106,6 +106,7 @@ export async function createStructuredWorkerSession(args: {
|
||||
// Absent, the host seeds the user's saved selection — the same fallback a chat gets.
|
||||
...(args.options ? { options: args.options } : {}),
|
||||
// Dispatching a worker is background work; it must not pull the surface away from the user.
|
||||
atRest: true,
|
||||
activate: false
|
||||
})
|
||||
if (!created.ok) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path.
|
||||
|
||||
import { assertAgentPromptRequestActive } from '../../orca-runtime-core'
|
||||
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
|
||||
import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
||||
@@ -27,10 +28,7 @@ import {
|
||||
type StructuredAgentCliSourceEvidence
|
||||
} from '../../../native-chat/structured-agent-cli-source'
|
||||
import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle'
|
||||
import {
|
||||
resolveUncommittedStructuredCreate,
|
||||
type StructuredCreateRefused
|
||||
} from './structured-agent-session-precommit-refusal'
|
||||
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create'
|
||||
import { structuredLaunchSeedOnSavedListing } from '../../session-tabs/structured-launch-seed-listing'
|
||||
@@ -297,6 +295,8 @@ export async function createStructuredAgentSessionForWorktree(args: {
|
||||
tabId?: string
|
||||
/** Create without starting the agent; its first message starts it. */
|
||||
atRest?: true
|
||||
firstMessage?: StructuredAgentSessionFirstMessage
|
||||
signal?: AbortSignal
|
||||
}): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
// An at-rest create's ledger row is keyed by its intent, as a client's create is.
|
||||
const envelope = args.atRest
|
||||
@@ -305,13 +305,13 @@ export async function createStructuredAgentSessionForWorktree(args: {
|
||||
payloadFingerprint: structuredAgentSessionCreateIntentFingerprint(args)
|
||||
}
|
||||
: args.envelope
|
||||
const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused =
|
||||
await resolveUncommittedStructuredCreate(() =>
|
||||
prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope })
|
||||
)
|
||||
const prepared = await resolveUncommittedStructuredCreate(() =>
|
||||
prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope })
|
||||
)
|
||||
if ('refusal' in prepared) {
|
||||
return { ok: false, refusal: prepared.refusal }
|
||||
}
|
||||
assertAgentPromptRequestActive(args.signal)
|
||||
return commitStructuredAgentSessionCreate({
|
||||
runtime: args.runtime,
|
||||
caller: args.caller,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_LAUNCH_RUNTIME_CAPABILITIES } from '../../src/shared/agent-launch-runtime-capability'
|
||||
import type { StructuredAgentSessionFirstMessage } from '../../src/shared/structured-agent-session-create'
|
||||
import { openTestAgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store-test-harness'
|
||||
import { RuntimeClient, RuntimeClientError } from '../../src/cli/runtime-client'
|
||||
import { callAgentLaunch, readCliAgentLaunch } from '../../src/cli/agent-launch-request'
|
||||
@@ -17,14 +18,10 @@ import {
|
||||
} from '../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
|
||||
|
||||
const chatCreate = vi.hoisted(() => vi.fn())
|
||||
const promptCommit = vi.hoisted(() => vi.fn(async () => 'message_test'))
|
||||
const terminalPrompt = vi.hoisted(() => vi.fn(async () => true))
|
||||
vi.mock('../../src/main/runtime/rpc/methods/structured-agent-session-create', () => ({
|
||||
createStructuredAgentSessionForWorktree: chatCreate
|
||||
}))
|
||||
vi.mock('../../src/main/runtime/rpc/methods/agent-launch-structured-prompt', () => ({
|
||||
commitStructuredAgentSessionLaunchPrompt: promptCommit
|
||||
}))
|
||||
vi.mock('../../src/main/runtime/rpc/methods/agent-launch-terminal-prompt', () => ({
|
||||
deliverTerminalAgentLaunchPrompt: terminalPrompt
|
||||
}))
|
||||
@@ -34,8 +31,17 @@ let directory: string
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-launch-cli-'))
|
||||
setAgentLaunchRecordStore(await openTestAgentSessionRecordStore(directory))
|
||||
chatCreate.mockReset().mockResolvedValue({ ok: true, value: { sessionId: 'codex_test' } })
|
||||
promptCommit.mockClear()
|
||||
chatCreate
|
||||
.mockReset()
|
||||
.mockImplementation(async (args: { firstMessage?: StructuredAgentSessionFirstMessage }) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'codex_test',
|
||||
...(args.firstMessage
|
||||
? { firstMessage: { clientMessageId: args.firstMessage.clientMessageId } }
|
||||
: {})
|
||||
}
|
||||
}))
|
||||
terminalPrompt.mockClear()
|
||||
})
|
||||
afterEach(async () => {
|
||||
@@ -163,12 +169,16 @@ describe('CLI launch through host surface creation and replay admission', () =>
|
||||
{ target, presentation: 'background' },
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
)
|
||||
const firstMessage = chatCreate.mock.calls[0]?.[0].firstMessage
|
||||
expect(firstMessage).toMatchObject({
|
||||
clientMessageId: expect.any(String),
|
||||
body: { blocks: [{ type: 'text', text: launch.prompt.text }] }
|
||||
})
|
||||
expect(result.result.prompt).toMatchObject({
|
||||
outcome: 'journaled',
|
||||
messageId: 'message_test'
|
||||
messageId: firstMessage.clientMessageId
|
||||
})
|
||||
expect(chatCreate).toHaveBeenCalledTimes(1)
|
||||
expect(promptCommit).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(
|
||||
target.kind === 'create-worktree' ? 1 : 0
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user