@harness-mix/cli 0.2.4 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +4 -4
- package/docs/harness-management.md +1 -1
- package/docs/multi-agent-collaboration.md +3 -1
- package/docs/native-acp.md +138 -127
- package/output/native-build/renderer-extension.js +149 -19
- package/package.json +4 -2
- package/scripts/acp-image-test.cjs +69 -0
- package/scripts/adapters-test.cjs +25 -0
- package/scripts/codex-accounts-test.cjs +34 -0
- package/scripts/codex-adapter-test.cjs +77 -1
- package/scripts/collaboration-test.cjs +304 -1
- package/scripts/collaboration-ui-smoke.cjs +25 -3
- package/scripts/e2e-delegate.cjs +1 -1
- package/scripts/e2e-hermes-image.cjs +60 -0
- package/scripts/openclaw-adapter-test.cjs +121 -3
- package/scripts/openclaw-mcp-probe.cjs +100 -0
- package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
- package/scripts/openclaw-thinking-probe.cjs +73 -0
- package/scripts/storage-verification-test.cjs +11 -0
- package/src/main/adapters/claude.js +15 -7
- package/src/main/adapters/codex-app-server.js +29 -11
- package/src/main/adapters/codex.js +39 -5
- package/src/main/adapters/omp.js +7 -2
- package/src/main/adapters/openclaw.js +534 -343
- package/src/main/adapters/pi-family.js +35 -8
- package/src/main/adapters/zcode.js +12 -8
- package/src/main/host/collaboration-tools.js +1 -1
- package/src/main/host/collaboration.js +285 -36
- package/src/main/host/runtime.js +9 -3
- package/src/main/host/verification-gates.js +14 -2
- package/src/main/native/codex-accounts.js +15 -4
- package/src/main/native/protocol.js +6 -0
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +20 -7
- package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
- package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
|
@@ -4,8 +4,9 @@ const os = require('node:os');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
6
|
const { mentionedAgents, teamTaskDepths, teamPhase, teamProgress } = require('../src/main/host/collaboration');
|
|
7
|
+
const { createWorkspace, reviewWorkspace, git } = require('../src/main/host/collaboration-worktree');
|
|
7
8
|
const { JsonlProcess } = require('../src/main/host/jsonl');
|
|
8
|
-
const wait = async fn => { for (let i = 0; i <
|
|
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'); };
|
|
9
10
|
|
|
10
11
|
async function main() {
|
|
11
12
|
const root = await fs.mkdtemp(path.resolve('output/collaboration-'));
|
|
@@ -235,6 +236,68 @@ async function main() {
|
|
|
235
236
|
assert.deepEqual(onAgain, { collaboration: true, agentTeam: true });
|
|
236
237
|
} finally { await prefsRt.close(); }
|
|
237
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
|
+
|
|
238
301
|
// 同目录外部并发(另一个独立会话正在运行)→ 用户收到 toast 警告,
|
|
239
302
|
// 且新委派的 worker 默认升级为隔离模式(auto);非 Git 目录下 auto 回落 shared。
|
|
240
303
|
// 用系统临时目录:output/ 位于本仓库内,auto 在仓库内会真实创建 worktree。
|
|
@@ -268,6 +331,246 @@ async function main() {
|
|
|
268
331
|
const quietJob = await isoRt.collaboration.call(isoParent.id, 'delegate_to_agent', { agent_type: 'worker', task: 'shared again once quiet' });
|
|
269
332
|
assert.equal(isoRt.collaboration.jobs.get(quietJob.task_id).isolation, 'shared', 'Default returns to shared once the directory is quiet');
|
|
270
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
|
+
|
|
271
574
|
console.log('PASS: real MCP stdio → authenticated Host → parallel native-session adapters → results/follow-up/cancellation, ownership and shared review');
|
|
272
575
|
} finally { transport.stop(); await rt.close(); }
|
|
273
576
|
}
|
|
@@ -12,9 +12,10 @@ app.whenReady().then(async () => {
|
|
|
12
12
|
await win.webContents.executeJavaScript(bundle.outputFiles[0].text);
|
|
13
13
|
const result = await win.webContents.executeJavaScript(`(async () => {
|
|
14
14
|
const api = Mentions.installHarnessMentions(async () => ({agents:[{id:'pi',name:'Pi',available:true,lead:true},{id:'claude',name:'Claude Code',available:true,lead:true},{id:'dsh',name:'DeepSeek Harness',available:false,lead:false}],sessions:[{id:'c2Vzc2lvbg',title:'旧版登录审查',harnessId:'claude',cwd:'E:/project',running:false}]}));
|
|
15
|
-
const teamState = {action:'message_sent',team_id:'team-1',lead_thread_id:'lead-thread',name:'Release Team',goal:'实现并验证 Agent Team',status:'active',updated_at:2,lead:{id:'lead',name:'Team Lead',role:'Codex · 协调与验收',agent:'codex',display_status:'working'},members:[{id:'member-1',name:'Backend',role:'后端架构与 Host Runtime',agent:'pi',childId:'builder-thread',display_status:'working'},{id:'member-2',name:'Reviewer',role:'独立代码审查与质量验收',agent:'claude',display_status:'ready'},{id:'member-3',name:'Frontend',role:'Codex 原生界面集成',agent:'codex',display_status:'working'},{id:'member-4',name:'QA',role:'测试矩阵与回归验证',agent:'dsh',display_status:'ready'},{id:'member-5',name:'Docs',role:'文档与交付说明',agent:'opencode',display_status:'ready'},{id:'member-6',name:'Release',role:'Git、Diff 与最终发布检查',agent:'antigravity',display_status:'blocked'}],tasks:[{id:'task-1',title:'实现 Team Runtime',assignee:'member-1',dependsOn:[],status:'in_progress'},{id:'task-2',title:'独立验证实现',assignee:'member-2',dependsOn:['task-1'],status:'blocked'},{id:'task-3',title:'实现内嵌团队页面',assignee:'member-3',dependsOn:[],status:'in_progress'},{id:'task-4',title:'执行完整回归',assignee:'member-4',dependsOn:['task-1','task-3'],status:'blocked'},{id:'task-5',title:'更新协作文档',assignee:'member-5',dependsOn:[],status:'completed'},{id:'task-6',title:'核对最终 Diff',assignee:'member-6',dependsOn:['task-2','task-4'],status:'blocked'}],messages:[{id:'message-1',from:'member-1',fromName:'Backend',to:'member-2',body:'Runtime 已就绪,请准备审查。',at:1}]};
|
|
15
|
+
const teamState = {action:'message_sent',team_id:'team-1',lead_thread_id:'lead-thread',name:'Release Team',goal:'实现并验证 Agent Team',status:'active',updated_at:2,lead:{id:'lead',name:'Team Lead',role:'Codex · 协调与验收',agent:'codex',display_status:'working'},members:[{id:'member-1',name:'Backend',role:'后端架构与 Host Runtime',agent:'pi',childId:'builder-thread',display_status:'working'},{id:'member-2',name:'Reviewer',role:'独立代码审查与质量验收',agent:'claude',display_status:'ready',unread:2},{id:'member-3',name:'Frontend',role:'Codex 原生界面集成',agent:'codex',display_status:'working'},{id:'member-4',name:'QA',role:'测试矩阵与回归验证',agent:'dsh',display_status:'ready'},{id:'member-5',name:'Docs',role:'文档与交付说明',agent:'opencode',display_status:'ready'},{id:'member-6',name:'Release',role:'Git、Diff 与最终发布检查',agent:'antigravity',display_status:'blocked'}],tasks:[{id:'task-1',title:'实现 Team Runtime',assignee:'member-1',dependsOn:[],status:'in_progress'},{id:'task-2',title:'独立验证实现',assignee:'member-2',dependsOn:['task-1'],status:'blocked'},{id:'task-3',title:'实现内嵌团队页面',assignee:'member-3',dependsOn:[],status:'in_progress'},{id:'task-4',title:'执行完整回归',assignee:'member-4',dependsOn:['task-1','task-3'],status:'blocked'},{id:'task-5',title:'更新协作文档',assignee:'member-5',dependsOn:[],status:'completed'},{id:'task-6',title:'核对最终 Diff',assignee:'member-6',dependsOn:['task-2','task-4'],status:'blocked'},{id:'task-7',title:'修复构建失败',assignee:'member-2',dependsOn:[],status:'failed'},{id:'task-8',title:'恢复中断任务',assignee:'member-1',dependsOn:[],status:'interrupted'}],messages:[{id:'message-1',from:'member-1',fromName:'Backend',to:'member-2',body:'Runtime 已就绪,请准备审查。',at:1}]};
|
|
16
16
|
globalThis.openedTeamThread = null;
|
|
17
|
-
|
|
17
|
+
globalThis.userActions = [];
|
|
18
|
+
const teamApi = Mentions.installTeamCards({inspectTeam:async () => ({team:teamState,snapshots:[{id:'snapshot-1',action:'team_created',at:1,team:{...teamState,tasks:[],messages:[]}},{id:'snapshot-2',action:'message_sent',at:2,team:teamState}]}),openThread:async id => { globalThis.openedTeamThread=id; },activeThread:() => ({threadId:'lead-thread',anchor:document.querySelector('#thread-content-anchor')}),userAction: async input => { globalThis.userActions.push(input); }});
|
|
18
19
|
const editor = document.querySelector('textarea');
|
|
19
20
|
editor.focus(); editor.value = '@cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
|
|
20
21
|
await new Promise(r=>setTimeout(r,30));
|
|
@@ -59,8 +60,19 @@ app.whenReady().then(async () => {
|
|
|
59
60
|
const workbench=document.querySelector('.harness-mix-team-workbench');
|
|
60
61
|
const builder=workbench.querySelector('[data-agent="pi"]'); builder.click(); await new Promise(r=>setTimeout(r,0));
|
|
61
62
|
const teamResult = {compact,chipOpened,name:workbench?.querySelector('.harness-mix-team-name')?.textContent,workbenches:document.querySelectorAll('.harness-mix-team-workbench').length,position:getComputedStyle(workbench).position,bodyOverflow:document.body.style.overflow,icons:workbench?.querySelectorAll('img').length,roles:[...workbench?.querySelectorAll('[data-agent]') ?? []].map(node=>node.getAttribute('data-agent')),lanes:workbench?.querySelectorAll('.harness-mix-team-lanes > div').length,hasTask:workbench?.textContent.includes('实现 Team Runtime'),hasRole:workbench?.textContent.includes('后端架构与 Host Runtime')&&workbench?.textContent.includes('独立代码审查与质量验收'),hasLeader:workbench?.textContent.includes('主导者'),hasMessage:workbench?.textContent.includes('Runtime 已就绪'),hasEvent:workbench?.textContent.includes('团队建立'),feedItems:workbench?.querySelectorAll('.harness-mix-team-feed-list article').length,workingLanes:workbench?.querySelectorAll('.harness-mix-team-lane[data-status="working"]').length,workingDots:workbench?.querySelectorAll('.harness-mix-team-status-dot[data-status="working"]').length,timeline:workbench?.querySelector('.harness-mix-team-timeline input')?.max,opened:globalThis.openedTeamThread};
|
|
63
|
+
// 看板操作面:失败/中断任务的操作按钮发起 protocol 级 userAction;进行中任务
|
|
64
|
+
// 有取消按钮(带确认,不在此触发);lead 工作中时「继续协作」呈禁用态
|
|
65
|
+
const failedCard = workbench.querySelector('[data-task-id="task-7"]');
|
|
66
|
+
const interruptedCard = workbench.querySelector('[data-task-id="task-8"]');
|
|
67
|
+
const runningCard = workbench.querySelector('[data-task-id="task-1"]');
|
|
68
|
+
const retryButton = [...failedCard.querySelectorAll('.harness-mix-team-action')].find(node => node.textContent === '重试');
|
|
69
|
+
const resumeTaskButton = [...interruptedCard.querySelectorAll('.harness-mix-team-action')].find(node => node.textContent === '恢复');
|
|
70
|
+
retryButton.click(); resumeTaskButton.click(); await new Promise(r=>setTimeout(r,30));
|
|
71
|
+
const continueButton = [...workbench.querySelectorAll('button')].find(node => node.textContent === '继续协作');
|
|
72
|
+
const reviewerLane = workbench.querySelector('[data-agent="claude"].harness-mix-team-member');
|
|
73
|
+
const actionResult = {retryPresent:!!retryButton, resumePresent:!!resumeTaskButton, reassignPresent:[...failedCard.querySelectorAll('.harness-mix-team-action')].some(node => node.textContent === '改派'), cancelPresent:[...runningCard.querySelectorAll('.harness-mix-team-action')].some(node => node.textContent === '取消'), actionCalls:globalThis.userActions.slice(), continueDisabled:continueButton?.disabled === true, unreadBadge:reviewerLane?.textContent.includes('💬2')};
|
|
62
74
|
globalThis.teamSmoke = teamApi;
|
|
63
|
-
return {atTriggerStayedNative,filtered,text,richText,badge,badgeLabel:badgeButton?.getAttribute('aria-label'),inline,selectedAgentCount,icons,disabled,sessionBadge,teamResult};
|
|
75
|
+
return {atTriggerStayedNative,filtered,text,richText,badge,badgeLabel:badgeButton?.getAttribute('aria-label'),inline,selectedAgentCount,icons,disabled,sessionBadge,teamResult,actionResult};
|
|
64
76
|
})()`);
|
|
65
77
|
assert.equal(result.atTriggerStayedNative, true);
|
|
66
78
|
assert.equal(result.filtered, 1);
|
|
@@ -92,6 +104,16 @@ app.whenReady().then(async () => {
|
|
|
92
104
|
assert.equal(result.teamResult.hasLeader, true);
|
|
93
105
|
assert.equal(result.teamResult.hasMessage, true);
|
|
94
106
|
assert.equal(result.teamResult.timeline, '1');
|
|
107
|
+
assert.equal(result.actionResult.retryPresent, true, '失败任务卡出现重试按钮');
|
|
108
|
+
assert.equal(result.actionResult.reassignPresent, true, '失败任务卡出现改派按钮');
|
|
109
|
+
assert.equal(result.actionResult.resumePresent, true, '中断任务卡出现恢复按钮');
|
|
110
|
+
assert.equal(result.actionResult.cancelPresent, true, '进行中任务卡出现取消按钮');
|
|
111
|
+
assert.equal(result.actionResult.continueDisabled, true, 'lead 工作中时继续协作为禁用态');
|
|
112
|
+
assert.equal(result.actionResult.unreadBadge, true, '成员卡渲染未读徽标');
|
|
113
|
+
assert.deepEqual(result.actionResult.actionCalls, [
|
|
114
|
+
{ action: 'task/reassign', threadId: 'lead-thread', teamId: 'team-1', taskId: 'task-7', memberId: 'member-2' },
|
|
115
|
+
{ action: 'task/reassign', threadId: 'lead-thread', teamId: 'team-1', taskId: 'task-8', memberId: 'member-1' },
|
|
116
|
+
], '看板按钮发起 protocol 级 userAction');
|
|
95
117
|
const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
|
|
96
118
|
await win.webContents.executeJavaScript('window.scrollTo(0,0)');
|
|
97
119
|
await fs.writeFile(path.join(out, 'team-inline-compact.png'), (await win.webContents.capturePage()).toPNG());
|
package/scripts/e2e-delegate.cjs
CHANGED
|
@@ -16,7 +16,7 @@ const to = arg("to", "pi");
|
|
|
16
16
|
if (!rt.status[from]?.available) throw new Error(`父 Harness 不可用:${from}`);
|
|
17
17
|
if (!rt.status[to]?.available) throw new Error(`子 Harness 不可用:${to}`);
|
|
18
18
|
|
|
19
|
-
const parent = await rt.createThread({ harnessId: from, cwd:
|
|
19
|
+
const parent = await rt.createThread({ harnessId: from, cwd: process.cwd(), title: "E2E 协作" });
|
|
20
20
|
if (parent.status === "error") throw new Error("parent open failed: " + parent.error);
|
|
21
21
|
console.log(`parent: ${parent.id.slice(0, 8)} (${from})`);
|
|
22
22
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 真实 Hermes 图片链路自检:经 `hermes acp` 的 session/prompt image 块发送一张纯红 PNG。
|
|
2
|
+
// 前提:本机已安装 hermes CLI 且完成 `hermes setup --portal` 登录。
|
|
3
|
+
const { HostRuntime } = require("../src/main/host/runtime");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const zlib = require("node:zlib");
|
|
7
|
+
|
|
8
|
+
// 最小合法 PNG:8x8 纯红(手工构造 IHDR+IDAT+IEND)
|
|
9
|
+
function redPng() {
|
|
10
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
11
|
+
let c = n;
|
|
12
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
return c >>> 0;
|
|
14
|
+
});
|
|
15
|
+
const crc32 = (buf) => {
|
|
16
|
+
let c = 0xffffffff;
|
|
17
|
+
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
18
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
19
|
+
};
|
|
20
|
+
const chunk = (type, data) => {
|
|
21
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
22
|
+
const body = Buffer.concat([Buffer.from(type), data]);
|
|
23
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));
|
|
24
|
+
return Buffer.concat([len, body, crc]);
|
|
25
|
+
};
|
|
26
|
+
const ihdr = Buffer.alloc(13);
|
|
27
|
+
ihdr.writeUInt32BE(8, 0); ihdr.writeUInt32BE(8, 4); ihdr[8] = 8; ihdr[9] = 2; // 8bit RGB
|
|
28
|
+
const raw = Buffer.alloc(8 * (1 + 8 * 3));
|
|
29
|
+
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
|
|
30
|
+
const o = y * 25 + 1 + x * 3; raw[o] = 255; raw[o + 1] = 0; raw[o + 2] = 0;
|
|
31
|
+
}
|
|
32
|
+
return Buffer.concat([
|
|
33
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
34
|
+
chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0)),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
(async () => {
|
|
39
|
+
const rt = new HostRuntime({ dataDirectory: path.join(os.tmpdir(), "hm-e2e-hermes-img") });
|
|
40
|
+
rt.subscribe((e) => { if (e.type === "toast") console.log(" [toast]", e.text); });
|
|
41
|
+
await rt.initialize();
|
|
42
|
+
const thread = await rt.createThread({ harnessId: "hermes", cwd: "E:\\harness-mix", title: "E2E 图片自检" });
|
|
43
|
+
if (thread.status === "error") throw new Error("open failed: " + thread.error);
|
|
44
|
+
const caps = rt.getCapabilities("hermes");
|
|
45
|
+
console.log("attachments capability:", caps.conversation.attachments);
|
|
46
|
+
console.log("opened, native session:", thread.nativeSessionId);
|
|
47
|
+
const png = redPng();
|
|
48
|
+
await rt.send(thread.id, "这张图片是什么颜色?用一个词回答。", {
|
|
49
|
+
attachments: [{ kind: "image", name: "red.png", mime: "image/png", data: png.toString("base64") }],
|
|
50
|
+
});
|
|
51
|
+
for (let i = 0; i < 180 && rt.threads.find((t) => t.id === thread.id).status === "working"; i++) await new Promise((r) => setTimeout(r, 1000));
|
|
52
|
+
const t = rt.threads.find((th) => th.id === thread.id);
|
|
53
|
+
const answer = t.messages.at(-1)?.text ?? "";
|
|
54
|
+
console.log("status:", t.status, "| error:", t.error ?? "(none)", "| assistant:", JSON.stringify(answer.slice(0, 300)));
|
|
55
|
+
console.log("messages:", JSON.stringify(t.messages.map((m) => ({ role: m.role, text: (m.text ?? "").slice(0, 120) }))));
|
|
56
|
+
await rt.close();
|
|
57
|
+
const ok = t.status === "ready" && /红|red/i.test(answer);
|
|
58
|
+
console.log(ok ? "PASS: Hermes 收到并识别了图片" : "WARN: 回答未明确提到红色(可能模型/会话不支持视觉,需人工看回答)");
|
|
59
|
+
process.exit(t.status === "ready" ? 0 : 1);
|
|
60
|
+
})().catch((e) => { console.error("E2E FAILED:", e.message); process.exit(1); });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const assert = require('node:assert/strict');
|
|
2
|
-
const { manifest, create, projectAgentEvent, projectApproval, applySessionDescription } = require('../src/main/adapters/openclaw');
|
|
2
|
+
const { manifest, create, projectAgentEvent, projectApproval, applySessionDescription, planMcpReconcile, toOpenClawServerEntry, MCP_OWNED_PREFIX, THINKING_LEVELS } = require('../src/main/adapters/openclaw');
|
|
3
3
|
const hermes = require('../src/main/adapters/hermes');
|
|
4
4
|
|
|
5
5
|
// OpenClaw Adapter 投影单元测试。帧形状以 Gateway 2026.5.12 实测捕获为准
|
|
@@ -54,6 +54,18 @@ const hermes = require('../src/main/adapters/hermes');
|
|
|
54
54
|
assert.equal(projectAgentEvent({ stream: 'patch', data: {} }, session).length, 0, '未验证的流不伪造');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// 4b. thinking 流投影(形状取自上游 emitAgentEvent stream:"thinking" 的 {text,delta},与 assistant 同构)
|
|
58
|
+
{
|
|
59
|
+
const session = { state: {} };
|
|
60
|
+
const evts = [
|
|
61
|
+
...projectAgentEvent({ stream: 'thinking', data: { text: '先列约束', delta: '先列约束' } }, session),
|
|
62
|
+
...projectAgentEvent({ stream: 'thinking', data: { text: '先列约束,再枚举' } }, session),
|
|
63
|
+
];
|
|
64
|
+
assert.deepEqual(evts, [{ kind: 'thinking-delta', text: '先列约束' }, { kind: 'thinking-delta', text: ',再枚举' }], 'delta 优先 + 累计差分');
|
|
65
|
+
assert.equal(session.state.lastThinkingText, '先列约束,再枚举', 'thinking 累计文本独立跟踪');
|
|
66
|
+
assert.equal(projectAgentEvent({ stream: 'thinking', data: { text: '' } }, session).length, 0, '空思考不投影');
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
// 5. 审批投影与应答路由(exec/plugin 分流;deny 为唯一拒绝决定)
|
|
58
70
|
(async () => {
|
|
59
71
|
{
|
|
@@ -154,10 +166,116 @@ const hermes = require('../src/main/adapters/hermes');
|
|
|
154
166
|
assert.equal(empty.state.usage, undefined);
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
// 9. Manifest 能力声明(诚实面:
|
|
169
|
+
// 9. Manifest 能力声明(诚实面:thinking 投影已接线但无推理凭据完成端到端实测,能力位保持 false;
|
|
170
|
+
// compaction=true 以原生 /compact 实测为准;usage 取自原生 token 统计)
|
|
158
171
|
{
|
|
159
172
|
assert.equal(manifest.id, 'openclaw');
|
|
160
|
-
assert.deepEqual(manifest.capabilities, { streaming: true, thinking: false, tools: true, approvals: true, questions: false, models: true, thinkingLevels: true, permissionModes: false, resume: true, fork: false, forkFromMessage: false, compaction:
|
|
173
|
+
assert.deepEqual(manifest.capabilities, { streaming: true, thinking: false, tools: true, approvals: true, questions: false, models: true, thinkingLevels: true, permissionModes: false, resume: true, fork: false, forkFromMessage: false, compaction: true, usage: true, contextUsage: true, attachments: true });
|
|
174
|
+
assert.equal(manifest.integrations.mcp, true, '托管 MCP 走 mcp.servers 注册表对账');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 9a. 静态思考档位兜底 = Gateway 2026.5.12 实测目录(sessions.create 后首 turn 前 describe 即返回,
|
|
178
|
+
// 见 output/openclaw-thinking-probe/*.json:thinkingLevels 5 档、thinkingDefault "off")
|
|
179
|
+
{
|
|
180
|
+
assert.deepEqual(THINKING_LEVELS, ['off', 'minimal', 'low', 'medium', 'high'], '兜底目录对齐实测 5 档,不夸大');
|
|
181
|
+
const withDefault = { state: { thinkingLevels: [{ id: 'off' }, { id: 'high' }], thinkingDefault: 'off' }, models: null };
|
|
182
|
+
const described = await create().describeFor(withDefault);
|
|
183
|
+
assert.deepEqual(described.thinkingLevels, [{ id: 'off', default: true }, { id: 'high', default: false }], '原生 thinkingDefault 标记预选');
|
|
184
|
+
const fallback = await create().describeFor({ state: {}, models: null });
|
|
185
|
+
assert.deepEqual(fallback.thinkingLevels, THINKING_LEVELS.map((id) => ({ id, label: id })), 'describe 缺席时回落静态兜底');
|
|
186
|
+
assert.deepEqual(fallback.permissionModes, [], 'OpenClaw 无原生权限模式,不虚构');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 9b. 托管 MCP 对账:只动 harness-mix/* 自有键,用户键原样保留;条目形状对齐 openclaw mcp set
|
|
190
|
+
{
|
|
191
|
+
assert.deepEqual(toOpenClawServerEntry({ command: 'node', args: ['server.js'], env: { A: '1' } }), { command: 'node', args: ['server.js'], env: { A: '1' } });
|
|
192
|
+
assert.deepEqual(toOpenClawServerEntry({ url: 'https://mcp.example/sse', http_headers: { Authorization: 'Bearer x' } }), { url: 'https://mcp.example/sse', headers: { Authorization: 'Bearer x' } });
|
|
193
|
+
assert.deepEqual(toOpenClawServerEntry({ command: 'uvx' }), { command: 'uvx' }, '空 args/env 不携带');
|
|
194
|
+
|
|
195
|
+
const current = {
|
|
196
|
+
'user-own': { command: 'uvx', args: ['context7-mcp'] },
|
|
197
|
+
[`${MCP_OWNED_PREFIX}old`]: { url: 'https://old/sse' },
|
|
198
|
+
[`${MCP_OWNED_PREFIX}keep`]: { command: 'node', args: ['k.js'] },
|
|
199
|
+
};
|
|
200
|
+
const managed = [
|
|
201
|
+
{ name: 'keep', command: 'node', args: ['k.js'] },
|
|
202
|
+
{ name: 'new', url: 'https://new/sse' },
|
|
203
|
+
];
|
|
204
|
+
const plan = planMcpReconcile(current, managed);
|
|
205
|
+
assert.deepEqual(plan.kept, { 'user-own': { command: 'uvx', args: ['context7-mcp'] } }, '用户键不动');
|
|
206
|
+
assert.deepEqual(plan.unsets, [`${MCP_OWNED_PREFIX}old`], '托管集合里消失的自有键被移除');
|
|
207
|
+
assert.deepEqual(plan.sets, [[`${MCP_OWNED_PREFIX}new`, { url: 'https://new/sse' }]], '新增进集合;未变化的键不重写');
|
|
208
|
+
|
|
209
|
+
const update = planMcpReconcile({ [`${MCP_OWNED_PREFIX}keep`]: { command: 'node', args: ['old.js'] } }, [{ name: 'keep', command: 'node', args: ['k.js'] }]);
|
|
210
|
+
assert.deepEqual(update.sets, [[`${MCP_OWNED_PREFIX}keep`, { command: 'node', args: ['k.js'] }]], '内容漂移的自有键被更新');
|
|
211
|
+
|
|
212
|
+
const empty = planMcpReconcile(undefined, []);
|
|
213
|
+
assert.deepEqual(empty, { kept: {}, sets: [], unsets: [] }, '空状态零操作');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 9c. 原生斜杠命令目录与执行:commands.list {scope:'text'}(行形状按 Gateway 2026.5.12 实测)
|
|
217
|
+
// → UI 命令:compact 专用条目去重、无文本别名条目诚实跳过、参数提示与来源标注
|
|
218
|
+
{
|
|
219
|
+
const payload = { commands: [
|
|
220
|
+
{ name: 'help', nativeName: 'help', textAliases: ['/help'], description: 'Show available commands.', category: 'core', source: 'native', scope: 'both', acceptsArgs: false, args: [] },
|
|
221
|
+
{ name: 'compact', nativeName: 'compact', textAliases: ['/compact'], description: 'Compact the session context.', category: 'core', source: 'native', scope: 'text', acceptsArgs: true, args: [{ name: 'instructions', type: 'string', required: false }] },
|
|
222
|
+
{ name: 'skill:Code Review', nativeName: 'code-review', textAliases: ['/code-review'], description: 'Review recent changes.', source: 'skill', scope: 'text', acceptsArgs: true, args: [{ name: 'focus', type: 'string', required: true }] },
|
|
223
|
+
{ name: 'Summarize Links', nativeName: 'summarize-links', textAliases: ['/summarize-links'], description: 'Summarize a page.', source: 'plugin', scope: 'text', args: [{ name: 'url', required: false }] },
|
|
224
|
+
{ name: 'no-alias', nativeName: 'no-alias', textAliases: [], description: '不可经文本面执行', source: 'native', scope: 'text' },
|
|
225
|
+
{ name: '无别名', nativeName: 'cn', description: 'textAliases 缺席', source: 'native' },
|
|
226
|
+
{ name: 'DUPE', nativeName: 'dupe', textAliases: ['/dupe'], source: 'native' },
|
|
227
|
+
{ name: 'dupe', nativeName: 'dupe-2', textAliases: ['/dupe-2'], source: 'native' },
|
|
228
|
+
] };
|
|
229
|
+
const calls = [];
|
|
230
|
+
const session = { host: { call: async (method, params) => { calls.push({ method, params }); return payload; } }, state: {} };
|
|
231
|
+
const commands = await create().listCommands(session);
|
|
232
|
+
assert.deepEqual(calls, [{ method: 'commands.list', params: { scope: 'text' } }]);
|
|
233
|
+
assert.equal(commands[0].id, 'compact');
|
|
234
|
+
assert.equal(commands[0].action, 'execute');
|
|
235
|
+
assert.equal(commands.filter((c) => c.id === 'compact').length, 1, '原生 compact 与映射目录去重');
|
|
236
|
+
const help = commands.find((c) => c.id === 'help');
|
|
237
|
+
assert.deepEqual([help.label, help.text, help.action], ['/help', '/help ', 'insert']);
|
|
238
|
+
assert.ok(help.description.includes('原生'), '来源标注:原生');
|
|
239
|
+
const review = commands.find((c) => c.id === 'skill:code-review');
|
|
240
|
+
assert.ok(review, 'name slug 化(skill:Code Review → skill:code-review)');
|
|
241
|
+
assert.ok(review.description.includes('<focus>'), '必选参数以 <name> 提示');
|
|
242
|
+
assert.ok(review.description.includes('技能'), '来源标注:技能');
|
|
243
|
+
const summarize = commands.find((c) => c.id === 'summarize-links');
|
|
244
|
+
assert.ok(summarize.description.includes('[url]'), '可选参数以 [name] 提示');
|
|
245
|
+
assert.ok(summarize.description.includes('插件'), '来源标注:插件');
|
|
246
|
+
assert.ok(!commands.some((c) => c.id === 'no-alias' || c.id === 'cn'), '无文本别名条目跳过');
|
|
247
|
+
assert.equal(commands.filter((c) => c.id === 'dupe').length, 1, 'slug 撞名去重');
|
|
248
|
+
assert.ok(commands.every((c) => /^[A-Za-z0-9._:-]+$/.test(c.id)), 'id 满足 UI 契约字符集');
|
|
249
|
+
assert.ok(commands.every((c) => c.label && c.label.length <= 128 && c.description.length <= 512), 'label/description 长度满足 UI 契约');
|
|
250
|
+
assert.equal(session.state.commands, commands, '目录缓存进会话状态');
|
|
251
|
+
|
|
252
|
+
// 目录 RPC 失败:仅保留可执行的压缩指令
|
|
253
|
+
const failing = { host: { call: async () => { throw new Error('rpc down'); } }, state: {} };
|
|
254
|
+
assert.deepEqual((await create().listCommands(failing)).map((c) => c.id), ['compact']);
|
|
255
|
+
|
|
256
|
+
// executeCommand:未知指令拒绝;compact 经 send 以消息文本走 agent 管道
|
|
257
|
+
const emitted = [];
|
|
258
|
+
const agentCalls = [];
|
|
259
|
+
const compactSession = {
|
|
260
|
+
host: { call: async (method, params) => {
|
|
261
|
+
agentCalls.push({ method, params });
|
|
262
|
+
if (method === 'agent') return { runId: 'run-c', status: 'accepted' };
|
|
263
|
+
if (method === 'agent.wait') return { runId: 'run-c', status: 'ok' };
|
|
264
|
+
if (method === 'sessions.describe') return null;
|
|
265
|
+
throw new Error('unexpected ' + method);
|
|
266
|
+
} },
|
|
267
|
+
nativeSessionId: 'agent:main:hm-c', state: {},
|
|
268
|
+
};
|
|
269
|
+
const adapter = create();
|
|
270
|
+
await assert.rejects(adapter.executeCommand(compactSession, 'nope', { emit: () => {} }), /未知/);
|
|
271
|
+
await adapter.executeCommand(compactSession, 'compact', { emit: (e) => emitted.push(e) });
|
|
272
|
+
assert.deepEqual(agentCalls.map((c) => c.method), ['agent', 'agent.wait', 'sessions.describe'], 'send 全链路只触这三个 RPC');
|
|
273
|
+
assert.equal(agentCalls[0].params.message, '/compact', 'compact 以消息文本走 agent 运行管道');
|
|
274
|
+
assert.equal(agentCalls[0].params.sessionKey, 'agent:main:hm-c');
|
|
275
|
+
const compaction = emitted.find((e) => e.kind === 'compaction');
|
|
276
|
+
assert.equal(compaction.outcome, 'succeeded');
|
|
277
|
+
assert.ok(/OpenClaw 压缩/.test(compaction.summary));
|
|
278
|
+
assert.equal(compaction.tokensBefore, undefined, '压缩流形状未实测,不伪造 token 前后值');
|
|
161
279
|
}
|
|
162
280
|
|
|
163
281
|
// 10. Hermes 经 ACP 家族工厂接入,未文档化能力不声明
|