@harness-mix/cli 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +469 -467
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/desktop-controller.mjs +1 -1
  7. package/output/native-build/renderer-extension.js +172 -23
  8. package/package.json +13 -9
  9. package/scripts/acp-image-test.cjs +69 -0
  10. package/scripts/adapters-test.cjs +25 -0
  11. package/scripts/antigravity-adapter-test.cjs +647 -626
  12. package/scripts/codex-accounts-test.cjs +34 -0
  13. package/scripts/codex-adapter-test.cjs +238 -127
  14. package/scripts/collaboration-test.cjs +577 -262
  15. package/scripts/collaboration-ui-smoke.cjs +25 -3
  16. package/scripts/e2e-delegate.cjs +1 -1
  17. package/scripts/e2e-hermes-image.cjs +60 -0
  18. package/scripts/jsonl-stdin-test.cjs +40 -31
  19. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  20. package/scripts/native-acp-depth-test.cjs +30 -5
  21. package/scripts/native-update-apply-test.cjs +269 -215
  22. package/scripts/native-update.cjs +78 -0
  23. package/scripts/native-vendor-adapters-test.cjs +196 -154
  24. package/scripts/openclaw-adapter-test.cjs +121 -3
  25. package/scripts/openclaw-mcp-probe.cjs +100 -0
  26. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  27. package/scripts/openclaw-thinking-probe.cjs +73 -0
  28. package/scripts/salvage-rollout-writes.cjs +72 -0
  29. package/scripts/storage-verification-test.cjs +11 -0
  30. package/scripts/zcode-adapter-test.cjs +329 -0
  31. package/scripts/zcode-live-probe.cjs +66 -0
  32. package/src/main/adapters/antigravity.js +1428 -1418
  33. package/src/main/adapters/claude.js +15 -7
  34. package/src/main/adapters/codex-app-server.js +29 -11
  35. package/src/main/adapters/codex.js +690 -649
  36. package/src/main/adapters/native-acp-command.js +51 -48
  37. package/src/main/adapters/native-acp.js +47 -12
  38. package/src/main/adapters/omp.js +7 -2
  39. package/src/main/adapters/openclaw.js +534 -343
  40. package/src/main/adapters/pi-family.js +35 -8
  41. package/src/main/adapters/qoder.js +12 -8
  42. package/src/main/adapters/zcode.js +925 -10
  43. package/src/main/host/collaboration-tools.js +1 -1
  44. package/src/main/host/collaboration.js +971 -714
  45. package/src/main/host/jsonl.js +130 -120
  46. package/src/main/host/runtime.js +9 -3
  47. package/src/main/host/verification-gates.js +14 -2
  48. package/src/main/native/codex-accounts.js +15 -4
  49. package/src/main/native/config.js +9 -9
  50. package/src/main/native/launcher.js +252 -237
  51. package/src/main/native/process-utils.js +157 -57
  52. package/src/main/native/protocol.js +1227 -1187
  53. package/src/main/native/update-state.js +123 -110
  54. package/src/main/native/updater.js +460 -394
  55. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  56. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  57. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  58. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  65. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  67. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
  68. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  69. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  70. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  71. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
  72. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
@@ -71,6 +71,40 @@ async function main() {
71
71
  assert.equal(registry.includes('work@example.com'), false, 'Registry must not persist isolated identity or credentials');
72
72
  await manager.delete(created.account.accountId);
73
73
  assert.equal(fs.existsSync(context.codexHome), false);
74
+
75
+ // ── 旧结构账号兼容:显式 codexHome 必须被尊重(曾因改写到不存在的 profiles/<id>
76
+ // 导致 codex app-server 拒绝启动,新会话静默消失)
77
+ const legacyHome = path.join(root, 'real-codex-home');
78
+ fs.mkdirSync(legacyHome, { recursive: true });
79
+ fs.writeFileSync(path.join(root, 'codex-accounts', 'accounts.json'), JSON.stringify({
80
+ version: 1,
81
+ activeAccountId: 'default',
82
+ accounts: [
83
+ { accountId: 'default', label: 'Default Codex Account', codexHome: legacyHome },
84
+ { accountId: 'account-broken', label: 'Broken', codexHome: 'relative/not/absolute' },
85
+ { accountId: 'account-managed', label: 'Managed' },
86
+ ],
87
+ }));
88
+ const legacyManager = new CodexAccountManager({ dataDirectory: root, requestOfficial: official, emit: () => {}, acquireServer });
89
+ const legacyContext = legacyManager.executionContext('default');
90
+ assert.equal(legacyContext.codexHome, legacyHome, 'Legacy explicit codexHome must be honored, not rewritten to profiles/<id>');
91
+ assert.equal(fs.existsSync(path.join(root, 'codex-accounts', 'profiles', 'default')), false, 'No phantom profiles/default dir may be created for a legacy account');
92
+
93
+ // 相对路径残留 → 回落托管 profile 并自愈建目录
94
+ const brokenContext = legacyManager.executionContext('account-broken');
95
+ assert.ok(brokenContext.codexHome.startsWith(path.join(root, 'codex-accounts', 'profiles') + path.sep));
96
+ assert.equal(fs.existsSync(brokenContext.codexHome), true, 'Missing managed profile dir must be self-healed (codex refuses a missing CODEX_HOME)');
97
+
98
+ // 无 codexHome 的托管条目同样自愈
99
+ const managedContext = legacyManager.executionContext('account-managed');
100
+ assert.equal(fs.existsSync(managedContext.codexHome), true);
101
+
102
+ // 删除指向外部目录的旧账号:只移除账号槽位,不动外部目录
103
+ await legacyManager.delete('default');
104
+ assert.equal(fs.existsSync(legacyHome), true, 'External codexHome must never be deleted with the account');
105
+ const afterDelete = await legacyManager.list();
106
+ assert.equal(afterDelete.accounts.some(account => account.accountId === 'default'), false, 'Legacy account slot must be removed from the registry');
107
+ legacyManager.close();
74
108
  } finally {
75
109
  manager.close();
76
110
  fs.rmSync(root, { recursive: true, force: true });
@@ -1,127 +1,238 @@
1
- const assert = require('node:assert/strict');
2
- const { projectNotification, queueRequest, usageView, modelView } = require('../src/main/adapters/codex');
3
-
4
- function fakeSession() {
5
- return {
6
- nativeSessionId: 'thread-native',
7
- pendingApprovals: new Map(),
8
- state: {
9
- turn: null, compaction: null, nativeTurnId: null, usage: undefined,
10
- itemText: new Map(), reasoningItems: new Set(), toolOutput: new Map(),
11
- },
12
- };
13
- }
14
-
15
- (async () => {
16
- const session = fakeSession();
17
- const events = [];
18
- const emit = event => events.push(event);
19
- projectNotification({ method: 'turn/started', params: { threadId: 'thread-native', turn: { id: 'turn-1' } } }, session, emit);
20
- projectNotification({ method: 'item/agentMessage/delta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'answer-1', delta: '你好' } }, session, emit);
21
- projectNotification({ method: 'item/reasoning/summaryTextDelta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'reason-1', delta: '分析' } }, session, emit);
22
- projectNotification({ method: 'item/started', params: { threadId: 'thread-native', turnId: 'turn-1', item: { type: 'commandExecution', id: 'tool-1', command: 'git status', status: 'inProgress' } } }, session, emit);
23
- projectNotification({ method: 'item/commandExecution/outputDelta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'tool-1', delta: 'clean' } }, session, emit);
24
- projectNotification({ method: 'item/completed', params: { threadId: 'thread-native', turnId: 'turn-1', item: { type: 'commandExecution', id: 'tool-1', command: 'git status', status: 'completed', aggregatedOutput: 'clean' } } }, session, emit);
25
- projectNotification({ method: 'thread/tokenUsage/updated', params: { threadId: 'thread-native', turnId: 'turn-1', tokenUsage: { last: { totalTokens: 250 }, total: { inputTokens: 200, outputTokens: 50, cachedInputTokens: 25 }, modelContextWindow: 1000 } } }, session, emit);
26
- projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, session, emit);
27
-
28
- assert.ok(events.some(event => event.kind === 'text-delta' && event.text === '你好'));
29
- assert.ok(events.some(event => event.kind === 'thinking-delta' && event.text === '分析'));
30
- assert.ok(events.some(event => event.kind === 'tool' && event.toolCallId === 'tool-1' && event.state === 'done' && event.title === 'exec_command' && event.input === 'git status'));
31
- assert.ok(events.some(event => event.kind === 'usage' && event.usage.contextPercent === 25));
32
- assert.ok(events.some(event => event.kind === 'completed' && event.nativeRef.checkpointId === 'turn-1'));
33
-
34
- const questionSession = fakeSession();
35
- const questions = [];
36
- const answer = queueRequest({ id: 7, method: 'item/tool/requestUserInput', params: {
37
- threadId: 'thread-native', turnId: 'turn-1', itemId: 'question-1', isBlocking: true,
38
- questions: [
39
- { id: 'framework', header: '框架', question: '选择框架', options: [{ label: 'React' }, { label: 'Vue' }] },
40
- { id: 'name', header: '名称', question: '输入名称', options: null },
41
- ],
42
- } }, questionSession, event => questions.push(event));
43
- assert.equal(questions.length, 2);
44
- const adapter = require('../src/main/adapters/codex').create();
45
- await adapter.respond(questionSession, 'codex-7-framework', { optionId: 'React' });
46
- await adapter.respond(questionSession, 'codex-7-name', { value: 'Harness Mix' });
47
- assert.deepEqual(await answer, { answers: { framework: { answers: ['React'] }, name: { answers: ['Harness Mix'] } } });
48
-
49
- const approvalSession = fakeSession();
50
- const approval = queueRequest({ id: 8, method: 'item/commandExecution/requestApproval', params: {
51
- threadId: 'thread-native', turnId: 'turn-1', itemId: 'tool-2', command: 'npm test', availableDecisions: ['accept', 'decline'],
52
- } }, approvalSession, () => {});
53
- await adapter.respond(approvalSession, 'codex-8', { optionId: 'accept' });
54
- assert.deepEqual(await approval, { decision: 'accept' });
55
- const mcpSession = fakeSession();
56
- const mcpEvents = [];
57
- const mcpApproval = queueRequest({ id: 9, method: 'mcpServer/elicitation/request', params: {
58
- threadId: 'thread-native', serverName: 'harness-mix', mode: 'form', message: 'Allow tool?',
59
- requestedSchema: { type: 'object', properties: {}, additionalProperties: false },
60
- } }, mcpSession, event => mcpEvents.push(event));
61
- assert.equal(mcpEvents[0].kind, 'approval');
62
- assert.equal(mcpSession.pendingApprovals.size, 1, 'MCP requests await a native user response');
63
- await adapter.respond(mcpSession, 'codex-9', { optionId: 'decline' });
64
- assert.deepEqual(await mcpApproval, { action: 'decline', content: null });
65
-
66
- const compactSession = fakeSession();
67
- const compactEvents = [];
68
- compactSession.host = { async request(method) {
69
- assert.equal(method, 'thread/compact/start');
70
- projectNotification({ method: 'turn/started', params: { threadId: 'thread-native', turn: { id: 'compact-turn' } } }, compactSession, event => compactEvents.push(event));
71
- projectNotification({ method: 'item/completed', params: { threadId: 'thread-native', turnId: 'compact-turn', item: { type: 'contextCompaction', id: 'compact-item' } } }, compactSession, event => compactEvents.push(event));
72
- projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'compact-turn', status: 'completed' } } }, compactSession, event => compactEvents.push(event));
73
- return {};
74
- } };
75
- await adapter.executeCommand(compactSession, 'compact', { emit: event => compactEvents.push(event) });
76
- const compactDone = compactEvents.find(event => event.kind === 'completed');
77
- assert.equal(compactDone.nativeRef.checkpointId, 'compact-turn');
78
- assert.ok(compactEvents.some(event => event.kind === 'text-delta' && /Codex 压缩/.test(event.text)));
79
-
80
- assert.equal(usageView({ last: { totalTokens: 50 }, total: {}, modelContextWindow: 200 }).contextPercent, 25);
81
- assert.deepEqual(modelView({ model: 'gpt-x', displayName: 'GPT X', supportedReasoningEfforts: [], isDefault: true }).id, 'gpt-x');
82
-
83
- // 权限透传:Desktop 选择(回合级)优先,缺失时回退线程级设置,均转发到原生 turn/start
84
- const sendSession = fakeSession();
85
- sendSession.model = { id: 'gpt-x' };
86
- sendSession.turnPermissions = { approvalPolicy: 'on-request', sandboxPolicy: { type: 'readOnly' } };
87
- const sentRequests = [];
88
- sendSession.host = { async request(method, params) { sentRequests.push({ method, params }); return { turn: { id: `turn-${sentRequests.length}` } }; } };
89
- const first = adapter.send(sendSession, '第一轮', {}, { images: [], turnPermissions: { approvalPolicy: 'never', approvalsReviewer: 'guardian', sandboxPolicy: { type: 'dangerFullAccess' } } });
90
- const turnStart = sentRequests.find(r => r.method === 'turn/start');
91
- assert.equal(turnStart.params.approvalPolicy, 'never', '回合级权限覆盖优先转发');
92
- assert.equal(turnStart.params.approvalsReviewer, 'guardian_subagent', 'Desktop guardian 归一化为原生枚举');
93
- assert.deepEqual(turnStart.params.sandboxPolicy, { type: 'dangerFullAccess' });
94
- projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, sendSession, () => {});
95
- await first;
96
- sentRequests.length = 0;
97
- const second = adapter.send(sendSession, '第二轮', {}, { images: [] });
98
- const fallbackStart = sentRequests.find(r => r.method === 'turn/start');
99
- assert.equal(fallbackStart.params.approvalPolicy, 'on-request', '无线索级覆盖时回退线程级设置');
100
- assert.deepEqual(fallbackStart.params.sandboxPolicy, { type: 'readOnly' });
101
- projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, sendSession, () => {});
102
- await second;
103
-
104
- // Queue resume can race app-server's active-turn cleanup. Retry only that transient
105
- // without surfacing a failed Core turn or duplicating unrelated failures.
106
- const retrySession = fakeSession();
107
- let startAttempts = 0;
108
- retrySession.host = { async request(method) {
109
- assert.equal(method, 'turn/start');
110
- startAttempts++;
111
- if (startAttempts < 3) throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.");
112
- return { turn: { id: 'turn-after-settlement' } };
113
- } };
114
- const retried = adapter.send(retrySession, '编辑后的排队消息', {}, { images: [] });
115
- while (startAttempts < 3) await new Promise(resolve => setTimeout(resolve, 10));
116
- assert.equal(retrySession.state.nativeTurnId, 'turn-after-settlement');
117
- projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-after-settlement', status: 'completed' } } }, retrySession, () => {});
118
- await retried;
119
- assert.equal(startAttempts, 3, 'Only the transient app-server busy race is retried');
120
-
121
- const fatalSession = fakeSession();
122
- let fatalAttempts = 0;
123
- fatalSession.host = { async request() { fatalAttempts++; throw new Error('Authentication required'); } };
124
- await assert.rejects(adapter.send(fatalSession, '不可重试的错误', {}, { images: [] }), /Authentication required/);
125
- assert.equal(fatalAttempts, 1, 'Unrelated app-server errors are never retried or hidden');
126
- console.log('codex adapter: native notifications, usage, multi-question and approvals passed');
127
- })().catch(error => { console.error(error); process.exitCode = 1; });
1
+ const assert = require('node:assert/strict');
2
+ const { projectNotification, queueRequest, usageView, modelView } = require('../src/main/adapters/codex');
3
+
4
+ function fakeSession() {
5
+ return {
6
+ nativeSessionId: 'thread-native',
7
+ pendingApprovals: new Map(),
8
+ state: {
9
+ turn: null, compaction: null, nativeTurnId: null, usage: undefined,
10
+ itemText: new Map(), reasoningItems: new Set(), toolOutput: new Map(),
11
+ },
12
+ };
13
+ }
14
+
15
+ (async () => {
16
+ const session = fakeSession();
17
+ const events = [];
18
+ const emit = event => events.push(event);
19
+ projectNotification({ method: 'turn/started', params: { threadId: 'thread-native', turn: { id: 'turn-1' } } }, session, emit);
20
+ projectNotification({ method: 'item/agentMessage/delta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'answer-1', delta: '你好' } }, session, emit);
21
+ projectNotification({ method: 'item/reasoning/summaryTextDelta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'reason-1', delta: '分析' } }, session, emit);
22
+ projectNotification({ method: 'item/started', params: { threadId: 'thread-native', turnId: 'turn-1', item: { type: 'commandExecution', id: 'tool-1', command: 'git status', status: 'inProgress' } } }, session, emit);
23
+ projectNotification({ method: 'item/commandExecution/outputDelta', params: { threadId: 'thread-native', turnId: 'turn-1', itemId: 'tool-1', delta: 'clean' } }, session, emit);
24
+ projectNotification({ method: 'item/completed', params: { threadId: 'thread-native', turnId: 'turn-1', item: { type: 'commandExecution', id: 'tool-1', command: 'git status', status: 'completed', aggregatedOutput: 'clean' } } }, session, emit);
25
+ projectNotification({ method: 'thread/tokenUsage/updated', params: { threadId: 'thread-native', turnId: 'turn-1', tokenUsage: { last: { totalTokens: 250 }, total: { inputTokens: 200, outputTokens: 50, cachedInputTokens: 25 }, modelContextWindow: 1000 } } }, session, emit);
26
+ projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, session, emit);
27
+
28
+ assert.ok(events.some(event => event.kind === 'text-delta' && event.text === '你好'));
29
+ assert.ok(events.some(event => event.kind === 'thinking-delta' && event.text === '分析'));
30
+ assert.ok(events.some(event => event.kind === 'tool' && event.toolCallId === 'tool-1' && event.state === 'done' && event.title === 'exec_command' && event.input === 'git status'));
31
+ assert.ok(events.some(event => event.kind === 'usage' && event.usage.contextPercent === 25));
32
+ assert.ok(events.some(event => event.kind === 'completed' && event.nativeRef.checkpointId === 'turn-1'));
33
+
34
+ const questionSession = fakeSession();
35
+ const questions = [];
36
+ const answer = queueRequest({ id: 7, method: 'item/tool/requestUserInput', params: {
37
+ threadId: 'thread-native', turnId: 'turn-1', itemId: 'question-1', isBlocking: true,
38
+ questions: [
39
+ { id: 'framework', header: '框架', question: '选择框架', options: [{ label: 'React' }, { label: 'Vue' }] },
40
+ { id: 'name', header: '名称', question: '输入名称', options: null },
41
+ ],
42
+ } }, questionSession, event => questions.push(event));
43
+ assert.equal(questions.length, 2);
44
+ const adapter = require('../src/main/adapters/codex').create();
45
+ await adapter.respond(questionSession, 'codex-7-framework', { optionId: 'React' });
46
+ await adapter.respond(questionSession, 'codex-7-name', { value: 'Harness Mix' });
47
+ assert.deepEqual(await answer, { answers: { framework: { answers: ['React'] }, name: { answers: ['Harness Mix'] } } });
48
+
49
+ const approvalSession = fakeSession();
50
+ const approval = queueRequest({ id: 8, method: 'item/commandExecution/requestApproval', params: {
51
+ threadId: 'thread-native', turnId: 'turn-1', itemId: 'tool-2', command: 'npm test', availableDecisions: ['accept', 'decline'],
52
+ } }, approvalSession, () => {});
53
+ await adapter.respond(approvalSession, 'codex-8', { optionId: 'accept' });
54
+ assert.deepEqual(await approval, { decision: 'accept' });
55
+ const mcpSession = fakeSession();
56
+ const mcpEvents = [];
57
+ const mcpApproval = queueRequest({ id: 9, method: 'mcpServer/elicitation/request', params: {
58
+ threadId: 'thread-native', serverName: 'harness-mix', mode: 'form', message: 'Allow tool?',
59
+ requestedSchema: { type: 'object', properties: {}, additionalProperties: false },
60
+ } }, mcpSession, event => mcpEvents.push(event));
61
+ assert.equal(mcpEvents[0].kind, 'approval');
62
+ assert.equal(mcpSession.pendingApprovals.size, 1, 'MCP requests await a native user response');
63
+ await adapter.respond(mcpSession, 'codex-9', { optionId: 'decline' });
64
+ assert.deepEqual(await mcpApproval, { action: 'decline', content: null });
65
+
66
+ const compactSession = fakeSession();
67
+ const compactEvents = [];
68
+ compactSession.host = { async request(method) {
69
+ assert.equal(method, 'thread/compact/start');
70
+ projectNotification({ method: 'turn/started', params: { threadId: 'thread-native', turn: { id: 'compact-turn' } } }, compactSession, event => compactEvents.push(event));
71
+ projectNotification({ method: 'item/completed', params: { threadId: 'thread-native', turnId: 'compact-turn', item: { type: 'contextCompaction', id: 'compact-item' } } }, compactSession, event => compactEvents.push(event));
72
+ projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'compact-turn', status: 'completed' } } }, compactSession, event => compactEvents.push(event));
73
+ return {};
74
+ } };
75
+ await adapter.executeCommand(compactSession, 'compact', { emit: event => compactEvents.push(event) });
76
+ const compactDone = compactEvents.find(event => event.kind === 'completed');
77
+ assert.equal(compactDone.nativeRef.checkpointId, 'compact-turn');
78
+ assert.ok(compactEvents.some(event => event.kind === 'text-delta' && /Codex 压缩/.test(event.text)));
79
+
80
+ // listCommands:无会话返回静态目录;skills/list 插入型命令(slug 化、与 compact 去重、
81
+ // enabled!==false 过滤、id 满足 UI 契约字符集);旧版 app-server 无该 RPC 时回落静态目录
82
+ const staticList = await adapter.listCommands(null);
83
+ assert.deepEqual(staticList, [{ id: 'compact', label: '压缩上下文', description: '由 Codex 原生 app-server 压缩当前 Thread', action: 'execute' }]);
84
+
85
+ const skillsSession = fakeSession();
86
+ skillsSession.cwd = 'E:\\harness-mix';
87
+ const skillsRequests = [];
88
+ skillsSession.host = { async request(method, params) {
89
+ skillsRequests.push({ method, params });
90
+ return { data: [{ skills: [
91
+ { name: 'Agent Browser', description: 'Drive a real browser end to end.', shortDescription: '浏览器自动化', enabled: true, scope: 'user', interface: { displayName: 'Agent Browser', defaultPrompt: 'Browse' } },
92
+ { name: 'commit', description: 'Create a git commit', enabled: true, scope: 'repo' },
93
+ { name: 'Compact', description: 'should dedupe against compact', enabled: true, scope: 'user' },
94
+ { name: 'turned-off', description: 'disabled by config', enabled: false, scope: 'user' },
95
+ { name: '重复技能', description: 'slug 与重复技能相同', enabled: true, scope: 'system' },
96
+ ] }], nextCursor: null };
97
+ } };
98
+ const listed = await adapter.listCommands(skillsSession);
99
+ assert.deepEqual(skillsRequests, [{ method: 'skills/list', params: { cwds: ['E:\\harness-mix'] } }]);
100
+ assert.equal(listed[0].id, 'compact');
101
+ assert.equal(listed[0].action, 'execute', 'compact execute 条目保持首位');
102
+ const browser = listed.find(c => c.id === 'agent-browser');
103
+ assert.ok(browser, '"Agent Browser" slug 化为 agent-browser');
104
+ assert.equal(browser.label, '/Agent Browser', 'label 优先 interface.displayName');
105
+ assert.equal(browser.text, '/agent-browser ', '插入文本用 slug 触发原生技能');
106
+ assert.equal(browser.action, 'insert');
107
+ assert.ok(browser.description.includes('浏览器自动化') && browser.description.includes('Codex 技能·user'), '描述含短述与来源标注');
108
+ const commit = listed.find(c => c.id === 'commit');
109
+ assert.ok(commit.description.includes('Codex 技能·repo'), 'scope 标注随条目');
110
+ assert.ok(!commit.description.includes('displayName'), '无 shortDescription 时回落 description');
111
+ assert.equal(listed.filter(c => c.id === 'compact').length, 1, 'slug 化的 Compact 与静态 compact 去重');
112
+ assert.deepEqual(listed.map(c => c.id), ['compact', 'agent-browser', 'commit'], 'enabled:false 过滤;非 ASCII name slug 化为空即跳过');
113
+ assert.ok(listed.every(c => /^[A-Za-z0-9._:-]+$/.test(c.id)), 'id 满足 UI 契约字符集');
114
+
115
+ const legacySession = fakeSession();
116
+ legacySession.cwd = 'E:\\harness-mix';
117
+ legacySession.host = { async request(method) { throw new Error(`unknown method ${method}`); } };
118
+ assert.deepEqual(await adapter.listCommands(legacySession), staticList, '旧版 app-server 缺 skills/list 时回落静态目录');
119
+
120
+ assert.equal(usageView({ last: { totalTokens: 50 }, total: {}, modelContextWindow: 200 }).contextPercent, 25);
121
+ assert.deepEqual(modelView({ model: 'gpt-x', displayName: 'GPT X', supportedReasoningEfforts: [], isDefault: true }).id, 'gpt-x');
122
+
123
+ // 权限透传:Desktop 选择(回合级)优先,缺失时回退线程级设置,均转发到原生 turn/start
124
+ const sendSession = fakeSession();
125
+ sendSession.model = { id: 'gpt-x' };
126
+ sendSession.turnPermissions = { approvalPolicy: 'on-request', sandboxPolicy: { type: 'readOnly' } };
127
+ const sentRequests = [];
128
+ sendSession.host = { async request(method, params) { sentRequests.push({ method, params }); return { turn: { id: `turn-${sentRequests.length}` } }; } };
129
+ const first = adapter.send(sendSession, '第一轮', {}, { images: [], turnPermissions: { approvalPolicy: 'never', approvalsReviewer: 'guardian', sandboxPolicy: { type: 'dangerFullAccess' } } });
130
+ const turnStart = sentRequests.find(r => r.method === 'turn/start');
131
+ assert.equal(turnStart.params.approvalPolicy, 'never', '回合级权限覆盖优先转发');
132
+ assert.equal(turnStart.params.approvalsReviewer, 'guardian_subagent', 'Desktop 的 guardian 归一化为原生枚举');
133
+ assert.deepEqual(turnStart.params.sandboxPolicy, { type: 'dangerFullAccess' });
134
+ projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, sendSession, () => {});
135
+ await first;
136
+ sentRequests.length = 0;
137
+ const second = adapter.send(sendSession, '第二轮', {}, { images: [] });
138
+ const fallbackStart = sentRequests.find(r => r.method === 'turn/start');
139
+ assert.equal(fallbackStart.params.approvalPolicy, 'on-request', '无线索级覆盖时回退线程级设置');
140
+ assert.deepEqual(fallbackStart.params.sandboxPolicy, { type: 'readOnly' });
141
+ projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-1', status: 'completed' } } }, sendSession, () => {});
142
+ await second;
143
+
144
+ // Queue resume can race app-server's active-turn cleanup. Retry only that transient
145
+ // without surfacing a failed Core turn or duplicating unrelated failures.
146
+ const retrySession = fakeSession();
147
+ let startAttempts = 0;
148
+ retrySession.host = { async request(method) {
149
+ assert.equal(method, 'turn/start');
150
+ startAttempts++;
151
+ if (startAttempts < 3) throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.");
152
+ return { turn: { id: 'turn-after-settlement' } };
153
+ } };
154
+ const retried = adapter.send(retrySession, '编辑后的排队消息', {}, { images: [] });
155
+ while (startAttempts < 3) await new Promise(resolve => setTimeout(resolve, 10));
156
+ assert.equal(retrySession.state.nativeTurnId, 'turn-after-settlement');
157
+ projectNotification({ method: 'turn/completed', params: { threadId: 'thread-native', turn: { id: 'turn-after-settlement', status: 'completed' } } }, retrySession, () => {});
158
+ await retried;
159
+ assert.equal(startAttempts, 3, 'Only the transient app-server busy race is retried');
160
+
161
+ const fatalSession = fakeSession();
162
+ let fatalAttempts = 0;
163
+ fatalSession.host = { async request() { fatalAttempts++; throw new Error('Authentication required'); } };
164
+ await assert.rejects(adapter.send(fatalSession, '不可重试的错误', {}, { images: [] }), /Authentication required/);
165
+ assert.equal(fatalAttempts, 1, 'Unrelated app-server errors are never retried or hidden');
166
+
167
+ // cancel() 必须能结算每种挂起的原生请求形状:requestUserInput 的条目经由 group
168
+ // 结算(条目本身没有 resolve,直接调用会 TypeError 并吞掉后续 interrupt)。
169
+ const cancelSession = fakeSession();
170
+ const cancelledAnswer = queueRequest({ id: 21, method: 'item/tool/requestUserInput', params: {
171
+ threadId: 'thread-native', turnId: 'turn-c', itemId: 'q-c', isBlocking: true,
172
+ questions: [{ id: 'pick', header: '选择', question: '选择一项', options: [{ label: 'A' }, { label: 'B' }] }],
173
+ } }, cancelSession, () => {});
174
+ const cancelledApproval = queueRequest({ id: 22, method: 'item/commandExecution/requestApproval', params: {
175
+ threadId: 'thread-native', turnId: 'turn-c', itemId: 't-c', command: 'npm test', availableDecisions: ['accept', 'decline'],
176
+ } }, cancelSession, () => {});
177
+ const cancelledElicitation = queueRequest({ id: 23, method: 'mcpServer/elicitation/request', params: {
178
+ threadId: 'thread-native', serverName: 'harness-mix', mode: 'select', message: 'Allow?',
179
+ } }, cancelSession, () => {});
180
+ const cancelRequests = [];
181
+ cancelSession.host = { request: (method) => { cancelRequests.push(method); return Promise.resolve({}); } };
182
+ cancelSession.state.turn = { resolve() {}, reject() {} };
183
+ await adapter.cancel(cancelSession);
184
+ assert.equal(cancelSession.pendingApprovals.size, 0);
185
+ assert.deepEqual(await cancelledAnswer, { answers: {} }, 'group 条目以空答案结算');
186
+ assert.deepEqual(await cancelledApproval, { decision: 'decline' });
187
+ assert.deepEqual(await cancelledElicitation, { action: 'cancel', content: null });
188
+ assert.equal(cancelRequests.length, 0, 'turn/start 尚在途(无 nativeTurnId)时不发 interrupt');
189
+ assert.equal(cancelSession.state.turn, null, '本地回合被释放,线程不会卡在「当前回合尚未结束」');
190
+
191
+ // nativeTurnId 已知时:interrupt 发出后释放本地回合。
192
+ const interruptSession = fakeSession();
193
+ const interrupts = [];
194
+ interruptSession.host = { request: (method) => { interrupts.push(method); return Promise.resolve({}); } };
195
+ interruptSession.state.nativeTurnId = 'turn-live';
196
+ interruptSession.state.turn = { resolve() {}, reject() {} };
197
+ await adapter.cancel(interruptSession);
198
+ assert.deepEqual(interrupts, ['turn/interrupt']);
199
+ assert.equal(interruptSession.state.turn, null);
200
+
201
+ // startTurnAfterNativeSettlement:busy 瞬时占用按指数退避重试;其他错误必须立刻可见
202
+ {
203
+ const { startTurnAfterNativeSettlement } = require('../src/main/adapters/codex');
204
+ let busyCalls = 0;
205
+ const busyHost = { request: async () => { if (++busyCalls <= 2) throw new Error('Agent is already processing.'); return { turn: { id: 'turn-ok' } }; } };
206
+ assert.deepEqual(await startTurnAfterNativeSettlement(busyHost, { threadId: 't' }), { turn: { id: 'turn-ok' } });
207
+ assert.equal(busyCalls, 3);
208
+ let strictCalls = 0;
209
+ const strictHost = { request: async () => { strictCalls++; throw new Error('model not found'); } };
210
+ await assert.rejects(() => startTurnAfterNativeSettlement(strictHost, { threadId: 't' }), /model not found/);
211
+ assert.equal(strictCalls, 1, '非 busy 错误不得重试');
212
+ }
213
+
214
+ // 握手超时:app-server 拉起后永不应答 initialize 时,acquire 必须限时终止并报错,
215
+ // 而不是让 thread/start 永久 pending(Desktop 端表现为新对话一直"在执行"、无会话产生)。
216
+ // Windows 夹具:cmd.exe 无 /c 时进入交互态等待 stdin,永不输出 JSON-RPC。
217
+ if (process.platform === 'win32') {
218
+ const { CodexAppServer } = require('../src/main/adapters/codex-app-server');
219
+ const previousExecutable = process.env.HARNESS_MIX_CODEX_EXECUTABLE;
220
+ const previousStock = process.env.HARNESSMIX_STOCK_CODEX_PATH;
221
+ const previousTimeout = process.env.HARNESS_MIX_CODEX_HANDSHAKE_TIMEOUT_MS;
222
+ process.env.HARNESS_MIX_CODEX_EXECUTABLE = process.env.ComSpec || 'cmd.exe';
223
+ process.env.HARNESS_MIX_CODEX_HANDSHAKE_TIMEOUT_MS = '600';
224
+ delete process.env.HARNESSMIX_STOCK_CODEX_PATH;
225
+ try {
226
+ const startedAt = Date.now();
227
+ await assert.rejects(() => CodexAppServer.acquire(), /完成初始化握手/);
228
+ const elapsed = Date.now() - startedAt;
229
+ assert.ok(elapsed >= 500 && elapsed < 5_000, `握手超时应接近配置窗口(实际 ${elapsed}ms)`);
230
+ } finally {
231
+ if (previousExecutable) process.env.HARNESS_MIX_CODEX_EXECUTABLE = previousExecutable; else delete process.env.HARNESS_MIX_CODEX_EXECUTABLE;
232
+ if (previousStock) process.env.HARNESSMIX_STOCK_CODEX_PATH = previousStock; else delete process.env.HARNESSMIX_STOCK_CODEX_PATH;
233
+ if (previousTimeout) process.env.HARNESS_MIX_CODEX_HANDSHAKE_TIMEOUT_MS = previousTimeout; else delete process.env.HARNESS_MIX_CODEX_HANDSHAKE_TIMEOUT_MS;
234
+ }
235
+ }
236
+
237
+ console.log('codex adapter: native notifications, usage, multi-question, approvals, cancel shapes, busy retry and handshake timeout passed');
238
+ })().catch(error => { console.error(error); process.exitCode = 1; });