浏览代码

fix(chat): 优化严格模式工作流显示顺序

消除阶段流与工具时间线的父级/子步骤双轨重复,按权威顺序展示细粒度阶段,并修正缺 startedAt 时排序错乱。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 月之前
父节点
当前提交
74e1734fe3

+ 48 - 0
src/components/chat/agent-stage-stream.spec.tsx

@@ -68,4 +68,52 @@ describe("AgentStageStream", () => {
     expect(html).toContain("min-w-0")
     expect(html).toContain("max-w-full")
   })
+
+  it("renders stages in canonical order and hides redundant chapter_workflow", () => {
+    const disordered: AgentStageTrace[] = [
+      {
+        id: "final_output",
+        title: "最终输出",
+        status: "done",
+        summary: "正文已生成",
+        events: [],
+        startedAt: 400,
+      },
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "不应展示",
+        events: [],
+        startedAt: 50,
+      },
+      {
+        id: "read_context",
+        title: "读取上下文",
+        status: "done",
+        summary: "已读",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "generate_draft",
+        title: "生成章节草稿",
+        status: "done",
+        summary: "草稿完成",
+        events: [],
+        startedAt: 200,
+      },
+    ]
+
+    const html = renderToStaticMarkup(<AgentStageStream stages={disordered} />)
+    const readIndex = html.indexOf("读取上下文")
+    const draftIndex = html.indexOf("生成章节草稿")
+    const finalIndex = html.indexOf("最终输出")
+
+    expect(readIndex).toBeGreaterThan(-1)
+    expect(draftIndex).toBeGreaterThan(readIndex)
+    expect(finalIndex).toBeGreaterThan(draftIndex)
+    expect(html).not.toContain("多任务写作循环")
+    expect(html).toContain("(3)")
+  })
 })

+ 6 - 2
src/components/chat/agent-stage-stream.tsx

@@ -11,7 +11,11 @@ import {
   Wrench,
   XCircle,
 } from "lucide-react"
-import { getDefaultOpenAgentStageId, summarizeAgentStage } from "@/lib/agent/activity-trace"
+import {
+  getDefaultOpenAgentStageId,
+  prepareAgentStagesForDisplay,
+  summarizeAgentStage,
+} from "@/lib/agent/activity-trace"
 import type { AgentActivityEvent, AgentActivityKind, AgentStageStatus, AgentStageTrace } from "@/lib/agent/types"
 import { cn } from "@/lib/utils"
 
@@ -20,7 +24,7 @@ interface AgentStageStreamProps {
 }
 
 export function AgentStageStream({ stages }: AgentStageStreamProps) {
-  const visibleStages = stages ?? []
+  const visibleStages = useMemo(() => prepareAgentStagesForDisplay(stages), [stages])
   const defaultOpenStageId = useMemo(() => getDefaultOpenAgentStageId(visibleStages), [visibleStages])
   const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
   const [allOpen, setAllOpen] = useState(false)

+ 45 - 0
src/components/chat/agent-tool-call-message.spec.tsx

@@ -99,6 +99,51 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).not.toContain("apply_skill")
   })
 
+  it("hides parent run_chapter_workflow once child chapter steps exist", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "workflow-1",
+              name: "run_chapter_workflow",
+              params: {},
+              result: "",
+              status: "running",
+              startedAt: 100,
+              finishedAt: 0,
+            },
+            {
+              id: "workflow-1:chapter_context",
+              parentCallId: "workflow-1",
+              name: "chapter_context",
+              params: { title: "读取上下文" },
+              result: "上下文完成",
+              status: "done",
+              startedAt: 110,
+              finishedAt: 150,
+            },
+            {
+              id: "workflow-1:chapter_execution_repair",
+              parentCallId: "workflow-1",
+              name: "chapter_execution_repair",
+              params: { title: "返修执行失败项" },
+              result: "",
+              status: "running",
+              startedAt: 200,
+              finishedAt: 0,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).not.toContain("运行章节工作流")
+    expect(host.textContent).toContain("读取章节上下文")
+    expect(host.textContent).toContain("返修执行清单失败项")
+    expect(host.textContent).toContain("运行中")
+  })
+
   it("shows error style for failed tool calls", async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[4]]} />)

+ 2 - 2
src/components/chat/agent-workflow-panel.tsx

@@ -2,7 +2,7 @@ import { useMemo, useRef, useEffect } from "react"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
-import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import { createStreamingEventBuilder, compareToolCallsByStartedAt, filterToolCallsForDisplay } from "@/components/common/timeline-types"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 
@@ -62,7 +62,7 @@ export function AgentWorkflowPanel({
   const wasStreamingRef = useRef(false)
 
   const sortedCalls = useMemo(
-    () => [...safeToolCalls].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)),
+    () => filterToolCallsForDisplay([...safeToolCalls]).sort(compareToolCallsByStartedAt),
     [safeToolCalls],
   )
 

+ 59 - 0
src/components/common/timeline-types.spec.ts

@@ -1,6 +1,8 @@
 import { describe, expect, it } from "vitest"
 import {
+  compareToolCallsByStartedAt,
   createStreamingEventBuilder,
+  filterToolCallsForDisplay,
   interleaveThinkingWithToolCalls,
   type ToolCallEventItem,
 } from "./timeline-types"
@@ -18,6 +20,63 @@ function createToolCall(
   }
 }
 
+describe("compareToolCallsByStartedAt", () => {
+  it("sorts missing startedAt after known timestamps and breaks ties by id", () => {
+    const calls = [
+      { id: "b", startedAt: undefined },
+      { id: "a", startedAt: 200 },
+      { id: "c", startedAt: undefined },
+      { id: "d", startedAt: 100 },
+    ]
+
+    expect([...calls].sort(compareToolCallsByStartedAt).map((call) => call.id)).toEqual([
+      "d",
+      "a",
+      "b",
+      "c",
+    ])
+  })
+})
+
+describe("filterToolCallsForDisplay", () => {
+  it("hides parent tools once child steps exist", () => {
+    const calls = [
+      { id: "workflow-1", name: "run_chapter_workflow", startedAt: 100 },
+      {
+        id: "workflow-1:chapter_context",
+        parentCallId: "workflow-1",
+        name: "chapter_context",
+        startedAt: 110,
+      },
+      {
+        id: "workflow-1:chapter_draft",
+        parentCallId: "workflow-1",
+        name: "chapter_draft",
+        startedAt: 200,
+      },
+      { id: "write-1", name: "write_chapter", startedAt: 300 },
+    ]
+
+    expect(filterToolCallsForDisplay(calls).map((call) => call.id)).toEqual([
+      "workflow-1:chapter_context",
+      "workflow-1:chapter_draft",
+      "write-1",
+    ])
+  })
+
+  it("keeps parent tools when they have no children yet", () => {
+    const calls = [
+      { id: "workflow-1", name: "run_chapter_workflow", startedAt: 100 },
+      { id: "read-1", name: "read_chapter", startedAt: 50 },
+    ]
+
+    expect(filterToolCallsForDisplay(calls).map((call) => call.id)).toEqual([
+      "workflow-1",
+      "read-1",
+    ])
+  })
+})
+
 describe("createStreamingEventBuilder", () => {
   it("keeps both tool ids in order while replacing the second running call with done", () => {
     const builder = createStreamingEventBuilder("thinking")

+ 25 - 0
src/components/common/timeline-types.ts

@@ -2,6 +2,31 @@ import type { ToolCallStatus } from "@/lib/agent/types"
 
 export type TimelineToolCategory = "read" | "write" | "action" | "virtual"
 
+export function compareToolCallsByStartedAt(
+  a: { id: string; startedAt?: number },
+  b: { id: string; startedAt?: number },
+): number {
+  const aStart = a.startedAt ?? Number.MAX_SAFE_INTEGER
+  const bStart = b.startedAt ?? Number.MAX_SAFE_INTEGER
+  if (aStart !== bStart) return aStart - bStart
+  return a.id.localeCompare(b.id)
+}
+
+/**
+ * 隐藏已有子步骤的父工具(如 run_chapter_workflow),避免父级一直「运行中」置顶、与子步骤双轨重复。
+ * 尚无子步骤时仍展示父级,作为工作流刚启动的占位。
+ */
+export function filterToolCallsForDisplay<T extends { id: string; parentCallId?: string }>(
+  calls: T[],
+): T[] {
+  const parentIdsWithChildren = new Set<string>()
+  for (const call of calls) {
+    if (call.parentCallId) parentIdsWithChildren.add(call.parentCallId)
+  }
+  if (parentIdsWithChildren.size === 0) return calls
+  return calls.filter((call) => !parentIdsWithChildren.has(call.id))
+}
+
 export interface ToolCallEventItem {
   id: string
   name: string

+ 2 - 6
src/components/sources/outline-workflow-stages.tsx

@@ -1,7 +1,7 @@
 import React, { useMemo, useRef, useEffect } from "react"
 import type { ToolCallRecord } from "@/lib/agent/tool-events"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
-import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import { compareToolCallsByStartedAt, createStreamingEventBuilder, filterToolCallsForDisplay } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
@@ -62,11 +62,7 @@ export const OutlineWorkflowStages = React.memo(function OutlineWorkflowStages(
   const thinkingStreaming = thinkingExtract.streaming || isStreaming
 
   const sortedCalls = useMemo(() => {
-    return [...toolCalls].sort((a, b) => {
-      const aStart = (a as { startedAt?: number }).startedAt ?? 0
-      const bStart = (b as { startedAt?: number }).startedAt ?? 0
-      return aStart - bStart
-    })
+    return filterToolCallsForDisplay([...toolCalls]).sort(compareToolCallsByStartedAt)
   }, [toolCalls])
 
   const adaptedCalls = useMemo(

+ 89 - 0
src/lib/agent/activity-trace.spec.ts

@@ -5,6 +5,8 @@ import {
   createAgentActivityEvent,
   createStageStartedEvent,
   getDefaultOpenAgentStageId,
+  prepareAgentStagesForDisplay,
+  resolveAgentStageTitle,
   settleRunningAgentStages,
   summarizeAgentStage,
 } from "./activity-trace"
@@ -186,4 +188,91 @@ describe("activity trace", () => {
       finishedAt: 320,
     })
   })
+
+  it("sorts stages by canonical display order and hides redundant chapter_workflow", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "final_output",
+        title: "最终输出",
+        status: "done",
+        summary: "完成",
+        events: [],
+        startedAt: 400,
+      },
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "聚合",
+        events: [],
+        startedAt: 50,
+      },
+      {
+        id: "generate_draft",
+        title: "生成章节草稿",
+        status: "done",
+        summary: "草稿",
+        events: [],
+        startedAt: 200,
+      },
+      {
+        id: "read_context",
+        title: "读取上下文",
+        status: "done",
+        summary: "上下文",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "write_confirmation",
+        title: "写入确认",
+        status: "approval_required",
+        summary: "待确认",
+        events: [],
+        startedAt: 500,
+      },
+    ]
+
+    const prepared = prepareAgentStagesForDisplay(stages)
+    expect(prepared.map((stage) => stage.id)).toEqual([
+      "read_context",
+      "generate_draft",
+      "final_output",
+      "write_confirmation",
+    ])
+  })
+
+  it("keeps chapter_workflow when no detailed chapter stages exist", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "运行中",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "write_confirmation",
+        title: "写入确认",
+        status: "approval_required",
+        summary: "待确认",
+        events: [],
+        startedAt: 200,
+      },
+    ]
+
+    expect(prepareAgentStagesForDisplay(stages).map((stage) => stage.id)).toEqual([
+      "write_confirmation",
+      "chapter_workflow",
+    ])
+  })
+
+  it("resolves titles for post-draft strict stages", () => {
+    expect(resolveAgentStageTitle("execution_report")).toBe("执行报告")
+    expect(resolveAgentStageTitle("execution_recheck")).toBe("执行复检")
+    expect(resolveAgentStageTitle("plan_compliance")).toBe("计划履约")
+    expect(resolveAgentStageTitle("plan_deviation_repair")).toBe("计划偏离返修")
+    expect(resolveAgentStageTitle("plan_deviation_recheck")).toBe("计划偏离复检")
+  })
 })

+ 77 - 2
src/lib/agent/activity-trace.ts

@@ -9,6 +9,43 @@ import type {
 const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 
+/** 细粒度章节流水线阶段;存在任一此类阶段时隐藏冗余的 chapter_workflow 聚合阶段。 */
+export const DETAILED_CHAPTER_STAGE_IDS = new Set([
+  "read_context",
+  "plot_analysis",
+  "generate_draft",
+  "validate_revision",
+  "execution_report",
+  "execution_recheck",
+  "plan_compliance",
+  "plan_deviation_repair",
+  "plan_deviation_recheck",
+  "final_output",
+])
+
+export const AGENT_STAGE_DISPLAY_ORDER = [
+  "task_understanding",
+  "capability_selection",
+  "read_context",
+  "plot_analysis",
+  "generate_draft",
+  "validate_revision",
+  "execution_report",
+  "execution_recheck",
+  "plan_compliance",
+  "plan_deviation_repair",
+  "plan_deviation_recheck",
+  "final_output",
+  "external_search",
+  "write_confirmation",
+  "react_tools",
+  "chapter_workflow",
+] as const
+
+const STAGE_DISPLAY_ORDER_INDEX = new Map<string, number>(
+  AGENT_STAGE_DISPLAY_ORDER.map((id, index) => [id, index]),
+)
+
 export interface CreateAgentActivityEventInput {
   id: string
   stageId: string
@@ -89,6 +126,35 @@ export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string |
     ?? null
 }
 
+export function filterAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+  const hasDetailedChapterStage = stages.some((stage) => DETAILED_CHAPTER_STAGE_IDS.has(stage.id))
+  if (!hasDetailedChapterStage) return stages
+  return stages.filter((stage) => stage.id !== "chapter_workflow")
+}
+
+export function sortAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+  const unknownBase = AGENT_STAGE_DISPLAY_ORDER.length
+  return stages
+    .map((stage, originalIndex) => ({ stage, originalIndex }))
+    .sort((a, b) => {
+      const orderA = STAGE_DISPLAY_ORDER_INDEX.get(a.stage.id) ?? unknownBase
+      const orderB = STAGE_DISPLAY_ORDER_INDEX.get(b.stage.id) ?? unknownBase
+      if (orderA !== orderB) return orderA - orderB
+
+      const startedA = a.stage.startedAt ?? Number.MAX_SAFE_INTEGER
+      const startedB = b.stage.startedAt ?? Number.MAX_SAFE_INTEGER
+      if (startedA !== startedB) return startedA - startedB
+
+      return a.originalIndex - b.originalIndex
+    })
+    .map(({ stage }) => stage)
+}
+
+export function prepareAgentStagesForDisplay(stages: AgentStageTrace[] | undefined): AgentStageTrace[] {
+  const visible = filterAgentStagesForDisplay(stages ?? [])
+  return sortAgentStagesForDisplay(visible)
+}
+
 export function settleRunningAgentStages(
   stages: AgentStageTrace[] | undefined,
   status: Extract<AgentStageStatus, "done" | "error" | "cancelled"> = "done",
@@ -221,7 +287,7 @@ function inferStageIdFromToolName(name: string): string {
   return "react_tools"
 }
 
-function inferStageTitle(event: AgentActivityEvent): string {
+export function resolveAgentStageTitle(stageId: string, fallbackTitle?: string): string {
   const titles: Record<string, string> = {
     task_understanding: "任务理解",
     capability_selection: "能力选择",
@@ -230,12 +296,21 @@ function inferStageTitle(event: AgentActivityEvent): string {
     chapter_workflow: "多任务写作循环",
     generate_draft: "生成章节草稿",
     validate_revision: "校验与修正",
+    execution_report: "执行报告",
+    execution_recheck: "执行复检",
+    plan_compliance: "计划履约",
+    plan_deviation_repair: "计划偏离返修",
+    plan_deviation_recheck: "计划偏离复检",
     final_output: "最终输出",
     external_search: "外部检索",
     write_confirmation: "写入确认",
     react_tools: "工具调用",
   }
-  return titles[event.stageId] ?? event.title
+  return titles[stageId] ?? fallbackTitle ?? stageId
+}
+
+function inferStageTitle(event: AgentActivityEvent): string {
+  return resolveAgentStageTitle(event.stageId, event.title)
 }
 
 function inferActivityKindFromToolName(name: string): AgentActivityKind {

+ 41 - 2
src/lib/agent/tool-events.spec.ts

@@ -128,7 +128,7 @@ describe("applyAgentToolEvent", () => {
     expect(event.content).toContain("铜铃线索")
   })
 
-  it("converts workflow child events into chapter workflow activities", () => {
+  it("skips writing chapter_* child tool events into agent stages", () => {
     const event = activityEventFromAgentToolEvent({
       type: "result",
       callId: "workflow-1:chapter_task_brief",
@@ -139,10 +139,49 @@ describe("applyAgentToolEvent", () => {
       timestamp: 200,
     })
 
+    expect(event).toBeNull()
+
+    const stages = applyAgentToolActivityEvent(
+      [
+        {
+          id: "read_context",
+          title: "读取上下文",
+          status: "done",
+          summary: "已完成",
+          events: [],
+          startedAt: 100,
+          finishedAt: 150,
+        },
+      ],
+      {
+        type: "result",
+        callId: "workflow-1:chapter_task_brief",
+        parentCallId: "workflow-1",
+        name: "chapter_task_brief",
+        params: { title: "生成写作任务书" },
+        result: "写作任务书完成。",
+        timestamp: 200,
+      },
+    )
+
+    expect(stages).toHaveLength(1)
+    expect(stages[0].id).toBe("read_context")
+  })
+
+  it("still maps orphan chapter_* events without parentCallId into chapter_workflow", () => {
+    const event = activityEventFromAgentToolEvent({
+      type: "result",
+      callId: "orphan-chapter-context",
+      name: "chapter_context",
+      params: { title: "读取上下文" },
+      result: "上下文完成。",
+      timestamp: 200,
+    })
+
     expect(event).toMatchObject({
       stageId: "chapter_workflow",
       kind: "stage_output",
-      title: "生成写作任务书",
+      title: "读取上下文",
     })
   })
 

+ 10 - 1
src/lib/agent/tool-events.ts

@@ -68,7 +68,14 @@ export function settleRunningAgentToolCalls(
   })
 }
 
+/** chapter_* 子步骤已在工具时间线展示,且细粒度阶段由 onActivityEvent 写入,避免双轨重复。 */
+export function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
+  return Boolean(event.parentCallId) && event.name.startsWith("chapter_")
+}
+
 export function activityEventFromAgentToolEvent(event: AgentToolEvent) {
+  if (shouldSkipToolActivityForStages(event)) return null
+
   const activity = activityEventFromToolEvent(event)
   const titleFromParams = typeof event.params.title === "string" ? event.params.title : ""
 
@@ -88,5 +95,7 @@ export function applyAgentToolActivityEvent(
   stages: AgentStageTrace[] | undefined,
   event: AgentToolEvent,
 ): AgentStageTrace[] {
-  return applyAgentActivityEvent(stages, activityEventFromAgentToolEvent(event))
+  const activity = activityEventFromAgentToolEvent(event)
+  if (!activity) return stages ?? []
+  return applyAgentActivityEvent(stages, activity)
 }