Browse Source

fix(bridge): 对齐远程确认与研究运行状态协议

远程 Agent 身份、人工确认路由和进度呈现与 PaaS 协议存在差异。本次补齐远程交互和成果引用,并保留真实终态。

- 支持并验证 LocoStack 身份,远程确认传递 approval_id
- 区分暂停、取消、跳过和未知阶段,清理流读取与中止监听
- 传递研究题号、反馈、成果引用及幂等键,返回研究信息和鉴权下载地址
- 新增真实回环 HTTP 协议测试和操作说明
- Bridge 30 项、TUI 19 项测试通过,两个包的类型检查通过
邵勃源 5 days ago
parent
commit
22d6170039

+ 39 - 1
.opencode/README.md

@@ -37,7 +37,7 @@ Research67 统一工作流为:
 bun run --cwd packages/opencode dev -- --agent research67
 ```
 
-之后直接在 OpenCode 中输入科学问题、125 Questions 题号或继续追问即可。同一 OpenCode session 会自动复用远端 AgentPaaS thread 和上一轮 workspace。
+之后直接在 OpenCode 中输入科学问题、125 Questions 题号或继续追问即可。同一 OpenCode session 会保留 AgentPaaS thread 和上一轮运行引用。对于 LocoStack 远程 Agent,继续研究会在新工作区中复制上一轮选定成果,并保留父子版本关系。
 
 ## 推荐入口
 
@@ -76,3 +76,41 @@ export AGENTPAAS_API_KEY=你的_AgentPaaS_API_Key
 - OpenCode 不直接生成科研产物,只展示远端返回结果和实时进度。
 - 每个门面单回合只调用一次 `agentpaas_run`,失败后不得自动重试或改调其他 Agent。
 - 真实湿实验、人体/动物实验和硬件实验需要远端授权门禁;未授权时只能生成实验协议和 `external_authorization_package.json`,不能声称实验验证完成。
+
+## 远程协议与研究修订
+
+`agentpaas_run` 支持 `ag_...` 和 `locostack:<namespace>:<name>` 两种 Agent 身份。新增可选参数:
+
+| 参数                 | 含义                                                     |
+| -------------------- | -------------------------------------------------------- |
+| `question_id`        | 1–125 的题号                                             |
+| `feedback_kind`      | `human`、`program_check`、`execution_result`、`evidence` |
+| `input_artifact_ids` | 从上一轮已登记成果中选择输入;省略时由 PaaS 选择全部     |
+| `idempotency_key`    | 同一提交重试时保持不变;修改请求内容应使用新键           |
+
+使用 `conversation: "continue"` 关联上一轮运行。服务端会检查用户、租户、Agent、session 和成果归属。单次调用出错仍不自动提交另一次研究;已有 run 可以通过 PaaS 快照和事件接口恢复读取。
+
+远程人工确认携带 `approval_id`,走 `/agents/{agent_id}/runs/{run_id}/confirm`。本地 Agent 保留原 trace 确认路由。暂停、取消、跳过和未知状态分别显示,未报告的研究阶段不会自动标为完成。
+
+结果 metadata 中包含 `research` 和登记成果信息。下载地址使用 PaaS API,仍需调用者鉴权,不能作为公开分享链接。`AGENTPAAS_BRIDGE_STATE_DIR` 可以覆盖默认会话状态目录,方便在隔离环境测试;不要在测试中复用真实用户状态。
+
+这些改动需与配套 PaaS/Remote 的 `research-integration` 版本一起使用;尚未实现客户端重启后的全流程自动恢复,也未增加协议版本协商。
+
+## 协议回归
+
+先在仓库根目录和 `.opencode` 安装 Bun 依赖。测试从各包目录运行:
+
+```bash
+cd packages/opencode
+bun test test/tool/agentpaas-run.test.ts test/tool/agentpaas-protocol.test.ts
+bun typecheck
+```
+
+TUI 测试在 `packages/tui`:
+
+```bash
+bun test test/cli/tui/inline-tool-wrap-snapshot.test.tsx
+bun typecheck
+```
+
+完整的本地 HTTP/Q124 验收入口在配套 PaaS 仓库的 `scripts/smoke_research_integration.py`。该测试使用真实 HTTP 和仿真执行器;Loco 发现目录为测试夹具,不代表真实 Kubernetes 已验收。

+ 301 - 182
.opencode/tool/agentpaas_run.ts

@@ -9,7 +9,7 @@ const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
 type SSEEvent = { event: string; data: unknown }
 type AgentListItem = { id: string; name: string }
 type ConversationState = { threadID: string; runID: string }
-type ProgressStatus = "completed" | "running" | "pending" | "failed"
+type ProgressStatus = "completed" | "running" | "pending" | "failed" | "paused" | "cancelled" | "skipped" | "unknown"
 type ProgressMilestone = {
   id: string
   label: string
@@ -34,7 +34,7 @@ export default tool({
 - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
 - target_subagent:mode=edit 时的目标子智能体 ID
 - work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
-- conversation:continue(默认,复用本 OpenCode session 的远端 thread/workspace)/ new(新课题)
+- conversation:continue(默认,沿用远端 thread 并引用上一轮成果)/ new(新课题)
 - thread_id:可选的远端会话线程 ID;显式值优先于自动映射
 - parameters:额外参数,原样传给远端智能体
 
@@ -47,6 +47,13 @@ export default tool({
     work_dir: tool.schema.string().optional().describe("服务端可见工作目录;通常留空,由 AgentPaaS 创建"),
     thread_id: tool.schema.string().optional().describe("远端会话线程 ID;通常留空,由 OpenCode session 自动维护"),
     conversation: tool.schema.enum(["continue", "new"]).default("continue").describe("复用当前对话或开始新课题"),
+    question_id: tool.schema.number().int().min(1).max(125).optional().describe("Science 125 科学问题编号"),
+    feedback_kind: tool.schema.enum(["human", "program_check", "execution_result", "evidence"]).optional(),
+    input_artifact_ids: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe("续跑时选取的上一轮产物 ID;省略时复用上一轮已登记产物"),
+    idempotency_key: tool.schema.string().optional().describe("同一请求重试时复用;不同请求必须使用不同键"),
     parameters: tool.schema
       .record(tool.schema.string(), tool.schema.any())
       .optional()
@@ -79,6 +86,7 @@ export default tool({
           Authorization: `Bearer ${config.apiKey}`,
           "Content-Type": "application/json",
           Accept: "text/event-stream",
+          ...(args.idempotency_key ? { "Idempotency-Key": args.idempotency_key } : {}),
         },
         body: JSON.stringify({
           input: args.input,
@@ -89,6 +97,9 @@ export default tool({
           context: {
             ...(workDir ? { work_dir: workDir } : {}),
             ...(continueRunID ? { run_id: continueRunID } : {}),
+            ...(args.question_id ? { question_id: args.question_id } : {}),
+            ...(args.feedback_kind ? { feedback_kind: args.feedback_kind } : {}),
+            ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
           },
         }),
         signal: context.abort,
@@ -127,174 +138,228 @@ export default tool({
       remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
     }
     context.abort.addEventListener("abort", cancelOnAbort, { once: true })
-    for await (const ev of parseSSE(resp.body)) {
-      const data = asRecord(ev.data)
-      if (ev.event === "started") {
-        startedRunID = getString(data?.run_id)
-        const remoteThreadID = getString(data?.thread_id)
-        if (remoteThreadID && startedRunID) {
-          await saveConversationState(context.sessionID, conversationKey, {
-            threadID: remoteThreadID,
-            runID: startedRunID,
+    try {
+      for await (const ev of parseSSE(resp.body)) {
+        const data = asRecord(ev.data)
+        if (ev.event === "started") {
+          startedRunID = getString(data?.run_id)
+          const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
+          if (remoteThreadID && startedRunID) {
+            await saveConversationState(context.sessionID, conversationKey, {
+              threadID: remoteThreadID,
+              runID: startedRunID,
+            })
+          }
+          if (context.abort.aborted) cancelOnAbort()
+          updateMetadata("Research67 正在运行", {
+            run_id: data?.run_id,
+            thread_id: data?.thread_id,
+            latest_event: ev.event,
           })
-        }
-        if (context.abort.aborted) cancelOnAbort()
-        updateMetadata("Research67 正在运行", {
-          run_id: data?.run_id,
-          thread_id: data?.thread_id,
-          latest_event: ev.event,
-        })
-      } else if (ev.event === "stage_started") {
-        const stageName = getString(data?.stage_name) || 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,
-          stage_name: stageName,
-          stage_status: data?.status,
-          attempt: data?.attempt,
-          latest_event: ev.event,
-        })
-      } 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,
-          stage_name: stageName,
-          stage_status: data?.status,
-          attempt: data?.attempt,
-          latest_event: ev.event,
-        })
-      } 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,
-          stage_name: stageName,
-          stage_status: data?.status,
-          stage_error: data?.error,
-          attempt: data?.attempt,
-          latest_event: ev.event,
-          status: "failed",
-        })
-      } else if (ev.event === "pipeline_completed") {
-        progress.finishPipeline()
-        updateMetadata("Research67 流水线已完成", {
-          pipeline_id: data?.pipeline_id,
-          pipeline_status: data?.status,
-          latest_event: ev.event,
-          status: "completed",
-        })
-      } else if (ev.event === "confirm_required") {
-        const runID = getString(data?.run_id) || getString(liveMetadata.run_id)
-        const toolName = getString(data?.tool) || "高风险工具"
-        const reason = getString(data?.reason)
-        const input = getString(data?.input)
-        if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
+        } else if (ev.event === "stage_started") {
+          const stageName = getString(data?.stage_name) || 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,
+            stage_name: stageName,
+            stage_status: data?.status,
+            attempt: data?.attempt,
+            latest_event: ev.event,
+          })
+        } 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,
+            stage_name: stageName,
+            stage_status: data?.status,
+            attempt: data?.attempt,
+            latest_event: ev.event,
+          })
+        } 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,
+            stage_name: stageName,
+            stage_status: data?.status,
+            stage_error: data?.error,
+            attempt: data?.attempt,
+            latest_event: ev.event,
+            status: "failed",
+          })
+        } else if (ev.event === "pipeline_completed") {
+          progress.finishPipeline()
+          updateMetadata("Research67 流水线已完成", {
+            pipeline_id: data?.pipeline_id,
+            pipeline_status: data?.status,
+            latest_event: ev.event,
+            status: "completed",
+          })
+        } else if (ev.event === "confirm_required") {
+          const runID = getString(data?.run_id) || getString(liveMetadata.run_id)
+          const toolName = getString(data?.tool) || "高风险工具"
+          const reason = getString(data?.reason)
+          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,
-          confirmation_tool: toolName,
-          confirmation_reason: reason,
-          confirmation_input: input,
-        })
-        let approved = true
-        try {
-          await context.ask({
-            permission: "agentpaas_confirm",
-            patterns: [`${agentID}:${toolName}`],
-            always: [`${agentID}:${toolName}`],
-            metadata: { run_id: runID, agent_name: args.agent_name, tool: toolName, reason, input },
+          progress.note(`等待确认 · ${toolName}`)
+          updateMetadata(`Research67 正在等待确认:${toolName}`, {
+            latest_event: ev.event,
+            pending_confirmation: true,
+            confirmation_tool: toolName,
+            confirmation_reason: reason,
+            confirmation_input: input,
           })
-        } catch {
-          approved = false
-        }
-        await resolveConfirmation(config, runID, approved, context.abort)
-        progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
-        updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
-          latest_event: "confirmation_resolved",
-          pending_confirmation: false,
-          confirmation_approved: approved,
-        })
-      } else if (ev.event === "tool_call") {
-        const toolName = getString(data?.tool) || "工具"
-        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) || "工具"
-        progress.toolResult(toolName, getString(data?.content))
-        updateMetadata(withStage("Research67 已更新当前步骤", liveMetadata), {
-          latest_event: ev.event,
-          latest_tool: toolName,
-          step: data?.step,
-        })
-      } else if (ev.event === "think" || ev.event === "think_chunk") {
-        const now = Date.now()
-        if (ev.event === "think" || now - lastThinkUpdate >= 1000) {
-          lastThinkUpdate = now
-          updateMetadata(withStage("Research67 正在思考", liveMetadata), {
+          let approved = true
+          try {
+            await context.ask({
+              permission: "agentpaas_confirm",
+              patterns: [`${agentID}:${toolName}`],
+              always: [`${agentID}:${toolName}`],
+              metadata: { run_id: runID, agent_name: args.agent_name, tool: toolName, reason, input },
+            })
+          } catch {
+            approved = false
+          }
+          await resolveConfirmation(config, agentID, runID, approved, context.abort, getString(data?.approval_id))
+          progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
+          updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
+            latest_event: "confirmation_resolved",
+            pending_confirmation: false,
+            confirmation_approved: approved,
+          })
+        } else if (ev.event === "tool_call") {
+          const toolName = getString(data?.tool) || "工具"
+          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) || "工具"
+          progress.toolResult(toolName, getString(data?.content))
+          updateMetadata(withStage("Research67 已更新当前步骤", liveMetadata), {
+            latest_event: ev.event,
+            latest_tool: toolName,
+            step: data?.step,
+          })
+        } else if (ev.event === "think" || ev.event === "think_chunk") {
+          const now = Date.now()
+          if (ev.event === "think" || now - lastThinkUpdate >= 1000) {
+            lastThinkUpdate = now
+            updateMetadata(withStage("Research67 正在思考", liveMetadata), {
+              latest_event: ev.event,
+              step: data?.step,
+            })
+          }
+        } else if (ev.event === "answer") {
+          updateMetadata(withStage("Research67 正在整理结果", liveMetadata), {
             latest_event: ev.event,
             step: data?.step,
           })
+        } else if (ev.event === "heartbeat") {
+          updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
+        } else if (ev.event === "error") {
+          const message = extractMessage(ev.data)
+          errors.push(message)
+          updateMetadata("Research67 执行失败", {
+            latest_event: ev.event,
+            status: "failed",
+            error_code: data?.code,
+            error_message: message,
+            workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
+          })
+        } else if (ev.event === "paused") {
+          progress.pause()
+          updateMetadata("Research67 已暂停", {
+            latest_event: ev.event,
+            status: "paused",
+            checkpoint_id: data?.checkpoint_id,
+            workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
+          })
+        } else if (ev.event === "approval_required") {
+          // Durable human gates stop the stream. Surface the approval card; a separate
+          // control action must approve it and re-subscribe to the same run.
+          updateMetadata("Research67 等待人工反馈", {
+            latest_event: ev.event,
+            status: "waiting_human",
+            pending_approval: 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
+          break
         }
-      } else if (ev.event === "answer") {
-        updateMetadata(withStage("Research67 正在整理结果", liveMetadata), {
-          latest_event: ev.event,
-          step: data?.step,
-        })
-      } else if (ev.event === "heartbeat") {
-        updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
-      } else if (ev.event === "error") {
-        const message = extractMessage(ev.data)
-        errors.push(message)
-        updateMetadata("Research67 执行失败", {
-          latest_event: ev.event,
-          status: "failed",
-          error_code: data?.code,
-          error_message: message,
-          workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
-        })
-      } 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
-        break
       }
+    } finally {
+      context.abort.removeEventListener("abort", cancelOnAbort)
     }
-    context.abort.removeEventListener("abort", cancelOnAbort)
     if (!done) {
       const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
       throw new Error(`AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}`)
     }
     const status = typeof done.status === "string" ? done.status : "unknown"
+    if (status === "completed" || status === "failed") progress.stopUnverified()
+    if (status === "paused") progress.pause()
+    if (status === "cancelled") progress.cancel()
     const progressMetadata = progress.snapshot()
     const metadata = {
       agent_name: args.agent_name,
       agent_id: agentID,
       run_id: done.run_id ?? liveMetadata.run_id,
-      thread_id: done.thread_id ?? liveMetadata.thread_id,
+      thread_id: done.thread_id ?? done.session_id ?? liveMetadata.thread_id,
       status,
       steps: done.steps,
-      total_tokens: done.total_tokens,
-      cost_usd: done.cost_usd,
+      total_tokens: done.total_tokens ?? asRecord(done.usage)?.total_tokens,
+      cost_usd: done.cost_usd ?? asRecord(done.usage)?.cost_usd,
+      research: done.research,
+      artifacts: Array.isArray(done.artifacts) ? done.artifacts : [],
       workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
       error_code: liveMetadata.error_code,
       error_message: liveMetadata.error_message,
+      checkpoint_id: done.checkpoint_id ?? liveMetadata.checkpoint_id,
+      pending_approval: done.pending_approval ?? liveMetadata.pending_approval,
       ...(progressMetadata ? { research67_progress: progressMetadata } : {}),
     }
+    const artifactLinks = metadata.artifacts.flatMap((value) => {
+      const artifact = asRecord(value)
+      const id = getString(artifact?.artifact_id)
+      if (!id) return []
+      const name = (getString(artifact?.name) || id).replace(/[\[\]\r\n]/g, "_")
+      return [
+        `- [${name}](${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(String(metadata.run_id))}/artifacts/${encodeURIComponent(id)}/download)`,
+      ]
+    })
     if (status === "completed")
-      return { title: `AgentPaaS ${args.agent_name}`, output: String(done.output ?? ""), metadata }
+      return {
+        title: `AgentPaaS ${args.agent_name}`,
+        output: [
+          String(done.output ?? ""),
+          ...(artifactLinks.length ? ["\n研究产物(通过 PaaS 身份验证后下载):", ...artifactLinks] : []),
+        ].join("\n"),
+        metadata,
+      }
     if (status === "cancelled") return { title: `AgentPaaS ${args.agent_name}`, output: "任务已被取消。", metadata }
+    if (status === "paused")
+      return {
+        title: `AgentPaaS ${args.agent_name}(已暂停)`,
+        output: `任务已暂停。run_id=${String(metadata.run_id ?? "")};checkpoint_id=${String(metadata.checkpoint_id ?? "未提供")}。恢复应使用该 checkpoint,不能重新提交任务。`,
+        metadata,
+      }
+    if (status === "waiting_human" || status === "waiting_confirmation")
+      return {
+        title: `AgentPaaS ${args.agent_name}(等待确认)`,
+        output: `任务正在等待人工确认。run_id=${String(metadata.run_id ?? "")}。请检查待确认材料并提交决定;这不是任务失败。`,
+        metadata,
+      }
     return {
       title: `AgentPaaS ${args.agent_name}(失败)`,
       output: [
@@ -340,10 +405,28 @@ async function resolveAgentID(config: { url: string; apiKey: string }, agentName
   if (matches.length === 0) throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
   if (matches.length > 1)
     throw new Error(`AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,无法确定调用目标`)
-  if (!matches[0].id.startsWith("ag_")) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
+  if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
   return matches[0].id
 }
 
+// Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
+// Every route still encodes the complete ID as one path segment.
+function isSupportedAgentID(id: string): boolean {
+  if (id.length > 512) return false
+  if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
+  const parts = id.split(":")
+  if (parts.length !== 3 || parts[0] !== "locostack") return false
+  const namespace = parts[1]
+  const name = parts[2]
+  const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
+  return (
+    namespace.length <= 63 &&
+    label.test(namespace) &&
+    name.length <= 253 &&
+    name.split(".").every((part) => part.length <= 63 && label.test(part))
+  )
+}
+
 const conversationCache = new Map<string, ConversationState>()
 
 async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
@@ -432,22 +515,32 @@ function agentPaaSConfigPath() {
 }
 
 function agentPaaSConversationDir() {
-  return path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
+  return (
+    process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
+  )
 }
 
 async function resolveConfirmation(
   config: { url: string; apiKey: string },
+  agentID: string,
   runID: string,
   approved: boolean,
   signal: AbortSignal,
+  approvalID: string,
 ) {
-  const resp = await fetch(`${config.url}/api/v1/traces/${encodeURIComponent(runID)}/confirm`, {
+  // Compatibility adapter: legacy local runs keep their existing confirmation API.
+  // Remote runs must use their owner-aware route; never fall back after a failed POST.
+  const remote = agentID.startsWith("locostack:")
+  const endpoint = remote
+    ? `/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/confirm`
+    : `/api/v1/traces/${encodeURIComponent(runID)}/confirm`
+  const resp = await fetch(`${config.url}${endpoint}`, {
     method: "POST",
     headers: {
       Authorization: `Bearer ${config.apiKey}`,
       "Content-Type": "application/json",
     },
-    body: JSON.stringify({ approved }),
+    body: JSON.stringify({ approved, ...(remote && approvalID ? { approval_id: approvalID } : {}) }),
     signal,
   })
   if (resp.ok) return
@@ -465,7 +558,7 @@ async function cancelRemoteRun(config: { url: string; apiKey: string }, agentID:
       signal,
     },
   )
-  if (resp.ok || resp.status === 409) return
+  if (resp.ok) return
   const detail = await resp.text().catch(() => "")
   throw new Error(`AgentPaaS 取消请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
 }
@@ -474,31 +567,36 @@ async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEv
   const reader = body.getReader()
   const decoder = new TextDecoder()
   let buffer = ""
-  while (true) {
-    const { done, value } = await reader.read()
-    if (done) break
-    buffer += decoder.decode(value, { stream: true })
-    buffer = buffer.replace(/\r\n/g, "\n")
-    let idx: number
-    while ((idx = buffer.indexOf("\n\n")) >= 0) {
-      const chunk = buffer.slice(0, idx)
-      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 {
-          data = JSON.parse(dataRaw)
-        } catch {
-          /* 非 JSON 当纯文本 */
+  try {
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+      buffer += decoder.decode(value, { stream: true })
+      buffer = buffer.replace(/\r\n/g, "\n")
+      let idx: number
+      while ((idx = buffer.indexOf("\n\n")) >= 0) {
+        const chunk = buffer.slice(0, idx)
+        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 {
+            data = JSON.parse(dataRaw)
+          } catch {
+            /* 非 JSON 当纯文本 */
+          }
         }
+        if (event) yield { event, data }
       }
-      if (event) yield { event, data }
     }
+  } finally {
+    await reader.cancel().catch(() => undefined)
+    reader.releaseLock()
   }
 }
 function parseSSEField(chunk: string, field: string): string {
@@ -700,6 +798,7 @@ function createResearch67Progress() {
       ],
     },
   ]
+  const observedStages: ProgressStage[] = []
   let current: ProgressStage | undefined
   let active: ProgressMilestone | undefined
   const pendingArtifacts = new Map<string, Array<Array<{ milestone: ProgressMilestone; key: string }>>>()
@@ -750,7 +849,10 @@ function createResearch67Progress() {
   const findOrCreateStage = (stageID: string) => {
     const normalized = normalizeStageID(stageID || "remote-stage")
     let stage = stages.find((item) => item.id === normalized)
-    if (stage) return stage
+    if (stage) {
+      if (!observedStages.includes(stage)) observedStages.push(stage)
+      return stage
+    }
     stage = {
       id: normalized,
       label: `${stageID || "remote-stage"} · 远端阶段`,
@@ -758,6 +860,7 @@ function createResearch67Progress() {
       milestones: genericMilestones(),
     }
     stages.push(stage)
+    observedStages.push(stage)
     return stage
   }
 
@@ -853,7 +956,7 @@ 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) return
+    if (!item.milestone.total || (item.milestone.done?.size ?? 0) < item.milestone.total) return
     item.milestone.status = "completed"
     if (active !== item.milestone || !current) return
     const next = current.milestones[current.milestones.indexOf(item.milestone) + 1]
@@ -884,10 +987,7 @@ function createResearch67Progress() {
       if (stage === current) resetStage(stage)
       current = stage
       activeAttempt = attempt
-      for (const item of stages) {
-        if (item === current) break
-        if (item.status === "pending") item.status = "completed"
-      }
+      // A later stage starting says nothing about whether earlier stages ran.
       current.status = "running"
       startMilestone(current.milestones[0])
     },
@@ -907,17 +1007,36 @@ function createResearch67Progress() {
       note = status === "failed" ? "阶段执行失败" : "阶段验收通过"
     },
     finishPipeline() {
-      for (const stage of stages) {
-        stage.status = "completed"
-        for (const milestone of stage.milestones) milestone.status = "completed"
+      // Terminal pipeline status is not evidence that every predefined stage ran.
+      for (const stage of observedStages) {
+        if (stage.status === "running") stage.status = "unknown"
+        for (const milestone of stage.milestones) {
+          if (milestone.status === "running") milestone.status = "unknown"
+        }
       }
       active = undefined
       timerRunning = false
-      note = "全部阶段已完成"
+      note = "流程已结束;阶段状态以服务端事件为准"
+    },
+    stopUnverified() {
+      for (const stage of observedStages) {
+        if (stage.status === "running") stage.status = "unknown"
+        for (const milestone of stage.milestones) {
+          if (milestone.status === "running") milestone.status = "unknown"
+        }
+      }
+      active = undefined
+      timerRunning = false
+    },
+    pause() {
+      if (current?.status === "running") current.status = "paused"
+      if (active?.status === "running") active.status = "paused"
+      timerRunning = false
+      note = "任务已暂停,等待恢复"
     },
     cancel() {
-      if (current?.status === "running") current.status = "failed"
-      if (active?.status === "running") active.status = "failed"
+      if (current?.status === "running") current.status = "cancelled"
+      if (active?.status === "running") active.status = "cancelled"
       timerRunning = false
       note = "任务已取消"
     },
@@ -1009,7 +1128,7 @@ function createResearch67Progress() {
     },
     snapshot() {
       if (!current) return undefined
-      const rows = stages.flatMap((stage) => {
+      const rows = observedStages.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

+ 205 - 0
packages/opencode/test/tool/agentpaas-protocol.test.ts

@@ -0,0 +1,205 @@
+// Real loopback HTTP/SSE contract tests; no model or external service calls.
+import { expect, test } from "bun:test"
+import bridge from "../../../../.opencode/tool/agentpaas_run"
+type Event = [string, Record<string, unknown>]
+
+async function execute(id: string, events: Event[], approved = true, confirmationStatus = 200) {
+  const previousURL = process.env.AGENTPAAS_URL
+  const previousKey = process.env.AGENTPAAS_API_KEY
+  const requests: Array<{ path: string; body?: Record<string, unknown> }> = []
+  const updates: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
+  process.env.AGENTPAAS_API_KEY = "synthetic-key"
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(request) {
+      const url = new URL(request.url)
+      const raw = request.method === "POST" ? await request.text() : ""
+      const body = raw ? JSON.parse(raw) : undefined
+      requests.push({ path: url.pathname, body })
+      if (url.pathname === "/api/v1/agents") return Response.json({ agents: [{ id, name: "research67" }] })
+      if (url.pathname.endsWith("/run/stream"))
+        return new Response(
+          events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join(""),
+          { headers: { "Content-Type": "text/event-stream" } },
+        )
+      if (url.pathname.endsWith("/confirm"))
+        return Response.json({ status: approved ? "confirmed" : "denied" }, { status: confirmationStatus })
+      return Response.json({ error: `Unexpected request ${url.pathname}` }, { status: 404 })
+    },
+  })
+  process.env.AGENTPAAS_URL = server.url.origin
+  try {
+    const result = await bridge.execute(
+      { input: "synthetic protocol test", agent_name: "research67", mode: "chat", conversation: "new" },
+      {
+        sessionID: `isolated_${crypto.randomUUID()}`,
+        messageID: "probe",
+        agent: "research67",
+        directory: import.meta.dir,
+        worktree: import.meta.dir,
+        abort: new AbortController().signal,
+        metadata: (update) => updates.push(update),
+        ask: async () => {
+          if (!approved) throw new Error("synthetic deny")
+        },
+      },
+    )
+    if (typeof result === "string" || !result.metadata) throw new Error("Bridge must return structured tool output")
+    return { result: { ...result, metadata: result.metadata }, requests, updates }
+  } catch (error) {
+    throw Object.assign(error instanceof Error ? error : new Error(String(error)), { requests })
+  } finally {
+    await server.stop(true)
+    if (previousURL === undefined) delete process.env.AGENTPAAS_URL
+    else process.env.AGENTPAAS_URL = previousURL
+    if (previousKey === undefined) delete process.env.AGENTPAAS_API_KEY
+    else process.env.AGENTPAAS_API_KEY = previousKey
+  }
+}
+const done = (status = "completed"): Event => ["done", { run_id: "run_probe", status, output: "ok" }]
+const start = (stage: string): Event => ["stage_started", { stage_id: stage, stage_name: stage, attempt: 1 }]
+const passed = (stage: string): Event => ["stage_passed", { stage_id: stage, status: "passed" }]
+const pipeline: Event = ["pipeline_completed", { status: "completed" }]
+function rows(result: unknown) {
+  return (
+    result as { metadata: { research67_progress: { rows: Array<{ id: string; status: string; depth: number }> } } }
+  ).metadata.research67_progress.rows.filter((row) => row.depth === 0)
+}
+
+test("local ID remains accepted", async () => {
+  const actual = await execute("ag_research67", [done()])
+  expect(actual.result.output).toBe("ok")
+  expect(actual.requests[1].path).toBe("/api/v1/agents/ag_research67/run/stream")
+})
+test("remote namespaced ID accepted and encoded as one route segment", async () => {
+  const actual = await execute("locostack:science-team:research67.v1", [done()])
+  expect(actual.result.output).toBe("ok")
+  expect(actual.requests[1].path).toBe("/api/v1/agents/locostack%3Ascience-team%3Aresearch67.v1/run/stream")
+})
+for (const id of [
+  "ag_",
+  "ag_../other",
+  "ag_a%2fb",
+  "locostack:default:../admin",
+  "locostack:default:agent/extra",
+  "locostack:default:agent%2Fextra",
+  "locostack:default:agent:extra",
+  "locostack::agent",
+  "locostack:Default:agent",
+  "locostack:default:-agent",
+  "locostack:default:agent..name",
+  `locostack:${"a".repeat(64)}:agent`,
+]) {
+  test(`reject malformed ID ${id.slice(0, 70)}`, async () => {
+    await expect(execute(id, [done()])).rejects.toThrow("无效 ID")
+  })
+}
+test("local confirmation preserves legacy route and approval body", async () => {
+  const actual = await execute(
+    "ag_research67",
+    [["confirm_required", { run_id: "run_probe", tool: "WriteFile", reason: "overwrite" }], done()],
+    false,
+  )
+  expect(actual.requests.find((r) => r.path.endsWith("/confirm"))).toEqual({
+    path: "/api/v1/traces/run_probe/confirm",
+    body: { approved: false },
+  })
+})
+test("remote confirmation uses owner-aware route and approval ID", async () => {
+  const actual = await execute("locostack:default:research67", [
+    ["confirm_required", { run_id: "run_probe", tool: "WriteFile", reason: "overwrite", approval_id: "approval_123" }],
+    done(),
+  ])
+  expect(actual.requests.find((r) => r.path.endsWith("/confirm"))).toEqual({
+    path: "/api/v1/agents/locostack%3Adefault%3Aresearch67/runs/run_probe/confirm",
+    body: { approved: true, approval_id: "approval_123" },
+  })
+})
+test("remote denial is preserved and never converted to approval", async () => {
+  const actual = await execute(
+    "locostack:default:research67",
+    [["confirm_required", { run_id: "run_probe", tool: "Shell", approval_id: "approval_123" }], done()],
+    false,
+  )
+  expect(actual.requests.find((r) => r.path.endsWith("/confirm"))?.body).toEqual({
+    approved: false,
+    approval_id: "approval_123",
+  })
+})
+test("remote confirmation HTTP failure never falls back or retries a mutation", async () => {
+  const error = await execute(
+    "locostack:default:research67",
+    [["confirm_required", { run_id: "run_probe", tool: "Shell", approval_id: "approval_123" }], done()],
+    true,
+    503,
+  ).catch((error) => error)
+  expect(error.message).toContain("确认请求失败: HTTP 503")
+  expect(error.requests.filter((request: { path: string }) => request.path.endsWith("/confirm"))).toHaveLength(1)
+})
+test("paused is not failed; checkpoint survives and progress stops", async () => {
+  const actual = await execute("ag_research67", [
+    start("experiment-design"),
+    ["paused", { run_id: "run_probe", checkpoint_id: "cp_123" }],
+    done("paused"),
+  ])
+  expect(actual.result.title).toContain("已暂停")
+  expect(actual.result.metadata.checkpoint_id).toBe("cp_123")
+  expect(rows(actual.result)).toEqual([expect.objectContaining({ id: "experiment-design", status: "paused" })])
+})
+test("durable human gate is visible without automatic approval or rerun", async () => {
+  const approval = { run_id: "run_probe", approval_id: "approval_123", prompt: "Review protocol", artifacts: [] }
+  const actual = await execute("ag_research67", [["approval_required", approval], done("waiting_human")])
+  expect(actual.result.title).toContain("等待确认")
+  expect(actual.result.metadata.pending_approval).toEqual(approval)
+  expect(actual.requests.filter((r) => r.path.endsWith("/confirm"))).toHaveLength(0)
+  expect(actual.requests.filter((r) => r.path.endsWith("/run/stream"))).toHaveLength(1)
+})
+test("starting a later stage never invents earlier completed stages", async () => {
+  const actual = await execute("ag_research67", [start("paper-writing"), done()])
+  expect(rows(actual.result)).toEqual([expect.objectContaining({ id: "paper-writing", status: "unknown" })])
+})
+test("three-stage completion never claims experiments ran", async () => {
+  const actual = await execute("ag_research67", [
+    start("idea-analysis"),
+    passed("idea-analysis"),
+    start("literature-review"),
+    passed("literature-review"),
+    start("paper-writing"),
+    passed("paper-writing"),
+    pipeline,
+    done(),
+  ])
+  expect(rows(actual.result).map(({ id, status }) => ({ id, status }))).toEqual([
+    { id: "idea-analysis", status: "completed" },
+    { id: "literature-review", status: "completed" },
+    { id: "paper-writing", status: "completed" },
+  ])
+})
+test("pipeline completion cannot erase a failed stage", async () => {
+  const actual = await execute("ag_research67", [
+    start("experiment-execution"),
+    ["stage_failed", { stage_id: "experiment-execution", error: "instrument unavailable" }],
+    pipeline,
+    done(),
+  ])
+  expect(rows(actual.result)).toEqual([expect.objectContaining({ id: "experiment-execution", status: "failed" })])
+})
+test("missing terminal stage event stays unverified at pipeline end", async () => {
+  const actual = await execute("ag_research67", [start("experiment-design"), pipeline, done()])
+  expect(rows(actual.result)).toEqual([expect.objectContaining({ id: "experiment-design", status: "unknown" })])
+})
+test("cancelled stage is distinct from failure", async () => {
+  const actual = await execute("ag_research67", [
+    start("experiment-design"),
+    ["cancelled", { run_id: "run_probe" }],
+    done("cancelled"),
+  ])
+  expect(rows(actual.result)).toEqual([expect.objectContaining({ id: "experiment-design", status: "cancelled" })])
+})
+test("transport EOF without done remains explicit interruption; never auto-rerun", async () => {
+  const error = await execute("ag_research67", [["heartbeat", {}]]).catch((error) => error)
+  expect(error.message).toContain("未收到 done 事件")
+  expect(error.requests.filter((request: { path: string }) => request.path.endsWith("/run/stream"))).toHaveLength(1)
+  expect(error.requests.some((request: { path: string }) => request.path.endsWith("/cancel"))).toBe(false)
+})

+ 3 - 3
packages/opencode/test/tool/agentpaas-run.test.ts

@@ -294,7 +294,7 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
         updates.find((update) => update.metadata?.latest_event === "tool_result" && update.metadata.step === step)
           ?.metadata?.research67_progress,
       ).toMatchObject({
-        title: "Research67 · paper-writer · 论文撰写 · 写作计划与大纲",
+        title: "Research67 · paper-writer · 论文写作 · 写作计划与大纲",
         rows: expect.arrayContaining([
           expect.objectContaining({ id: "paper-writing:outline", status: "running" }),
           expect.objectContaining({ id: "paper-writing:compile", status: "pending" }),
@@ -335,7 +335,7 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
           update.metadata.step === 7,
       )?.metadata?.research67_progress,
     ).toMatchObject({
-      title: "Research67 · paper-writer · 论文撰写 · 编译双语 PDF",
+      title: "Research67 · paper-writer · 论文写作 · 编译双语 PDF",
       rows: expect.arrayContaining([expect.objectContaining({ id: "paper-writing:compile", status: "running" })]),
     })
     expect(snapshots).toContainEqual(
@@ -552,7 +552,7 @@ test("preserves completed artifacts and forbids automatic reruns after failure",
       updates.find((update) => update.metadata?.latest_event === "tool_result" && update.metadata.step === "repair")
         ?.metadata?.research67_progress,
     ).toMatchObject({
-      title: "Research67 · paper-writer · 论文撰写 · 英文稿与参考文献",
+      title: "Research67 · paper-writer · 论文写作 · 英文稿与参考文献",
       footer: "论文源文件已更新,等待重新编译",
       rows: expect.arrayContaining([
         expect.objectContaining({ id: "paper-writing:english", status: "running" }),

+ 17 - 2
packages/tui/src/routes/session/index.tsx

@@ -2377,7 +2377,15 @@ function Task(props: ToolProps) {
   )
 }
 
-type Research67ProgressStatus = "completed" | "running" | "pending" | "failed"
+type Research67ProgressStatus =
+  | "completed"
+  | "running"
+  | "pending"
+  | "failed"
+  | "paused"
+  | "cancelled"
+  | "skipped"
+  | "unknown"
 type Research67Progress = {
   rows: Array<{ id: string; label: string; status: Research67ProgressStatus; detail?: string; depth: 0 | 1 }>
   activeSince?: number
@@ -2419,13 +2427,20 @@ export function formatResearch67Elapsed(activeSince: number, now: number) {
 }
 
 function isResearch67ProgressStatus(value: string | undefined): value is Research67ProgressStatus {
-  return value === "completed" || value === "running" || value === "pending" || value === "failed"
+  return (
+    typeof value === "string" &&
+    ["completed", "running", "pending", "failed", "paused", "cancelled", "skipped", "unknown"].includes(value)
+  )
 }
 
 export function research67ProgressIcon(status: Research67ProgressStatus) {
   if (status === "completed") return "✓"
   if (status === "running") return "●"
   if (status === "failed") return "✗"
+  if (status === "paused") return "Ⅱ"
+  if (status === "cancelled") return "⊘"
+  if (status === "skipped") return "−"
+  if (status === "unknown") return "?"
   return "○"
 }
 

+ 13 - 4
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -287,7 +287,7 @@ describe("TUI inline tool wrapping", () => {
         kind: "research67",
         rows: [
           { id: "done", label: "Done", status: "completed", depth: 0 },
-          { id: "bad", label: "Bad", status: "unknown", depth: 0 },
+          { id: "bad", label: "Bad", status: "invalid", depth: 0 },
           { id: "deep", label: "Deep", status: "pending", depth: 2 },
         ],
         activeSince: 1_000,
@@ -301,9 +301,18 @@ describe("TUI inline tool wrapping", () => {
     expect(formatResearch67Elapsed(1_000, 2_000)).toBe("1 秒")
     expect(formatResearch67Elapsed(1_000, 62_000)).toBe("1 分 1 秒")
     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"])
+    const statuses = ["completed", "running", "pending", "failed", "paused", "cancelled", "skipped", "unknown"] as const
+    expect(statuses.map(research67ProgressIcon)).toEqual(["✓", "●", "○", "✗", "Ⅱ", "⊘", "−", "?"])
+    expect(statuses.map(research67ProgressTone)).toEqual([
+      "success",
+      "warning",
+      "textMuted",
+      "error",
+      "textMuted",
+      "textMuted",
+      "textMuted",
+      "textMuted",
+    ])
   })
 
   test("ignores diagnostics with malformed nested ranges", () => {