Source integration points (previously missing from CCP):
- useScheduledTasks: export createScheduledTaskQueuedCommand, autonomy-backed
queue dedup with markAutonomyRunCancelled on dispose
- processUserInput: thread autonomy payload through to slash commands;
expose deferAutonomyCompletion on ProcessUserInputBaseResult
- processSlashCommand: accept autonomy arg; allowBackgroundForkedSlashCommands
test-only gate on ToolUseContext; defer/finalize autonomy in background fork
- handlePromptSubmit: claim stale autonomy commands before idle queue drain;
finalize autonomy on error paths
- Tool.ts: add allowBackgroundForkedSlashCommands to ToolUseContext
Tests ported from upstream f2e9af49 (11 files, +97 assertions):
- autonomyAuthority.test.ts — authority parsing, prompt assembly
- autonomyFlows.test.ts — managed flow lifecycle
- autonomyPersistence.test.ts — file locking, active retention
- autonomyQueueLifecycle.test.ts — queue partition/claim/finalize
- autonomyRuns.test.ts — state machine, dedup, formatters
- queryAutonomyProviderBoundary.test.ts — provider error finalization
- handlePromptSubmit.test.ts — stale autonomy filtering
- useScheduledTasks.test.ts — cron task → autonomy queue
- processSlashCommand.test.ts — slash command autonomy integration
- autonomy-lifecycle-user-flow.test.ts — CLI subprocess integration
- RemoteTriggerTool.test.ts — merged upstream assertion improvements
3699 pass, 0 fail (was 3602)
This commit is contained in:
parent
12301713b9
commit
9538ebd243
|
|
@ -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<string, unknown>) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
src/__tests__/handlePromptSubmit.test.ts
Normal file
166
src/__tests__/handlePromptSubmit.test.ts
Normal file
|
|
@ -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<void>,
|
||||
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()
|
||||
})
|
||||
})
|
||||
337
src/__tests__/queryAutonomyProviderBoundary.test.ts
Normal file
337
src/__tests__/queryAutonomyProviderBoundary.test.ts
Normal file
|
|
@ -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<string>()
|
||||
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<string>) => Set<string>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
80
src/hooks/__tests__/useScheduledTasks.test.ts
Normal file
80
src/hooks/__tests__/useScheduledTasks.test.ts
Normal file
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<React.SetStateAction<Message[]>>
|
||||
}
|
||||
|
||||
export async function createScheduledTaskQueuedCommand(
|
||||
task: Pick<CronTask, 'id' | 'prompt'>,
|
||||
options?: {
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
shouldCreate?: () => boolean
|
||||
},
|
||||
): Promise<QueuedCommand | null> {
|
||||
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
|
||||
|
|
|
|||
331
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
331
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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('<autonomy_authority>')
|
||||
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('<tick>12:00:00</tick>')
|
||||
})
|
||||
|
||||
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: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 0,
|
||||
})
|
||||
const second = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:10:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 10 * 60_000,
|
||||
})
|
||||
const third = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:31:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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')
|
||||
})
|
||||
})
|
||||
1226
src/utils/__tests__/autonomyFlows.test.ts
Normal file
1226
src/utils/__tests__/autonomyFlows.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
136
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
136
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
279
src/utils/__tests__/autonomyQueueLifecycle.test.ts
Normal file
279
src/utils/__tests__/autonomyQueueLifecycle.test.ts
Normal file
|
|
@ -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: '<tick>12:00:00</tick>',
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
864
src/utils/__tests__/autonomyRuns.test.ts
Normal file
864
src/utils/__tests__/autonomyRuns.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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<Record<string, unknown>>
|
||||
}
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
shouldCreate: () => false,
|
||||
})
|
||||
const committed = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:01:00</tick>',
|
||||
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: '<tick>12:00:00</tick>',
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void> {
|
|||
// 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<void> {
|
|||
commands.every(c => c.workload === firstWorkload)
|
||||
? firstWorkload
|
||||
: undefined
|
||||
const deferredAutonomyRunIds = new Set<string>()
|
||||
|
||||
// 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<void> {
|
|||
// 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
|
||||
|
|
|
|||
375
src/utils/processUserInput/__tests__/processSlashCommand.test.ts
Normal file
375
src/utils/processUserInput/__tests__/processSlashCommand.test.ts
Normal file
|
|
@ -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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
'<scheduled-task-result command="/forked">',
|
||||
),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<ContentBlockParam>
|
||||
/**
|
||||
|
|
@ -137,6 +141,7 @@ export async function processUserInput({
|
|||
*/
|
||||
isMeta?: boolean
|
||||
skipAttachments?: boolean
|
||||
autonomy?: QueuedCommand['autonomy']
|
||||
}): Promise<ProcessUserInputBaseResult> {
|
||||
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<ProcessUserInputBaseResult> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
148
tests/integration/autonomy-lifecycle-user-flow.test.ts
Normal file
148
tests/integration/autonomy-lifecycle-user-flow.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user