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)
376 lines
9.9 KiB
TypeScript
376 lines
9.9 KiB
TypeScript
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)
|
|
})
|
|
})
|