@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
|
@@ -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}`);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn) {
|
|
9
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error('timed out');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 取消-重发竞态(票据所有制发送锁):
|
|
14
|
+
// 流式进行中 cancel 会提前放锁让新发送进入(设计如此,避免卡死的 adapter.send 永久锁线程),
|
|
15
|
+
// 但旧 send 退出时的 finally 不得误删新发送的锁——否则第三个发送会与在途发送并发,
|
|
16
|
+
// 撞上原生侧 "Agent is already processing"。cancelRequests likewise 按票据隔离,
|
|
17
|
+
// 上一代发送遗留的取消登记不得被新发送的消费点误吞。
|
|
18
|
+
(async () => {
|
|
19
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-send-cancel-race-'));
|
|
20
|
+
const root = path.join(directory, 'workspace');
|
|
21
|
+
await fs.mkdir(root);
|
|
22
|
+
|
|
23
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
24
|
+
await rt.store.load();
|
|
25
|
+
|
|
26
|
+
const emits = new Map();
|
|
27
|
+
const pendingSends = new Map(); // threadId -> resolve:adapter.send 阻塞至 cancel(模拟原生 abort 才结算)
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
31
|
+
async send(session) { await new Promise(resolve => pendingSends.set(session.threadId, resolve)); },
|
|
32
|
+
async cancel(session) { pendingSends.get(session.threadId)?.(); pendingSends.delete(session.threadId); },
|
|
33
|
+
async close() {},
|
|
34
|
+
};
|
|
35
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
36
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const thread = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
40
|
+
|
|
41
|
+
// A 进入流式(adapter.send 阻塞中)
|
|
42
|
+
const sendA = rt.send(thread.id, 'first');
|
|
43
|
+
await until(() => pendingSends.has(thread.id));
|
|
44
|
+
|
|
45
|
+
// 流式中 cancel:立即放锁 + 作废旧票据;adapter.cancel 让 A 的 send 返回
|
|
46
|
+
await rt.cancel(thread.id);
|
|
47
|
+
await sendA; // A 的 finally 在此执行——票据已作废,不得触碰锁
|
|
48
|
+
|
|
49
|
+
// 立即重发 B:cancel 已放锁,必须能进入
|
|
50
|
+
const sendB = rt.send(thread.id, 'second');
|
|
51
|
+
await until(() => pendingSends.has(thread.id));
|
|
52
|
+
|
|
53
|
+
// 关键回归断言 1:B 在途时第三个发送必须被拒(若 A 的 finally 误删锁,这里会漏成并发)
|
|
54
|
+
await assert.rejects(rt.send(thread.id, 'third'), /正在执行/);
|
|
55
|
+
|
|
56
|
+
// 关键回归断言 2:B 的回合仍在运行——A 遗留的取消登记不得被 B 的消费点误吞
|
|
57
|
+
await sleep(50);
|
|
58
|
+
assert.equal(rt.execution.isRunning(thread.id), true, 'B 的回合不得被上一代取消登记误结算');
|
|
59
|
+
assert.equal(thread.error, undefined, 'B 不得携带取消/错误状态');
|
|
60
|
+
|
|
61
|
+
// 收尾:取消 B,B 的 send 返回;锁由 B 自己的 finally(或 cancel)正常回收
|
|
62
|
+
await rt.cancel(thread.id);
|
|
63
|
+
await sendB;
|
|
64
|
+
await until(() => !rt.sending.has(thread.id));
|
|
65
|
+
|
|
66
|
+
// 完整结算后新发送 C 可用:线程没有因竞态永久锁定
|
|
67
|
+
const sendC = rt.send(thread.id, 'third after settle');
|
|
68
|
+
await until(() => pendingSends.has(thread.id));
|
|
69
|
+
await rt.cancel(thread.id);
|
|
70
|
+
await sendC;
|
|
71
|
+
await until(() => !rt.sending.has(thread.id));
|
|
72
|
+
|
|
73
|
+
console.log('send-cancel-race-test: stale send finally cannot clobber the newer send lock; stale cancel requests are ticket-scoped');
|
|
74
|
+
} finally {
|
|
75
|
+
await rt.close();
|
|
76
|
+
}
|
|
77
|
+
})().catch(error => {
|
|
78
|
+
console.error(error);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn, label = 'condition') {
|
|
9
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error(`timed out: ${label}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 「Turn 启动前」取消-重发竞态(send-cancel-race 的前半段窗口):
|
|
14
|
+
// 旧 send 仍停留在会话打开/prompt 组装阶段时用户 cancel 并立即重发——
|
|
15
|
+
// 1) 旧 send 恢复后必须命中取消登记、不得投递 prompt(否则与新发送双双进入原生会话,
|
|
16
|
+
// 撞上原生侧 "Agent is already processing");
|
|
17
|
+
// 2) 旧 send 不得再 turnStarted 挤占 lastTurn(否则新发送的投递前自查会误判空闲而丢消息);
|
|
18
|
+
// 3) 旧 send 迟到的 reject/事件不得击中正在运行的新回合(按回合身份核验)。
|
|
19
|
+
(async () => {
|
|
20
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-send-pre-turn-cancel-'));
|
|
21
|
+
const root = path.join(directory, 'workspace');
|
|
22
|
+
await fs.mkdir(root);
|
|
23
|
+
|
|
24
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
25
|
+
await rt.store.load();
|
|
26
|
+
|
|
27
|
+
const delivered = [];
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open() { return {}; },
|
|
31
|
+
async send(session, text) { delivered.push(text); /* 非阻塞 ack,回合由事件流结算 */ },
|
|
32
|
+
async cancel() {},
|
|
33
|
+
async close() {},
|
|
34
|
+
};
|
|
35
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
36
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const thread = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
40
|
+
await until(() => rt.sessions.has(thread.id), 'session open');
|
|
41
|
+
|
|
42
|
+
// 制造「下次发送需重新打开原生会话」的慢窗口:关掉会话并标记惰性恢复
|
|
43
|
+
const session = rt.sessions.get(thread.id);
|
|
44
|
+
await session.adapter.close(session);
|
|
45
|
+
rt.sessions.delete(thread.id);
|
|
46
|
+
thread.restore = true;
|
|
47
|
+
let openGate;
|
|
48
|
+
const openBarrier = new Promise(resolve => { openGate = resolve; });
|
|
49
|
+
const realOpen = adapter.open;
|
|
50
|
+
adapter.open = async () => { await openBarrier; return realOpen(); };
|
|
51
|
+
|
|
52
|
+
// A 进入 #send,悬挂在 #ensureOpen(Turn 尚未启动)
|
|
53
|
+
const sendA = rt.send(thread.id, 'first');
|
|
54
|
+
await until(() => rt.openings.has(thread.id), 'A suspended in native open');
|
|
55
|
+
|
|
56
|
+
// Turn 未启动时 cancel:登记取消(A 的票据)并提前放锁
|
|
57
|
+
await rt.cancel(thread.id);
|
|
58
|
+
|
|
59
|
+
// 用户立即重发 B:放锁后必须能进入;B 不得误吞 A 的取消登记
|
|
60
|
+
const sendB = rt.send(thread.id, 'second');
|
|
61
|
+
|
|
62
|
+
// 原生会话打开完成:A 的 continuation 先恢复,随后 B
|
|
63
|
+
openGate();
|
|
64
|
+
await Promise.allSettled([sendA, sendB]);
|
|
65
|
+
await until(() => delivered.length > 0, 'B delivered');
|
|
66
|
+
|
|
67
|
+
// 核心断言 1:只有 B 投递;A 在恢复后命中取消登记,不再投递
|
|
68
|
+
assert.deepEqual(delivered, ['second'], '被取消的旧发送不得投递 prompt');
|
|
69
|
+
|
|
70
|
+
// 核心断言 2:A 的取消以取消卡片闭环(lastTurn 曾是 A 的取消回合),随后 B 的回合接管
|
|
71
|
+
const assistant = thread.messages.find(m => m.role === 'assistant');
|
|
72
|
+
assert.ok(assistant, 'A 的取消应留下助手回合卡片');
|
|
73
|
+
assert.equal(assistant.stopReason, 'cancelled', 'A 的回合以取消结算');
|
|
74
|
+
assert.equal(thread.error, undefined, '线程不得携带错误状态');
|
|
75
|
+
|
|
76
|
+
// B 的回合正常运行中(非阻塞适配器:ack 即返回,回合等事件流结算)
|
|
77
|
+
assert.equal(rt.execution.isRunning(thread.id), true, 'B 的回合应正常运行');
|
|
78
|
+
|
|
79
|
+
// B 的回合经事件流正常结算,线程回到空闲,后续发送不受影响
|
|
80
|
+
const sessionB = rt.sessions.get(thread.id);
|
|
81
|
+
// 通过 adapter emit 结算 B:模拟原生 completed
|
|
82
|
+
//(createThread 时记录的是旧 emit, reopen 后需取最新会话的 emit——
|
|
83
|
+
// 本测试 adapter.open 未保存 emit,直接用 execution 事件路径验证空闲后可发)
|
|
84
|
+
await rt.cancel(thread.id); // 用户停止 B
|
|
85
|
+
await until(() => !rt.execution.isRunning(thread.id), 'B settled by cancel');
|
|
86
|
+
const sendC = rt.send(thread.id, 'third after settle');
|
|
87
|
+
await until(() => delivered.length === 2, 'C delivered');
|
|
88
|
+
assert.deepEqual(delivered, ['second', 'third after settle']);
|
|
89
|
+
await rt.cancel(thread.id);
|
|
90
|
+
await sendC;
|
|
91
|
+
assert.ok(sessionB, 'session exists');
|
|
92
|
+
|
|
93
|
+
console.log('send-pre-turn-cancel: cancelled pre-turn send never delivers; resend owns the turn; late events cannot hit the newer turn');
|
|
94
|
+
} finally {
|
|
95
|
+
await rt.close();
|
|
96
|
+
}
|
|
97
|
+
})().catch(error => {
|
|
98
|
+
console.error(error);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|