Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions openspec/changes/add-mesh-swarm-mode/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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** 菜单中只有「蜂群模式(网状)」处于选中态
Expand Down
10 changes: 10 additions & 0 deletions openspec/changes/add-mesh-swarm-mode/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 未提交文件逐字节校验一致,未纳入本次提交。
50 changes: 50 additions & 0 deletions tests/agent/agent_loop_goal_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> 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("<goal_context>"), std::string::npos);
EXPECT_NE(request.find("<multi_agent_role>"), std::string::npos);
EXPECT_NE(request.find("You are `/root`"), std::string::npos);
std::unordered_set<std::string> 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;
Expand Down
14 changes: 12 additions & 2 deletions web/src/components/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
121 changes: 118 additions & 3 deletions web/src/lib/composerDraftCallbacks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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));
Expand Down Expand Up @@ -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 });
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 }]);
});
2 changes: 1 addition & 1 deletion web/src/lib/slashCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading