@harness-mix/cli 0.2.2 → 0.2.4
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 +25 -0
- package/README.md +469 -467
- package/output/native-build/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +23 -4
- package/package.json +16 -9
- package/scripts/antigravity-adapter-test.cjs +647 -626
- package/scripts/codex-adapter-test.cjs +162 -127
- package/scripts/collaboration-test.cjs +274 -262
- package/scripts/core-review-test.cjs +68 -0
- package/scripts/delegation-await-test.cjs +76 -0
- package/scripts/jsonl-stdin-test.cjs +40 -0
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-protocol-test.cjs +14 -1
- 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/salvage-rollout-writes.cjs +72 -0
- package/scripts/send-cancel-race-test.cjs +80 -0
- package/scripts/send-pre-turn-cancel-test.cjs +100 -0
- package/scripts/stuck-turn-test.cjs +6 -1
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1415
- package/src/main/adapters/codex.js +656 -649
- package/src/main/adapters/native-acp-command.js +51 -48
- package/src/main/adapters/native-acp.js +47 -12
- package/src/main/adapters/qoder.js +12 -8
- package/src/main/adapters/zcode.js +921 -10
- package/src/main/harness-adapter/event-normalizer.js +5 -2
- package/src/main/host/collaboration.js +723 -715
- package/src/main/host/jsonl.js +130 -116
- package/src/main/host/runtime.js +30 -14
- package/src/main/native/config.js +9 -9
- package/src/main/native/host.js +2 -0
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1221 -1177
- package/src/main/native/secure-store.js +2 -0
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- package/src/main/workspace/core-review.js +13 -5
- package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3211 -3181
- package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
const { projectReview } = require('../src/main/workspace/core-review');
|
|
7
|
+
|
|
8
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
async function until(fn) {
|
|
10
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
11
|
+
throw new Error('timed out');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 审查归属(core-review foreignPaths):foreign 集合扫描同目录其他会话的历史全部轮次,
|
|
15
|
+
// 旧会话碰过的路径会永久滞留其中。本轮工具已明确触碰的文件属本会话自身的正向事实,
|
|
16
|
+
// 不得被 foreign 误剔——否则本轮编辑从审查卡片与撤回列表中消失。
|
|
17
|
+
(async () => {
|
|
18
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-core-review-'));
|
|
19
|
+
const root = path.join(directory, 'workspace');
|
|
20
|
+
await fs.mkdir(root);
|
|
21
|
+
|
|
22
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
23
|
+
await rt.store.load();
|
|
24
|
+
|
|
25
|
+
const emitters = new Map();
|
|
26
|
+
rt.adapters.set('test', {
|
|
27
|
+
manifest: { id: 'test', name: 'Test', capabilities: {} },
|
|
28
|
+
async open(input) { emitters.set(input.thread.id, input.emit); return {}; },
|
|
29
|
+
async send() {}, async cancel() {}, async close() {},
|
|
30
|
+
});
|
|
31
|
+
rt.status.test = { available: true };
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// 线程 B(同目录旧会话):历史上编辑过 src/index.js 与 other.js
|
|
35
|
+
const threadB = await rt.createThread({ harnessId: 'test', cwd: root });
|
|
36
|
+
await rt.send(threadB.id, 'B 的历史编辑');
|
|
37
|
+
emitters.get(threadB.id)({ kind: 'tool', toolCallId: 'b1', title: 'Edit', state: 'completed', path: 'src/index.js', input: '{}' });
|
|
38
|
+
emitters.get(threadB.id)({ kind: 'tool', toolCallId: 'b2', title: 'Edit', state: 'completed', path: 'other.js', input: '{}' });
|
|
39
|
+
emitters.get(threadB.id)({ kind: 'completed', finalAnswer: true });
|
|
40
|
+
await until(() => !rt.execution.isRunning(threadB.id));
|
|
41
|
+
|
|
42
|
+
// 线程 A:本轮自己用工具编辑了 src/index.js(正向触碰证据),未碰 other.js
|
|
43
|
+
const threadA = await rt.createThread({ harnessId: 'test', cwd: root });
|
|
44
|
+
await rt.send(threadA.id, 'A 的本轮编辑');
|
|
45
|
+
const messageA = threadA.messages.at(-1);
|
|
46
|
+
assert.ok(messageA.coreTurnId, '本轮消息携带 Core Turn');
|
|
47
|
+
emitters.get(threadA.id)({ kind: 'tool', toolCallId: 'a1', title: 'Edit', state: 'completed', path: 'src/index.js', input: '{}' });
|
|
48
|
+
emitters.get(threadA.id)({ kind: 'completed', finalAnswer: true });
|
|
49
|
+
await until(() => !rt.execution.isRunning(threadA.id));
|
|
50
|
+
|
|
51
|
+
// 目录级快照同时捕到两个文件的变化(含 B 的历史编辑残留)
|
|
52
|
+
const record = { id: 'synthetic', at: Date.now(), endedAt: Date.now(), skipped: 0, concurrent: false, changes: [
|
|
53
|
+
{ path: 'src/index.js', before: { text: 'old index\n' }, after: { text: 'new index\n' }, added: 1, removed: 1 },
|
|
54
|
+
{ path: 'other.js', before: { text: 'old other\n' }, after: { text: 'new other\n' }, added: 1, removed: 1 },
|
|
55
|
+
] };
|
|
56
|
+
const summary = await projectReview(rt, threadA, messageA, record);
|
|
57
|
+
const paths = summary.files.map(f => f.path);
|
|
58
|
+
assert.ok(paths.includes('src/index.js'), '本轮触碰的文件不被 foreign 历史归属误剔');
|
|
59
|
+
assert.ok(!paths.includes('other.js'), '本轮未触碰的 foreign 文件仍被剔除');
|
|
60
|
+
|
|
61
|
+
console.log('core-review-test: this-turn touched paths win over historical foreign attribution; untouched foreign paths stay excluded');
|
|
62
|
+
} finally {
|
|
63
|
+
await rt.close();
|
|
64
|
+
}
|
|
65
|
+
})().catch(error => {
|
|
66
|
+
console.error(error);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn, label = 'condition') {
|
|
9
|
+
for (let i = 0; i < 500; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error(`timed out: ${label}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// /delegate 委派等待链(#awaitDelegation):
|
|
14
|
+
// 非阻塞适配器(Pi 家族收到 prompt ack 即返回)的回合由异步事件流结算,
|
|
15
|
+
// 旧实现只兜底 30 秒——子任务超过 30 秒父线程就被误判成功且丢失结果。
|
|
16
|
+
// 修复后:等待至子任务真正结算(上限 delegationTimeoutMs,对齐协作编排 30 分钟),
|
|
17
|
+
// 超时主动取消子任务并向父线程回报错误。
|
|
18
|
+
(async () => {
|
|
19
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-delegation-await-'));
|
|
20
|
+
const root = path.join(directory, 'workspace');
|
|
21
|
+
await fs.mkdir(root);
|
|
22
|
+
|
|
23
|
+
const rt = new HostRuntime({ dataDirectory: directory, delegationTimeoutMs: 600 });
|
|
24
|
+
await rt.store.load();
|
|
25
|
+
|
|
26
|
+
const emits = new Map();
|
|
27
|
+
const cancels = new Map();
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
31
|
+
// 非阻塞:ack 即返回,Turn 结算完全由后续 emit 驱动(Pi 家族形态)
|
|
32
|
+
async send(session) {},
|
|
33
|
+
async cancel(session) { cancels.set(session.threadId, (cancels.get(session.threadId) ?? 0) + 1); },
|
|
34
|
+
async close() {},
|
|
35
|
+
};
|
|
36
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
37
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 1. 正常路径:adapter.send 早已返回,子任务经异步事件结算后答案回投父线程
|
|
41
|
+
const parent = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
42
|
+
const { child, turn } = await rt.delegateTask({ fromThreadId: parent.id, harnessId: 'test-harness', task: '干活' });
|
|
43
|
+
assert.equal(rt.execution.isRunning(parent.id), true, '父线程协作 Turn 运行中');
|
|
44
|
+
await sleep(150); // adapter.send 已返回而子任务未结算:父线程必须继续等待
|
|
45
|
+
assert.equal(rt.execution.isRunning(parent.id), true, '子任务未结算时父线程不得提前收尾');
|
|
46
|
+
emits.get(child.id)({ kind: 'text-delta', text: '子任务结论' });
|
|
47
|
+
emits.get(child.id)({ kind: 'completed', finalAnswer: true });
|
|
48
|
+
await until(() => !rt.execution.isRunning(parent.id), 'parent settles after child completes');
|
|
49
|
+
const doneItem = rt.core.getItemsForTurn(turn.id).find(i => i.type === 'tool_call');
|
|
50
|
+
assert.equal(doneItem.status, 'completed', '子任务成功后父线程协作工具项结算为完成');
|
|
51
|
+
assert.match(JSON.stringify(doneItem), /子任务结论/, '子任务最终文本回投到父线程');
|
|
52
|
+
assert.equal(cancels.get(child.id) ?? 0, 0, '正常结算不触发取消');
|
|
53
|
+
|
|
54
|
+
// 2. 超时路径:子任务永不结算且持续有事件(看门狗管不到),超过 delegationTimeoutMs
|
|
55
|
+
// 父线程报错收尾且子任务被主动取消(旧实现 30s 后误判成功、子任务成孤儿)
|
|
56
|
+
const parent2 = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
57
|
+
const d2 = await rt.delegateTask({ fromThreadId: parent2.id, harnessId: 'test-harness', task: '长跑任务' });
|
|
58
|
+
const heartbeat = setInterval(() => emits.get(d2.child.id)?.({ kind: 'text-delta', text: '.' }), 100);
|
|
59
|
+
try {
|
|
60
|
+
await until(() => !rt.execution.isRunning(parent2.id), 'parent settles on delegation timeout');
|
|
61
|
+
} finally {
|
|
62
|
+
clearInterval(heartbeat);
|
|
63
|
+
}
|
|
64
|
+
const failItem = rt.core.getItemsForTurn(d2.turn.id).find(i => i.type === 'tool_call');
|
|
65
|
+
assert.equal(failItem.status, 'error', '超时后父线程协作工具项结算为错误');
|
|
66
|
+
assert.match(JSON.stringify(failItem), /超时|未结算/, '错误说明包含超时原因');
|
|
67
|
+
assert.ok((cancels.get(d2.child.id) ?? 0) >= 1, '超时主动取消子任务,不留孤儿进程');
|
|
68
|
+
|
|
69
|
+
console.log('delegation-await-test: parent awaits async child settlement; timeout cancels the child and reports an error');
|
|
70
|
+
} finally {
|
|
71
|
+
await rt.close();
|
|
72
|
+
}
|
|
73
|
+
})().catch(error => {
|
|
74
|
+
console.error(error);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { JsonlProcess } = require('../src/main/host/jsonl');
|
|
3
|
+
|
|
4
|
+
// JsonlProcess stdin EPIPE 防护:子进程异常退出/管道破裂后,迟到的 stdin.write 会在
|
|
5
|
+
// 流上异步抛 'error'(EPIPE);Writable 无 error 监听时 Node 将其当作未捕获异常直接
|
|
6
|
+
// crash 宿主进程。修复后错误进入诊断通道,真实失败仍由 exit/error 路径统一结算。
|
|
7
|
+
(async () => {
|
|
8
|
+
let crashed = null;
|
|
9
|
+
const onCrash = error => { crashed = error; };
|
|
10
|
+
process.on('uncaughtException', onCrash);
|
|
11
|
+
const diags = [];
|
|
12
|
+
try {
|
|
13
|
+
const proc = new JsonlProcess(process.execPath, ['-e', 'process.exit(0)'], {}, {
|
|
14
|
+
onDiagnostic(line) { diags.push(String(line)); },
|
|
15
|
+
});
|
|
16
|
+
await new Promise(resolve => proc.child.on('exit', resolve));
|
|
17
|
+
// 迟到写入(管道已破)+ 直接注入流错误:两种形态都不得成为未捕获异常
|
|
18
|
+
proc.notify('late.notify', {});
|
|
19
|
+
proc.send({ type: 'late' });
|
|
20
|
+
proc.child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
|
|
21
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
22
|
+
assert.equal(crashed, null, `stdin EPIPE 不得成为未捕获异常: ${crashed?.message ?? ''}`);
|
|
23
|
+
assert.ok(diags.some(line => /stdin/.test(line)), 'stdin 流错误进入诊断通道');
|
|
24
|
+
console.log('jsonl-stdin-test: late writes and EPIPE after child exit are swallowed as diagnostics');
|
|
25
|
+
} finally {
|
|
26
|
+
process.removeListener('uncaughtException', onCrash);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 子进程退出时,挂起中的请求必须以带 harnessExited 标记的错误结算——
|
|
30
|
+
// protocol.inspect 依赖该标记把确定性失败标为不可重试,抑制渲染层的重开循环。
|
|
31
|
+
{
|
|
32
|
+
const proc = new JsonlProcess(process.execPath, ['-e', 'setTimeout(() => process.exit(0), 50)'], {}, {});
|
|
33
|
+
const pending = proc.request('never/answered', {});
|
|
34
|
+
await assert.rejects(pending, error => error.harnessExited === true && /进程已退出/.test(error.message));
|
|
35
|
+
console.log('jsonl-stdin-test: pending requests reject with the harnessExited marker');
|
|
36
|
+
}
|
|
37
|
+
})().catch(error => {
|
|
38
|
+
console.error(error);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
});
|
|
@@ -1,100 +1,124 @@
|
|
|
1
|
-
const assert = require('node:assert/strict');
|
|
2
|
-
const { acpAdapter, catalog } = require('../src/main/adapters/acp');
|
|
3
|
-
const { nativeCommand } = require('../src/main/adapters/native-acp-command');
|
|
4
|
-
|
|
5
|
-
if (process.argv.includes('--fixture')) {
|
|
6
|
-
const readline = require('node:readline');
|
|
7
|
-
const write = value => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n');
|
|
8
|
-
const configs = [{ id: 'model', currentValue: 'sonnet', options: [{ value: 'sonnet', name: 'Sonnet' }] }, { id: 'effortLevel', currentValue: 'medium', options: [{ value: 'high', name: 'High' }] }];
|
|
9
|
-
let prompt;
|
|
10
|
-
readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
11
|
-
const r = JSON.parse(line);
|
|
12
|
-
if (r.id === 'permission') {
|
|
13
|
-
assert.equal(r.result.outcome.optionId, 'native-once');
|
|
14
|
-
write({ method: 'session/update', params: { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'native reply' } } } });
|
|
15
|
-
write({ id: prompt, result: { stopReason: 'end_turn' } });
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
let result = {};
|
|
19
|
-
if (r.method === 'initialize') result = { agentCapabilities: { loadSession: true } };
|
|
20
|
-
else if (r.method === 'session/new' || r.method === 'session/load') result = { sessionId: r.params.sessionId || 'native-session', configOptions: configs };
|
|
21
|
-
else if (r.method === 'session/set_config_option') { configs.find(c => c.id === r.params.configId).currentValue = r.params.value; result = { configOptions: configs }; }
|
|
22
|
-
else if (r.method === 'session/prompt') {
|
|
23
|
-
prompt = r.id;
|
|
24
|
-
write({ id: 'permission', method: 'session/request_permission', params: { toolCall: { title: 'Test native permission' }, options: [{ kind: 'allow_once', optionId: 'native-once' }] } });
|
|
25
|
-
return;
|
|
26
|
-
} else if (r.method === 'session/cancel') return;
|
|
27
|
-
if (r.id !== undefined) write({ id: r.id, result });
|
|
28
|
-
});
|
|
29
|
-
} else {
|
|
30
|
-
(async () => {
|
|
31
|
-
for (const [id, key, argv] of [
|
|
32
|
-
['qoder', 'HARNESS_MIX_QODER_EXECUTABLE', ['--acp']],
|
|
33
|
-
['
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
process.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.env
|
|
57
|
-
assert.deepEqual(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
process.env
|
|
72
|
-
assert.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
assert.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { acpAdapter, catalog } = require('../src/main/adapters/acp');
|
|
3
|
+
const { nativeCommand } = require('../src/main/adapters/native-acp-command');
|
|
4
|
+
|
|
5
|
+
if (process.argv.includes('--fixture')) {
|
|
6
|
+
const readline = require('node:readline');
|
|
7
|
+
const write = value => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n');
|
|
8
|
+
const configs = [{ id: 'model', currentValue: 'sonnet', options: [{ value: 'sonnet', name: 'Sonnet' }] }, { id: 'effortLevel', currentValue: 'medium', options: [{ value: 'high', name: 'High' }] }];
|
|
9
|
+
let prompt;
|
|
10
|
+
readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
11
|
+
const r = JSON.parse(line);
|
|
12
|
+
if (r.id === 'permission') {
|
|
13
|
+
assert.equal(r.result.outcome.optionId, 'native-once');
|
|
14
|
+
write({ method: 'session/update', params: { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'native reply' } } } });
|
|
15
|
+
write({ id: prompt, result: { stopReason: 'end_turn' } });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let result = {};
|
|
19
|
+
if (r.method === 'initialize') result = { agentCapabilities: { loadSession: true } };
|
|
20
|
+
else if (r.method === 'session/new' || r.method === 'session/load') result = { sessionId: r.params.sessionId || 'native-session', configOptions: configs };
|
|
21
|
+
else if (r.method === 'session/set_config_option') { configs.find(c => c.id === r.params.configId).currentValue = r.params.value; result = { configOptions: configs }; }
|
|
22
|
+
else if (r.method === 'session/prompt') {
|
|
23
|
+
prompt = r.id;
|
|
24
|
+
write({ id: 'permission', method: 'session/request_permission', params: { toolCall: { title: 'Test native permission' }, options: [{ kind: 'allow_once', optionId: 'native-once' }] } });
|
|
25
|
+
return;
|
|
26
|
+
} else if (r.method === 'session/cancel') return;
|
|
27
|
+
if (r.id !== undefined) write({ id: r.id, result });
|
|
28
|
+
});
|
|
29
|
+
} else {
|
|
30
|
+
(async () => {
|
|
31
|
+
for (const [id, key, argv] of [
|
|
32
|
+
['qoder', 'HARNESS_MIX_QODER_EXECUTABLE', ['--acp']],
|
|
33
|
+
['trae', 'HARNESS_MIX_TRAE_EXECUTABLE', []],
|
|
34
|
+
]) {
|
|
35
|
+
const previous = process.env[key];
|
|
36
|
+
try {
|
|
37
|
+
delete process.env[key];
|
|
38
|
+
if (id !== 'qoder') assert.throws(() => nativeCommand(id, argv), /ACP/);
|
|
39
|
+
process.env[key] = __filename + '.missing';
|
|
40
|
+
assert.throws(() => nativeCommand(id, argv), /未安装/);
|
|
41
|
+
process.env[key] = __filename;
|
|
42
|
+
assert.deepEqual(nativeCommand(id, argv), { command: process.execPath, args: [__filename, ...argv] });
|
|
43
|
+
} finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; }
|
|
44
|
+
}
|
|
45
|
+
// ZCode 走原生 app-server 适配器,不再是 ACP;解析只认无头 zcode.cjs /
|
|
46
|
+
// HARNESS_MIX_ZCODE_EXECUTABLE,旧 ACP 键与 nativeCommand('zcode') 已移除。
|
|
47
|
+
assert.throws(() => nativeCommand('zcode', []), /Unknown native CLI/);
|
|
48
|
+
{
|
|
49
|
+
const zcode = require('../src/main/adapters/zcode');
|
|
50
|
+
assert.equal(zcode.manifest.capabilities.approvals, true);
|
|
51
|
+
assert.equal(zcode.manifest.capabilities.fork, false);
|
|
52
|
+
assert.deepEqual(zcode.manifest.integrations.skills, { global: ['.zcode/skills', '.agents/skills'], project: ['.zcode/skills', '.agents/skills'] });
|
|
53
|
+
const previous = process.env.HARNESS_MIX_ZCODE_EXECUTABLE;
|
|
54
|
+
try {
|
|
55
|
+
delete process.env.HARNESS_MIX_ZCODE_EXECUTABLE;
|
|
56
|
+
process.env.HARNESS_MIX_ZCODE_EXECUTABLE = __filename;
|
|
57
|
+
assert.deepEqual(zcode.resolveLaunch(), { command: process.execPath, args: [__filename, 'app-server', '--stdio'] });
|
|
58
|
+
// 无覆盖且无桌面版捆绑 CLI 时必须明确报错(有的机器装有桌面版,此时允许回退)
|
|
59
|
+
} finally { if (previous === undefined) delete process.env.HARNESS_MIX_ZCODE_EXECUTABLE; else process.env.HARNESS_MIX_ZCODE_EXECUTABLE = previous; }
|
|
60
|
+
}
|
|
61
|
+
for (const name of ['kiro', 'cursor']) {
|
|
62
|
+
const module = require('../src/main/adapters/' + name);
|
|
63
|
+
assert.equal(module.manifest.capabilities.fork, name === 'kiro');
|
|
64
|
+
assert.equal(module.manifest.capabilities.questions, true);
|
|
65
|
+
const key = name === 'kiro' ? 'HARNESS_MIX_KIRO_EXECUTABLE' : 'HARNESS_MIX_CURSOR_EXECUTABLE';
|
|
66
|
+
const previous = process.env[key];
|
|
67
|
+
try {
|
|
68
|
+
process.env[key] = __filename + '.missing';
|
|
69
|
+
assert.throws(() => nativeCommand(module.manifest.id, ['acp']), /未安装/);
|
|
70
|
+
assert.equal((await module.create().inspect()).available, false);
|
|
71
|
+
process.env[key] = process.execPath;
|
|
72
|
+
assert.deepEqual(nativeCommand(module.manifest.id, ['acp']), { command: process.execPath, args: ['acp'] });
|
|
73
|
+
} finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; }
|
|
74
|
+
}
|
|
75
|
+
// Qoder:官方 qodercli --acp。能力按 1.1.58 实测声明(fork 走标准 Zed 参数,
|
|
76
|
+
// 思考档位是 reasoning_effort 配置项;CLI 从不发送 usage_update)。
|
|
77
|
+
{
|
|
78
|
+
const module = require('../src/main/adapters/qoder');
|
|
79
|
+
const caps = module.manifest.capabilities;
|
|
80
|
+
assert.deepEqual(module.manifest.integrations.skills, { global: ['.qoder/skills'], project: ['.qoder/skills'] });
|
|
81
|
+
for (const [capability, expected] of [['fork', true], ['questions', false], ['thinkingLevels', true], ['usage', false], ['contextUsage', false], ['resume', true], ['permissionModes', true], ['models', true], ['attachments', true], ['approvals', true], ['compaction', false]])
|
|
82
|
+
assert.equal(caps[capability], expected, `qoder ${capability}`);
|
|
83
|
+
}
|
|
84
|
+
// Cline:官方 `cline --acp`;诚实能力声明(无 plan/原生 diff/thinking 档/独立提问),
|
|
85
|
+
// 保留 resume 与 plan/act 权限模式;命令只认 cline 与 HARNESS_MIX_CLINE_EXECUTABLE。
|
|
86
|
+
{
|
|
87
|
+
const module = require('../src/main/adapters/cline');
|
|
88
|
+
const caps = module.manifest.capabilities;
|
|
89
|
+
assert.deepEqual(module.manifest.integrations.skills, { global: ['.cline/skills'], project: ['.cline/skills'] });
|
|
90
|
+
for (const [capability, expected] of [['fork', false], ['questions', false], ['thinkingLevels', false], ['plan', false], ['nativeDiff', false], ['usage', false], ['resume', true], ['permissionModes', true], ['models', true], ['attachments', true], ['approvals', true]])
|
|
91
|
+
assert.equal(caps[capability], expected, `cline ${capability}`);
|
|
92
|
+
const previous = process.env.HARNESS_MIX_CLINE_EXECUTABLE;
|
|
93
|
+
try {
|
|
94
|
+
delete process.env.HARNESS_MIX_CLINE_EXECUTABLE;
|
|
95
|
+
process.env.HARNESS_MIX_CLINE_EXECUTABLE = __filename + '.missing';
|
|
96
|
+
assert.throws(() => nativeCommand('cline', ['--acp']), /未安装/);
|
|
97
|
+
assert.equal((await module.create().inspect()).available, false);
|
|
98
|
+
process.env.HARNESS_MIX_CLINE_EXECUTABLE = process.execPath;
|
|
99
|
+
assert.deepEqual(nativeCommand('cline', ['--acp']), { command: process.execPath, args: ['--acp'] });
|
|
100
|
+
} finally { if (previous === undefined) delete process.env.HARNESS_MIX_CLINE_EXECUTABLE; else process.env.HARNESS_MIX_CLINE_EXECUTABLE = previous; }
|
|
101
|
+
}
|
|
102
|
+
const adapter = acpAdapter({ id: 'fixture', name: 'Fixture', bin: () => ({ command: process.execPath, args: [__filename, '--fixture'] }), args: [], requestTimeoutMs: 5000 }).create();
|
|
103
|
+
const events = [];
|
|
104
|
+
let session;
|
|
105
|
+
const emit = event => {
|
|
106
|
+
events.push(event);
|
|
107
|
+
if (event.kind === 'approval') queueMicrotask(() => adapter.respond(session, event.requestId, { confirmed: true }));
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
session = await adapter.open({ thread: { cwd: process.cwd() }, emit });
|
|
111
|
+
assert.equal(catalog(session).models[0].id, 'sonnet');
|
|
112
|
+
await adapter.setThinkingLevel(session, 'high');
|
|
113
|
+
assert.equal(session.state.configOptions[1].currentValue, 'high');
|
|
114
|
+
await adapter.send(session, 'hello', { emit });
|
|
115
|
+
assert.ok(events.some(e => e.text === 'native reply'));
|
|
116
|
+
assert.equal(events.filter(e => e.kind === 'completed').length, 1);
|
|
117
|
+
await adapter.close(session);
|
|
118
|
+
session = await adapter.open({ thread: { cwd: process.cwd(), restore: true, nativeSessionId: 'native-session' }, emit });
|
|
119
|
+
assert.equal(session.nativeSessionId, 'native-session');
|
|
120
|
+
await adapter.cancel(session);
|
|
121
|
+
} finally { if (session) await adapter.close(session); }
|
|
122
|
+
console.log('Kiro/Cursor: discovery isolation, config, native approval, streaming, resume and cancel PASS (fixture, not live model)');
|
|
123
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
124
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const assert = require('node:assert/strict');
|
|
2
|
-
const { nativeAcp, project } = require('../src/main/adapters/native-acp');
|
|
2
|
+
const { nativeAcp, project, catalog } = require('../src/main/adapters/native-acp');
|
|
3
3
|
const { AcpInteractions } = require('../src/main/adapters/acp-interactions');
|
|
4
4
|
const { historyUsage, branch, latestAssistantAfterUser } = require('../src/main/adapters/codebuddy-history');
|
|
5
5
|
const { projectUsage } = require('../src/main/native/usage');
|
|
@@ -25,10 +25,11 @@ if (process.argv.includes('--fixture')) {
|
|
|
25
25
|
result = { sessionId: sid, configOptions: configs };
|
|
26
26
|
}
|
|
27
27
|
if (r.method === 'session/set_config_option') {
|
|
28
|
-
|
|
28
|
+
// 模拟 qodercli 的模型切换重置思考档位(Max→Flash 实测重置回默认档)
|
|
29
|
+
configs = configs.map(c => c.id === r.params.configId ? { ...c, currentValue: r.params.value } : (r.params.configId === 'model' && c.id === 'thought_level' ? { ...c, currentValue: 'medium' } : c));
|
|
29
30
|
result = { configOptions: configs };
|
|
30
31
|
}
|
|
31
|
-
if (r.method === 'session/fork') { assert.equal(r.params._meta?.kiro?.messageId, 'native-end'); result = { sessionId: randomUUID() }; }
|
|
32
|
+
if (r.method === 'session/fork') { if (r.params._meta) assert.equal(r.params._meta?.kiro?.messageId, 'native-end'); result = { sessionId: randomUUID() }; }
|
|
32
33
|
if (r.method === '_kiro/session/context') result = { usagePercentage: 23 };
|
|
33
34
|
if (r.method === 'session/prompt') {
|
|
34
35
|
if (r.params.prompt[0].text === 'image') assert.deepEqual(r.params.prompt[1], { type: 'image', data: 'AA==', mimeType: 'image/png' });
|
|
@@ -48,7 +49,7 @@ if (process.argv.includes('--fixture')) {
|
|
|
48
49
|
} else {
|
|
49
50
|
(async () => {
|
|
50
51
|
for (const vendor of ['codebuddy', 'kiro-cli', 'cursor-cli', 'qoder', 'zcode', 'trae', 'cline']) {
|
|
51
|
-
const module = nativeAcp({ id: vendor, name: vendor, args: [], timeoutMs: 2000, command: () => ({ command: process.execPath, args: [__filename, '--fixture'] }) });
|
|
52
|
+
const module = nativeAcp({ id: vendor, name: vendor, args: [], timeoutMs: 2000, command: () => ({ command: process.execPath, args: [__filename, '--fixture'] }), ...(vendor === 'qoder' ? { capabilities: { fork: true } } : {}) });
|
|
52
53
|
const adapter = module.create(), events = [];
|
|
53
54
|
let s;
|
|
54
55
|
try {
|
|
@@ -58,6 +59,11 @@ if (process.argv.includes('--fixture')) {
|
|
|
58
59
|
events.length = 0;
|
|
59
60
|
await adapter.setModel(s, { id: 'other' });
|
|
60
61
|
if (vendor !== 'cursor-cli') await adapter.setThinkingLevel(s, 'high');
|
|
62
|
+
if (vendor === 'qoder') {
|
|
63
|
+
// 模型切换重置原生 effort 后,已确认的思考档位必须重放(fixture 模拟了重置)
|
|
64
|
+
await adapter.setModel(s, { id: 'native[variant]' });
|
|
65
|
+
assert.equal(s.state.configOptions.find(c => c.id === 'thought_level').currentValue, 'high', '模型切换后确认的思考档位必须重放');
|
|
66
|
+
}
|
|
61
67
|
const first = adapter.send(s, 'wait', { emit: e => events.push(e) });
|
|
62
68
|
await assert.rejects(adapter.send(s, 'busy', { emit: () => {} }), /busy/);
|
|
63
69
|
await assert.rejects(adapter.setModel(s, { id: 'other' }), /busy/);
|
|
@@ -84,6 +90,12 @@ if (process.argv.includes('--fixture')) {
|
|
|
84
90
|
await assert.rejects(adapter.fork({ cwd: process.cwd(), nativeSessionId: s.nativeSessionId }, { emit: () => {} }), /Compacted/);
|
|
85
91
|
} finally { if (saved === undefined) delete process.env.KIRO_HOME; else process.env.KIRO_HOME = saved; }
|
|
86
92
|
}
|
|
93
|
+
if (vendor === 'qoder') {
|
|
94
|
+
// qoder fork 走标准 Zed 参数(无 kiro checkpoint _meta)
|
|
95
|
+
const forked = await adapter.fork({ cwd: process.cwd(), nativeSessionId: s.nativeSessionId }, { emit: () => {} });
|
|
96
|
+
assert.notEqual(forked.session.nativeSessionId, s.nativeSessionId);
|
|
97
|
+
await adapter.close(forked.session);
|
|
98
|
+
}
|
|
87
99
|
} finally { await adapter.close(s); }
|
|
88
100
|
}
|
|
89
101
|
const events = [], s = { active: true, nativeSessionId: 's', emit: e => events.push(e) };
|
|
@@ -98,10 +110,15 @@ if (process.argv.includes('--fixture')) {
|
|
|
98
110
|
const unresponsive = nativeAcp({ id: 'cursor-cli', name: 'Cursor', args: [], timeoutMs: 500,
|
|
99
111
|
command: () => ({ command: process.execPath, args: [__filename, '--fixture', '--ignore-cancel'] }) }).create();
|
|
100
112
|
const stalled = await unresponsive.open({ thread: { cwd: process.cwd() }, emit: () => {} });
|
|
113
|
+
const stalledSid = stalled.nativeSessionId;
|
|
101
114
|
const stalledTurn = unresponsive.send(stalled, 'wait', { emit: () => {} });
|
|
102
115
|
const failedTurn = assert.rejects(stalledTurn);
|
|
103
116
|
await assert.rejects(unresponsive.cancel(stalled)); await failedTurn;
|
|
104
|
-
assert.ok(stalled.fault);
|
|
117
|
+
assert.ok(stalled.fault);
|
|
118
|
+
// 进程死后下一次 send 透明重连(restore 同一原生会话),不再永久毒化线程
|
|
119
|
+
await unresponsive.send(stalled, 'again', { emit: () => {} });
|
|
120
|
+
assert.equal(stalled.nativeSessionId, stalledSid);
|
|
121
|
+
assert.equal(stalled.fault, null);
|
|
105
122
|
await unresponsive.close(stalled);
|
|
106
123
|
const idle = nativeAcp({ id: 'codebuddy', name: 'CodeBuddy', args: [], timeoutMs: 1000, turnIdleTimeoutMs: 100,
|
|
107
124
|
command: () => ({ command: process.execPath, args: [__filename, '--fixture'] }) }).create();
|
|
@@ -157,6 +174,14 @@ if (process.argv.includes('--fixture')) {
|
|
|
157
174
|
assert.equal(latestAssistantAfterUser([{ type: 'message', id: 'new-u', role: 'user', content: [{ type: 'input_text', text: 'new' }] },
|
|
158
175
|
{ type: 'message', id: 'new-a', role: 'assistant', content: [{ type: 'output_text', text: 'native final' }] }], 'new-u'), 'native final');
|
|
159
176
|
assert.throws(() => branch([{ type: 'message', id: 'a', parentId: 'a' }]), /parent chain/);
|
|
177
|
+
// qoder 的思考档位 id 是 reasoning_effort(category=model),必须照常映射
|
|
178
|
+
const qoderCatalog = catalog({ vendor: 'qoder', state: { configOptions: [
|
|
179
|
+
{ id: 'model', currentValue: 'qfmodel', options: [{ value: 'qfmodel', name: 'Qwen3.8-Flash' }] },
|
|
180
|
+
{ id: 'reasoning_effort', category: 'model', currentValue: 'xhigh', options: [{ value: 'xhigh', name: 'Extra High' }, { value: 'none', name: 'None' }] },
|
|
181
|
+
{ id: 'mode', currentValue: 'default', options: [{ value: 'default' }, { value: 'yolo' }] },
|
|
182
|
+
], models: null, modes: null } });
|
|
183
|
+
assert.deepEqual(qoderCatalog.thinkingLevels.map(o => o.id), ['xhigh', 'none']);
|
|
184
|
+
assert.deepEqual(qoderCatalog.permissionModes.map(o => o.id), ['default', 'yolo']);
|
|
160
185
|
// 进度事件重置 idle,heartbeat-only 不重置:心跳场景下 idle 应当照常起效。
|
|
161
186
|
const heartbeatOnly = nativeAcp({ id: 'codebuddy', name: 'CodeBuddy', args: [], timeoutMs: 1000, turnIdleTimeoutMs: 80, turnPromptTimeoutMs: 60_000, cancelGraceMs: 1000,
|
|
162
187
|
command: () => ({ command: process.execPath, args: [__filename, '--fixture', '--ignore-prompt', '--heartbeat'] }) }).create();
|
|
@@ -13,10 +13,11 @@ async function main() {
|
|
|
13
13
|
let emit;
|
|
14
14
|
const emits = [];
|
|
15
15
|
const answers = [];
|
|
16
|
-
const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true } },
|
|
16
|
+
const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true, fork: true } },
|
|
17
17
|
async open(input) { emits.push(input.emit); emit = input.emit; return {}; },
|
|
18
18
|
async describe() { return { models: [{ id: 'demo', name: 'Demo', provider: 'test' }], thinkingLevels: [{ id: 'high', label: 'High', default: true }, { id: 'low', label: 'Low' }], permissionModes: [] }; },
|
|
19
19
|
async send(session, text, hooks, extras) { sendExtras.push(extras); }, async cancel() {}, async close() {},
|
|
20
|
+
async fork(source) { return { session: {}, nativeSessionId: `forked-${source.id}` }; },
|
|
20
21
|
async respond(session, id, answer) { answers.push({ id, answer }); } };
|
|
21
22
|
const sendExtras = [];
|
|
22
23
|
runtime.adapters.set('pi', adapter); runtime.status.pi = { available: true };
|
|
@@ -183,9 +184,21 @@ async function main() {
|
|
|
183
184
|
const question = events.find(e => e.method === 'item/tool/requestUserInput');
|
|
184
185
|
await bridge.respond({ id: question.id, result: { answers: { question: { answers: ['Alice'] } } } });
|
|
185
186
|
assert.equal(answers[1].answer.value, 'Alice');
|
|
187
|
+
// 多选提问:answers 数组必须全量保留(JSON 编码),只取 [0] 会无声吞掉其余选项
|
|
188
|
+
emit({ kind: 'approval', requestId: 'multi', method: 'input', title: 'Pick many?' });
|
|
189
|
+
const multi = events.filter(e => e.method === 'item/tool/requestUserInput').at(-1);
|
|
190
|
+
await bridge.respond({ id: multi.id, result: { answers: { multi: { answers: ['a', 'b'] } } } });
|
|
191
|
+
assert.equal(answers[2].answer.value, '["a","b"]', '多选答案 JSON 编码全量保留');
|
|
192
|
+
emit({ kind: 'approval', requestId: 'empty', method: 'input', title: 'Empty?' });
|
|
193
|
+
const emptyQ = events.filter(e => e.method === 'item/tool/requestUserInput').at(-1);
|
|
194
|
+
await bridge.respond({ id: emptyQ.id, result: { answers: { empty: { answers: [] } } } });
|
|
195
|
+
assert.equal(answers[3].answer.value, '', '空答案数组回退为空字符串而非 undefined');
|
|
186
196
|
emit({ kind: 'file-change', changes: [{ path: 'a.txt', changeType: 'added', before: '', after: 'hello', complete: true }] });
|
|
187
197
|
emit({ kind: 'completed', finalAnswer: true });
|
|
188
198
|
await wait(() => !runtime.threads.find(t => t.id === threadId).reviewPending && !runtime.sending.has(threadId));
|
|
199
|
+
// Fork:Host 侧新建分支线程必须广播 thread/started,否则 Desktop 侧边栏不显示分支
|
|
200
|
+
const forked = await bridge.request('thread/fork', { threadId });
|
|
201
|
+
assert.ok(events.some(e => e.method === 'thread/started' && e.params.thread.id === forked.thread.id), 'Fork 后 Desktop 收到分支线程的 thread/started');
|
|
189
202
|
assert.equal(events.filter(e => e.method === 'item/agentMessage/delta').map(e => e.params.delta).join(''), 'hello world');
|
|
190
203
|
// 非终端工具投影为 dynamicToolCall(摘要显示真实工具名),终端命令投影为原生 commandExecution
|
|
191
204
|
assert.ok(events.some(e => e.method === 'item/completed' && e.params.item.type === 'dynamicToolCall' && e.params.item.tool === 'Read'));
|