mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口
围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
(修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
WorkflowTool(inline)/service(scriptPath)/ultracode(harness)
含配套 workflow engine/panel 完善与 run-state-persistence design doc。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,11 @@ test('RunProgress 字段契约:面板读取的 key 均存在', () => {
|
||||
workflowName: 'review',
|
||||
status: 'running',
|
||||
phases: [{ title: 'Find', status: 'done' }],
|
||||
declaredPhases: ['Find', 'Review'],
|
||||
currentPhase: 'Review',
|
||||
agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }],
|
||||
agentCount: 1,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
// 面板 WorkflowList/Detail 读取的路径
|
||||
@@ -56,10 +58,12 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
workflowName: 'w',
|
||||
status: 'completed',
|
||||
phases: [],
|
||||
declaredPhases: [],
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
returnValue: 'ok',
|
||||
startedAt: 2,
|
||||
updatedAt: 2,
|
||||
};
|
||||
const failed: RunProgress = {
|
||||
@@ -67,10 +71,12 @@ test('RunProgress 完成/失败形态:returnValue/error 可选', () => {
|
||||
workflowName: 'w',
|
||||
status: 'failed',
|
||||
phases: [],
|
||||
declaredPhases: [],
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
error: 'boom',
|
||||
startedAt: 3,
|
||||
updatedAt: 3,
|
||||
};
|
||||
expect(completed.returnValue).toBe('ok');
|
||||
|
||||
@@ -21,6 +21,7 @@ mock.module(
|
||||
content: [{ type: 'text', text: 'agent-text' }],
|
||||
usage: { output_tokens: 42 },
|
||||
totalTokens: 42,
|
||||
totalToolUseCount: 3,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -42,6 +43,39 @@ mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
|
||||
mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} }))
|
||||
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
|
||||
|
||||
// isolation:'worktree' 测试用:mock worktree 三件套(避免真跑 git worktree add)。
|
||||
// 注意 mock.module 是 process-global;worktreeState 在工厂外定义供测试重置。
|
||||
// 不 mock cwd.js:runWithCwdOverride 真跑 AsyncLocalStorage 对 mock runAgent 无害,
|
||||
// 且避免污染同进程其他依赖 pwd/getCwd 的测试。
|
||||
const worktreeState = {
|
||||
shouldThrow: false,
|
||||
hasChanges: false,
|
||||
created: [] as string[],
|
||||
removed: [] as string[],
|
||||
changesCalls: 0,
|
||||
}
|
||||
mock.module('src/utils/worktree.js', () => ({
|
||||
createAgentWorktree: async (slug: string) => {
|
||||
if (worktreeState.shouldThrow) throw new Error('wt boom')
|
||||
worktreeState.created.push(slug)
|
||||
return {
|
||||
worktreePath: '/fake/wt',
|
||||
worktreeBranch: 'wt-branch',
|
||||
headCommit: 'abc123',
|
||||
gitRoot: '/fake',
|
||||
hookBased: false,
|
||||
}
|
||||
},
|
||||
hasWorktreeChanges: async () => {
|
||||
worktreeState.changesCalls++
|
||||
return worktreeState.hasChanges
|
||||
},
|
||||
removeAgentWorktree: async (path: string) => {
|
||||
worktreeState.removed.push(path)
|
||||
return true
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
claudeCodeBackend,
|
||||
resolveAgentDefinition,
|
||||
@@ -77,15 +111,68 @@ function ctx() {
|
||||
}
|
||||
}
|
||||
|
||||
test('文本 agent → ok + token 计量', async () => {
|
||||
test('文本 agent → ok + token/tool/model 计量', async () => {
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
if (res.kind === 'ok') {
|
||||
expect(res.output).toBe('agent-text')
|
||||
expect(res.usage.outputTokens).toBe(42)
|
||||
// 面板展示字段:tokenCount(=totalTokens) / toolCount / model(fallback mainLoopModel 'm')
|
||||
expect(res.tokenCount).toBe(42)
|
||||
expect(res.toolCount).toBe(3)
|
||||
expect(res.model).toBe('m')
|
||||
}
|
||||
})
|
||||
|
||||
test('isolation:worktree → 创建 worktree + 无变更自动清理;slug 匹配清理正则', async () => {
|
||||
worktreeState.shouldThrow = false
|
||||
worktreeState.hasChanges = false
|
||||
worktreeState.created = []
|
||||
worktreeState.removed = []
|
||||
worktreeState.changesCalls = 0
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(1)
|
||||
// slug 必须匹配 cleanupStaleAgentWorktrees 的清理正则 ^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$
|
||||
expect(worktreeState.created[0]).toMatch(/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/)
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
expect(worktreeState.removed).toHaveLength(1) // 无变更 → auto-remove
|
||||
})
|
||||
|
||||
test('isolation:worktree 有变更 → 保留 worktree(不 remove)', async () => {
|
||||
worktreeState.hasChanges = true
|
||||
worktreeState.created = []
|
||||
worktreeState.removed = []
|
||||
worktreeState.changesCalls = 0
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.removed).toHaveLength(0) // 有变更 → 保留
|
||||
expect(worktreeState.changesCalls).toBe(1)
|
||||
})
|
||||
|
||||
test('isolation:worktree 创建失败 → fail-closed 返 dead(不静默退化共享 cwd)', async () => {
|
||||
worktreeState.shouldThrow = true
|
||||
const res = await claudeCodeBackend.run(
|
||||
{ prompt: 'do', isolation: 'worktree' },
|
||||
ctx(),
|
||||
)
|
||||
expect(res.kind).toBe('dead')
|
||||
worktreeState.shouldThrow = false
|
||||
})
|
||||
|
||||
test('无 isolation → 不创建 worktree', async () => {
|
||||
worktreeState.created = []
|
||||
const res = await claudeCodeBackend.run({ prompt: 'do' }, ctx())
|
||||
expect(res.kind).toBe('ok')
|
||||
expect(worktreeState.created).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('runAgent 抛错 → dead', async () => {
|
||||
// 覆盖 mock 让 runAgent 抛(last-write-wins)
|
||||
mock.module(
|
||||
|
||||
@@ -47,6 +47,7 @@ function makeRun(
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -173,3 +173,59 @@ test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () =
|
||||
expect(agents.find(a => a.id === 1)?.outputShape).toBe('text')
|
||||
expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined()
|
||||
})
|
||||
|
||||
test('agent_progress 实时更新 token/tool(按 agentId 关联)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({
|
||||
type: 'agent_started',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
label: 'a',
|
||||
phase: 'A',
|
||||
})
|
||||
bus.emit({
|
||||
type: 'agent_progress',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
tokenCount: 1200,
|
||||
toolCount: 2,
|
||||
})
|
||||
let a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.tokenCount).toBe(1200)
|
||||
expect(a.toolCount).toBe(2)
|
||||
bus.emit({
|
||||
type: 'agent_progress',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
tokenCount: 2400,
|
||||
toolCount: 3,
|
||||
})
|
||||
a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.tokenCount).toBe(2400)
|
||||
expect(a.toolCount).toBe(3)
|
||||
})
|
||||
|
||||
test('agent_done 落地 model/tokenCount/toolCount(ok 变体)', () => {
|
||||
const { bus, store } = newStore()
|
||||
bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })
|
||||
bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' })
|
||||
bus.emit({
|
||||
type: 'agent_done',
|
||||
runId: 'r1',
|
||||
agentId: 0,
|
||||
phase: 'A',
|
||||
result: {
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 5 },
|
||||
model: 'glm-5.2',
|
||||
tokenCount: 22900,
|
||||
toolCount: 1,
|
||||
},
|
||||
})
|
||||
const a = store.get('r1')!.agents.find(x => x.id === 0)!
|
||||
expect(a.model).toBe('glm-5.2')
|
||||
expect(a.tokenCount).toBe(22900)
|
||||
expect(a.toolCount).toBe(1)
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ function run(partial: Partial<RunProgress>): RunProgress {
|
||||
currentPhase: null,
|
||||
agents: [],
|
||||
agentCount: 0,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
...partial,
|
||||
}
|
||||
|
||||
@@ -145,6 +145,29 @@ test('launch → completed;store 出现该 run', async () => {
|
||||
expect(r!.workflowName).toBe('workflow')
|
||||
})
|
||||
|
||||
test('launch inline script → 返回 scriptPath(持久化到 cwdOverride 目录)', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
||||
try {
|
||||
const { ports, store } = fakePorts()
|
||||
const svc = makeService(ports, store, dir)
|
||||
const result = await svc.launch(
|
||||
{ script: `return agent('x')` },
|
||||
stubTUC,
|
||||
stubCanUseTool,
|
||||
)
|
||||
expect(result.scriptPath).toBe(
|
||||
join(dir, '.claude', 'workflow-runs', 'run-1', 'script.js'),
|
||||
)
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
expect(await readFile(result.scriptPath!, 'utf-8')).toBe(
|
||||
`return agent('x')`,
|
||||
)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('kill 走 taskRegistrar.kill', async () => {
|
||||
__resetWorkflowServiceForTests()
|
||||
const { ports, store, killed } = fakePorts()
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
import {
|
||||
STATUS_DOT,
|
||||
RUN_STATUS_COLOR,
|
||||
RUN_STATUS_TEXT,
|
||||
PHASE_MARK,
|
||||
PHASE_COLOR,
|
||||
agentVisual,
|
||||
formatTokenCount,
|
||||
agentMetaText,
|
||||
} from '../panel/status.js'
|
||||
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => {
|
||||
test('STATUS_DOT / RUN_STATUS_COLOR / RUN_STATUS_TEXT 覆盖四种 run 状态', () => {
|
||||
const statuses: RunProgress['status'][] = [
|
||||
'running',
|
||||
'completed',
|
||||
@@ -18,11 +21,14 @@ test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符',
|
||||
for (const s of statuses) {
|
||||
expect(STATUS_DOT[s].length).toBeGreaterThan(0)
|
||||
expect(RUN_STATUS_COLOR[s]).toBeTruthy()
|
||||
expect(RUN_STATUS_TEXT[s].length).toBeGreaterThan(0)
|
||||
}
|
||||
expect(STATUS_DOT.running).toBe('●')
|
||||
expect(STATUS_DOT.completed).toBe('✓')
|
||||
expect(STATUS_DOT.failed).toBe('✗')
|
||||
expect(STATUS_DOT.killed).toBe('■')
|
||||
expect(RUN_STATUS_TEXT.completed).toBe('done')
|
||||
expect(RUN_STATUS_TEXT.running).toBe('running')
|
||||
})
|
||||
|
||||
test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
|
||||
@@ -32,44 +38,51 @@ test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => {
|
||||
expect(PHASE_COLOR.pending).toBe('subtle')
|
||||
})
|
||||
|
||||
test('agentVisual:running → ● warning running', () => {
|
||||
test('agentVisual:running → ● warning', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'running' }
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '●',
|
||||
color: 'warning',
|
||||
suffix: 'running',
|
||||
})
|
||||
expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning' })
|
||||
})
|
||||
|
||||
test('agentVisual:done·object → ✓ success object', () => {
|
||||
test('agentVisual:done·ok → ✓ success(不再带 outputShape 后缀)', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'object',
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'object',
|
||||
})
|
||||
expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success' })
|
||||
})
|
||||
|
||||
test('agentVisual:done·text → ✓ success text', () => {
|
||||
test('agentVisual:dead → ✗ error', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error' })
|
||||
})
|
||||
|
||||
test('formatTokenCount:<1000 原值,≥1000 保留 1 位小数 + k', () => {
|
||||
expect(formatTokenCount(undefined)).toBe('0')
|
||||
expect(formatTokenCount(0)).toBe('0')
|
||||
expect(formatTokenCount(42)).toBe('42')
|
||||
expect(formatTokenCount(1000)).toBe('1.0k')
|
||||
expect(formatTokenCount(22900)).toBe('22.9k')
|
||||
})
|
||||
|
||||
test('agentMetaText:model · Nk tok · N tool', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'done',
|
||||
resultKind: 'ok',
|
||||
outputShape: 'text',
|
||||
model: 'glm-5.2',
|
||||
tokenCount: 22900,
|
||||
toolCount: 1,
|
||||
}
|
||||
expect(agentVisual(a)).toEqual({
|
||||
mark: '✓',
|
||||
color: 'success',
|
||||
suffix: 'text',
|
||||
})
|
||||
expect(agentMetaText(a)).toBe('glm-5.2 · 22.9k tok · 1 tool')
|
||||
})
|
||||
|
||||
test('agentVisual:dead → ✗ error dead', () => {
|
||||
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
|
||||
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
|
||||
test('agentMetaText:无 model 时省略前段', () => {
|
||||
const a: AgentProgress = {
|
||||
id: 1,
|
||||
status: 'running',
|
||||
tokenCount: 500,
|
||||
toolCount: 2,
|
||||
}
|
||||
expect(agentMetaText(a)).toBe('500 tok · 2 tool')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user