@harness-mix/cli 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +469 -467
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/desktop-controller.mjs +1 -1
  7. package/output/native-build/renderer-extension.js +172 -23
  8. package/package.json +13 -9
  9. package/scripts/acp-image-test.cjs +69 -0
  10. package/scripts/adapters-test.cjs +25 -0
  11. package/scripts/antigravity-adapter-test.cjs +647 -626
  12. package/scripts/codex-accounts-test.cjs +34 -0
  13. package/scripts/codex-adapter-test.cjs +238 -127
  14. package/scripts/collaboration-test.cjs +577 -262
  15. package/scripts/collaboration-ui-smoke.cjs +25 -3
  16. package/scripts/e2e-delegate.cjs +1 -1
  17. package/scripts/e2e-hermes-image.cjs +60 -0
  18. package/scripts/jsonl-stdin-test.cjs +40 -31
  19. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  20. package/scripts/native-acp-depth-test.cjs +30 -5
  21. package/scripts/native-update-apply-test.cjs +269 -215
  22. package/scripts/native-update.cjs +78 -0
  23. package/scripts/native-vendor-adapters-test.cjs +196 -154
  24. package/scripts/openclaw-adapter-test.cjs +121 -3
  25. package/scripts/openclaw-mcp-probe.cjs +100 -0
  26. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  27. package/scripts/openclaw-thinking-probe.cjs +73 -0
  28. package/scripts/salvage-rollout-writes.cjs +72 -0
  29. package/scripts/storage-verification-test.cjs +11 -0
  30. package/scripts/zcode-adapter-test.cjs +329 -0
  31. package/scripts/zcode-live-probe.cjs +66 -0
  32. package/src/main/adapters/antigravity.js +1428 -1418
  33. package/src/main/adapters/claude.js +15 -7
  34. package/src/main/adapters/codex-app-server.js +29 -11
  35. package/src/main/adapters/codex.js +690 -649
  36. package/src/main/adapters/native-acp-command.js +51 -48
  37. package/src/main/adapters/native-acp.js +47 -12
  38. package/src/main/adapters/omp.js +7 -2
  39. package/src/main/adapters/openclaw.js +534 -343
  40. package/src/main/adapters/pi-family.js +35 -8
  41. package/src/main/adapters/qoder.js +12 -8
  42. package/src/main/adapters/zcode.js +925 -10
  43. package/src/main/host/collaboration-tools.js +1 -1
  44. package/src/main/host/collaboration.js +971 -714
  45. package/src/main/host/jsonl.js +130 -120
  46. package/src/main/host/runtime.js +9 -3
  47. package/src/main/host/verification-gates.js +14 -2
  48. package/src/main/native/codex-accounts.js +15 -4
  49. package/src/main/native/config.js +9 -9
  50. package/src/main/native/launcher.js +252 -237
  51. package/src/main/native/process-utils.js +157 -57
  52. package/src/main/native/protocol.js +1227 -1187
  53. package/src/main/native/update-state.js +123 -110
  54. package/src/main/native/updater.js +460 -394
  55. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  56. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  57. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  58. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  65. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  67. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
  68. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  69. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  70. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  71. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
  72. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
@@ -0,0 +1,55 @@
1
+ // OpenClaw 托管 MCP 端到端实测:推送极简 echo MCP 服务器 → 真实 agent 回合调用其工具 →
2
+ // 捕获 tool 流帧验证 → 清理并断言注册表还原。证据落 output/openclaw-mcp-probe/。
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { randomUUID } = require("node:crypto");
6
+ const { OpenClawGatewayHost, OPENCLAW_CONFIG } = require("../src/main/adapters/openclaw-gateway");
7
+ const { applyManagedMcpServers, MCP_OWNED_PREFIX } = require("../src/main/adapters/openclaw");
8
+
9
+ const OUT_DIR = path.join(__dirname, "..", "output", "openclaw-mcp-probe");
10
+ const ECHO_SERVER = path.join(OUT_DIR, "echo-mcp-server.cjs");
11
+
12
+ (async () => {
13
+ const backup = path.join(OUT_DIR, `openclaw.json.backup-tool-${Date.now()}`);
14
+ fs.copyFileSync(OPENCLAW_CONFIG, backup);
15
+ const frames = [];
16
+ let ok = false;
17
+ try {
18
+ await applyManagedMcpServers([{ name: "probe", command: process.execPath, args: [ECHO_SERVER] }], (l) => console.log(l));
19
+ const host = await OpenClawGatewayHost.acquire((l) => console.error(l));
20
+ try {
21
+ const baseKey = `harness-mix-mcp-tool-probe-${Date.now()}`;
22
+ const created = await host.call("sessions.create", { key: baseKey, label: `Harness Mix MCP tool probe ${Date.now()}` });
23
+ const sessionKey = created?.key ?? baseKey;
24
+ const unwatch = host.onEvent((frame) => {
25
+ if (frame?.type !== "event" || frame.event !== "agent") return;
26
+ const p = frame.payload ?? {};
27
+ if (p.sessionKey && p.sessionKey !== sessionKey) return;
28
+ frames.push(p);
29
+ if (p.stream === "tool") console.log(`[tool] ${p.data?.name ?? ""} phase=${p.data?.phase} meta=${p.data?.meta ?? ""}`);
30
+ });
31
+ const accepted = await host.call("agent", {
32
+ message: "请调用 MCP 工具 hm_echo(若名称带前缀也用它),参数 text=HELLO_HM。完成后只回复工具返回的原始文本,不要添加别的内容。",
33
+ sessionKey, idempotencyKey: randomUUID(), deliver: false,
34
+ });
35
+ console.log("agent accepted:", JSON.stringify(accepted));
36
+ const settled = await host.call("agent.wait", { runId: accepted.runId, timeoutMs: 240_000 }, 250_000);
37
+ console.log("agent.wait:", JSON.stringify(settled).slice(0, 300));
38
+ unwatch();
39
+ const toolCalls = frames.filter((f) => f.stream === "tool");
40
+ const echoHit = toolCalls.some((f) => String(f.data?.name ?? "").includes("hm_echo") || String(f.data?.meta ?? "").includes("hm_echo"));
41
+ const assistantText = frames.filter((f) => f.stream === "assistant").map((f) => f.data?.delta ?? f.data?.text ?? "").join("");
42
+ console.log(`tool frames: ${toolCalls.length}, hm_echo hit: ${echoHit}, assistant: ${assistantText.slice(0, 120)}`);
43
+ fs.writeFileSync(path.join(OUT_DIR, "tool-call-evidence.json"), JSON.stringify({ capturedAt: new Date().toISOString(), frames, echoHit, assistantText }, null, 2));
44
+ ok = echoHit || /ECHO:HELLO_HM/.test(assistantText);
45
+ console.log(ok ? "TOOL PROBE OK:托管 MCP 工具被真实调用" : "TOOL PROBE 未观测到工具调用(模型未配合或 bundle 未加载)");
46
+ } finally { await OpenClawGatewayHost.release(); }
47
+ } finally {
48
+ await applyManagedMcpServers([], () => {});
49
+ if (!ok) {
50
+ fs.copyFileSync(backup, OPENCLAW_CONFIG);
51
+ console.error("已用备份还原 openclaw.json");
52
+ }
53
+ }
54
+ process.exit(ok ? 0 : 1);
55
+ })().catch((e) => { console.error("FAILED:", e); process.exit(1); });
@@ -0,0 +1,73 @@
1
+ // OpenClaw Gateway 思考流探针:开启 reasoningLevel=stream 后跑一个真实回合,
2
+ // 捕获 agent 广播原始帧(重点 stream:"thinking"),为适配器投影与能力声明提供实测证据。
3
+ // 用法:node scripts/openclaw-thinking-probe.cjs [modelId]
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { randomUUID } = require("node:crypto");
7
+ const { OpenClawGatewayHost } = require("../src/main/adapters/openclaw-gateway");
8
+
9
+ const OUT_DIR = path.join(__dirname, "..", "output", "openclaw-thinking-probe");
10
+
11
+ (async () => {
12
+ fs.mkdirSync(OUT_DIR, { recursive: true });
13
+ const host = await OpenClawGatewayHost.acquire((line) => console.error(line));
14
+ const frames = [];
15
+ try {
16
+ const key = `harness-mix-thinking-probe-${Date.now()}`;
17
+ const created = await host.call("sessions.create", { key, label: `Harness Mix thinking probe ${Date.now()}` });
18
+ console.log("sessions.create ->", JSON.stringify(created));
19
+ const sessionKey = created?.key ?? key;
20
+
21
+ // 两个独立维度:thinkingLevel 控制推理投入,reasoningLevel=stream 才把思考流广播给客户端
22
+ let patched = null;
23
+ try {
24
+ patched = await host.call("sessions.patch", { key: sessionKey, reasoningLevel: "stream", thinkingLevel: "medium" });
25
+ } catch (error) {
26
+ console.error("sessions.patch FAILED:", error.message, "code=", error.code);
27
+ }
28
+ console.log("sessions.patch ->", JSON.stringify(patched));
29
+
30
+ const modelArg = process.argv[2] || undefined;
31
+ if (modelArg) console.log("using model override:", modelArg);
32
+
33
+ const unwatch = host.onEvent((frame) => {
34
+ if (frame?.type !== "event" || frame.event !== "agent") return;
35
+ const p = frame.payload ?? {};
36
+ if (p.sessionKey && p.sessionKey !== sessionKey) return;
37
+ frames.push(p);
38
+ const data = p.data ?? {};
39
+ const brief = typeof data.delta === "string" ? data.delta : typeof data.text === "string" ? data.text : "";
40
+ console.log(`[stream=${p.stream}] ${(brief || JSON.stringify(data)).slice(0, 160).replace(/\n/g, "\\n")}`);
41
+ });
42
+
43
+ const accepted = await host.call("agent", {
44
+ message: "一个三位数,各位数字之和为 18,百位比个位大 3,且它是 4 的倍数。求这个数,并给出推理过程。",
45
+ sessionKey,
46
+ idempotencyKey: randomUUID(),
47
+ deliver: false,
48
+ thinking: "high",
49
+ ...(modelArg ? { model: modelArg } : {}),
50
+ });
51
+ console.log("agent ->", JSON.stringify(accepted));
52
+ if (!accepted?.runId) throw new Error("未返回 runId");
53
+ const settled = await host.call("agent.wait", { runId: accepted.runId, timeoutMs: 240_000 }, 250_000);
54
+ console.log("agent.wait ->", JSON.stringify(settled));
55
+ unwatch();
56
+
57
+ const described = await host.call("sessions.describe", { key: sessionKey }).catch((e) => ({ error: e.message }));
58
+ console.log("sessions.describe ->", JSON.stringify(described?.session ?? described));
59
+
60
+ const summary = {};
61
+ for (const f of frames) summary[f.stream] = (summary[f.stream] ?? 0) + 1;
62
+ const hasThinking = frames.some((f) => f.stream === "thinking" && (f.data?.delta || f.data?.text));
63
+ console.log("stream summary ->", JSON.stringify(summary), "thinkingVerified=", hasThinking);
64
+
65
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
66
+ const outFile = path.join(OUT_DIR, `thinking-frames-${stamp}.json`);
67
+ fs.writeFileSync(outFile, JSON.stringify({ capturedAt: new Date().toISOString(), key, frames, described: described?.session ?? null, summary, hasThinking }, null, 2));
68
+ console.log("saved ->", outFile);
69
+ } finally {
70
+ await OpenClawGatewayHost.release();
71
+ }
72
+ process.exit(0);
73
+ })().catch((e) => { console.error("FAILED:", e); process.exit(1); });
@@ -0,0 +1,72 @@
1
+ // One-off salvage: extract Write/Edit tool-call payloads for out/ paths from a
2
+ // ZCode model-io rollout journal and replay the write+edit chain to rebuild
3
+ // final file contents. Used to recover the promo-video generation scripts
4
+ // after the workspace out/ tree was deleted. Not part of any suite.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const journal = process.argv[2];
9
+ const destRoot = process.argv[3];
10
+ if (!journal || !destRoot) {
11
+ console.error('usage: node salvage-rollout-writes.cjs <rollout.jsonl> <destRoot>');
12
+ process.exit(1);
13
+ }
14
+
15
+ const lines = fs.readFileSync(journal, 'utf8').split('\n').filter(Boolean);
16
+ const occurrences = []; // {fp, tool, lineIdx, content?, old?, new?} in journal order
17
+
18
+ const normalize = fp => String(fp).replace(/\\/g, '/').replace(/^[A-Za-z]:\/+/, '').replace(new RegExp(`^${path.basename(process.cwd())}/`), '');
19
+
20
+ function scan(node, lineIdx) {
21
+ if (Array.isArray(node)) { for (const x of node) scan(x, lineIdx); return; }
22
+ if (!node || typeof node !== 'object') return;
23
+ const isToolCall = (node.type === 'tool_use') || (typeof node.id === 'string' && node.id.startsWith('call_') && typeof node.name === 'string' && node.input && typeof node.input === 'object');
24
+ if (isToolCall && node.input && typeof node.input.file_path === 'string') {
25
+ const fp = normalize(node.input.file_path);
26
+ if (/(^|\/)out\//.test(fp)) {
27
+ occurrences.push({
28
+ fp, tool: node.name, lineIdx,
29
+ content: node.input.content != null ? String(node.input.content) : null,
30
+ old: node.input.old_string != null ? String(node.input.old_string) : null,
31
+ next: node.input.new_string != null ? String(node.input.new_string) : null,
32
+ replaceAll: node.input.replace_all === true,
33
+ });
34
+ }
35
+ }
36
+ for (const v of Object.values(node)) scan(v, lineIdx);
37
+ }
38
+
39
+ let idx = 0;
40
+ for (const line of lines) {
41
+ idx += 1;
42
+ try { scan(JSON.parse(line), idx); } catch { /* malformed line */ }
43
+ }
44
+
45
+ // Replay: last Write wins as the base, then Edits that appear AFTER it in order.
46
+ // Edits before the last Write are already baked into that Write's content.
47
+ const byPath = new Map();
48
+ for (const occ of occurrences) {
49
+ if (!byPath.has(occ.fp)) byPath.set(occ.fp, []);
50
+ byPath.get(occ.fp).push(occ);
51
+ }
52
+
53
+ const summary = [];
54
+ for (const [fp, chain] of byPath) {
55
+ const lastWriteIdx = chain.map(o => o.tool === 'Write').lastIndexOf(true);
56
+ if (lastWriteIdx === -1) { summary.push(`SKIP (no Write captured)\t${fp}`); continue; }
57
+ let text = chain[lastWriteIdx].content;
58
+ let applied = 0, skipped = 0;
59
+ for (const occ of chain.slice(lastWriteIdx + 1)) {
60
+ if (occ.tool !== 'Edit' || occ.old == null || occ.next == null) continue;
61
+ const count = text.split(occ.old).length - 1;
62
+ if (count === 0 || (!occ.replaceAll && count > 1)) { skipped += 1; continue; }
63
+ text = occ.replaceAll ? text.split(occ.old).join(occ.next) : text.replace(occ.old, occ.next);
64
+ applied += 1;
65
+ }
66
+ const abs = path.join(destRoot, fp);
67
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
68
+ fs.writeFileSync(abs, text);
69
+ summary.push(`OK\tline ${chain[lastWriteIdx].lineIdx} (+${applied}edits, ${skipped}skipped)\t${fp}\t${text.length} chars`);
70
+ }
71
+ console.log(summary.join('\n'));
72
+ console.log(`FILES ${summary.filter(s => s.startsWith('OK')).length} / paths ${byPath.size}`);
@@ -69,6 +69,17 @@ const { ThreadStore } = require('../src/main/host/thread-store');
69
69
  const failed = await gates.run(thread);
70
70
  assert.equal(failed.status, 'failed');
71
71
  assert.equal(gates.inspect(thread).satisfied, true, 'advisory failures do not block delivery');
72
+ // advisory():off 策略的 apply 零配置安全网——跑内建检查,不跑用户命令、
73
+ // 不写 latestReport、不改线程策略;显式策略下不附加
74
+ gates.configure(thread, { mode: 'off', commands: [`"${process.execPath}" -e "process.exit(3)"`] });
75
+ const advisory = await gates.advisory(thread);
76
+ assert.equal(advisory.mode, 'advisory');
77
+ assert.deepEqual(advisory.commands, [], 'advisory 不跑用户命令');
78
+ assert.ok(advisory.checks.some(check => check.id === 'turnCompleted'), '内建检查在列');
79
+ assert.equal(gates.inspect(thread).policy.mode, 'off', '线程策略保持 off');
80
+ assert.equal(gates.inspect(thread).report, null, 'advisory 不写 latestReport');
81
+ gates.configure(thread, { mode: 'advisory' });
82
+ assert.equal(await gates.advisory(thread), null, '显式策略下不附加安全网');
72
83
  assert.throws(() => normalizePolicy({ commands: [{ command: '' }] }), /Invalid verification command/);
73
84
  assert.throws(() => normalizePolicy({ commands: [{ command: 'tool --token sk-1234567890abcdef' }] }), /credential-like data/);
74
85
  console.log('PASS: schema v2 compact migration, backup, storage metrics and configurable verification gates');
@@ -0,0 +1,329 @@
1
+ // ZCode native app-server adapter coverage: runs the adapter against a fixture
2
+ // ZCode Protocol app-server (same framing, method set and event shapes as
3
+ // zcode.cjs 0.16.5). Fully offline. Invoked as
4
+ // node zcode-adapter-test.cjs -> driver
5
+ // node zcode-adapter-test.cjs app-server --stdio -> fixture (via env override)
6
+ const assert = require('node:assert/strict');
7
+
8
+ const mode = process.argv[2];
9
+
10
+ if (mode === 'app-server') {
11
+ // Fixture: minimal ZCode app-server speaking the verified protocol subset.
12
+ let nextId = 100;
13
+ let turnCount = 0;
14
+ const seenModes = [];
15
+ const send = payload => process.stdout.write(`${JSON.stringify(payload)}\n`);
16
+ const notify = (method, params) => send({ method, params });
17
+ let buffer = '';
18
+ // server→client 请求的处理注册表:应答到达时回调
19
+ const pendingServerRequests = new Map();
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', chunk => {
22
+ buffer += chunk;
23
+ let i;
24
+ while ((i = buffer.indexOf('\n')) >= 0) {
25
+ const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
26
+ let message;
27
+ try { message = JSON.parse(line); } catch { continue; }
28
+ // jsonrpc:false 模式下客户端帧不得携带 jsonrpc 字段
29
+ assert.equal(message.jsonrpc, undefined, 'client frames must not carry a jsonrpc key');
30
+ const { id, method, params } = message;
31
+ if (id !== undefined && pendingServerRequests.has(id)) {
32
+ pendingServerRequests.get(id)(message);
33
+ pendingServerRequests.delete(id);
34
+ continue;
35
+ }
36
+ if (method === 'session/create') {
37
+ const prefsId = nextId++;
38
+ pendingServerRequests.set(prefsId, data => {
39
+ assert.deepEqual(data.result, { nativeSearchEnhancementsEnabled: false, memoryEnabled: false, askUserQuestionAutoResolutionEnabled: true, modelContextBudgetStrategy: 'preflight-v1' });
40
+ send({ id, result: { session: { sessionId: 'sess-fix', mode: 'build', status: 'idle' }, projection: { contextUsed: 0, contextWindow: 200000, status: 'idle' } } });
41
+ });
42
+ send({ id: prefsId, method: 'session/requestRuntimePreferences', params: { sessionId: 'sess-fix', scope: 'runtime-materialization' } });
43
+ } else if (method === 'session/subscribe') {
44
+ assert.equal(params.deliveryKind, 'desktop-continuous');
45
+ send({ id, result: { sessionId: params.sessionId, eventSeq: 0, events: [] } });
46
+ notify('state.updated', { scope: 'session', sessionId: params.sessionId, revision: 1, patch: { model: { available: [
47
+ { ref: { providerId: 'account:fixture-plan', modelId: 'glm-4.6' }, label: 'GLM-4.6', contextWindow: 200000, reasoning: { levels: [{ value: 'low' }, { value: 'high' }], defaultLevel: 'high' } },
48
+ { ref: { providerId: 'account:fixture-plan', modelId: 'glm-4.5-air' }, label: 'GLM-4.5 Air' },
49
+ ] }, projection: { contextUsed: 1234, contextWindow: 200000 } } });
50
+ } else if (method === 'session/setModel') {
51
+ assert.ok(params.model?.options?.reasoningLevel, 'setModel 必须携带 reasoningLevel');
52
+ send({ id, result: { sessionId: params.sessionId, accepted: true } });
53
+ } else if (method === 'session/setThoughtLevel') {
54
+ send({ id, result: { sessionId: params.sessionId, accepted: true } });
55
+ } else if (method === 'session/setMode') {
56
+ assert.ok(['plan', 'build', 'edit', 'yolo'].includes(params.mode), `setMode 必须使用规范模式枚举,收到 ${params.mode}`);
57
+ assert.equal(params.expectedRevision, undefined, 'setMode 无需 expectedRevision');
58
+ seenModes.push(params.mode);
59
+ assert.deepEqual(seenModes, ['build', 'yolo'].slice(0, seenModes.length), `setMode 序列应为 open 应用线程选项→显式切换:${seenModes}`);
60
+ send({ id, result: { sessionId: params.sessionId, mode: params.mode, accepted: true } });
61
+ } else if (method === 'provider/updateAccountConfig') {
62
+ // 安全属性:账号声明必须零密钥——仅凭据条目名(connectionKey)参与链接
63
+ const providerIds = Object.keys(params.providers ?? {});
64
+ assert.ok(providerIds.length > 0 && providerIds.every(id => /^account:/.test(id)), '推送必须使用内置 account: 供应商 id');
65
+ for (const id of providerIds) {
66
+ const access = params.providers[id]?.access ?? {};
67
+ assert.deepEqual(Object.keys(access).sort(), ['entitled', 'type'], 'access 不得携带密钥字段');
68
+ assert.equal(access.type, 'zhipu-account');
69
+ const state = params.states?.[id] ?? {};
70
+ assert.ok(/^account-provider:.*:api-key$/.test(state.connectionKey ?? ''), 'states 需携带凭据条目名 connectionKey');
71
+ }
72
+ assert.ok(!JSON.stringify(params).includes('apiKey'), '推送体不得包含 apiKey 值');
73
+ send({ id, result: { receivedRevision: params.revision, providerCount: providerIds.length, status: 'received' } });
74
+ } else if (method === 'session/send') {
75
+ assert.ok(['hello', '带路径图', '纯base64图', '流恢复', '拒绝流程', '重连后回合'].includes(params.content), `意外的回合内容:${params.content}`);
76
+ if (Array.isArray(params.attachments)) {
77
+ // 实测约束:dataBase64 通道只会退化为元数据占位符,附件必须走 localPath
78
+ for (const attachment of params.attachments) {
79
+ assert.equal(attachment.kind, 'image', 'Harness Mix 只路由图片附件');
80
+ assert.ok(attachment.filename && attachment.mimeType, '附件必须带 filename 与 mimeType');
81
+ assert.ok(typeof attachment.localPath === 'string' && attachment.localPath, '附件必须走 localPath 通道');
82
+ assert.equal(attachment.dataBase64, undefined, '不得使用会降级的 dataBase64 通道');
83
+ assert.ok(Number.isInteger(attachment.sizeBytes) && attachment.sizeBytes > 0, '附件需携带 sizeBytes 提示');
84
+ }
85
+ }
86
+ send({ id, result: { accepted: true, sessionId: params.sessionId, stateRevision: 2 } });
87
+ const push = payload => send({ method: 'session/event', params: { deliveryKind: 'desktop-continuous', eventId: `evt-${nextId++}`, type: payload.type, payload } });
88
+ turnCount += 1;
89
+ if (params.content === '流恢复') {
90
+ // 流恢复重放:上游断流后 agent 重发“尾窗口(+增量)”,eventId 是新的,
91
+ // 去重拦不住——适配器必须剪掉与累积文本的重叠,只下发增量。
92
+ const replay = () => {
93
+ push({ type: 'turn.started', turnNumber: turnCount, input: params.content });
94
+ push({ type: 'model.streaming', kind: 'text_delta', delta: 'abcdefgh1234' });
95
+ push({ type: 'model.streaming', kind: 'text_delta', delta: 'abcdefgh1234' });
96
+ push({ type: 'model.streaming', kind: 'text_delta', delta: 'abcdefgh1234下一步' });
97
+ push({ type: 'model.streaming', kind: 'text_delta', delta: '继续' });
98
+ push({ type: 'turn.completed', response: 'abcdefgh1234下一步继续', tokenCount: 5, usage: { inputTokens: 3, outputTokens: 2 }, toolCallCount: 0, duration: 0.1 });
99
+ };
100
+ setTimeout(replay, 30);
101
+ continue;
102
+ }
103
+ if (turnCount > 1) {
104
+ // 附件回合:简化事件流,回合直接完成
105
+ const simple = () => {
106
+ push({ type: 'turn.started', turnNumber: turnCount, input: params.content });
107
+ push({ type: 'part.delta', messageId: 'm2', partId: 'p3', field: 'text', delta: '图已收到' });
108
+ push({ type: 'turn.completed', response: '图已收到', tokenCount: 9, usage: { inputTokens: 5, outputTokens: 4 }, toolCallCount: 0, duration: 0.2 });
109
+ };
110
+ setTimeout(simple, 30);
111
+ continue;
112
+ }
113
+ const runTurn = () => {
114
+ push({ type: 'turn.started', turnNumber: 0, input: params.content });
115
+ push({ type: 'part.delta', messageId: 'm1', partId: 'p1', field: 'reasoning', delta: '思考' });
116
+ push({ type: 'part.delta', messageId: 'm1', partId: 'p2', field: 'text', delta: '你' });
117
+ push({ type: 'part.delta', messageId: 'm1', partId: 'p2', field: 'text', delta: '好' });
118
+ push({ type: 'tool.updated', kind: 'scheduled', toolCallId: 't1', toolName: 'read_file', input: { path: 'a.txt' } });
119
+ push({ type: 'tool.updated', kind: 'result', toolCallId: 't1', toolName: 'read_file', output: 'file body' });
120
+ push({ type: 'tool.updated', kind: 'scheduled', toolCallId: 't2', toolName: 'write_file', input: { path: 'b.txt' } });
121
+ const permId = nextId++;
122
+ pendingServerRequests.set(permId, data => {
123
+ const decision = data.result?.decision;
124
+ push({ type: 'permission.resolved', toolCallId: 't2', decision });
125
+ if (decision === 'allow') push({ type: 'tool.updated', kind: 'result', toolCallId: 't2', toolName: 'write_file', output: 'written' });
126
+ else push({ type: 'tool.updated', kind: 'result', toolCallId: 't2', toolName: 'write_file', output: `decision:${decision}` });
127
+ const askId = nextId++;
128
+ pendingServerRequests.set(askId, askData => {
129
+ push({ type: 'turn.completed', response: `你好(${decision}/${askData.result?.value})`, tokenCount: 42, usage: { inputTokens: 30, outputTokens: 12 }, toolCallCount: 2, duration: 1.5, cacheStats: { cacheReadTokens: 7 } });
130
+ });
131
+ send({ id: askId, method: 'interaction/requestUserInput', params: { requestId: 'ask-1', prompt: '继续吗?', inputType: 'choice', choices: ['是', '否'] } });
132
+ });
133
+ send({ id: permId, method: 'interaction/requestPermission', params: { requestId: 'perm-1', toolCallId: 't2', toolName: 'write_file', riskLevel: 'medium', reason: '写入文件', input: { path: 'b.txt' }, options: [{ kind: 'allow_once' }] } });
134
+ };
135
+ // send 的应答先落盘,再在下一个 tick 推事件
136
+ setTimeout(runTurn, 30);
137
+ } else if (method === 'session/setModel') {
138
+ send({ id, result: { sessionId: params.sessionId, accepted: true } });
139
+ } else if (method === 'session/stop') {
140
+ send({ id, result: { sessionId: params.sessionId, accepted: true, stateRevision: 3 } });
141
+ } else if (method === 'session/events') {
142
+ send({ id, result: { sessionId: params.sessionId, eventSeq: 0, events: [] } });
143
+ } else if (method === 'session/close') {
144
+ send({ id, result: { sessionId: params.sessionId } });
145
+ } else if (id !== undefined) {
146
+ send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
147
+ }
148
+ }
149
+ });
150
+ setTimeout(() => process.exit(0), 60000);
151
+ return;
152
+ }
153
+
154
+ // --- driver: exercise the adapter against the fixture via the env override ---
155
+ const path = require('node:path');
156
+ const fs = require('node:fs');
157
+ const os = require('node:os');
158
+ const zcode = require('../src/main/adapters/zcode');
159
+
160
+ (async () => {
161
+ // 隔离:fixture 内置目录(account: fixture-plan)+ 凭据键名,不触碰真实安装
162
+ const fixtureBase = fs.mkdtempSync(path.join(os.tmpdir(), 'zcode-fixture-'));
163
+ const fixtureBuiltin = path.join(fixtureBase, 'zcode-builtin.json');
164
+ fs.writeFileSync(fixtureBuiltin, JSON.stringify({
165
+ schemaVersion: 1, revision: 42,
166
+ config: { providerConfigRules: { providerRules: [{ providerId: 'account:fixture-plan', config: { access: { type: 'zhipu-account', entitled: true }, builtinModelIds: ['glm-4.6', 'glm-4.5-air'] } }] }, modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] } },
167
+ }));
168
+ fs.writeFileSync(path.join(fixtureBase, 'credentials.json'), JSON.stringify({ 'account-provider:coding-plan:account:fixture-plan:account:1:api-key': 'enc:v1:fake' }));
169
+ const previous = {
170
+ BUILTIN: process.env.HARNESS_MIX_ZCODE_BUILTIN_CONFIG,
171
+ EXEC: process.env.HARNESS_MIX_ZCODE_EXECUTABLE,
172
+ CREDENTIALS: process.env.HARNESS_MIX_ZCODE_CREDENTIALS,
173
+ SECRET: process.env.ZCODE_CREDENTIAL_SECRET,
174
+ DATA: process.env.ZCODE_DATA_BASE_DIR,
175
+ };
176
+ process.env.HARNESS_MIX_ZCODE_EXECUTABLE = __filename;
177
+ process.env.HARNESS_MIX_ZCODE_BUILTIN_CONFIG = fixtureBuiltin;
178
+ process.env.HARNESS_MIX_ZCODE_CREDENTIALS = path.join(fixtureBase, 'credentials.json');
179
+ delete process.env.ZCODE_DATA_BASE_DIR;
180
+ assert.deepEqual(zcode.resolveLaunch(), { command: process.execPath, args: [__filename, 'app-server', '--stdio'] });
181
+ assert.equal(zcode.manifest.capabilities.permissionModes, true, 'manifest 必须声明 permissionModes 能力');
182
+ assert.equal(zcode.manifest.capabilities.attachments, true, 'manifest 必须声明 attachments 能力');
183
+ assert.equal(zcode.manifest.capabilities.collaborationTools, true, 'manifest 必须声明 collaborationTools(工人/团队成员角色)');
184
+ const adapter = zcode.create();
185
+ const events = [];
186
+ let session;
187
+ try {
188
+ session = await adapter.open({
189
+ thread: { cwd: process.cwd(), options: { permissionMode: 'build' } },
190
+ emit: event => events.push(event),
191
+ diagnostic: () => {},
192
+ // 协作描述符在 open 时传入(工人/成员角色不需要 harness 侧 MCP 工具)
193
+ collaboration: { command: process.execPath, args: ['bridge.cjs'], env: { HARNESS_MIX_COLLAB_KEY: 'fixture' } },
194
+ });
195
+ assert.equal(session.state.sessionId, 'sess-fix');
196
+ const sessionEvent = events.find(event => event.kind === 'session');
197
+ assert.equal(sessionEvent.nativeSessionId, 'sess-fix');
198
+
199
+ const models = await adapter.listModelsFor(session);
200
+ assert.deepEqual(models.map(model => model.id), ['glm-4.6', 'glm-4.5-air']);
201
+ assert.equal(models[0].provider, 'account:fixture-plan');
202
+ assert.deepEqual(models[0].efforts, ['low', 'high']);
203
+ assert.equal(models[0].defaultEffort, 'high');
204
+ const selected = await adapter.setModel(session, models[0]);
205
+ assert.equal(selected.id, 'glm-4.6');
206
+ await adapter.setThinkingLevel(session, 'low');
207
+
208
+ // 权限模式目录与官方桌面选择器一致;线程选项在 open 时已生效
209
+ const catalog = await adapter.describeFor(session);
210
+ assert.deepEqual(catalog.permissionModes.map(mode => mode.id), ['plan', 'build', 'edit', 'yolo']);
211
+ assert.equal(catalog.permissionModes.find(mode => mode.default)?.id, 'build', '原生默认档是 build');
212
+ assert.equal(catalog.permissionModes.find(mode => mode.dangerous)?.id, 'yolo', '完全访问必须标记 dangerous');
213
+ await adapter.setPermissionMode(session, 'yolo');
214
+ await assert.rejects(() => adapter.setPermissionMode(session, 'auto'), /未知的 ZCode 权限模式/, '目录外模式必须拒绝');
215
+
216
+ // 权限/提问卡片先于回合结束出现,respond 后回合才完成
217
+ const settled = adapter.send(session, 'hello');
218
+ await assert.rejects(
219
+ () => Promise.race([settled, new Promise((_, reject) => setTimeout(() => reject(new Error('回合提前结束')), 400))]),
220
+ /回合提前结束/, '权限未应答时回合必须保持等待');
221
+ const waitFor = async predicate => {
222
+ for (let i = 0; i < 200; i++) {
223
+ const found = events.find(predicate);
224
+ if (found) return found;
225
+ await new Promise(resolve => setTimeout(resolve, 30));
226
+ }
227
+ return null;
228
+ };
229
+ const approval = await waitFor(event => event.kind === 'approval' && String(event.requestId).includes('requestPermission'));
230
+ assert.ok(approval, '权限卡片必须投影');
231
+ assert.ok(approval.title.includes('write_file'));
232
+ await adapter.respond(session, approval.requestId, { optionId: 'accept' });
233
+ const question = await waitFor(event => event.kind === 'approval' && String(event.requestId).includes('requestUserInput'));
234
+ assert.ok(question, '提问卡片必须投影');
235
+ assert.deepEqual(question.options.map(option => option.id), ['是', '否']);
236
+ await adapter.respond(session, question.requestId, { optionId: '是' });
237
+ await settled;
238
+
239
+ assert.equal(events.filter(event => event.kind === 'thinking-delta').map(event => event.text).join(''), '思考', 'reasoning delta 投影');
240
+ assert.equal(events.filter(event => event.kind === 'text-delta').map(event => event.text).join(''), '你好');
241
+ const tools = events.filter(event => event.kind === 'tool');
242
+ assert.deepEqual(tools.map(tool => `${tool.toolCallId}:${tool.state}`), ['t1:running', 't1:done', 't2:running', 't2:done']);
243
+ assert.equal(tools[1].output, 'file body');
244
+ assert.equal(tools[3].output, 'written');
245
+ const usageEvent = events.find(event => event.kind === 'usage');
246
+ assert.deepEqual(usageEvent.usage, { inputTokens: 30, outputTokens: 12, cachedInputTokens: 7, totalTokens: 42 });
247
+ assert.ok(events.some(event => event.kind === 'completed' && event.finalAnswer === true));
248
+ const context = await adapter.getContextUsage(session);
249
+ // projectUsage 只认 tokens/contextUsedTokens;usedTokens 键永远匹配不上
250
+ assert.deepEqual(context, { tokens: 1234, contextWindow: 200000 });
251
+
252
+ // 附件:runtime 已给路径的图直接走 localPath;纯 base64 落盘为临时文件后同通道发送
253
+ await adapter.send(session, '带路径图', null, { images: [
254
+ { name: 'shot.png', mime: 'image/png', data: 'aGVsbG8=', path: path.join(fixtureBase, 'shot.png') },
255
+ ] });
256
+ await adapter.send(session, '纯base64图', null, { images: [
257
+ { name: 'paste.png', mime: 'image/png', data: Buffer.from('png-bytes').toString('base64') },
258
+ ] });
259
+ const textAfter = events.filter(event => event.kind === 'text-delta').map(event => event.text).join('');
260
+ assert.ok(textAfter.includes('图已收到'), '附件回合必须完成');
261
+ assert.equal(session.state.tempFiles.length, 1, '仅 base64-only 图片落盘');
262
+ const tempFile = session.state.tempFiles[0];
263
+ assert.ok(fs.existsSync(tempFile), 'base64 附件必须物化为临时文件');
264
+ assert.equal(fs.readFileSync(tempFile).toString(), 'png-bytes', '落盘内容必须与 base64 解码一致');
265
+
266
+ // 流恢复重放去重:重放窗口整体与累积文本尾部重叠,只允许增量透出
267
+ const trim = zcode.trimStreamReplayOverlap;
268
+ assert.equal(trim('', 'abc'), 'abc', '空累积不裁剪');
269
+ assert.equal(trim('你好', 'xy'), 'xy', '无重叠不裁剪');
270
+ assert.equal(trim(`xx${'0123456789AB'}`, `${'0123456789AB'}cd`), 'cd', '12 字符重叠必须裁剪');
271
+ assert.equal(trim(`xx${'0123456789A'}`, `${'0123456789A'}cd`), `${'0123456789A'}cd`, '11 字符重叠低于阈值不裁剪');
272
+ assert.equal(trim('abcdefgh1234', 'abcdefgh1234'), '', '纯重放(无增量)整段丢弃');
273
+ const replayMark = events.length;
274
+ await adapter.send(session, '流恢复');
275
+ const replayText = events.slice(replayMark).filter(event => event.kind === 'text-delta').map(event => event.text).join('');
276
+ assert.equal(replayText, 'abcdefgh1234下一步继续', '重放窗口必须剪掉,只透出增量');
277
+ assert.ok(events.slice(replayMark).some(event => event.kind === 'completed'), '流恢复回合必须正常完成');
278
+
279
+ // 取消:向 server 发送 session/stop 并本地结算;旧回合迟到的 turn.completed
280
+ // 不得结算新回合(suppressCompletions 直到下一次 turn.started 才复位)
281
+ await adapter.cancel(session);
282
+ assert.equal(session.state.suppressCompletions, true, '取消后必须压制迟到 completion');
283
+ await adapter.close(session);
284
+ assert.equal(fs.existsSync(tempFile), false, 'close 后必须清理附件临时文件');
285
+
286
+ // 拒绝权限:decline 必须映射为 {decision:'deny'}(fixture 会把 decision 回显进回合响应)
287
+ {
288
+ const declineEvents = [];
289
+ const declineSession = await adapter.open({ thread: { cwd: process.cwd() }, emit: e => declineEvents.push(e), diagnostic: () => {} });
290
+ try {
291
+ const declineWaitFor = async predicate => {
292
+ for (let i = 0; i < 100; i++) {
293
+ const hit = declineEvents.find(predicate);
294
+ if (hit) return hit;
295
+ await new Promise(resolve => setTimeout(resolve, 30));
296
+ }
297
+ return null;
298
+ };
299
+ const turn = adapter.send(declineSession, '拒绝流程', null);
300
+ const approval = await declineWaitFor(event => event.kind === 'approval' && String(event.requestId).includes('requestPermission'));
301
+ assert.ok(approval, 'decline 流程:权限卡片必须投影');
302
+ await adapter.respond(declineSession, approval.requestId, { optionId: 'decline' });
303
+ const question = await declineWaitFor(event => event.kind === 'approval' && String(event.requestId).includes('requestUserInput'));
304
+ assert.ok(question, 'decline 流程:提问卡片必须投影');
305
+ await adapter.respond(declineSession, question.requestId, { optionId: '是' });
306
+ await turn;
307
+ const denyTool = declineEvents.find(event => event.kind === 'tool' && event.toolCallId === 't2' && event.state === 'done');
308
+ assert.equal(denyTool?.output, 'decision:deny', 'decline 应答必须是 deny(不得短路成 cancelled)');
309
+ // 进程死亡自愈:下一次 send 重连恢复(resume 失败则回退新会话),不得永久挂起
310
+ declineSession.proc.stop();
311
+ for (let i = 0; i < 100 && !declineSession.state.closed; i++) await new Promise(resolve => setTimeout(resolve, 30));
312
+ assert.equal(declineSession.state.closed, true, '进程死后 closed 必须置位');
313
+ declineEvents.length = 0;
314
+ // '流恢复' 分支无权限交互:重连后的新 fixture 进程第一轮即可自动完成
315
+ await adapter.send(declineSession, '流恢复', null);
316
+ assert.equal(declineSession.state.closed, false, 'send 自愈后 closed 必须复位');
317
+ assert.ok(declineEvents.some(event => event.kind === 'completed'), '重连回合必须完成');
318
+ } finally { await adapter.close(declineSession); }
319
+ }
320
+ console.log('zcode adapter: protocol framing, session lifecycle, model catalog, permissions, questions, deltas, tools, usage, attachments, collaboration, decline-to-deny, crash self-heal and cancel PASS');
321
+ } finally {
322
+ for (const [key, value] of Object.entries(previous)) {
323
+ if (value === undefined) delete process.env[key];
324
+ else process.env[key] = value;
325
+ }
326
+ fs.rmSync(fixtureBase, { recursive: true, force: true });
327
+ session?.proc?.stop();
328
+ }
329
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,66 @@
1
+ // Live probe against the real ZCode app-server (needs the desktop install and
2
+ // a logged-in shared credential). Verifies: session/send attachments end-to-end
3
+ // (localPath channel), mcp/list contents. Run: node scripts/zcode-live-probe.cjs
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const assert = require('node:assert');
8
+
9
+ const zcode = require('../src/main/adapters/zcode');
10
+
11
+ // 64x64 solid red PNG generated via System.Drawing and verified decodable by
12
+ // ZCode's Read tool. Inline "tiny red PNG" snippets circulating in docs have
13
+ // proven undecodable, so this exact payload is the verified one.
14
+ const RED_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACJSURBVHhe7dAhAQBAEITA7Z/sW/15KoAYg2Rv+2ZjsGkAg00DGGwawGDTAAabBjDYNIDBpgEMNg1gsGkAg00DGGwawGDTAAabBjDYNIDBpgEMNg1gsGkAg00DGGwawGDTAAabBjDYNIDBpgEMNg1gsGkAg00DGGwawGDTAAabBjDYNIDBpgEMNgeiYnGlP5FKrAAAAABJRU5ErkJggg==';
15
+ const redPng = () => Buffer.from(RED_PNG_BASE64, 'base64');
16
+
17
+ async function ask(adapter, session, events, text, attachments) {
18
+ events.length = 0;
19
+ await adapter.send(session, text, null, attachments);
20
+ return events.filter(e => e.kind === 'text-delta').map(e => e.text).join('');
21
+ }
22
+
23
+ async function main() {
24
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zcode-probe-'));
25
+ const image = path.join(probeDir, 'solid-red.png');
26
+ fs.writeFileSync(image, redPng());
27
+ const events = [];
28
+ const adapter = zcode.create();
29
+ // Hard watchdog: a wedged turn (e.g. an attachment error path that never
30
+ // settles) must not hang the probe forever.
31
+ const watchdog = setTimeout(() => { console.error('PROBE WATCHDOG: 240s budget exceeded'); process.exit(2); }, 240_000);
32
+ watchdog.unref();
33
+ const session = await adapter.open({
34
+ thread: { cwd: probeDir },
35
+ emit: event => {
36
+ events.push(event);
37
+ process.stdout.write(`[event] ${JSON.stringify(event).slice(0, 180)}\n`);
38
+ // Throwaway probe dir: auto-answer any permission card so the turn settles.
39
+ if (event.kind === 'approval') adapter.respond(session, event.requestId, { optionId: event.options?.[0]?.id }).catch(() => {});
40
+ },
41
+ diagnostic: line => process.stdout.write(`[diag] ${String(line).slice(0, 400)}\n`),
42
+ });
43
+ try {
44
+ console.log('sessionId:', session.state.sessionId);
45
+
46
+ try {
47
+ const mcp = await session.proc.request('mcp/list', { workspace: { workspacePath: probeDir, workspaceKey: probeDir } });
48
+ console.log('\n=== mcp/list ===\n', JSON.stringify(mcp).slice(0, 1800));
49
+ } catch (error) {
50
+ console.log('mcp/list failed:', error.message.slice(0, 300));
51
+ }
52
+
53
+ const answer = await ask(adapter, session, events,
54
+ '我给你发了一张本地图片文件。只能用 Read 工具查看它(禁止使用 Bash),然后回答:图片是什么颜色?只回答颜色词。',
55
+ { images: [{ name: 'solid-red.png', mime: 'image/png', data: RED_PNG_BASE64, path: image }] });
56
+ console.log('\n=== localPath attachment answer ===\n', answer.slice(0, 300));
57
+ assert.match(answer, /红|red/i, 'model should see the red image via localPath');
58
+
59
+ console.log('\nLIVE PROBE PASS');
60
+ } finally {
61
+ await adapter.close(session).catch(() => {});
62
+ setTimeout(() => { try { fs.rmSync(probeDir, { recursive: true, force: true }); } catch { /* windows eperm racing the just-killed child */ } }, 1500);
63
+ }
64
+ }
65
+
66
+ main().catch(error => { console.error('PROBE FAILED:', error); process.exit(1); });