@harness-mix/cli 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +469 -467
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/desktop-controller.mjs +1 -1
  7. package/output/native-build/renderer-extension.js +172 -23
  8. package/package.json +13 -9
  9. package/scripts/acp-image-test.cjs +69 -0
  10. package/scripts/adapters-test.cjs +25 -0
  11. package/scripts/antigravity-adapter-test.cjs +647 -626
  12. package/scripts/codex-accounts-test.cjs +34 -0
  13. package/scripts/codex-adapter-test.cjs +238 -127
  14. package/scripts/collaboration-test.cjs +577 -262
  15. package/scripts/collaboration-ui-smoke.cjs +25 -3
  16. package/scripts/e2e-delegate.cjs +1 -1
  17. package/scripts/e2e-hermes-image.cjs +60 -0
  18. package/scripts/jsonl-stdin-test.cjs +40 -31
  19. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  20. package/scripts/native-acp-depth-test.cjs +30 -5
  21. package/scripts/native-update-apply-test.cjs +269 -215
  22. package/scripts/native-update.cjs +78 -0
  23. package/scripts/native-vendor-adapters-test.cjs +196 -154
  24. package/scripts/openclaw-adapter-test.cjs +121 -3
  25. package/scripts/openclaw-mcp-probe.cjs +100 -0
  26. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  27. package/scripts/openclaw-thinking-probe.cjs +73 -0
  28. package/scripts/salvage-rollout-writes.cjs +72 -0
  29. package/scripts/storage-verification-test.cjs +11 -0
  30. package/scripts/zcode-adapter-test.cjs +329 -0
  31. package/scripts/zcode-live-probe.cjs +66 -0
  32. package/src/main/adapters/antigravity.js +1428 -1418
  33. package/src/main/adapters/claude.js +15 -7
  34. package/src/main/adapters/codex-app-server.js +29 -11
  35. package/src/main/adapters/codex.js +690 -649
  36. package/src/main/adapters/native-acp-command.js +51 -48
  37. package/src/main/adapters/native-acp.js +47 -12
  38. package/src/main/adapters/omp.js +7 -2
  39. package/src/main/adapters/openclaw.js +534 -343
  40. package/src/main/adapters/pi-family.js +35 -8
  41. package/src/main/adapters/qoder.js +12 -8
  42. package/src/main/adapters/zcode.js +925 -10
  43. package/src/main/host/collaboration-tools.js +1 -1
  44. package/src/main/host/collaboration.js +971 -714
  45. package/src/main/host/jsonl.js +130 -120
  46. package/src/main/host/runtime.js +9 -3
  47. package/src/main/host/verification-gates.js +14 -2
  48. package/src/main/native/codex-accounts.js +15 -4
  49. package/src/main/native/config.js +9 -9
  50. package/src/main/native/launcher.js +252 -237
  51. package/src/main/native/process-utils.js +157 -57
  52. package/src/main/native/protocol.js +1227 -1187
  53. package/src/main/native/update-state.js +123 -110
  54. package/src/main/native/updater.js +460 -394
  55. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  56. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  57. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  58. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  65. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  67. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
  68. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  69. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  70. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  71. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
  72. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
@@ -1,262 +1,577 @@
1
- const assert = require('node:assert/strict');
2
- const fs = require('node:fs/promises');
3
- const os = require('node:os');
4
- const path = require('node:path');
5
- const { HostRuntime } = require('../src/main/host/runtime');
6
- const { mentionedAgents, teamTaskDepths, teamPhase, teamProgress } = require('../src/main/host/collaboration');
7
- const { JsonlProcess } = require('../src/main/host/jsonl');
8
- const wait = async fn => { for (let i = 0; i < 300; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 10)); } throw new Error('Timed out'); };
9
-
10
- async function main() {
11
- const root = await fs.mkdtemp(path.resolve('output/collaboration-'));
12
- const rt = new HostRuntime({ dataDirectory: path.join(root, 'data') });
13
- await rt.store.load();
14
- let connection, teamConnection, active = 0, maximum = 0;
15
- const pending = new Map();
16
- let leadPrompt = '';
17
- const lead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
18
- async open(input) { connection = input.collaboration; return { emit: input.emit, collaborationEnabled: true }; },
19
- async send(s, text) { leadPrompt = text; }, async cancel() {}, async close() {} };
20
- const worker = { manifest: { id: 'worker', name: 'Worker', aliases: ['w'], capabilities: { collaborationTools: true } },
21
- async open(input) {
22
- if (input.collaboration) teamConnection = input.collaboration;
23
- else assert.equal(input.collaboration, undefined);
24
- return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration };
25
- },
26
- async send(s, text) { active++; maximum = Math.max(maximum, active); pending.set(s.id, { s, text }); },
27
- async cancel(s) { if (pending.delete(s.id)) active--; }, async close() {} };
28
- const reviewer = { ...worker, manifest: { id: 'reviewer', name: 'Reviewer', capabilities: { collaborationTools: true } } };
29
- rt.adapters.set('lead', lead); rt.status.lead = { available: true };
30
- rt.adapters.set('worker', worker); rt.status.worker = { available: true };
31
- rt.adapters.set('reviewer', reviewer); rt.status.reviewer = { available: true };
32
- const parent = await rt.createThread({ harnessId: 'lead', cwd: root });
33
- rt.collaboration.jobs.set('interrupted-fixture', { id: 'interrupted-fixture', owner: parent.id, agent: 'worker', childId: 'old-child', status: 'interrupted' });
34
- const ownerConnection = connection;
35
- const transport = new JsonlProcess(connection.command, connection.args, { env: { ...process.env, ...connection.env } }, {});
36
- const call = (name, args) => rt.collaboration.call(parent.id, name, args);
37
- const finish = (id, answer) => { const { s } = pending.get(id); pending.delete(id); active--; s.emit({ kind: 'text-delta', text: answer }); s.emit({ kind: 'completed', finalAnswer: true }); };
38
- try {
39
- assert.deepEqual(mentionedAgents('ask #w and [Worker](harness-mix://agent/worker) `#lead` issue#lead #worker/foo', rt), ['worker']);
40
- assert.deepEqual(mentionedAgents('leave @w to native Codex mentions', rt), []);
41
- assert.deepEqual(mentionedAgents('创建 Agent Team:Worker 负责开发,Reviewer 负责审查。', rt), ['worker', 'reviewer'], '纯文本 Harness 名称可在明确团队语境中授权');
42
- assert.deepEqual(mentionedAgents('讨论 Worker Reviewer 的界面显示', rt), [], '普通产品讨论不得误启动跨 Harness 协作');
43
- assert.deepEqual(mentionedAgents('组建团队,但不要从 `Worker` ```Reviewer``` 调度', rt), [], '代码区域中的名称不得作为授权');
44
- assert.deepEqual(mentionedAgents(' issue-worker 参与团队', rt), [], '较长标识符内的 Harness 子串不得误匹配');
45
- const depthFixture = [{ id: 'a', dependsOn: [] }, { id: 'b', dependsOn: ['a'] }, { id: 'c', dependsOn: ['b', 'missing'] }];
46
- assert.equal(teamTaskDepths(depthFixture).get('c'), 2, 'Depth follows the longest dependency chain and ignores unknown ids');
47
- assert.equal(teamTaskDepths(depthFixture).get('a'), 0);
48
- const cyclic = teamTaskDepths([{ id: 'x', dependsOn: ['y'] }, { id: 'y', dependsOn: ['x'] }]);
49
- assert.ok(Number.isFinite(cyclic.get('x')) && Number.isFinite(cyclic.get('y')), 'Cyclic graphs terminate');
50
- assert.equal(teamPhase({ tasks: [] }), 'forming');
51
- assert.equal(teamProgress([{ status: 'completed' }, { status: 'blocked' }]).percent, 50);
52
- await assert.rejects(call('list_agents', {}), /no longer active/);
53
- rt.history.context = async ({ nativeSessionId }) => { assert.equal(nativeSessionId, 'c2Vzc2lvbg'); return { harnessId: 'pi', title: 'Old session', cwd: root, transcript: 'User: prior question\nAssistant: prior answer' }; };
54
- await rt.send(parent.id, '#worker #reviewer review files #[Old session](harness-mix://session/c2Vzc2lvbg)');
55
- assert.ok(rt.execution.isRunning(parent.id));
56
- assert.match(leadPrompt, /untrusted historical data[\s\S]*prior answer/);
57
- assert.match(leadPrompt, /Recovery checkpoint:[\s\S]*interrupted-fixture \(worker\)[\s\S]*call list_delegations now/);
58
- assert.equal(rt.collaboration.jobs.get('interrupted-fixture').status, 'interrupted', 'Prompt injection never auto-resumes interrupted work');
59
- rt.collaboration.jobs.delete('interrupted-fixture');
60
- assert.ok(!JSON.stringify(rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id)).includes('[Harness Mix collaboration]'), 'Routing guidance stays out of displayed user text');
61
- const init = await transport.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } });
62
- assert.ok(init.capabilities.tools);
63
- const catalog = await transport.request('tools/list', {});
64
- assert.equal(catalog.tools.length, 15);
65
- assert.ok(catalog.tools.some(tool => tool.name === 'review_delegation_changes'));
66
- assert.ok(catalog.tools.some(tool => tool.name === 'apply_delegation_changes'));
67
- assert.ok(catalog.tools.some(tool => tool.name === 'create_agent_team'));
68
- assert.equal(catalog.tools.find(tool => tool.name === 'create_agent_team').inputSchema.properties.members.maxItems, 6, 'A Team supports six specialist members plus its Lead');
69
- await call('update_agent_plan', { steps: [{ text: 'Develop then review', status: 'in_progress' }] });
70
- assert.ok(rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).some(item => item.type === 'plan' && item.entries[0].text === 'Develop then review'));
71
- const first = JSON.parse((await transport.request('tools/call', { name: 'delegate_to_agent', arguments: { agent_type: 'worker', task: 'one' } })).content[0].text);
72
- const second = await call('delegate_to_agent', { agent_type: 'worker', task: 'two', isolation: 'shared' });
73
- await wait(() => pending.size === 2);
74
- assert.equal(maximum, 2, 'Workers execute concurrently');
75
- const jobs = [...rt.collaboration.jobs.values()];
76
- for (const job of jobs) {
77
- const child = rt.threads.find(t => t.id === job.childId);
78
- assert.equal(child.parentThreadId, parent.id);
79
- assert.equal(child.cwd, root, 'Default worker shares the lead workspace');
80
- const assistant = child.messages.find(m => m.role === 'assistant');
81
- assert.equal(assistant.reviewId, undefined);
82
- assert.equal(assistant.reviewOwnerThreadId, parent.id);
83
- }
84
- const waitingChild = rt.threads.find(t => t.id === jobs[0].childId);
85
- waitingChild && pending.get(waitingChild.id).s.emit({ kind: 'approval', requestId: 'native-approval', method: 'confirm', title: 'Allow test?' });
86
- assert.equal(rt.collaboration.view(jobs[0]).display_status, 'waiting_approval');
87
- await wait(() => rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).some(item => item.type === 'tool_call' && String(item.output).includes('waiting_approval')));
88
- pending.get(jobs[0].childId).s.emit({ kind: 'interaction-responded', requestId: 'native-approval' });
89
- finish(jobs[0].childId, 'first-result');
90
- await wait(() => jobs[0].status === 'completed');
91
- const before = Date.now();
92
- const partial = await call('get_delegation_status', { task_ids: [first.task_id, second.task_id], wait_ms: 3000 });
93
- assert.equal(partial[1].status, 'running');
94
- assert.ok(Date.now() - before < 1000, 'Fan-out collection returns when any result is ready');
95
- finish(jobs[1].childId, 'second-result');
96
- await wait(() => jobs[1].status === 'completed');
97
- const results = await call('get_delegation_status', { task_ids: [first.task_id, second.task_id], wait_ms: 3000 });
98
- assert.deepEqual(results.map(r => r.status), ['completed', 'completed']);
99
- assert.deepEqual(results.map(r => r.result), ['first-result', 'second-result']);
100
- const cards = rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).filter(item => item.collaboration);
101
- const { projectItem } = require('../src/main/native/protocol');
102
- const spawnCard = cards.find(item => item.collaboration.operation === 'spawnAgent' && item.collaboration.task_id === jobs[0].id);
103
- const workCard = cards.find(item => item.collaboration.operation === 'sendInput' && item.collaboration.task_id === jobs[0].id);
104
- assert.ok(spawnCard && workCard, 'Delegation projects separate spawn and execution cards');
105
- assert.equal(projectItem(spawnCard).type, 'collabAgentToolCall');
106
- assert.equal(projectItem(spawnCard).status, 'completed', 'Spawn card settles once the agent session exists (no stuck 创建中)');
107
- assert.deepEqual(projectItem(spawnCard).receiverThreadIds, [jobs[0].childId]);
108
- assert.equal(projectItem(workCard).status, 'completed');
109
- assert.equal(projectItem(workCard).agentsStates[jobs[0].childId].message, 'first-result');
110
- assert.ok(rt.execution.isRunning(parent.id), 'Tool results do not end the native lead turn');
111
- await call('message_agent', { task_id: first.task_id, task: 'follow-up' });
112
- await wait(() => pending.size === 1);
113
- finish(jobs[0].childId, 'follow-up-result');
114
- assert.equal((await call('get_delegation_status', { task_ids: [first.task_id], wait_ms: 3000 }))[0].result, 'follow-up-result');
115
- const team = await call('create_agent_team', { name: 'Release team', goal: 'Ship a verified change', members: [{ name: 'Builder', role: 'Implement and coordinate', agent_type: 'worker' }, { name: 'Reviewer', role: 'Independently verify', agent_type: 'reviewer' }, { name: 'Frontend', role: 'Integrate the native UI', agent_type: 'worker' }, { name: 'QA', role: 'Run the regression matrix', agent_type: 'reviewer' }, { name: 'Docs', role: 'Document the delivery', agent_type: 'worker' }, { name: 'Release', role: 'Verify Git and final Diff', agent_type: 'reviewer' }] });
116
- assert.equal(team.lead.agent, 'lead');
117
- assert.match(team.lead.role, /Lead/);
118
- assert.equal(team.members.length, 6);
119
- const teamTask = (await call('assign_team_task', { team_id: team.team_id, title: 'Build', description: 'Implement the change', assignee: team.members[0].id })).task;
120
- const dependentAssign = await call('assign_team_task', { team_id: team.team_id, title: 'Verify', description: 'Verify the result', assignee: team.members[1].id, depends_on: [teamTask.id] });
121
- const dependent = dependentAssign.task;
122
- assert.equal(dependent.status, 'blocked');
123
- assert.equal(dependentAssign.team.phase, 'waiting', 'Assigned work with nothing in flight reads as waiting');
124
- assert.deepEqual(dependentAssign.team.progress, { total: 2, pending: 1, blocked: 1, in_progress: 0, completed: 0, failed: 0, interrupted: 0, percent: 0 });
125
- assert.equal(dependentAssign.team.tasks.find(entry => entry.id === dependent.id).depth, 1, 'Dependent task sits one dependency lane deep');
126
- assert.equal(dependentAssign.team.tasks.find(entry => entry.id === teamTask.id).depth, 0);
127
- const teamJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'Implement as Builder', team_id: team.team_id, member_id: team.members[0].id, team_task_id: teamTask.id, isolation: 'shared' });
128
- await wait(() => pending.size === 1 && teamConnection);
129
- const teammateTransport = new JsonlProcess(teamConnection.command, teamConnection.args, { env: { ...process.env, ...teamConnection.env } }, {});
130
- await teammateTransport.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'teammate-test', version: '1' } });
131
- const teammateState = JSON.parse((await teammateTransport.request('tools/call', { name: 'get_team_state', arguments: { team_id: team.team_id } })).content[0].text);
132
- assert.equal(teammateState.members[0].display_status, 'working');
133
- assert.equal(teammateState.phase, 'running', 'In-flight work reads as running');
134
- assert.equal(teammateState.progress.in_progress, 1);
135
- await teammateTransport.request('tools/call', { name: 'send_team_message', arguments: { team_id: team.team_id, to: 'lead', task_id: teamTask.id, kind: 'review-request', message: 'Implementation is ready for verification.' } });
136
- await assert.rejects(rt.collaboration.call(parent.id, 'send_team_message', { team_id: team.team_id, to: 'lead', message: 'bad kind', kind: 'bogus' }), /invalid_value|Invalid/, 'Message kind is a closed enum');
137
- const teamChild = rt.collaboration.jobs.get(teamJob.task_id).childId;
138
- await assert.rejects(rt.collaboration.call(teamChild, 'assign_team_task', { team_id: team.team_id, title: 'Escalate', description: 'Must be lead-owned', assignee: team.members[0].id }), /Only the Team Lead/);
139
- await assert.rejects(rt.collaboration.call(teamChild, 'delegate_to_agent', { agent_type: 'worker', task: 'recursive' }), /Only lead/);
140
- finish(teamChild, 'team-result');
141
- await wait(() => rt.collaboration.jobs.get(teamJob.task_id).status === 'completed');
142
- const settledTeam = await call('get_team_state', { team_id: team.team_id });
143
- assert.equal(settledTeam.tasks.find(entry => entry.id === teamTask.id).status, 'completed');
144
- assert.equal(settledTeam.tasks.find(entry => entry.id === dependent.id).status, 'pending');
145
- assert.equal(settledTeam.messages[0].fromName, 'Builder');
146
- assert.equal(settledTeam.messages[0].kind, 'review-request', 'Mailbox messages keep their typed kind');
147
- assert.equal(settledTeam.phase, 'waiting', 'Build settled but dependent review is not delegated yet');
148
- assert.equal(settledTeam.progress.completed, 1);
149
- assert.equal(settledTeam.members[0].child_thread_id ?? settledTeam.members[0].childId, teamChild);
150
- const verifyJob = await call('delegate_to_agent', { agent_type: 'reviewer', task: 'Verify independently as Reviewer', team_id: team.team_id, member_id: team.members[1].id, team_task_id: dependent.id, isolation: 'shared' });
151
- await wait(() => rt.collaboration.jobs.get(verifyJob.task_id).childId && pending.has(rt.collaboration.jobs.get(verifyJob.task_id).childId));
152
- const reviewerChild = rt.collaboration.jobs.get(verifyJob.task_id).childId;
153
- assert.notEqual(reviewerChild, teamChild, 'Different Harness teammates own different native sessions');
154
- finish(reviewerChild, 'verification-result');
155
- await wait(() => rt.collaboration.jobs.get(verifyJob.task_id).status === 'completed');
156
- const polish = (await call('assign_team_task', { team_id: team.team_id, title: 'Polish', description: 'Address final details', assignee: team.members[0].id, depends_on: [dependent.id] })).task;
157
- const polishJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'Polish in the persistent Builder session', team_id: team.team_id, member_id: team.members[0].id, team_task_id: polish.id, isolation: 'worktree' });
158
- assert.equal(polishJob.child_thread_id, teamChild, 'A teammate keeps one native session across team tasks');
159
- assert.equal(polishJob.workspace.cwd, rt.collaboration.jobs.get(teamJob.task_id).workspace.cwd, 'A teammate keeps its existing workspace across team tasks');
160
- await wait(() => pending.has(teamChild));
161
- finish(teamChild, 'polish-result');
162
- await wait(() => rt.collaboration.jobs.get(polishJob.task_id).status === 'completed');
163
- const inspectedTeam = await rt.collaboration.inspectTeam(parent.id, team.team_id);
164
- assert.equal(inspectedTeam.team.team_id, team.team_id);
165
- assert.equal(inspectedTeam.team.phase, 'completed', 'All team tasks settled');
166
- assert.equal(inspectedTeam.team.progress.percent, 100);
167
- assert.ok(inspectedTeam.snapshots.length >= 8, 'Team lifecycle is preserved as replayable snapshots');
168
- assert.ok(inspectedTeam.snapshots.some(entry => entry.action === 'member_session_ready'));
169
- assert.ok(inspectedTeam.snapshots.some(entry => entry.action === 'task_settled'));
170
- assert.equal((await rt.collaboration.inspectTeam(teamChild, team.team_id)).team.team_id, team.team_id, 'Members may inspect their own shared team state');
171
- const { NativeProtocol } = require('../src/main/native/protocol');
172
- const protocolTeam = await new NativeProtocol(rt, () => {}).request('harnessmix/thread/team/inspect', { threadId: parent.id, teamId: team.team_id });
173
- assert.equal(protocolTeam.snapshots.at(-1).team.team_id, team.team_id, 'Renderer protocol exposes the persisted Team timeline');
174
- await rt.collaboration.saveTeams();
175
- const persistedTeams = JSON.parse(await fs.readFile(path.join(root, 'data', 'collaboration', 'teams.json'), 'utf8'));
176
- assert.equal(persistedTeams[0].id, team.team_id);
177
- assert.equal(persistedTeams[0].history.length, inspectedTeam.snapshots.length);
178
- teammateTransport.stop();
179
- await assert.rejects(call('get_delegation_status', { task_ids: ['foreign-task'] }), /Unknown task/);
180
- await assert.rejects(rt.collaboration.call(jobs[0].childId, 'delegate_to_agent', { agent_type: 'worker', task: 'recursive' }), /Only lead/);
181
- await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'x', unexpected: true }));
182
- const savedMentions = parent.activeMentions;
183
- delete parent.activeMentions;
184
- await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'unauthorized' }), /用户本轮未显式委派/);
185
- parent.activeMentions = savedMentions;
186
- const denied = await fetch(ownerConnection.env.HARNESS_MIX_COLLAB_URL, { method: 'POST', headers: { Authorization: 'Bearer invalid' }, body: '{}' });
187
- assert.equal(denied.status, 403);
188
- const outsider = await rt.createThread({ harnessId: 'worker', cwd: root });
189
- await assert.rejects(rt.collaboration.inspectTeam(outsider.id, team.team_id), /not a team participant/);
190
- const noTeam = await rt.collaboration.inspectTeam(outsider.id);
191
- assert.equal(noTeam.team, null, 'Inspecting a non-team thread without a teamId answers benignly');
192
- assert.deepEqual(noTeam.snapshots, []);
193
- await rt.send(outsider.id, 'unrelated');
194
- assert.equal(outsider.messages.at(-1).concurrent, true, 'Independent same-workspace turns are marked concurrent');
195
- await rt.cancel(outsider.id); await wait(() => !pending.has(outsider.id));
196
- for (let i = 0; i < 6; i++) await call('delegate_to_agent', { agent_type: 'worker', task: 'cancel me', isolation: 'shared' });
197
- await wait(() => pending.size === 6);
198
- await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'too many' }), /six concurrent/);
199
- await rt.cancel(parent.id);
200
- await wait(() => !pending.size);
201
- assert.equal(rt.execution.lastTurn(parent.id).status, 'cancelled');
202
- await assert.rejects(call('list_agents', {}), /no longer active/);
203
-
204
- // Settings Collaboration 开关:默认开启;关闭后服务端硬拒绝,并持久化到磁盘
205
- const prefsRoot = await fs.mkdtemp(path.resolve('output/collaboration-prefs-'));
206
- const prefsRt = new HostRuntime({ dataDirectory: path.join(prefsRoot, 'data') });
207
- try {
208
- await prefsRt.collaboration.initialize();
209
- assert.deepEqual(prefsRt.collaboration.getPreferences(), { collaboration: true, agentTeam: true }, 'Both collaboration switches default to on');
210
- await prefsRt.collaboration.setPreferences({ collaboration: false });
211
- await assert.rejects(prefsRt.collaboration.call('any-thread', 'list_agents', {}), /停用.*disabled/, 'Collaboration tools are hard-rejected while the switch is off');
212
- await prefsRt.collaboration.setPreferences({ collaboration: true, agentTeam: false });
213
- await assert.rejects(
214
- prefsRt.collaboration.call('any-thread', 'create_agent_team', { name: 't', goal: 'g', members: [{ name: 'A', role: 'r', agent_type: 'worker' }] }),
215
- /Agent Team 已在设置中停用/,
216
- 'create_agent_team is rejected while the Agent Team switch is off, before any turn-state check',
217
- );
218
- const reloaded = new HostRuntime({ dataDirectory: path.join(prefsRoot, 'data') });
219
- await reloaded.collaboration.initialize();
220
- assert.deepEqual(reloaded.collaboration.getPreferences(), { collaboration: true, agentTeam: false }, 'Preferences survive a Host restart');
221
- await reloaded.close();
222
- const onAgain = await prefsRt.collaboration.setPreferences({ agentTeam: true });
223
- assert.deepEqual(onAgain, { collaboration: true, agentTeam: true });
224
- } finally { await prefsRt.close(); }
225
-
226
- // 同目录外部并发(另一个独立会话正在运行)→ 用户收到 toast 警告,
227
- // 且新委派的 worker 默认升级为隔离模式(auto);非 Git 目录下 auto 回落 shared。
228
- // 用系统临时目录:output/ 位于本仓库内,auto 在仓库内会真实创建 worktree。
229
- const isoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harness-mix-iso-'));
230
- const isoRt = new HostRuntime({ dataDirectory: path.join(isoRoot, 'data') });
231
- try {
232
- await isoRt.store.load();
233
- const isoToasts = [];
234
- isoRt.subscribe(event => { if (event.type === 'toast') isoToasts.push(event); });
235
- const isoPending = new Map();
236
- const isoLead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
237
- async open(input) { return { emit: input.emit, collaborationEnabled: true }; },
238
- async send() {}, async cancel() {}, async close() {} };
239
- const isoWorker = { manifest: { id: 'worker', name: 'Worker', capabilities: { collaborationTools: true } },
240
- async open(input) { return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration }; },
241
- async send(s) { isoPending.set(s.id, s); }, async cancel() {}, async close() {} };
242
- isoRt.adapters.set('lead', isoLead); isoRt.status.lead = { available: true };
243
- isoRt.adapters.set('worker', isoWorker); isoRt.status.worker = { available: true };
244
- const isoParent = await isoRt.createThread({ harnessId: 'lead', cwd: isoRoot });
245
- await isoRt.send(isoParent.id, '#worker build something');
246
- const isoOutsider = await isoRt.createThread({ harnessId: 'worker', cwd: isoRoot });
247
- await isoRt.send(isoOutsider.id, 'other session');
248
- await wait(() => isoPending.has(isoOutsider.id));
249
- assert.equal(isoOutsider.messages.at(-1).concurrent, true, 'Concurrent turn is still marked');
250
- assert.ok(isoToasts.some(t => t.threadId === isoOutsider.id && /同一目录/.test(t.text)), 'Concurrent same-directory sessions warn the user');
251
- const isoJob = await isoRt.collaboration.call(isoParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'auto-isolated under concurrency' });
252
- assert.equal(isoRt.collaboration.jobs.get(isoJob.task_id).isolation, 'auto', 'A new worker defaults to isolated mode while another session runs in the same directory');
253
- await wait(() => isoRt.collaboration.jobs.get(isoJob.task_id).workspace);
254
- assert.equal(isoRt.collaboration.jobs.get(isoJob.task_id).workspace.mode, 'shared', 'Auto falls back to shared outside Git projects');
255
- await isoRt.cancel(isoOutsider.id);
256
- const quietJob = await isoRt.collaboration.call(isoParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'shared again once quiet' });
257
- assert.equal(isoRt.collaboration.jobs.get(quietJob.task_id).isolation, 'shared', 'Default returns to shared once the directory is quiet');
258
- } finally { await isoRt.close(); }
259
- console.log('PASS: real MCP stdio → authenticated Host → parallel native-session adapters → results/follow-up/cancellation, ownership and shared review');
260
- } finally { transport.stop(); await rt.close(); }
261
- }
262
- main().catch(error => { console.error(error); process.exitCode = 1; });
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { HostRuntime } = require('../src/main/host/runtime');
6
+ const { mentionedAgents, teamTaskDepths, teamPhase, teamProgress } = require('../src/main/host/collaboration');
7
+ const { createWorkspace, reviewWorkspace, git } = require('../src/main/host/collaboration-worktree');
8
+ const { JsonlProcess } = require('../src/main/host/jsonl');
9
+ const wait = async fn => { for (let i = 0; i < 600; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 10)); } throw new Error('Timed out'); };
10
+
11
+ async function main() {
12
+ const root = await fs.mkdtemp(path.resolve('output/collaboration-'));
13
+ const rt = new HostRuntime({ dataDirectory: path.join(root, 'data') });
14
+ await rt.store.load();
15
+ let connection, teamConnection, active = 0, maximum = 0;
16
+ const pending = new Map();
17
+ let leadPrompt = '';
18
+ const lead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
19
+ async open(input) { connection = input.collaboration; return { emit: input.emit, collaborationEnabled: true }; },
20
+ async send(s, text) { leadPrompt = text; }, async cancel() {}, async close() {} };
21
+ const worker = { manifest: { id: 'worker', name: 'Worker', aliases: ['w'], capabilities: { collaborationTools: true } },
22
+ async open(input) {
23
+ if (input.collaboration) teamConnection = input.collaboration;
24
+ else assert.equal(input.collaboration, undefined);
25
+ return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration };
26
+ },
27
+ async send(s, text) { active++; maximum = Math.max(maximum, active); pending.set(s.id, { s, text }); },
28
+ async cancel(s) { if (pending.delete(s.id)) active--; }, async close() {} };
29
+ const reviewer = { ...worker, manifest: { id: 'reviewer', name: 'Reviewer', capabilities: { collaborationTools: true } } };
30
+ rt.adapters.set('lead', lead); rt.status.lead = { available: true };
31
+ rt.adapters.set('worker', worker); rt.status.worker = { available: true };
32
+ rt.adapters.set('reviewer', reviewer); rt.status.reviewer = { available: true };
33
+ const parent = await rt.createThread({ harnessId: 'lead', cwd: root });
34
+ rt.collaboration.jobs.set('interrupted-fixture', { id: 'interrupted-fixture', owner: parent.id, agent: 'worker', childId: 'old-child', status: 'interrupted' });
35
+ const ownerConnection = connection;
36
+ const transport = new JsonlProcess(connection.command, connection.args, { env: { ...process.env, ...connection.env } }, {});
37
+ const call = (name, args) => rt.collaboration.call(parent.id, name, args);
38
+ const finish = (id, answer) => { const { s } = pending.get(id); pending.delete(id); active--; s.emit({ kind: 'text-delta', text: answer }); s.emit({ kind: 'completed', finalAnswer: true }); };
39
+ try {
40
+ assert.deepEqual(mentionedAgents('ask #w and [Worker](harness-mix://agent/worker) `#lead` issue#lead #worker/foo', rt), ['worker']);
41
+ assert.deepEqual(mentionedAgents('leave @w to native Codex mentions', rt), []);
42
+ assert.deepEqual(mentionedAgents('创建 Agent Team:Worker 负责开发,Reviewer 负责审查。', rt), ['worker', 'reviewer'], '纯文本 Harness 名称可在明确团队语境中授权');
43
+ assert.deepEqual(mentionedAgents('讨论 Worker Reviewer 的界面显示', rt), [], '普通产品讨论不得误启动跨 Harness 协作');
44
+ assert.deepEqual(mentionedAgents('组建团队,但不要从 `Worker` 或 ```Reviewer``` 调度', rt), [], '代码区域中的名称不得作为授权');
45
+ assert.deepEqual(mentionedAgents('让 issue-worker 参与团队', rt), [], '较长标识符内的 Harness 子串不得误匹配');
46
+ const depthFixture = [{ id: 'a', dependsOn: [] }, { id: 'b', dependsOn: ['a'] }, { id: 'c', dependsOn: ['b', 'missing'] }];
47
+ assert.equal(teamTaskDepths(depthFixture).get('c'), 2, 'Depth follows the longest dependency chain and ignores unknown ids');
48
+ assert.equal(teamTaskDepths(depthFixture).get('a'), 0);
49
+ const cyclic = teamTaskDepths([{ id: 'x', dependsOn: ['y'] }, { id: 'y', dependsOn: ['x'] }]);
50
+ assert.ok(Number.isFinite(cyclic.get('x')) && Number.isFinite(cyclic.get('y')), 'Cyclic graphs terminate');
51
+ assert.equal(teamPhase({ tasks: [] }), 'forming');
52
+ assert.equal(teamProgress([{ status: 'completed' }, { status: 'blocked' }]).percent, 50);
53
+ await assert.rejects(call('list_agents', {}), /no longer active/);
54
+ rt.history.context = async ({ nativeSessionId }) => { assert.equal(nativeSessionId, 'c2Vzc2lvbg'); return { harnessId: 'pi', title: 'Old session', cwd: root, transcript: 'User: prior question\nAssistant: prior answer' }; };
55
+ await rt.send(parent.id, '#worker #reviewer review files #[Old session](harness-mix://session/c2Vzc2lvbg)');
56
+ assert.ok(rt.execution.isRunning(parent.id));
57
+ assert.match(leadPrompt, /untrusted historical data[\s\S]*prior answer/);
58
+ assert.match(leadPrompt, /Recovery checkpoint:[\s\S]*interrupted-fixture \(worker\)[\s\S]*call list_delegations now/);
59
+ assert.equal(rt.collaboration.jobs.get('interrupted-fixture').status, 'interrupted', 'Prompt injection never auto-resumes interrupted work');
60
+ rt.collaboration.jobs.delete('interrupted-fixture');
61
+ assert.ok(!JSON.stringify(rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id)).includes('[Harness Mix collaboration]'), 'Routing guidance stays out of displayed user text');
62
+ const init = await transport.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } });
63
+ assert.ok(init.capabilities.tools);
64
+ const catalog = await transport.request('tools/list', {});
65
+ assert.equal(catalog.tools.length, 15);
66
+ assert.ok(catalog.tools.some(tool => tool.name === 'review_delegation_changes'));
67
+ assert.ok(catalog.tools.some(tool => tool.name === 'apply_delegation_changes'));
68
+ assert.ok(catalog.tools.some(tool => tool.name === 'create_agent_team'));
69
+ assert.equal(catalog.tools.find(tool => tool.name === 'create_agent_team').inputSchema.properties.members.maxItems, 6, 'A Team supports six specialist members plus its Lead');
70
+ await call('update_agent_plan', { steps: [{ text: 'Develop then review', status: 'in_progress' }] });
71
+ assert.ok(rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).some(item => item.type === 'plan' && item.entries[0].text === 'Develop then review'));
72
+ const first = JSON.parse((await transport.request('tools/call', { name: 'delegate_to_agent', arguments: { agent_type: 'worker', task: 'one' } })).content[0].text);
73
+ const second = await call('delegate_to_agent', { agent_type: 'worker', task: 'two', isolation: 'shared' });
74
+ await wait(() => pending.size === 2);
75
+ assert.equal(maximum, 2, 'Workers execute concurrently');
76
+ const jobs = [...rt.collaboration.jobs.values()];
77
+ for (const job of jobs) {
78
+ const child = rt.threads.find(t => t.id === job.childId);
79
+ assert.equal(child.parentThreadId, parent.id);
80
+ assert.equal(child.cwd, root, 'Default worker shares the lead workspace');
81
+ const assistant = child.messages.find(m => m.role === 'assistant');
82
+ assert.equal(assistant.reviewId, undefined);
83
+ assert.equal(assistant.reviewOwnerThreadId, parent.id);
84
+ }
85
+ const waitingChild = rt.threads.find(t => t.id === jobs[0].childId);
86
+ waitingChild && pending.get(waitingChild.id).s.emit({ kind: 'approval', requestId: 'native-approval', method: 'confirm', title: 'Allow test?' });
87
+ assert.equal(rt.collaboration.view(jobs[0]).display_status, 'waiting_approval');
88
+ await wait(() => rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).some(item => item.type === 'tool_call' && String(item.output).includes('waiting_approval')));
89
+ pending.get(jobs[0].childId).s.emit({ kind: 'interaction-responded', requestId: 'native-approval' });
90
+ finish(jobs[0].childId, 'first-result');
91
+ await wait(() => jobs[0].status === 'completed');
92
+ const before = Date.now();
93
+ const partial = await call('get_delegation_status', { task_ids: [first.task_id, second.task_id], wait_ms: 3000 });
94
+ assert.equal(partial[1].status, 'running');
95
+ assert.ok(Date.now() - before < 1000, 'Fan-out collection returns when any result is ready');
96
+ finish(jobs[1].childId, 'second-result');
97
+ await wait(() => jobs[1].status === 'completed');
98
+ const results = await call('get_delegation_status', { task_ids: [first.task_id, second.task_id], wait_ms: 3000 });
99
+ assert.deepEqual(results.map(r => r.status), ['completed', 'completed']);
100
+ assert.deepEqual(results.map(r => r.result), ['first-result', 'second-result']);
101
+ const cards = rt.core.getItemsForTurn(rt.execution.lastTurn(parent.id).id).filter(item => item.collaboration);
102
+ const { projectItem } = require('../src/main/native/protocol');
103
+ const spawnCard = cards.find(item => item.collaboration.operation === 'spawnAgent' && item.collaboration.task_id === jobs[0].id);
104
+ const workCard = cards.find(item => item.collaboration.operation === 'sendInput' && item.collaboration.task_id === jobs[0].id);
105
+ assert.ok(spawnCard && workCard, 'Delegation projects separate spawn and execution cards');
106
+ assert.equal(projectItem(spawnCard).type, 'collabAgentToolCall');
107
+ assert.equal(projectItem(spawnCard).status, 'completed', 'Spawn card settles once the agent session exists (no stuck 创建中)');
108
+ assert.deepEqual(projectItem(spawnCard).receiverThreadIds, [jobs[0].childId]);
109
+ assert.equal(projectItem(workCard).status, 'completed');
110
+ assert.equal(projectItem(workCard).agentsStates[jobs[0].childId].message, 'first-result');
111
+ assert.ok(rt.execution.isRunning(parent.id), 'Tool results do not end the native lead turn');
112
+ await call('message_agent', { task_id: first.task_id, task: 'follow-up' });
113
+ await wait(() => pending.size === 1);
114
+ finish(jobs[0].childId, 'follow-up-result');
115
+ assert.equal((await call('get_delegation_status', { task_ids: [first.task_id], wait_ms: 3000 }))[0].result, 'follow-up-result');
116
+ // 回归:apply 的同目录并发守卫不得把正在等待 MCP 工具结果的 lead 回合自身计为并发
117
+ // (此前条件恒真,apply_delegation_changes 不可能成功);第三方会话占用同目录时仍必须拦截。
118
+ rt.collaboration.jobs.set('apply-guard-job', { id: 'apply-guard-job', owner: parent.id, agent: 'worker', status: 'completed',
119
+ workspace: { mode: 'worktree', cwd: path.join(root, 'guard-wt'), root: path.join(root, 'guard-wt'), source: root, branch: 'guard', baseTree: 't', baseCommit: 'c' } });
120
+ await assert.rejects(rt.collaboration.apply('apply-guard-job', '0'.repeat(64)),
121
+ error => !/结算后再应用/.test(error.message), '运行中的 lead 自身不得触发同目录并发守卫');
122
+ const guardIntruder = await rt.createThread({ harnessId: 'worker', cwd: root });
123
+ const intruderTurn = rt.send(guardIntruder.id, '占用目录');
124
+ await wait(() => rt.execution.isRunning(guardIntruder.id));
125
+ await assert.rejects(rt.collaboration.apply('apply-guard-job', '0'.repeat(64)), /结算后再应用/, '第三方同目录运行会话仍被守卫拦截');
126
+ await rt.cancel(guardIntruder.id);
127
+ await intruderTurn;
128
+ const team = await call('create_agent_team', { name: 'Release team', goal: 'Ship a verified change', members: [{ name: 'Builder', role: 'Implement and coordinate', agent_type: 'worker' }, { name: 'Reviewer', role: 'Independently verify', agent_type: 'reviewer' }, { name: 'Frontend', role: 'Integrate the native UI', agent_type: 'worker' }, { name: 'QA', role: 'Run the regression matrix', agent_type: 'reviewer' }, { name: 'Docs', role: 'Document the delivery', agent_type: 'worker' }, { name: 'Release', role: 'Verify Git and final Diff', agent_type: 'reviewer' }] });
129
+ assert.equal(team.lead.agent, 'lead');
130
+ assert.match(team.lead.role, /Lead/);
131
+ assert.equal(team.members.length, 6);
132
+ const teamTask = (await call('assign_team_task', { team_id: team.team_id, title: 'Build', description: 'Implement the change', assignee: team.members[0].id })).task;
133
+ const dependentAssign = await call('assign_team_task', { team_id: team.team_id, title: 'Verify', description: 'Verify the result', assignee: team.members[1].id, depends_on: [teamTask.id] });
134
+ const dependent = dependentAssign.task;
135
+ assert.equal(dependent.status, 'blocked');
136
+ assert.equal(dependentAssign.team.phase, 'waiting', 'Assigned work with nothing in flight reads as waiting');
137
+ assert.deepEqual(dependentAssign.team.progress, { total: 2, pending: 1, blocked: 1, in_progress: 0, completed: 0, failed: 0, interrupted: 0, percent: 0 });
138
+ assert.equal(dependentAssign.team.tasks.find(entry => entry.id === dependent.id).depth, 1, 'Dependent task sits one dependency lane deep');
139
+ assert.equal(dependentAssign.team.tasks.find(entry => entry.id === teamTask.id).depth, 0);
140
+ const teamJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'Implement as Builder', team_id: team.team_id, member_id: team.members[0].id, team_task_id: teamTask.id, isolation: 'shared' });
141
+ await wait(() => pending.size === 1 && teamConnection);
142
+ const teammateTransport = new JsonlProcess(teamConnection.command, teamConnection.args, { env: { ...process.env, ...teamConnection.env } }, {});
143
+ await teammateTransport.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'teammate-test', version: '1' } });
144
+ const teammateState = JSON.parse((await teammateTransport.request('tools/call', { name: 'get_team_state', arguments: { team_id: team.team_id } })).content[0].text);
145
+ assert.equal(teammateState.members[0].display_status, 'working');
146
+ assert.equal(teammateState.phase, 'running', 'In-flight work reads as running');
147
+ assert.equal(teammateState.progress.in_progress, 1);
148
+ await teammateTransport.request('tools/call', { name: 'send_team_message', arguments: { team_id: team.team_id, to: 'lead', task_id: teamTask.id, kind: 'review-request', message: 'Implementation is ready for verification.' } });
149
+ await assert.rejects(rt.collaboration.call(parent.id, 'send_team_message', { team_id: team.team_id, to: 'lead', message: 'bad kind', kind: 'bogus' }), /invalid_value|Invalid/, 'Message kind is a closed enum');
150
+ const teamChild = rt.collaboration.jobs.get(teamJob.task_id).childId;
151
+ await assert.rejects(rt.collaboration.call(teamChild, 'assign_team_task', { team_id: team.team_id, title: 'Escalate', description: 'Must be lead-owned', assignee: team.members[0].id }), /Only the Team Lead/);
152
+ await assert.rejects(rt.collaboration.call(teamChild, 'delegate_to_agent', { agent_type: 'worker', task: 'recursive' }), /Only lead/);
153
+ finish(teamChild, 'team-result');
154
+ await wait(() => rt.collaboration.jobs.get(teamJob.task_id).status === 'completed');
155
+ const settledTeam = await call('get_team_state', { team_id: team.team_id });
156
+ assert.equal(settledTeam.tasks.find(entry => entry.id === teamTask.id).status, 'completed');
157
+ assert.equal(settledTeam.tasks.find(entry => entry.id === dependent.id).status, 'pending');
158
+ assert.equal(settledTeam.messages[0].fromName, 'Builder');
159
+ assert.equal(settledTeam.messages[0].kind, 'review-request', 'Mailbox messages keep their typed kind');
160
+ assert.equal(settledTeam.phase, 'waiting', 'Build settled but dependent review is not delegated yet');
161
+ assert.equal(settledTeam.progress.completed, 1);
162
+ assert.equal(settledTeam.members[0].child_thread_id ?? settledTeam.members[0].childId, teamChild);
163
+ const verifyJob = await call('delegate_to_agent', { agent_type: 'reviewer', task: 'Verify independently as Reviewer', team_id: team.team_id, member_id: team.members[1].id, team_task_id: dependent.id, isolation: 'shared' });
164
+ await wait(() => rt.collaboration.jobs.get(verifyJob.task_id).childId && pending.has(rt.collaboration.jobs.get(verifyJob.task_id).childId));
165
+ const reviewerChild = rt.collaboration.jobs.get(verifyJob.task_id).childId;
166
+ assert.notEqual(reviewerChild, teamChild, 'Different Harness teammates own different native sessions');
167
+ finish(reviewerChild, 'verification-result');
168
+ await wait(() => rt.collaboration.jobs.get(verifyJob.task_id).status === 'completed');
169
+ const polish = (await call('assign_team_task', { team_id: team.team_id, title: 'Polish', description: 'Address final details', assignee: team.members[0].id, depends_on: [dependent.id] })).task;
170
+ const polishJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'Polish in the persistent Builder session', team_id: team.team_id, member_id: team.members[0].id, team_task_id: polish.id, isolation: 'worktree' });
171
+ assert.equal(polishJob.child_thread_id, teamChild, 'A teammate keeps one native session across team tasks');
172
+ assert.equal(polishJob.workspace.cwd, rt.collaboration.jobs.get(teamJob.task_id).workspace.cwd, 'A teammate keeps its existing workspace across team tasks');
173
+ await wait(() => pending.has(teamChild));
174
+ finish(teamChild, 'polish-result');
175
+ await wait(() => rt.collaboration.jobs.get(polishJob.task_id).status === 'completed');
176
+ const inspectedTeam = await rt.collaboration.inspectTeam(parent.id, team.team_id);
177
+ assert.equal(inspectedTeam.team.team_id, team.team_id);
178
+ assert.equal(inspectedTeam.team.phase, 'completed', 'All team tasks settled');
179
+ assert.equal(inspectedTeam.team.progress.percent, 100);
180
+ assert.ok(inspectedTeam.snapshots.length >= 8, 'Team lifecycle is preserved as replayable snapshots');
181
+ assert.ok(inspectedTeam.snapshots.some(entry => entry.action === 'member_session_ready'));
182
+ assert.ok(inspectedTeam.snapshots.some(entry => entry.action === 'task_settled'));
183
+ assert.equal((await rt.collaboration.inspectTeam(teamChild, team.team_id)).team.team_id, team.team_id, 'Members may inspect their own shared team state');
184
+ const { NativeProtocol } = require('../src/main/native/protocol');
185
+ const protocolTeam = await new NativeProtocol(rt, () => {}).request('harnessmix/thread/team/inspect', { threadId: parent.id, teamId: team.team_id });
186
+ assert.equal(protocolTeam.snapshots.at(-1).team.team_id, team.team_id, 'Renderer protocol exposes the persisted Team timeline');
187
+ await rt.collaboration.saveTeams();
188
+ const persistedTeams = JSON.parse(await fs.readFile(path.join(root, 'data', 'collaboration', 'teams.json'), 'utf8'));
189
+ assert.equal(persistedTeams[0].id, team.team_id);
190
+ assert.equal(persistedTeams[0].history.length, inspectedTeam.snapshots.length);
191
+ teammateTransport.stop();
192
+ await assert.rejects(call('get_delegation_status', { task_ids: ['foreign-task'] }), /Unknown task/);
193
+ await assert.rejects(rt.collaboration.call(jobs[0].childId, 'delegate_to_agent', { agent_type: 'worker', task: 'recursive' }), /Only lead/);
194
+ await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'x', unexpected: true }));
195
+ const savedMentions = parent.activeMentions;
196
+ delete parent.activeMentions;
197
+ await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'unauthorized' }), /用户本轮未显式委派/);
198
+ parent.activeMentions = savedMentions;
199
+ const denied = await fetch(ownerConnection.env.HARNESS_MIX_COLLAB_URL, { method: 'POST', headers: { Authorization: 'Bearer invalid' }, body: '{}' });
200
+ assert.equal(denied.status, 403);
201
+ const outsider = await rt.createThread({ harnessId: 'worker', cwd: root });
202
+ await assert.rejects(rt.collaboration.inspectTeam(outsider.id, team.team_id), /not a team participant/);
203
+ const noTeam = await rt.collaboration.inspectTeam(outsider.id);
204
+ assert.equal(noTeam.team, null, 'Inspecting a non-team thread without a teamId answers benignly');
205
+ assert.deepEqual(noTeam.snapshots, []);
206
+ await rt.send(outsider.id, 'unrelated');
207
+ assert.equal(outsider.messages.at(-1).concurrent, true, 'Independent same-workspace turns are marked concurrent');
208
+ await rt.cancel(outsider.id); await wait(() => !pending.has(outsider.id));
209
+ for (let i = 0; i < 6; i++) await call('delegate_to_agent', { agent_type: 'worker', task: 'cancel me', isolation: 'shared' });
210
+ await wait(() => pending.size === 6);
211
+ await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'too many' }), /six concurrent/);
212
+ await rt.cancel(parent.id);
213
+ await wait(() => !pending.size);
214
+ assert.equal(rt.execution.lastTurn(parent.id).status, 'cancelled');
215
+ await assert.rejects(call('list_agents', {}), /no longer active/);
216
+
217
+ // Settings → Collaboration 开关:默认开启;关闭后服务端硬拒绝,并持久化到磁盘
218
+ const prefsRoot = await fs.mkdtemp(path.resolve('output/collaboration-prefs-'));
219
+ const prefsRt = new HostRuntime({ dataDirectory: path.join(prefsRoot, 'data') });
220
+ try {
221
+ await prefsRt.collaboration.initialize();
222
+ assert.deepEqual(prefsRt.collaboration.getPreferences(), { collaboration: true, agentTeam: true }, 'Both collaboration switches default to on');
223
+ await prefsRt.collaboration.setPreferences({ collaboration: false });
224
+ await assert.rejects(prefsRt.collaboration.call('any-thread', 'list_agents', {}), /停用.*disabled/, 'Collaboration tools are hard-rejected while the switch is off');
225
+ await prefsRt.collaboration.setPreferences({ collaboration: true, agentTeam: false });
226
+ await assert.rejects(
227
+ prefsRt.collaboration.call('any-thread', 'create_agent_team', { name: 't', goal: 'g', members: [{ name: 'A', role: 'r', agent_type: 'worker' }] }),
228
+ /Agent Team 已在设置中停用/,
229
+ 'create_agent_team is rejected while the Agent Team switch is off, before any turn-state check',
230
+ );
231
+ const reloaded = new HostRuntime({ dataDirectory: path.join(prefsRoot, 'data') });
232
+ await reloaded.collaboration.initialize();
233
+ assert.deepEqual(reloaded.collaboration.getPreferences(), { collaboration: true, agentTeam: false }, 'Preferences survive a Host restart');
234
+ await reloaded.close();
235
+ const onAgain = await prefsRt.collaboration.setPreferences({ agentTeam: true });
236
+ assert.deepEqual(onAgain, { collaboration: true, agentTeam: true });
237
+ } finally { await prefsRt.close(); }
238
+
239
+ // 上一段已取消 lead 回合:先开一个新的运行中回合再驱动后续回归
240
+ await rt.send(parent.id, '#worker 继续回归验证');
241
+
242
+ // 回归:用户在 UI 直接停止 worker 线程(= runtime.cancel)不得把团队任务永久卡在
243
+ // in_progress——此前 runtime.cancel 直改 job 状态、绕过团队簿记,该成员从此无法
244
+ // 再被委派(delegate_to_agent already running 拒绝)
245
+ const stopTeam = await call('create_agent_team', { name: 'Stop team', goal: 'Survive direct worker stops', members: [{ name: 'Runner', role: 'Run until stopped', agent_type: 'worker' }] });
246
+ const stopTask = (await call('assign_team_task', { team_id: stopTeam.team_id, title: 'Run', description: 'Run until stopped', assignee: stopTeam.members[0].id })).task;
247
+ const stopJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'run', team_id: stopTeam.team_id, member_id: stopTeam.members[0].id, team_task_id: stopTask.id, isolation: 'shared' });
248
+ await wait(() => { const child = rt.collaboration.jobs.get(stopJob.task_id).childId; return child && pending.has(child); });
249
+ const stopChild = rt.collaboration.jobs.get(stopJob.task_id).childId;
250
+ await rt.cancel(stopChild);
251
+ const stopTeamObj = rt.collaboration.teams.get(stopTeam.team_id);
252
+ await wait(() => stopTeamObj.tasks.find(t => t.id === stopTask.id).status === 'pending');
253
+ assert.equal(stopTeamObj.members.find(m => m.id === stopTeam.members[0].id).status, 'ready', '直接停止 worker 后成员回到 ready');
254
+ const rerunJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'run again', team_id: stopTeam.team_id, member_id: stopTeam.members[0].id, team_task_id: stopTask.id, isolation: 'shared' });
255
+ await wait(() => pending.has(rt.collaboration.jobs.get(rerunJob.task_id).childId));
256
+ finish(rt.collaboration.jobs.get(rerunJob.task_id).childId, 'rerun-result');
257
+ await wait(() => rt.collaboration.jobs.get(rerunJob.task_id).status === 'completed');
258
+ assert.equal(rt.collaboration.teams.get(stopTeam.team_id).tasks.find(t => t.id === stopTask.id).status, 'completed', '同一任务在直接停止后可被重新委派并完成');
259
+
260
+ // 回归:子会话线程被删除后,后续输入回落新建替代会话——此前抛
261
+ // 'Native child history is missing' 使该作业永久报废
262
+ const goneJob = await call('delegate_to_agent', { agent_type: 'worker', task: 'original work', isolation: 'shared' });
263
+ await wait(() => { const child = rt.collaboration.jobs.get(goneJob.task_id).childId; return child && pending.has(child); });
264
+ const goneChild = rt.collaboration.jobs.get(goneJob.task_id).childId;
265
+ finish(goneChild, 'original-result');
266
+ await wait(() => rt.collaboration.jobs.get(goneJob.task_id).status === 'completed');
267
+ await rt.removeThread(goneChild);
268
+ assert.equal(rt.collaboration.jobs.get(goneJob.task_id).childId, goneChild, '删除线程不抹掉作业的会话记录');
269
+ await call('message_agent', { task_id: goneJob.task_id, task: 'continue in a replacement session' });
270
+ await wait(() => {
271
+ const revivedChild = rt.collaboration.jobs.get(goneJob.task_id).childId;
272
+ return revivedChild && revivedChild !== goneChild && pending.has(revivedChild);
273
+ });
274
+ finish(rt.collaboration.jobs.get(goneJob.task_id).childId, 'replacement-result');
275
+ await wait(() => rt.collaboration.jobs.get(goneJob.task_id).status === 'completed');
276
+ assert.equal((await call('get_delegation_status', { task_ids: [goneJob.task_id], wait_ms: 0 }))[0].result, 'replacement-result', '替代会话产出可被正常收集');
277
+
278
+ // 回归:委派超时必须落 failed+error——此前先 cancel 子线程再抛错,runtime.cancel 的
279
+ // 直改把作业标成 cancelled,catch 的记录分支(仅认 running)吞掉了超时错误
280
+ const timeoutRoot = await fs.mkdtemp(path.resolve('output/collaboration-timeout-'));
281
+ const timeoutRt = new HostRuntime({ dataDirectory: path.join(timeoutRoot, 'data'), delegationTimeoutMs: 150 });
282
+ try {
283
+ await timeoutRt.store.load();
284
+ const tLead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
285
+ async open(input) { return { emit: input.emit, collaborationEnabled: true }; }, async send() {}, async cancel() {}, async close() {} };
286
+ const tWorker = { manifest: { id: 'worker', name: 'Worker', capabilities: { collaborationTools: true } },
287
+ async open(input) { return { id: input.thread.id, emit: input.emit }; },
288
+ async send(s) { tPending.set(s.id, s); }, async cancel() {}, async close() {} };
289
+ const tPending = new Map();
290
+ timeoutRt.adapters.set('lead', tLead); timeoutRt.status.lead = { available: true };
291
+ timeoutRt.adapters.set('worker', tWorker); timeoutRt.status.worker = { available: true };
292
+ const tParent = await timeoutRt.createThread({ harnessId: 'lead', cwd: timeoutRoot });
293
+ await timeoutRt.send(tParent.id, '#worker stuck');
294
+ const tJob = await timeoutRt.collaboration.call(tParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'never settles', isolation: 'shared' });
295
+ await wait(() => timeoutRt.collaboration.jobs.get(tJob.task_id).status === 'failed');
296
+ const failedJob = timeoutRt.collaboration.jobs.get(tJob.task_id);
297
+ assert.match(failedJob.error, /timed out/, '超时错误被记录');
298
+ assert.notEqual(failedJob.status, 'cancelled', '超时不得误报为已取消');
299
+ } finally { await timeoutRt.close(); }
300
+
301
+ // 同目录外部并发(另一个独立会话正在运行)→ 用户收到 toast 警告,
302
+ // 且新委派的 worker 默认升级为隔离模式(auto);非 Git 目录下 auto 回落 shared。
303
+ // 用系统临时目录:output/ 位于本仓库内,auto 在仓库内会真实创建 worktree。
304
+ const isoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harness-mix-iso-'));
305
+ const isoRt = new HostRuntime({ dataDirectory: path.join(isoRoot, 'data') });
306
+ try {
307
+ await isoRt.store.load();
308
+ const isoToasts = [];
309
+ isoRt.subscribe(event => { if (event.type === 'toast') isoToasts.push(event); });
310
+ const isoPending = new Map();
311
+ const isoLead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
312
+ async open(input) { return { emit: input.emit, collaborationEnabled: true }; },
313
+ async send() {}, async cancel() {}, async close() {} };
314
+ const isoWorker = { manifest: { id: 'worker', name: 'Worker', capabilities: { collaborationTools: true } },
315
+ async open(input) { return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration }; },
316
+ async send(s) { isoPending.set(s.id, s); }, async cancel() {}, async close() {} };
317
+ isoRt.adapters.set('lead', isoLead); isoRt.status.lead = { available: true };
318
+ isoRt.adapters.set('worker', isoWorker); isoRt.status.worker = { available: true };
319
+ const isoParent = await isoRt.createThread({ harnessId: 'lead', cwd: isoRoot });
320
+ await isoRt.send(isoParent.id, '#worker build something');
321
+ const isoOutsider = await isoRt.createThread({ harnessId: 'worker', cwd: isoRoot });
322
+ await isoRt.send(isoOutsider.id, 'other session');
323
+ await wait(() => isoPending.has(isoOutsider.id));
324
+ assert.equal(isoOutsider.messages.at(-1).concurrent, true, 'Concurrent turn is still marked');
325
+ assert.ok(isoToasts.some(t => t.threadId === isoOutsider.id && /同一目录/.test(t.text)), 'Concurrent same-directory sessions warn the user');
326
+ const isoJob = await isoRt.collaboration.call(isoParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'auto-isolated under concurrency' });
327
+ assert.equal(isoRt.collaboration.jobs.get(isoJob.task_id).isolation, 'auto', 'A new worker defaults to isolated mode while another session runs in the same directory');
328
+ await wait(() => isoRt.collaboration.jobs.get(isoJob.task_id).workspace);
329
+ assert.equal(isoRt.collaboration.jobs.get(isoJob.task_id).workspace.mode, 'shared', 'Auto falls back to shared outside Git projects');
330
+ await isoRt.cancel(isoOutsider.id);
331
+ const quietJob = await isoRt.collaboration.call(isoParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'shared again once quiet' });
332
+ assert.equal(isoRt.collaboration.jobs.get(quietJob.task_id).isolation, 'shared', 'Default returns to shared once the directory is quiet');
333
+ } finally { await isoRt.close(); }
334
+
335
+ // 看板用户操作面(Phase 1):取消/改派/消息/继续协作。取消与消息任意时刻可执行;
336
+ // 改派与继续协作要求 lead 空闲,由 Host 向 lead 线程注入指令回合实现——指令自带
337
+ // 目标成员的 #提及,走与用户手打提及完全相同的授权路径。
338
+ const uaRoot = await fs.mkdtemp(path.resolve('output/collaboration-user-action-'));
339
+ const uaRt = new HostRuntime({ dataDirectory: path.join(uaRoot, 'data') });
340
+ try {
341
+ await uaRt.store.load();
342
+ let uaLeadPrompt = '';
343
+ const uaPending = new Map();
344
+ const uaLead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
345
+ async open(input) { return { emit: input.emit, collaborationEnabled: true }; },
346
+ async send(s, text) { uaLeadPrompt = text; }, async cancel() {}, async close() {} };
347
+ const uaWorker = { manifest: { id: 'worker', name: 'Worker', capabilities: { collaborationTools: true } },
348
+ async open(input) { return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration }; },
349
+ async send(s, text) { uaPending.set(s.id, { s, text }); }, async cancel(s) { uaPending.delete(s.id); }, async close() {} };
350
+ const uaReviewer = { ...uaWorker, manifest: { id: 'reviewer', name: 'Reviewer', capabilities: { collaborationTools: true } } };
351
+ uaRt.adapters.set('lead', uaLead); uaRt.status.lead = { available: true };
352
+ uaRt.adapters.set('worker', uaWorker); uaRt.status.worker = { available: true };
353
+ uaRt.adapters.set('reviewer', uaReviewer); uaRt.status.reviewer = { available: true };
354
+ const uaParent = await uaRt.createThread({ harnessId: 'lead', cwd: uaRoot });
355
+ const uaCall = (name, args) => uaRt.collaboration.call(uaParent.id, name, args);
356
+ await uaRt.send(uaParent.id, '#worker #reviewer 修复发布阻塞');
357
+ const uaTeam = await uaCall('create_agent_team', { name: 'User-action team', goal: 'Exercise board actions', members: [{ name: 'Builder', role: 'Implement', agent_type: 'worker' }, { name: 'Checker', role: 'Verify', agent_type: 'reviewer' }] });
358
+ const uaTask = (await uaCall('assign_team_task', { team_id: uaTeam.team_id, title: 'Fix', description: 'Make it pass', assignee: uaTeam.members[0].id })).task;
359
+ const uaJob = await uaCall('delegate_to_agent', { agent_type: 'worker', task: 'fix it', team_id: uaTeam.team_id, member_id: uaTeam.members[0].id, team_task_id: uaTask.id, isolation: 'shared' });
360
+ await wait(() => { const child = uaRt.collaboration.jobs.get(uaJob.task_id).childId; return child && uaPending.has(child); });
361
+ const uaBuilderChild = uaRt.collaboration.jobs.get(uaJob.task_id).childId;
362
+
363
+ // 授权边界:成员线程不能执行团队操作;运行中任务必须先取消才能改派
364
+ await assert.rejects(uaRt.collaboration.userAction(uaBuilderChild, 'task/cancel', { teamId: uaTeam.team_id, taskId: uaTask.id }), /仅限主导者线程/);
365
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'task/reassign', { teamId: uaTeam.team_id, taskId: uaTask.id, memberId: uaTeam.members[1].id }), /不能改派/);
366
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'bogus', {}), /未知的用户操作/);
367
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'task/reassign', { teamId: uaTeam.team_id, taskId: 'nope', memberId: uaTeam.members[1].id }), /未知的团队任务/);
368
+
369
+ // 取消:lead 运行中也可执行——任务回 pending、成员回 ready
370
+ const uaCancelled = await uaRt.collaboration.userAction(uaParent.id, 'task/cancel', { teamId: uaTeam.team_id, taskId: uaTask.id });
371
+ assert.equal(uaCancelled.tasks.find(entry => entry.id === uaTask.id).status, 'pending');
372
+ assert.equal(uaCancelled.members.find(entry => entry.id === uaTeam.members[0].id).display_status, 'ready');
373
+
374
+ // 重派后让子回合以 error 结算 → 任务 failed,供改派使用
375
+ await uaCall('delegate_to_agent', { agent_type: 'worker', task: 'fix it again', team_id: uaTeam.team_id, member_id: uaTeam.members[0].id, team_task_id: uaTask.id, isolation: 'shared' });
376
+ await wait(() => { const child = uaRt.collaboration.jobs.get(uaJob.task_id).childId; return child && uaPending.has(child) && uaRt.execution.isRunning(child); });
377
+ uaPending.get(uaRt.collaboration.jobs.get(uaJob.task_id).childId).s.emit({ kind: 'error', message: 'build broke' });
378
+ await wait(() => uaRt.collaboration.teams.get(uaTeam.team_id).tasks.find(entry => entry.id === uaTask.id).status === 'failed');
379
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'task/reassign', { teamId: uaTeam.team_id, taskId: uaTask.id, memberId: uaTeam.members[1].id }), /回合进行中/, 'lead 回合运行中不能改派');
380
+
381
+ // 改派:lead 空闲时执行——任务重置 pending、assignee 切换、指令回合注入 lead
382
+ await uaRt.cancel(uaParent.id);
383
+ await wait(() => !uaRt.execution.isRunning(uaParent.id));
384
+ const uaReassigned = await uaRt.collaboration.userAction(uaParent.id, 'task/reassign', { teamId: uaTeam.team_id, taskId: uaTask.id, memberId: uaTeam.members[1].id, note: '换人重做' });
385
+ const uaReassignedTask = uaReassigned.tasks.find(entry => entry.id === uaTask.id);
386
+ assert.equal(uaReassignedTask.status, 'pending');
387
+ assert.equal(uaReassignedTask.assignee, uaTeam.members[1].id);
388
+ assert.equal(uaReassignedTask.reassignedFrom, uaTeam.members[0].id, '改派保留原负责人痕迹');
389
+ await wait(() => uaLeadPrompt.includes('用户改派'));
390
+ assert.match(uaLeadPrompt, /#reviewer/, '改派指令自带目标成员的 #提及,走同一授权路径');
391
+ assert.match(uaLeadPrompt, /换人重做/);
392
+ await wait(() => uaRt.execution.isRunning(uaParent.id));
393
+ assert.deepEqual(uaParent.activeMentions, ['reviewer'], '指令回合重算 activeMentions');
394
+ // lead 依指令重新派发:授权门放行,成员绑定成立
395
+ const uaRedone = await uaCall('delegate_to_agent', { agent_type: 'reviewer', task: 'redo as Checker', team_id: uaTeam.team_id, member_id: uaTeam.members[1].id, team_task_id: uaTask.id, isolation: 'shared' });
396
+ await wait(() => { const child = uaRt.collaboration.jobs.get(uaRedone.task_id).childId; return child && uaPending.has(child); });
397
+ uaPending.get(uaRt.collaboration.jobs.get(uaRedone.task_id).childId).s.emit({ kind: 'text-delta', text: 'redone' });
398
+ uaPending.get(uaRt.collaboration.jobs.get(uaRedone.task_id).childId).s.emit({ kind: 'completed', finalAnswer: true });
399
+ await wait(() => uaRt.collaboration.teams.get(uaTeam.team_id).tasks.find(entry => entry.id === uaTask.id).status === 'completed');
400
+
401
+ // 消息:以 lead 身份直发;空闲收件人经原生会话直达
402
+ const uaMessaged = await uaRt.collaboration.userAction(uaParent.id, 'message/send', { teamId: uaTeam.team_id, to: uaTeam.members[0].name, message: 'user note' });
403
+ assert.equal(uaMessaged.team.messages.at(-1).from, 'lead');
404
+ assert.equal(uaMessaged.team.messages.at(-1).body, 'user note');
405
+ await wait(() => uaRt.collaboration.teams.get(uaTeam.team_id).messages.at(-1).delivery === 'native_session');
406
+ // 直投为 Builder 开启了一个真实子回合:结算它,成员回到空闲,供下面的降级路径复用
407
+ await wait(() => uaPending.has(uaBuilderChild));
408
+ uaPending.get(uaBuilderChild).s.emit({ kind: 'text-delta', text: 'noted' });
409
+ uaPending.get(uaBuilderChild).s.emit({ kind: 'completed', finalAnswer: true });
410
+ await wait(() => !uaRt.execution.isRunning(uaBuilderChild));
411
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'message/send', { teamId: uaTeam.team_id, to: uaTeam.members[0].id, message: 'x', kind: 'bogus' }), /未知的消息类型/);
412
+
413
+ // 继续协作:generic 与指定 taskId 两种指令;非中断任务拒绝
414
+ uaRt.collaboration.jobs.set('ua-interrupted', { id: 'ua-interrupted', owner: uaParent.id, agent: 'worker', childId: 'gone', status: 'interrupted' });
415
+ await assert.rejects(uaRt.collaboration.userAction(uaParent.id, 'continue', { taskId: uaRedone.task_id }), /仅中断的委派/);
416
+ await uaRt.cancel(uaParent.id);
417
+ await wait(() => !uaRt.execution.isRunning(uaParent.id));
418
+ const uaContinued = await uaRt.collaboration.userAction(uaParent.id, 'continue', {});
419
+ assert.equal(uaContinued.dispatched, true);
420
+ assert.deepEqual(uaContinued.interrupted.map(job => job.task_id), ['ua-interrupted']);
421
+ await wait(() => uaLeadPrompt.includes('继续之前中断的协作'));
422
+ assert.match(uaLeadPrompt, /#worker/, '继续协作指令带中断作业的 #提及');
423
+ await uaRt.cancel(uaParent.id);
424
+ await wait(() => !uaRt.execution.isRunning(uaParent.id));
425
+ await uaRt.collaboration.userAction(uaParent.id, 'continue', { taskId: 'ua-interrupted' });
426
+ await wait(() => uaLeadPrompt.includes('恢复中断的委派 ua-interrupted'));
427
+
428
+ // Renderer 协议面:方法经 NativeProtocol → collaboration.userAction
429
+ await uaRt.cancel(uaParent.id);
430
+ await wait(() => !uaRt.execution.isRunning(uaParent.id));
431
+ const { NativeProtocol } = require('../src/main/native/protocol');
432
+ const protoTeam = await new NativeProtocol(uaRt, () => {}).request('harnessmix/thread/team/message/send', { threadId: uaParent.id, teamId: uaTeam.team_id, to: uaTeam.members[0].name, message: 'via protocol' });
433
+ assert.equal(protoTeam.team.messages.at(-1).body, 'via protocol', 'Protocol 方法触达 userAction');
434
+ await wait(() => { const message = uaRt.collaboration.teams.get(uaTeam.team_id).messages.at(-1); return message.delivery === 'mailbox' && message.deliveryError; }, 'lead 空闲时直投降级回邮箱并记录原因');
435
+ await assert.rejects(new NativeProtocol(uaRt, () => {}).request('harnessmix/thread/team/task/reassign', { threadId: uaParent.id, teamId: uaTeam.team_id, taskId: uaTask.id, memberId: uaTeam.members[0].id }), /已完成的任务不能改派/);
436
+ } finally { await uaRt.close(); }
437
+
438
+ // Phase 2 失败处理:retry 预算自动重派(同一成员,复用会话与工作区,附上次失败
439
+ // 原因);任务失败必达 lead 邮箱的 system 通知;用户主动取消不算失败、不通知。
440
+ const r2Root = await fs.mkdtemp(path.resolve('output/collaboration-retry-'));
441
+ const r2Rt = new HostRuntime({ dataDirectory: path.join(r2Root, 'data') });
442
+ try {
443
+ await r2Rt.store.load();
444
+ const r2Pending = new Map();
445
+ const r2Sends = [];
446
+ const r2Lead = { manifest: { id: 'lead', name: 'Lead', capabilities: { collaborationTools: true } },
447
+ async open(input) { return { emit: input.emit, collaborationEnabled: true }; },
448
+ async send() {}, async cancel() {}, async close() {} };
449
+ const r2Worker = { manifest: { id: 'worker', name: 'Worker', capabilities: { collaborationTools: true } },
450
+ async open(input) { return { id: input.thread.id, emit: input.emit, collaborationEnabled: !!input.collaboration }; },
451
+ async send(s, text) { r2Pending.set(s.id, { s, text }); r2Sends.push({ id: s.id, text }); }, async cancel(s) { r2Pending.delete(s.id); }, async close() {} };
452
+ r2Rt.adapters.set('lead', r2Lead); r2Rt.status.lead = { available: true };
453
+ r2Rt.adapters.set('worker', r2Worker); r2Rt.status.worker = { available: true };
454
+ const r2Parent = await r2Rt.createThread({ harnessId: 'lead', cwd: r2Root });
455
+ const r2Call = (name, args) => r2Rt.collaboration.call(r2Parent.id, name, args);
456
+ await r2Rt.send(r2Parent.id, '#worker 重试回归');
457
+ const r2Team = await r2Call('create_agent_team', { name: 'Retry team', goal: 'Failure handling', members: [{ name: 'Builder', role: 'Build', agent_type: 'worker' }] });
458
+ const r2TeamObj = r2Rt.collaboration.teams.get(r2Team.team_id);
459
+ const r2TaskState = task => r2TeamObj.tasks.find(entry => entry.id === task.id);
460
+ const r2Notices = task => r2TeamObj.messages.filter(message => message.from === 'system' && message.taskId === task.id);
461
+ const r2Fail = id => r2Pending.get(id).s.emit({ kind: 'error', message: 'flaky build' });
462
+ const r2Ok = (id, text) => { const entry = r2Pending.get(id); entry.s.emit({ kind: 'text-delta', text }); entry.s.emit({ kind: 'completed', finalAnswer: true }); };
463
+ const r2Delegate = async (task, attempt) => r2Call('delegate_to_agent', { agent_type: 'worker', task: attempt, team_id: r2Team.team_id, member_id: r2Team.members[0].id, team_task_id: task.id, isolation: 'shared' });
464
+ // 等发送记录本身(每场景唯一文本),而不是 r2Pending.has + isRunning:回合注册先于
465
+ // adapter.send 投递(中间有 review 快照/save 等 await),按运行态同步会在「已注册
466
+ // 未投递」窗口提前放行,r2Fail 会结算一个从未投递的回合,计数随即少一
467
+ const r2WaitSent = text => wait(() => r2Sends.some(send => send.text.startsWith(text)));
468
+ const r2WaitTurn = () => wait(() => r2Rt.execution.isRunning(childA));
469
+
470
+ // A) retry {max:1}:失败 → system 通知 + 自动重派(附失败原因、复用成员会话)→ 成功
471
+ const taskA = (await r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'A', description: 'retry then pass', assignee: r2Team.members[0].id, retry: { max: 1 } })).task;
472
+ assert.deepEqual(taskA.retry, { max: 1, used: 0 }, '预算持久化在任务上');
473
+ const jobA = await r2Delegate(taskA, 'attempt one A');
474
+ await r2WaitSent('attempt one A');
475
+ const childA = r2Rt.collaboration.jobs.get(jobA.task_id).childId;
476
+ r2Fail(childA);
477
+ await wait(() => r2Sends.filter(send => send.id === childA).length >= 2, '自动重派复用同一成员会话');
478
+ assert.match(r2Sends.find(send => send.id === childA && send.text.includes('Previous attempt failed')).text, /flaky build/, '重派提示词附上次失败原因');
479
+ assert.match(r2Sends.filter(send => send.id === childA).at(-1).text, /persistent teammate/, '重派仍携带团队信封');
480
+ assert.equal(r2Notices(taskA).length, 1);
481
+ assert.match(r2Notices(taskA)[0].body, /失败[\s\S]*第 1\/1 次/, '失败通知先于重派进入 lead 邮箱');
482
+ await r2WaitTurn();
483
+ r2Ok(childA, 'A-fixed');
484
+ await wait(() => r2TaskState(taskA).status === 'completed');
485
+ assert.equal(r2TaskState(taskA).retry.used, 1, '成功后预算停在 1');
486
+ assert.ok(r2TeamObj.history.some(entry => entry.action === 'task_retry'), 'task_retry 入史');
487
+
488
+ // B) retry {max:1} 预算耗尽:两次失败后落 failed,两条通知(重试中/已耗尽)
489
+ const taskB = (await r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'B', description: 'always fails', assignee: r2Team.members[0].id, retry: { max: 1 } })).task;
490
+ const baselineB = r2Sends.length;
491
+ const jobB = await r2Delegate(taskB, 'attempt one B');
492
+ await r2WaitSent('attempt one B');
493
+ r2Fail(childA);
494
+ await wait(() => r2Sends.length >= baselineB + 2, '预算内自动重试');
495
+ await r2WaitTurn();
496
+ r2Fail(childA);
497
+ await wait(() => r2TaskState(taskB).status === 'failed');
498
+ assert.equal(r2TaskState(taskB).retry.used, 1, '预算只消耗一次');
499
+ assert.match(r2TaskState(taskB).result, /flaky build/);
500
+ const noticesB = r2Notices(taskB);
501
+ assert.equal(noticesB.length, 2);
502
+ assert.match(noticesB[0].body, /第 1\/1 次/);
503
+ assert.match(noticesB[1].body, /重试预算已耗尽/);
504
+ await wait(() => r2TeamObj.history.some(entry => entry.action === 'task_failed'), '最终失败入史');
505
+
506
+ // C) 无预算:单条纯失败通知,不重派
507
+ const taskC = (await r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'C', description: 'no budget', assignee: r2Team.members[0].id })).task;
508
+ assert.equal(taskC.retry, undefined, '未声明 retry 时行为与现状一致');
509
+ const baselineC = r2Sends.length;
510
+ const jobC = await r2Delegate(taskC, 'attempt one C');
511
+ await r2WaitSent('attempt one C');
512
+ r2Fail(childA);
513
+ await wait(() => r2TaskState(taskC).status === 'failed');
514
+ assert.equal(r2Sends.length, baselineC + 1, '无预算不重派');
515
+ assert.equal(r2Notices(taskC).length, 1);
516
+ assert.match(r2Notices(taskC)[0].body, /失败/);
517
+ assert.ok(!/重试/.test(r2Notices(taskC)[0].body), '纯失败通知不带重试字样');
518
+
519
+ // schema 边界:retry.max 超界被 zod 拒绝
520
+ await assert.rejects(r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'X', description: 'bad budget', assignee: r2Team.members[0].id, retry: { max: 5 } }));
521
+
522
+ // Phase 3:忙碌收件人 queued → 回合边界投递;成员未读计数
523
+ const taskD = (await r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'D', description: 'busy recipient', assignee: r2Team.members[0].id })).task;
524
+ const jobD = await r2Delegate(taskD, 'busy work');
525
+ await r2WaitSent('busy work');
526
+ const msgQ = await r2Call('send_team_message', { team_id: r2Team.team_id, to: r2Team.members[0].name, message: 'while busy' });
527
+ assert.equal(msgQ.message.delivery, 'queued', '忙碌收件人聚合状态为 queued');
528
+ assert.equal(msgQ.message.deliveryBy[r2Team.members[0].id], 'queued');
529
+ assert.equal(msgQ.team.members[0].unread, 1, '未读计数进入 teamView');
530
+ // 结束成员回合:结算路径的投递泵把 queued 消息送进其原生会话
531
+ r2Ok(childA, 'D-done');
532
+ await wait(() => r2TaskState(taskD).status === 'completed');
533
+ await wait(() => r2TeamObj.messages.find(message => message.id === msgQ.message.id).deliveryBy[r2Team.members[0].id] === 'native_session');
534
+ assert.equal((await r2Call('get_team_state', { team_id: r2Team.team_id })).members[0].unread, 0, '送达后未读清零');
535
+ assert.ok(r2Sends.some(send => send.id === childA && send.text.includes('while busy')), '排队消息按 teammate 信封投进原生会话');
536
+
537
+ // Phase 3 降级:排队消息在 lead 回合结束时回落邮箱并记录原因。
538
+ // E 的语义就是成员忙('while busy' 投递回合仍在运行):任务派发不要求送达
539
+ //(busy 会按重试窗口等待),直接验证消息排队与 lead 结束后的邮箱回落。
540
+ const taskE = (await r2Call('assign_team_task', { team_id: r2Team.team_id, title: 'E', description: 'race the end', assignee: r2Team.members[0].id })).task;
541
+ const jobE = await r2Delegate(taskE, 'busy again');
542
+ const msgE = await r2Call('send_team_message', { team_id: r2Team.team_id, to: r2Team.members[0].name, message: 'race the end' });
543
+ assert.equal(msgE.message.delivery, 'queued');
544
+ await r2Rt.cancel(r2Parent.id);
545
+ await wait(() => {
546
+ const message = r2TeamObj.messages.find(entry => entry.id === msgE.message.id);
547
+ return message.deliveryBy[r2Team.members[0].id] === 'mailbox' && message.deliveryError;
548
+ }, 'lead 结束后排队消息降级回邮箱');
549
+
550
+ // Phase 4:apply 附加 advisory 验证(off 策略零配置安全网,不阻断、不改门禁)
551
+ const r4Repo = path.join(r2Root, 'apply-repo');
552
+ await fs.mkdir(r4Repo, { recursive: true });
553
+ await git(r4Repo, ['init', '-q']);
554
+ await git(r4Repo, ['config', 'core.autocrlf', 'false']);
555
+ await fs.writeFile(path.join(r4Repo, 'file.txt'), 'base\n');
556
+ await git(r4Repo, ['add', '.']);
557
+ await git(r4Repo, ['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'base']);
558
+ const r4Ws = await createWorkspace(r4Repo, 'apply-advisory-job', 'worktree');
559
+ await fs.writeFile(path.join(r4Ws.cwd, 'file.txt'), 'applied\n');
560
+ const r4Rev = await reviewWorkspace(r4Ws);
561
+ const r4Child = await r2Rt.createThread({ harnessId: 'worker', cwd: r4Ws.cwd, title: 'apply advisory child' });
562
+ await r2Rt.send(r4Child.id, 'produce the change');
563
+ await wait(() => r2Pending.has(r4Child.id) && r2Rt.execution.isRunning(r4Child.id));
564
+ r2Ok(r4Child.id, 'done');
565
+ await wait(() => !r2Rt.execution.isRunning(r4Child.id));
566
+ r2Rt.collaboration.jobs.set('apply-advisory-job', { id: 'apply-advisory-job', owner: r2Parent.id, agent: 'worker', childId: r4Child.id, status: 'completed', task: 'apply advisory', workspace: r4Ws });
567
+ const r4Result = await r2Rt.collaboration.apply('apply-advisory-job', r4Rev.digest);
568
+ assert.equal(r4Result.verification.mode, 'advisory', 'apply 结果附带 advisory 验证');
569
+ assert.ok(r4Result.verification.checks.some(check => check.id === 'turnCompleted' && check.status === 'passed'));
570
+ assert.equal(r2Rt.collaboration.jobs.get('apply-advisory-job').verification.status, r4Result.verification.status, 'advisory 结果持久化在作业上');
571
+ assert.equal(r2Rt.verificationGates.inspect(r2Rt.threads.find(t => t.id === r4Child.id)).policy.mode, 'off', 'advisory 不改线程门禁策略');
572
+ } finally { await r2Rt.close(); }
573
+
574
+ console.log('PASS: real MCP stdio → authenticated Host → parallel native-session adapters → results/follow-up/cancellation, ownership and shared review');
575
+ } finally { transport.stop(); await rt.close(); }
576
+ }
577
+ main().catch(error => { console.error(error); process.exitCode = 1; });