diff --git a/web/src/api/normalizers.js b/web/src/api/normalizers.js index 179cf13..5eb6e66 100644 --- a/web/src/api/normalizers.js +++ b/web/src/api/normalizers.js @@ -254,12 +254,14 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath ...threadIds, ...runtime.threads.filter((thread) => threadBelongsToProject(thread, targetPath)).map((thread) => thread.id), ]) + const projectMainThreadIds = new Set( + runtime.threads.filter((thread) => projectThreadIds.has(thread.id) && isMainThread(thread)).map((thread) => thread.id), + ) + const agentIdsWithProjectMainEdge = buildAgentIdsWithProjectMainEdge(runtime.edges, threadIds, projectMainThreadIds) const threads = runtime.threads.filter((thread) => threadIds.has(thread.id)) const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId) || projectThreadIds.has(goal.threadId)) const edges = runtime.edges.filter((edge) => - (threadIds.has(edge.fromThreadId) || threadIds.has(edge.toThreadId)) && - projectThreadIds.has(edge.fromThreadId) && - projectThreadIds.has(edge.toThreadId), + edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }), ) const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source })) const phaseGroups = buildPhaseGroups(agents) @@ -286,6 +288,55 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath } } +function buildAgentIdsWithProjectMainEdge(edges, threadIds, projectMainThreadIds) { + const agentIds = new Set() + for (const edge of edges) { + if (projectMainThreadIds.has(edge.fromThreadId) && threadIds.has(edge.toThreadId)) { + agentIds.add(edge.toThreadId) + } + if (projectMainThreadIds.has(edge.toThreadId) && threadIds.has(edge.fromThreadId)) { + agentIds.add(edge.fromThreadId) + } + } + return agentIds +} + +function edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }) { + const fromIsAgent = threadIds.has(edge.fromThreadId) + const toIsAgent = threadIds.has(edge.toThreadId) + const fromIsProjectThread = projectThreadIds.has(edge.fromThreadId) + const toIsProjectThread = projectThreadIds.has(edge.toThreadId) + const fromThread = threadByID.get(edge.fromThreadId) + const toThread = threadByID.get(edge.toThreadId) + const fromIsMain = mainThreadCanJoinProjectFlow(fromThread, targetPath, agentIdsWithProjectMainEdge, edge.toThreadId) + const toIsMain = mainThreadCanJoinProjectFlow(toThread, targetPath, agentIdsWithProjectMainEdge, edge.fromThreadId) + + if (fromIsAgent && toIsAgent) { + return true + } + if (fromIsAgent && (toIsProjectThread || toIsMain)) { + return true + } + if (toIsAgent && (fromIsProjectThread || fromIsMain)) { + return true + } + return false +} + +function mainThreadCanJoinProjectFlow(thread, targetPath, agentIdsWithProjectMainEdge, agentThreadId) { + if (!isMainThread(thread)) { + return false + } + if (threadBelongsToProject(thread, targetPath)) { + return true + } + if (agentIdsWithProjectMainEdge.has(agentThreadId)) { + return false + } + const cwd = normalizePath(thread?.cwd) + return Boolean(cwd && targetPath.startsWith(`${cwd}/`)) +} + function normalizeProjectHandoff(edge, context) { const source = normalizeSource(edge.source, context.source) const status = normalizeSpawnStatus(edge.reason || edge.status) || 'unknown' @@ -528,11 +579,11 @@ function enrichProjectWorkflow(agent, targetPath) { function extractWorkflowBatchName(values) { for (const value of values) { const text = String(value || '') - const explicit = text.match(/工作流批次\s*[::]\s*([^\]\n;]+)/) + const explicit = text.match(/工作流批次\s*[::]\s*([^\]`\n;]+)/) if (explicit) { - return explicit[1].trim() + return cleanExplicitWorkflowBatchName(explicit[1]) } - const version = text.match(/\b(v[0-9]+(?:\.[0-9]+)+)\s*([^\]\n;,,]*)/i) + const version = text.match(/\b(v[0-9]+(?:\.[0-9]+)+)\s*([^\]`\n;,,。::/、]*)/i) if (version && /重构|升级|优化|修复|初始|搭建|发布/.test(version[2] || '')) { return `${version[1]} ${version[2].trim()}`.trim() } @@ -540,6 +591,12 @@ function extractWorkflowBatchName(values) { return '' } +function cleanExplicitWorkflowBatchName(value) { + return String(value || '') + .replace(/\s*[\/、]\s*(?:优化阶段|阶段)\s*[0-9一二三四五六七八九十]+.*$/, '') + .trim() +} + function normalizeWorkflowBatchName(value) { return String(value || '默认工作流').replace(/^(v[0-9]+(?:\.[0-9]+)+)([^\s].*)$/, '$1 $2').trim() } diff --git a/web/src/api/normalizers.test.mjs b/web/src/api/normalizers.test.mjs index 50cc0f6..c2b8b78 100644 --- a/web/src/api/normalizers.test.mjs +++ b/web/src/api/normalizers.test.mjs @@ -320,6 +320,94 @@ test('bare subagent threads are not treated as main supervision threads', () => assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接') }) +test('keeps main thread handoffs to project agents even when the main thread has only ancestor cwd', () => { + const runtime = normalizeRuntime({ + items: [ + { + id: 'main', + cwd: '/Users/yoilun', + title: '我想要一个图形页面去管理 code 文件夹下的项目', + threadSource: 'user', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'reviewer', + cwd: '/Users/yoilun', + title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前未提交改动。背景:用户要求新增批次切换。', + agentNickname: 'Mencius', + agentRole: '代码审查员', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + ], + edges: [ + { fromThreadId: 'main', toThreadId: 'reviewer', reason: 'closed' }, + ], + source: { kind: 'sqlite_readonly', confidence: 'high' }, + }) + + const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager') + + assert.equal(projectRuntime.agents.length, 1) + assert.equal(projectRuntime.handoffs.length, 1) + assert.equal(projectRuntime.handoffs[0].directionLabel, '主线程派发') + assert.equal(projectRuntime.workflowBatches[0].handoffCount, 1) + assert.deepEqual(projectRuntime.workflowBatches[0].phases[0].handoffs.map((handoff) => handoff.to), [ + 'Mencius / 代码审查员', + ]) +}) + +test('keeps parent main handoff when another project main is unrelated to that agent', () => { + const runtime = normalizeRuntime({ + items: [ + { + id: 'project-main', + cwd: '/Users/yoilun', + title: '另一个明确提到 /Users/yoilun/Code/codex-agent-manager 的用户线程,但没有派发这个智能体', + threadSource: 'user', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'parent-main', + cwd: '/Users/yoilun', + title: '继续整个项目', + threadSource: 'user', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'reviewer', + cwd: '/Users/yoilun', + title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前 bugfix。', + agentNickname: 'Hubble', + agentRole: '代码审查员', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'other-agent', + cwd: '/Users/yoilun', + title: '阶段 2:另一个 /Users/yoilun/Code/codex-agent-manager 智能体', + agentNickname: 'Noether', + agentRole: '后端架构师', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + ], + edges: [ + { fromThreadId: 'parent-main', toThreadId: 'reviewer', reason: 'open' }, + { fromThreadId: 'project-main', toThreadId: 'other-agent', reason: 'closed' }, + ], + source: { kind: 'sqlite_readonly', confidence: 'high' }, + }) + + const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager') + + assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [ + '主线程 / 主智能体监管 -> Hubble / 代码审查员', + '主线程 / 主智能体监管 -> Noether / 后端架构师', + ]) +}) + test('groups project flow by workflow batch and classifies current optimizations', () => { const runtime = normalizeRuntime({ items: [ @@ -357,6 +445,33 @@ test('groups project flow by workflow batch and classifies current optimizations threadSource: 'subagent', source: { kind: 'sqlite_table', confidence: 'high' }, }, + { + id: 'review-prompt', + cwd: '/Users/yoilun', + title: '请审查 /Users/yoilun/Code/codex-agent-manager。背景:将优化改造归类到 `v1.1 优化修复 / 优化阶段 1`。当前实现会给项目内 agent/handoff 增加工作流批次。', + agentNickname: 'Peirce', + agentRole: '代码审查员', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'explicit-slash-batch', + cwd: '/Users/yoilun', + title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.2 CI/CD 发布] [阶段: 阶段 9 发布验证]', + agentNickname: 'Curie', + agentRole: '发布经理', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'explicit-phase-tail', + cwd: '/Users/yoilun', + title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复 / 优化阶段 1] [阶段: 优化阶段 1]', + agentNickname: 'Turing', + agentRole: '前端开发者', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, ], edges: [ { fromThreadId: 'main', toThreadId: 'initial', reason: 'closed' }, @@ -373,9 +488,13 @@ test('groups project flow by workflow batch and classifies current optimizations assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').phaseName, '优化阶段 1') assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').workflowBatch, 'v2.0 重构升级') assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').phaseName, '阶段 3') + assert.equal(projectRuntime.agents.find((agent) => agent.id === 'review-prompt').workflowBatch, 'v1.1 优化修复') + assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-slash-batch').workflowBatch, 'v1.2 CI/CD 发布') + assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-phase-tail').workflowBatch, 'v1.1 优化修复') assert.deepEqual(projectRuntime.workflowBatches.map((batch) => batch.name), [ 'v1.0 初始搭建', 'v1.1 优化修复', + 'v1.2 CI/CD 发布', 'v2.0 重构升级', ]) assert.deepEqual(projectRuntime.workflowBatches[1].phases.map((phase) => phase.name), ['优化阶段 1'])