@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.
- package/CHANGELOG.md +24 -0
- package/README.md +469 -467
- 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/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +172 -23
- package/package.json +13 -9
- package/scripts/acp-image-test.cjs +69 -0
- package/scripts/adapters-test.cjs +25 -0
- package/scripts/antigravity-adapter-test.cjs +647 -626
- package/scripts/codex-accounts-test.cjs +34 -0
- package/scripts/codex-adapter-test.cjs +238 -127
- package/scripts/collaboration-test.cjs +577 -262
- 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/jsonl-stdin-test.cjs +40 -31
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-update-apply-test.cjs +269 -215
- package/scripts/native-update.cjs +78 -0
- package/scripts/native-vendor-adapters-test.cjs +196 -154
- 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/salvage-rollout-writes.cjs +72 -0
- package/scripts/storage-verification-test.cjs +11 -0
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1418
- package/src/main/adapters/claude.js +15 -7
- package/src/main/adapters/codex-app-server.js +29 -11
- package/src/main/adapters/codex.js +690 -649
- package/src/main/adapters/native-acp-command.js +51 -48
- package/src/main/adapters/native-acp.js +47 -12
- 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/qoder.js +12 -8
- package/src/main/adapters/zcode.js +925 -10
- package/src/main/host/collaboration-tools.js +1 -1
- package/src/main/host/collaboration.js +971 -714
- package/src/main/host/jsonl.js +130 -120
- 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/config.js +9 -9
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1227 -1187
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- 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/desktop-control/src/renderer-cdp-control-session.ts +358 -358
- 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 +3224 -3181
- 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/src/settings/connections-page.ts +2 -2
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
|
@@ -1,154 +1,196 @@
|
|
|
1
|
-
const assert = require('node:assert/strict');
|
|
2
|
-
const oc = require('../src/main/adapters/opencode');
|
|
3
|
-
const grok = require('../src/main/adapters/grok');
|
|
4
|
-
const pi = require('../src/main/adapters/pi');
|
|
5
|
-
(async () => {
|
|
6
|
-
const events = [], calls = [];
|
|
7
|
-
const session = { nativeSessionId: 's', emit: e => events.push(e), state: { active: true, userMessages: new Set(), parts: new Map(), text: new Map(), permissions: new Map(), questions: new Map() } };
|
|
8
|
-
const event = (type, properties) => oc.projectEvent(session, { type, properties: { sessionID: 's', ...properties } }, session.emit);
|
|
9
|
-
event('message.updated', { info: { role: 'user', id: 'u' } });
|
|
10
|
-
event('message.part.updated', { part: { id: 'up', messageID: 'u', type: 'text', text: 'do not echo' } });
|
|
11
|
-
event('message.part.updated', { part: { id: 'p', messageID: 'a', type: 'text', text: 'A' } });
|
|
12
|
-
event('message.part.delta', { partID: 'p', field: 'text', delta: 'B' });
|
|
13
|
-
oc.projectPart(session, { id: 'p', messageID: 'a', type: 'text', text: 'AB' }, session.emit);
|
|
14
|
-
assert.equal(events.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), 'AB');
|
|
15
|
-
event('question.asked', { id: 'q', questions: [{ question: 'Choose', options: [{ label: 'yes' }] }] });
|
|
16
|
-
assert.equal(events.at(-1).kind, 'approval');
|
|
17
|
-
let fail = true;
|
|
18
|
-
session.host = { request: async (...args) => { if (fail) throw new Error('network'); calls.push(args); } };
|
|
19
|
-
const adapter = oc.create();
|
|
20
|
-
await assert.rejects(adapter.respond(session, 'q:0', { value: 'yes' }));
|
|
21
|
-
assert.equal(session.state.questions.get('q').index, 0);
|
|
22
|
-
fail = false;
|
|
23
|
-
await adapter.respond(session, 'q:0', { value: 'yes' });
|
|
24
|
-
assert.deepEqual(calls.at(-1)[2], { answers: [['yes']] });
|
|
25
|
-
event('permission.asked', { id: 'perm', permission: 'shell', patterns: ['test'] });
|
|
26
|
-
await assert.rejects(adapter.respond(session, 'perm', { value: 'invented' }));
|
|
27
|
-
await adapter.respond(session, 'perm', { optionId: 'always' });
|
|
28
|
-
assert.deepEqual(calls.at(-1)[2], { reply: 'always' });
|
|
29
|
-
assert.deepEqual(grok.projectUsage({ input_tokens: 12, outputTokens: 3, signature: 'private', costUsdTicks: 5 }), { inputTokens: 12, outputTokens: 3 });
|
|
30
|
-
const piFailure = pi.project({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'native quota exhausted' }] });
|
|
31
|
-
assert.equal(piFailure.kind, 'error'); assert.match(piFailure.message, /quota exhausted/);
|
|
32
|
-
let answer;
|
|
33
|
-
const gs = { pendingApprovals: new Map([['r', { options: [{ optionId: 'native-always', kind: 'allow_always' }], resolve: v => { answer = v; } }]]) };
|
|
34
|
-
await assert.rejects(grok.create().respond(gs, 'r', { optionId: 'fake' }));
|
|
35
|
-
await grok.create().respond(gs, 'r', { optionId: 'native-always' });
|
|
36
|
-
assert.equal(grok.manifest.capabilities.attachments, true);
|
|
37
|
-
const grokCalls = [];
|
|
38
|
-
const grokSession = { nativeSessionId: 'grok-1', state: { agentCapabilities: {} }, process: { request: async (method, params) => { grokCalls.push({ method, params }); return { stopReason: 'end_turn', _meta: { promptId: 'p1' } }; } } };
|
|
39
|
-
await grok.create().send(grokSession, 'hi', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
40
|
-
assert.equal(grokCalls[0].method, 'session/prompt');
|
|
41
|
-
assert.deepEqual(grokCalls[0].params.prompt, [{ type: 'text', text: 'hi' }, { type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
42
|
-
|
|
43
|
-
// Grok auto-compaction update parsing
|
|
44
|
-
const updateStarted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_started', tokens_used: 400000, context_window: 500000 });
|
|
45
|
-
assert.deepEqual(updateStarted, { type: 'started', tokensUsed: 400000, contextWindowTokens: 500000 });
|
|
46
|
-
const updateCompleted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_completed', tokens_before: 400000, tokens_after: 12000, context_window: 500000 });
|
|
47
|
-
assert.deepEqual(updateCompleted, { type: 'completed', outcome: 'succeeded', tokensBefore: 400000, tokensAfter: 12000, contextWindowTokens: 500000 });
|
|
48
|
-
const updateFailed = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_failed', error_message: 'compaction error' });
|
|
49
|
-
assert.deepEqual(updateFailed, { type: 'completed', outcome: 'failed', errorMessage: 'compaction error' });
|
|
50
|
-
const updateCancelled = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_cancelled' });
|
|
51
|
-
assert.deepEqual(updateCancelled, { type: 'completed', outcome: 'cancelled' });
|
|
52
|
-
|
|
53
|
-
// Grok commands & manual compact
|
|
54
|
-
const grokAdapterInst = grok.create();
|
|
55
|
-
const grokCmds = await grokAdapterInst.listCommands(grokSession);
|
|
56
|
-
assert.ok(grokCmds.some(c => c.id === 'compact' && c.action === 'execute'));
|
|
57
|
-
|
|
58
|
-
// Test /compact interception in send()
|
|
59
|
-
const grokCompactEmits = [];
|
|
60
|
-
grokCalls.length = 0;
|
|
61
|
-
grokSession.process.request = async (method, params) => {
|
|
62
|
-
grokCalls.push({ method, params });
|
|
63
|
-
return { outcome: 'succeeded', tokensBefore: 50000, tokensAfter: 5000 };
|
|
64
|
-
};
|
|
65
|
-
await grokAdapterInst.send(grokSession, '/compact keep recent tests', { emit: e => grokCompactEmits.push(e) });
|
|
66
|
-
assert.equal(grokCalls[0].method, 'x.ai/compact_conversation');
|
|
67
|
-
assert.deepEqual(grokCalls[0].params, { sessionId: 'grok-1', userContext: 'keep recent tests' });
|
|
68
|
-
assert.ok(grokCompactEmits.some(e => e.kind === 'compaction' && e.state === 'completed' && e.outcome === 'succeeded'));
|
|
69
|
-
assert.ok(grokCompactEmits.some(e => e.kind === 'usage' && e.usage.tokens === 5000));
|
|
70
|
-
|
|
71
|
-
// Test EventNormalizer compaction mapping
|
|
72
|
-
const { EventNormalizer } = require('../src/main/harness-adapter/event-normalizer');
|
|
73
|
-
const normalizer = new EventNormalizer({ threadId: 't-comp' });
|
|
74
|
-
normalizer.beginTurn('turn-c', 'test compact');
|
|
75
|
-
const normEvents = normalizer.normalize({ kind: 'compaction', tokensBefore: 50000, tokensAfter: 5000 });
|
|
76
|
-
assert.equal(normEvents.length, 2);
|
|
77
|
-
assert.equal(normEvents[0].type, 'item.started');
|
|
78
|
-
assert.equal(normEvents[0].payload.type, 'context_compaction');
|
|
79
|
-
assert.equal(normEvents[0].payload.tokensBefore, 50000);
|
|
80
|
-
assert.equal(normEvents[1].type, 'item.completed');
|
|
81
|
-
|
|
82
|
-
// Test projectItem mapping
|
|
83
|
-
const { projectItem } = require('../src/main/native/protocol');
|
|
84
|
-
const projected = projectItem({ id: 'norm-item-1', type: 'context_compaction' });
|
|
85
|
-
assert.deepEqual(projected, { id: 'norm-item-1', type: 'contextCompaction' });
|
|
86
|
-
|
|
87
|
-
// Pi 自动重试(stream 错误如 "Anthropic stream ended without a stop reason"):
|
|
88
|
-
// message_end 的 error 不得提前定论为 Turn 失败——agent_end 携带 willRetry,
|
|
89
|
-
// 重试成功的回答必须完整落地,全程零 error 事件(否则 Host 提前结算,
|
|
90
|
-
// 重试结果被丢弃,用户重发还会撞上原生 "Agent is already processing")。
|
|
91
|
-
{
|
|
92
|
-
const emitted = [];
|
|
93
|
-
const fakeProc = { harnessMixAnswer: '', command: async () => ({ leafId: 'leaf-1' }) };
|
|
94
|
-
const fwd = (event) => pi.forwardEvent(fakeProc, event, e => emitted.push(e));
|
|
95
|
-
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Anthropic stream ended without a stop reason' };
|
|
96
|
-
fwd({ type: 'message_end', message: failedMsg });
|
|
97
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, 'message_end 不得提前报错');
|
|
98
|
-
fwd({ type: 'agent_end', willRetry: true, messages: [{ role: 'user', content: [{ type: 'text', text: '登录好了' }] }, failedMsg] });
|
|
99
|
-
assert.equal(emitted.at(-1).kind, 'status');
|
|
100
|
-
fwd({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: failedMsg.errorMessage });
|
|
101
|
-
assert.equal(emitted.at(-1).kind, 'status');
|
|
102
|
-
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '好的,' } });
|
|
103
|
-
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '开始执行。' } });
|
|
104
|
-
fwd({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' } });
|
|
105
|
-
assert.equal(emitted.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), '好的,开始执行。', '已流式回答不得重复补发');
|
|
106
|
-
fwd({ type: 'auto_retry_end', success: true, attempt: 1 });
|
|
107
|
-
fwd({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' }] });
|
|
108
|
-
fwd({ type: 'agent_settled', sessionId: 's-retry' });
|
|
109
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
110
|
-
assert.equal(emitted.at(-1).kind, 'completed');
|
|
111
|
-
assert.equal(emitted.at(-1).nativeRef.checkpointId, 'leaf-1');
|
|
112
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, '重试成功全程不得出现 error');
|
|
113
|
-
}
|
|
114
|
-
// Pi 重试耗尽/不可重试:error 在 agent_end(willRetry:false)处恰好结算一次
|
|
115
|
-
{
|
|
116
|
-
const emitted = [];
|
|
117
|
-
const fwd = (event) => pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) }, event, e => emitted.push(e));
|
|
118
|
-
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'native quota exhausted' };
|
|
119
|
-
fwd({ type: 'message_end', message: failedMsg });
|
|
120
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0);
|
|
121
|
-
fwd({ type: 'agent_end', willRetry: false, messages: [failedMsg] });
|
|
122
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 1);
|
|
123
|
-
assert.match(emitted.find(e => e.kind === 'error').message, /quota exhausted/);
|
|
124
|
-
}
|
|
125
|
-
// Pi 未流式回答的 message_end 回退补发仍然有效
|
|
126
|
-
{
|
|
127
|
-
const emitted = [];
|
|
128
|
-
pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) },
|
|
129
|
-
{ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '非流式回答' }], stopReason: 'stop' } },
|
|
130
|
-
e => emitted.push(e));
|
|
131
|
-
assert.ok(emitted.some(e => e.kind === 'text-delta' && e.text === '非流式回答'));
|
|
132
|
-
}
|
|
133
|
-
// Pi send():原生拒绝 "already processing"(Host 与原生状态偶发分叉,如取消竞态)时
|
|
134
|
-
// 按原生协议提示以 followUp 排队重发;其他错误原样抛出
|
|
135
|
-
{
|
|
136
|
-
const calls = [];
|
|
137
|
-
let rejectOnce = true;
|
|
138
|
-
const busySession = { process: { harnessMixAnswer: '', command: async (payload) => {
|
|
139
|
-
calls.push(payload);
|
|
140
|
-
if (rejectOnce && payload.type === 'prompt') { rejectOnce = false; throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); }
|
|
141
|
-
return {};
|
|
142
|
-
} } };
|
|
143
|
-
await pi.create().send(busySession, '登录好了', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
144
|
-
assert.equal(calls.length, 2);
|
|
145
|
-
assert.equal(calls[0].streamingBehavior, undefined);
|
|
146
|
-
assert.equal(calls[1].streamingBehavior, 'followUp');
|
|
147
|
-
assert.equal(calls[1].message, '登录好了');
|
|
148
|
-
assert.deepEqual(calls[1].images, [{ type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
149
|
-
const failingSession = { process: { harnessMixAnswer: '', command: async () => { throw new Error('Authentication failed'); } } };
|
|
150
|
-
await assert.rejects(pi.create().send(failingSession, 'hi', { emit: () => {} }, {}), /Authentication failed/);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const oc = require('../src/main/adapters/opencode');
|
|
3
|
+
const grok = require('../src/main/adapters/grok');
|
|
4
|
+
const pi = require('../src/main/adapters/pi');
|
|
5
|
+
(async () => {
|
|
6
|
+
const events = [], calls = [];
|
|
7
|
+
const session = { nativeSessionId: 's', emit: e => events.push(e), state: { active: true, userMessages: new Set(), parts: new Map(), text: new Map(), permissions: new Map(), questions: new Map() } };
|
|
8
|
+
const event = (type, properties) => oc.projectEvent(session, { type, properties: { sessionID: 's', ...properties } }, session.emit);
|
|
9
|
+
event('message.updated', { info: { role: 'user', id: 'u' } });
|
|
10
|
+
event('message.part.updated', { part: { id: 'up', messageID: 'u', type: 'text', text: 'do not echo' } });
|
|
11
|
+
event('message.part.updated', { part: { id: 'p', messageID: 'a', type: 'text', text: 'A' } });
|
|
12
|
+
event('message.part.delta', { partID: 'p', field: 'text', delta: 'B' });
|
|
13
|
+
oc.projectPart(session, { id: 'p', messageID: 'a', type: 'text', text: 'AB' }, session.emit);
|
|
14
|
+
assert.equal(events.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), 'AB');
|
|
15
|
+
event('question.asked', { id: 'q', questions: [{ question: 'Choose', options: [{ label: 'yes' }] }] });
|
|
16
|
+
assert.equal(events.at(-1).kind, 'approval');
|
|
17
|
+
let fail = true;
|
|
18
|
+
session.host = { request: async (...args) => { if (fail) throw new Error('network'); calls.push(args); } };
|
|
19
|
+
const adapter = oc.create();
|
|
20
|
+
await assert.rejects(adapter.respond(session, 'q:0', { value: 'yes' }));
|
|
21
|
+
assert.equal(session.state.questions.get('q').index, 0);
|
|
22
|
+
fail = false;
|
|
23
|
+
await adapter.respond(session, 'q:0', { value: 'yes' });
|
|
24
|
+
assert.deepEqual(calls.at(-1)[2], { answers: [['yes']] });
|
|
25
|
+
event('permission.asked', { id: 'perm', permission: 'shell', patterns: ['test'] });
|
|
26
|
+
await assert.rejects(adapter.respond(session, 'perm', { value: 'invented' }));
|
|
27
|
+
await adapter.respond(session, 'perm', { optionId: 'always' });
|
|
28
|
+
assert.deepEqual(calls.at(-1)[2], { reply: 'always' });
|
|
29
|
+
assert.deepEqual(grok.projectUsage({ input_tokens: 12, outputTokens: 3, signature: 'private', costUsdTicks: 5 }), { inputTokens: 12, outputTokens: 3 });
|
|
30
|
+
const piFailure = pi.project({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'native quota exhausted' }] });
|
|
31
|
+
assert.equal(piFailure.kind, 'error'); assert.match(piFailure.message, /quota exhausted/);
|
|
32
|
+
let answer;
|
|
33
|
+
const gs = { pendingApprovals: new Map([['r', { options: [{ optionId: 'native-always', kind: 'allow_always' }], resolve: v => { answer = v; } }]]) };
|
|
34
|
+
await assert.rejects(grok.create().respond(gs, 'r', { optionId: 'fake' }));
|
|
35
|
+
await grok.create().respond(gs, 'r', { optionId: 'native-always' });
|
|
36
|
+
assert.equal(grok.manifest.capabilities.attachments, true);
|
|
37
|
+
const grokCalls = [];
|
|
38
|
+
const grokSession = { nativeSessionId: 'grok-1', state: { agentCapabilities: {} }, process: { request: async (method, params) => { grokCalls.push({ method, params }); return { stopReason: 'end_turn', _meta: { promptId: 'p1' } }; } } };
|
|
39
|
+
await grok.create().send(grokSession, 'hi', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
40
|
+
assert.equal(grokCalls[0].method, 'session/prompt');
|
|
41
|
+
assert.deepEqual(grokCalls[0].params.prompt, [{ type: 'text', text: 'hi' }, { type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
42
|
+
|
|
43
|
+
// Grok auto-compaction update parsing
|
|
44
|
+
const updateStarted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_started', tokens_used: 400000, context_window: 500000 });
|
|
45
|
+
assert.deepEqual(updateStarted, { type: 'started', tokensUsed: 400000, contextWindowTokens: 500000 });
|
|
46
|
+
const updateCompleted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_completed', tokens_before: 400000, tokens_after: 12000, context_window: 500000 });
|
|
47
|
+
assert.deepEqual(updateCompleted, { type: 'completed', outcome: 'succeeded', tokensBefore: 400000, tokensAfter: 12000, contextWindowTokens: 500000 });
|
|
48
|
+
const updateFailed = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_failed', error_message: 'compaction error' });
|
|
49
|
+
assert.deepEqual(updateFailed, { type: 'completed', outcome: 'failed', errorMessage: 'compaction error' });
|
|
50
|
+
const updateCancelled = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_cancelled' });
|
|
51
|
+
assert.deepEqual(updateCancelled, { type: 'completed', outcome: 'cancelled' });
|
|
52
|
+
|
|
53
|
+
// Grok commands & manual compact
|
|
54
|
+
const grokAdapterInst = grok.create();
|
|
55
|
+
const grokCmds = await grokAdapterInst.listCommands(grokSession);
|
|
56
|
+
assert.ok(grokCmds.some(c => c.id === 'compact' && c.action === 'execute'));
|
|
57
|
+
|
|
58
|
+
// Test /compact interception in send()
|
|
59
|
+
const grokCompactEmits = [];
|
|
60
|
+
grokCalls.length = 0;
|
|
61
|
+
grokSession.process.request = async (method, params) => {
|
|
62
|
+
grokCalls.push({ method, params });
|
|
63
|
+
return { outcome: 'succeeded', tokensBefore: 50000, tokensAfter: 5000 };
|
|
64
|
+
};
|
|
65
|
+
await grokAdapterInst.send(grokSession, '/compact keep recent tests', { emit: e => grokCompactEmits.push(e) });
|
|
66
|
+
assert.equal(grokCalls[0].method, 'x.ai/compact_conversation');
|
|
67
|
+
assert.deepEqual(grokCalls[0].params, { sessionId: 'grok-1', userContext: 'keep recent tests' });
|
|
68
|
+
assert.ok(grokCompactEmits.some(e => e.kind === 'compaction' && e.state === 'completed' && e.outcome === 'succeeded'));
|
|
69
|
+
assert.ok(grokCompactEmits.some(e => e.kind === 'usage' && e.usage.tokens === 5000));
|
|
70
|
+
|
|
71
|
+
// Test EventNormalizer compaction mapping
|
|
72
|
+
const { EventNormalizer } = require('../src/main/harness-adapter/event-normalizer');
|
|
73
|
+
const normalizer = new EventNormalizer({ threadId: 't-comp' });
|
|
74
|
+
normalizer.beginTurn('turn-c', 'test compact');
|
|
75
|
+
const normEvents = normalizer.normalize({ kind: 'compaction', tokensBefore: 50000, tokensAfter: 5000 });
|
|
76
|
+
assert.equal(normEvents.length, 2);
|
|
77
|
+
assert.equal(normEvents[0].type, 'item.started');
|
|
78
|
+
assert.equal(normEvents[0].payload.type, 'context_compaction');
|
|
79
|
+
assert.equal(normEvents[0].payload.tokensBefore, 50000);
|
|
80
|
+
assert.equal(normEvents[1].type, 'item.completed');
|
|
81
|
+
|
|
82
|
+
// Test projectItem mapping
|
|
83
|
+
const { projectItem } = require('../src/main/native/protocol');
|
|
84
|
+
const projected = projectItem({ id: 'norm-item-1', type: 'context_compaction' });
|
|
85
|
+
assert.deepEqual(projected, { id: 'norm-item-1', type: 'contextCompaction' });
|
|
86
|
+
|
|
87
|
+
// Pi 自动重试(stream 错误如 "Anthropic stream ended without a stop reason"):
|
|
88
|
+
// message_end 的 error 不得提前定论为 Turn 失败——agent_end 携带 willRetry,
|
|
89
|
+
// 重试成功的回答必须完整落地,全程零 error 事件(否则 Host 提前结算,
|
|
90
|
+
// 重试结果被丢弃,用户重发还会撞上原生 "Agent is already processing")。
|
|
91
|
+
{
|
|
92
|
+
const emitted = [];
|
|
93
|
+
const fakeProc = { harnessMixAnswer: '', command: async () => ({ leafId: 'leaf-1' }) };
|
|
94
|
+
const fwd = (event) => pi.forwardEvent(fakeProc, event, e => emitted.push(e));
|
|
95
|
+
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Anthropic stream ended without a stop reason' };
|
|
96
|
+
fwd({ type: 'message_end', message: failedMsg });
|
|
97
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, 'message_end 不得提前报错');
|
|
98
|
+
fwd({ type: 'agent_end', willRetry: true, messages: [{ role: 'user', content: [{ type: 'text', text: '登录好了' }] }, failedMsg] });
|
|
99
|
+
assert.equal(emitted.at(-1).kind, 'status');
|
|
100
|
+
fwd({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: failedMsg.errorMessage });
|
|
101
|
+
assert.equal(emitted.at(-1).kind, 'status');
|
|
102
|
+
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '好的,' } });
|
|
103
|
+
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '开始执行。' } });
|
|
104
|
+
fwd({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' } });
|
|
105
|
+
assert.equal(emitted.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), '好的,开始执行。', '已流式回答不得重复补发');
|
|
106
|
+
fwd({ type: 'auto_retry_end', success: true, attempt: 1 });
|
|
107
|
+
fwd({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' }] });
|
|
108
|
+
fwd({ type: 'agent_settled', sessionId: 's-retry' });
|
|
109
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
110
|
+
assert.equal(emitted.at(-1).kind, 'completed');
|
|
111
|
+
assert.equal(emitted.at(-1).nativeRef.checkpointId, 'leaf-1');
|
|
112
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, '重试成功全程不得出现 error');
|
|
113
|
+
}
|
|
114
|
+
// Pi 重试耗尽/不可重试:error 在 agent_end(willRetry:false)处恰好结算一次
|
|
115
|
+
{
|
|
116
|
+
const emitted = [];
|
|
117
|
+
const fwd = (event) => pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) }, event, e => emitted.push(e));
|
|
118
|
+
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'native quota exhausted' };
|
|
119
|
+
fwd({ type: 'message_end', message: failedMsg });
|
|
120
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0);
|
|
121
|
+
fwd({ type: 'agent_end', willRetry: false, messages: [failedMsg] });
|
|
122
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 1);
|
|
123
|
+
assert.match(emitted.find(e => e.kind === 'error').message, /quota exhausted/);
|
|
124
|
+
}
|
|
125
|
+
// Pi 未流式回答的 message_end 回退补发仍然有效
|
|
126
|
+
{
|
|
127
|
+
const emitted = [];
|
|
128
|
+
pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) },
|
|
129
|
+
{ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '非流式回答' }], stopReason: 'stop' } },
|
|
130
|
+
e => emitted.push(e));
|
|
131
|
+
assert.ok(emitted.some(e => e.kind === 'text-delta' && e.text === '非流式回答'));
|
|
132
|
+
}
|
|
133
|
+
// Pi send():原生拒绝 "already processing"(Host 与原生状态偶发分叉,如取消竞态)时
|
|
134
|
+
// 按原生协议提示以 followUp 排队重发;其他错误原样抛出
|
|
135
|
+
{
|
|
136
|
+
const calls = [];
|
|
137
|
+
let rejectOnce = true;
|
|
138
|
+
const busySession = { process: { harnessMixAnswer: '', command: async (payload) => {
|
|
139
|
+
calls.push(payload);
|
|
140
|
+
if (rejectOnce && payload.type === 'prompt') { rejectOnce = false; throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); }
|
|
141
|
+
return {};
|
|
142
|
+
} } };
|
|
143
|
+
await pi.create().send(busySession, '登录好了', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
144
|
+
assert.equal(calls.length, 2);
|
|
145
|
+
assert.equal(calls[0].streamingBehavior, undefined);
|
|
146
|
+
assert.equal(calls[1].streamingBehavior, 'followUp');
|
|
147
|
+
assert.equal(calls[1].message, '登录好了');
|
|
148
|
+
assert.deepEqual(calls[1].images, [{ type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
149
|
+
const failingSession = { process: { harnessMixAnswer: '', command: async () => { throw new Error('Authentication failed'); } } };
|
|
150
|
+
await assert.rejects(pi.create().send(failingSession, 'hi', { emit: () => {} }, {}), /Authentication failed/);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// qoder 解析不得回退到 PATH 上的裸 `qoder`(IDE 启动器):它能通过 --version 可用性
|
|
154
|
+
// 探测,却把每次 --acp 会话打开变成完整 GUI 启动且永远完不成 ACP 握手。
|
|
155
|
+
// 只有无头 qodercli 或显式 HARNESS_MIX_QODER_EXECUTABLE 覆盖是合法入口。
|
|
156
|
+
{
|
|
157
|
+
const fs = require('node:fs');
|
|
158
|
+
const os = require('node:os');
|
|
159
|
+
const path = require('node:path');
|
|
160
|
+
const { nativeCommand } = require('../src/main/adapters/native-acp-command');
|
|
161
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-qoder-path-'));
|
|
162
|
+
fs.writeFileSync(path.join(dir, process.platform === 'win32' ? 'qoder.cmd' : 'qoder'), '');
|
|
163
|
+
const originalPath = process.env.PATH;
|
|
164
|
+
const originalOverride = process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
165
|
+
// nativeCommand 在 PATH 之外还探测固定根(APPDATA/npm、LOCALAPPDATA/Kiro-Cli、
|
|
166
|
+
// ~/.local/bin):本机真实安装的 qodercli 会从这些根解析成功,让“必须抛错”的
|
|
167
|
+
// 断言失效。测试期间把这几个根全部沙箱到临时目录。
|
|
168
|
+
const sandbox = { APPDATA: process.env.APPDATA, LOCALAPPDATA: process.env.LOCALAPPDATA, USERPROFILE: process.env.USERPROFILE, HOME: process.env.HOME };
|
|
169
|
+
process.env.APPDATA = dir;
|
|
170
|
+
process.env.LOCALAPPDATA = dir;
|
|
171
|
+
process.env.USERPROFILE = dir;
|
|
172
|
+
process.env.HOME = dir;
|
|
173
|
+
delete process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
174
|
+
const pathWithoutQodercli = originalPath.split(path.delimiter).filter(Boolean).filter(entry => {
|
|
175
|
+
try { return !fs.existsSync(path.join(entry, process.platform === 'win32' ? 'qodercli.cmd' : 'qodercli')); } catch { return true; }
|
|
176
|
+
}).join(path.delimiter);
|
|
177
|
+
try {
|
|
178
|
+
process.env.PATH = `${dir}${path.delimiter}${pathWithoutQodercli}`;
|
|
179
|
+
assert.throws(() => nativeCommand('qoder', ['--acp']), /qodercli 未安装/, '裸 qoder(IDE 启动器)不得作为 ACP 回退');
|
|
180
|
+
const override = path.join(dir, 'custom-acp.exe');
|
|
181
|
+
fs.writeFileSync(override, '');
|
|
182
|
+
process.env.HARNESS_MIX_QODER_EXECUTABLE = override;
|
|
183
|
+
assert.equal(nativeCommand('qoder', ['--acp']).command, override, '显式覆盖仍然生效');
|
|
184
|
+
} finally {
|
|
185
|
+
process.env.PATH = originalPath;
|
|
186
|
+
for (const [key, value] of Object.entries(sandbox)) {
|
|
187
|
+
if (value === undefined) delete process.env[key];
|
|
188
|
+
else process.env[key] = value;
|
|
189
|
+
}
|
|
190
|
+
if (originalOverride === undefined) delete process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
191
|
+
else process.env.HARNESS_MIX_QODER_EXECUTABLE = originalOverride;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('Native adapters: streaming deduplication, user suppression, question retry, exact native approvals, sanitized usage, grok images, grok compaction & UI projection, qoder ACP resolution PASS');
|
|
196
|
+
})().catch(error => { console.error(error); process.exitCode = 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 家族工厂接入,未文档化能力不声明
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OpenClaw 托管 MCP 对账实测探针:
|
|
2
|
+
// 备份 openclaw.json → 推送一个仓库内 stdio MCP 服务器(collaboration-mcp.cjs)→
|
|
3
|
+
// 验证 openclaw mcp list / Gateway tools 面能看到 → 移除 → 断言用户键零变化。
|
|
4
|
+
// 任何一步异常都会用备份还原配置。
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { execFileSync } = require("node:child_process");
|
|
8
|
+
const { OpenClawGatewayHost } = require("../src/main/adapters/openclaw-gateway");
|
|
9
|
+
const { applyManagedMcpServers, MCP_OWNED_PREFIX } = require("../src/main/adapters/openclaw");
|
|
10
|
+
const { OPENCLAW_CONFIG } = require("../src/main/adapters/openclaw-gateway");
|
|
11
|
+
const { cliSpawn } = require("../src/main/host/jsonl");
|
|
12
|
+
|
|
13
|
+
const OUT_DIR = path.join(__dirname, "..", "output", "openclaw-mcp-probe");
|
|
14
|
+
const SERVER = path.join(__dirname, "..", "src", "main", "host", "collaboration-mcp.cjs");
|
|
15
|
+
|
|
16
|
+
const readCfg = () => JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, "utf8"));
|
|
17
|
+
const stripOwned = (servers) => Object.fromEntries(Object.entries(servers ?? {}).filter(([k]) => !k.startsWith(MCP_OWNED_PREFIX)));
|
|
18
|
+
// 键序无关的规范化序列化(CLI 规范化会重排键序);meta.lastTouched* 是 CLI 自维护簿记,不计入
|
|
19
|
+
const canonical = (value) => {
|
|
20
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
21
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
const userViewOf = (cfg) => {
|
|
25
|
+
const { meta, ...rest } = cfg;
|
|
26
|
+
const mcp = { ...rest.mcp, servers: stripOwned(rest.mcp?.servers) };
|
|
27
|
+
// CLI 规范化会删除空的 mcp/servers 键;空对象与键缺失视为等价(不含用户内容)
|
|
28
|
+
if (mcp.servers && Object.keys(mcp.servers).length === 0) delete mcp.servers;
|
|
29
|
+
if (Object.keys(mcp).length === 0) delete rest.mcp; else rest.mcp = mcp;
|
|
30
|
+
return rest;
|
|
31
|
+
};
|
|
32
|
+
const userView = (cfg) => JSON.stringify(canonical(userViewOf(cfg)));
|
|
33
|
+
const diffViews = (beforeStr, nowStr) => {
|
|
34
|
+
const walk = (x, y, p) => {
|
|
35
|
+
for (const k of new Set([...Object.keys(x || {}), ...Object.keys(y || {})])) {
|
|
36
|
+
const q = p ? `${p}.${k}` : k;
|
|
37
|
+
if (JSON.stringify(x?.[k]) !== JSON.stringify(y?.[k])) {
|
|
38
|
+
if (x?.[k] && y?.[k] && typeof x[k] === "object" && typeof y[k] === "object" && !Array.isArray(x[k]) && !Array.isArray(y[k])) walk(x[k], y[k], q);
|
|
39
|
+
else console.error(`USERVIEW DIFF: ${q} | ${JSON.stringify(x?.[k])?.slice(0, 90)} -> ${JSON.stringify(y?.[k])?.slice(0, 90)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(JSON.parse(beforeStr), JSON.parse(nowStr), "");
|
|
44
|
+
};
|
|
45
|
+
const mcpList = () => {
|
|
46
|
+
const cli = cliSpawn("openclaw", ["mcp", "list", "--json"]);
|
|
47
|
+
return JSON.parse(execFileSync(cli.command, cli.args, { encoding: "utf8", windowsHide: true, timeout: 20000 }));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
(async () => {
|
|
51
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
52
|
+
const backup = path.join(OUT_DIR, `openclaw.json.backup-${Date.now()}`);
|
|
53
|
+
fs.copyFileSync(OPENCLAW_CONFIG, backup);
|
|
54
|
+
console.log("backup ->", backup);
|
|
55
|
+
const before = userView(readCfg());
|
|
56
|
+
let ok = false;
|
|
57
|
+
try {
|
|
58
|
+
// 1. 推送托管服务器(与适配器 open() 同路径)
|
|
59
|
+
await applyManagedMcpServers([{ name: "probe", command: process.execPath, args: [SERVER], env: {} }], (line) => console.log(line));
|
|
60
|
+
const listed = mcpList();
|
|
61
|
+
console.log("mcp list after push ->", JSON.stringify(Object.keys(listed)));
|
|
62
|
+
if (!listed[MCP_OWNED_PREFIX + "probe"]) throw new Error("自有键未出现在 mcp list");
|
|
63
|
+
const viewNow = userView(readCfg());
|
|
64
|
+
if (viewNow !== before) {
|
|
65
|
+
diffViews(before, viewNow);
|
|
66
|
+
throw new Error("用户键视图发生变化(不应发生)");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Gateway 侧确认注册表可读;tools 面尝试 catalog/effective(effective 需要 sessionKey)
|
|
70
|
+
const host = await OpenClawGatewayHost.acquire(() => {});
|
|
71
|
+
try {
|
|
72
|
+
const sessionKey = `harness-mix-mcp-probe-${Date.now()}`;
|
|
73
|
+
await host.call("sessions.create", { key: sessionKey, label: `Harness Mix MCP probe ${Date.now()}` });
|
|
74
|
+
const evidence = {};
|
|
75
|
+
evidence.catalog = await host.call("tools.catalog", {}).catch((e) => ({ error: e.message }));
|
|
76
|
+
evidence.effective = await host.call("tools.effective", { sessionKey }).catch((e) => ({ error: e.message }));
|
|
77
|
+
const text = JSON.stringify(evidence);
|
|
78
|
+
fs.writeFileSync(path.join(OUT_DIR, "tools-evidence.json"), JSON.stringify(evidence, null, 2));
|
|
79
|
+
const hitCount = (text.match(new RegExp(MCP_OWNED_PREFIX + "probe", "g")) || []).length;
|
|
80
|
+
console.log("tools evidence saved; owned-server mentions:", hitCount);
|
|
81
|
+
console.log("effective summary ->", JSON.stringify(evidence.effective).slice(0, 400));
|
|
82
|
+
} finally { await OpenClawGatewayHost.release(); }
|
|
83
|
+
|
|
84
|
+
// 3. 移除托管服务器,断言注册表回到初始用户视图
|
|
85
|
+
await applyManagedMcpServers([], (line) => console.log(line));
|
|
86
|
+
const after = mcpList();
|
|
87
|
+
console.log("mcp list after cleanup ->", JSON.stringify(Object.keys(after)));
|
|
88
|
+
if (after[MCP_OWNED_PREFIX + "probe"]) throw new Error("自有键未被移除");
|
|
89
|
+
const finalView = userView(readCfg());
|
|
90
|
+
if (finalView !== before) { diffViews(before, finalView); throw new Error("清理后用户键视图与初始不一致"); }
|
|
91
|
+
ok = true;
|
|
92
|
+
console.log("PROBE OK:推送/注册表可见/清理/用户键零变化 全部通过");
|
|
93
|
+
} finally {
|
|
94
|
+
if (!ok) {
|
|
95
|
+
fs.copyFileSync(backup, OPENCLAW_CONFIG);
|
|
96
|
+
console.error("已用备份还原 openclaw.json");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
process.exit(ok ? 0 : 1);
|
|
100
|
+
})().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });
|