@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
|
@@ -21,11 +21,12 @@ async function until(fn) {
|
|
|
21
21
|
await rt.store.load();
|
|
22
22
|
|
|
23
23
|
const emits = new Map();
|
|
24
|
+
const cancels = new Map(); // threadId -> adapter.cancel 次数(看门狗级联取消断言)
|
|
24
25
|
const adapter = {
|
|
25
26
|
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
26
27
|
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
27
28
|
async send(session) {},
|
|
28
|
-
async cancel(session) {},
|
|
29
|
+
async cancel(session) { cancels.set(session.threadId, (cancels.get(session.threadId) ?? 0) + 1); },
|
|
29
30
|
async close() {},
|
|
30
31
|
};
|
|
31
32
|
rt.adapters.set(adapter.manifest.id, adapter);
|
|
@@ -57,11 +58,15 @@ async function until(fn) {
|
|
|
57
58
|
await rt.send(stuck.id, 'wedged session');
|
|
58
59
|
await until(() => stuck.status === 'error');
|
|
59
60
|
assert.match(stuck.error, /卡死/);
|
|
61
|
+
// 结算的同时级联取消原生会话:否则僵尸进程常驻,下一回合撞上原生侧占用报错
|
|
62
|
+
await until(() => (cancels.get(stuck.id) ?? 0) >= 1);
|
|
60
63
|
const turn = rt.execution.lastTurn(stuck.id);
|
|
61
64
|
assert.equal(turn.status, 'error');
|
|
62
65
|
await until(() => !stuck.reviewPending);
|
|
63
66
|
const open = rt.core.getItemsForTurn(turn.id).filter(item => !['completed', 'error', 'cancelled'].includes(item.status));
|
|
64
67
|
assert.equal(open.length, 0, 'watchdog settlement finalizes every open item');
|
|
68
|
+
assert.equal(cancels.get(active.id) ?? 0, 0, '正常完成的活跃回合不触发 adapter.cancel');
|
|
69
|
+
assert.equal(cancels.get(approval.id) ?? 0, 0, '审批等待后正常完成的回合不触发 adapter.cancel');
|
|
65
70
|
|
|
66
71
|
console.log('stuck-turn-test: active/approval-waiting turns survive; wedged zero-event turn auto-settles and finalizes items');
|
|
67
72
|
} finally {
|
|
@@ -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); });
|