瀏覽代碼

fix(tool): 按真实尝试推进 Research67 进度

桥接层原先会在重复 stage_started 时重置里程碑,并用心跳文案替代真实耗时,容易混淆 Guard 恢复与 Pipeline 新尝试。本提交让状态推进只由后端 attempt 和已完成产物驱动。

- 同阶段相同或更低 attempt 保持幂等,仅更高 attempt 重置里程碑
- 用队列关联并发工具调用与产物结果,支持 BuildResearchPaper 双 PDF 构建
- 产物集齐后立即推进下一里程碑,末项完成后展示 Guard 验收状态
- 透传 activeSince 供前端本地计时,并移除“连接正常”心跳文案
- 将 passed=false 视为真实工具失败并保留失败里程碑
- 验证:桥接测试 3 passed、29 assertions;packages/opencode typecheck 通过
Jinchen Zhang 1 周之前
父節點
當前提交
d852af4b81
共有 2 個文件被更改,包括 213 次插入32 次删除
  1. 81 30
      .opencode/tool/agentpaas_run.ts
  2. 132 2
      packages/opencode/test/tool/agentpaas-run.test.ts

+ 81 - 30
.opencode/tool/agentpaas_run.ts

@@ -122,7 +122,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))
+        progress.startStage(getString(data?.stage_id), getPositiveInteger(data?.attempt))
         updateMetadata(`Research67 正在进行 ${stageName}`, {
           pipeline_id: data?.pipeline_id,
           stage_id: data?.stage_id,
@@ -227,7 +227,6 @@ export default tool({
           step: data?.step,
         })
       } else if (ev.event === "heartbeat") {
-        progress.note("连接正常")
         updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
       } else if (ev.event === "error") {
         const message = extractMessage(ev.data)
@@ -442,6 +441,10 @@ function getString(value: unknown): string {
   return typeof value === "string" ? value : ""
 }
 
+function getPositiveInteger(value: unknown): number {
+  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1
+}
+
 function withStage(title: string, metadata: Record<string, unknown>): string {
   const stageName = getString(metadata.stage_name)
   return stageName ? `${title}(${stageName})` : title
@@ -485,23 +488,38 @@ function createResearch67Progress() {
   ]
   let current: ProgressStage | undefined
   let active: ProgressMilestone | undefined
-  let pendingArtifact: { milestone: ProgressMilestone; key: string } | undefined
+  const pendingArtifacts = new Map<string, Array<Array<{ milestone: ProgressMilestone; key: string }>>>()
   let searchCalls = 0
   let note = ""
   let activeSince = Date.now()
+  let activeAttempt = 0
+  let timerRunning = false
 
   const startMilestone = (milestone: ProgressMilestone) => {
     if (!current) return
+    if (milestone.status === "completed") return
     for (const item of current.milestones) {
       if (item === milestone) break
-      item.status = "completed"
+      if (item.status !== "failed") item.status = "completed"
     }
-    if (milestone.status !== "completed") milestone.status = "running"
+    milestone.status = "running"
+    if (milestone.detail === "尚未开始") milestone.detail = undefined
+    if (active !== milestone) activeSince = Date.now()
     active = milestone
-    activeSince = Date.now()
+    timerRunning = true
     note = ""
   }
 
+  const resetStage = (stage: ProgressStage) => {
+    for (const milestone of stage.milestones) {
+      milestone.status = "pending"
+      milestone.done?.clear()
+      milestone.detail = milestone.id === "search" ? "尚未开始" : undefined
+    }
+    if (stage.id === "literature-review") searchCalls = 0
+    pendingArtifacts.clear()
+  }
+
   const artifact = (content: string) => {
     if (!current) return undefined
     const value = content.replaceAll("\\", "/").toLowerCase()
@@ -543,15 +561,38 @@ function createResearch67Progress() {
 
   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"
-    }
+    if (!item.milestone.total || item.milestone.done?.size !== item.milestone.total) return
+    item.milestone.status = "completed"
+    if (active !== item.milestone || !current) return
+    const next = current.milestones[current.milestones.indexOf(item.milestone) + 1]
+    if (next) return startMilestone(next)
+    active = undefined
+    activeSince = Date.now()
+    timerRunning = true
+    note = "正在进行 Guard / 阶段产物验收"
+  }
+
+  const enqueueArtifacts = (toolName: string, items: Array<{ milestone: ProgressMilestone; key: string }>) => {
+    const queue = pendingArtifacts.get(toolName) ?? []
+    queue.push(items)
+    pendingArtifacts.set(toolName, queue)
+  }
+
+  const dequeueArtifacts = (toolName: string) => {
+    const queue = pendingArtifacts.get(toolName)
+    const items = queue?.shift()
+    if (!queue?.length) pendingArtifacts.delete(toolName)
+    return items
   }
 
   return {
-    startStage(stageID: string) {
-      current = stages.find((stage) => stage.id === stageID)
-      if (!current) return
+    startStage(stageID: string, attempt: number) {
+      const stage = stages.find((stage) => stage.id === stageID)
+      if (!stage) return
+      if (stage === current && attempt <= activeAttempt) return
+      if (stage === current) resetStage(stage)
+      current = stage
+      activeAttempt = attempt
       for (const stage of stages) {
         if (stage === current) break
         stage.status = "completed"
@@ -572,6 +613,7 @@ function createResearch67Progress() {
       }
       current = stage
       active = status === "failed" ? active : undefined
+      timerRunning = false
       note = status === "failed" ? "阶段执行失败" : "阶段验收通过"
     },
     finishPipeline() {
@@ -580,11 +622,13 @@ function createResearch67Progress() {
         for (const milestone of stage.milestones) milestone.status = "completed"
       }
       active = undefined
+      timerRunning = false
       note = "全部阶段已完成"
     },
     cancel() {
       if (current?.status === "running") current.status = "failed"
       if (active?.status === "running") active.status = "failed"
+      timerRunning = false
       note = "任务已取消"
     },
     note(value: string) {
@@ -595,40 +639,51 @@ function createResearch67Progress() {
       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",
-        }
+        enqueueArtifacts(lower, [
+          { milestone, key: content.toLowerCase().includes("paper_zh.tex") ? "paper_zh_pdf" : "paper_pdf" },
+        ])
+        return
+      }
+      if (current?.id === "paper-writing" && lower === "buildresearchpaper") {
+        const milestone = current.milestones.find((item) => item.id === "compile")!
+        startMilestone(milestone)
+        enqueueArtifacts(lower, [
+          { milestone, key: "paper_pdf" },
+          { milestone, key: "paper_zh_pdf" },
+        ])
         return
       }
       const item = artifact(content)
       if (!item) return
       startMilestone(item.milestone)
-      pendingArtifact = item
+      enqueueArtifacts(lower, [item])
     },
     toolResult(toolName: string, content: string) {
       const lower = toolName.toLowerCase()
-      const failed = /\[(?:tool_)?error\]|mcp_error|"status"\s*:\s*"(?:failed|error)"/i.test(content)
+      const failed = /\[(?:tool_)?error\]|mcp_error|"status"\s*:\s*"(?:failed|error)"|"passed"\s*:\s*false/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
+      const items = dequeueArtifacts(lower)
+      if (!items) return
+      if (!failed) items.forEach(completeArtifact)
+      if (failed) {
+        for (const item of items) item.milestone.status = "failed"
+        note = "当前操作失败,等待重试"
+      }
     },
     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 }]
@@ -651,13 +706,9 @@ function createResearch67Progress() {
         kind: "research67",
         title: active ? `Research67 · ${current.label} · ${active.label}` : `Research67 · ${current.label}`,
         rows,
-        footer: note || (active ? `当前步骤已运行 ${formatElapsed(elapsed)}` : "进度已更新"),
+        activeSince: timerRunning ? activeSince : undefined,
+        footer: note || (!active ? "进度已更新" : undefined),
       }
     },
   }
 }
-
-function formatElapsed(seconds: number) {
-  if (seconds < 60) return `${seconds} 秒`
-  return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`
-}

+ 132 - 2
packages/opencode/test/tool/agentpaas-run.test.ts

@@ -29,6 +29,20 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
     ],
     ["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" }],
+    [
+      "tool_call",
+      { tool: "WriteFile", step: 1, content: '{"file_path":"/run/idea-analysis/artifacts/search_queries.json"}' },
+    ],
+    ["tool_result", { tool: "WriteFile", step: 1, content: "[OK] Created search_queries.json" }],
+    [
+      "tool_call",
+      { tool: "WriteFile", step: 1, content: '{"file_path":"/run/idea-analysis/artifacts/similar_papers.json"}' },
+    ],
+    ["tool_result", { tool: "WriteFile", step: 1, content: "[OK] Created similar_papers.json" }],
+    ["tool_call", { tool: "WriteFile", step: 1, content: '{"file_path":"/run/idea-analysis/report.json"}' }],
+    ["tool_result", { tool: "WriteFile", step: 1, content: "[OK] Created report.json" }],
+    ["tool_call", { tool: "WriteFile", step: 1, content: '{"file_path":"/run/idea-analysis/report.md"}' }],
+    ["tool_result", { tool: "WriteFile", step: 1, content: "[OK] Created report.md" }],
     [
       "stage_passed",
       {
@@ -102,6 +116,20 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       { 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" }],
+    [
+      "tool_call",
+      {
+        tool: "BuildResearchPaper",
+        step: 6,
+        content:
+          '{"phase_dir":"/run/paper-writing","papers_selected_path":"/run/literature-review/artifacts/papers_selected.json"}',
+      },
+    ],
+    ["tool_result", { tool: "BuildResearchPaper", step: 6, content: '{"status":"completed","passed":true}' }],
+    ["tool_call", { tool: "WriteFile", step: 7, content: '{"file_path":"/run/paper-writing/report.json"}' }],
+    ["tool_call", { tool: "WriteFile", step: 7, content: '{"file_path":"/run/paper-writing/report.md"}' }],
+    ["tool_result", { tool: "WriteFile", step: 7, content: "[OK] Created report.json" }],
+    ["tool_result", { tool: "WriteFile", step: 7, content: "[OK] Created report.md" }],
     [
       "stage_passed",
       {
@@ -182,10 +210,11 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       expect.objectContaining({
         metadata: expect.objectContaining({
           latest_event: "heartbeat",
-          research67_progress: expect.objectContaining({ footer: "连接正常" }),
+          research67_progress: expect.objectContaining({ activeSince: expect.any(Number) }),
         }),
       }),
     )
+    expect(JSON.stringify(updates)).not.toContain("连接正常")
     const snapshots = updates.flatMap((update) => {
       const value = update.metadata?.research67_progress
       return value && typeof value === "object" ? [Object.fromEntries(Object.entries(value))] : []
@@ -193,6 +222,21 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
     expect(snapshots.some((snapshot) => snapshot.title === "Research67 · idea-analyst · 创意分析 · 制定分析计划")).toBe(
       true,
     )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "idea-analysis:plan", status: "completed" }),
+          expect.objectContaining({ id: "idea-analysis:directions", status: "running" }),
+        ]),
+      }),
+    )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        footer: "正在进行 Guard / 阶段产物验收",
+        activeSince: expect.any(Number),
+        rows: expect.arrayContaining([expect.objectContaining({ id: "idea-analysis:report", status: "completed" })]),
+      }),
+    )
     expect(snapshots).toContainEqual(
       expect.objectContaining({
         rows: expect.arrayContaining([
@@ -212,6 +256,14 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
         ]),
       }),
     )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "literature-review:select", status: "completed" }),
+          expect.objectContaining({ id: "literature-review:report", status: "running" }),
+        ]),
+      }),
+    )
     expect(snapshots).toContainEqual(
       expect.objectContaining({
         rows: expect.arrayContaining([
@@ -219,6 +271,22 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
         ]),
       }),
     )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "paper-writing:compile", status: "completed" }),
+          expect.objectContaining({ id: "paper-writing:report", status: "running" }),
+        ]),
+      }),
+    )
+    expect(snapshots).toContainEqual(
+      expect.objectContaining({
+        footer: "正在进行 Guard / 阶段产物验收",
+        rows: expect.arrayContaining([
+          expect.objectContaining({ id: "paper-writing:report", status: "completed", detail: undefined }),
+        ]),
+      }),
+    )
     expect(updates.at(-1)?.metadata).toMatchObject({
       pipeline_id: "research67-one-round",
       stage_id: "paper-writing",
@@ -281,6 +349,37 @@ test("preserves completed artifacts and forbids automatic reruns after failure",
     ],
     ["tool_call", { tool: "WriteFile", content: '{"file_path":"/run/paper-writing/artifacts/outline.json"}' }],
     ["tool_result", { tool: "WriteFile", content: "[OK] Created outline.json" }],
+    [
+      "stage_started",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "paper-writing",
+        stage_name: "实验前研究草稿撰写",
+        attempt: 1,
+        status: "running",
+      },
+    ],
+    ["tool_call", { tool: "CompileLatex", content: '{"tex_path":"/run/paper-writing/artifacts/paper.tex"}' }],
+    ["tool_result", { tool: "CompileLatex", content: "[TOOL_ERROR] UnicodeDecodeError" }],
+    [
+      "tool_call",
+      {
+        tool: "WriteFile",
+        step: "repair",
+        content: '{"file_path":"/run/paper-writing/artifacts/paper.tex"}',
+      },
+    ],
+    ["tool_result", { tool: "WriteFile", step: "repair", content: "[OK] Updated paper.tex" }],
+    [
+      "stage_started",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "paper-writing",
+        stage_name: "实验前研究草稿撰写",
+        attempt: 2,
+        status: "running",
+      },
+    ],
     ["tool_call", { tool: "CompileLatex", content: '{"tex_path":"/run/paper-writing/artifacts/paper.tex"}' }],
     ["tool_result", { tool: "CompileLatex", content: "[TOOL_ERROR] UnicodeDecodeError" }],
     [
@@ -289,7 +388,7 @@ test("preserves completed artifacts and forbids automatic reruns after failure",
         pipeline_id: "research67-one-round",
         stage_id: "paper-writing",
         stage_name: "实验前研究草稿撰写",
-        attempt: 1,
+        attempt: 2,
         status: "failed",
         error: "Native function-calling loop produced no final output",
       },
@@ -348,6 +447,37 @@ test("preserves completed artifacts and forbids automatic reruns after failure",
     expect(result.output).toContain("Native function-calling loop produced no final output")
     expect(result.output).toContain(workspacePath)
     expect(result.output).toContain("失败严禁自动重跑、拆分任务或调用其他 subagent")
+    expect(
+      updates.find((update) => update.metadata?.latest_event === "tool_result" && update.metadata.step === "repair")
+        ?.metadata?.research67_progress,
+    ).toMatchObject({
+      title: "Research67 · paper-writer · 论文撰写 · 编译双语 PDF",
+      footer: "当前操作失败,等待重试",
+      rows: expect.arrayContaining([
+        expect.objectContaining({ id: "paper-writing:english", status: "completed" }),
+        expect.objectContaining({ id: "paper-writing:compile", status: "failed" }),
+      ]),
+    })
+    const attemptStarts = updates.filter((update) => update.metadata?.latest_event === "stage_started")
+    const firstAttemptProgress = attemptStarts[0]?.metadata?.research67_progress
+    const firstActiveSince =
+      firstAttemptProgress && typeof firstAttemptProgress === "object" && "activeSince" in firstAttemptProgress
+        ? firstAttemptProgress.activeSince
+        : undefined
+    expect(attemptStarts[1]?.metadata?.research67_progress).toMatchObject({
+      activeSince: firstActiveSince,
+      rows: expect.arrayContaining([
+        expect.objectContaining({ id: "paper-writing:outline", status: "running", detail: "1/2" }),
+      ]),
+    })
+    expect(attemptStarts[2]?.metadata?.research67_progress).toMatchObject({
+      activeSince: expect.any(Number),
+      rows: expect.arrayContaining([
+        expect.objectContaining({ id: "paper-writing:outline", status: "running", detail: undefined }),
+        expect.objectContaining({ id: "paper-writing:english", status: "pending", detail: undefined }),
+        expect.objectContaining({ id: "paper-writing:compile", status: "pending", detail: undefined }),
+      ]),
+    })
     expect(result.metadata).toMatchObject({
       run_id: "run_failure",
       status: "failed",