Feat/integrate lint preview (#285)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎

Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct detectMimeFromBase64 to decode raw bytes from base64

Cherry-picked from origin/lint/preview (ee36954).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构

Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes.

- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
- 修复 --daemon-worker=kind 等号格式解析
- 修复 daemon/bg fast path 缺少 setShellIfWindows()
- 修复 checkPathExists 用 existsSync 替代 execSync('dir')
- 7 个 spawn 站点迁移到 CliLaunchSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-16 20:59:29 +08:00
committed by GitHub
parent a02dc0bded
commit c8d08d235b
137 changed files with 13267 additions and 837 deletions

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import {
AUTONOMY_AGENTS_PATH_POSIX,
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
loadAutonomyAuthority,
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 = ''
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 writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
await writeTempFile(
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 writeTempFile(
tempDir,
HEARTBEAT_REL,
'# Root heartbeat\nRemember the root task',
)
await writeTempFile(
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 writeTempFile(tempDir, AGENTS_REL, 'root rules')
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
await writeTempFile(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 writeTempFile(
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 writeTempFile(
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 (')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
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('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)
}
})
})

View File

@@ -0,0 +1,421 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
recoverManagedAutonomyFlowPrompt,
resolveAutonomyRunsPath,
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 = ''
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 writeTempFile(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',
})
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 writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(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/failed 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,
})
await markAutonomyRunCompleted(runId, tempDir, 200)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'completed',
endedAt: 200,
})
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'failed',
endedAt: 300,
error: 'boom',
})
})
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('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 writeTempFile(
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 writeTempFile(
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)
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'
import {
buildMissedTaskNotification,
isRecurringTaskAged,
} from '../cronScheduler'
describe('cronScheduler baseline helpers', () => {
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
0,
),
).toBe(false)
})
test('isRecurringTaskAged only ages recurring non-permanent tasks', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0 },
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{
id: 'b',
cron: '* * * * *',
prompt: 'x',
createdAt: 0,
recurring: true,
permanent: true,
},
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
100,
),
).toBe(true)
})
test('buildMissedTaskNotification preserves AskUserQuestion safety instruction', () => {
const msg = buildMissedTaskNotification([
{
id: 'a1b2c3d4',
cron: '* * * * *',
prompt: 'check deployment',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('AskUserQuestion')
expect(msg).toContain('Do NOT execute this prompt yet')
expect(msg).toContain('check deployment')
})
test('buildMissedTaskNotification widens the code fence when the prompt contains backticks', () => {
const msg = buildMissedTaskNotification([
{
id: 'z9y8x7w6',
cron: '* * * * *',
prompt: 'run ```dangerous``` only if approved',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('````')
expect(msg).toContain('run ```dangerous``` only if approved')
})
})

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
getSessionCronTasks,
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
addCronTask,
findMissedTasks,
getCronFilePath,
hasCronTasksSync,
listAllCronTasks,
markCronTasksFired,
nextCronRunMs,
oneShotJitteredNextCronRunMs,
readCronTasks,
removeCronTasks,
writeCronTasks,
} from '../cronTasks'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('cron-baseline-')
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('cronTasks baseline', () => {
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
const tasks = await listAllCronTasks()
expect(id).toHaveLength(8)
expect(getSessionCronTasks()).toHaveLength(1)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({
id,
prompt: 'session-only prompt',
durable: false,
recurring: true,
})
expect(existsSync(getCronFilePath())).toBe(false)
})
test('durable cron tasks are written to .claude/scheduled_tasks.json', async () => {
const id = await addCronTask('* * * * *', 'durable prompt', true, true)
const filePath = getCronFilePath()
const fileTasks = await readCronTasks()
expect(existsSync(filePath)).toBe(true)
expect(filePath).toBe(join(tempDir, '.claude', 'scheduled_tasks.json'))
expect(fileTasks).toHaveLength(1)
expect(fileTasks[0]).toMatchObject({
id,
prompt: 'durable prompt',
recurring: true,
})
expect(fileTasks[0].durable).toBeUndefined()
})
test('writeCronTasks strips runtime-only durable flags from disk', async () => {
await writeCronTasks([
{
id: 'abc12345',
cron: '* * * * *',
prompt: 'strip durable',
createdAt: 123,
recurring: true,
durable: false,
},
])
const raw = await readFile(getCronFilePath(), 'utf-8')
expect(raw).not.toContain('"durable"')
})
test('hasCronTasksSync reflects whether the durable cron file has entries', async () => {
expect(hasCronTasksSync()).toBe(false)
await writeCronTasks([
{
id: 'sync0001',
cron: '* * * * *',
prompt: 'present',
createdAt: 1,
},
])
expect(hasCronTasksSync()).toBe(true)
})
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
await addCronTask('* * * * *', 'session prompt', true, false)
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
const sessionView = await listAllCronTasks()
const daemonView = await listAllCronTasks(tempDir)
expect(sessionView).toHaveLength(2)
expect(daemonView).toHaveLength(1)
expect(daemonView[0]).toMatchObject({
id: durableId,
prompt: 'durable prompt',
})
})
test('removeCronTasks without dir removes session-only tasks from memory', async () => {
const sessionId = await addCronTask('* * * * *', 'remove me', true, false)
await removeCronTasks([sessionId])
expect(getSessionCronTasks()).toHaveLength(0)
expect(await listAllCronTasks()).toHaveLength(0)
})
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
await addCronTask('* * * * *', 'durable prompt', true, true)
await removeCronTasks([sessionId], tempDir)
expect(getSessionCronTasks()).toHaveLength(1)
expect(getSessionCronTasks()[0]?.id).toBe(sessionId)
})
test('markCronTasksFired persists lastFiredAt for durable tasks', async () => {
await writeCronTasks([
{
id: 'fire0001',
cron: '* * * * *',
prompt: 'persist fired',
createdAt: 100,
recurring: true,
},
])
await markCronTasksFired(['fire0001'], 123456789)
const tasks = await readCronTasks()
expect(tasks[0]?.lastFiredAt).toBe(123456789)
})
test('findMissedTasks returns tasks whose first scheduled run is in the past', () => {
const nowMs = new Date('2026-04-12T10:10:00').getTime()
const tasks = findMissedTasks(
[
{
id: 'missed01',
cron: '* * * * *',
prompt: 'old task',
createdAt: new Date('2026-04-12T10:00:00').getTime(),
},
{
id: 'future01',
cron: '59 23 31 12 *',
prompt: 'far future',
createdAt: nowMs,
},
],
nowMs,
)
expect(tasks.map(t => t.id)).toEqual(['missed01'])
})
test('nextCronRunMs returns null for invalid cron expressions', () => {
expect(nextCronRunMs('invalid cron', Date.now())).toBeNull()
})
test('oneShotJitteredNextCronRunMs never returns a time earlier than fromMs', () => {
const fromMs = new Date('2026-04-12T10:59:50').getTime()
const next = oneShotJitteredNextCronRunMs('0 11 * * *', fromMs, '00000000')
expect(next).not.toBeNull()
expect(next!).toBeGreaterThanOrEqual(fromMs)
})
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
const fromMs = new Date('2026-04-12T10:00:00').getTime()
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
expect(exact).not.toBeNull()
expect(jittered).not.toBeNull()
expect(jittered!).toBeGreaterThanOrEqual(fromMs)
})
})

View File

@@ -0,0 +1,82 @@
import { describe, test, expect, mock } from 'bun:test'
// Mock dependencies before importing the module under test
let mockPreferredLanguage: string | undefined
let mockSystemLocale: string | undefined
mock.module('src/utils/config.js', () => ({
getGlobalConfig: () => ({
preferredLanguage: mockPreferredLanguage,
}),
}))
mock.module('src/utils/intl.js', () => ({
getSystemLocaleLanguage: () => mockSystemLocale,
}))
const { getResolvedLanguage, getLanguageDisplayName } = await import(
'src/utils/language.js'
)
describe('getResolvedLanguage', () => {
test('returns en when config is explicitly en', () => {
mockPreferredLanguage = 'en'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('en')
})
test('returns zh when config is explicitly zh', () => {
mockPreferredLanguage = 'zh'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to system locale zh when config is auto', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to en when config is auto and system locale is not zh', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to en when config is auto and system locale is undefined', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to auto behavior when config preferredLanguage is undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('defaults to en when both config and locale are undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
})
describe('getLanguageDisplayName', () => {
test('returns Auto (follow system) for auto', () => {
expect(getLanguageDisplayName('auto')).toBe('Auto (follow system)')
})
test('returns English for en', () => {
expect(getLanguageDisplayName('en')).toBe('English')
})
test('returns 中文 for zh', () => {
expect(getLanguageDisplayName('zh')).toBe('中文')
})
test('returns the input string for unknown language codes', () => {
expect(getLanguageDisplayName('fr')).toBe('fr')
expect(getLanguageDisplayName('unknown')).toBe('unknown')
})
})

View File

@@ -0,0 +1,124 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import {
setMasterMutedPipes,
isMasterPipeMuted,
removeMasterPipeMute,
clearMasterMutedPipes,
addSendOverride,
removeSendOverride,
hasSendOverride,
clearSendOverrides,
} from '../pipeMuteState.js'
describe('setMasterMutedPipes', () => {
beforeEach(() => {
clearMasterMutedPipes()
clearSendOverrides()
})
test('sets muted pipes from iterable', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(true)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
test('replaces previous muted set', () => {
setMasterMutedPipes(['pipe-a'])
setMasterMutedPipes(['pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
})
describe('isMasterPipeMuted', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('returns false for unknown pipe', () => {
expect(isMasterPipeMuted('unknown')).toBe(false)
})
})
describe('removeMasterPipeMute', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('removes a single muted pipe', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
removeMasterPipeMute('pipe-a')
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
test('no-ops for non-existent pipe', () => {
removeMasterPipeMute('nonexistent')
expect(isMasterPipeMuted('nonexistent')).toBe(false)
})
})
describe('clearMasterMutedPipes', () => {
test('clears all muted pipes', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b', 'pipe-c'])
clearMasterMutedPipes()
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(false)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
})
describe('addSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('adds a send override', () => {
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
test('adding same override twice is idempotent', () => {
addSendOverride('pipe-x')
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
})
describe('removeSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('removes a send override', () => {
addSendOverride('pipe-x')
removeSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(false)
})
test('no-ops for non-existent override', () => {
removeSendOverride('nonexistent')
expect(hasSendOverride('nonexistent')).toBe(false)
})
})
describe('hasSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('returns false when no overrides set', () => {
expect(hasSendOverride('pipe-x')).toBe(false)
})
})
describe('clearSendOverrides', () => {
test('clears all send overrides', () => {
addSendOverride('pipe-a')
addSendOverride('pipe-b')
clearSendOverrides()
expect(hasSendOverride('pipe-a')).toBe(false)
expect(hasSendOverride('pipe-b')).toBe(false)
})
})

View File

@@ -0,0 +1,93 @@
/**
* Tests for src/utils/taskSummary.ts
*
* Covers: shouldGenerateTaskSummary, maybeGenerateTaskSummary
*
* Note: bun:bundle's feature() is a compile-time construct and cannot be
* trivially mocked at test time. We test maybeGenerateTaskSummary (which
* is called unconditionally) and the rate-limit behavior indirectly.
*/
import { describe, expect, test, mock, beforeEach } from 'bun:test'
// ─── mocks ──────────────────────────────────────────────────────────────────
let _updateCalls: any[] = []
mock.module('bun:bundle', () => ({
feature: (_name: string) => false,
}))
mock.module('../concurrentSessions.js', () => ({
isBgSession: () => false,
updateSessionActivity: async (data: any) => {
_updateCalls.push(data)
},
}))
mock.module('../debug.js', () => ({
logForDebugging: () => {},
}))
// ─── import after mocks ─────────────────────────────────────────────────────
const { shouldGenerateTaskSummary, maybeGenerateTaskSummary } = await import(
'../taskSummary.js'
)
// ─── tests ──────────────────────────────────────────────────────────────────
beforeEach(() => {
_updateCalls = []
})
describe('shouldGenerateTaskSummary', () => {
test('returns false when feature is disabled', () => {
// bun:bundle feature mock returns false
expect(shouldGenerateTaskSummary()).toBe(false)
})
})
describe('maybeGenerateTaskSummary', () => {
test('does not throw with empty messages', () => {
expect(() =>
maybeGenerateTaskSummary({ forkContextMessages: [] }),
).not.toThrow()
})
test('does not throw with undefined messages', () => {
expect(() => maybeGenerateTaskSummary({})).not.toThrow()
})
test('does not throw with assistant message containing tool_use', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'Let me check' },
{ type: 'tool_use', name: 'bash' },
],
},
},
],
}),
).not.toThrow()
})
test('does not throw with non-array content', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: 'plain text response',
},
},
],
}),
).not.toThrow()
})
})