@harness-mix/cli 0.2.3 → 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 +13 -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 +11 -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/jsonl-stdin-test.cjs +40 -31
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-update-apply-test.cjs +269 -215
- package/scripts/native-update.cjs +78 -0
- package/scripts/native-vendor-adapters-test.cjs +196 -154
- package/scripts/salvage-rollout-writes.cjs +72 -0
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1418
- package/src/main/adapters/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/host/collaboration.js +723 -715
- package/src/main/host/jsonl.js +130 -120
- package/src/main/native/config.js +9 -9
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1221 -1187
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- 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
|
@@ -1,154 +1,196 @@
|
|
|
1
|
-
const assert = require('node:assert/strict');
|
|
2
|
-
const oc = require('../src/main/adapters/opencode');
|
|
3
|
-
const grok = require('../src/main/adapters/grok');
|
|
4
|
-
const pi = require('../src/main/adapters/pi');
|
|
5
|
-
(async () => {
|
|
6
|
-
const events = [], calls = [];
|
|
7
|
-
const session = { nativeSessionId: 's', emit: e => events.push(e), state: { active: true, userMessages: new Set(), parts: new Map(), text: new Map(), permissions: new Map(), questions: new Map() } };
|
|
8
|
-
const event = (type, properties) => oc.projectEvent(session, { type, properties: { sessionID: 's', ...properties } }, session.emit);
|
|
9
|
-
event('message.updated', { info: { role: 'user', id: 'u' } });
|
|
10
|
-
event('message.part.updated', { part: { id: 'up', messageID: 'u', type: 'text', text: 'do not echo' } });
|
|
11
|
-
event('message.part.updated', { part: { id: 'p', messageID: 'a', type: 'text', text: 'A' } });
|
|
12
|
-
event('message.part.delta', { partID: 'p', field: 'text', delta: 'B' });
|
|
13
|
-
oc.projectPart(session, { id: 'p', messageID: 'a', type: 'text', text: 'AB' }, session.emit);
|
|
14
|
-
assert.equal(events.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), 'AB');
|
|
15
|
-
event('question.asked', { id: 'q', questions: [{ question: 'Choose', options: [{ label: 'yes' }] }] });
|
|
16
|
-
assert.equal(events.at(-1).kind, 'approval');
|
|
17
|
-
let fail = true;
|
|
18
|
-
session.host = { request: async (...args) => { if (fail) throw new Error('network'); calls.push(args); } };
|
|
19
|
-
const adapter = oc.create();
|
|
20
|
-
await assert.rejects(adapter.respond(session, 'q:0', { value: 'yes' }));
|
|
21
|
-
assert.equal(session.state.questions.get('q').index, 0);
|
|
22
|
-
fail = false;
|
|
23
|
-
await adapter.respond(session, 'q:0', { value: 'yes' });
|
|
24
|
-
assert.deepEqual(calls.at(-1)[2], { answers: [['yes']] });
|
|
25
|
-
event('permission.asked', { id: 'perm', permission: 'shell', patterns: ['test'] });
|
|
26
|
-
await assert.rejects(adapter.respond(session, 'perm', { value: 'invented' }));
|
|
27
|
-
await adapter.respond(session, 'perm', { optionId: 'always' });
|
|
28
|
-
assert.deepEqual(calls.at(-1)[2], { reply: 'always' });
|
|
29
|
-
assert.deepEqual(grok.projectUsage({ input_tokens: 12, outputTokens: 3, signature: 'private', costUsdTicks: 5 }), { inputTokens: 12, outputTokens: 3 });
|
|
30
|
-
const piFailure = pi.project({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'native quota exhausted' }] });
|
|
31
|
-
assert.equal(piFailure.kind, 'error'); assert.match(piFailure.message, /quota exhausted/);
|
|
32
|
-
let answer;
|
|
33
|
-
const gs = { pendingApprovals: new Map([['r', { options: [{ optionId: 'native-always', kind: 'allow_always' }], resolve: v => { answer = v; } }]]) };
|
|
34
|
-
await assert.rejects(grok.create().respond(gs, 'r', { optionId: 'fake' }));
|
|
35
|
-
await grok.create().respond(gs, 'r', { optionId: 'native-always' });
|
|
36
|
-
assert.equal(grok.manifest.capabilities.attachments, true);
|
|
37
|
-
const grokCalls = [];
|
|
38
|
-
const grokSession = { nativeSessionId: 'grok-1', state: { agentCapabilities: {} }, process: { request: async (method, params) => { grokCalls.push({ method, params }); return { stopReason: 'end_turn', _meta: { promptId: 'p1' } }; } } };
|
|
39
|
-
await grok.create().send(grokSession, 'hi', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
40
|
-
assert.equal(grokCalls[0].method, 'session/prompt');
|
|
41
|
-
assert.deepEqual(grokCalls[0].params.prompt, [{ type: 'text', text: 'hi' }, { type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
42
|
-
|
|
43
|
-
// Grok auto-compaction update parsing
|
|
44
|
-
const updateStarted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_started', tokens_used: 400000, context_window: 500000 });
|
|
45
|
-
assert.deepEqual(updateStarted, { type: 'started', tokensUsed: 400000, contextWindowTokens: 500000 });
|
|
46
|
-
const updateCompleted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_completed', tokens_before: 400000, tokens_after: 12000, context_window: 500000 });
|
|
47
|
-
assert.deepEqual(updateCompleted, { type: 'completed', outcome: 'succeeded', tokensBefore: 400000, tokensAfter: 12000, contextWindowTokens: 500000 });
|
|
48
|
-
const updateFailed = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_failed', error_message: 'compaction error' });
|
|
49
|
-
assert.deepEqual(updateFailed, { type: 'completed', outcome: 'failed', errorMessage: 'compaction error' });
|
|
50
|
-
const updateCancelled = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_cancelled' });
|
|
51
|
-
assert.deepEqual(updateCancelled, { type: 'completed', outcome: 'cancelled' });
|
|
52
|
-
|
|
53
|
-
// Grok commands & manual compact
|
|
54
|
-
const grokAdapterInst = grok.create();
|
|
55
|
-
const grokCmds = await grokAdapterInst.listCommands(grokSession);
|
|
56
|
-
assert.ok(grokCmds.some(c => c.id === 'compact' && c.action === 'execute'));
|
|
57
|
-
|
|
58
|
-
// Test /compact interception in send()
|
|
59
|
-
const grokCompactEmits = [];
|
|
60
|
-
grokCalls.length = 0;
|
|
61
|
-
grokSession.process.request = async (method, params) => {
|
|
62
|
-
grokCalls.push({ method, params });
|
|
63
|
-
return { outcome: 'succeeded', tokensBefore: 50000, tokensAfter: 5000 };
|
|
64
|
-
};
|
|
65
|
-
await grokAdapterInst.send(grokSession, '/compact keep recent tests', { emit: e => grokCompactEmits.push(e) });
|
|
66
|
-
assert.equal(grokCalls[0].method, 'x.ai/compact_conversation');
|
|
67
|
-
assert.deepEqual(grokCalls[0].params, { sessionId: 'grok-1', userContext: 'keep recent tests' });
|
|
68
|
-
assert.ok(grokCompactEmits.some(e => e.kind === 'compaction' && e.state === 'completed' && e.outcome === 'succeeded'));
|
|
69
|
-
assert.ok(grokCompactEmits.some(e => e.kind === 'usage' && e.usage.tokens === 5000));
|
|
70
|
-
|
|
71
|
-
// Test EventNormalizer compaction mapping
|
|
72
|
-
const { EventNormalizer } = require('../src/main/harness-adapter/event-normalizer');
|
|
73
|
-
const normalizer = new EventNormalizer({ threadId: 't-comp' });
|
|
74
|
-
normalizer.beginTurn('turn-c', 'test compact');
|
|
75
|
-
const normEvents = normalizer.normalize({ kind: 'compaction', tokensBefore: 50000, tokensAfter: 5000 });
|
|
76
|
-
assert.equal(normEvents.length, 2);
|
|
77
|
-
assert.equal(normEvents[0].type, 'item.started');
|
|
78
|
-
assert.equal(normEvents[0].payload.type, 'context_compaction');
|
|
79
|
-
assert.equal(normEvents[0].payload.tokensBefore, 50000);
|
|
80
|
-
assert.equal(normEvents[1].type, 'item.completed');
|
|
81
|
-
|
|
82
|
-
// Test projectItem mapping
|
|
83
|
-
const { projectItem } = require('../src/main/native/protocol');
|
|
84
|
-
const projected = projectItem({ id: 'norm-item-1', type: 'context_compaction' });
|
|
85
|
-
assert.deepEqual(projected, { id: 'norm-item-1', type: 'contextCompaction' });
|
|
86
|
-
|
|
87
|
-
// Pi 自动重试(stream 错误如 "Anthropic stream ended without a stop reason"):
|
|
88
|
-
// message_end 的 error 不得提前定论为 Turn 失败——agent_end 携带 willRetry,
|
|
89
|
-
// 重试成功的回答必须完整落地,全程零 error 事件(否则 Host 提前结算,
|
|
90
|
-
// 重试结果被丢弃,用户重发还会撞上原生 "Agent is already processing")。
|
|
91
|
-
{
|
|
92
|
-
const emitted = [];
|
|
93
|
-
const fakeProc = { harnessMixAnswer: '', command: async () => ({ leafId: 'leaf-1' }) };
|
|
94
|
-
const fwd = (event) => pi.forwardEvent(fakeProc, event, e => emitted.push(e));
|
|
95
|
-
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Anthropic stream ended without a stop reason' };
|
|
96
|
-
fwd({ type: 'message_end', message: failedMsg });
|
|
97
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, 'message_end 不得提前报错');
|
|
98
|
-
fwd({ type: 'agent_end', willRetry: true, messages: [{ role: 'user', content: [{ type: 'text', text: '登录好了' }] }, failedMsg] });
|
|
99
|
-
assert.equal(emitted.at(-1).kind, 'status');
|
|
100
|
-
fwd({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: failedMsg.errorMessage });
|
|
101
|
-
assert.equal(emitted.at(-1).kind, 'status');
|
|
102
|
-
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '好的,' } });
|
|
103
|
-
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '开始执行。' } });
|
|
104
|
-
fwd({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' } });
|
|
105
|
-
assert.equal(emitted.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), '好的,开始执行。', '已流式回答不得重复补发');
|
|
106
|
-
fwd({ type: 'auto_retry_end', success: true, attempt: 1 });
|
|
107
|
-
fwd({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' }] });
|
|
108
|
-
fwd({ type: 'agent_settled', sessionId: 's-retry' });
|
|
109
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
110
|
-
assert.equal(emitted.at(-1).kind, 'completed');
|
|
111
|
-
assert.equal(emitted.at(-1).nativeRef.checkpointId, 'leaf-1');
|
|
112
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, '重试成功全程不得出现 error');
|
|
113
|
-
}
|
|
114
|
-
// Pi 重试耗尽/不可重试:error 在 agent_end(willRetry:false)处恰好结算一次
|
|
115
|
-
{
|
|
116
|
-
const emitted = [];
|
|
117
|
-
const fwd = (event) => pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) }, event, e => emitted.push(e));
|
|
118
|
-
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'native quota exhausted' };
|
|
119
|
-
fwd({ type: 'message_end', message: failedMsg });
|
|
120
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 0);
|
|
121
|
-
fwd({ type: 'agent_end', willRetry: false, messages: [failedMsg] });
|
|
122
|
-
assert.equal(emitted.filter(e => e.kind === 'error').length, 1);
|
|
123
|
-
assert.match(emitted.find(e => e.kind === 'error').message, /quota exhausted/);
|
|
124
|
-
}
|
|
125
|
-
// Pi 未流式回答的 message_end 回退补发仍然有效
|
|
126
|
-
{
|
|
127
|
-
const emitted = [];
|
|
128
|
-
pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) },
|
|
129
|
-
{ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '非流式回答' }], stopReason: 'stop' } },
|
|
130
|
-
e => emitted.push(e));
|
|
131
|
-
assert.ok(emitted.some(e => e.kind === 'text-delta' && e.text === '非流式回答'));
|
|
132
|
-
}
|
|
133
|
-
// Pi send():原生拒绝 "already processing"(Host 与原生状态偶发分叉,如取消竞态)时
|
|
134
|
-
// 按原生协议提示以 followUp 排队重发;其他错误原样抛出
|
|
135
|
-
{
|
|
136
|
-
const calls = [];
|
|
137
|
-
let rejectOnce = true;
|
|
138
|
-
const busySession = { process: { harnessMixAnswer: '', command: async (payload) => {
|
|
139
|
-
calls.push(payload);
|
|
140
|
-
if (rejectOnce && payload.type === 'prompt') { rejectOnce = false; throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); }
|
|
141
|
-
return {};
|
|
142
|
-
} } };
|
|
143
|
-
await pi.create().send(busySession, '登录好了', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
144
|
-
assert.equal(calls.length, 2);
|
|
145
|
-
assert.equal(calls[0].streamingBehavior, undefined);
|
|
146
|
-
assert.equal(calls[1].streamingBehavior, 'followUp');
|
|
147
|
-
assert.equal(calls[1].message, '登录好了');
|
|
148
|
-
assert.deepEqual(calls[1].images, [{ type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
149
|
-
const failingSession = { process: { harnessMixAnswer: '', command: async () => { throw new Error('Authentication failed'); } } };
|
|
150
|
-
await assert.rejects(pi.create().send(failingSession, 'hi', { emit: () => {} }, {}), /Authentication failed/);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const oc = require('../src/main/adapters/opencode');
|
|
3
|
+
const grok = require('../src/main/adapters/grok');
|
|
4
|
+
const pi = require('../src/main/adapters/pi');
|
|
5
|
+
(async () => {
|
|
6
|
+
const events = [], calls = [];
|
|
7
|
+
const session = { nativeSessionId: 's', emit: e => events.push(e), state: { active: true, userMessages: new Set(), parts: new Map(), text: new Map(), permissions: new Map(), questions: new Map() } };
|
|
8
|
+
const event = (type, properties) => oc.projectEvent(session, { type, properties: { sessionID: 's', ...properties } }, session.emit);
|
|
9
|
+
event('message.updated', { info: { role: 'user', id: 'u' } });
|
|
10
|
+
event('message.part.updated', { part: { id: 'up', messageID: 'u', type: 'text', text: 'do not echo' } });
|
|
11
|
+
event('message.part.updated', { part: { id: 'p', messageID: 'a', type: 'text', text: 'A' } });
|
|
12
|
+
event('message.part.delta', { partID: 'p', field: 'text', delta: 'B' });
|
|
13
|
+
oc.projectPart(session, { id: 'p', messageID: 'a', type: 'text', text: 'AB' }, session.emit);
|
|
14
|
+
assert.equal(events.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), 'AB');
|
|
15
|
+
event('question.asked', { id: 'q', questions: [{ question: 'Choose', options: [{ label: 'yes' }] }] });
|
|
16
|
+
assert.equal(events.at(-1).kind, 'approval');
|
|
17
|
+
let fail = true;
|
|
18
|
+
session.host = { request: async (...args) => { if (fail) throw new Error('network'); calls.push(args); } };
|
|
19
|
+
const adapter = oc.create();
|
|
20
|
+
await assert.rejects(adapter.respond(session, 'q:0', { value: 'yes' }));
|
|
21
|
+
assert.equal(session.state.questions.get('q').index, 0);
|
|
22
|
+
fail = false;
|
|
23
|
+
await adapter.respond(session, 'q:0', { value: 'yes' });
|
|
24
|
+
assert.deepEqual(calls.at(-1)[2], { answers: [['yes']] });
|
|
25
|
+
event('permission.asked', { id: 'perm', permission: 'shell', patterns: ['test'] });
|
|
26
|
+
await assert.rejects(adapter.respond(session, 'perm', { value: 'invented' }));
|
|
27
|
+
await adapter.respond(session, 'perm', { optionId: 'always' });
|
|
28
|
+
assert.deepEqual(calls.at(-1)[2], { reply: 'always' });
|
|
29
|
+
assert.deepEqual(grok.projectUsage({ input_tokens: 12, outputTokens: 3, signature: 'private', costUsdTicks: 5 }), { inputTokens: 12, outputTokens: 3 });
|
|
30
|
+
const piFailure = pi.project({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'native quota exhausted' }] });
|
|
31
|
+
assert.equal(piFailure.kind, 'error'); assert.match(piFailure.message, /quota exhausted/);
|
|
32
|
+
let answer;
|
|
33
|
+
const gs = { pendingApprovals: new Map([['r', { options: [{ optionId: 'native-always', kind: 'allow_always' }], resolve: v => { answer = v; } }]]) };
|
|
34
|
+
await assert.rejects(grok.create().respond(gs, 'r', { optionId: 'fake' }));
|
|
35
|
+
await grok.create().respond(gs, 'r', { optionId: 'native-always' });
|
|
36
|
+
assert.equal(grok.manifest.capabilities.attachments, true);
|
|
37
|
+
const grokCalls = [];
|
|
38
|
+
const grokSession = { nativeSessionId: 'grok-1', state: { agentCapabilities: {} }, process: { request: async (method, params) => { grokCalls.push({ method, params }); return { stopReason: 'end_turn', _meta: { promptId: 'p1' } }; } } };
|
|
39
|
+
await grok.create().send(grokSession, 'hi', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
40
|
+
assert.equal(grokCalls[0].method, 'session/prompt');
|
|
41
|
+
assert.deepEqual(grokCalls[0].params.prompt, [{ type: 'text', text: 'hi' }, { type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
42
|
+
|
|
43
|
+
// Grok auto-compaction update parsing
|
|
44
|
+
const updateStarted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_started', tokens_used: 400000, context_window: 500000 });
|
|
45
|
+
assert.deepEqual(updateStarted, { type: 'started', tokensUsed: 400000, contextWindowTokens: 500000 });
|
|
46
|
+
const updateCompleted = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_completed', tokens_before: 400000, tokens_after: 12000, context_window: 500000 });
|
|
47
|
+
assert.deepEqual(updateCompleted, { type: 'completed', outcome: 'succeeded', tokensBefore: 400000, tokensAfter: 12000, contextWindowTokens: 500000 });
|
|
48
|
+
const updateFailed = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_failed', error_message: 'compaction error' });
|
|
49
|
+
assert.deepEqual(updateFailed, { type: 'completed', outcome: 'failed', errorMessage: 'compaction error' });
|
|
50
|
+
const updateCancelled = grok.parseGrokCompactionUpdate({ sessionUpdate: 'auto_compact_cancelled' });
|
|
51
|
+
assert.deepEqual(updateCancelled, { type: 'completed', outcome: 'cancelled' });
|
|
52
|
+
|
|
53
|
+
// Grok commands & manual compact
|
|
54
|
+
const grokAdapterInst = grok.create();
|
|
55
|
+
const grokCmds = await grokAdapterInst.listCommands(grokSession);
|
|
56
|
+
assert.ok(grokCmds.some(c => c.id === 'compact' && c.action === 'execute'));
|
|
57
|
+
|
|
58
|
+
// Test /compact interception in send()
|
|
59
|
+
const grokCompactEmits = [];
|
|
60
|
+
grokCalls.length = 0;
|
|
61
|
+
grokSession.process.request = async (method, params) => {
|
|
62
|
+
grokCalls.push({ method, params });
|
|
63
|
+
return { outcome: 'succeeded', tokensBefore: 50000, tokensAfter: 5000 };
|
|
64
|
+
};
|
|
65
|
+
await grokAdapterInst.send(grokSession, '/compact keep recent tests', { emit: e => grokCompactEmits.push(e) });
|
|
66
|
+
assert.equal(grokCalls[0].method, 'x.ai/compact_conversation');
|
|
67
|
+
assert.deepEqual(grokCalls[0].params, { sessionId: 'grok-1', userContext: 'keep recent tests' });
|
|
68
|
+
assert.ok(grokCompactEmits.some(e => e.kind === 'compaction' && e.state === 'completed' && e.outcome === 'succeeded'));
|
|
69
|
+
assert.ok(grokCompactEmits.some(e => e.kind === 'usage' && e.usage.tokens === 5000));
|
|
70
|
+
|
|
71
|
+
// Test EventNormalizer compaction mapping
|
|
72
|
+
const { EventNormalizer } = require('../src/main/harness-adapter/event-normalizer');
|
|
73
|
+
const normalizer = new EventNormalizer({ threadId: 't-comp' });
|
|
74
|
+
normalizer.beginTurn('turn-c', 'test compact');
|
|
75
|
+
const normEvents = normalizer.normalize({ kind: 'compaction', tokensBefore: 50000, tokensAfter: 5000 });
|
|
76
|
+
assert.equal(normEvents.length, 2);
|
|
77
|
+
assert.equal(normEvents[0].type, 'item.started');
|
|
78
|
+
assert.equal(normEvents[0].payload.type, 'context_compaction');
|
|
79
|
+
assert.equal(normEvents[0].payload.tokensBefore, 50000);
|
|
80
|
+
assert.equal(normEvents[1].type, 'item.completed');
|
|
81
|
+
|
|
82
|
+
// Test projectItem mapping
|
|
83
|
+
const { projectItem } = require('../src/main/native/protocol');
|
|
84
|
+
const projected = projectItem({ id: 'norm-item-1', type: 'context_compaction' });
|
|
85
|
+
assert.deepEqual(projected, { id: 'norm-item-1', type: 'contextCompaction' });
|
|
86
|
+
|
|
87
|
+
// Pi 自动重试(stream 错误如 "Anthropic stream ended without a stop reason"):
|
|
88
|
+
// message_end 的 error 不得提前定论为 Turn 失败——agent_end 携带 willRetry,
|
|
89
|
+
// 重试成功的回答必须完整落地,全程零 error 事件(否则 Host 提前结算,
|
|
90
|
+
// 重试结果被丢弃,用户重发还会撞上原生 "Agent is already processing")。
|
|
91
|
+
{
|
|
92
|
+
const emitted = [];
|
|
93
|
+
const fakeProc = { harnessMixAnswer: '', command: async () => ({ leafId: 'leaf-1' }) };
|
|
94
|
+
const fwd = (event) => pi.forwardEvent(fakeProc, event, e => emitted.push(e));
|
|
95
|
+
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Anthropic stream ended without a stop reason' };
|
|
96
|
+
fwd({ type: 'message_end', message: failedMsg });
|
|
97
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, 'message_end 不得提前报错');
|
|
98
|
+
fwd({ type: 'agent_end', willRetry: true, messages: [{ role: 'user', content: [{ type: 'text', text: '登录好了' }] }, failedMsg] });
|
|
99
|
+
assert.equal(emitted.at(-1).kind, 'status');
|
|
100
|
+
fwd({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: failedMsg.errorMessage });
|
|
101
|
+
assert.equal(emitted.at(-1).kind, 'status');
|
|
102
|
+
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '好的,' } });
|
|
103
|
+
fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '开始执行。' } });
|
|
104
|
+
fwd({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' } });
|
|
105
|
+
assert.equal(emitted.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), '好的,开始执行。', '已流式回答不得重复补发');
|
|
106
|
+
fwd({ type: 'auto_retry_end', success: true, attempt: 1 });
|
|
107
|
+
fwd({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' }] });
|
|
108
|
+
fwd({ type: 'agent_settled', sessionId: 's-retry' });
|
|
109
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
110
|
+
assert.equal(emitted.at(-1).kind, 'completed');
|
|
111
|
+
assert.equal(emitted.at(-1).nativeRef.checkpointId, 'leaf-1');
|
|
112
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0, '重试成功全程不得出现 error');
|
|
113
|
+
}
|
|
114
|
+
// Pi 重试耗尽/不可重试:error 在 agent_end(willRetry:false)处恰好结算一次
|
|
115
|
+
{
|
|
116
|
+
const emitted = [];
|
|
117
|
+
const fwd = (event) => pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) }, event, e => emitted.push(e));
|
|
118
|
+
const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'native quota exhausted' };
|
|
119
|
+
fwd({ type: 'message_end', message: failedMsg });
|
|
120
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 0);
|
|
121
|
+
fwd({ type: 'agent_end', willRetry: false, messages: [failedMsg] });
|
|
122
|
+
assert.equal(emitted.filter(e => e.kind === 'error').length, 1);
|
|
123
|
+
assert.match(emitted.find(e => e.kind === 'error').message, /quota exhausted/);
|
|
124
|
+
}
|
|
125
|
+
// Pi 未流式回答的 message_end 回退补发仍然有效
|
|
126
|
+
{
|
|
127
|
+
const emitted = [];
|
|
128
|
+
pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) },
|
|
129
|
+
{ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '非流式回答' }], stopReason: 'stop' } },
|
|
130
|
+
e => emitted.push(e));
|
|
131
|
+
assert.ok(emitted.some(e => e.kind === 'text-delta' && e.text === '非流式回答'));
|
|
132
|
+
}
|
|
133
|
+
// Pi send():原生拒绝 "already processing"(Host 与原生状态偶发分叉,如取消竞态)时
|
|
134
|
+
// 按原生协议提示以 followUp 排队重发;其他错误原样抛出
|
|
135
|
+
{
|
|
136
|
+
const calls = [];
|
|
137
|
+
let rejectOnce = true;
|
|
138
|
+
const busySession = { process: { harnessMixAnswer: '', command: async (payload) => {
|
|
139
|
+
calls.push(payload);
|
|
140
|
+
if (rejectOnce && payload.type === 'prompt') { rejectOnce = false; throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); }
|
|
141
|
+
return {};
|
|
142
|
+
} } };
|
|
143
|
+
await pi.create().send(busySession, '登录好了', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
|
|
144
|
+
assert.equal(calls.length, 2);
|
|
145
|
+
assert.equal(calls[0].streamingBehavior, undefined);
|
|
146
|
+
assert.equal(calls[1].streamingBehavior, 'followUp');
|
|
147
|
+
assert.equal(calls[1].message, '登录好了');
|
|
148
|
+
assert.deepEqual(calls[1].images, [{ type: 'image', data: 'AA==', mimeType: 'image/png' }]);
|
|
149
|
+
const failingSession = { process: { harnessMixAnswer: '', command: async () => { throw new Error('Authentication failed'); } } };
|
|
150
|
+
await assert.rejects(pi.create().send(failingSession, 'hi', { emit: () => {} }, {}), /Authentication failed/);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// qoder 解析不得回退到 PATH 上的裸 `qoder`(IDE 启动器):它能通过 --version 可用性
|
|
154
|
+
// 探测,却把每次 --acp 会话打开变成完整 GUI 启动且永远完不成 ACP 握手。
|
|
155
|
+
// 只有无头 qodercli 或显式 HARNESS_MIX_QODER_EXECUTABLE 覆盖是合法入口。
|
|
156
|
+
{
|
|
157
|
+
const fs = require('node:fs');
|
|
158
|
+
const os = require('node:os');
|
|
159
|
+
const path = require('node:path');
|
|
160
|
+
const { nativeCommand } = require('../src/main/adapters/native-acp-command');
|
|
161
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-qoder-path-'));
|
|
162
|
+
fs.writeFileSync(path.join(dir, process.platform === 'win32' ? 'qoder.cmd' : 'qoder'), '');
|
|
163
|
+
const originalPath = process.env.PATH;
|
|
164
|
+
const originalOverride = process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
165
|
+
// nativeCommand 在 PATH 之外还探测固定根(APPDATA/npm、LOCALAPPDATA/Kiro-Cli、
|
|
166
|
+
// ~/.local/bin):本机真实安装的 qodercli 会从这些根解析成功,让“必须抛错”的
|
|
167
|
+
// 断言失效。测试期间把这几个根全部沙箱到临时目录。
|
|
168
|
+
const sandbox = { APPDATA: process.env.APPDATA, LOCALAPPDATA: process.env.LOCALAPPDATA, USERPROFILE: process.env.USERPROFILE, HOME: process.env.HOME };
|
|
169
|
+
process.env.APPDATA = dir;
|
|
170
|
+
process.env.LOCALAPPDATA = dir;
|
|
171
|
+
process.env.USERPROFILE = dir;
|
|
172
|
+
process.env.HOME = dir;
|
|
173
|
+
delete process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
174
|
+
const pathWithoutQodercli = originalPath.split(path.delimiter).filter(Boolean).filter(entry => {
|
|
175
|
+
try { return !fs.existsSync(path.join(entry, process.platform === 'win32' ? 'qodercli.cmd' : 'qodercli')); } catch { return true; }
|
|
176
|
+
}).join(path.delimiter);
|
|
177
|
+
try {
|
|
178
|
+
process.env.PATH = `${dir}${path.delimiter}${pathWithoutQodercli}`;
|
|
179
|
+
assert.throws(() => nativeCommand('qoder', ['--acp']), /qodercli 未安装/, '裸 qoder(IDE 启动器)不得作为 ACP 回退');
|
|
180
|
+
const override = path.join(dir, 'custom-acp.exe');
|
|
181
|
+
fs.writeFileSync(override, '');
|
|
182
|
+
process.env.HARNESS_MIX_QODER_EXECUTABLE = override;
|
|
183
|
+
assert.equal(nativeCommand('qoder', ['--acp']).command, override, '显式覆盖仍然生效');
|
|
184
|
+
} finally {
|
|
185
|
+
process.env.PATH = originalPath;
|
|
186
|
+
for (const [key, value] of Object.entries(sandbox)) {
|
|
187
|
+
if (value === undefined) delete process.env[key];
|
|
188
|
+
else process.env[key] = value;
|
|
189
|
+
}
|
|
190
|
+
if (originalOverride === undefined) delete process.env.HARNESS_MIX_QODER_EXECUTABLE;
|
|
191
|
+
else process.env.HARNESS_MIX_QODER_EXECUTABLE = originalOverride;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('Native adapters: streaming deduplication, user suppression, question retry, exact native approvals, sanitized usage, grok images, grok compaction & UI projection, qoder ACP resolution PASS');
|
|
196
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -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}`);
|