diff --git a/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts b/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts index 3196ad98a..775f40763 100644 --- a/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts +++ b/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts @@ -50,19 +50,17 @@ mock.module('bun:bundle', () => ({ feature: () => false, })) -// Narrow mock for the side-effectful entries in `src/constants/oauth.js`. -// Pure data exports (ALL_OAUTH_SCOPES, CLAUDE_AI_*_SCOPE, etc.) come from -// the real module and are not mocked, per the test policy that constants -// modules without side effects should not be replaced wholesale. -mock.module('src/constants/oauth.js', () => { - const actual = require('../../../../../../src/constants/oauth.js') - return { - ...actual, - fileSuffixForOauthConfig: () => '', - getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }), - MCP_CLIENT_METADATA_URL: 'https://example.test/oauth/metadata', - } -}) +mock.module('src/constants/oauth.js', () => ({ + ALL_OAUTH_SCOPES: ['user:profile', 'user:inference'], + CLAUDE_AI_INFERENCE_SCOPE: 'user:inference', + CLAUDE_AI_OAUTH_SCOPES: ['user:profile', 'user:inference'], + CLAUDE_AI_PROFILE_SCOPE: 'user:profile', + CONSOLE_OAUTH_SCOPES: ['org:create_api_key', 'user:profile'], + MCP_CLIENT_METADATA_URL: 'https://example.test/oauth/metadata', + OAUTH_BETA_HEADER: 'oauth-test', + fileSuffixForOauthConfig: () => '', + getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }), +})) mock.module('src/utils/remoteTriggerAudit.js', () => ({ appendRemoteTriggerAuditRecord: async (record: Record) => { diff --git a/src/Tool.ts b/src/Tool.ts index e2dfa0e9e..734eac6f0 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -178,6 +178,15 @@ export type ToolUseContext = { querySource?: QuerySource /** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */ refreshTools?: () => Tools + /** + * @internal TEST-ONLY ESCAPE HATCH. MUST remain undefined in production. + * + * Allows non-bundled unit-test harnesses to exercise the background + * forked slash command path that production assistant mode gates behind + * `feature('KAIROS')`. Setting this true outside NODE_ENV=test is rejected + * by processSlashCommand. + */ + allowBackgroundForkedSlashCommands?: boolean } abortController: AbortController readFileState: FileStateCache diff --git a/src/__tests__/handlePromptSubmit.test.ts b/src/__tests__/handlePromptSubmit.test.ts new file mode 100644 index 000000000..42db05af0 --- /dev/null +++ b/src/__tests__/handlePromptSubmit.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { createAbortController } from '../utils/abortController' +import { QueryGuard } from '../utils/QueryGuard' +import { handlePromptSubmit } from '../utils/handlePromptSubmit' +import { + getCommandQueue, + resetCommandQueue, +} from '../utils/messageQueueManager' +import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system' +import { + createAutonomyQueuedPrompt, + markAutonomyRunCancelled, +} from '../utils/autonomyRuns' + +let tempDirs: string[] = [] + +function createBaseParams() { + const queryGuard = new QueryGuard() + queryGuard.reserve() + + return { + queryGuard, + helpers: { + setCursorOffset: mock((_offset: number) => {}), + clearBuffer: mock(() => {}), + resetHistory: mock(() => {}), + }, + onInputChange: mock((_value: string) => {}), + setPastedContents: mock((_value: unknown) => {}), + setToolJSX: mock((_value: unknown) => {}), + getToolUseContext: mock(() => { + throw new Error('getToolUseContext should not be called in queued path') + }), + messages: [], + mainLoopModel: 'claude-sonnet-4-6', + ideSelection: undefined, + querySource: 'repl_main_thread' as any, + commands: [], + setUserInputOnProcessing: mock((_prompt?: string) => {}), + setAbortController: mock((_abortController: AbortController | null) => {}), + onQuery: mock(async () => undefined) as unknown as ( + ...args: unknown[] + ) => Promise, + setAppState: mock((_updater: unknown) => {}), + } +} + +describe('handlePromptSubmit', () => { + beforeEach(() => { + resetCommandQueue() + tempDirs = [] + }) + + afterEach(async () => { + for (const tempDir of tempDirs) { + await cleanupTempDir(tempDir) + } + }) + + test('aborts the current turn when only cancel-interrupt tools are running', async () => { + const params = createBaseParams() + const abortController = createAbortController() + + await handlePromptSubmit({ + ...params, + input: 'hello', + mode: 'prompt', + pastedContents: {}, + abortController, + streamMode: 'normal' as any, + hasInterruptibleToolInProgress: true, + isExternalLoading: false, + }) + + expect(abortController.signal.aborted).toBe(true) + expect(abortController.signal.reason).toBe('interrupt') + expect(getCommandQueue()).toHaveLength(1) + expect(getCommandQueue()[0]).toMatchObject({ + value: 'hello', + preExpansionValue: 'hello', + mode: 'prompt', + }) + expect(params.onInputChange).toHaveBeenCalledWith('') + }) + + test('queues the input without aborting when a blocking tool is running', async () => { + const params = createBaseParams() + const abortController = createAbortController() + + await handlePromptSubmit({ + ...params, + input: 'hello', + mode: 'prompt', + pastedContents: {}, + abortController, + streamMode: 'normal' as any, + hasInterruptibleToolInProgress: false, + isExternalLoading: false, + }) + + expect(abortController.signal.aborted).toBe(false) + expect(getCommandQueue()).toHaveLength(1) + expect(getCommandQueue()[0]).toMatchObject({ + value: 'hello', + preExpansionValue: 'hello', + mode: 'prompt', + }) + }) + + test('preserves bridgeOrigin when a remote slash command is queued during external loading', async () => { + const params = createBaseParams() + const abortController = createAbortController() + + await handlePromptSubmit({ + ...params, + input: '/proactive', + mode: 'prompt', + pastedContents: {}, + abortController, + streamMode: 'normal' as any, + hasInterruptibleToolInProgress: true, + isExternalLoading: true, + skipSlashCommands: true, + bridgeOrigin: true, + }) + + expect(getCommandQueue()).toHaveLength(1) + expect(getCommandQueue()[0]).toMatchObject({ + value: '/proactive', + preExpansionValue: '/proactive', + mode: 'prompt', + skipSlashCommands: true, + bridgeOrigin: true, + }) + }) + + test('skips stale autonomy commands in the idle queued path', async () => { + const params = createBaseParams() + const abortController = createAbortController() + const tempDir = await createTempDir('handle-prompt-autonomy-') + tempDirs.push(tempDir) + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir) + + await handlePromptSubmit({ + ...params, + input: '', + mode: 'prompt', + pastedContents: {}, + abortController, + streamMode: 'normal' as any, + hasInterruptibleToolInProgress: false, + isExternalLoading: false, + queuedCommands: [command!], + }) + + expect(params.getToolUseContext).not.toHaveBeenCalled() + expect(params.onQuery).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/queryAutonomyProviderBoundary.test.ts b/src/__tests__/queryAutonomyProviderBoundary.test.ts new file mode 100644 index 000000000..5da040c13 --- /dev/null +++ b/src/__tests__/queryAutonomyProviderBoundary.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { randomUUID } from 'crypto' +import { + resetStateForTests, + setCwdState, + setOriginalCwd, + setProjectRoot, +} from '../bootstrap/state' +import { query } from '../query' +import { getEmptyToolPermissionContext } from '../Tool' +import type { AssistantMessage } from '../types/message' +import { asSystemPrompt } from '../utils/systemPromptType' +import { + createAssistantAPIErrorMessage, + createUserMessage, +} from '../utils/messages' +import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system' +import { + enqueue, + getCommandsByMaxPriority, + resetCommandQueue, +} from '../utils/messageQueueManager' +import { getAutonomyFlowById, listAutonomyFlows } from '../utils/autonomyFlows' +import { + getAutonomyRunById, + startManagedAutonomyFlowFromHeartbeatTask, +} from '../utils/autonomyRuns' + +let tempDir = '' +let originalProcessCwd = '' + +beforeEach(async () => { + originalProcessCwd = process.cwd() + tempDir = await createTempDir('query-autonomy-provider-boundary-') + resetStateForTests() + resetCommandQueue() + setOriginalCwd(tempDir) + setCwdState(tempDir) + setProjectRoot(tempDir) +}) + +afterEach(async () => { + resetStateForTests() + resetCommandQueue() + if (originalProcessCwd) { + process.chdir(originalProcessCwd) + } + if (tempDir) { + let lastError: unknown + for (let attempt = 0; attempt < 20; attempt++) { + try { + await cleanupTempDir(tempDir) + lastError = undefined + break + } catch (error) { + lastError = error + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + if (lastError) { + throw lastError + } + } +}) + +function createToolUseAssistantMessage(): AssistantMessage { + return { + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + requestId: undefined, + message: { + id: 'msg_tool_use', + type: 'message', + role: 'assistant', + model: 'test-model', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + content: [ + { + type: 'tool_use', + id: 'toolu_provider_boundary', + name: 'MissingBoundaryTool', + input: {}, + }, + ], + }, + } as unknown as AssistantMessage +} + +function createToolUseContext(): any { + let inProgressToolUseIds = new Set() + let responseLength = 0 + let appState = { + toolPermissionContext: getEmptyToolPermissionContext(), + fastMode: false, + mcp: { + tools: [], + clients: [], + }, + effortValue: undefined, + advisorModel: undefined, + sessionHooks: new Map(), + } + + return { + options: { + commands: [], + debug: false, + mainLoopModel: 'claude-sonnet-4-5-20250929', + tools: [], + verbose: false, + thinkingConfig: { type: 'disabled' }, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: true, + agentDefinitions: { + activeAgents: [], + allowedAgentTypes: [], + }, + }, + abortController: new AbortController(), + readFileState: new Map(), + getAppState: () => appState, + setAppState: (updater: (state: any) => any) => { + appState = updater(appState as never) + }, + setInProgressToolUseIDs: (updater: (state: Set) => Set) => { + inProgressToolUseIds = updater(inProgressToolUseIds) + }, + setResponseLength: (updater: (state: number) => number) => { + responseLength = updater(responseLength) + }, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + messages: [], + } as any +} + +describe('query autonomy/provider boundary', () => { + test('provider api-error messages fail a consumed autonomy run instead of advancing the flow', async () => { + const previousDisableAttachments = + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1' + try { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'provider-boundary', + interval: '1h', + prompt: 'Exercise provider boundary', + steps: [ + { name: 'first', prompt: 'First provider-boundary step' }, + { name: 'second', prompt: 'Second provider-boundary step' }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + priority: 'next', + }) + expect(command).not.toBeNull() + enqueue(command!) + + const toolUseContext = createToolUseContext() + + let callCount = 0 + const deps = { + uuid: () => 'query-chain-id', + microcompact: async (messages: unknown[]) => ({ messages }), + autocompact: async () => ({ + compactionResult: undefined, + consecutiveFailures: 0, + }), + callModel: async function* () { + callCount += 1 + if (callCount === 1) { + yield createToolUseAssistantMessage() + return + } + yield createAssistantAPIErrorMessage({ + content: 'API Error: provider unavailable', + apiError: 'api_error', + error: new Error('provider unavailable') as never, + }) + }, + } + + const emitted: any[] = [] + const generator = query({ + messages: [ + createUserMessage({ + content: 'start provider-boundary test', + }), + ], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async (_tool, input) => ({ + behavior: 'allow', + updatedInput: input, + }), + toolUseContext, + querySource: 'sdk', + maxTurns: 3, + deps: deps as never, + }) + let next = await generator.next() + while (!next.done) { + emitted.push(next.value) + next = await generator.next() + } + + const [flow] = await listAutonomyFlows(tempDir) + const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir) + const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir) + + expect(next.value.reason).toBe('model_error') + expect(callCount).toBe(2) + expect( + emitted.some( + message => + message.type === 'attachment' && + message.attachment.type === 'queued_command', + ), + ).toBe(true) + expect(run!.status).toBe('failed') + expect(run!.error).toBe('provider api_error') + expect(finalFlow!.status).toBe('failed') + expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([ + 'failed', + 'pending', + ]) + expect(getCommandsByMaxPriority('later')).toHaveLength(0) + } finally { + if (previousDisableAttachments === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + } else { + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments + } + } + }) + + test('generator return cancels a consumed autonomy run instead of leaving it running', async () => { + const previousDisableAttachments = + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1' + try { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'return-boundary', + interval: '1h', + prompt: 'Exercise generator return boundary', + steps: [ + { name: 'first', prompt: 'First return-boundary step' }, + { name: 'second', prompt: 'Second return-boundary step' }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + priority: 'next', + }) + expect(command).not.toBeNull() + enqueue(command!) + + const toolUseContext = createToolUseContext() + const deps = { + uuid: () => 'query-chain-id', + microcompact: async (messages: unknown[]) => ({ messages }), + autocompact: async () => ({ + compactionResult: undefined, + consecutiveFailures: 0, + }), + callModel: async function* () { + yield createToolUseAssistantMessage() + }, + } + + const generator = query({ + messages: [ + createUserMessage({ + content: 'start return-boundary test', + }), + ], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async (_tool, input) => ({ + behavior: 'allow', + updatedInput: input, + }), + toolUseContext, + querySource: 'sdk', + maxTurns: 3, + deps: deps as never, + }) + + let sawQueuedAttachment = false + let next = await generator.next() + while (!next.done) { + const message = next.value as any + if ( + message.type === 'attachment' && + message.attachment.type === 'queued_command' + ) { + sawQueuedAttachment = true + await generator.return(undefined as never) + break + } + next = await generator.next() + } + + const [flow] = await listAutonomyFlows(tempDir) + const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir) + const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir) + + expect(sawQueuedAttachment).toBe(true) + expect(run!.status).toBe('cancelled') + expect(finalFlow!.status).toBe('cancelled') + expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([ + 'cancelled', + 'cancelled', + ]) + expect(getCommandsByMaxPriority('later')).toHaveLength(0) + } finally { + if (previousDisableAttachments === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + } else { + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments + } + } + }) +}) diff --git a/src/hooks/__tests__/useScheduledTasks.test.ts b/src/hooks/__tests__/useScheduledTasks.test.ts new file mode 100644 index 000000000..ce6b1f966 --- /dev/null +++ b/src/hooks/__tests__/useScheduledTasks.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + resetStateForTests, + setCwdState, + setOriginalCwd, + setProjectRoot, +} from '../../bootstrap/state' +import { createScheduledTaskQueuedCommand } from '../useScheduledTasks' +import { + listAutonomyRuns, + markAutonomyRunCompleted, +} from '../../utils/autonomyRuns' +import { resetAutonomyAuthorityForTests } from '../../utils/autonomyAuthority' +import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system' + +let tempDir = '' + +beforeEach(async () => { + tempDir = await createTempDir('scheduled-tasks-') + resetStateForTests() + resetAutonomyAuthorityForTests() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) + setCwdState(tempDir) +}) + +afterEach(async () => { + resetStateForTests() + resetAutonomyAuthorityForTests() + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +describe('createScheduledTaskQueuedCommand', () => { + function createCommandForTest(task: { id: string; prompt: string }) { + return createScheduledTaskQueuedCommand(task, { + rootDir: tempDir, + currentDir: tempDir, + }) + } + + test('skips a scheduled task when the same source already has an active run', async () => { + const task = { + id: 'cron-1', + prompt: '/loop review the repository', + } + + const first = await createCommandForTest(task) + const second = await createCommandForTest(task) + const runs = await listAutonomyRuns(tempDir) + + expect(first).not.toBeNull() + expect(second).toBeNull() + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ + trigger: 'scheduled-task', + status: 'queued', + sourceId: 'cron-1', + }) + }) + + test('allows a scheduled task after the previous same-source run completes', async () => { + const task = { + id: 'cron-1', + prompt: '/loop review the repository', + } + + const first = await createCommandForTest(task) + expect(first?.autonomy?.runId).toBeDefined() + + await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 100) + const second = await createCommandForTest(task) + const runs = await listAutonomyRuns(tempDir) + + expect(second).not.toBeNull() + expect(runs).toHaveLength(2) + expect(runs.map(run => run.status).sort()).toEqual(['completed', 'queued']) + }) +}) diff --git a/src/hooks/useScheduledTasks.ts b/src/hooks/useScheduledTasks.ts index 1bdff1223..112a5b850 100644 --- a/src/hooks/useScheduledTasks.ts +++ b/src/hooks/useScheduledTasks.ts @@ -7,13 +7,20 @@ import { } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' import { isKairosCronEnabled } from '@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js' import type { Message } from '../types/message.js' +import { getCwd } from '../utils/cwd.js' import { getCronJitterConfig } from '../utils/cronJitterConfig.js' import { createCronScheduler } from '../utils/cronScheduler.js' -import { removeCronTasks } from '../utils/cronTasks.js' +import { removeCronTasks, type CronTask } from '../utils/cronTasks.js' +import { + createAutonomyQueuedPrompt, + createAutonomyQueuedPromptIfNoActiveSource, + markAutonomyRunCancelled, +} from '../utils/autonomyRuns.js' import { logForDebugging } from '../utils/debug.js' import { enqueuePendingNotification } from '../utils/messageQueueManager.js' import { createScheduledTaskFireMessage } from '../utils/messages.js' import { WORKLOAD_CRON } from '../utils/workloadContext.js' +import type { QueuedCommand } from '../types/textInputTypes.js' type Props = { isLoading: boolean @@ -29,6 +36,32 @@ type Props = { setMessages: React.Dispatch> } +export async function createScheduledTaskQueuedCommand( + task: Pick, + options?: { + rootDir?: string + currentDir?: string + shouldCreate?: () => boolean + }, +): Promise { + const command = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: task.prompt, + trigger: 'scheduled-task', + rootDir: options?.rootDir, + currentDir: options?.currentDir ?? getCwd(), + sourceId: task.id, + sourceLabel: task.prompt, + workload: WORKLOAD_CRON, + shouldCreate: options?.shouldCreate, + }) + if (!command) { + logForDebugging( + `[ScheduledTasks] skipping ${task.id}: previous run still queued or running`, + ) + } + return command +} + /** * REPL wrapper for the cron scheduler. Mounts the scheduler once and tears * it down on unmount. Fired prompts go into the command queue as 'later' @@ -68,25 +101,41 @@ export function useScheduledTasks({ // forward isMeta, so their messages remain visible in the // transcript. This is acceptable since normal mode is not the // primary use case for scheduled tasks. - const enqueueForLead = (prompt: string) => - enqueuePendingNotification({ - value: prompt, - mode: 'prompt', - priority: 'later', - isMeta: true, - // Threaded through to cc_workload= in the billing-header - // attribution block so the API can serve cron-initiated requests - // at lower QoS when capacity is tight. No human is actively - // waiting on this response. + let disposed = false + const enqueueForLead = async (prompt: string) => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: prompt, + trigger: 'scheduled-task', + currentDir: getCwd(), workload: WORKLOAD_CRON, + shouldCreate: () => !disposed, }) + if (!command) { + return + } + if (disposed) { + await markAutonomyRunCancelled( + command.autonomy!.runId, + command.autonomy!.rootDir, + ) + return + } + enqueuePendingNotification(command) + } const scheduler = createCronScheduler({ // Missed-task surfacing (onFire fallback). Teammate crons are always // session-only (durable:false) so they never appear in the missed list, // which is populated from disk at scheduler startup — this path only // handles team-lead durable crons. - onFire: enqueueForLead, + onFire: prompt => { + void enqueueForLead(prompt).catch(error => + logForDebugging( + `[ScheduledTasks] failed to enqueue missed task prompt: ${error}`, + { level: 'error' }, + ), + ) + }, // Normal fires receive the full CronTask so we can route by agentId. onFireTask: task => { if (task.agentId) { @@ -107,11 +156,31 @@ export function useScheduledTasks({ void removeCronTasks([task.id]) return } - const msg = createScheduledTaskFireMessage( - `Running scheduled task (${formatCronFireTime(new Date())})`, + void (async () => { + const command = await createScheduledTaskQueuedCommand(task, { + shouldCreate: () => !disposed, + }) + if (!command) { + return + } + if (disposed) { + await markAutonomyRunCancelled( + command.autonomy!.runId, + command.autonomy!.rootDir, + ) + return + } + const msg = createScheduledTaskFireMessage( + `Running scheduled task (${formatCronFireTime(new Date())})`, + ) + setMessages(prev => [...prev, msg]) + enqueuePendingNotification(command) + })().catch(error => + logForDebugging( + `[ScheduledTasks] failed to enqueue task ${task.id}: ${error}`, + { level: 'error' }, + ), ) - setMessages(prev => [...prev, msg]) - enqueueForLead(task.prompt) }, isLoading: () => isLoadingRef.current, assistantMode, @@ -119,7 +188,10 @@ export function useScheduledTasks({ isKilled: () => !isKairosCronEnabled(), }) scheduler.start() - return () => scheduler.stop() + return () => { + disposed = true + scheduler.stop() + } // assistantMode is stable for the session lifetime; store/setAppState are // stable refs from useSyncExternalStore; setMessages is a stable useCallback. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/utils/__tests__/autonomyAuthority.test.ts b/src/utils/__tests__/autonomyAuthority.test.ts new file mode 100644 index 000000000..876e9e7b7 --- /dev/null +++ b/src/utils/__tests__/autonomyAuthority.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + AUTONOMY_AGENTS_PATH_POSIX, + AUTONOMY_DIR, + buildAutonomyTurnPrompt, + loadAutonomyAuthority, + parseHeartbeatAuthorityTasks, + resetAutonomyAuthorityForTests, +} from '../autonomyAuthority' +import { + cleanupTempDir, + createTempDir, + createTempSubdir, + writeTempFile, +} from '../../../tests/mocks/file-system' + +const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md') +const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md') + +let tempDir = '' + +async function writeAuthorityFile( + dir: string, + name: string, + content: string, +): Promise { + await mkdir(dirname(join(dir, name)), { recursive: true }) + return writeTempFile(dir, name, content) +} + +beforeEach(async () => { + tempDir = await createTempDir('autonomy-authority-') +}) + +afterEach(async () => { + resetAutonomyAuthorityForTests() + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +describe('autonomyAuthority', () => { + test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => { + const nestedDir = await createTempSubdir(tempDir, 'packages/app') + await writeAuthorityFile(tempDir, AGENTS_REL, 'root authority') + await writeAuthorityFile(nestedDir, AGENTS_REL, 'nested authority') + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + '# Heartbeat', + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ].join('\n'), + ) + + const snapshot = await loadAutonomyAuthority({ + rootDir: tempDir, + currentDir: nestedDir, + }) + + expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([ + AUTONOMY_AGENTS_PATH_POSIX, + `packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`, + ]) + expect(snapshot.agentsContent).toContain('root authority') + expect(snapshot.agentsContent).toContain('nested authority') + expect(snapshot.heartbeatContent).toContain('# Heartbeat') + expect(snapshot.heartbeatTasks).toEqual([ + { + name: 'inbox', + interval: '30m', + prompt: 'Check inbox', + steps: [], + }, + ]) + }) + + test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => { + const nestedDir = await createTempSubdir(tempDir, 'child') + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + '# Root heartbeat\nRemember the root task', + ) + await writeAuthorityFile( + nestedDir, + HEARTBEAT_REL, + '# Nested heartbeat\nThis should not be used', + ) + + const snapshot = await loadAutonomyAuthority({ + rootDir: tempDir, + currentDir: nestedDir, + }) + + expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL)) + expect(snapshot.heartbeatContent).toContain('Root heartbeat') + expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat') + }) + + test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => { + const prompt = await buildAutonomyTurnPrompt({ + basePrompt: 'Run the scheduled task.', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + }) + + expect(prompt).toBe('Run the scheduled task.') + }) + + test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => { + const nestedDir = await createTempSubdir(tempDir, 'nested') + await writeAuthorityFile(tempDir, AGENTS_REL, 'root rules') + await writeAuthorityFile(nestedDir, AGENTS_REL, 'nested rules') + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + 'Check heartbeat directives', + ) + + const scheduledPrompt = await buildAutonomyTurnPrompt({ + basePrompt: 'Review the nightly report.', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: nestedDir, + }) + const tickPrompt = await buildAutonomyTurnPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: nestedDir, + }) + + expect(scheduledPrompt).toContain( + 'This prompt was generated automatically. Follow the workspace authority below before acting.', + ) + expect(scheduledPrompt).toContain('') + expect(scheduledPrompt).toContain('root rules') + expect(scheduledPrompt).toContain('nested rules') + expect(scheduledPrompt).toContain('Check heartbeat directives') + expect(scheduledPrompt).toContain('Review the nightly report.') + + expect(tickPrompt).toContain( + 'This is an autonomous proactive turn. Follow the workspace authority below before acting.', + ) + expect(tickPrompt).toContain('12:00:00') + }) + + test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => { + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ].join('\n'), + ) + + const first = await buildAutonomyTurnPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + nowMs: 0, + }) + const second = await buildAutonomyTurnPrompt({ + basePrompt: '12:10:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + nowMs: 10 * 60_000, + }) + const third = await buildAutonomyTurnPrompt({ + basePrompt: '12:31:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + nowMs: 31 * 60_000, + }) + + expect(first).toContain('Due HEARTBEAT.md tasks:') + expect(first).toContain('- inbox (30m): Check inbox') + expect(second).not.toContain('Due HEARTBEAT.md tasks:') + expect(third).toContain('Due HEARTBEAT.md tasks:') + }) + + test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => { + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ' - name: weekly-report', + ' interval: 7d', + ' prompt: "Ship the weekly report"', + ' steps:', + ' - name: gather', + ' prompt: "Gather weekly inputs"', + ' - name: draft', + ' prompt: "Draft the weekly report"', + ' wait_for: manual', + ].join('\n'), + ) + + const snapshot = await loadAutonomyAuthority({ + rootDir: tempDir, + currentDir: tempDir, + }) + const prompt = await buildAutonomyTurnPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + nowMs: 0, + }) + + expect(snapshot.heartbeatTasks).toEqual([ + { + name: 'inbox', + interval: '30m', + prompt: 'Check inbox', + steps: [], + }, + { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { + name: 'gather', + prompt: 'Gather weekly inputs', + }, + { + name: 'draft', + prompt: 'Draft the weekly report', + waitFor: 'manual', + }, + ], + }, + ]) + expect(prompt).toContain('- inbox (30m): Check inbox') + expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report') + expect(prompt).not.toContain('- gather (') + }) + + test('parseHeartbeatAuthorityTasks ignores tasks: literals inside markdown code fences', () => { + const content = [ + '# HEARTBEAT.md', + '', + '```yaml', + 'tasks:', + ' - name: not-a-real-task', + ' interval: 1m', + ' prompt: "would-be-shadowed"', + '```', + '', + 'tasks:', + ' - name: real-task', + ' interval: 30m', + ' prompt: "Real prompt"', + ].join('\n') + + const parsed = parseHeartbeatAuthorityTasks(content) + + expect(parsed).toHaveLength(1) + expect(parsed[0]).toMatchObject({ + name: 'real-task', + interval: '30m', + prompt: 'Real prompt', + }) + }) + + test('parseHeartbeatAuthorityTasks ignores tasks: literals inside tilde markdown code fences', () => { + const content = [ + '# HEARTBEAT.md', + '', + '~~~yaml', + 'tasks:', + ' - name: not-a-real-task', + ' interval: 1m', + ' prompt: "would-be-shadowed"', + '~~~', + '', + 'tasks:', + ' - name: real-task', + ' interval: 30m', + ' prompt: "Real prompt"', + ].join('\n') + + const parsed = parseHeartbeatAuthorityTasks(content) + + expect(parsed).toHaveLength(1) + expect(parsed[0]).toMatchObject({ + name: 'real-task', + interval: '30m', + prompt: 'Real prompt', + }) + }) + + test('parseHeartbeatAuthorityTasks parses real tasks even when documentation precedes them', () => { + const content = [ + '# Heartbeat docs', + '', + 'See `tasks:` below — the parser keys on the literal at column 0.', + '', + 'tasks:', + ' - name: weekly', + ' interval: 7d', + ' prompt: "Ship report"', + ].join('\n') + + const parsed = parseHeartbeatAuthorityTasks(content) + + // Inline `tasks:` mention does NOT collide because it's not at column 0 + // on its own line — the existing line.trim() === 'tasks:' guard handles + // that case. This test pins the behaviour. + expect(parsed).toHaveLength(1) + expect(parsed[0]?.name).toBe('weekly') + }) +}) diff --git a/src/utils/__tests__/autonomyFlows.test.ts b/src/utils/__tests__/autonomyFlows.test.ts new file mode 100644 index 000000000..8cf504fb8 --- /dev/null +++ b/src/utils/__tests__/autonomyFlows.test.ts @@ -0,0 +1,1226 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + resetStateForTests, + setOriginalCwd, + setProjectRoot, +} from '../../bootstrap/state' +import { + createManagedAutonomyFlowKey, + DEFAULT_AUTONOMY_OWNER_KEY, + formatAutonomyFlowDetail, + formatAutonomyFlowsList, + formatAutonomyFlowsStatus, + getAutonomyFlowById, + listAutonomyFlows, + markManagedAutonomyFlowStepCancelled, + markManagedAutonomyFlowStepCompleted, + markManagedAutonomyFlowStepFailed, + markManagedAutonomyFlowStepRunning, + queueManagedAutonomyFlowStepRun, + requestManagedAutonomyFlowCancel, + resolveAutonomyFlowsPath, + resumeManagedAutonomyFlow, + startManagedAutonomyFlow, + type AutonomyFlowRecord, + type ManagedAutonomyFlowStepDefinition, +} from '../autonomyFlows' +import { AUTONOMY_DIR } from '../autonomyAuthority' +import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system' + +let tempDir = '' + +beforeEach(async () => { + tempDir = await createTempDir('autonomy-flows-') + resetStateForTests() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) +}) + +afterEach(async () => { + resetStateForTests() + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +const TWO_STEPS: ManagedAutonomyFlowStepDefinition[] = [ + { name: 'gather', prompt: 'Gather inputs' }, + { name: 'draft', prompt: 'Draft the report' }, +] + +const STEPS_WITH_WAIT: ManagedAutonomyFlowStepDefinition[] = [ + { name: 'gather', prompt: 'Gather inputs', waitFor: 'manual-review' }, + { name: 'draft', prompt: 'Draft the report' }, +] + +describe('createManagedAutonomyFlowKey', () => { + test('builds deterministic key from trigger and sourceId', () => { + const key = createManagedAutonomyFlowKey({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + goal: 'Do stuff', + }) + expect(key).toBe('managed:scheduled-task:cron-1') + }) + + test('uses sourceLabel when sourceId is absent', () => { + const key = createManagedAutonomyFlowKey({ + trigger: 'scheduled-task', + sourceLabel: 'nightly', + goal: 'Do stuff', + }) + expect(key).toBe('managed:scheduled-task:nightly') + }) + + test('falls back to goal when no sourceId or sourceLabel', () => { + const key = createManagedAutonomyFlowKey({ + trigger: 'scheduled-task', + goal: 'Do stuff', + }) + expect(key).toBe('managed:scheduled-task:Do stuff') + }) + + test('uses heartbeat-loop for proactive-tick without sourceId', () => { + const key = createManagedAutonomyFlowKey({ + trigger: 'proactive-tick', + goal: 'Tick goal', + }) + expect(key).toBe('managed:proactive-tick:heartbeat-loop') + }) +}) + +describe('resolveAutonomyFlowsPath', () => { + test('resolves to flows.json under the autonomy dir', () => { + const path = resolveAutonomyFlowsPath(tempDir) + expect(path).toContain(AUTONOMY_DIR) + expect(path).toContain('flows.json') + }) +}) + +describe('listAutonomyFlows', () => { + test('returns empty array when no file exists', async () => { + const flows = await listAutonomyFlows(tempDir) + expect(flows).toEqual([]) + }) + + test('reads and normalizes flow records from disk', async () => { + const flowsPath = resolveAutonomyFlowsPath(tempDir) + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + flowsPath, + JSON.stringify({ + flows: [ + { + flowId: 'flow-1', + flowKey: 'managed:scheduled-task:cron-1', + syncMode: 'managed', + trigger: 'scheduled-task', + status: 'queued', + rootDir: tempDir, + goal: 'Test goal', + createdAt: 100, + updatedAt: 200, + revision: 1, + runCount: 0, + ownerKey: DEFAULT_AUTONOMY_OWNER_KEY, + currentDir: tempDir, + boundary: [ + ' src/utils/** ', + '/absolute/not-allowed', + 'src\\windows', + '../outside', + 'src/utils/**', + 'docs/*.md', + ], + stateJson: { + currentStepIndex: 0, + steps: [ + { + stepId: 'step-1', + name: 'gather', + prompt: 'Gather inputs', + status: 'pending', + }, + ], + }, + }, + ], + }), + 'utf-8', + ) + + const flows = await listAutonomyFlows(tempDir) + expect(flows).toHaveLength(1) + expect(flows[0]?.flowId).toBe('flow-1') + expect(flows[0]?.syncMode).toBe('managed') + expect(flows[0]?.boundary).toEqual(['src/utils/**', 'docs/*.md']) + expect(flows[0]?.stateJson?.steps).toHaveLength(1) + }) + + test('filters out invalid records', async () => { + const flowsPath = resolveAutonomyFlowsPath(tempDir) + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + flowsPath, + JSON.stringify({ + flows: [ + { + flowId: 'valid-flow', + flowKey: 'k', + trigger: 'scheduled-task', + status: 'queued', + rootDir: tempDir, + createdAt: 1, + updatedAt: 2, + goal: 'g', + revision: 0, + runCount: 0, + ownerKey: 'main-thread', + currentDir: tempDir, + }, + { bad: true }, + null, + ], + }), + 'utf-8', + ) + + const flows = await listAutonomyFlows(tempDir) + expect(flows).toHaveLength(1) + expect(flows[0]?.flowId).toBe('valid-flow') + }) + + test('returns empty array for malformed JSON', async () => { + const flowsPath = resolveAutonomyFlowsPath(tempDir) + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile(flowsPath, 'not json', 'utf-8') + + const flows = await listAutonomyFlows(tempDir) + expect(flows).toEqual([]) + }) + + test('persistence pruning keeps active flows ahead of recent terminal history', async () => { + const flows: AutonomyFlowRecord[] = [ + { + flowId: 'old-active', + flowKey: 'managed:scheduled-task:old-active', + syncMode: 'managed', + ownerKey: DEFAULT_AUTONOMY_OWNER_KEY, + revision: 1, + trigger: 'scheduled-task', + status: 'queued', + goal: 'old active', + rootDir: tempDir, + currentDir: tempDir, + runCount: 0, + createdAt: 1, + updatedAt: 1, + }, + ...Array.from({ length: 100 }, (_, index) => ({ + flowId: `history-${index}`, + flowKey: `managed:scheduled-task:history-${index}`, + syncMode: 'managed' as const, + ownerKey: DEFAULT_AUTONOMY_OWNER_KEY, + revision: 1, + trigger: 'scheduled-task' as const, + status: 'succeeded' as const, + goal: `history ${index}`, + rootDir: tempDir, + currentDir: tempDir, + runCount: 1, + createdAt: 1_000 + index, + updatedAt: 1_000 + index, + endedAt: 2_000 + index, + })), + ] + const flowsPath = resolveAutonomyFlowsPath(tempDir) + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + flowsPath, + `${JSON.stringify({ flows }, null, 2)}\n`, + 'utf-8', + ) + + await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'fresh active', + steps: TWO_STEPS, + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'fresh-active', + nowMs: 9_999, + }) + + const persisted = await listAutonomyFlows(tempDir) + expect(persisted).toHaveLength(100) + expect(persisted.some(flow => flow.flowId === 'old-active')).toBe(true) + expect(persisted.some(flow => flow.flowId === 'history-0')).toBe(false) + }) +}) + +describe('startManagedAutonomyFlow', () => { + test('returns null when steps array is empty', async () => { + const result = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Test', + steps: [], + rootDir: tempDir, + }) + expect(result).toBeNull() + }) + + test('creates a new flow with queued status and returns nextStep', async () => { + const result = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Ship report', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + + expect(result).not.toBeNull() + expect(result!.started).toBe(true) + expect(result!.flow.status).toBe('queued') + expect(result!.flow.goal).toBe('Ship report') + expect(result!.flow.currentStep).toBe('gather') + expect(result!.flow.stateJson?.steps).toHaveLength(2) + expect(result!.flow.stateJson?.steps[0]?.status).toBe('pending') + expect(result!.nextStep).toBeDefined() + expect(result!.nextStep!.stepIndex).toBe(0) + expect(result!.nextStep!.step.name).toBe('gather') + }) + + test('normalizes and preserves boundary across completed flow restarts', async () => { + const first = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Scoped flow', + steps: [{ name: 'only', prompt: 'Do it' }], + rootDir: tempDir, + sourceId: 'scoped-src', + boundary: [' src/utils/** ', 'src\\bad', '/absolute', 'docs/*.md'], + nowMs: 1000, + }) + const flowId = first!.flow.flowId + + expect(first!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md']) + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: first!.nextStep!.step.stepId, + stepIndex: 0, + runId: 'run-1', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 3000, + }) + + const restarted = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Scoped flow', + steps: [{ name: 'only', prompt: 'Do it again' }], + rootDir: tempDir, + sourceId: 'scoped-src', + nowMs: 4000, + }) + + expect(restarted!.started).toBe(true) + expect(restarted!.flow.flowId).toBe(flowId) + expect(restarted!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md']) + }) + + test('sets status=waiting when first step has waitFor', async () => { + const result = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Wait flow', + steps: STEPS_WITH_WAIT, + rootDir: tempDir, + nowMs: 1000, + }) + + expect(result!.started).toBe(true) + expect(result!.flow.status).toBe('waiting') + expect(result!.flow.waitJson).toBeDefined() + expect(result!.flow.waitJson!.reason).toBe('manual-review') + expect(result!.nextStep).toBeUndefined() + }) + + test('returns started=false if active flow with same key exists', async () => { + const first = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Ship report', + steps: TWO_STEPS, + rootDir: tempDir, + sourceId: 'dup-key', + nowMs: 1000, + }) + expect(first!.started).toBe(true) + + const second = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Ship report', + steps: TWO_STEPS, + rootDir: tempDir, + sourceId: 'dup-key', + nowMs: 2000, + }) + expect(second!.started).toBe(false) + expect(second!.flow.flowId).toBe(first!.flow.flowId) + }) + + test('reuses flowId when restarting a completed flow', async () => { + // Start and complete a flow + const first = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Repeatable', + steps: [{ name: 'only', prompt: 'Do it' }], + rootDir: tempDir, + sourceId: 'repeat-src', + nowMs: 1000, + }) + const flowId = first!.flow.flowId + + // Queue and complete + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: first!.nextStep!.step.stepId, + stepIndex: 0, + runId: 'run-1', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 3000, + }) + await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 4000, + }) + + // Verify it completed + const completed = await getAutonomyFlowById(flowId, tempDir) + expect(completed!.status).toBe('succeeded') + + // Restart with the same key + const restarted = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Repeatable', + steps: [{ name: 'only', prompt: 'Do it again' }], + rootDir: tempDir, + sourceId: 'repeat-src', + nowMs: 5000, + }) + + expect(restarted!.started).toBe(true) + expect(restarted!.flow.flowId).toBe(flowId) + expect(restarted!.flow.revision).toBeGreaterThan(first!.flow.revision) + }) + + test('persists the flow to disk', async () => { + await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Persist test', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + + const raw = await readFile(resolveAutonomyFlowsPath(tempDir), 'utf-8') + const parsed = JSON.parse(raw) as { flows: AutonomyFlowRecord[] } + expect(parsed.flows).toHaveLength(1) + expect(parsed.flows[0]?.goal).toBe('Persist test') + }) +}) + +describe('full lifecycle: start → queue → running → completed → succeeded', () => { + test('advances through all steps to succeeded', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Full lifecycle', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + // Queue step 0 + const queued = await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + expect(queued!.status).toBe('queued') + expect(queued!.latestRunId).toBe('run-0') + expect(queued!.runCount).toBe(1) + + // Mark step 0 running + const running = await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + expect(running!.status).toBe('running') + expect(running!.startedAt).toBe(3000) + + // Complete step 0 — should auto-advance to step 1 + const advanced = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 4000, + }) + expect(advanced!.flow.status).toBe('queued') + expect(advanced!.flow.currentStep).toBe('draft') + expect(advanced!.nextStep).toBeDefined() + expect(advanced!.nextStep!.stepIndex).toBe(1) + const step1Id = advanced!.nextStep!.step.stepId + + // Queue step 1 + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step1Id, + stepIndex: 1, + runId: 'run-1', + rootDir: tempDir, + nowMs: 5000, + }) + + // Mark step 1 running + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 6000, + }) + + // Complete step 1 — no more steps, should succeed + const final = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 7000, + }) + expect(final!.flow.status).toBe('succeeded') + expect(final!.flow.endedAt).toBe(7000) + expect(final!.nextStep).toBeUndefined() + + // Verify persisted state + const persisted = await getAutonomyFlowById(flowId, tempDir) + expect(persisted!.status).toBe('succeeded') + expect(persisted!.stateJson?.steps[0]?.status).toBe('completed') + expect(persisted!.stateJson?.steps[1]?.status).toBe('completed') + }) +}) + +describe('lifecycle: step failure', () => { + test('marks flow as failed when step fails', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Fail lifecycle', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + + const failed = await markManagedAutonomyFlowStepFailed({ + flowId, + runId: 'run-0', + error: 'Something broke', + rootDir: tempDir, + nowMs: 4000, + }) + + expect(failed!.flow.status).toBe('failed') + expect(failed!.flow.lastError).toBe('Something broke') + expect(failed!.flow.blockedRunId).toBe('run-0') + expect(failed!.flow.endedAt).toBe(4000) + expect(failed!.flow.stateJson?.steps[0]?.status).toBe('failed') + expect(failed!.flow.stateJson?.steps[0]?.error).toBe('Something broke') + }) +}) + +describe('lifecycle: waitFor → resume', () => { + test('starts waiting then resumes and completes', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Wait then resume', + steps: STEPS_WITH_WAIT, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + expect(startResult!.flow.status).toBe('waiting') + expect(startResult!.nextStep).toBeUndefined() + + // Resume the waiting flow + const resumed = await resumeManagedAutonomyFlow({ + flowId, + rootDir: tempDir, + nowMs: 2000, + }) + expect(resumed!.flow.status).toBe('queued') + expect(resumed!.flow.waitJson).toBeUndefined() + expect(resumed!.nextStep).toBeDefined() + expect(resumed!.nextStep!.step.name).toBe('gather') + + // Queue, run, complete step 0 + const step0Id = resumed!.nextStep!.step.stepId + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 4000, + }) + const afterStep0 = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 5000, + }) + + // Step 1 has no waitFor, so should auto-queue + expect(afterStep0!.flow.status).toBe('queued') + expect(afterStep0!.nextStep!.step.name).toBe('draft') + + // Complete step 1 + const step1Id = afterStep0!.nextStep!.step.stepId + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step1Id, + stepIndex: 1, + runId: 'run-1', + rootDir: tempDir, + nowMs: 6000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 7000, + }) + const final = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-1', + rootDir: tempDir, + nowMs: 8000, + }) + expect(final!.flow.status).toBe('succeeded') + }) +}) + +describe('lifecycle: next step has waitFor', () => { + test('completing a step transitions to waiting when next step has waitFor', async () => { + const steps: ManagedAutonomyFlowStepDefinition[] = [ + { name: 'step-a', prompt: 'Do A' }, + { name: 'step-b', prompt: 'Do B', waitFor: 'approval' }, + ] + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Wait mid-flow', + steps, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + const afterStep0 = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 4000, + }) + + expect(afterStep0!.flow.status).toBe('waiting') + expect(afterStep0!.flow.waitJson).toBeDefined() + expect(afterStep0!.flow.waitJson!.reason).toBe('approval') + expect(afterStep0!.flow.waitJson!.stepName).toBe('step-b') + expect(afterStep0!.nextStep).toBeUndefined() + }) +}) + +describe('requestManagedAutonomyFlowCancel', () => { + test('immediate cancel when not running (queued)', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Cancel test', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + + const cancelResult = await requestManagedAutonomyFlowCancel({ + flowId, + rootDir: tempDir, + nowMs: 2000, + }) + + expect(cancelResult!.accepted).toBe(true) + expect(cancelResult!.flow.status).toBe('cancelled') + expect(cancelResult!.flow.endedAt).toBe(2000) + }) + + test('deferred cancel when step is running, completes on next step completion', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Deferred cancel', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + // Queue and start running + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + + // Request cancel while running — should be deferred + const cancelResult = await requestManagedAutonomyFlowCancel({ + flowId, + rootDir: tempDir, + nowMs: 4000, + }) + expect(cancelResult!.accepted).toBe(true) + expect(cancelResult!.flow.status).toBe('running') // Still running + expect(cancelResult!.flow.cancelRequestedAt).toBe(4000) + + // Complete the step — cancel should now take effect + const completed = await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 5000, + }) + expect(completed!.flow.status).toBe('cancelled') + expect(completed!.flow.endedAt).toBe(5000) + // Remaining steps should be cancelled + expect(completed!.flow.stateJson?.steps[1]?.status).toBe('cancelled') + }) + + test('returns accepted=false for already completed flow', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Already done', + steps: [{ name: 'only', prompt: 'Do it' }], + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const stepId = startResult!.nextStep!.step.stepId + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + await markManagedAutonomyFlowStepCompleted({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 4000, + }) + + const cancelResult = await requestManagedAutonomyFlowCancel({ + flowId, + rootDir: tempDir, + nowMs: 5000, + }) + expect(cancelResult!.accepted).toBe(false) + }) + + test('returns null for unknown flowId', async () => { + const cancelResult = await requestManagedAutonomyFlowCancel({ + flowId: 'nonexistent', + rootDir: tempDir, + nowMs: 1000, + }) + expect(cancelResult).toBeNull() + }) +}) + +describe('markManagedAutonomyFlowStepCancelled', () => { + test('cancels the step and all remaining steps', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Cancel step', + steps: [ + { name: 's1', prompt: 'p1' }, + { name: 's2', prompt: 'p2' }, + { name: 's3', prompt: 'p3' }, + ], + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + + const cancelled = await markManagedAutonomyFlowStepCancelled({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + + expect(cancelled!.flow.status).toBe('cancelled') + expect(cancelled!.flow.endedAt).toBe(3000) + expect(cancelled!.flow.stateJson?.steps[0]?.status).toBe('cancelled') + expect(cancelled!.flow.stateJson?.steps[1]?.status).toBe('cancelled') + expect(cancelled!.flow.stateJson?.steps[2]?.status).toBe('cancelled') + }) +}) + +describe('resumeManagedAutonomyFlow', () => { + test('returns unchanged flow when not in waiting status', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Not waiting', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + + const resumed = await resumeManagedAutonomyFlow({ + flowId, + rootDir: tempDir, + nowMs: 2000, + }) + + // Flow is queued, not waiting, so resume should not change status + expect(resumed!.flow.status).toBe('queued') + }) + + test('cancels when cancel was requested during wait', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Cancel during wait', + steps: STEPS_WITH_WAIT, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + expect(startResult!.flow.status).toBe('waiting') + + // Request cancel while waiting + await requestManagedAutonomyFlowCancel({ + flowId, + rootDir: tempDir, + nowMs: 2000, + }) + + // The flow should already be cancelled since it's not running + const flow = await getAutonomyFlowById(flowId, tempDir) + expect(flow!.status).toBe('cancelled') + }) +}) + +describe('getAutonomyFlowById', () => { + test('returns null when flow does not exist', async () => { + const flow = await getAutonomyFlowById('nonexistent', tempDir) + expect(flow).toBeNull() + }) + + test('returns the flow when it exists', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Find me', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + + const found = await getAutonomyFlowById(flowId, tempDir) + expect(found).not.toBeNull() + expect(found!.flowId).toBe(flowId) + expect(found!.goal).toBe('Find me') + }) +}) + +describe('queueManagedAutonomyFlowStepRun edge cases', () => { + test('returns null for unknown flowId', async () => { + const result = await queueManagedAutonomyFlowStepRun({ + flowId: 'nonexistent', + stepId: 'step-0', + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 1000, + }) + expect(result).toBeNull() + }) + + test('returns unchanged flow for mismatched stepId', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Mismatch test', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + + const result = await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: 'wrong-step-id', + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + + // Should return the flow unchanged (still pending, not queued step) + expect(result).not.toBeNull() + expect(result!.stateJson?.steps[0]?.status).toBe('pending') + }) +}) + +describe('markManagedAutonomyFlowStepRunning edge cases', () => { + test('returns null for unknown flowId', async () => { + const result = await markManagedAutonomyFlowStepRunning({ + flowId: 'nonexistent', + runId: 'run-0', + rootDir: tempDir, + nowMs: 1000, + }) + expect(result).toBeNull() + }) +}) + +describe('markManagedAutonomyFlowStepFailed with cancelRequestedAt', () => { + test('marks flow as cancelled (not failed) when cancel was requested', async () => { + const startResult = await startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Fail after cancel', + steps: TWO_STEPS, + rootDir: tempDir, + nowMs: 1000, + }) + const flowId = startResult!.flow.flowId + const step0Id = startResult!.nextStep!.step.stepId + + await queueManagedAutonomyFlowStepRun({ + flowId, + stepId: step0Id, + stepIndex: 0, + runId: 'run-0', + rootDir: tempDir, + nowMs: 2000, + }) + await markManagedAutonomyFlowStepRunning({ + flowId, + runId: 'run-0', + rootDir: tempDir, + nowMs: 3000, + }) + + // Request cancel while running + await requestManagedAutonomyFlowCancel({ + flowId, + rootDir: tempDir, + nowMs: 4000, + }) + + // Step fails — should result in cancelled (because cancel was requested) + const result = await markManagedAutonomyFlowStepFailed({ + flowId, + runId: 'run-0', + error: 'step error', + rootDir: tempDir, + nowMs: 5000, + }) + + expect(result!.flow.status).toBe('cancelled') + expect(result!.flow.lastError).toBe('step error') + }) +}) + +describe('formatAutonomyFlowsStatus', () => { + test('formats counts for various statuses', () => { + const flows: AutonomyFlowRecord[] = [ + makeMinimalFlow({ status: 'queued' }), + makeMinimalFlow({ status: 'running' }), + makeMinimalFlow({ status: 'succeeded' }), + makeMinimalFlow({ status: 'succeeded' }), + makeMinimalFlow({ status: 'failed' }), + ] + + const status = formatAutonomyFlowsStatus(flows) + expect(status).toContain('Autonomy flows: 5') + expect(status).toContain('Queued: 1') + expect(status).toContain('Running: 1') + expect(status).toContain('Succeeded: 2') + expect(status).toContain('Failed: 1') + expect(status).toContain('Cancelled: 0') + }) +}) + +describe('formatAutonomyFlowsList', () => { + test('returns message when no flows', () => { + const list = formatAutonomyFlowsList([]) + expect(list).toBe('No autonomy flows recorded.') + }) + + test('formats flow list with source and step info', () => { + const flows: AutonomyFlowRecord[] = [ + makeMinimalFlow({ + flowId: 'flow-abc', + goal: 'Test goal', + currentStep: 'gather', + sourceLabel: 'nightly', + revision: 3, + runCount: 2, + status: 'queued', + }), + ] + + const list = formatAutonomyFlowsList(flows) + expect(list).toContain('flow-abc') + expect(list).toContain('nightly') + expect(list).toContain('step=gather') + expect(list).toContain('rev=3') + expect(list).toContain('goal=Test goal') + expect(list).toContain('runs=2') + }) + + test('respects limit parameter', () => { + const flows = Array.from({ length: 5 }, (_, i) => + makeMinimalFlow({ flowId: `flow-${i}` }), + ) + + const list = formatAutonomyFlowsList(flows, 2) + expect(list).toContain('flow-0') + expect(list).toContain('flow-1') + expect(list).not.toContain('flow-2') + }) + + test('shows waiting info for waiting flows', () => { + const flows: AutonomyFlowRecord[] = [ + makeMinimalFlow({ + status: 'waiting', + waitJson: { + reason: 'manual-review', + stepId: 's1', + stepName: 'review', + stepIndex: 1, + }, + }), + ] + + const list = formatAutonomyFlowsList(flows) + expect(list).toContain('waiting=manual-review') + }) +}) + +describe('formatAutonomyFlowDetail', () => { + test('returns not found for null', () => { + expect(formatAutonomyFlowDetail(null)).toBe('Autonomy flow not found.') + expect(formatAutonomyFlowDetail(undefined)).toBe('Autonomy flow not found.') + }) + + test('formats full flow detail with steps', () => { + const flow = makeMinimalFlow({ + flowId: 'detail-flow', + flowKey: 'managed:scheduled-task:src', + revision: 2, + trigger: 'scheduled-task', + status: 'running', + goal: 'Detail test', + sourceLabel: 'nightly', + ownerKey: 'main-thread', + currentStep: 'gather', + runCount: 1, + latestRunId: 'run-0', + stateJson: { + currentStepIndex: 0, + steps: [ + { + stepId: 's0', + name: 'gather', + prompt: 'Gather inputs', + status: 'running', + runId: 'run-0', + }, + { + stepId: 's1', + name: 'draft', + prompt: 'Draft report', + status: 'pending', + waitFor: 'approval', + }, + ], + }, + }) + + const detail = formatAutonomyFlowDetail(flow) + expect(detail).toContain('Flow: detail-flow') + expect(detail).toContain('Key: managed:scheduled-task:src') + expect(detail).toContain('Mode: managed') + expect(detail).toContain('Revision: 2') + expect(detail).toContain('Status: running') + expect(detail).toContain('Goal: Detail test') + expect(detail).toContain('Source: nightly') + expect(detail).toContain('Current step: gather') + expect(detail).toContain('1. gather | running | run=run-0') + expect(detail).toContain('2. draft | pending | run=none | wait=approval') + }) + + test('includes error and blocked info', () => { + const flow = makeMinimalFlow({ + status: 'failed', + lastError: 'step exploded', + blockedRunId: 'run-x', + blockedSummary: 'step exploded', + }) + + const detail = formatAutonomyFlowDetail(flow) + expect(detail).toContain('Error: step exploded') + expect(detail).toContain('Blocked run: run-x') + expect(detail).toContain('Blocked summary: step exploded') + }) + + test('includes cancel requested timestamp', () => { + const flow = makeMinimalFlow({ + cancelRequestedAt: 99999, + }) + const detail = formatAutonomyFlowDetail(flow) + expect(detail).toContain('Cancel requested:') + }) +}) + +describe('concurrent startManagedAutonomyFlow calls', () => { + test('do not lose updates', async () => { + await Promise.all([ + startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Flow A', + steps: [{ name: 'a', prompt: 'A' }], + rootDir: tempDir, + sourceId: 'src-a', + nowMs: 1000, + }), + startManagedAutonomyFlow({ + trigger: 'scheduled-task', + goal: 'Flow B', + steps: [{ name: 'b', prompt: 'B' }], + rootDir: tempDir, + sourceId: 'src-b', + nowMs: 1000, + }), + ]) + + const flows = await listAutonomyFlows(tempDir) + expect(flows).toHaveLength(2) + const goals = new Set(flows.map(f => f.goal)) + expect(goals).toEqual(new Set(['Flow A', 'Flow B'])) + }) +}) + +// Helper to make minimal flow records for formatter tests +function makeMinimalFlow( + overrides: Partial = {}, +): AutonomyFlowRecord { + return { + flowId: 'flow-0', + flowKey: 'managed:scheduled-task:src', + syncMode: 'managed', + ownerKey: DEFAULT_AUTONOMY_OWNER_KEY, + revision: 1, + trigger: 'scheduled-task', + status: 'queued', + goal: 'Default goal', + rootDir: '/tmp/test', + currentDir: '/tmp/test', + runCount: 0, + createdAt: 1000, + updatedAt: 2000, + ...overrides, + } +} diff --git a/src/utils/__tests__/autonomyPersistence.test.ts b/src/utils/__tests__/autonomyPersistence.test.ts new file mode 100644 index 000000000..f16877206 --- /dev/null +++ b/src/utils/__tests__/autonomyPersistence.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system' + +// Mock the lockfile module so tests don't need real file locks +mock.module('../lockfile.js', () => ({ + lock: async (_file: string, _options?: unknown) => { + return async () => {} + }, +})) + +let tempDir = '' + +beforeEach(async () => { + tempDir = await createTempDir('autonomy-persistence-') +}) + +afterEach(async () => { + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +describe('withAutonomyPersistenceLock', () => { + test('runs fn and returns its result', async () => { + const { withAutonomyPersistenceLock } = await import( + '../autonomyPersistence' + ) + const result = await withAutonomyPersistenceLock(tempDir, async () => { + return 42 + }) + expect(result).toBe(42) + }) + + test('creates the autonomy directory and lock file', async () => { + const { withAutonomyPersistenceLock } = await import( + '../autonomyPersistence' + ) + await withAutonomyPersistenceLock(tempDir, async () => 'ok') + + const autonomyDir = join(tempDir, '.claude', 'autonomy') + expect(existsSync(autonomyDir)).toBe(true) + }) + + test('propagates errors from the inner function', async () => { + const { withAutonomyPersistenceLock } = await import( + '../autonomyPersistence' + ) + await expect( + withAutonomyPersistenceLock(tempDir, async () => { + throw new Error('inner failure') + }), + ).rejects.toThrow('inner failure') + }) + + test('releases same-root lock bookkeeping after success and failure', async () => { + const { + getAutonomyPersistenceLockCountForTests, + withAutonomyPersistenceLock, + } = await import('../autonomyPersistence') + + expect(getAutonomyPersistenceLockCountForTests()).toBe(0) + + await withAutonomyPersistenceLock(tempDir, async () => 'ok') + expect(getAutonomyPersistenceLockCountForTests()).toBe(0) + + await expect( + withAutonomyPersistenceLock(tempDir, async () => { + throw new Error('inner failure') + }), + ).rejects.toThrow('inner failure') + expect(getAutonomyPersistenceLockCountForTests()).toBe(0) + }) + + test('serializes concurrent calls on the same rootDir', async () => { + const { withAutonomyPersistenceLock } = await import( + '../autonomyPersistence' + ) + const order: number[] = [] + + const first = withAutonomyPersistenceLock(tempDir, async () => { + order.push(1) + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 20)) + order.push(2) + return 'first' + }) + + const second = withAutonomyPersistenceLock(tempDir, async () => { + order.push(3) + return 'second' + }) + + const [r1, r2] = await Promise.all([first, second]) + + expect(r1).toBe('first') + expect(r2).toBe('second') + // The second call must wait for the first to finish + expect(order).toEqual([1, 2, 3]) + }) + + test('allows parallel calls on different rootDirs', async () => { + const { withAutonomyPersistenceLock } = await import( + '../autonomyPersistence' + ) + const tempDir2 = await createTempDir('autonomy-persistence-2-') + + try { + const order: string[] = [] + + const first = withAutonomyPersistenceLock(tempDir, async () => { + order.push('a-start') + await new Promise(resolve => setTimeout(resolve, 10)) + order.push('a-end') + return 'a' + }) + + const second = withAutonomyPersistenceLock(tempDir2, async () => { + order.push('b-start') + await new Promise(resolve => setTimeout(resolve, 10)) + order.push('b-end') + return 'b' + }) + + const [r1, r2] = await Promise.all([first, second]) + expect(r1).toBe('a') + expect(r2).toBe('b') + // Both should start before either ends (parallel) + expect(order.indexOf('a-start')).toBeLessThan(order.indexOf('a-end')) + expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('b-end')) + } finally { + await cleanupTempDir(tempDir2) + } + }) +}) diff --git a/src/utils/__tests__/autonomyQueueLifecycle.test.ts b/src/utils/__tests__/autonomyQueueLifecycle.test.ts new file mode 100644 index 000000000..2449f8405 --- /dev/null +++ b/src/utils/__tests__/autonomyQueueLifecycle.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { createTempDir, cleanupTempDir } from '../../../tests/mocks/file-system' +import { getAttachmentMessages } from '../attachments' +import { + createAutonomyQueuedPrompt, + createProactiveAutonomyCommands, + getAutonomyRunById, + markAutonomyRunCancelled, + startManagedAutonomyFlowFromHeartbeatTask, +} from '../autonomyRuns' +import { getAutonomyFlowById, listAutonomyFlows } from '../autonomyFlows' +import { + cancelQueuedAutonomyCommands, + claimConsumableQueuedAutonomyCommands, + finalizeAutonomyCommandsForTurn, + partitionConsumableQueuedAutonomyCommands, +} from '../autonomyQueueLifecycle' +import { + enqueue, + getCommandsByMaxPriority, + remove as removeFromQueue, + resetCommandQueue, +} from '../messageQueueManager' + +let tempDir = '' +let extraTempDirs: string[] = [] + +beforeEach(async () => { + tempDir = await createTempDir('autonomy-queue-lifecycle-') + extraTempDirs = [] + resetCommandQueue() +}) + +afterEach(async () => { + resetCommandQueue() + if (tempDir) { + await cleanupTempDir(tempDir) + } + for (const extraTempDir of extraTempDirs) { + await cleanupTempDir(extraTempDir) + } +}) + +describe('autonomyQueueLifecycle', () => { + async function consumeQueuedAutonomyAttachmentTurn() { + const previousDisableAttachments = + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1' + try { + const snapshot = getCommandsByMaxPriority('later') + const claim = await claimConsumableQueuedAutonomyCommands( + snapshot, + tempDir, + ) + removeFromQueue(claim.staleCommands) + removeFromQueue(claim.claimedCommands) + + const attachments = [] + for await (const attachment of getAttachmentMessages( + null, + {} as never, + null, + claim.attachmentCommands, + [], + )) { + attachments.push(attachment) + } + + const consumedCommands = claim.attachmentCommands.filter( + command => + (command.mode === 'prompt' || command.mode === 'task-notification') && + !claim.claimedCommands.includes(command), + ) + removeFromQueue(consumedCommands) + const nextCommands = await finalizeAutonomyCommandsForTurn({ + commands: claim.claimedCommands, + outcome: { type: 'completed' }, + currentDir: tempDir, + priority: 'later', + }) + for (const command of nextCommands) { + enqueue(command) + } + + return { attachments, runningRunIds: claim.claimedRunIds, nextCommands } + } finally { + if (previousDisableAttachments === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + } else { + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments + } + } + } + + test('filters stale autonomy commands before mid-turn attachment consumption', async () => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + + const initial = await partitionConsumableQueuedAutonomyCommands( + [command!], + tempDir, + ) + expect(initial.attachmentCommands).toHaveLength(1) + expect(initial.staleCommands).toHaveLength(0) + + await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir) + + const afterCancel = await partitionConsumableQueuedAutonomyCommands( + [command!], + tempDir, + ) + expect(afterCancel.attachmentCommands).toHaveLength(0) + expect(afterCancel.staleCommands).toHaveLength(1) + }) + + test('cancels proactive commands that are created but dropped before enqueue', async () => { + const commands = await createProactiveAutonomyCommands({ + basePrompt: '12:00:00', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(commands).toHaveLength(1) + + const queuedRun = await getAutonomyRunById( + commands[0]!.autonomy!.runId, + tempDir, + ) + expect(queuedRun!.status).toBe('queued') + + await cancelQueuedAutonomyCommands({ commands, rootDir: tempDir }) + + const cancelledRun = await getAutonomyRunById( + commands[0]!.autonomy!.runId, + tempDir, + ) + expect(cancelledRun!.status).toBe('cancelled') + }) + + test('uses command rootDir when claiming after project context changes', async () => { + const otherProjectDir = await createTempDir('autonomy-other-project-') + extraTempDirs.push(otherProjectDir) + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + expect(command!.autonomy?.rootDir).toBe(tempDir) + + const claim = await claimConsumableQueuedAutonomyCommands( + [command!], + otherProjectDir, + ) + + const originalRun = await getAutonomyRunById( + command!.autonomy!.runId, + tempDir, + ) + const wrongProjectRun = await getAutonomyRunById( + command!.autonomy!.runId, + otherProjectDir, + ) + + expect(claim.claimedRunIds).toEqual([command!.autonomy!.runId]) + expect(claim.attachmentCommands).toHaveLength(1) + expect(originalRun!.status).toBe('running') + expect(wrongProjectRun).toBeNull() + }) + + test('advances a managed flow consumed as a queued attachment', async () => { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { name: 'gather', prompt: 'Gather weekly inputs' }, + { name: 'draft', prompt: 'Draft weekly report' }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + + const claim = await claimConsumableQueuedAutonomyCommands( + [command!], + tempDir, + ) + const runningRunIds = claim.claimedRunIds + expect(runningRunIds).toEqual([command!.autonomy!.runId]) + + const nextCommands = await finalizeAutonomyCommandsForTurn({ + commands: claim.claimedCommands, + outcome: { type: 'completed' }, + currentDir: tempDir, + priority: 'later', + }) + const [flow] = await listAutonomyFlows(tempDir) + const detail = await getAutonomyFlowById(flow!.flowId, tempDir) + const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir) + + expect(run!.status).toBe('completed') + expect(nextCommands).toHaveLength(1) + expect(nextCommands[0]!.autonomy?.flowId).toBe(flow!.flowId) + expect(detail!.stateJson!.steps.map(step => step.status)).toEqual([ + 'completed', + 'queued', + ]) + }) + + test('keeps managed autonomy flow coherent across queued attachment turns', async () => { + const firstCommand = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { name: 'gather', prompt: 'Gather weekly inputs' }, + { name: 'draft', prompt: 'Draft weekly report' }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + }) + expect(firstCommand).not.toBeNull() + enqueue(firstCommand!) + + const firstTurn = await consumeQueuedAutonomyAttachmentTurn() + const queuedAfterFirstTurn = getCommandsByMaxPriority('later') + const [flowAfterFirstTurn] = await listAutonomyFlows(tempDir) + const firstRun = await getAutonomyRunById( + firstCommand!.autonomy!.runId, + tempDir, + ) + + expect(firstTurn.attachments).toHaveLength(1) + expect(firstTurn.attachments[0]!.attachment?.type).toBe('queued_command') + expect(firstTurn.runningRunIds).toEqual([firstCommand!.autonomy!.runId]) + expect(firstTurn.nextCommands).toHaveLength(1) + expect(queuedAfterFirstTurn).toHaveLength(1) + expect(queuedAfterFirstTurn[0]!.autonomy?.flowId).toBe( + flowAfterFirstTurn!.flowId, + ) + expect(firstRun!.status).toBe('completed') + expect( + flowAfterFirstTurn!.stateJson!.steps.map(step => step.status), + ).toEqual(['completed', 'queued']) + + const secondCommand = queuedAfterFirstTurn[0]! + const secondTurn = await consumeQueuedAutonomyAttachmentTurn() + const queuedAfterSecondTurn = getCommandsByMaxPriority('later') + const finalFlow = await getAutonomyFlowById( + flowAfterFirstTurn!.flowId, + tempDir, + ) + const secondRun = await getAutonomyRunById( + secondCommand.autonomy!.runId, + tempDir, + ) + + expect(secondTurn.attachments).toHaveLength(1) + expect(secondTurn.runningRunIds).toEqual([secondCommand.autonomy!.runId]) + expect(secondTurn.nextCommands).toHaveLength(0) + expect(queuedAfterSecondTurn).toHaveLength(0) + expect(secondRun!.status).toBe('completed') + expect(finalFlow!.status).toBe('succeeded') + expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([ + 'completed', + 'completed', + ]) + }) +}) diff --git a/src/utils/__tests__/autonomyRuns.test.ts b/src/utils/__tests__/autonomyRuns.test.ts new file mode 100644 index 000000000..2a6fa2b2f --- /dev/null +++ b/src/utils/__tests__/autonomyRuns.test.ts @@ -0,0 +1,864 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { existsSync, readFileSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import { dirname, join, resolve as resolvePath } from 'path' +import { + resetStateForTests, + setCwdState, + setOriginalCwd, + setProjectRoot, +} from '../../bootstrap/state' +import { + createAutonomyRun, + formatAutonomyRunsList, + formatAutonomyRunsStatus, + listAutonomyRuns, + createAutonomyQueuedPrompt, + createAutonomyQueuedPromptIfNoActiveSource, + createProactiveAutonomyCommands, + finalizeAutonomyRunCompleted, + getAutonomyRunById, + hasActiveAutonomyRunForSource, + markAutonomyRunCompleted, + markAutonomyRunCancelled, + markAutonomyRunFailed, + markAutonomyRunRunning, + recoverManagedAutonomyFlowPrompt, + resolveAutonomyRunsPath, + STALE_ACTIVE_RUN_ERROR_PREFIX, + startManagedAutonomyFlowFromHeartbeatTask, +} from '../autonomyRuns' +import { + formatAutonomyFlowsList, + getAutonomyFlowById, + listAutonomyFlows, +} from '../autonomyFlows' +import { + AUTONOMY_DIR, + resetAutonomyAuthorityForTests, +} from '../autonomyAuthority' +import { resetCommandQueue } from '../messageQueueManager' +import { + cleanupTempDir, + createTempDir, + createTempSubdir, + writeTempFile, +} from '../../../tests/mocks/file-system' + +const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md') +const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md') + +let tempDir = '' + +async function writeAuthorityFile( + dir: string, + name: string, + content: string, +): Promise { + await mkdir(dirname(join(dir, name)), { recursive: true }) + return writeTempFile(dir, name, content) +} + +beforeEach(async () => { + tempDir = await createTempDir('autonomy-runs-') + resetStateForTests() + resetAutonomyAuthorityForTests() + resetCommandQueue() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) +}) + +afterEach(async () => { + resetStateForTests() + resetAutonomyAuthorityForTests() + resetCommandQueue() + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +describe('autonomyRuns', () => { + test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => { + const currentDir = await createTempSubdir(tempDir, 'nested') + await writeAuthorityFile(tempDir, AGENTS_REL, 'root authority') + + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'Review nightly report', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir, + sourceId: 'cron-1', + sourceLabel: 'nightly-report', + workload: 'cron', + }) + + const runs = await listAutonomyRuns(tempDir) + const flows = await listAutonomyFlows(tempDir) + + expect(command).not.toBeNull() + expect(command!.mode).toBe('prompt') + expect(command!.isMeta).toBe(true) + expect(command!.autonomy?.trigger).toBe('scheduled-task') + expect(command!.autonomy?.sourceId).toBe('cron-1') + expect(command!.origin).toBeDefined() + expect(command!.value).toContain('root authority') + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ + runId: command!.autonomy?.runId, + runtime: 'automatic', + trigger: 'scheduled-task', + status: 'queued', + ownerKey: 'main-thread', + sourceId: 'cron-1', + sourceLabel: 'nightly-report', + ownerProcessId: process.pid, + }) + expect(runs[0]?.ownerSessionId).toBeString() + expect(flows).toHaveLength(0) + expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude') + }) + + test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => { + const nestedDir = await createTempSubdir(tempDir, 'nested') + await writeAuthorityFile(tempDir, AGENTS_REL, 'root authority') + await writeAuthorityFile(nestedDir, AGENTS_REL, 'nested authority') + setOriginalCwd(nestedDir) + setCwdState(nestedDir) + + const command = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + }) + + expect(command).not.toBeNull() + expect(command!.value).toContain('root authority') + expect(command!.value).toContain('nested authority') + }) + + test('markAutonomyRunRunning/completed update persisted lifecycle state for plain runs', async () => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + const runId = command!.autonomy!.runId + + await markAutonomyRunRunning(runId, tempDir, 100) + let runs = await listAutonomyRuns(tempDir) + expect(runs[0]).toMatchObject({ + runId, + status: 'running', + startedAt: 100, + ownerProcessId: process.pid, + }) + expect(runs[0]?.ownerSessionId).toBeString() + + await markAutonomyRunCompleted(runId, tempDir, 200) + runs = await listAutonomyRuns(tempDir) + expect(runs[0]).toMatchObject({ + runId, + status: 'completed', + endedAt: 200, + }) + }) + + test('markAutonomyRunFailed updates a non-terminal run', async () => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + const runId = command!.autonomy!.runId + + await markAutonomyRunRunning(runId, tempDir, 100) + await markAutonomyRunFailed(runId, 'boom', tempDir, 300) + const runs = await listAutonomyRuns(tempDir) + + expect(runs[0]).toMatchObject({ + runId, + status: 'failed', + endedAt: 300, + error: 'boom', + }) + }) + + test('terminal runs are not revived by stale lifecycle updates', async () => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + const runId = command!.autonomy!.runId + + await markAutonomyRunCancelled(runId, tempDir, 100) + const revived = await markAutonomyRunRunning(runId, tempDir, 200) + const completed = await markAutonomyRunCompleted(runId, tempDir, 300) + const failed = await markAutonomyRunFailed( + runId, + 'late failure', + tempDir, + 400, + ) + const persisted = await getAutonomyRunById(runId, tempDir) + + expect(revived).toBeNull() + expect(completed).toBeNull() + expect(failed).toBeNull() + expect(persisted).toMatchObject({ + status: 'cancelled', + endedAt: 100, + }) + expect(persisted!.error).toBeUndefined() + }) + + test('hasActiveAutonomyRunForSource only treats queued and running scheduled runs as active', async () => { + const command = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + sourceLabel: 'nightly', + }) + expect(command).not.toBeNull() + const runId = command!.autonomy!.runId + + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + rootDir: tempDir, + }), + ).resolves.toBe(true) + + await markAutonomyRunRunning(runId, tempDir, 100) + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + rootDir: tempDir, + }), + ).resolves.toBe(true) + + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-2', + rootDir: tempDir, + }), + ).resolves.toBe(false) + + await markAutonomyRunCompleted(runId, tempDir, 200) + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + rootDir: tempDir, + }), + ).resolves.toBe(false) + + const failedCommand = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }) + expect(failedCommand).not.toBeNull() + await markAutonomyRunFailed( + failedCommand!.autonomy!.runId, + 'boom', + tempDir, + 300, + ) + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + rootDir: tempDir, + }), + ).resolves.toBe(false) + }) + + test('createAutonomyQueuedPromptIfNoActiveSource atomically skips duplicate active scheduled sources', async () => { + const [first, second] = await Promise.all([ + createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }), + createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }), + ]) + + const created = [first, second].filter(command => command !== null) + const runs = await listAutonomyRuns(tempDir) + + expect(created).toHaveLength(1) + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ + trigger: 'scheduled-task', + status: 'queued', + sourceId: 'cron-1', + }) + }) + + test('createAutonomyQueuedPromptIfNoActiveSource scopes dedup by ownerKey', async () => { + const first = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + ownerKey: 'owner-a', + }) + const second = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + ownerKey: 'owner-b', + }) + + const runs = await listAutonomyRuns(tempDir) + + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(runs).toHaveLength(2) + expect(new Set(runs.map(run => run.ownerKey))).toEqual( + new Set(['owner-a', 'owner-b']), + ) + }) + + test('createAutonomyQueuedPromptIfNoActiveSource does not advance heartbeat last-run state on dedup skip (two-phase commit invariant)', async () => { + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ].join('\n'), + ) + + // Seed an active queued run for cron-1 so the next dedup attempt skips. + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + resolveAutonomyRunsPath(tempDir), + `${JSON.stringify( + { + runs: [ + { + runId: 'preexisting-active', + runtime: 'automatic', + trigger: 'scheduled-task', + status: 'queued', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + promptPreview: 'still queued', + createdAt: 100, + ownerProcessId: process.pid, + ownerSessionId: 'self', + }, + ], + }, + null, + 2, + )}\n`, + 'utf-8', + ) + + const skipped = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }) + expect(skipped).toBeNull() + + // If the dedup skip wrongly advanced heartbeat state, the next + // proactive-tick prompt would NOT include the inbox task. Verify it + // still does. + const followUp = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + }) + expect(followUp).not.toBeNull() + expect(followUp!.value).toContain('Due HEARTBEAT.md tasks:') + expect(followUp!.value).toContain('- inbox (30m): Check inbox') + }) + + test('createAutonomyQueuedPromptIfNoActiveSource recovers stale active runs from dead owner processes', async () => { + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + resolveAutonomyRunsPath(tempDir), + `${JSON.stringify( + { + runs: [ + { + runId: 'stale-run', + runtime: 'automatic', + trigger: 'scheduled-task', + status: 'running', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + sourceLabel: 'nightly', + promptPreview: 'stale scheduled prompt', + createdAt: 100, + startedAt: 100, + ownerProcessId: 2_147_483_647, + ownerSessionId: 'dead-session', + }, + ], + }, + null, + 2, + )}\n`, + 'utf-8', + ) + + await expect( + hasActiveAutonomyRunForSource({ + trigger: 'scheduled-task', + sourceId: 'cron-1', + rootDir: tempDir, + }), + ).resolves.toBe(false) + + const command = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }) + const runs = await listAutonomyRuns(tempDir) + + expect(command).not.toBeNull() + expect(runs).toHaveLength(2) + expect(runs[0]).toMatchObject({ + trigger: 'scheduled-task', + status: 'queued', + sourceId: 'cron-1', + ownerProcessId: process.pid, + }) + expect(runs[1]).toMatchObject({ + runId: 'stale-run', + status: 'failed', + endedAt: runs[0]?.createdAt, + error: expect.stringContaining('owner process 2147483647'), + }) + }) + + test('stale managed-flow run recovery also marks the flow step failed', async () => { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { + name: 'gather', + prompt: 'Gather weekly inputs', + }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + }) + expect(command).not.toBeNull() + const runId = command!.autonomy!.runId + await markAutonomyRunRunning(runId, tempDir, 100) + + const runsPath = resolveAutonomyRunsPath(tempDir) + const file = JSON.parse(readFileSync(runsPath, 'utf-8')) as { + runs: Array> + } + file.runs = file.runs.map(run => + run.runId === runId ? { ...run, ownerProcessId: 2_147_483_647 } : run, + ) + await writeFile(runsPath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8') + + const replacement = await createAutonomyQueuedPromptIfNoActiveSource({ + basePrompt: 'replacement prompt', + trigger: 'managed-flow-step', + rootDir: tempDir, + currentDir: tempDir, + sourceId: command!.autonomy!.sourceId!, + ownerKey: 'main-thread', + }) + const [flow] = await listAutonomyFlows(tempDir) + const runs = await listAutonomyRuns(tempDir) + + expect(replacement).not.toBeNull() + expect(runs.find(run => run.runId === runId)).toMatchObject({ + status: 'failed', + error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX), + }) + expect(flow).toMatchObject({ + status: 'failed', + blockedRunId: runId, + }) + expect(flow?.stateJson?.steps[0]).toMatchObject({ + status: 'failed', + runId, + error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX), + }) + }) + + test('formatters produce readable status and run listings', async () => { + const first = await createAutonomyQueuedPrompt({ + basePrompt: 'scheduled prompt', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + sourceLabel: 'nightly', + }) + const second = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + }) + + expect(first).not.toBeNull() + expect(second).not.toBeNull() + await markAutonomyRunRunning(first!.autonomy!.runId, tempDir, 100) + await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 200) + await markAutonomyRunFailed( + second!.autonomy!.runId, + 'stopped', + tempDir, + 300, + ) + + const runs = await listAutonomyRuns(tempDir) + const status = formatAutonomyRunsStatus(runs) + const list = formatAutonomyRunsList(runs, 5) + const flows = await listAutonomyFlows(tempDir) + const flowList = formatAutonomyFlowsList(flows, 5) + + expect(status).toContain('Autonomy runs: 2') + expect(status).toContain('Completed: 1') + expect(status).toContain('Failed: 1') + expect(list).toContain(first!.autonomy!.runId) + expect(list).toContain(second!.autonomy!.runId) + expect(list).toContain('nightly') + expect(list).toContain('stopped') + expect(flowList).toBe('No autonomy flows recorded.') + }) + + test('same-process concurrent run creation does not lose updates', async () => { + await Promise.all([ + createAutonomyQueuedPrompt({ + basePrompt: 'scheduled one', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }), + createAutonomyQueuedPrompt({ + basePrompt: 'scheduled two', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-2', + }), + ]) + + const runs = await listAutonomyRuns(tempDir) + + expect(runs).toHaveLength(2) + expect(new Set(runs.map(run => run.sourceId))).toEqual( + new Set(['cron-1', 'cron-2']), + ) + }) + + test('persistence pruning keeps active runs ahead of recent completed history', async () => { + const runs = [ + { + runId: 'old-active', + runtime: 'automatic', + trigger: 'scheduled-task', + status: 'queued', + rootDir: tempDir, + currentDir: tempDir, + ownerKey: 'main-thread', + promptPreview: 'old active', + createdAt: 1, + }, + ...Array.from({ length: 200 }, (_, index) => ({ + runId: `history-${index}`, + runtime: 'automatic', + trigger: 'scheduled-task', + status: 'completed', + rootDir: tempDir, + currentDir: tempDir, + ownerKey: 'main-thread', + promptPreview: `history ${index}`, + createdAt: 1_000 + index, + endedAt: 2_000 + index, + })), + ] + await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) + await writeFile( + resolveAutonomyRunsPath(tempDir), + `${JSON.stringify({ runs }, null, 2)}\n`, + 'utf-8', + ) + + await createAutonomyRun({ + trigger: 'scheduled-task', + prompt: 'fresh active', + rootDir: tempDir, + currentDir: tempDir, + nowMs: 9_999, + }) + + const persisted = await listAutonomyRuns(tempDir) + expect(persisted).toHaveLength(200) + expect(persisted.some(run => run.runId === 'old-active')).toBe(true) + expect(persisted.some(run => run.runId === 'history-0')).toBe(false) + }) + + test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => { + const runsPath = resolveAutonomyRunsPath(tempDir) + await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true }) + await writeFile( + runsPath, + `${JSON.stringify( + { + runs: [ + { + runId: 'legacy-run', + trigger: 'scheduled-task', + status: 'completed', + rootDir: tempDir, + promptPreview: 'legacy prompt', + createdAt: 123, + }, + ], + }, + null, + 2, + )}\n`, + 'utf-8', + ) + + const [legacy] = await listAutonomyRuns(tempDir) + + expect(legacy).toMatchObject({ + runId: 'legacy-run', + runtime: 'automatic', + ownerKey: 'main-thread', + currentDir: tempDir, + status: 'completed', + }) + }) + + test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => { + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ].join('\n'), + ) + + const skipped = await createAutonomyQueuedPrompt({ + basePrompt: '12:00:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + shouldCreate: () => false, + }) + const committed = await createAutonomyQueuedPrompt({ + basePrompt: '12:01:00', + trigger: 'proactive-tick', + rootDir: tempDir, + currentDir: tempDir, + }) + + const runs = await listAutonomyRuns(tempDir) + + expect(skipped).toBeNull() + expect(committed).not.toBeNull() + expect(committed!.value).toContain('Due HEARTBEAT.md tasks:') + expect(runs).toHaveLength(1) + }) + + test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => { + await writeAuthorityFile( + tempDir, + HEARTBEAT_REL, + [ + 'tasks:', + ' - name: inbox', + ' interval: 30m', + ' prompt: "Check inbox"', + ' - name: weekly-report', + ' interval: 7d', + ' prompt: "Ship the weekly report"', + ' steps:', + ' - name: gather', + ' prompt: "Gather weekly inputs"', + ' - name: draft', + ' prompt: "Draft the weekly report"', + ].join('\n'), + ) + + const commands = await createProactiveAutonomyCommands({ + basePrompt: '12:00:00', + rootDir: tempDir, + currentDir: tempDir, + }) + + const runs = await listAutonomyRuns(tempDir) + const flows = await listAutonomyFlows(tempDir) + + expect(commands).toHaveLength(2) + expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick') + expect(commands[0]!.value).toContain('- inbox (30m): Check inbox') + expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step') + expect(commands[1]!.value).toContain( + 'This is step 1/2 of the managed autonomy flow', + ) + expect(runs).toHaveLength(2) + expect(flows).toHaveLength(1) + expect(flows[0]).toMatchObject({ + status: 'queued', + currentStep: 'gather', + goal: 'Ship the weekly report', + }) + }) + + test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { + name: 'gather', + prompt: 'Gather weekly inputs', + }, + { + name: 'draft', + prompt: 'Draft the weekly report', + }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + }) + + expect(command).not.toBeNull() + await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100) + + const nextCommands = await finalizeAutonomyRunCompleted({ + runId: command!.autonomy!.runId, + rootDir: tempDir, + currentDir: tempDir, + }) + + const runs = await listAutonomyRuns(tempDir) + const [flow] = await listAutonomyFlows(tempDir) + const detail = await getAutonomyFlowById(flow!.flowId, tempDir) + + expect(nextCommands).toHaveLength(1) + expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step') + expect(nextCommands[0]!.value).toContain('Current step: draft') + expect(runs).toHaveLength(2) + expect(flow).toMatchObject({ + status: 'queued', + currentStep: 'draft', + runCount: 2, + }) + expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([ + 'completed', + 'queued', + ]) + }) + + test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => { + const command = await startManagedAutonomyFlowFromHeartbeatTask({ + task: { + name: 'weekly-report', + interval: '7d', + prompt: 'Ship the weekly report', + steps: [ + { + name: 'gather', + prompt: 'Gather weekly inputs', + }, + { + name: 'draft', + prompt: 'Draft the weekly report', + }, + ], + }, + rootDir: tempDir, + currentDir: tempDir, + }) + + const [flow] = await listAutonomyFlows(tempDir) + const recovered = await recoverManagedAutonomyFlowPrompt({ + flowId: flow!.flowId, + rootDir: tempDir, + currentDir: tempDir, + }) + + expect(recovered).not.toBeNull() + expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId) + expect(recovered!.autonomy?.flowId).toBe(flow!.flowId) + }) + + test('STALE_ACTIVE_RUN_ERROR_PREFIX stays in sync with HEARTBEAT.md stale-recovery-health task', () => { + // The HEARTBEAT.md stale-recovery-health task prompt embeds this prefix + // as a literal string. Changing the constant without updating the + // heartbeat prompt would silently break the monitor — this test fails + // first to force the simultaneous update. + const heartbeatPath = resolvePath( + import.meta.dir, + '..', + '..', + '..', + '.claude', + 'autonomy', + 'HEARTBEAT.md', + ) + if (!existsSync(heartbeatPath)) { + // .claude/ may be absent in some checkout layouts (e.g., shallow clone + // for npm pack). Skip rather than fail in that case. + return + } + const content = readFileSync(heartbeatPath, 'utf8') + expect(content).toContain(STALE_ACTIVE_RUN_ERROR_PREFIX) + }) +}) diff --git a/src/utils/handlePromptSubmit.ts b/src/utils/handlePromptSubmit.ts index 4e514dd05..13125755d 100644 --- a/src/utils/handlePromptSubmit.ts +++ b/src/utils/handlePromptSubmit.ts @@ -19,6 +19,7 @@ import { } from '../types/textInputTypes.js' import { createAbortController } from './abortController.js' import type { PastedContent } from './config.js' +import { getCwd } from './cwd.js' import { logForDebugging } from './debug.js' import type { EffortValue } from './effort.js' import type { FileHistoryState } from './fileHistory.js' @@ -26,6 +27,10 @@ import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js' import { gracefulShutdownSync } from './gracefulShutdown.js' import { enqueue } from './messageQueueManager.js' import { resolveSkillModelOverride } from './model/model.js' +import { + claimConsumableQueuedAutonomyCommands, + finalizeAutonomyCommandsForTurn, +} from './autonomyQueueLifecycle.js' import type { ProcessUserInputContext } from './processUserInput/processUserInput.js' import { processUserInput } from './processUserInput/processUserInput.js' import type { QueryGuard } from './QueryGuard.js' @@ -115,6 +120,8 @@ export type HandlePromptSubmitParams = BaseExecutionParams & { * trigger local slash commands or skills. */ skipSlashCommands?: boolean + /** Preserves that the input originated from Remote Control when queued. */ + bridgeOrigin?: boolean } export async function handlePromptSubmit( @@ -141,6 +148,7 @@ export async function handlePromptSubmit( queuedCommands, uuid, skipSlashCommands, + bridgeOrigin, } = params const { setCursorOffset, clearBuffer, resetHistory } = helpers @@ -339,6 +347,7 @@ export async function handlePromptSubmit( mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, + bridgeOrigin, uuid, }) @@ -362,6 +371,7 @@ export async function handlePromptSubmit( mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, + bridgeOrigin, uuid, } @@ -448,7 +458,14 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { // Iterate all commands uniformly. First command gets attachments + // ideSelection + pastedContents, rest skip attachments to avoid // duplicating turn-level context (IDE selection, todos, diffs). - const commands = queuedCommands ?? [] + let commands = queuedCommands ?? [] + const queuedAutonomyClaim = + await claimConsumableQueuedAutonomyCommands(commands) + commands = queuedAutonomyClaim.attachmentCommands + const claimedAutonomyCommands = queuedAutonomyClaim.claimedCommands + if (commands.length === 0) { + return + } // Compute the workload tag for this turn. queueProcessor can batch a // cron prompt with a same-tick human prompt; only tag when EVERY @@ -460,6 +477,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { commands.every(c => c.workload === firstWorkload) ? firstWorkload : undefined + const deferredAutonomyRunIds = new Set() // Wrap the entire turn (processUserInput loop + onQuery) in an // AsyncLocalStorage context. This is the ONLY way to correctly @@ -469,131 +487,169 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { // context — isolated from the parent's continuation. A process-global // mutable slot would be clobbered at the detached closure's first // await by this function's synchronous return path. See state.ts. - await runWithWorkload(turnWorkload, async () => { - for (let i = 0; i < commands.length; i++) { - const cmd = commands[i]! - const isFirst = i === 0 - const result = await processUserInput({ - input: cmd.value, - preExpansionInput: cmd.preExpansionValue, - mode: cmd.mode, - setToolJSX, - context: makeContext(), - pastedContents: isFirst ? cmd.pastedContents : undefined, - messages, - setUserInputOnProcessing: isFirst - ? setUserInputOnProcessing - : undefined, - isAlreadyProcessing: !isFirst, - querySource, - canUseTool, - uuid: cmd.uuid, - ideSelection: isFirst ? ideSelection : undefined, - skipSlashCommands: cmd.skipSlashCommands, - bridgeOrigin: cmd.bridgeOrigin, - isMeta: cmd.isMeta, - skipAttachments: !isFirst, - }) - // Stamp origin here rather than threading another arg through - // processUserInput → processUserInputBase → processTextPrompt → createUserMessage. - // Derive origin from mode for task-notifications — mirrors the origin - // derivation at messages.ts (case 'queued_command'); intentionally - // does NOT mirror its isMeta:true so idle-dequeued notifications stay - // visible in the transcript via UserAgentNotificationMessage. - const origin = - cmd.origin ?? - (cmd.mode === 'task-notification' - ? ({ kind: 'task-notification' } as const) - : undefined) - if (origin) { - for (const m of result.messages) { - if (m.type === 'user') m.origin = origin + try { + await runWithWorkload(turnWorkload, async () => { + for (let i = 0; i < commands.length; i++) { + const cmd = commands[i]! + const isFirst = i === 0 + const runId = cmd.autonomy?.runId + const result = await processUserInput({ + input: cmd.value, + preExpansionInput: cmd.preExpansionValue, + mode: cmd.mode, + setToolJSX, + context: makeContext(), + pastedContents: isFirst ? cmd.pastedContents : undefined, + messages, + setUserInputOnProcessing: isFirst + ? setUserInputOnProcessing + : undefined, + isAlreadyProcessing: !isFirst, + querySource, + canUseTool, + uuid: cmd.uuid, + ideSelection: isFirst ? ideSelection : undefined, + skipSlashCommands: cmd.skipSlashCommands, + bridgeOrigin: cmd.bridgeOrigin, + isMeta: cmd.isMeta, + skipAttachments: !isFirst, + autonomy: cmd.autonomy, + }) + if (runId && result.deferAutonomyCompletion) { + deferredAutonomyRunIds.add(runId) + } + // Stamp origin here rather than threading another arg through + // processUserInput → processUserInputBase → processTextPrompt → createUserMessage. + // Derive origin from mode for task-notifications — mirrors the origin + // derivation at messages.ts (case 'queued_command'); intentionally + // does NOT mirror its isMeta:true so idle-dequeued notifications stay + // visible in the transcript via UserAgentNotificationMessage. + const origin = + cmd.origin ?? + (cmd.mode === 'task-notification' + ? ({ kind: 'task-notification' } as const) + : undefined) + if (origin) { + for (const m of result.messages) { + if (m.type === 'user') m.origin = origin + } + } + newMessages.push(...result.messages) + if (isFirst) { + shouldQuery = result.shouldQuery + allowedTools = result.allowedTools + model = result.model + effort = result.effort + nextInput = result.nextInput + submitNextInput = result.submitNextInput } } - newMessages.push(...result.messages) - if (isFirst) { - shouldQuery = result.shouldQuery - allowedTools = result.allowedTools - model = result.model - effort = result.effort - nextInput = result.nextInput - submitNextInput = result.submitNextInput - } - } - queryCheckpoint('query_process_user_input_end') - if (fileHistoryEnabled()) { - queryCheckpoint('query_file_history_snapshot_start') - newMessages.filter(selectableUserMessagesFilter).forEach(message => { - void fileHistoryMakeSnapshot( - (updater: (prev: FileHistoryState) => FileHistoryState) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory), - })) - }, - message.uuid, + queryCheckpoint('query_process_user_input_end') + if (fileHistoryEnabled()) { + queryCheckpoint('query_file_history_snapshot_start') + newMessages.filter(selectableUserMessagesFilter).forEach(message => { + void fileHistoryMakeSnapshot( + (updater: (prev: FileHistoryState) => FileHistoryState) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })) + }, + message.uuid, + ) + }) + queryCheckpoint('query_file_history_snapshot_end') + } + + if (newMessages.length) { + // History is now added in the caller (onSubmit) for direct user submissions. + // This ensures queued command processing (notifications, already-queued user input) + // doesn't add to history, since those either shouldn't be in history or were + // already added when originally queued. + resetHistory() + setToolJSX({ + jsx: null, + shouldHidePromptInput: false, + clearLocalJSX: true, + }) + + const primaryCmd = commands[0] + const primaryMode = primaryCmd?.mode ?? 'prompt' + const primaryInput = + primaryCmd && typeof primaryCmd.value === 'string' + ? primaryCmd.value + : undefined + const shouldCallBeforeQuery = primaryMode === 'prompt' + await onQuery( + newMessages, + abortController, + shouldQuery, + allowedTools ?? [], + model + ? resolveSkillModelOverride(model, mainLoopModel) + : mainLoopModel, + shouldCallBeforeQuery ? onBeforeQuery : undefined, + primaryInput, + effort, ) - }) - queryCheckpoint('query_file_history_snapshot_end') - } - - if (newMessages.length) { - // History is now added in the caller (onSubmit) for direct user submissions. - // This ensures queued command processing (notifications, already-queued user input) - // doesn't add to history, since those either shouldn't be in history or were - // already added when originally queued. - resetHistory() - setToolJSX({ - jsx: null, - shouldHidePromptInput: false, - clearLocalJSX: true, - }) - - const primaryCmd = commands[0] - const primaryMode = primaryCmd?.mode ?? 'prompt' - const primaryInput = - primaryCmd && typeof primaryCmd.value === 'string' - ? primaryCmd.value - : undefined - const shouldCallBeforeQuery = primaryMode === 'prompt' - await onQuery( - newMessages, - abortController, - shouldQuery, - allowedTools ?? [], - model - ? resolveSkillModelOverride(model, mainLoopModel) - : mainLoopModel, - shouldCallBeforeQuery ? onBeforeQuery : undefined, - primaryInput, - effort, - ) - } else { - // Local slash commands that skip messages (e.g., /model, /theme). - // Release the guard BEFORE clearing toolJSX to prevent spinner flash — - // the spinner formula checks: (!toolJSX || showSpinner) && isLoading. - // If we clear toolJSX while the guard is still reserved, spinner briefly - // shows. The finally below also calls cancelReservation (no-op if idle). - queryGuard.cancelReservation() - setToolJSX({ - jsx: null, - shouldHidePromptInput: false, - clearLocalJSX: true, - }) - resetHistory() - setAbortController(null) - } - - // Handle nextInput from commands that want to chain (e.g., /discover activation) - if (nextInput) { - if (submitNextInput) { - enqueue({ value: nextInput, mode: 'prompt' }) } else { - params.onInputChange(nextInput) + // Local slash commands that skip messages (e.g., /model, /theme). + // Release the guard BEFORE clearing toolJSX to prevent spinner flash — + // the spinner formula checks: (!toolJSX || showSpinner) && isLoading. + // If we clear toolJSX while the guard is still reserved, spinner briefly + // shows. The finally below also calls cancelReservation (no-op if idle). + queryGuard.cancelReservation() + setToolJSX({ + jsx: null, + shouldHidePromptInput: false, + clearLocalJSX: true, + }) + resetHistory() + setAbortController(null) + } + + // Handle nextInput from commands that want to chain (e.g., /discover activation) + if (nextInput) { + if (submitNextInput) { + enqueue({ value: nextInput, mode: 'prompt' }) + } else { + params.onInputChange(nextInput) + } + } + }) // end runWithWorkload — ALS context naturally scoped, no finally needed + if (claimedAutonomyCommands.length) { + const finalizableCommands = claimedAutonomyCommands.filter(command => { + const runId = command.autonomy?.runId + return !runId || !deferredAutonomyRunIds.has(runId) + }) + const nextCommands = await finalizeAutonomyCommandsForTurn({ + commands: finalizableCommands, + outcome: { type: 'completed' }, + currentDir: getCwd(), + priority: 'later', + workload: turnWorkload, + }) + for (const nextCommand of nextCommands) { + enqueue(nextCommand) } } - }) // end runWithWorkload — ALS context naturally scoped, no finally needed + } catch (error) { + if (claimedAutonomyCommands.length) { + const finalizableCommands = claimedAutonomyCommands.filter(command => { + const runId = command.autonomy?.runId + return !runId || !deferredAutonomyRunIds.has(runId) + }) + await finalizeAutonomyCommandsForTurn({ + commands: finalizableCommands, + outcome: { type: 'failed', error }, + currentDir: getCwd(), + priority: 'later', + workload: turnWorkload, + }) + } + throw error + } } finally { // Safety net: release the guard reservation if processUserInput threw // or onQuery was skipped. No-op if onQuery already ran (guard is idle diff --git a/src/utils/processUserInput/__tests__/processSlashCommand.test.ts b/src/utils/processUserInput/__tests__/processSlashCommand.test.ts new file mode 100644 index 000000000..7ba0f3c2b --- /dev/null +++ b/src/utils/processUserInput/__tests__/processSlashCommand.test.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import type { QueuedCommand } from '../../../types/textInputTypes' +import { + resetStateForTests, + setCwdState, + setOriginalCwd, + setProjectRoot, +} from '../../../bootstrap/state' +import { + createAutonomyQueuedPrompt, + getAutonomyRunById, + listAutonomyRuns, + markAutonomyRunRunning, +} from '../../autonomyRuns' +import { resetAutonomyAuthorityForTests } from '../../autonomyAuthority' +import { createScheduledTaskQueuedCommand } from '../../../hooks/useScheduledTasks' +import { + cleanupTempDir, + createTempDir, +} from '../../../../tests/mocks/file-system' + +let runAgentBlocker: Promise | null = null +let releaseRunAgentBlocker: (() => void) | null = null +let runAgentStartCount = 0 +let originalNodeEnv: string | undefined +let originalAnthropicApiKey: string | undefined +const commandQueue: QueuedCommand[] = [] + +function enqueue(command: QueuedCommand): void { + commandQueue.push({ ...command, priority: command.priority ?? 'next' }) +} + +function enqueuePendingNotification(command: QueuedCommand): void { + commandQueue.push({ ...command, priority: command.priority ?? 'later' }) +} + +function getCommandQueue(): QueuedCommand[] { + return [...commandQueue] +} + +function hasCommandsInQueue(): boolean { + return commandQueue.length > 0 +} + +function resetCommandQueue(): void { + commandQueue.length = 0 +} + +function createMessageQueueManagerMock() { + return { + enqueue, + enqueuePendingNotification, + getCommandQueue, + hasCommandsInQueue, + resetCommandQueue, + } +} + +function holdRunAgent(): void { + runAgentBlocker = new Promise(resolve => { + releaseRunAgentBlocker = resolve + }) +} + +function releaseRunAgent(): void { + releaseRunAgentBlocker?.() + runAgentBlocker = null + releaseRunAgentBlocker = null +} + +mock.module('bun:bundle', () => ({ + feature: (name: string) => name === 'KAIROS', +})) + +mock.module( + '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', + () => ({ + runAgent: async function* () { + runAgentStartCount += 1 + if (runAgentBlocker) { + await runAgentBlocker + } + yield { + type: 'assistant', + uuid: 'assistant-1', + timestamp: new Date().toISOString(), + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: 'forked command done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + } + }, + }), +) + +mock.module('@claude-code-best/builtin-tools/tools/AgentTool/UI.js', () => ({ + AgentPromptDisplay: () => null, + AgentResponseDisplay: () => null, + extractLastToolInfo: () => null, + renderGroupedAgentToolUse: () => null, + renderToolResultMessage: () => null, + renderToolUseErrorMessage: () => null, + renderToolUseMessage: () => null, + renderToolUseProgressMessage: () => null, + renderToolUseRejectedMessage: () => null, + renderToolUseTag: () => null, + userFacingName: () => 'Agent', + userFacingNameBackgroundColor: () => 'gray', +})) + +mock.module('../../messageQueueManager', createMessageQueueManagerMock) +mock.module('../../messageQueueManager.js', createMessageQueueManagerMock) + +const { processSlashCommand } = await import('../processSlashCommand') + +let tempDir = '' + +function createScheduledTaskQueuedCommandForTest(task: { + id: string + prompt: string +}) { + return createScheduledTaskQueuedCommand(task, { + rootDir: tempDir, + currentDir: tempDir, + }) +} + +async function waitForRunStatus( + runId: string, + status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled', +): Promise { + for (let i = 0; i < 200; i++) { + const run = await getAutonomyRunById(runId, tempDir) + if (run?.status === status) { + return + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + const run = await getAutonomyRunById(runId, tempDir) + throw new Error(`Expected ${runId} to be ${status}, got ${run?.status}`) +} + +async function waitForRunAgentStarts(expected: number): Promise { + for (let i = 0; i < 200; i++) { + if (runAgentStartCount >= expected) { + return + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error( + `Expected runAgent to start ${expected} time(s), got ${runAgentStartCount}`, + ) +} + +async function waitForCommandQueueLength(expected: number): Promise { + for (let i = 0; i < 200; i++) { + if (getCommandQueue().length === expected) { + return + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error( + `Expected command queue length ${expected}, got ${getCommandQueue().length}`, + ) +} + +beforeEach(async () => { + tempDir = await createTempDir('process-slash-command-') + originalNodeEnv = process.env.NODE_ENV + originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY + process.env.NODE_ENV = 'test' + process.env.ANTHROPIC_API_KEY = 'test-key' + runAgentBlocker = null + releaseRunAgentBlocker = null + runAgentStartCount = 0 + resetStateForTests() + resetAutonomyAuthorityForTests() + resetCommandQueue() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) + setCwdState(tempDir) +}) + +afterEach(async () => { + releaseRunAgent() + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + if (originalAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey + } + resetStateForTests() + resetAutonomyAuthorityForTests() + resetCommandQueue() + if (tempDir) { + await cleanupTempDir(tempDir) + } + mock.restore() +}) + +describe('processSlashCommand', () => { + const forkedCommand = { + type: 'prompt', + name: 'forked', + description: 'test forked command', + progressMessage: 'forking', + contentLength: 0, + source: 'builtin', + context: 'fork', + getPromptForCommand: async () => [ + { type: 'text', text: 'review from fork' }, + ], + } as const + + function createContext() { + return { + getAppState: () => ({ + kairosEnabled: true, + mcp: { clients: [] }, + toolPermissionContext: { + mode: 'default', + alwaysAllowRules: {}, + }, + }), + options: { + commands: [forkedCommand], + allowBackgroundForkedSlashCommands: true, + tools: [], + refreshTools: () => [], + agentDefinitions: { + activeAgents: [{ agentType: 'general-purpose' }], + }, + }, + setResponseLength: mock((_updater: (length: number) => number) => {}), + } as any + } + + test('defers autonomy completion until a KAIROS background forked command completes', async () => { + const queued = await createAutonomyQueuedPrompt({ + basePrompt: '/forked review', + trigger: 'scheduled-task', + rootDir: tempDir, + currentDir: tempDir, + sourceId: 'cron-1', + }) + expect(queued).not.toBeNull() + const runId = queued!.autonomy!.runId + await markAutonomyRunRunning(runId, tempDir, 100) + + const result = await processSlashCommand( + '/forked review', + [], + [], + [], + createContext(), + mock(() => {}), + undefined, + false, + async () => ({ behavior: 'allow', updatedInput: {} }) as any, + queued!.autonomy, + ) + + expect(result).toMatchObject({ + messages: [], + shouldQuery: false, + deferAutonomyCompletion: true, + }) + + await waitForRunStatus(runId, 'completed') + await waitForCommandQueueLength(1) + expect(getCommandQueue()).toEqual([ + expect.objectContaining({ + mode: 'prompt', + isMeta: true, + skipSlashCommands: true, + value: expect.stringContaining( + '', + ), + }), + ]) + }) + + test('keeps repeated /loop scheduled fires bounded while a background fork is running', async () => { + const task = { + id: 'cron-loop', + prompt: '/forked review', + } + const first = await createScheduledTaskQueuedCommandForTest(task) + expect(first?.autonomy?.runId).toBeDefined() + const runId = first!.autonomy!.runId + await markAutonomyRunRunning(runId, tempDir, 100) + + holdRunAgent() + const result = await processSlashCommand( + '/forked review', + [], + [], + [], + createContext(), + mock(() => {}), + undefined, + false, + async () => ({ behavior: 'allow', updatedInput: {} }) as any, + first!.autonomy, + ) + + expect(result.deferAutonomyCompletion).toBe(true) + await waitForRunAgentStarts(1) + + const repeatedFires = await Promise.all( + Array.from({ length: 200 }, () => + createScheduledTaskQueuedCommandForTest(task), + ), + ) + expect(repeatedFires.every(command => command === null)).toBe(true) + expect( + (await listAutonomyRuns(tempDir)).filter( + run => run.sourceId === 'cron-loop', + ), + ).toHaveLength(1) + expect(getCommandQueue()).toHaveLength(0) + + releaseRunAgent() + await waitForRunStatus(runId, 'completed') + await waitForCommandQueueLength(1) + expect(getCommandQueue()).toHaveLength(1) + + const next = await createScheduledTaskQueuedCommandForTest(task) + expect(next?.autonomy?.runId).toBeDefined() + expect( + (await listAutonomyRuns(tempDir)).filter( + run => run.sourceId === 'cron-loop', + ), + ).toHaveLength(2) + }) + + test('rejects the background fork test override outside test runtime', async () => { + process.env.NODE_ENV = 'production' + + const result = await processSlashCommand( + '/forked review', + [], + [], + [], + createContext(), + mock(() => {}), + undefined, + false, + async () => ({ behavior: 'allow', updatedInput: {} }) as any, + ) + + expect(result.shouldQuery).toBe(false) + expect( + result.messages.some(message => + JSON.stringify(message).includes( + 'allowBackgroundForkedSlashCommands is test-only', + ), + ), + ).toBe(true) + expect(runAgentStartCount).toBe(0) + }) +}) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index 6ee4bfe93..5c52a5dd5 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -1,10 +1,7 @@ -import { feature } from 'bun:bundle' -import type { - ContentBlockParam, - TextBlockParam, -} from '@anthropic-ai/sdk/resources' -import { randomUUID } from 'crypto' -import { setPromptId } from 'src/bootstrap/state.js' +import { feature } from 'bun:bundle'; +import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources'; +import { randomUUID } from 'crypto'; +import { setPromptId } from 'src/bootstrap/state.js'; import { builtInCommandNames, type Command, @@ -14,9 +11,9 @@ import { getCommandName, hasCommand, type PromptCommand, -} from 'src/commands.js' -import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js' -import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js' +} from 'src/commands.js'; +import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'; +import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'; import type { AssistantMessage, AttachmentMessage, @@ -24,42 +21,37 @@ import type { NormalizedUserMessage, ProgressMessage, UserMessage, -} from 'src/types/message.js' -import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js' -import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js' -import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' +} from 'src/types/message.js'; +import type { QueuedCommand } from 'src/types/textInputTypes.js'; +import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js'; +import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js'; +import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent, -} from '../../services/analytics/index.js' -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' -import { buildPostCompactMessages } from '../../services/compact/compact.js' -import { resetMicrocompactState } from '../../services/compact/microCompact.js' -import type { Progress as AgentProgress } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js' -import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js' -import { renderToolUseProgressMessage } from '@claude-code-best/builtin-tools/tools/AgentTool/UI.js' -import type { CommandResultDisplay } from '../../types/command.js' -import { createAbortController } from '../abortController.js' -import { getAgentContext } from '../agentContext.js' -import { - createAttachmentMessage, - getAttachmentMessages, -} from '../attachments.js' -import { logForDebugging } from '../debug.js' -import { isEnvTruthy } from '../envUtils.js' -import { AbortError, MalformedCommandError } from '../errors.js' -import { getDisplayPath } from '../file.js' -import { - extractResultText, - prepareForkedCommandContext, -} from '../forkedAgent.js' -import { getFsImplementation } from '../fsOperations.js' -import { isFullscreenEnvEnabled } from '../fullscreen.js' -import { toArray } from '../generators.js' -import { registerSkillHooks } from '../hooks/registerSkillHooks.js' -import { logError } from '../log.js' -import { enqueuePendingNotification } from '../messageQueueManager.js' +} from '../../services/analytics/index.js'; +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; +import { buildPostCompactMessages } from '../../services/compact/compact.js'; +import { resetMicrocompactState } from '../../services/compact/microCompact.js'; +import type { Progress as AgentProgress } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'; +import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'; +import { renderToolUseProgressMessage } from '@claude-code-best/builtin-tools/tools/AgentTool/UI.js'; +import type { CommandResultDisplay } from '../../types/command.js'; +import { createAbortController } from '../abortController.js'; +import { getAgentContext } from '../agentContext.js'; +import { createAttachmentMessage, getAttachmentMessages } from '../attachments.js'; +import { logForDebugging } from '../debug.js'; +import { isEnvTruthy } from '../envUtils.js'; +import { AbortError, MalformedCommandError } from '../errors.js'; +import { getDisplayPath } from '../file.js'; +import { extractResultText, prepareForkedCommandContext } from '../forkedAgent.js'; +import { getFsImplementation } from '../fsOperations.js'; +import { isFullscreenEnvEnabled } from '../fullscreen.js'; +import { toArray } from '../generators.js'; +import { registerSkillHooks } from '../hooks/registerSkillHooks.js'; +import { logError } from '../log.js'; +import { enqueue, enqueuePendingNotification } from '../messageQueueManager.js'; import { createCommandInputMessage, createSyntheticUserCaveatMessage, @@ -71,40 +63,44 @@ import { isSystemLocalCommandMessage, normalizeMessages, prepareUserContent, -} from '../messages.js' -import type { ModelAlias } from '../model/aliases.js' -import { parseToolListFromCLI } from '../permissions/permissionSetup.js' -import { hasPermissionsToUseTool } from '../permissions/permissions.js' -import { - isOfficialMarketplaceName, - parsePluginIdentifier, -} from '../plugins/pluginIdentifier.js' -import { - isRestrictedToPluginOnly, - isSourceAdminTrusted, -} from '../settings/pluginOnlyPolicy.js' -import { parseSlashCommand } from '../slashCommandParsing.js' -import { sleep } from '../sleep.js' -import { recordSkillUsage } from '../suggestions/skillUsageTracking.js' -import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js' -import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js' -import { getAssistantMessageContentLength } from '../tokens.js' -import { createAgentId } from '../uuid.js' -import { getWorkload } from '../workloadContext.js' -import type { - ProcessUserInputBaseResult, - ProcessUserInputContext, -} from './processUserInput.js' +} from '../messages.js'; +import type { ModelAlias } from '../model/aliases.js'; +import { parseToolListFromCLI } from '../permissions/permissionSetup.js'; +import { hasPermissionsToUseTool } from '../permissions/permissions.js'; +import { isOfficialMarketplaceName, parsePluginIdentifier } from '../plugins/pluginIdentifier.js'; +import { isRestrictedToPluginOnly, isSourceAdminTrusted } from '../settings/pluginOnlyPolicy.js'; +import { parseSlashCommand } from '../slashCommandParsing.js'; +import { sleep } from '../sleep.js'; +import { recordSkillUsage } from '../suggestions/skillUsageTracking.js'; +import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'; +import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js'; +import { getAssistantMessageContentLength } from '../tokens.js'; +import { createAgentId } from '../uuid.js'; +import { finalizeAutonomyRunCompleted, finalizeAutonomyRunFailed } from '../autonomyRuns.js'; +import { getWorkload } from '../workloadContext.js'; +import type { ProcessUserInputBaseResult, ProcessUserInputContext } from './processUserInput.js'; type SlashCommandResult = ProcessUserInputBaseResult & { - command: Command -} + command: Command; +}; // Poll interval and deadline for MCP settle before launching a background // forked subagent. MCP servers typically connect within 1-3s of startup; // 10s headroom covers slow SSE handshakes. -const MCP_SETTLE_POLL_MS = 200 -const MCP_SETTLE_TIMEOUT_MS = 10_000 +const MCP_SETTLE_POLL_MS = 200; +const MCP_SETTLE_TIMEOUT_MS = 10_000; + +function isTestRuntime(): boolean { + return process.env.NODE_ENV === 'test'; +} + +function assertBackgroundForkedSlashCommandTestOverrideAllowed(): void { + if (!isTestRuntime()) { + throw new Error( + 'ToolUseContext.options.allowBackgroundForkedSlashCommands is test-only and cannot be enabled outside NODE_ENV=test.', + ); + } +} /** * Executes a slash command with context: fork in a sub-agent. @@ -116,40 +112,35 @@ async function executeForkedSlashCommand( precedingInputBlocks: ContentBlockParam[], setToolJSX: SetToolJSXFn, canUseTool: CanUseToolFn, + autonomy?: QueuedCommand['autonomy'], ): Promise { - const agentId = createAgentId() + const agentId = createAgentId(); const pluginMarketplace = command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace - : undefined + : undefined; logEvent('tengu_slash_command_forked', { - command_name: - command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - invocation_trigger: - 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + command_name: command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(command.pluginInfo && { - _PROTO_plugin_name: command.pluginInfo.pluginManifest - .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_plugin_name: command.pluginInfo.pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(pluginMarketplace && { - _PROTO_marketplace_name: - pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_marketplace_name: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), ...buildPluginCommandTelemetryFields(command.pluginInfo), }), - }) + }); - const { skillContent, modifiedGetAppState, baseAgent, promptMessages } = - await prepareForkedCommandContext(command, args, context) + const { skillContent, modifiedGetAppState, baseAgent, promptMessages } = await prepareForkedCommandContext( + command, + args, + context, + ); // Merge skill's effort into the agent definition so runAgent applies it - const agentDefinition = - command.effort !== undefined - ? { ...baseAgent, effort: command.effort } - : baseAgent + const agentDefinition = command.effort !== undefined ? { ...baseAgent, effort: command.effort } : baseAgent; - logForDebugging( - `Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`, - ) + logForDebugging(`Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`); // Assistant mode: fire-and-forget. Launch subagent in background, return // immediately, re-enqueue the result as an isMeta prompt when done. @@ -163,12 +154,25 @@ async function executeForkedSlashCommand( // isMeta prompts are hidden. Outside assistant mode, context:fork commands // are user-invoked skills (/commit etc.) that should run synchronously // with the progress UI. - if (feature('KAIROS') && (await context.getAppState()).kairosEnabled) { + const appState = await context.getAppState(); + const allowBackgroundForkedSlashCommands = context.options.allowBackgroundForkedSlashCommands === true; + if (allowBackgroundForkedSlashCommands) { + assertBackgroundForkedSlashCommandTestOverrideAllowed(); + } + let canRunBackgroundForkedSlashCommand = false; + if (appState.kairosEnabled) { + if (feature('KAIROS')) { + canRunBackgroundForkedSlashCommand = true; + } else if (allowBackgroundForkedSlashCommands) { + canRunBackgroundForkedSlashCommand = true; + } + } + if (canRunBackgroundForkedSlashCommand) { // Standalone abortController — background subagents survive main-thread // ESC (same policy as AgentTool's async path). They're cron-driven; if // killed mid-run they just re-fire on the next schedule. - const bgAbortController = createAbortController() - const commandName = getCommandName(command) + const bgAbortController = createAbortController(); + const commandName = getCommandName(command); // Workload: handlePromptSubmit wraps the entire turn in runWithWorkload // (AsyncLocalStorage). ALS context is captured when this `void` fires @@ -179,7 +183,7 @@ async function executeForkedSlashCommand( // handlePromptSubmit → fresh runWithWorkload boundary (which always // establishes a new context, even for `undefined`) → so it needs its // own QueuedCommand.workload tag to preserve attribution. - const spawnTimeWorkload = getWorkload() + const spawnTimeWorkload = getWorkload(); // Re-enter the queue as a hidden prompt. isMeta: hides from queue // preview + placeholder + transcript. skipSlashCommands: prevents @@ -195,7 +199,31 @@ async function executeForkedSlashCommand( isMeta: true, skipSlashCommands: true, workload: spawnTimeWorkload, - }) + }); + const finalizeDeferredAutonomyRunCompleted = async (): Promise => { + if (!autonomy?.runId) { + return; + } + const nextCommands = await finalizeAutonomyRunCompleted({ + runId: autonomy.runId, + rootDir: autonomy.rootDir, + priority: 'later', + workload: spawnTimeWorkload, + }); + for (const nextCommand of nextCommands) { + enqueue(nextCommand); + } + }; + const finalizeDeferredAutonomyRunFailed = async (error: unknown): Promise => { + if (!autonomy?.runId) { + return; + } + await finalizeAutonomyRunFailed({ + runId: autonomy.runId, + rootDir: autonomy.rootDir, + error: error instanceof Error ? error.message : String(error), + }); + }; void (async () => { // Wait for MCP servers to settle. Scheduled tasks fire at startup and @@ -204,16 +232,15 @@ async function executeForkedSlashCommand( // accidentally avoided this — tasks serialized, so task N's drain // happened after task N-1's 30s run, by which time MCP was up. // Poll until no 'pending' clients remain, then refresh. - const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS + const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS; while (Date.now() < deadline) { - const s = context.getAppState() - if (!s.mcp.clients.some(c => c.type === 'pending')) break - await sleep(MCP_SETTLE_POLL_MS) + const s = context.getAppState(); + if (!s.mcp.clients.some(c => c.type === 'pending')) break; + await sleep(MCP_SETTLE_POLL_MS); } - const freshTools = - context.options.refreshTools?.() ?? context.options.tools + const freshTools = context.options.refreshTools?.() ?? context.options.tools; - const agentMessages: Message[] = [] + const agentMessages: Message[] = []; for await (const message of runAgent({ agentDefinition, promptMessages, @@ -229,40 +256,41 @@ async function executeForkedSlashCommand( availableTools: freshTools, override: { agentId }, })) { - agentMessages.push(message) + agentMessages.push(message); } - const resultText = extractResultText(agentMessages, 'Command completed') - logForDebugging( - `Background forked command /${commandName} completed (agent ${agentId})`, - ) - enqueueResult( - `\n${resultText}\n`, - ) - })().catch(err => { - logError(err) + const resultText = extractResultText(agentMessages, 'Command completed'); + logForDebugging(`Background forked command /${commandName} completed (agent ${agentId})`); + await finalizeDeferredAutonomyRunCompleted(); + enqueueResult(`\n${resultText}\n`); + })().catch(async err => { + logError(err); enqueueResult( `\n${err instanceof Error ? err.message : String(err)}\n`, - ) - }) + ); + await finalizeDeferredAutonomyRunFailed(err); + }); // Nothing to render, nothing to query — the background runner re-enters // the queue on its own schedule. - return { messages: [], shouldQuery: false, command } + return { + messages: [], + shouldQuery: false, + command, + deferAutonomyCompletion: Boolean(autonomy?.runId), + }; } // Collect messages from the forked agent - const agentMessages: Message[] = [] + const agentMessages: Message[] = []; // Build progress messages for the agent progress UI - const progressMessages: ProgressMessage[] = [] - const parentToolUseID = `forked-command-${command.name}` - let toolUseCounter = 0 + const progressMessages: ProgressMessage[] = []; + const parentToolUseID = `forked-command-${command.name}`; + let toolUseCounter = 0; // Helper to create a progress message from an agent message - const createProgressMessage = ( - message: AssistantMessage | NormalizedUserMessage, - ): ProgressMessage => { - toolUseCounter++ + const createProgressMessage = (message: AssistantMessage | NormalizedUserMessage): ProgressMessage => { + toolUseCounter++; return { type: 'progress', data: { @@ -275,8 +303,8 @@ async function executeForkedSlashCommand( toolUseID: `${parentToolUseID}-${toolUseCounter}`, timestamp: new Date().toISOString(), uuid: randomUUID(), - } - } + }; + }; // Helper to update progress display using agent progress UI const updateProgress = (): void => { @@ -288,11 +316,11 @@ async function executeForkedSlashCommand( shouldHidePromptInput: false, shouldContinueAnimation: true, showSpinner: true, - }) - } + }); + }; // Show initial "Initializing…" state - updateProgress() + updateProgress(); // Run the sub-agent try { @@ -309,47 +337,45 @@ async function executeForkedSlashCommand( model: command.model as ModelAlias | undefined, availableTools: context.options.tools, })) { - agentMessages.push(message) - const normalizedNew = normalizeMessages([message]) + agentMessages.push(message); + const normalizedNew = normalizeMessages([message]); // Add progress message for assistant messages (which contain tool uses) if (message.type === 'assistant') { // Increment token count in spinner for assistant messages - const contentLength = getAssistantMessageContentLength(message as AssistantMessage) + const contentLength = getAssistantMessageContentLength(message as AssistantMessage); if (contentLength > 0) { - context.setResponseLength(len => len + contentLength) + context.setResponseLength(len => len + contentLength); } - const normalizedMsg = normalizedNew[0] + const normalizedMsg = normalizedNew[0]; if (normalizedMsg && normalizedMsg.type === 'assistant') { - progressMessages.push(createProgressMessage(message as AssistantMessage)) - updateProgress() + progressMessages.push(createProgressMessage(message as AssistantMessage)); + updateProgress(); } } // Add progress message for user messages (which contain tool results) if (message.type === 'user') { - const normalizedMsg = normalizedNew[0] + const normalizedMsg = normalizedNew[0]; if (normalizedMsg && normalizedMsg.type === 'user') { - progressMessages.push(createProgressMessage(normalizedMsg as AssistantMessage)) - updateProgress() + progressMessages.push(createProgressMessage(normalizedMsg as AssistantMessage)); + updateProgress(); } } } } finally { // Clear the progress display - setToolJSX(null) + setToolJSX(null); } - let resultText = extractResultText(agentMessages, 'Command completed') + let resultText = extractResultText(agentMessages, 'Command completed'); - logForDebugging( - `Forked slash command /${command.name} completed with agent ${agentId}`, - ) + logForDebugging(`Forked slash command /${command.name} completed with agent ${agentId}`); // Prepend debug log for ant users so it appears inside the command output if (process.env.USER_TYPE === 'ant') { - resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}` + resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}`; } // Return the result as a user message (simulates the agent's output) @@ -363,14 +389,14 @@ async function executeForkedSlashCommand( createUserMessage({ content: `\n${resultText}\n`, }), - ] + ]; return { messages, shouldQuery: false, command, resultText, - } + }; } /** @@ -383,7 +409,7 @@ async function executeForkedSlashCommand( export function looksLikeCommand(commandName: string): boolean { // Command names should only contain [a-zA-Z0-9:_-] // If it contains other characters, it's probably a file path or other input - return !/[^a-zA-Z0-9:\-_]/.test(commandName) + return !/[^a-zA-Z0-9:\-_]/.test(commandName); } export async function processSlashCommand( @@ -396,11 +422,12 @@ export async function processSlashCommand( uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, + autonomy?: QueuedCommand['autonomy'], ): Promise { - const parsed = parseSlashCommand(inputString) + const parsed = parseSlashCommand(inputString); if (!parsed) { - logEvent('tengu_input_slash_missing', {}) - const errorMessage = 'Commands are in the form `/command [args]`' + logEvent('tengu_input_slash_missing', {}); + const errorMessage = 'Commands are in the form `/command [args]`'; return { messages: [ createSyntheticUserCaveatMessage(), @@ -414,35 +441,30 @@ export async function processSlashCommand( ], shouldQuery: false, resultText: errorMessage, - } + }; } - const { commandName, args: parsedArgs, isMcp } = parsed + const { commandName, args: parsedArgs, isMcp } = parsed; - const sanitizedCommandName = isMcp - ? 'mcp' - : !builtInCommandNames().has(commandName) - ? 'custom' - : commandName + const sanitizedCommandName = isMcp ? 'mcp' : !builtInCommandNames().has(commandName) ? 'custom' : commandName; // Check if it's a real command before processing if (!hasCommand(commandName, context.options.commands)) { // Check if this looks like a command name vs a file path or other input // Also check if it's an actual file path that exists - let isFilePath = false + let isFilePath = false; try { - await getFsImplementation().stat(`/${commandName}`) - isFilePath = true + await getFsImplementation().stat(`/${commandName}`); + isFilePath = true; } catch { // Not a file path — treat as command name } if (looksLikeCommand(commandName) && !isFilePath) { logEvent('tengu_input_slash_invalid', { - input: - commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + input: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); - const unknownMessage = `Unknown skill: ${commandName}` + const unknownMessage = `Unknown skill: ${commandName}`; return { messages: [ createSyntheticUserCaveatMessage(), @@ -455,29 +477,22 @@ export async function processSlashCommand( }), // gh-32591: preserve args so the user can copy/resubmit without // retyping. System warning is UI-only (filtered before API). - ...(parsedArgs - ? [ - createSystemMessage( - `Args from unknown skill: ${parsedArgs}`, - 'warning', - ), - ] - : []), + ...(parsedArgs ? [createSystemMessage(`Args from unknown skill: ${parsedArgs}`, 'warning')] : []), ], shouldQuery: false, resultText: unknownMessage, - } + }; } - const promptId = randomUUID() - setPromptId(promptId) - logEvent('tengu_input_prompt', {}) + const promptId = randomUUID(); + setPromptId(promptId); + logEvent('tengu_input_prompt', {}); // Log user prompt event for OTLP void logOTelEvent('user_prompt', { prompt_length: String(inputString.length), prompt: redactIfDisabled(inputString), 'prompt.id': promptId, - }) + }); return { messages: [ createUserMessage({ @@ -487,7 +502,7 @@ export async function processSlashCommand( ...attachmentMessages, ], shouldQuery: true, - } + }; } // Track slash command usage for feature discovery @@ -502,6 +517,7 @@ export async function processSlashCommand( resultText, nextInput, submitNextInput, + deferAutonomyCompletion, } = await getMessagesForSlashCommand( commandName, parsedArgs, @@ -512,66 +528,55 @@ export async function processSlashCommand( isAlreadyProcessing, canUseTool, uuid, - ) + autonomy, + ); // Local slash commands that skip messages if (newMessages.length === 0) { const eventData: Record = { - input: - sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } + input: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }; // Add plugin metadata if this is a plugin command if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { - const { pluginManifest, repository } = returnedCommand.pluginInfo - const { marketplace } = parsePluginIdentifier(repository) - const isOfficial = isOfficialMarketplaceName(marketplace) + const { pluginManifest, repository } = returnedCommand.pluginInfo; + const { marketplace } = parsePluginIdentifier(repository); + const isOfficial = isOfficialMarketplaceName(marketplace); // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns // (unredacted, all users); plugin_name/plugin_repository stay in // additional_metadata as redacted variants for general-access dashboards. - eventData._PROTO_plugin_name = - pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED + eventData._PROTO_plugin_name = pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; if (marketplace) { - eventData._PROTO_marketplace_name = - marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED + eventData._PROTO_marketplace_name = marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; } eventData.plugin_repository = ( isOfficial ? repository : 'third-party' - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; eventData.plugin_name = ( isOfficial ? pluginManifest.name : 'third-party' - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; if (isOfficial && pluginManifest.version) { - eventData.plugin_version = - pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + eventData.plugin_version = pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; } - Object.assign( - eventData, - buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), - ) + Object.assign(eventData, buildPluginCommandTelemetryFields(returnedCommand.pluginInfo)); } logEvent('tengu_input_command', { ...eventData, - invocation_trigger: - 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(process.env.USER_TYPE === 'ant' && { - skill_name: - commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(returnedCommand.type === 'prompt' && { - skill_source: - returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.loadedFrom && { - skill_loaded_from: - returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.kind && { - skill_kind: - returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }), - }) + }); return { messages: [], shouldQuery: false, @@ -579,7 +584,8 @@ export async function processSlashCommand( model, nextInput, submitNextInput, - } + deferAutonomyCompletion, + }; } // For invalid commands, preserve both the user message and error @@ -591,15 +597,12 @@ export async function processSlashCommand( ) { // Don't log as invalid if it looks like a common file path const looksLikeFilePath = - inputString.startsWith('/var') || - inputString.startsWith('/tmp') || - inputString.startsWith('/private') + inputString.startsWith('/var') || inputString.startsWith('/tmp') || inputString.startsWith('/private'); if (!looksLikeFilePath) { logEvent('tengu_input_slash_invalid', { - input: - commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + input: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } return { @@ -608,75 +611,58 @@ export async function processSlashCommand( allowedTools, model, - } + }; } // A valid command const eventData: Record = { - input: - sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } + input: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }; // Add plugin metadata if this is a plugin command if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { - const { pluginManifest, repository } = returnedCommand.pluginInfo - const { marketplace } = parsePluginIdentifier(repository) - const isOfficial = isOfficialMarketplaceName(marketplace) - eventData._PROTO_plugin_name = - pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED + const { pluginManifest, repository } = returnedCommand.pluginInfo; + const { marketplace } = parsePluginIdentifier(repository); + const isOfficial = isOfficialMarketplaceName(marketplace); + eventData._PROTO_plugin_name = pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; if (marketplace) { - eventData._PROTO_marketplace_name = - marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED + eventData._PROTO_marketplace_name = marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; } eventData.plugin_repository = ( isOfficial ? repository : 'third-party' - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; eventData.plugin_name = ( isOfficial ? pluginManifest.name : 'third-party' - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; if (isOfficial && pluginManifest.version) { - eventData.plugin_version = - pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + eventData.plugin_version = pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; } - Object.assign( - eventData, - buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), - ) + Object.assign(eventData, buildPluginCommandTelemetryFields(returnedCommand.pluginInfo)); } logEvent('tengu_input_command', { ...eventData, - invocation_trigger: - 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(process.env.USER_TYPE === 'ant' && { - skill_name: - commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(returnedCommand.type === 'prompt' && { - skill_source: - returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.loadedFrom && { - skill_loaded_from: - returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.kind && { - skill_kind: - returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }), - }) + }); // Check if this is a compact result which handle their own synthetic caveat message ordering - const isCompactResult = - newMessages.length > 0 && - newMessages[0] && - isCompactBoundaryMessage(newMessages[0]) + const isCompactResult = newMessages.length > 0 && newMessages[0] && isCompactBoundaryMessage(newMessages[0]); return { messages: - messageShouldQuery || - newMessages.every(isSystemLocalCommandMessage) || - isCompactResult + messageShouldQuery || newMessages.every(isSystemLocalCommandMessage) || isCompactResult ? newMessages : [createSyntheticUserCaveatMessage(), ...newMessages], shouldQuery: messageShouldQuery, @@ -686,7 +672,8 @@ export async function processSlashCommand( resultText, nextInput, submitNextInput, - } + deferAutonomyCompletion, + }; } async function getMessagesForSlashCommand( @@ -699,12 +686,13 @@ async function getMessagesForSlashCommand( _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string, + autonomy?: QueuedCommand['autonomy'], ): Promise { - const command = getCommand(commandName, context.options.commands) + const command = getCommand(commandName, context.options.commands); // Track skill usage for ranking (only for prompt commands that are user-invocable) if (command.type === 'prompt' && command.userInvocable !== false) { - recordSkillUsage(commandName) + recordSkillUsage(commandName); } // Check if the command is user-invocable @@ -724,25 +712,25 @@ async function getMessagesForSlashCommand( ], shouldQuery: false, command, - } + }; } try { switch (command.type) { case 'local-jsx': { return new Promise(resolve => { - let doneWasCalled = false + let doneWasCalled = false; const onDone = ( result?: string, options?: { - display?: CommandResultDisplay - shouldQuery?: boolean - metaMessages?: string[] - nextInput?: string - submitNextInput?: boolean + display?: CommandResultDisplay; + shouldQuery?: boolean; + metaMessages?: string[]; + nextInput?: string; + submitNextInput?: boolean; }, ) => { - doneWasCalled = true + doneWasCalled = true; // If display is 'skip', don't add any messages to the conversation if (options?.display === 'skip') { void resolve({ @@ -751,14 +739,14 @@ async function getMessagesForSlashCommand( command, nextInput: options?.nextInput, submitNextInput: options?.submitNextInput, - }) - return + }); + return; } // Meta messages are model-visible but hidden from the user - const metaMessages = (options?.metaMessages ?? []).map( - (content: string) => createUserMessage({ content, isMeta: true }), - ) + const metaMessages = (options?.metaMessages ?? []).map((content: string) => + createUserMessage({ content, isMeta: true }), + ); // In fullscreen the command just showed as a centered modal // pane — the transient notification is enough feedback. The @@ -771,9 +759,7 @@ async function getMessagesForSlashCommand( // usage, /rename, /proactive) use display:system for actual // output that must reach the transcript. const skipTranscript = - isFullscreenEnvEnabled() && - typeof result === 'string' && - result.endsWith(' dismissed') + isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed'); void resolve({ messages: @@ -781,12 +767,8 @@ async function getMessagesForSlashCommand( ? skipTranscript ? metaMessages : [ - createCommandInputMessage( - formatCommandInput(command, args), - ), - createCommandInputMessage( - `${result}`, - ), + createCommandInputMessage(formatCommandInput(command, args)), + createCommandInputMessage(`${result}`), ...metaMessages, ] : [ @@ -809,21 +791,21 @@ async function getMessagesForSlashCommand( command, nextInput: options?.nextInput, submitNextInput: options?.submitNextInput, - }) - } + }); + }; void command .load() .then(mod => mod.call(onDone, { ...context, canUseTool }, args)) .then(jsx => { - if (jsx == null) return + if (jsx == null) return; if (context.options.isNonInteractiveSession) { void resolve({ messages: [], shouldQuery: false, command, - }) - return + }); + return; } // Guard: if onDone fired during mod.call() (early-exit path // that calls onDone then returns JSX), skip setToolJSX. This @@ -832,51 +814,51 @@ async function getMessagesForSlashCommand( // its setToolJSX({clearLocalJSX: true}) before we get here. // Setting isLocalJSXCommand after clear leaves it stuck true, // blocking useQueueProcessor and TextInput focus. - if (doneWasCalled) return + if (doneWasCalled) return; setToolJSX({ jsx, shouldHidePromptInput: true, showSpinner: false, isLocalJSXCommand: true, isImmediate: command.immediate === true, - }) + }); }) .catch(e => { // If load()/call() throws and onDone never fired, the outer // Promise hangs forever, leaving queryGuard stuck in // 'dispatching' and deadlocking the queue processor. - logError(e) - if (doneWasCalled) return - doneWasCalled = true + logError(e); + if (doneWasCalled) return; + doneWasCalled = true; setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, - }) - void resolve({ messages: [], shouldQuery: false, command }) - }) - }) + }); + void resolve({ messages: [], shouldQuery: false, command }); + }); + }); } case 'local': { - const displayArgs = command.isSensitive && args.trim() ? '***' : args + const displayArgs = command.isSensitive && args.trim() ? '***' : args; const userMessage = createUserMessage({ content: prepareUserContent({ inputString: formatCommandInput(command, displayArgs), precedingInputBlocks, }), - }) + }); try { - const syntheticCaveatMessage = createSyntheticUserCaveatMessage() - const mod = await command.load() - const result = await mod.call(args, context) + const syntheticCaveatMessage = createSyntheticUserCaveatMessage(); + const mod = await command.load(); + const result = await mod.call(args, context); if (result.type === 'skip') { return { messages: [], shouldQuery: false, command, - } + }; } // Use discriminated union to handle different result types @@ -899,52 +881,43 @@ async function getMessagesForSlashCommand( }), ] : []), - ] + ]; const compactionResultWithSlashMessages = { ...result.compactionResult, - messagesToKeep: [ - ...(result.compactionResult.messagesToKeep ?? []), - ...slashCommandMessages, - ], - } + messagesToKeep: [...(result.compactionResult.messagesToKeep ?? []), ...slashCommandMessages], + }; // Reset microcompact state since full compact replaces all // messages — old tool IDs are no longer relevant. Budget state // (on toolUseContext) needs no reset: stale entries are inert // (UUIDs never repeat, so they're never looked up). - resetMicrocompactState() + resetMicrocompactState(); return { - messages: buildPostCompactMessages( - compactionResultWithSlashMessages, - ) as AssistantMessage[], + messages: buildPostCompactMessages(compactionResultWithSlashMessages) as AssistantMessage[], shouldQuery: false, command, - } + }; } // Text result — use system message so it doesn't render as a user bubble return { messages: [ userMessage, - createCommandInputMessage( - `${result.value}`, - ), + createCommandInputMessage(`${result.value}`), ], shouldQuery: false, command, resultText: result.value, - } + }; } catch (e) { - logError(e) + logError(e); return { messages: [ userMessage, - createCommandInputMessage( - `${String(e)}`, - ), + createCommandInputMessage(`${String(e)}`), ], shouldQuery: false, command, - } + }; } } case 'prompt': { @@ -958,7 +931,8 @@ async function getMessagesForSlashCommand( precedingInputBlocks, setToolJSX, canUseTool ?? hasPermissionsToUseTool, - ) + autonomy, + ); } return await getMessagesForPromptSlashCommand( @@ -968,7 +942,7 @@ async function getMessagesForSlashCommand( precedingInputBlocks, imageContentBlocks, uuid, - ) + ); } catch (e) { // Handle abort errors specially to show proper "Interrupted" message if (e instanceof AbortError) { @@ -984,7 +958,7 @@ async function getMessagesForSlashCommand( ], shouldQuery: false, command, - } + }; } return { messages: [ @@ -1000,7 +974,7 @@ async function getMessagesForSlashCommand( ], shouldQuery: false, command, - } + }; } } } @@ -1017,46 +991,40 @@ async function getMessagesForSlashCommand( ], shouldQuery: false, command, - } + }; } - throw e + throw e; } } function formatCommandInput(command: CommandBase, args: string): string { - return formatCommandInputTags(getCommandName(command), args) + return formatCommandInputTags(getCommandName(command), args); } /** * Formats the metadata for a skill loading message. * Used by the Skill tool and for subagent skill preloading. */ -export function formatSkillLoadingMetadata( - skillName: string, - _progressMessage: string = 'loading', -): string { +export function formatSkillLoadingMetadata(skillName: string, _progressMessage: string = 'loading'): string { // Use skill name only - UserCommandMessage renders as "Skill(name)" return [ `<${COMMAND_MESSAGE_TAG}>${skillName}`, `<${COMMAND_NAME_TAG}>${skillName}`, `true`, - ].join('\n') + ].join('\n'); } /** * Formats the metadata for a slash command loading message. */ -function formatSlashCommandLoadingMetadata( - commandName: string, - args?: string, -): string { +function formatSlashCommandLoadingMetadata(commandName: string, args?: string): string { return [ `<${COMMAND_MESSAGE_TAG}>${commandName}`, `<${COMMAND_NAME_TAG}>/${commandName}`, args ? `${args}` : null, ] .filter(Boolean) - .join('\n') + .join('\n'); } /** @@ -1064,26 +1032,19 @@ function formatSlashCommandLoadingMetadata( * User-invocable skills use slash command format (/name), while model-only * skills use the skill format ("The X skill is running"). */ -function formatCommandLoadingMetadata( - command: CommandBase & PromptCommand, - args?: string, -): string { +function formatCommandLoadingMetadata(command: CommandBase & PromptCommand, args?: string): string { // Use command.name (the qualified name including plugin prefix, e.g. // "product-management:feature-spec") instead of userFacingName() which may // strip the plugin prefix via displayName fallback. // User-invocable skills should show as /command-name like regular slash commands if (command.userInvocable !== false) { - return formatSlashCommandLoadingMetadata(command.name, args) + return formatSlashCommandLoadingMetadata(command.name, args); } // Model-only skills (userInvocable: false) show as "The X skill is running" - if ( - command.loadedFrom === 'skills' || - command.loadedFrom === 'plugin' || - command.loadedFrom === 'mcp' - ) { - return formatSkillLoadingMetadata(command.name, command.progressMessage) + if (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin' || command.loadedFrom === 'mcp') { + return formatSkillLoadingMetadata(command.name, command.progressMessage); } - return formatSlashCommandLoadingMetadata(command.name, args) + return formatSlashCommandLoadingMetadata(command.name, args); } export async function processPromptSlashCommand( @@ -1093,22 +1054,16 @@ export async function processPromptSlashCommand( context: ToolUseContext, imageContentBlocks: ContentBlockParam[] = [], ): Promise { - const command = findCommand(commandName, commands) + const command = findCommand(commandName, commands); if (!command) { - throw new MalformedCommandError(`Unknown command: ${commandName}`) + throw new MalformedCommandError(`Unknown command: ${commandName}`); } if (command.type !== 'prompt') { throw new Error( `Unexpected ${command.type} command. Expected 'prompt' command. Use /${commandName} directly in the main conversation.`, - ) + ); } - return getMessagesForPromptSlashCommand( - command, - args, - context, - [], - imageContentBlocks, - ) + return getMessagesForPromptSlashCommand(command, args, context, [], imageContentBlocks); } async function getMessagesForPromptSlashCommand( @@ -1128,33 +1083,23 @@ async function getMessagesForPromptSlashCommand( // parent env, so we also check !context.agentId: agentId is only set for // subagents, letting workers fall through to getPromptForCommand and receive // the real skill content when they invoke the Skill tool. - if ( - feature('COORDINATOR_MODE') && - isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) && - !context.agentId - ) { - const metadata = formatCommandLoadingMetadata(command, args) - const parts: string[] = [ - `Skill "/${command.name}" is available for workers.`, - ] + if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) && !context.agentId) { + const metadata = formatCommandLoadingMetadata(command, args); + const parts: string[] = [`Skill "/${command.name}" is available for workers.`]; if (command.description) { - parts.push(`Description: ${command.description}`) + parts.push(`Description: ${command.description}`); } if (command.whenToUse) { - parts.push(`When to use: ${command.whenToUse}`) + parts.push(`When to use: ${command.whenToUse}`); } - const skillAllowedTools = command.allowedTools ?? [] + const skillAllowedTools = command.allowedTools ?? []; if (skillAllowedTools.length > 0) { - parts.push( - `This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`, - ) + parts.push(`This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`); } parts.push( `\nInstruct a worker to use this skill by including "Use the /${command.name} skill" in your Agent prompt. The worker has access to the Skill tool and will receive the skill's content and permissions when it invokes it.`, - ) - const summaryContent: ContentBlockParam[] = [ - { type: 'text', text: parts.join('\n') }, - ] + ); + const summaryContent: ContentBlockParam[] = [{ type: 'text', text: parts.join('\n') }]; return { messages: [ createUserMessage({ content: metadata, uuid }), @@ -1164,55 +1109,45 @@ async function getMessagesForPromptSlashCommand( model: command.model, effort: command.effort, command, - } + }; } - const result = await command.getPromptForCommand(args, context) + const result = await command.getPromptForCommand(args, context); // Register skill hooks if defined. Under ["hooks"]-only (skills not locked), // user skills still load and reach this point — block hook REGISTRATION here // where source is known. Mirrors the agent frontmatter gate in runAgent.ts. - const hooksAllowedForThisSkill = - !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source) + const hooksAllowedForThisSkill = !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source); if (command.hooks && hooksAllowedForThisSkill) { - const sessionId = getSessionId() + const sessionId = getSessionId(); registerSkillHooks( context.setAppState, sessionId, command.hooks, command.name, command.type === 'prompt' ? command.skillRoot : undefined, - ) + ); } // Record skill invocation for compaction preservation, scoped by agent context. // Skills are tagged with their agentId so only skills belonging to the current // agent are restored during compaction (preventing cross-agent leaks). - const skillPath = command.source - ? `${command.source}:${command.name}` - : command.name + const skillPath = command.source ? `${command.source}:${command.name}` : command.name; const skillContent = result .filter((b): b is TextBlockParam => b.type === 'text') .map(b => b.text) - .join('\n\n') - addInvokedSkill( - command.name, - skillPath, - skillContent, - getAgentContext()?.agentId ?? null, - ) + .join('\n\n'); + addInvokedSkill(command.name, skillPath, skillContent, getAgentContext()?.agentId ?? null); - const metadata = formatCommandLoadingMetadata(command, args) + const metadata = formatCommandLoadingMetadata(command, args); - const additionalAllowedTools = parseToolListFromCLI( - command.allowedTools ?? [], - ) + const additionalAllowedTools = parseToolListFromCLI(command.allowedTools ?? []); // Create content for the main message, including any pasted images const mainMessageContent: ContentBlockParam[] = imageContentBlocks.length > 0 || precedingInputBlocks.length > 0 ? [...imageContentBlocks, ...precedingInputBlocks, ...result] - : result + : result; // Extract attachments from command arguments (@-mentions, MCP resources, // agent mentions in SKILL.md). skipSkillDiscovery prevents the SKILL.md @@ -1232,7 +1167,7 @@ async function getMessagesForPromptSlashCommand( 'repl_main_thread', { skipSkillDiscovery: true }, ), - ) + ); const messages = [ createUserMessage({ @@ -1249,7 +1184,7 @@ async function getMessagesForPromptSlashCommand( allowedTools: additionalAllowedTools, model: command.model, }), - ] + ]; return { messages, @@ -1258,5 +1193,5 @@ async function getMessagesForPromptSlashCommand( model: command.model, effort: command.effort, command, - } + }; } diff --git a/src/utils/processUserInput/processUserInput.ts b/src/utils/processUserInput/processUserInput.ts index 82afc7dd9..fc6809e70 100644 --- a/src/utils/processUserInput/processUserInput.ts +++ b/src/utils/processUserInput/processUserInput.ts @@ -28,6 +28,7 @@ import type { import type { PermissionMode } from '../../types/permissions.js' import { isValidImagePaste, + type QueuedCommand, type PromptInputMode, } from '../../types/textInputTypes.js' import { @@ -80,6 +81,8 @@ export type ProcessUserInputBaseResult = { // Used by /discover to chain into the selected feature's command nextInput?: string submitNextInput?: boolean + // When true, detached work will finalize the autonomy run later. + deferAutonomyCompletion?: boolean } export async function processUserInput({ @@ -100,6 +103,7 @@ export async function processUserInput({ bridgeOrigin, isMeta, skipAttachments, + autonomy, }: { input: string | Array /** @@ -137,6 +141,7 @@ export async function processUserInput({ */ isMeta?: boolean skipAttachments?: boolean + autonomy?: QueuedCommand['autonomy'] }): Promise { const inputString = typeof input === 'string' ? input : null // Immediately show the user input prompt while we are still processing the input. @@ -168,6 +173,7 @@ export async function processUserInput({ isMeta, skipAttachments, preExpansionInput, + autonomy, ) queryCheckpoint('query_process_user_input_base_end') @@ -251,7 +257,9 @@ export async function processUserInput({ ...hookResult.message, attachment: { ...hookResult.message.attachment!, - content: applyTruncation(hookResult.message.attachment!.content as string), + content: applyTruncation( + hookResult.message.attachment!.content as string, + ), }, } as AttachmentMessage) break @@ -296,6 +304,7 @@ async function processUserInputBase( isMeta?: boolean, skipAttachments?: boolean, preExpansionInput?: string, + autonomy?: QueuedCommand['autonomy'], ): Promise { let inputString: string | null = null let precedingInputBlocks: ContentBlockParam[] = [] @@ -488,6 +497,7 @@ async function processUserInputBase( uuid, isAlreadyProcessing, canUseTool, + autonomy, ) return addImageMetadataMessage(slashResult, imageMetadataTexts) } @@ -546,6 +556,7 @@ async function processUserInputBase( uuid, isAlreadyProcessing, canUseTool, + autonomy, ) return addImageMetadataMessage(slashResult, imageMetadataTexts) } diff --git a/tests/integration/autonomy-lifecycle-user-flow.test.ts b/tests/integration/autonomy-lifecycle-user-flow.test.ts new file mode 100644 index 000000000..2cf6a654f --- /dev/null +++ b/tests/integration/autonomy-lifecycle-user-flow.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { + resetStateForTests, + setOriginalCwd, + setProjectRoot, +} from '../../src/bootstrap/state' +import { + listAutonomyRuns, + startManagedAutonomyFlowFromHeartbeatTask, +} from '../../src/utils/autonomyRuns' +import { listAutonomyFlows } from '../../src/utils/autonomyFlows' + +const CLI_ENTRYPOINT = resolve(import.meta.dir, '../../src/entrypoints/cli.tsx') + +let tempDir = '' +let configDir = '' +let previousConfigDir: string | undefined + +async function runAutonomyCli(args: string[]): Promise { + const proc = Bun.spawn({ + cmd: [process.execPath, CLI_ENTRYPOINT, 'autonomy', ...args], + cwd: tempDir, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + CI: 'true', + GITHUB_ACTIONS: 'true', + NODE_ENV: 'development', + NO_COLOR: '1', + }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + return stdout +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'autonomy-user-flow-')) + configDir = join(tempDir, 'config') + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = configDir + resetStateForTests() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) +}) + +afterEach(() => { + resetStateForTests() + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + +describe('autonomy lifecycle user-equivalent CLI flow', () => { + test('status --deep works from a clean project without creating autonomy state', async () => { + const output = await runAutonomyCli(['status', '--deep']) + + expect(output).toContain('# Autonomy Deep Status') + expect(output).toContain('Autonomy runs: 0') + expect(output).toContain('Autonomy flows: 0') + expect(existsSync(join(tempDir, '.claude', 'autonomy', 'runs.json'))).toBe( + false, + ) + expect(existsSync(join(tempDir, '.claude', 'autonomy', 'flows.json'))).toBe( + false, + ) + }) + + test('real CLI can inspect, resume, and cancel a persisted managed flow', async () => { + await startManagedAutonomyFlowFromHeartbeatTask({ + rootDir: tempDir, + currentDir: tempDir, + task: { + name: 'manual-user-flow', + interval: '1h', + prompt: 'Manual lifecycle acceptance', + steps: [ + { + name: 'approve', + prompt: 'Wait for manual approval', + waitFor: 'manual', + }, + { + name: 'execute', + prompt: 'Execute approved work', + }, + ], + }, + }) + const [waitingFlow] = await listAutonomyFlows(tempDir) + expect(waitingFlow?.status).toBe('waiting') + + const status = await runAutonomyCli(['status', '--deep']) + expect(status).toContain('Autonomy flows: 1') + expect(status).toContain('Waiting: 1') + + const flows = await runAutonomyCli(['flows', '5']) + expect(flows).toContain(waitingFlow!.flowId) + expect(flows).toContain('waiting') + + const detailBefore = await runAutonomyCli(['flow', waitingFlow!.flowId]) + expect(detailBefore).toContain('Status: waiting') + expect(detailBefore).toContain('Current step: approve') + + const resume = await runAutonomyCli(['flow', 'resume', waitingFlow!.flowId]) + expect(resume).toContain('Prepared the next managed step') + expect(resume).toContain('Prompt:') + + const detailAfterResume = await runAutonomyCli([ + 'flow', + waitingFlow!.flowId, + ]) + expect(detailAfterResume).toContain('Status: queued') + expect(detailAfterResume).toContain('Latest run:') + + const cancel = await runAutonomyCli(['flow', 'cancel', waitingFlow!.flowId]) + expect(cancel).toContain('Cancelled flow') + + const [cancelledRun] = await listAutonomyRuns(tempDir) + const [cancelledFlow] = await listAutonomyFlows(tempDir) + expect(cancelledRun?.status).toBe('cancelled') + expect(cancelledFlow?.status).toBe('cancelled') + + const detailAfterCancel = await runAutonomyCli([ + 'flow', + waitingFlow!.flowId, + ]) + expect(detailAfterCancel).toContain('Status: cancelled') + }, 30000) +})