diff --git a/packages/workflow-engine/src/__tests__/runWorkflow.test.ts b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts index 27be14f3d..2fda8f3bf 100644 --- a/packages/workflow-engine/src/__tests__/runWorkflow.test.ts +++ b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts @@ -332,6 +332,100 @@ test('发射 run_started(含 workflowName)与 run_done 事件', async () => } }) +// 终态前补发当前 phase 的 phase_done:hook.phase 只在切换时 emit 上一个的 done, +// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。 +test('终态前补发 currentPhase 的 phase_done(completed 路径)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const { ports, events } = portsWithEvents( + dir, + new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]), + ) + await runWorkflow({ + script: `phase('Review')\nreturn agent('x')`, + runId: 'run-phase-done', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + // Review 的 phase_started + phase_done 都应存在(done 来自终态前补发) + expect( + events.some(e => e.type === 'phase_started' && e.phase === 'Review'), + ).toBe(true) + expect( + events.some(e => e.type === 'phase_done' && e.phase === 'Review'), + ).toBe(true) + // 顺序:phase_done 必须在 run_done 之前(reducer 不依赖顺序,但事件流语义清晰) + const lastPhaseDone = Math.max( + 0, + ...events.map((e, i) => (e.type === 'phase_done' ? i : -1)), + ) + const runDoneIdx = events.findIndex(e => e.type === 'run_done') + expect(runDoneIdx).toBeGreaterThan(0) + expect(lastPhaseDone).toBeLessThan(runDoneIdx) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('终态前补发 currentPhase 的 phase_done(killed 路径)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const { ports, events } = portsWithEvents( + dir, + new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]), + ) + const ac = new AbortController() + ac.abort() + await runWorkflow({ + script: `phase('Run')\nreturn agent('x')`, + runId: 'run-kill-phase', + ports, + host: createHostHandle(null), + signal: ac.signal, + cwd: dir, + budgetTotal: null, + }) + expect(events.some(e => e.type === 'phase_done' && e.phase === 'Run')).toBe( + true, + ) + expect( + events.some(e => e.type === 'run_done' && e.status === 'killed'), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const { ports, events } = portsWithEvents( + dir, + new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]), + ) + await runWorkflow({ + script: `return agent('x')`, + runId: 'run-no-phase', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + // 没有 phase() → currentPhase 为 null → 终态不补发 phase_done + expect(events.some(e => e.type === 'phase_done')).toBe(false) + expect(events.some(e => e.type === 'phase_started')).toBe(false) + expect( + events.some(e => e.type === 'run_done' && e.status === 'completed'), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + test('未传 workflowName 时从 meta.name 推导', async () => { const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) try { diff --git a/packages/workflow-engine/src/engine/runWorkflow.ts b/packages/workflow-engine/src/engine/runWorkflow.ts index 8402afa8f..efb80944b 100644 --- a/packages/workflow-engine/src/engine/runWorkflow.ts +++ b/packages/workflow-engine/src/engine/runWorkflow.ts @@ -100,37 +100,40 @@ export async function runWorkflow( const hooks = makeHooks(ctx, runSubWorkflow) + // hook.phase 只在切换 phase 时 emit 上一个 phase 的 phase_done;脚本结束时 + // currentPhase 是最后一个 phase,没有任何后续 phase() 触发其 phase_done → UI 左栏 + // 会永远显示 running(agent 列表已 ✓ done)。终态前补一条,所有 path 共用。 + const emitTerminalPhaseDone = (): void => { + if (!ctx.currentPhase) return + ports.progressEmitter.emit({ + type: 'phase_done', + runId: opts.runId, + phase: ctx.currentPhase, + }) + } + + let result: WorkflowRunResult try { const returnValue = await parsed.execute( hooks, opts.args, ctx.resources.budget, ) - ports.progressEmitter.emit({ - type: 'run_done', - runId: opts.runId, - status: 'completed', - returnValue, - }) - return { status: 'completed', returnValue } + result = { status: 'completed', returnValue } } catch (e) { if (e instanceof WorkflowAbortedError) { - ports.progressEmitter.emit({ - type: 'run_done', - runId: opts.runId, - status: 'killed', - }) - return { status: 'killed' } + result = { status: 'killed' } + } else { + result = { status: 'failed', error: (e as Error).message } } - const error = (e as Error).message - ports.progressEmitter.emit({ - type: 'run_done', - runId: opts.runId, - status: 'failed', - error, - }) - return { status: 'failed', error } } + emitTerminalPhaseDone() + ports.progressEmitter.emit({ + type: 'run_done', + runId: opts.runId, + ...result, + }) + return result } async function resolveSubScript( diff --git a/src/workflow/__tests__/WorkflowsPanel.test.tsx b/src/workflow/__tests__/WorkflowsPanel.test.tsx index 0f4d0be27..071fe1571 100644 --- a/src/workflow/__tests__/WorkflowsPanel.test.tsx +++ b/src/workflow/__tests__/WorkflowsPanel.test.tsx @@ -5,7 +5,7 @@ import { wrappedRender as render } from '@anthropic/ink'; import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js'; import type { RunProgress } from '../progress/store.js'; import { call as panelCall } from '../panel/panelCall.js'; -import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js'; +import { clampSelected, isRunTerminatedTransition, WorkflowsPanel } from '../panel/WorkflowsPanel.js'; import { truncateLabel } from '../panel/AgentList.js'; import { STATUS_DOT } from '../panel/status.js'; import { __resetWorkflowServiceForTests, getWorkflowService } from '../service.js'; @@ -162,3 +162,36 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => { __resetWorkflowServiceForTests(); } }); + +// focused run 从 running 转 terminal 时面板自动 onDone()(800ms 延迟让用户看到终态)。 +// 仅同 runId 的状态转换触发:切到已完成 tab 不退出;打开历史面板也不退出。 +// 转换判定逻辑抽成 isRunTerminatedTransition 纯函数,便于离线单测(Ink test 模式不 +// 自动 pump concurrent 状态更新,集成测试不可靠)。 +test('isRunTerminatedTransition:同 runId running → terminal 触发;其它情况不触发', () => { + const running = { runId: 'r1', status: 'running' as const }; + const completed = { runId: 'r1', status: 'completed' as const }; + const failed = { runId: 'r1', status: 'failed' as const }; + const killed = { runId: 'r1', status: 'killed' as const }; + + // 同 run running → terminal:三种 terminal 都触发 + expect(isRunTerminatedTransition(running, completed)).toBe(true); + expect(isRunTerminatedTransition(running, failed)).toBe(true); + expect(isRunTerminatedTransition(running, killed)).toBe(true); + + // prev=null(打开历史面板):不触发 + expect(isRunTerminatedTransition(null, completed)).toBe(false); + // curr=null(runs 清空):不触发 + expect(isRunTerminatedTransition(running, null)).toBe(false); + + // 不同 runId(切 tab):不触发 + expect(isRunTerminatedTransition({ runId: 'r1', status: 'running' }, { runId: 'r2', status: 'completed' })).toBe( + false, + ); + + // 同 run 但 prev 非 running(已是 terminal 又重渲染):不触发 + expect(isRunTerminatedTransition(completed, completed)).toBe(false); + expect(isRunTerminatedTransition(killed, completed)).toBe(false); + + // 同 run running → running(无变化):不触发 + expect(isRunTerminatedTransition(running, running)).toBe(false); +}); diff --git a/src/workflow/panel/WorkflowsPanel.tsx b/src/workflow/panel/WorkflowsPanel.tsx index 477590a92..6ef6c0ac4 100644 --- a/src/workflow/panel/WorkflowsPanel.tsx +++ b/src/workflow/panel/WorkflowsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useSyncExternalStore } from 'react'; +import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Box, Dialog, Text, useAnimationFrame } from '@anthropic/ink'; import type { Theme } from '@anthropic/ink'; import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; @@ -22,6 +22,25 @@ export function clampSelected(selected: number, len: number): number { return Math.min(n, len - 1); } +/** + * 判断 focused run 是否完成了 running → terminal 的状态转换(用于面板自动退出)。 + * 抽成纯函数便于单测;面板 useEffect 内部直接调用。 + * + * 触发条件:prev 与 curr 是同一 runId,prev 是 running,curr 是 completed/failed/killed。 + * - 打开历史面板(prev=null):不触发 + * - 切到已完成 tab(不同 runId):不触发 + * - 同 run running → terminal:触发 + */ +export function isRunTerminatedTransition( + prev: { runId: string; status: RunProgress['status'] } | null, + curr: { runId: string; status: RunProgress['status'] } | null, +): boolean { + if (!prev || !curr) return false; + if (prev.runId !== curr.runId) return false; + if (prev.status !== 'running') return false; + return curr.status === 'completed' || curr.status === 'failed' || curr.status === 'killed'; +} + /** * /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。 * @@ -74,6 +93,21 @@ export function WorkflowsPanel({ const phaseRowCount = phases.length + 1; const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount); + // focused run 从 running 转 terminal 时自动退出面板(800ms 延迟让用户看到 ✓/✗ 终态)。 + // 仅同 runId 的状态转换触发:切到已完成的 tab(prev 是别的 run)不退出;打开历史面板 + // (prev=null)也不退出。否则 agent 在 Workflow tool 等结果时被面板挡住,用户必须手动 q。 + const prevFocusedRef = useRef<{ runId: string; status: RunProgress['status'] } | null>(null); + useEffect(() => { + const curr = focused ? { runId: focused.runId, status: focused.status } : null; + const prev = prevFocusedRef.current; + prevFocusedRef.current = curr; + if (!isRunTerminatedTransition(prev, curr)) return; + const timer = setTimeout(() => onDone(), 800); + return (): void => { + clearTimeout(timer); + }; + }, [focused?.runId, focused?.status, onDone]); + // 选中 phase title(0 = All = undefined) const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;