Просмотр исходного кода

feat(tui): 展示 Research67 语义进度

原始 SSE 活动只能显示通用工具状态,难以判断长时间研究任务所处阶段。本提交从既有事件与产物契约派生可读的阶段和里程碑进度。

- 跟踪创意分析、文献检索和论文撰写三个阶段
- 根据检索、写文件和 CompileLatex 事件更新里程碑
- 用 heartbeat 保持长任务连接状态可见,并在 metadata 缺失时回退
- 更新桥接测试、TUI 渲染逻辑与快照覆盖
Jinchen Zhang 1 неделя назад
Родитель
Сommit
be6310d084

+ 250 - 3
.opencode/tool/agentpaas_run.ts

@@ -8,6 +8,21 @@ const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
 
 type SSEEvent = { event: string; data: unknown }
 type AgentListItem = { id: string; name: string }
+type ProgressStatus = "completed" | "running" | "pending" | "failed"
+type ProgressMilestone = {
+  id: string
+  label: string
+  status: ProgressStatus
+  done?: Set<string>
+  total?: number
+  detail?: string
+}
+type ProgressStage = {
+  id: string
+  label: string
+  status: ProgressStatus
+  milestones: ProgressMilestone[]
+}
 
 export default tool({
   description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
@@ -79,9 +94,14 @@ export default tool({
     let done: Record<string, unknown> | null = null
     let sawCancelled = false
     let lastThinkUpdate = 0
+    const progress = createResearch67Progress()
     const updateMetadata = (title: string, next: Record<string, unknown>) => {
       Object.assign(liveMetadata, next)
-      context.metadata({ title, metadata: { ...liveMetadata } })
+      const snapshot = progress.snapshot()
+      context.metadata({
+        title: snapshot?.title ?? title,
+        metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
+      })
     }
     for await (const ev of parseSSE(resp.body)) {
       const data = asRecord(ev.data)
@@ -93,6 +113,7 @@ export default tool({
         })
       } else if (ev.event === "stage_started") {
         const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "未知阶段"
+        progress.startStage(getString(data?.stage_id))
         updateMetadata(`Research67 正在进行 ${stageName}`, {
           pipeline_id: data?.pipeline_id,
           stage_id: data?.stage_id,
@@ -103,6 +124,7 @@ export default tool({
         })
       } else if (ev.event === "stage_passed") {
         const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
+        progress.finishStage(getString(data?.stage_id), "completed")
         updateMetadata(`Research67 已通过 ${stageName}`, {
           pipeline_id: data?.pipeline_id,
           stage_id: data?.stage_id,
@@ -113,6 +135,7 @@ export default tool({
         })
       } else if (ev.event === "stage_failed") {
         const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
+        progress.finishStage(getString(data?.stage_id), "failed")
         updateMetadata(`Research67 阶段失败:${stageName}`, {
           pipeline_id: data?.pipeline_id,
           stage_id: data?.stage_id,
@@ -124,6 +147,7 @@ export default tool({
           status: "failed",
         })
       } else if (ev.event === "pipeline_completed") {
+        progress.finishPipeline()
         updateMetadata("Research67 流水线已完成", {
           pipeline_id: data?.pipeline_id,
           pipeline_status: data?.status,
@@ -137,6 +161,7 @@ export default tool({
         const input = getString(data?.input)
         if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
 
+        progress.note(`等待确认 · ${toolName}`)
         updateMetadata(`Research67 正在等待确认:${toolName}`, {
           latest_event: ev.event,
           pending_confirmation: true,
@@ -156,6 +181,7 @@ export default tool({
           approved = false
         }
         await resolveConfirmation(config, runID, approved, context.abort)
+        progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
         updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
           latest_event: "confirmation_resolved",
           pending_confirmation: false,
@@ -163,14 +189,16 @@ export default tool({
         })
       } else if (ev.event === "tool_call") {
         const toolName = getString(data?.tool) || "工具"
-        updateMetadata(withStage(`Research67 正在调用 ${toolName}`, liveMetadata), {
+        progress.toolCall(toolName, getString(data?.content))
+        updateMetadata(withStage("Research67 正在处理当前步骤", liveMetadata), {
           latest_event: ev.event,
           latest_tool: toolName,
           step: data?.step,
         })
       } else if (ev.event === "tool_result") {
         const toolName = getString(data?.tool) || getString(liveMetadata.latest_tool) || "工具"
-        updateMetadata(withStage(`Research67 已完成 ${toolName}`, liveMetadata), {
+        progress.toolResult(toolName, getString(data?.content))
+        updateMetadata(withStage("Research67 已更新当前步骤", liveMetadata), {
           latest_event: ev.event,
           latest_tool: toolName,
           step: data?.step,
@@ -189,9 +217,13 @@ export default tool({
           latest_event: ev.event,
           step: data?.step,
         })
+      } else if (ev.event === "heartbeat") {
+        progress.note("连接正常")
+        updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
       } else if (ev.event === "error") errors.push(extractMessage(ev.data))
       else if (ev.event === "cancelled") {
         sawCancelled = true
+        progress.cancel()
         updateMetadata("Research67 已取消", { latest_event: ev.event, status: "cancelled" })
       } else if (ev.event === "done" && data) {
         done = data
@@ -203,6 +235,7 @@ export default tool({
       throw new Error(`AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}`)
     }
     const status = typeof done.status === "string" ? done.status : "unknown"
+    const progressMetadata = progress.snapshot()
     const metadata = {
       agent_name: args.agent_name,
       agent_id: agentID,
@@ -213,6 +246,7 @@ export default tool({
       total_tokens: done.total_tokens,
       cost_usd: done.cost_usd,
       workspace_path: done.workspace_path,
+      ...(progressMetadata ? { research67_progress: progressMetadata } : {}),
     }
     if (status === "completed")
       return { title: `AgentPaaS ${args.agent_name}`, output: String(done.output ?? ""), metadata }
@@ -324,6 +358,10 @@ async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEv
       buffer = buffer.slice(idx + 2)
       const event = parseSSEField(chunk, "event")
       const dataRaw = parseSSEField(chunk, "data")
+      if (!event && chunk.split("\n").some((line) => line.trim() === ": heartbeat")) {
+        yield { event: "heartbeat", data: undefined }
+        continue
+      }
       let data: unknown = dataRaw
       if (dataRaw !== "") {
         try {
@@ -366,3 +404,212 @@ function withStage(title: string, metadata: Record<string, unknown>): string {
   const stageName = getString(metadata.stage_name)
   return stageName ? `${title}(${stageName})` : title
 }
+
+function createResearch67Progress() {
+  const stages: ProgressStage[] = [
+    {
+      id: "idea-analysis",
+      label: "idea-analyst · 创意分析",
+      status: "pending",
+      milestones: [
+        { id: "plan", label: "制定分析计划", status: "pending", done: new Set(), total: 1 },
+        { id: "directions", label: "生成检索方向", status: "pending", done: new Set(), total: 2 },
+        { id: "report", label: "形成分析报告", status: "pending", done: new Set(), total: 2 },
+      ],
+    },
+    {
+      id: "literature-review",
+      label: "lit-searcher · 文献检索",
+      status: "pending",
+      milestones: [
+        { id: "plan", label: "制定检索计划", status: "pending", done: new Set(), total: 1 },
+        { id: "search", label: "检索候选文献", status: "pending", done: new Set(), detail: "尚未开始" },
+        { id: "select", label: "筛选入选论文", status: "pending", done: new Set(), total: 1 },
+        { id: "report", label: "形成文献综述", status: "pending", done: new Set(), total: 2 },
+      ],
+    },
+    {
+      id: "paper-writing",
+      label: "paper-writer · 论文撰写",
+      status: "pending",
+      milestones: [
+        { id: "outline", label: "写作计划与大纲", status: "pending", done: new Set(), total: 2 },
+        { id: "english", label: "英文稿与参考文献", status: "pending", done: new Set(), total: 8 },
+        { id: "chinese", label: "中文稿", status: "pending", done: new Set(), total: 7 },
+        { id: "compile", label: "编译双语 PDF", status: "pending", done: new Set(), total: 2 },
+        { id: "report", label: "形成交付报告", status: "pending", done: new Set(), total: 2 },
+      ],
+    },
+  ]
+  let current: ProgressStage | undefined
+  let active: ProgressMilestone | undefined
+  let pendingArtifact: { milestone: ProgressMilestone; key: string } | undefined
+  let searchCalls = 0
+  let note = ""
+  let activeSince = Date.now()
+
+  const startMilestone = (milestone: ProgressMilestone) => {
+    if (!current) return
+    for (const item of current.milestones) {
+      if (item === milestone) break
+      item.status = "completed"
+    }
+    if (milestone.status !== "completed") milestone.status = "running"
+    active = milestone
+    activeSince = Date.now()
+    note = ""
+  }
+
+  const artifact = (content: string) => {
+    if (!current) return undefined
+    const value = content.replaceAll("\\", "/").toLowerCase()
+    const match = (milestoneID: string, key: string) => {
+      const milestone = current?.milestones.find((item) => item.id === milestoneID)
+      return milestone ? { milestone, key } : undefined
+    }
+    if (value.includes("/work_plan.md") || value.includes('"work_plan.md"')) {
+      return match(current.id === "paper-writing" ? "outline" : "plan", "work_plan")
+    }
+    if (current.id === "idea-analysis") {
+      if (value.includes("search_queries.json")) return match("directions", "search_queries")
+      if (value.includes("similar_papers.json")) return match("directions", "similar_papers")
+      if (value.includes("report.json")) return match("report", "report_json")
+      if (value.includes("report.md")) return match("report", "report_md")
+    }
+    if (current.id === "literature-review") {
+      if (value.includes("papers_selected.json")) return match("select", "papers_selected")
+      if (value.includes("report.json")) return match("report", "report_json")
+      if (value.includes("report.md")) return match("report", "report_md")
+    }
+    if (current.id !== "paper-writing") return undefined
+    if (value.includes("outline.json")) return match("outline", "outline")
+    const section = value.match(
+      /artifacts\/sections\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex/,
+    )
+    if (section) return match("english", `section:${section[1]}`)
+    if (value.includes("paper.tex")) return match("english", "paper_tex")
+    if (value.includes("references.bib")) return match("english", "references")
+    const sectionZh = value.match(
+      /artifacts\/sections_zh\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex/,
+    )
+    if (sectionZh) return match("chinese", `section:${sectionZh[1]}`)
+    if (value.includes("paper_zh.tex")) return match("chinese", "paper_zh_tex")
+    if (value.includes("report.json")) return match("report", "report_json")
+    if (value.includes("report.md")) return match("report", "report_md")
+    return undefined
+  }
+
+  const completeArtifact = (item: { milestone: ProgressMilestone; key: string }) => {
+    item.milestone.done?.add(item.key)
+    if (item.milestone.total && item.milestone.done?.size === item.milestone.total) {
+      item.milestone.status = "completed"
+    }
+  }
+
+  return {
+    startStage(stageID: string) {
+      current = stages.find((stage) => stage.id === stageID)
+      if (!current) return
+      for (const stage of stages) {
+        if (stage === current) break
+        stage.status = "completed"
+      }
+      current.status = "running"
+      startMilestone(current.milestones[0])
+    },
+    finishStage(stageID: string, status: "completed" | "failed") {
+      const stage = stages.find((item) => item.id === stageID)
+      if (!stage) return
+      stage.status = status
+      for (const milestone of stage.milestones) milestone.status = status
+      current = stage
+      active = status === "failed" ? active : undefined
+      note = status === "failed" ? "阶段执行失败" : "阶段验收通过"
+    },
+    finishPipeline() {
+      for (const stage of stages) {
+        stage.status = "completed"
+        for (const milestone of stage.milestones) milestone.status = "completed"
+      }
+      active = undefined
+      note = "全部阶段已完成"
+    },
+    cancel() {
+      if (current?.status === "running") current.status = "failed"
+      if (active?.status === "running") active.status = "failed"
+      note = "任务已取消"
+    },
+    note(value: string) {
+      note = value
+    },
+    toolCall(toolName: string, content: string) {
+      const lower = toolName.toLowerCase()
+      if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
+        const milestone = current.milestones.find((item) => item.id === "search")!
+        startMilestone(milestone)
+        pendingArtifact = undefined
+        return
+      }
+      if (current?.id === "paper-writing" && lower === "compilelatex") {
+        const milestone = current.milestones.find((item) => item.id === "compile")!
+        startMilestone(milestone)
+        pendingArtifact = {
+          milestone,
+          key: content.toLowerCase().includes("paper_zh.tex") ? "paper_zh_pdf" : "paper_pdf",
+        }
+        return
+      }
+      const item = artifact(content)
+      if (!item) return
+      startMilestone(item.milestone)
+      pendingArtifact = item
+    },
+    toolResult(toolName: string, content: string) {
+      const lower = toolName.toLowerCase()
+      const failed = /\[error\]|mcp_error|"status"\s*:\s*"(?:failed|error)"/i.test(content)
+      if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
+        if (!failed) searchCalls++
+        const milestone = current.milestones.find((item) => item.id === "search")!
+        milestone.detail = searchCalls ? `已完成 ${searchCalls} 轮检索` : "等待检索重试"
+        return
+      }
+      if (!pendingArtifact) return
+      if (!failed) completeArtifact(pendingArtifact)
+      if (failed) note = "当前操作失败,等待重试"
+      pendingArtifact = undefined
+    },
+    snapshot() {
+      if (!current) return undefined
+      const elapsed = Math.max(0, Math.floor((Date.now() - activeSince) / 1000))
+      const rows = stages.flatMap((stage) => {
+        const detail = stage.status === "running" ? active?.label : undefined
+        const parent = [{ id: stage.id, label: stage.label, status: stage.status, detail, depth: 0 }]
+        if (stage !== current || !["running", "failed"].includes(stage.status)) return parent
+        return parent.concat(
+          stage.milestones.map((milestone) => ({
+            id: `${stage.id}:${milestone.id}`,
+            label: milestone.label,
+            status: milestone.status,
+            detail:
+              milestone.detail ??
+              (milestone.status !== "completed" && milestone.total && milestone.done?.size
+                ? `${milestone.done.size}/${milestone.total}`
+                : undefined),
+            depth: 1,
+          })),
+        )
+      })
+      return {
+        kind: "research67",
+        title: active ? `Research67 · ${current.label} · ${active.label}` : `Research67 · ${current.label}`,
+        rows,
+        footer: note || (active ? `当前步骤已运行 ${formatElapsed(elapsed)}` : "进度已更新"),
+      }
+    },
+  }
+}
+
+function formatElapsed(seconds: number) {
+  if (seconds < 60) return `${seconds} 秒`
+  return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`
+}

+ 117 - 17
packages/opencode/test/tool/agentpaas-run.test.ts

@@ -17,6 +17,28 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
   }> = []
   const events: Array<[string, unknown]> = [
     ["started", { run_id: "run_1", thread_id: "thread_1" }],
+    [
+      "stage_started",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "idea-analysis",
+        stage_name: "创意分析",
+        attempt: 1,
+        status: "running",
+      },
+    ],
+    ["tool_call", { tool: "WriteFile", step: 1, content: '{"file_path":"/run/idea-analysis/work_plan.md"}' }],
+    ["tool_result", { tool: "WriteFile", step: 1, content: "[OK] Created work_plan.md" }],
+    [
+      "stage_passed",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "idea-analysis",
+        stage_name: "创意分析",
+        attempt: 1,
+        status: "passed",
+      },
+    ],
     [
       "stage_started",
       {
@@ -37,8 +59,13 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       },
     ],
     ["think_chunk", { text: "检索中" }],
-    ["tool_call", { tool: "LiteratureSearch", step: 1 }],
-    ["tool_result", { tool: "LiteratureSearch", step: 1 }],
+    ["tool_call", { tool: "arxiv_search", step: 2, content: '{"query":"topic-specific words","limit":10}' }],
+    ["tool_result", { tool: "arxiv_search", step: 2, content: '{"status":"completed","result_count":10}' }],
+    [
+      "tool_call",
+      { tool: "WriteFile", step: 3, content: '{"file_path":"/run/literature-review/artifacts/papers_selected.json"}' },
+    ],
+    ["tool_result", { tool: "WriteFile", step: 3, content: "[OK] Created papers_selected.json" }],
     [
       "confirm_required",
       {
@@ -58,6 +85,33 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
         status: "passed",
       },
     ],
+    [
+      "stage_started",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "paper-writing",
+        stage_name: "实验前研究草稿撰写",
+        attempt: 1,
+        status: "running",
+      },
+    ],
+    ["tool_call", { tool: "WriteFile", step: 4, content: '{"file_path":"/run/paper-writing/artifacts/outline.json"}' }],
+    ["tool_result", { tool: "WriteFile", step: 4, content: "[OK] Created outline.json" }],
+    [
+      "tool_call",
+      { tool: "WriteFile", step: 5, content: '{"file_path":"/run/paper-writing/artifacts/sections/abstract.tex"}' },
+    ],
+    ["tool_result", { tool: "WriteFile", step: 5, content: "[OK] Created abstract.tex" }],
+    [
+      "stage_passed",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "paper-writing",
+        stage_name: "实验前研究草稿撰写",
+        attempt: 1,
+        status: "passed",
+      },
+    ],
     ["pipeline_completed", { pipeline_id: "research67-one-round", status: "completed" }],
     ["done", { run_id: "run_1", thread_id: "thread_1", status: "completed", output: "完成" }],
   ]
@@ -74,7 +128,12 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
         }
         if (pathname === "/api/v1/agents/ag_pipeline/run/stream") {
           const body =
-            events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}`).join("\n\n") + "\n\n"
+            events
+              .map(
+                ([event, data], index) =>
+                  `event: ${event}\ndata: ${JSON.stringify(data)}${index === 5 ? "\n\n: heartbeat" : ""}`,
+              )
+              .join("\n\n") + "\n\n"
           return new Response(body, { headers: { "Content-Type": "text/event-stream" } })
         }
         if (pathname === "/api/v1/traces/run_1/confirm") {
@@ -114,25 +173,66 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       },
     )
 
-    expect(updates.map((update) => update.title)).toEqual([
-      "Research67 正在运行",
-      "Research67 正在进行 文献检索",
-      "Research67 正在等待确认:Shell",
-      "Research67 已批准 Shell",
-      "Research67 正在思考(文献检索)",
-      "Research67 正在调用 LiteratureSearch(文献检索)",
-      "Research67 已完成 LiteratureSearch(文献检索)",
-      "Research67 正在等待确认:WriteFile",
-      "Research67 已拒绝 WriteFile",
-      "Research67 已通过 文献检索",
-      "Research67 流水线已完成",
-    ])
+    expect(updates.map((update) => update.title).join("\n")).not.toContain("正在调用")
+    expect(updates.map((update) => update.title).join("\n")).not.toContain("arxiv_search")
+    expect(JSON.stringify(updates.map((update) => update.metadata?.research67_progress))).not.toContain(
+      "topic-specific words",
+    )
+    expect(updates).toContainEqual(
+      expect.objectContaining({
+        metadata: expect.objectContaining({
+          latest_event: "heartbeat",
+          research67_progress: expect.objectContaining({ footer: "连接正常" }),
+        }),
+      }),
+    )
+    const snapshots = updates.flatMap((update) => {
+      const value = update.metadata?.research67_progress
+      return value && typeof value === "object" ? [Object.fromEntries(Object.entries(value))] : []
+    })
+    expect(snapshots.some((snapshot) => snapshot.title === "Research67 · idea-analyst · 创意分析 · 制定分析计划")).toBe(
+      true,
+    )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({
+            id: "literature-review:search",
+            status: "running",
+            detail: "已完成 1 轮检索",
+          }),
+        ]),
+      }),
+    )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "literature-review:search", status: "completed" }),
+          expect.objectContaining({ id: "literature-review:select", status: "running" }),
+        ]),
+      }),
+    )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "paper-writing:english", status: "running", detail: "1/8" }),
+        ]),
+      }),
+    )
     expect(updates.at(-1)?.metadata).toMatchObject({
       pipeline_id: "research67-one-round",
-      stage_id: "literature-review",
+      stage_id: "paper-writing",
       stage_status: "passed",
       pipeline_status: "completed",
       status: "completed",
+      research67_progress: {
+        kind: "research67",
+        rows: [
+          expect.objectContaining({ id: "idea-analysis", status: "completed" }),
+          expect.objectContaining({ id: "literature-review", status: "completed" }),
+          expect.objectContaining({ id: "paper-writing", status: "completed" }),
+        ],
+      },
     })
     expect(permissionRequests).toHaveLength(2)
     expect(permissionRequests[0]).toEqual({

+ 127 - 23
packages/tui/src/routes/session/index.tsx

@@ -1835,6 +1835,7 @@ function InlineTool(props: {
   failure?: string
   spinner?: boolean
   separate?: boolean
+  rich?: boolean
   children: JSX.Element
   part: ToolPart
   onClick?: () => void
@@ -1888,6 +1889,7 @@ function InlineTool(props: {
       failure={props.failure}
       spinner={props.spinner}
       separate={props.separate}
+      rich={props.rich}
       onMouseOver={() => clickable() && setHover(true)}
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
@@ -1918,6 +1920,7 @@ export function InlineToolRow(props: {
   failure?: string
   spinner?: boolean
   separate?: boolean
+  rich?: boolean
   children: JSX.Element
   onMouseOver?: () => void
   onMouseOut?: () => void
@@ -1964,13 +1967,20 @@ export function InlineToolRow(props: {
               >
                 {props.icon}
               </text>
-              <text
-                flexGrow={1}
-                fg={props.failed ? props.errorColor : props.color}
-                attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
+              <Show
+                when={props.rich}
+                fallback={
+                  <text
+                    flexGrow={1}
+                    fg={props.failed ? props.errorColor : props.color}
+                    attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
+                  >
+                    {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
+                  </text>
+                }
               >
-                {props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
-              </text>
+                <box flexGrow={1}>{props.children}</box>
+              </Show>
             </box>
           </Show>
         </Match>
@@ -2236,6 +2246,12 @@ function Task(props: ToolProps) {
     tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
   )
 
+  const progress = createMemo(() => {
+    const state = current()?.state
+    if (!state || (state.status !== "running" && state.status !== "completed")) return undefined
+    return parseResearch67Progress(state.metadata?.research67_progress)
+  })
+
   const status = createMemo(() => sync.data.session_status[sessionID() ?? ""])
   const isRunning = createMemo(() => {
     const value = status()
@@ -2286,28 +2302,116 @@ function Task(props: ToolProps) {
     return content.join("\n")
   })
 
+  const open = () => {
+    if (sessionID()) navigate({ type: "session", sessionID: sessionID()! })
+    const status = retry()
+    if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
+  }
+
   return (
-    <InlineTool
-      icon={props.part.state.status === "completed" ? "✓" : "│"}
-      separate={true}
-      color={retry() ? theme.error : undefined}
-      spinner={isRunning()}
-      complete={stringValue(props.input.description)}
-      pending="Delegating..."
-      part={props.part}
-      onClick={() => {
-        if (sessionID()) {
-          navigate({ type: "session", sessionID: sessionID()! })
-        }
-        const status = retry()
-        if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
-      }}
+    <Show
+      when={isRunning() && !retry() && progress()}
+      fallback={
+        <InlineTool
+          icon={props.part.state.status === "completed" ? "✓" : "│"}
+          separate={true}
+          color={retry() ? theme.error : undefined}
+          spinner={isRunning()}
+          complete={stringValue(props.input.description)}
+          pending="Delegating..."
+          part={props.part}
+          onClick={open}
+        >
+          {content()}
+        </InlineTool>
+      }
     >
-      {content()}
-    </InlineTool>
+      <InlineTool
+        icon="│"
+        complete={true}
+        pending="Delegating..."
+        separate={true}
+        rich={true}
+        part={props.part}
+        onClick={open}
+      >
+        <box flexDirection="column">
+          <text>
+            {formatSubagentTitle(
+              Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
+              stringValue(props.input.description) ?? "",
+              props.metadata.background === true,
+            )}
+          </text>
+          <For each={progress()!.rows}>
+            {(row) => (
+              <text fg={research67ProgressColor(row.status, theme)}>
+                {row.depth ? "   " : "↳ "}
+                {research67ProgressIcon(row.status)} {row.label}
+                <Show when={row.detail}> · {row.detail}</Show>
+              </text>
+            )}
+          </For>
+          <Show when={progress()!.footer}>
+            <text fg={theme.textMuted}> {progress()!.footer}</text>
+          </Show>
+        </box>
+      </InlineTool>
+    </Show>
   )
 }
 
+type Research67ProgressStatus = "completed" | "running" | "pending" | "failed"
+type Research67Progress = {
+  rows: Array<{ id: string; label: string; status: Research67ProgressStatus; detail?: string; depth: 0 | 1 }>
+  footer?: string
+}
+
+export function parseResearch67Progress(value: unknown): Research67Progress | undefined {
+  const progress = recordValue(value)
+  if (progress?.kind !== "research67" || !Array.isArray(progress.rows)) return undefined
+  const rows = progress.rows.flatMap((value) => {
+    const row = recordValue(value)
+    const id = stringValue(row?.id)
+    const label = stringValue(row?.label)
+    const status = stringValue(row?.status)
+    const depth = numberValue(row?.depth)
+    if (!id || !label || !isResearch67ProgressStatus(status) || (depth !== 0 && depth !== 1)) return []
+    const result: Research67Progress["rows"][number] = {
+      id,
+      label,
+      status,
+      detail: stringValue(row?.detail),
+      depth,
+    }
+    return [result]
+  })
+  if (rows.length === 0) return undefined
+  return { rows, footer: stringValue(progress.footer) }
+}
+
+function isResearch67ProgressStatus(value: string | undefined): value is Research67ProgressStatus {
+  return value === "completed" || value === "running" || value === "pending" || value === "failed"
+}
+
+export function research67ProgressIcon(status: Research67ProgressStatus) {
+  if (status === "completed") return "✓"
+  if (status === "running") return "●"
+  if (status === "failed") return "✗"
+  return "○"
+}
+
+function research67ProgressColor(status: Research67ProgressStatus, theme: ReturnType<typeof useTheme>["theme"]) {
+  return theme[research67ProgressTone(status)]
+}
+
+export function research67ProgressTone(status: Research67ProgressStatus) {
+  if (status === "completed") return "success" as const
+  if (status === "running") return "warning" as const
+  if (status === "failed") return "error" as const
+  return "textMuted" as const
+}
+
 export function formatSubagentToolcalls(count: number) {
   return `${count} toolcall${count === 1 ? "" : "s"}`
 }

+ 12 - 0
packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap

@@ -90,3 +90,15 @@ exports[`TUI inline tool wrapping separates an inline row from the previous assi
    ✓ Build Task — Review changes
      ↳ 48 toolcalls · 1m 40s"
 `;
+
+exports[`TUI inline tool wrapping renders a compact Research67 semantic progress tree 1`] = `
+"
+   │ Build Task — Research a topic
+     ↳ ✓ idea-analyst · 创意分析
+     ↳ ● lit-searcher · 文献检索 · 筛选入选论文
+      ✓ 检索候选文献 · 已完成 3 轮检索
+      ● 筛选入选论文
+      ○ 形成文献综述
+     ↳ ○ paper-writer · 论文撰写
+      连接正常"
+`;

+ 47 - 0
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -12,7 +12,10 @@ import {
   parseDiagnostics,
   parseQuestionAnswers,
   parseQuestions,
+  parseResearch67Progress,
   parseTodos,
+  research67ProgressIcon,
+  research67ProgressTone,
   alwaysSeparate,
   toolDisplay,
 } from "../../../src/routes/session"
@@ -124,6 +127,25 @@ function TaskRowsFixture() {
   )
 }
 
+function Research67ProgressFixture() {
+  return (
+    <box flexDirection="column" width={72}>
+      <InlineToolRow icon="│" complete={true} pending="" separate={true} rich={true}>
+        <box flexDirection="column">
+          <text>Build Task — Research a topic</text>
+          <text>↳ ✓ idea-analyst · 创意分析</text>
+          <text>↳ ● lit-searcher · 文献检索 · 筛选入选论文</text>
+          <text> ✓ 检索候选文献 · 已完成 3 轮检索</text>
+          <text> ● 筛选入选论文</text>
+          <text> ○ 形成文献综述</text>
+          <text>↳ ○ paper-writer · 论文撰写</text>
+          <text> 连接正常</text>
+        </box>
+      </InlineToolRow>
+    </box>
+  )
+}
+
 function LoadedReadBeforeTaskFixture() {
   return (
     <box flexDirection="column" width={72}>
@@ -258,6 +280,27 @@ describe("TUI inline tool wrapping", () => {
     expect(parseQuestionAnswers({})).toBeUndefined()
   })
 
+  test("validates Research67 progress snapshots and maps accessible status markers", () => {
+    expect(
+      parseResearch67Progress({
+        kind: "research67",
+        rows: [
+          { id: "done", label: "Done", status: "completed", depth: 0 },
+          { id: "bad", label: "Bad", status: "unknown", depth: 0 },
+          { id: "deep", label: "Deep", status: "pending", depth: 2 },
+        ],
+        footer: "连接正常",
+      }),
+    ).toEqual({
+      rows: [{ id: "done", label: "Done", status: "completed", detail: undefined, depth: 0 }],
+      footer: "连接正常",
+    })
+    expect(parseResearch67Progress({ kind: "other", rows: [] })).toBeUndefined()
+    const statuses = ["completed", "running", "pending", "failed"] as const
+    expect(statuses.map(research67ProgressIcon)).toEqual(["✓", "●", "○", "✗"])
+    expect(statuses.map(research67ProgressTone)).toEqual(["success", "warning", "textMuted", "error"])
+  })
+
   test("ignores diagnostics with malformed nested ranges", () => {
     expect(
       parseDiagnostics(
@@ -311,6 +354,10 @@ describe("TUI inline tool wrapping", () => {
     expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
   })
 
+  test("renders a compact Research67 semantic progress tree", async () => {
+    expect(await renderFrame(() => <Research67ProgressFixture />, { width: 72, height: 10 })).toMatchSnapshot()
+  })
+
   test("separates a task row from a preceding inline detail", async () => {
     expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
   })