From a0601e1ce15e76f67cf5a08115fcc08625531ffa Mon Sep 17 00:00:00 2001 From: haozhi shao Date: Thu, 15 Oct 2026 11:27:53 +0800 Subject: [PATCH] Fix Goal submissions with swarm mode --- .../changes/add-mesh-swarm-mode/design.md | 10 ++ .../specs/swarm-mode-selection/spec.md | 11 ++ openspec/changes/add-mesh-swarm-mode/tasks.md | 10 ++ tests/agent/agent_loop_goal_test.cpp | 50 ++++++++ web/src/components/ChatView.jsx | 14 +- web/src/lib/composerDraftCallbacks.test.js | 121 +++++++++++++++++- web/src/lib/slashCommands.js | 2 +- 7 files changed, 212 insertions(+), 6 deletions(-) diff --git a/openspec/changes/add-mesh-swarm-mode/design.md b/openspec/changes/add-mesh-swarm-mode/design.md index 5c8952710..95f788186 100644 --- a/openspec/changes/add-mesh-swarm-mode/design.md +++ b/openspec/changes/add-mesh-swarm-mode/design.md @@ -145,6 +145,16 @@ Payload: ## 实现记录(与上文设计的差异,以代码为准) +### 2026-10-15:Goal 与蜂群同时提交 + +本机 Desktop 会话证据显示:Goal 已创建,但会话元数据缺少 `swarm_mode`,模型请求的 swarm 上下文为空。模型随后尝试 Computer Use 并遇到辅助功能权限错误;这不是蜂群服务报错。首页提交还保留了已发送的 Goal 文本。 + +根因是两条前端分支遗漏:内置命令仅发送 `route.command`,丢弃输入框的 `swarm_mode`;首页为带结构化内容/蜂群选择的提交保留输入,但只为普通消息登记成功清理回执。 + +复用现有 `/swarm` 命令,在内置命令执行前等待所选模式写入成功,再执行 `/goal` 等命令;失败即停止后续命令并保留草稿。显式 `/swarm` 命令自身优先,不预先执行另一条模式切换。首页与会话内统一经过 `sendInputOrBuiltin`,成功后首页内置命令也登记现有的会话、文本及结构化内容匹配回执,避免清掉后续编辑。HTTP 协议与 Goal/蜂群运行时不变;两条命令不构成事务,模式成功而 Goal 失败时保留已应用的模式,重试仍然安全。 + +补齐前端可执行命令白名单中的 `swarm`:服务端目录已有该命令,但前端原先将其当普通消息发给模型。验收覆盖网状/星型/关闭、无本地选择、切换失败、显式 `/swarm`、首页回执早于/晚于导航、失败保留、新编辑保护,并核对 Goal 自动续跑时的蜂群上下文与工具可见性。 + 实现时用户拍板「全按 Codex V2」,上文几处设计据此改动: - **身份字段只加两个**:`SessionMeta.swarm_mode` 与 `agent_path`。根会话 id 直接用 `parent_session_id`(所有网状子 agent 扁平挂在根下),深度与任务名从路径推出;不合成 legacy 路径。 diff --git a/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md b/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md index 04baaa6e8..c1cc3e2b2 100644 --- a/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md +++ b/openspec/changes/add-mesh-swarm-mode/specs/swarm-mode-selection/spec.md @@ -85,6 +85,17 @@ ### Requirement: 各表面提供模式入口 Web/Desktop 输入框的 `+` 菜单 SHALL 提供「蜂群模式(星型)」与「蜂群模式(网状)」两个互斥单选项,选中后输入区 SHALL 显示对应的可移除标签,选择 SHALL 随下一条消息提交并成为会话模式。TUI SHALL 提供 `/swarm` 显示当前模式,`/swarm star|mesh|off` 切换模式,无效参数 SHALL 报错并列出合法值。headless 打印模式 SHALL 提供 `--swarm star|mesh`,缺省为 `off`,无效值 SHALL 以 usage 错误退出。 +#### Scenario: Goal 与蜂群一起发送 +- **WHEN** 用户在首页或已有会话选择蜂群模式并提交 Goal +- **THEN** 前端 SHALL 在启动 Goal 之前确认所选蜂群模式已写入该会话 +- **AND** 模式切换失败时 SHALL 保留输入并且不提交 Goal;没有本地模式变更时 SHALL 沿用服务端模式 +- **AND** Goal 的自动续跑 SHALL 继续使用该会话的蜂群上下文与协作工具 + +#### Scenario: 首页 Goal 成功后消费草稿 +- **WHEN** 首页 Goal 命令提交成功且导航到新会话,无论回执与导航谁先完成 +- **THEN** SHALL 清空与本次提交匹配的输入文字与引用 +- **AND** SHALL 保留提交期间的新编辑与其它会话草稿,失败时 SHALL 保留原输入 + #### Scenario: 菜单单选 - **WHEN** 用户在 `+` 菜单中已选中「蜂群模式(星型)」,再点击「蜂群模式(网状)」 - **THEN** 菜单中只有「蜂群模式(网状)」处于选中态 diff --git a/openspec/changes/add-mesh-swarm-mode/tasks.md b/openspec/changes/add-mesh-swarm-mode/tasks.md index 2b404c9eb..10a0328d8 100644 --- a/openspec/changes/add-mesh-swarm-mode/tasks.md +++ b/openspec/changes/add-mesh-swarm-mode/tasks.md @@ -81,3 +81,13 @@ - [x] 10.3 运行定向测试、Web 全量测试、构建、OpenSpec 严格验证与差异检查,记录结果 验证记录:修复前定向用例复现同一回合 4 条已处理摘要(期望 1 条);修复后 `transcriptProjection.test.js`、`sessionTranscript.test.js` 通过。`pnpm test` 通过(2981 项),`pnpm build` 及正则兼容检查通过,`openspec validate add-mesh-swarm-mode --strict`、`git diff --check` 通过。Headless Edge 使用实际 TranscriptItems / Message / ActivityLine 组件验证 1280px 浅色折叠态与 720px 深色展开态:1 条已处理、3 条独立通知、全部通知正文和 4 条活动详情可展开,无横向溢出及脚本异常。未重新编译或替换已安装 Desktop。 + +## 11. Goal 与蜂群同时提交修复(2026-10-15) + +- [x] 11.1 统一首页与会话内的内置命令发送,先确认本地蜂群选择生效再执行 Goal;切换失败不得启动 Goal +- [x] 11.2 首页内置命令成功后清理匹配的已提交草稿,保留失败输入和提交期间的新编辑,补充真实发送/回执回归 +- [x] 11.3 验证 Goal 续跑的网状上下文及工具可见性,运行 Web 全量测试、构建、OpenSpec 严格验证与差异检查并记录结果 + +验证记录:新增生产回调回归在修复前复现首页 Goal 成功后输入仍为已提交文本;修复后发送顺序、mesh/star/off、显式 `/swarm`、失败保留及新编辑保护共 28 项通过。`pnpm test` 全量通过(3079 条 pass),`pnpm build` 与正则兼容检查通过。新增 C++ 用例 `AgentLoopGoal.MeshToolsAndContextRemainAvailableDuringGoalContinuation` 在 Mac 通过,验证实际 AgentLoop 的 Goal 首轮与续跑请求同时包含目标上下文、网状角色、六个协作工具与 `update_goal`,且隐藏星型/thread 工具。使用现有 CMake 目录定向编译测试对象及生产库,再按该目录原链接参数链接独立测试程序;未宣称原生全量单测通过。OpenSpec 严格验证和差异检查通过。未替换已安装 Desktop,未对用户会话发出测试消息,保留原有 Computer Use 工作区改动。 + +远端整合复验:本地基线从 `308224bb` 同步到 `e090be90`(v1.0.6,48 个提交),逐段核对唯一重叠文件 `ChatView.jsx`,保留远端快捷键、预览及会话相关更新。合并后 `pnpm test` 通过(3176 条 `[pass]`,另有 Node test runner 3 项通过),`pnpm build`、正则兼容检查、OpenSpec 严格验证、层级/头文件/文件大小/所有权/文档路径检查及差异检查通过;新增 Goal C++ 用例在合并后的源码上语法编译通过,未重跑整合后的原生运行时测试。原有 9 个 Computer Use 未提交文件逐字节校验一致,未纳入本次提交。 diff --git a/tests/agent/agent_loop_goal_test.cpp b/tests/agent/agent_loop_goal_test.cpp index 823e3014c..5d7604ce9 100644 --- a/tests/agent/agent_loop_goal_test.cpp +++ b/tests/agent/agent_loop_goal_test.cpp @@ -262,6 +262,56 @@ TEST(AgentLoopGoal, HiddenGoalContextOmitsDisabledAskUserQuestion) { EXPECT_EQ(continuation.find("AskUserQuestion"), std::string::npos); } +// 触发:Goal 在 mesh 会话启动并自动续跑。两者应同时提供目标上下文、网状身份 +// 与协作工具;不能因为隐藏 Goal 输入没有 swarm_mode metadata 而退回 off。 +TEST(AgentLoopGoal, MeshToolsAndContextRemainAvailableDuringGoalContinuation) { + AgentLoopGoalHarness h("mesh_goal_continuation"); + h.session_manager().set_swarm_mode("mesh"); + const std::vector mesh_names = { + "agent_spawn", "agent_list", "agent_send_message", + "agent_followup_task", "agent_wait", "agent_interrupt"}; + auto register_probe = [&h](const std::string& name) { + acecode::ToolImpl tool; + tool.definition.name = name; + tool.definition.description = "test collaboration tool"; + tool.definition.parameters = {{"type", "object"}}; + tool.is_read_only = true; + tool.execute = [](const std::string&, const acecode::ToolContext&) { + return acecode::ToolResult{"ok", true}; + }; + EXPECT_TRUE(h.tools().register_tool(std::move(tool))); + }; + for (const auto& name : mesh_names) register_probe(name); + for (const auto* name : {"spawn_subagent", "wait_subagent", "create_thread"}) register_probe(name); + h.create_goal(); + h.provider().push_text("progress; continue collaborating"); + h.provider().push_tool_call("agent_list", "{}", "mesh-goal-list"); + h.provider().push_tool_call("update_goal", R"({"status":"complete"})", "mesh-goal-done"); + h.provider().push_text("completed"); + + h.loop().maybe_continue_goal(); + ASSERT_TRUE(h.wait_until([&h] { + const auto goal = h.goal(); + return goal && goal->status == acecode::ThreadGoalStatus::Complete && !h.loop().is_busy(); + }, 10s)); + ASSERT_GE(h.provider().turn_count(), 4); + for (int turn = 0; turn < h.provider().turn_count(); ++turn) { + SCOPED_TRACE(turn); + std::string request; + for (const auto& message : h.provider().messages_for_turn(turn)) request += message.content; + EXPECT_NE(request.find(""), std::string::npos); + EXPECT_NE(request.find(""), std::string::npos); + EXPECT_NE(request.find("You are `/root`"), std::string::npos); + std::unordered_set tools; + for (const auto& tool : h.provider().tools_for_turn(turn)) tools.insert(tool.name); + for (const auto& name : mesh_names) EXPECT_EQ(tools.count(name), 1u) << name; + EXPECT_EQ(tools.count("update_goal"), 1u); + EXPECT_EQ(tools.count("spawn_subagent"), 0u); + EXPECT_EQ(tools.count("wait_subagent"), 0u); + EXPECT_EQ(tools.count("create_thread"), 0u); + } +} + TEST(AgentLoopGoal, DisabledGoalUpdateDoesNotLeakIntoBudgetPrompt) { AgentLoopGoalHarness h("goal_update_policy"); acecode::ToolCapabilityPolicy policy; diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 80a6b2329..4a5c43873 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -3457,6 +3457,16 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const hasExtras = payloadHasExtras(requestPayload); const route = inputRouteForPayload(requestPayload); if (!hasExtras && route.kind === 'builtin') { + // /goal 等命令不经过消息接口,必须先应用随输入提交的蜂群选择。 + // /swarm 同步写入会话模式;失败时不启动后续命令,输入保留供重试。 + // 显式 /swarm 的参数优先于输入框中的待提交选择。 + if (typeof requestPayload.swarm_mode === 'string' && route.command.command !== 'swarm') { + await executeBuiltinCommand(targetSid, { + command: 'swarm', + args: requestPayload.swarm_mode, + display_text: `/swarm ${requestPayload.swarm_mode}`, + }); + } return executeBuiltinCommand(targetSid, route.command); } const materialized = await materializeWorkspaceDraftPastes(targetSid, payload); @@ -3730,7 +3740,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses : pending); } if (isBuiltin) { - await executeBuiltinCommand(id, route.command); + await sendInputOrBuiltin(id, materializedPayload); } else if (explicitHomeSend) { applyEvent({ type: 'busy_changed', payload: { busy: true } }, { emitEffects: false }); const sendPayload = worktreeIntent @@ -3756,7 +3766,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } } if (historyText.trim()) recordInputHistory(historyText); - if (!isBuiltin && explicitHomeSend) { + if (isBuiltin || explicitHomeSend) { setAcceptedHomeSubmission({ sessionId: id, text: submittedComposerText, diff --git a/web/src/lib/composerDraftCallbacks.test.js b/web/src/lib/composerDraftCallbacks.test.js index 69250702a..0c2aea923 100644 --- a/web/src/lib/composerDraftCallbacks.test.js +++ b/web/src/lib/composerDraftCallbacks.test.js @@ -3,6 +3,7 @@ import fs from 'node:fs'; import vm from 'node:vm'; import { parseSync, traverse } from '@babel/core'; import { composerDraftEditFingerprint } from './composerDraft.js'; +import { inputRouteForPayload } from './builtinCommandRouting.js'; // Execute production receipt guards. A stale response must not modify the // current editor or its attachment registry, even when its plain text matches. @@ -11,11 +12,18 @@ const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { const callbacks = new Map(); traverse(ast, { VariableDeclarator({ node }) { + if (node.id.name === 'sendInputOrBuiltin') { + const callback = node.init.arguments[0]; + callbacks.set('send', source.slice(callback.start, callback.end)); + } if (node.id.name === 'clearCurrentSessionDraft') { const callback = node.init.arguments[0]; callbacks.set('clear', source.slice(callback.start, callback.end)); } }, + FunctionDeclaration({ node }) { + if (node.id.name === 'payloadHasExtras') callbacks.set('hasExtras', source.slice(node.start, node.end)); + }, CallExpression({ node }) { if (node.callee?.name === 'useEffect' && source.slice(node.start, node.end).includes('acceptedHomeSubmission')) { callbacks.set('homeCleanup', source.slice(node.arguments[0].start, node.arguments[0].end)); @@ -142,7 +150,7 @@ run('the actual turn-interruption receipt only clears extras after its matching assert.equal(test.state.extraClears, 1); }); -function homeFixture({ workspace = 'workspace', attachments = false, failSend = false } = {}) { +function homeFixture({ workspace = 'workspace', attachments = false, failSend = false, builtin = false } = {}) { const submittedContent = attachments ? content() : { version: 1, parts: [{ type: 'text', text: 'before after' }] }; const test = fixture({ activeKey: '', current: submittedContent }); @@ -158,10 +166,15 @@ function homeFixture({ workspace = 'workspace', attachments = false, failSend = submittedComposerContent: submittedContent, payloadWithAttachmentIds: (payload) => payload, setPendingNewSessionFirstUserMessage() {}, applyEvent() {}, - isBuiltin: false, explicitHomeSend: true, worktreeIntent: null, - hasExtras: attachments, hasSwarmMode: false, + isBuiltin: builtin, explicitHomeSend: !builtin, worktreeIntent: null, + hasExtras: attachments, hasSwarmMode: builtin, composerDirtyRef: { current: true }, sendInputOrBuiltin: async () => { + state.sends += 1; + if (failSend) throw new Error('send failed'); + }, + executeBuiltinCommand: async () => { + state.directCommands += 1; if (failSend) throw new Error('send failed'); }, submittedHomeDraftWorkspaceHash: workspace, @@ -178,6 +191,8 @@ function homeFixture({ workspace = 'workspace', attachments = false, failSend = }, }); state.homeAccepted = 0; + state.sends = 0; + state.directCommands = 0; const receipt = vm.runInContext(`(${callbacks.get('homeReceipt')})`, context); const render = () => { // Before the repair there is no replay effect: running the original receipt @@ -302,3 +317,103 @@ await runHome('a failed home send preserves the submitted text, resources, and h assert.equal(test.context.composerValueRef.current, 'before after'); assert.equal(test.state.homeAccepted, 0); }); + +// 触发:在首页同时选择 Goal 与网状蜂群后发送。旧分支保留输入却跳过 builtin 回执清理。 +for (const navigationFirst of [false, true]) { + await runHome(`home Goal with swarm clears its draft (navigationFirst=${navigationFirst})`, async () => { + const test = homeFixture({ builtin: true, workspace: '' }); + if (navigationFirst) test.activate(); + await test.accept(); + if (!navigationFirst) test.activate(); + assert.equal(test.context.composerValueRef.current, ''); + assert.equal(test.state.extraClears, 1); + assert.equal(test.state.sends, 1, 'home commands must use the shared sender'); + assert.equal(test.state.directCommands, 0); + assert.equal(test.state.homeAccepted, 1); + }); +} + +await runHome('home Goal failure retains the input and does not consume either draft', async () => { + const test = homeFixture({ builtin: true, failSend: true }); + await assert.rejects(test.accept(), /send failed/); + test.activate(); + assertUntouched(test); + assert.equal(test.state.homeAccepted, 0); +}); + +await runHome('a successful home Goal receipt preserves newer edits', async () => { + const test = homeFixture({ builtin: true }); + await test.accept(); + test.context.composerValueRef.current = 'next objective'; + test.activate(); + assertUntouched(test); +}); + +function senderFixture(execute) { + const sent = []; + const context = vm.createContext({ + inputRouteForPayload, + executeBuiltinCommand: execute, + materializeWorkspaceDraftPastes: async (_sid, payload) => payload, + api: { sendInput: async (sid, payload) => { sent.push({ sid, payload }); } }, + }); + vm.runInContext(callbacks.get('hasExtras'), context); + return { send: vm.runInContext(`(${callbacks.get('send')})`, context), sent }; +} + +// 触发:Goal 走命令接口,旧实现只发送 route.command,丢掉 payload.swarm_mode。 +for (const mode of ['mesh', 'star', 'off']) { + await runHome(`Goal waits for ${mode} selection before starting`, async () => { + const calls = []; + let acceptMode; + const modeReceipt = new Promise((resolve) => { acceptMode = resolve; }); + const test = senderFixture(async (sid, command) => { + calls.push({ sid, ...command }); + if (command.command === 'swarm') await modeReceipt; + }); + const pending = test.send('task-a', { text: '/goal test collaboration', swarm_mode: mode }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, 'swarm'); + assert.equal(calls[0].args, mode); + acceptMode(); + await pending; + assert.equal(calls.length, 2); + assert.equal(calls[1].sid, 'task-a'); + assert.equal(calls[1].command, 'goal'); + assert.equal(calls[1].args, 'test collaboration'); + assert.equal(test.sent.length, 0); + }); +} + +await runHome('failed swarm selection prevents Goal from starting', async () => { + const calls = []; + const test = senderFixture(async (_sid, command) => { + calls.push(command.command); + throw new Error('tree still running'); + }); + await assert.rejects(test.send('task-a', { text: '/goal test', swarm_mode: 'off' }), /tree still running/); + assert.deepEqual(calls, ['swarm']); +}); + +await runHome('Goal without a local swarm choice preserves the server mode', async () => { + const calls = []; + const test = senderFixture(async (_sid, command) => { calls.push(command.command); }); + await test.send('task-a', { text: '/goal test' }); + assert.deepEqual(calls, ['goal']); +}); + +await runHome('explicit swarm commands override the composer choice', async () => { + const calls = []; + const test = senderFixture(async (_sid, command) => { calls.push(command); }); + await test.send('task-a', { text: '/swarm off', swarm_mode: 'mesh' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, 'swarm'); + assert.equal(calls[0].args, 'off'); +}); + +await runHome('ordinary swarm messages still use the message endpoint', async () => { + const test = senderFixture(async () => { assert.fail('ordinary input must not execute commands'); }); + const payload = { text: 'test', swarm_mode: 'mesh' }; + await test.send('task-a', payload); + assert.deepEqual(test.sent, [{ sid: 'task-a', payload }]); +}); diff --git a/web/src/lib/slashCommands.js b/web/src/lib/slashCommands.js index f21ee4ac2..4bfdcdb97 100644 --- a/web/src/lib/slashCommands.js +++ b/web/src/lib/slashCommands.js @@ -321,7 +321,7 @@ export function resolveLeadingSlashCommand(text, commands = []) { export function parseExecutableBuiltinCommand(value) { const text = typeof value === 'string' ? value.trim() : ''; - const leading = parseLeadingCommand(text, ['init', 'compact', 'goal', 'plan', 'lsp', 'sandbox', 'memory', 'rc', 'remote-control']); + const leading = parseLeadingCommand(text, ['init', 'compact', 'goal', 'plan', 'lsp', 'sandbox', 'memory', 'swarm', 'rc', 'remote-control']); if (!leading.name) return null; return { name: leading.name,