@harness-mix/cli 0.1.4 → 0.1.6
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 +8 -0
- package/README.md +26 -96
- package/docs/chatgpt-web-sidebar.md +7 -1
- package/docs/cross-platform.md +15 -0
- package/docs/harness-handoff-design.md +377 -0
- package/docs/harness-management.md +28 -1
- package/docs/multi-agent-collaboration.md +9 -9
- package/docs/native-codex.md +21 -10
- package/output/native-build/harness-mix-shim.exe +0 -0
- package/output/native-build/renderer-extension.js +2596 -135
- package/package.json +20 -10
- package/scripts/chatgpt-context-ui-smoke.cjs +16 -0
- package/scripts/codex-accounts-test.cjs +81 -0
- package/scripts/codex-accounts-ui-smoke.cjs +70 -0
- package/scripts/collaboration-test.cjs +8 -3
- package/scripts/collaboration-ui-smoke.cjs +40 -26
- package/scripts/e2e-integrations.cjs +52 -0
- package/scripts/e2e-native-host.cjs +39 -0
- package/scripts/handoff-checkpoint-test.cjs +83 -0
- package/scripts/install-native.cjs +4 -1
- package/scripts/integrations-test.cjs +123 -0
- package/scripts/integrations-ui-smoke.cjs +124 -0
- package/scripts/native-protocol-test.cjs +180 -5
- package/scripts/native-secret-test.cjs +2 -2
- package/scripts/native-shim-test.cjs +20 -4
- package/scripts/native-sidebar-test.cjs +7 -0
- package/scripts/native-skill-roots-test.cjs +90 -0
- package/scripts/native-ui-smoke.cjs +58 -2
- package/scripts/publish-native-package.cjs +8 -5
- package/scripts/support/integrations-mcp-fixture.cjs +15 -0
- package/scripts/switch-harness-test.cjs +27 -1
- package/scripts/test-live-mentions.cjs +152 -0
- package/src/main/adapters/acp.js +3 -3
- package/src/main/adapters/antigravity.js +17 -17
- package/src/main/adapters/claude.js +50 -10
- package/src/main/adapters/codebuddy.js +1 -0
- package/src/main/adapters/codex-app-server.js +20 -13
- package/src/main/adapters/codex.js +25 -5
- package/src/main/adapters/cursor.js +5 -1
- package/src/main/adapters/dsh.js +9 -1
- package/src/main/adapters/grok.js +7 -3
- package/src/main/adapters/hermes.js +8 -0
- package/src/main/adapters/kiro.js +2 -1
- package/src/main/adapters/managed-mcp.js +25 -0
- package/src/main/adapters/native-acp.js +4 -4
- package/src/main/adapters/omp.js +11 -0
- package/src/main/adapters/openclaw.js +6 -0
- package/src/main/adapters/opencode.js +13 -3
- package/src/main/adapters/pi.js +5 -0
- package/src/main/adapters/qoder.js +1 -0
- package/src/main/adapters/trae.js +4 -0
- package/src/main/adapters/zcode.js +3 -0
- package/src/main/harness-adapter/manifest.js +17 -2
- package/src/main/host/collaboration.js +57 -11
- package/src/main/host/handoff-access.js +53 -0
- package/src/main/host/handoff-checkpoints.js +206 -0
- package/src/main/host/handoff-mcp.cjs +38 -0
- package/src/main/host/handoff-tools.js +13 -0
- package/src/main/host/handoff.js +28 -6
- package/src/main/host/integrations.js +339 -0
- package/src/main/host/runtime.js +95 -15
- package/src/main/host/store.js +4 -4
- package/src/main/native/codex-accounts.js +260 -0
- package/src/main/native/host.js +1 -1
- package/src/main/native/launcher.js +2 -2
- package/src/main/native/protocol.js +176 -14
- package/src/main/native/redact.js +1 -1
- package/src/main/native/rs/crates/shim/src/main.rs +155 -12
- package/src/main/native/thread-list.js +3 -0
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/index.d.ts +4 -0
- package/src/native-ui/renderer-extension/dist/types/index.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts +2 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts +36 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-codex-account-state.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-codex-account-state.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-composer-dom.d.ts +3 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-composer-dom.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-handoff.d.ts +53 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-handoff.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts +11 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +105 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +7 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/accounts-list.d.ts +2 -0
- package/src/native-ui/renderer-extension/dist/types/settings/accounts-list.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/accounts-page.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/accounts-page.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts +6 -0
- package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts +5 -0
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +8 -4
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +4 -1
- package/src/native-ui/renderer-extension/src/index.ts +28 -0
- package/src/native-ui/renderer-extension/src/renderer-agent-icon.ts +4 -4
- package/src/native-ui/renderer-extension/src/renderer-agent-picker.ts +46 -6
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +115 -18
- package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +87 -0
- package/src/native-ui/renderer-extension/src/renderer-codex-account-state.ts +10 -0
- package/src/native-ui/renderer-extension/src/renderer-composer-dom.ts +10 -0
- package/src/native-ui/renderer-extension/src/renderer-harness-handoff.ts +330 -0
- package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +556 -123
- package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +59 -0
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +17 -1
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
- package/src/native-ui/renderer-extension/src/settings/accounts-list.ts +16 -1
- package/src/native-ui/renderer-extension/src/settings/accounts-page.ts +32 -3
- package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +1474 -0
- package/src/native-ui/renderer-extension/src/settings/localization.ts +32 -13
- package/src/native-ui/renderer-extension/src/settings/pages.ts +49 -18
- package/src/native-ui/renderer-extension/src/settings/shell.css +103 -0
- package/src/native-ui/renderer-extension/test/renderer-agent-picker.test.ts +44 -13
- package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +21 -0
- package/src/native-ui/renderer-extension/test/renderer-codex-account-state.test.ts +13 -1
- package/src/native-ui/renderer-extension/test/renderer-harness-handoff.test.ts +20 -0
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +9 -2
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
- package/src/native-ui/renderer-extension/test/settings/pages.test.ts +77 -8
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +6 -0
- package/src/native-ui/shared-contracts/dist/codex-accounts.d.ts +19 -4
- package/src/native-ui/shared-contracts/dist/codex-accounts.d.ts.map +1 -1
- package/src/native-ui/shared-contracts/dist/codex-accounts.js +2 -0
- package/src/native-ui/shared-contracts/dist/codex-accounts.js.map +1 -1
- package/src/native-ui/shared-contracts/dist/index.d.ts +2 -2
- package/src/native-ui/shared-contracts/dist/index.d.ts.map +1 -1
- package/src/native-ui/shared-contracts/dist/index.js +1 -1
- package/src/native-ui/shared-contracts/dist/index.js.map +1 -1
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts +29 -0
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts.map +1 -1
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.js +13 -0
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.js.map +1 -1
- package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/shared-contracts/src/codex-accounts.ts +2 -0
- package/src/native-ui/shared-contracts/src/index.ts +8 -1
- package/src/native-ui/shared-contracts/src/thread-harness-switch.ts +17 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { Integrations } = require('../src/main/host/integrations');
|
|
5
|
+
const { buildAdapters } = require('../src/main/adapters');
|
|
6
|
+
const { NativeProtocol } = require('../src/main/native/protocol');
|
|
7
|
+
const { acpServers, namedServers } = require('../src/main/adapters/managed-mcp');
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const root = path.resolve('output/integrations-test', `${Date.now()}`);
|
|
11
|
+
const project = path.join(root, 'project'), home = path.join(root, 'home');
|
|
12
|
+
await fs.mkdir(project, { recursive: true }); await fs.mkdir(home);
|
|
13
|
+
const adapters = new Map(buildAdapters(() => {}).map(a => [a.manifest.id, a]));
|
|
14
|
+
const runtime = { adapters, status: {}, sessions: new Map(), store: { directory: path.join(root, 'data') }, resolveHarnessId: id => id };
|
|
15
|
+
const manager = runtime.integrations = new Integrations(runtime, { home, environment: {} });
|
|
16
|
+
const global = { harnessId: 'claude', scope: 'global' }, local = { harnessId: 'claude', scope: 'project', cwd: project };
|
|
17
|
+
const server = { name: 'probe', command: process.execPath, args: ['probe.cjs'], enabled: true };
|
|
18
|
+
await Promise.all([manager.save({ ...global, server }), manager.save({ ...local, server: { ...server, enabled: false } })]);
|
|
19
|
+
assert.equal((await manager.forSession({ cwd: project }, adapters.get('claude'))).servers.length, 0, 'project disabled entry masks global');
|
|
20
|
+
let snapshot = await manager.list(local);
|
|
21
|
+
assert.equal(snapshot.servers.length, 2); assert.equal(snapshot.servers.filter(s => s.effective).length, 1);
|
|
22
|
+
await manager.remove({ ...local, id: snapshot.servers.find(s => s.scope === 'project').id });
|
|
23
|
+
const selected = await manager.forSession({ cwd: project }, adapters.get('claude'));
|
|
24
|
+
assert.equal(selected.servers[0].name, 'hm-user-probe');
|
|
25
|
+
runtime.sessions.set('s', { adapter: adapters.get('claude'), nativeSessionId: 'native-test', integrationCwd: await fs.realpath(project), integrationServers: selected.records,
|
|
26
|
+
query: { mcpServerStatus: async () => [{ name: 'hm-user-probe', status: 'connected', config: { token: 'DO_NOT_PROJECT' }, tools: [{ name: 'echo' }] }] } });
|
|
27
|
+
snapshot = await manager.list(local);
|
|
28
|
+
assert.equal(snapshot.servers[0].appliedSessions, 1); assert.equal(snapshot.native[0].status, 'connected'); assert.ok(!JSON.stringify(snapshot).includes('DO_NOT_PROJECT'));
|
|
29
|
+
await manager.save({ ...global, server: { ...server, args: ['changed.cjs'] } });
|
|
30
|
+
assert.equal((await manager.list(global)).servers[0].appliedSessions, 0, 'changed config not claimed as applied');
|
|
31
|
+
const reload = new Integrations(runtime, { home }); assert.equal((await reload.list(global)).servers.length, 1);
|
|
32
|
+
const stdioExtended = { name: 'stdio-ext', command: process.execPath, args: ['probe.cjs'], env: { NODE_ENV: 'test' }, env_vars: ['PATH'], cwd: project, enabled: true };
|
|
33
|
+
await manager.save({ ...local, server: stdioExtended });
|
|
34
|
+
const stdioLoaded = (await manager.list(local)).servers.find(s => s.name === 'stdio-ext');
|
|
35
|
+
assert.equal(stdioLoaded.transportType, 'stdio');
|
|
36
|
+
assert.deepEqual(stdioLoaded.env, { NODE_ENV: 'test' });
|
|
37
|
+
assert.deepEqual(stdioLoaded.env_vars, ['PATH']);
|
|
38
|
+
assert.equal(stdioLoaded.cwd, project);
|
|
39
|
+
|
|
40
|
+
const http = { name: 'http-probe', transportType: 'streamable_http', url: 'https://mcp.example.com/mcp', bearer_token_env_var: 'MCP_TOKEN', http_headers: { 'X-Custom': 'val' }, env_http_headers: { 'X-Auth': 'TOKEN_VAR' }, enabled: true };
|
|
41
|
+
await manager.save({ ...global, server: http });
|
|
42
|
+
const httpLoaded = (await manager.list(global)).servers.find(s => s.name === 'http-probe');
|
|
43
|
+
assert.equal(httpLoaded.transportType, 'streamable_http');
|
|
44
|
+
assert.equal(httpLoaded.url, 'https://mcp.example.com/mcp');
|
|
45
|
+
assert.equal(httpLoaded.bearer_token_env_var, 'MCP_TOKEN');
|
|
46
|
+
assert.deepEqual(httpLoaded.http_headers, { 'X-Custom': 'val' });
|
|
47
|
+
assert.deepEqual(httpLoaded.env_http_headers, { 'X-Auth': 'TOKEN_VAR' });
|
|
48
|
+
|
|
49
|
+
await assert.rejects(manager.save({ ...global, server: { ...server, invalidProp: 'never' } }));
|
|
50
|
+
await assert.rejects(manager.save({ ...global, server: { ...server, args: ['--token', 'never'] } }));
|
|
51
|
+
await assert.rejects(manager.save({ ...global, server: { name: 'bad-url', url: 'invalid-url', enabled: true } }));
|
|
52
|
+
await assert.rejects(manager.save({ ...global, server: { name: 'bad-bearer', url: 'https://example.com', bearer_token_env_var: '123-bad', enabled: true } }));
|
|
53
|
+
await assert.rejects(manager.save({ ...global, harnessId: 'pi', server }), /no supported/);
|
|
54
|
+
await assert.rejects(manager.remove({ ...local, id: snapshot.servers[0].id }), /scope/);
|
|
55
|
+
await assert.rejects(manager.list({ ...local, cwd: 'relative' }));
|
|
56
|
+
const source = path.join(root, 'source'); await fs.mkdir(path.join(source, 'scripts'), { recursive: true });
|
|
57
|
+
await fs.writeFile(path.join(source, 'SKILL.md'), '---\nname: sample\ndescription: Sample skill\n---\n# Sample\n');
|
|
58
|
+
await fs.writeFile(path.join(source, 'scripts', 'sample.txt'), 'unchanged');
|
|
59
|
+
await manager.skillChange({ ...local, name: 'sample', source, action: 'install' });
|
|
60
|
+
const nativeFile = path.join(project, '.claude/skills/sample/SKILL.md'); assert.match(await fs.readFile(nativeFile, 'utf8'), /Sample/);
|
|
61
|
+
await assert.rejects(manager.skillChange({ ...local, name: 'sample', source, action: 'install' }), /already exists/);
|
|
62
|
+
await manager.skillChange({ ...local, name: 'sample', action: 'disable' });
|
|
63
|
+
await assert.rejects(fs.access(nativeFile)); assert.equal((await manager.list(local)).skills[0].enabled, false);
|
|
64
|
+
await manager.skillChange({ ...local, name: 'sample', action: 'enable' });
|
|
65
|
+
assert.equal(await fs.readFile(path.join(project, '.claude/skills/sample/scripts/sample.txt'), 'utf8'), 'unchanged');
|
|
66
|
+
const droppedMarkdown = Buffer.from('---\nname: dropped\ndescription: Dropped skill\n---\n# Dropped\n').toString('base64');
|
|
67
|
+
await manager.skillChange({ ...local, name: 'dropped', files: [{ path: 'SKILL.md', contentBase64: droppedMarkdown }], action: 'install' });
|
|
68
|
+
assert.match(await fs.readFile(path.join(project, '.claude/skills/dropped/SKILL.md'), 'utf8'), /Dropped skill/);
|
|
69
|
+
await assert.rejects(manager.skillChange({ ...local, name: 'escape', files: [{ path: '../SKILL.md', contentBase64: droppedMarkdown }], action: 'install' }), /invalid path/);
|
|
70
|
+
await assert.rejects(manager.skillChange({ ...local, name: 'bad-data', files: [{ path: 'SKILL.md', contentBase64: '***' }], action: 'install' }), /invalid file data/);
|
|
71
|
+
await assert.rejects(manager.skillChange({ ...local, name: '../escape', source, action: 'install' }));
|
|
72
|
+
await assert.rejects(manager.skillChange({ ...local, name: 'sample', root, action: 'disable' }));
|
|
73
|
+
const link = path.join(project, '.claude/skills/linked');
|
|
74
|
+
await fs.symlink(source, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
75
|
+
assert.equal((await manager.list(local)).skills.find(s => s.name === 'linked').writable, false);
|
|
76
|
+
await assert.rejects(manager.skillChange({ ...local, name: 'linked', action: 'disable' }), /read-only/);
|
|
77
|
+
const collab = { command: 'native-node', args: ['bridge'], env: { LOCAL_BRIDGE: 'capability' } };
|
|
78
|
+
const acp = acpServers(selected.servers, collab);
|
|
79
|
+
assert.equal(acp.length, 2); assert.equal(acp[1].name, 'harness-mix'); assert.deepEqual(acp[0].env, []);
|
|
80
|
+
assert.ok(namedServers(selected.servers, collab)['hm-user-probe']);
|
|
81
|
+
const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length, 16);
|
|
82
|
+
for (const a of adapters.values()) {
|
|
83
|
+
const entry = catalog.harnesses.find(h => h.id === a.manifest.id);
|
|
84
|
+
assert.equal(entry.mcp, a.manifest.integrations?.mcp === true);
|
|
85
|
+
assert.equal(entry.skills, true, `${a.manifest.id} must declare verified native skill roots`);
|
|
86
|
+
const roots = await manager.ensureSkillRoots({ cwd: project }, a);
|
|
87
|
+
assert.ok(roots.length > 0, `${a.manifest.id} must resolve at least one native skill root`);
|
|
88
|
+
for (const skillRoot of roots) assert.equal((await fs.stat(skillRoot)).isDirectory(), true);
|
|
89
|
+
}
|
|
90
|
+
assert.deepEqual(manager.roots(adapters.get('antigravity'), { scope: 'global', cwd: null }), [path.join(home, '.gemini/config/skills')]);
|
|
91
|
+
assert.deepEqual(manager.roots(adapters.get('dsh'), { scope: 'project', cwd: project }), [path.join(project, '.dsh/skills'), path.join(project, '.agents/skills')]);
|
|
92
|
+
assert.deepEqual(manager.roots(adapters.get('zcode'), { scope: 'global', cwd: null }), [path.join(home, '.zcode/skills'), path.join(home, '.agents/skills')]);
|
|
93
|
+
assert.deepEqual(manager.roots(adapters.get('zcode'), { scope: 'project', cwd: project }), [path.join(project, '.zcode/skills'), path.join(project, '.agents/skills')]);
|
|
94
|
+
// Trae IDE (.trae) and TraeCode CLI (.traecli) share one managed root set.
|
|
95
|
+
assert.deepEqual(manager.roots(adapters.get('trae'), { scope: 'project', cwd: project }), [path.join(project, '.trae/skills'), path.join(project, '.traecli/skills'), path.join(project, '.agents/skills')]);
|
|
96
|
+
// Hermes documents no project-scope skills directory, so only its global root is managed.
|
|
97
|
+
assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'project', cwd: project }), []);
|
|
98
|
+
assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'global', cwd: null }), [path.join(home, '.hermes/skills')]);
|
|
99
|
+
// Environment overrides relocate global roots; the deprecated-but-loaded $CODEX_HOME/skills stays discoverable.
|
|
100
|
+
const codexHome = path.join(root, 'codex-home'), hermesHome = path.join(root, 'hermes-home');
|
|
101
|
+
const overridden = new Integrations(runtime, { home, environment: { CODEX_HOME: codexHome, HERMES_HOME: hermesHome } });
|
|
102
|
+
assert.deepEqual(overridden.roots(adapters.get('codex'), { scope: 'global', cwd: null }), [path.join(home, '.agents/skills'), path.join(codexHome, 'skills')]);
|
|
103
|
+
assert.deepEqual(overridden.roots(adapters.get('hermes'), { scope: 'global', cwd: null }), [path.join(hermesHome, 'skills')]);
|
|
104
|
+
// A root that cannot be created is reported and skipped, never fatal to opening a session.
|
|
105
|
+
const blockedHome = path.join(root, 'blocked-home');
|
|
106
|
+
await fs.mkdir(path.join(blockedHome, '.codex'), { recursive: true });
|
|
107
|
+
await fs.writeFile(path.join(blockedHome, '.codex', 'skills'), 'not a directory');
|
|
108
|
+
const blocked = new Integrations(runtime, { home: blockedHome, environment: {} });
|
|
109
|
+
const reported = [];
|
|
110
|
+
const ensured = await blocked.ensureSkillRoots({ cwd: project }, adapters.get('codex'), { onError: message => reported.push(message) });
|
|
111
|
+
assert.ok(!ensured.some(value => value.endsWith(path.join('.codex', 'skills'))), 'uncreatable root is skipped');
|
|
112
|
+
assert.equal(ensured.length, 2, 'the remaining global and project roots are still created');
|
|
113
|
+
assert.equal(reported.length, 1, 'the skipped root is reported');
|
|
114
|
+
assert.equal((await fs.stat(ensured[0])).isDirectory(), true);
|
|
115
|
+
// An unusable project scope degrades to global roots instead of failing the launch.
|
|
116
|
+
const globalOnly = await blocked.ensureSkillRoots({ cwd: 'relative-not-absolute' }, adapters.get('codex'), {});
|
|
117
|
+
assert.equal(globalOnly.length, 1);
|
|
118
|
+
// Exercise the real protocol dispatcher without starting native processes.
|
|
119
|
+
assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length, 16);
|
|
120
|
+
assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/list', local)).skills.length, 3);
|
|
121
|
+
console.log('integrations: scope precedence, persistence, native status projection, no credential fields, unsupported capabilities, skill install/disable/restore, links, and protocol PASS');
|
|
122
|
+
}
|
|
123
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const esbuild = require('esbuild');
|
|
6
|
+
const { Integrations } = require('../src/main/host/integrations');
|
|
7
|
+
const { buildAdapters } = require('../src/main/adapters');
|
|
8
|
+
const { NativeProtocol } = require('../src/main/native/protocol');
|
|
9
|
+
app.disableHardwareAcceleration();
|
|
10
|
+
|
|
11
|
+
app.whenReady().then(async () => {
|
|
12
|
+
const out = path.resolve('output/integrations-ui', `${Date.now()}`), project = path.join(out, 'project');
|
|
13
|
+
await fs.mkdir(project, { recursive: true });
|
|
14
|
+
const adapters = new Map(buildAdapters(() => {}).map(a => [a.manifest.id, a]));
|
|
15
|
+
const runtime = { adapters, sessions: new Map(), status: { claude: { available: true } }, store: { directory: path.join(out, 'data') }, resolveHarnessId: id => id };
|
|
16
|
+
runtime.integrations = new Integrations(runtime, { home: path.join(out, 'home') });
|
|
17
|
+
const preload = path.join(out, 'preload.cjs');
|
|
18
|
+
await fs.writeFile(preload, `const {contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('integrationTest',{request:(method,params)=>ipcRenderer.invoke('integrations-test',method,params)});`);
|
|
19
|
+
ipcMain.handle('integrations-test', (_event, method, params) => {
|
|
20
|
+
assert.ok(method.startsWith('codexhost/integrations/'));
|
|
21
|
+
return NativeProtocol.prototype.request.call({ runtime }, method, params);
|
|
22
|
+
});
|
|
23
|
+
const build = await esbuild.build({ stdin: { contents: `export {createMcpSettingsPage,createSkillsSettingsPage} from './src/native-ui/renderer-extension/src/settings/integrations-page.ts'; export {mountRendererSettingsShell} from './src/native-ui/renderer-extension/src/settings/shell.ts'; export {createRendererSettingsPageRegistry} from './src/native-ui/renderer-extension/src/settings/core.ts'; export {rendererSettingsMessages} from './src/native-ui/renderer-extension/src/settings/localization.ts'; export {createRendererIntegrationsClient} from './src/native-ui/renderer-extension/src/renderer-integrations-client.ts';`, resolveDir: process.cwd() }, bundle: true, platform: 'browser', format: 'iife', globalName: 'IntegrationUI', write: false, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.css': 'text' } });
|
|
24
|
+
const consoleProblems = [];
|
|
25
|
+
const win = new BrowserWindow({ show: false, width: 1100, height: 900, webPreferences: { preload, contextIsolation: true, sandbox: true, offscreen: true } });
|
|
26
|
+
win.webContents.on('console-message', (_event, details) => { if (details.level === 'error' || details.level === 'warning') consoleProblems.push(details.message); });
|
|
27
|
+
try {
|
|
28
|
+
await win.loadURL('data:text/html,<html><head><title>Harness Mix Integrations QA</title><meta http-equiv="Content-Security-Policy" content="default-src %27none%27; script-src %27none%27; style-src %27unsafe-inline%27; img-src data:"></head><body style="background:%23191919;color:white;font-family:Arial"></body></html>');
|
|
29
|
+
await win.webContents.executeJavaScript(build.outputFiles[0].text);
|
|
30
|
+
const result = await win.webContents.executeJavaScript(`(async()=>{
|
|
31
|
+
const messages=IntegrationUI.rendererSettingsMessages('zh-CN');
|
|
32
|
+
const client=IntegrationUI.createRendererIntegrationsClient((m,p)=>window.integrationTest.request(m,p));
|
|
33
|
+
const registry=IntegrationUI.createRendererSettingsPageRegistry([IntegrationUI.createMcpSettingsPage(messages,()=>client),IntegrationUI.createSkillsSettingsPage(messages,()=>client)]);
|
|
34
|
+
const shell=IntegrationUI.mountRendererSettingsShell(registry,document,messages); shell.openSettings(undefined,'mcp');
|
|
35
|
+
const root=shell.root.shadowRoot;
|
|
36
|
+
const wait=async predicate=>{for(let i=0;i<100;i++){if(predicate())return;await new Promise(r=>setTimeout(r,30));}throw Error('UI timed out');};
|
|
37
|
+
await wait(()=>root.querySelector('select')?.options.length===17 && !root.querySelector('select').disabled);
|
|
38
|
+
const [harness,scope]=root.querySelectorAll('select');harness.value='claude';harness.dispatchEvent(new Event('change'));
|
|
39
|
+
await wait(()=>root.querySelector('.settings-integrations-toolbar')&&!harness.disabled);
|
|
40
|
+
scope.value='project';scope.dispatchEvent(new Event('change'));
|
|
41
|
+
const cwd=root.querySelector('.settings-integrations-controls input');cwd.value=${JSON.stringify(project)};cwd.dispatchEvent(new Event('change'));
|
|
42
|
+
await wait(()=>root.querySelector('.settings-integrations-toolbar')&&!harness.disabled);
|
|
43
|
+
Array.from(root.querySelectorAll('button')).find(b=>b.textContent.includes('添加 MCP')).click();
|
|
44
|
+
const form=root.querySelector('form'); const fields=form.querySelectorAll('input');fields[0].value='ui-probe';fields[1].value='node';fields[2].value='probe.cjs';form.requestSubmit();
|
|
45
|
+
await wait(()=>root.textContent.includes('ui-probe')&&!harness.disabled&&!!root.querySelector('.settings-integrations-row'));
|
|
46
|
+
const mcpRow=()=>Array.from(root.querySelectorAll('.settings-integrations-row')).find(r=>r.querySelector('strong')?.textContent==='ui-probe');
|
|
47
|
+
mcpRow().querySelectorAll('button')[1].click();await wait(()=>mcpRow()?.textContent.includes('已停用')&&!harness.disabled);
|
|
48
|
+
mcpRow().querySelectorAll('button')[1].click();await wait(()=>mcpRow()?.textContent.includes('已配置')&&!harness.disabled);
|
|
49
|
+
harness.value = '__all__'; harness.dispatchEvent(new Event('change'));
|
|
50
|
+
await wait(()=>root.querySelector('.settings-mcp-overview-grid')&&!harness.disabled);
|
|
51
|
+
const mcpOverviewCard=Array.from(root.querySelectorAll('.settings-mcp-harness-card')).find(c=>c.querySelector('strong')?.textContent?.includes('Claude'));
|
|
52
|
+
if (!mcpOverviewCard || !mcpOverviewCard.textContent.includes('ui-probe')) throw Error('MCP overview missing ui-probe on Claude');
|
|
53
|
+
shell.openSettings(undefined,'skills');
|
|
54
|
+
await wait(()=>root.querySelector('.settings-skill-dropzone')&&!root.querySelector('select').disabled);
|
|
55
|
+
const [skillHarness,skillScope]=root.querySelectorAll('select');skillHarness.value='claude';skillHarness.dispatchEvent(new Event('change'));
|
|
56
|
+
await wait(()=>!skillHarness.disabled);skillScope.value='project';skillScope.dispatchEvent(new Event('change'));
|
|
57
|
+
const skillCwd=root.querySelector('.settings-integrations-controls input');skillCwd.value=${JSON.stringify(project)};skillCwd.dispatchEvent(new Event('change'));
|
|
58
|
+
await wait(()=>root.querySelector('.settings-skill-dropzone')&&!skillHarness.disabled);
|
|
59
|
+
const transfer=new DataTransfer();transfer.items.add(new File(['---\\nname: ui-skill\\ndescription: UI fixture\\n---\\n# Test\\n'],'ui-skill.md',{type:'text/markdown'}));
|
|
60
|
+
root.querySelector('.settings-skill-dropzone').dispatchEvent(new DragEvent('drop',{dataTransfer:transfer,bubbles:true,cancelable:true}));
|
|
61
|
+
const skillRow=()=>Array.from(root.querySelectorAll('.settings-integrations-row')).find(r=>r.querySelector('strong')?.textContent==='ui-skill');
|
|
62
|
+
await wait(()=>skillRow()&&!skillHarness.disabled);skillRow().querySelector('button').click();await wait(()=>skillRow()?.textContent.includes('已停用')&&!skillHarness.disabled);
|
|
63
|
+
skillRow().querySelector('button').click();await wait(()=>skillRow()?.textContent.includes('已安装')&&!skillHarness.disabled);
|
|
64
|
+
skillHarness.value = '__all__'; skillHarness.dispatchEvent(new Event('change'));
|
|
65
|
+
await wait(()=>root.querySelector('.settings-skills-overview-grid')&&!skillHarness.disabled);
|
|
66
|
+
return {mcpSaved:true,mcpToggled:true,mcpOverviewRendered:true,skillDropped:true,skillRestored:true,overviewRendered:true,separatePages:registry.pages.map(p=>p.id),scope:skillScope.value,text:root.textContent.slice(-2000)};
|
|
67
|
+
})()`);
|
|
68
|
+
assert.equal(await win.webContents.executeJavaScript('document.title'), 'Harness Mix Integrations QA');
|
|
69
|
+
assert.ok(result.mcpSaved && result.mcpOverviewRendered && result.skillDropped && result.skillRestored && result.overviewRendered); assert.deepEqual(result.separatePages, ['mcp', 'skills']); assert.deepEqual(consoleProblems, []);
|
|
70
|
+
const list = await runtime.integrations.list({ harnessId: 'claude', scope: 'project', cwd: project });
|
|
71
|
+
assert.equal(list.servers[0].enabled, true); assert.equal(list.skills[0].enabled, true);
|
|
72
|
+
const overviewImage = (await win.webContents.capturePage()).toPNG();
|
|
73
|
+
await fs.writeFile(path.join(out, 'overview.png'), overviewImage);
|
|
74
|
+
await fs.writeFile(path.join(out, 'settings.png'), overviewImage);
|
|
75
|
+
await fs.writeFile(path.join(out, 'skills.png'), overviewImage);
|
|
76
|
+
await win.webContents.executeJavaScript(`(async()=>{
|
|
77
|
+
const root=document.querySelector('[data-codexhost-settings-shell]').shadowRoot;
|
|
78
|
+
root.querySelector('button[data-page-id="mcp"]').click();
|
|
79
|
+
const wait=async predicate=>{for(let i=0;i<100;i++){if(predicate())return;await new Promise(r=>setTimeout(r,30));}throw Error('MCP screenshot timed out');};
|
|
80
|
+
await wait(()=>root.querySelector('select')?.options.length===17&&!root.querySelector('select').disabled);
|
|
81
|
+
const [harness,scope]=root.querySelectorAll('select');
|
|
82
|
+
scope.value='project';scope.dispatchEvent(new Event('change'));
|
|
83
|
+
const cwd=root.querySelector('.settings-integrations-controls input');cwd.value=${JSON.stringify(project)};cwd.dispatchEvent(new Event('change'));
|
|
84
|
+
harness.value='__all__';harness.dispatchEvent(new Event('change'));
|
|
85
|
+
await wait(()=>root.querySelector('.settings-mcp-overview-grid')&&root.textContent.includes('ui-probe')&&!harness.disabled);
|
|
86
|
+
const byServerBtn=Array.from(root.querySelectorAll('.settings-mcp-tabs button')).find(b=>b.textContent.includes('Server'));
|
|
87
|
+
byServerBtn?.click();
|
|
88
|
+
await wait(()=>root.querySelector('.settings-mcp-matrix-list')&&!harness.disabled);
|
|
89
|
+
const mcpMatrixCard=Array.from(root.querySelectorAll('.settings-mcp-matrix-card')).find(c=>c.querySelector('strong')?.textContent==='ui-probe');
|
|
90
|
+
if (!mcpMatrixCard) throw Error('MCP server matrix missing ui-probe card');
|
|
91
|
+
})()`);
|
|
92
|
+
await new Promise(r => setTimeout(r, 60));
|
|
93
|
+
await fs.writeFile(path.join(out, 'mcp-matrix.png'), (await win.webContents.capturePage()).toPNG());
|
|
94
|
+
await win.webContents.executeJavaScript(`(async()=>{
|
|
95
|
+
const root=document.querySelector('[data-codexhost-settings-shell]').shadowRoot;
|
|
96
|
+
const byHarnessBtn=Array.from(root.querySelectorAll('.settings-mcp-tabs button')).find(b=>b.textContent.includes('Harness'));
|
|
97
|
+
byHarnessBtn?.click();
|
|
98
|
+
const wait=async predicate=>{for(let i=0;i<100;i++){if(predicate())return;await new Promise(r=>setTimeout(r,30));}throw Error('MCP switch timed out');};
|
|
99
|
+
await wait(()=>root.querySelector('.settings-mcp-overview-grid')&&!root.querySelector('select').disabled);
|
|
100
|
+
})()`);
|
|
101
|
+
await new Promise(r => setTimeout(r, 60));
|
|
102
|
+
await fs.writeFile(path.join(out, 'mcp-overview.png'), (await win.webContents.capturePage()).toPNG());
|
|
103
|
+
await win.webContents.executeJavaScript(`(async()=>{
|
|
104
|
+
const root=document.querySelector('[data-codexhost-settings-shell]').shadowRoot;
|
|
105
|
+
const [harness,scope]=root.querySelectorAll('select');
|
|
106
|
+
harness.value='claude';harness.dispatchEvent(new Event('change'));
|
|
107
|
+
const wait=async predicate=>{for(let i=0;i<100;i++){if(predicate())return;await new Promise(r=>setTimeout(r,30));}throw Error('MCP reload timed out');};
|
|
108
|
+
await wait(()=>!harness.disabled);scope.value='project';scope.dispatchEvent(new Event('change'));
|
|
109
|
+
const cwd=root.querySelector('.settings-integrations-controls input');cwd.value=${JSON.stringify(project)};cwd.dispatchEvent(new Event('change'));
|
|
110
|
+
await wait(()=>root.textContent.includes('ui-probe')&&!harness.disabled);
|
|
111
|
+
})()`);
|
|
112
|
+
await fs.writeFile(path.join(out, 'mcp.png'), (await win.webContents.capturePage()).toPNG());
|
|
113
|
+
await win.webContents.executeJavaScript(`(async()=>{
|
|
114
|
+
const root=document.querySelector('[data-codexhost-settings-shell]').shadowRoot;
|
|
115
|
+
const mcpRow=Array.from(root.querySelectorAll('.settings-integrations-row')).find(r=>r.querySelector('strong')?.textContent?.includes('ui-probe'));
|
|
116
|
+
mcpRow.querySelector('button').click();
|
|
117
|
+
const wait=async predicate=>{for(let i=0;i<100;i++){if(predicate())return;await new Promise(r=>setTimeout(r,30));}throw Error('MCP editor timed out');};
|
|
118
|
+
await wait(()=>!root.querySelector('.settings-mcp-editor').hidden);
|
|
119
|
+
})()`);
|
|
120
|
+
await fs.writeFile(path.join(out, 'mcp-editor.png'), (await win.webContents.capturePage()).toPNG());
|
|
121
|
+
await fs.writeFile(path.join(out, 'report.json'), JSON.stringify(result, null, 2));
|
|
122
|
+
console.log(`Integrations rendered UI -> typed client -> NativeProtocol -> real files PASS: ${out}`);
|
|
123
|
+
} finally { win.destroy(); app.quit(); }
|
|
124
|
+
}).catch(error => { console.error(error); app.exit(1); });
|
|
@@ -21,9 +21,23 @@ async function main() {
|
|
|
21
21
|
runtime.adapters.set('pi', adapter); runtime.status.pi = { available: true };
|
|
22
22
|
const events = [];
|
|
23
23
|
const section = { id: 'test-pinned-section', name: 'Pinned', appearance: null };
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const officialRequests = [];
|
|
25
|
+
const bridge = new NativeProtocol(runtime, event => events.push(event), async (method, params) => {
|
|
26
|
+
officialRequests.push({ method, params });
|
|
27
|
+
if (method === 'threadSection/list') return { data: [section], nextCursor: null };
|
|
28
|
+
if (method === 'account/read') return { account: { type: 'chatgpt', email: 'native@example.com', planType: 'plus' }, requiresOpenaiAuth: true };
|
|
29
|
+
if (method === 'account/rateLimits/read') return {
|
|
30
|
+
rateLimits: {
|
|
31
|
+
primary: { usedPercent: 33, windowDurationMins: 300, resetsAt: 1_800_000_000 },
|
|
32
|
+
secondary: { usedPercent: 29, windowDurationMins: 10080, resetsAt: 1_800_604_800 },
|
|
33
|
+
},
|
|
34
|
+
rateLimitResetCredits: { availableCount: 0, credits: [] },
|
|
35
|
+
};
|
|
36
|
+
if (method === 'account/login/start') return { type: 'chatgptDeviceCode', loginId: 'native-login', verificationUrl: 'https://auth.example.com/device', userCode: 'ABCD-EFGH' };
|
|
37
|
+
if (method === 'account/login/cancel') return { status: 'canceled' };
|
|
38
|
+
if (method === 'account/logout') return {};
|
|
39
|
+
if (method === 'account/rateLimitResetCredit/consume') return { outcome: 'reset' };
|
|
40
|
+
throw new Error(`Unexpected official request: ${method}`);
|
|
27
41
|
});
|
|
28
42
|
try {
|
|
29
43
|
const catalogModels = [{ id: 'shared', provider: 'a' }, { id: 'shared', provider: 'b' }, { id: 'unique', provider: 'a' }];
|
|
@@ -51,6 +65,48 @@ async function main() {
|
|
|
51
65
|
const schemaPath = path.join(root, 'schemas.cjs');
|
|
52
66
|
await esbuild.build({ entryPoints: ['src/native-ui/shared-contracts/src/index.ts'], outfile: schemaPath, bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' });
|
|
53
67
|
const schemas = require(schemaPath);
|
|
68
|
+
const version = await bridge.request('harness-mix/runtime/version');
|
|
69
|
+
assert.equal(version.version, require('../package.json').version, 'About page reads the installed package version');
|
|
70
|
+
const accounts = await bridge.request('codexhost/account/list');
|
|
71
|
+
schemas.codexAccountListResultSchema.parse(accounts);
|
|
72
|
+
assert.equal(accounts.accounts[0].email, 'native@example.com', 'Official account/read is projected into Account management');
|
|
73
|
+
assert.equal(accounts.accounts[0].authenticated, true);
|
|
74
|
+
assert.equal(accounts.accounts[0].management, 'native');
|
|
75
|
+
const accountUsage = await bridge.request('codexhost/account/usage/inspect', { accountId: 'official-codex' });
|
|
76
|
+
schemas.codexAccountUsageResultSchema.parse(accountUsage);
|
|
77
|
+
assert.equal(accountUsage.usage.planFiveHourUsedPercent, 33);
|
|
78
|
+
assert.equal(accountUsage.usage.planSevenDayUsedPercent, 29);
|
|
79
|
+
assert.equal(accountUsage.accountCredits.periodType, 'five_hour');
|
|
80
|
+
assert.equal(accountUsage.accountCredits.productUsage[0].product, '7-day window');
|
|
81
|
+
const login = await bridge.request('codexhost/account/login/start', { accountId: 'official-codex' });
|
|
82
|
+
schemas.codexAccountLoginStartResultSchema.parse(login);
|
|
83
|
+
assert.equal(login.userCode, 'ABCD-EFGH');
|
|
84
|
+
assert.deepEqual(await bridge.request('codexhost/account/login/cancel', { accountId: 'official-codex', loginId: 'native-login' }), { cancelled: true });
|
|
85
|
+
const loggedOut = await bridge.request('codexhost/account/logout');
|
|
86
|
+
schemas.codexAccountMutationResultSchema.parse(loggedOut);
|
|
87
|
+
assert.equal(loggedOut.account.authenticated, false);
|
|
88
|
+
let openedCodexThread = null;
|
|
89
|
+
const codexAdapter = {
|
|
90
|
+
manifest: { id: 'codex', name: 'Codex', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true } },
|
|
91
|
+
async describe() { return { models: [{ id: 'gpt-test', name: 'GPT Test', provider: 'openai' }], thinkingLevels: [], permissionModes: [] }; },
|
|
92
|
+
async open(input) { openedCodexThread = input.thread; return { nativeSessionId: 'isolated-native-session', model: input.thread.options.model }; },
|
|
93
|
+
async send() {}, async cancel() {}, async close() {},
|
|
94
|
+
};
|
|
95
|
+
runtime.adapters.set('codex', codexAdapter);
|
|
96
|
+
runtime.status.codex = { available: true };
|
|
97
|
+
bridge.codexAccounts.close();
|
|
98
|
+
bridge.codexAccounts = {
|
|
99
|
+
executionContext(accountId) {
|
|
100
|
+
assert.equal(accountId, 'account-work');
|
|
101
|
+
return { accountId, codexHome: path.join(root, 'isolated-codex-home') };
|
|
102
|
+
},
|
|
103
|
+
close() {},
|
|
104
|
+
};
|
|
105
|
+
const isolated = await bridge.request('thread/start', { cwd: root, model: 'gpt-test', __codexhostAccountId: 'account-work' });
|
|
106
|
+
assert.equal(decodeRoute(isolated.thread.model).harnessId, 'codex-harness', 'An isolated Codex Account becomes an owned Codex adapter Thread');
|
|
107
|
+
assert.equal(openedCodexThread.options.accountId, 'account-work');
|
|
108
|
+
assert.equal(openedCodexThread.options.codexHome, path.join(root, 'isolated-codex-home'));
|
|
109
|
+
assert.equal(openedCodexThread.options.model.id, 'gpt-test');
|
|
54
110
|
const inspection = await bridge.inspect('pi');
|
|
55
111
|
schemas.harnessInspectionSchema.parse(inspection);
|
|
56
112
|
assert.equal(inspection.catalog.defaultThinkingOptionId, 'high', 'Declared default thinking level projects to the catalog');
|
|
@@ -142,7 +198,25 @@ async function main() {
|
|
|
142
198
|
assert.ok(runtime.execution.isRunning(threadId) && runtime.threads.find(t => t.id === threadId).currentTurn.id === current, 'Stale steering never cancels the running Turn');
|
|
143
199
|
emit({ kind: 'completed', finalAnswer: true });
|
|
144
200
|
await wait(() => !runtime.execution.isRunning(threadId) && !runtime.threads.find(t => t.id === threadId).reviewPending && !runtime.sending.has(threadId));
|
|
145
|
-
assert.equal(await bridge.request('thread/start', { model: 'official-model' }), undefined, 'Official Codex
|
|
201
|
+
assert.equal(await bridge.request('thread/start', { model: 'official-model' }), undefined, 'Official Codex thread/start passes through');
|
|
202
|
+
for (const [method, params] of [
|
|
203
|
+
['thread/read', { threadId: 'official-thread', includeTurns: true }],
|
|
204
|
+
['thread/resume', { threadId: 'official-thread' }],
|
|
205
|
+
['turn/start', { threadId: 'official-thread', input: [{ type: 'text', text: 'continue' }] }],
|
|
206
|
+
['turn/interrupt', { threadId: 'official-thread', turnId: 'official-turn' }],
|
|
207
|
+
['thread/fork', { threadId: 'official-thread', lastTurnId: 'official-turn' }],
|
|
208
|
+
['thread/compact/start', { threadId: 'official-thread' }],
|
|
209
|
+
]) {
|
|
210
|
+
assert.equal(await bridge.request(method, params), undefined, `Official Codex ${method} passes through`);
|
|
211
|
+
}
|
|
212
|
+
assert.deepEqual(await bridge.request('codexhost/thread/ownership/list', { threadIds: ['official-thread', threadId] }), {
|
|
213
|
+
threads: [
|
|
214
|
+
{ threadId: 'official-thread', owner: 'codex' },
|
|
215
|
+
{ threadId, owner: 'external', harnessId: 'pi' },
|
|
216
|
+
],
|
|
217
|
+
}, 'Official and Harness-managed threads keep separate ownership');
|
|
218
|
+
const runtimeInspection = await bridge.request('harness-mix/runtime/inspect');
|
|
219
|
+
assert.deepEqual(runtimeInspection.codex, { mode: 'official-passthrough', managedRoute: 'codex-harness' });
|
|
146
220
|
adapter.listCommands = async () => [{ id: 'compact', action: 'execute', label: 'Compact' }];
|
|
147
221
|
let compactCalls = 0;
|
|
148
222
|
adapter.executeCommand = async (_session, id, hooks) => {
|
|
@@ -252,7 +326,108 @@ async function main() {
|
|
|
252
326
|
assert.equal(discardResult.discarded, true);
|
|
253
327
|
assert.equal(discardResult.branch, 'codexhost/test-wt-branch');
|
|
254
328
|
assert.equal(dummyTargetThread.workspace, undefined, 'Discard deletes thread.workspace');
|
|
255
|
-
|
|
329
|
+
|
|
330
|
+
// 消息排队(thread/queue/add, list, update, reorder, delete, start)能力验证
|
|
331
|
+
const qThread = await bridge.request('thread/start', { cwd: root, model: routeModel('pi') });
|
|
332
|
+
const qThreadId = qThread.thread.id;
|
|
333
|
+
assert.deepEqual(await bridge.request('thread/queue/list', { threadId: qThreadId }), { data: [], nextCursor: null });
|
|
334
|
+
const q1 = await bridge.request('thread/queue/add', {
|
|
335
|
+
threadId: qThreadId,
|
|
336
|
+
input: [{ type: 'text', text: '排队补充需求 1' }],
|
|
337
|
+
clientUserMessageId: 'client-msg-1',
|
|
338
|
+
});
|
|
339
|
+
assert.equal(typeof q1.queuedSubmission.id, 'string');
|
|
340
|
+
assert.equal(q1.queuedSubmission.clientUserMessageId, 'client-msg-1');
|
|
341
|
+
assert.equal(events.at(-1)?.method, 'thread/queue/changed');
|
|
342
|
+
assert.equal(events.at(-1)?.params?.threadId, qThreadId);
|
|
343
|
+
|
|
344
|
+
const q2 = await bridge.request('thread/queue/add', {
|
|
345
|
+
threadId: qThreadId,
|
|
346
|
+
input: [{ type: 'text', text: '排队补充需求 2' }],
|
|
347
|
+
clientUserMessageId: 'client-msg-2',
|
|
348
|
+
});
|
|
349
|
+
let qList = await bridge.request('thread/queue/list', { threadId: qThreadId });
|
|
350
|
+
assert.equal(qList.data.length, 2);
|
|
351
|
+
assert.equal(qList.data[0].id, q1.queuedSubmission.id);
|
|
352
|
+
assert.equal(qList.data[1].id, q2.queuedSubmission.id);
|
|
353
|
+
|
|
354
|
+
// 更新排队项
|
|
355
|
+
const updated = await bridge.request('thread/queue/update', {
|
|
356
|
+
threadId: qThreadId,
|
|
357
|
+
queuedSubmissionId: q1.queuedSubmission.id,
|
|
358
|
+
input: [{ type: 'text', text: '更新后的补充需求 1' }],
|
|
359
|
+
});
|
|
360
|
+
assert.equal(updated.queuedSubmission.input[0].text, '更新后的补充需求 1');
|
|
361
|
+
|
|
362
|
+
// 重排序
|
|
363
|
+
await bridge.request('thread/queue/reorder', {
|
|
364
|
+
threadId: qThreadId,
|
|
365
|
+
queuedSubmissionIds: [q2.queuedSubmission.id, q1.queuedSubmission.id],
|
|
366
|
+
});
|
|
367
|
+
qList = await bridge.request('thread/queue/list', { threadId: qThreadId });
|
|
368
|
+
assert.equal(qList.data[0].id, q2.queuedSubmission.id);
|
|
369
|
+
assert.equal(qList.data[1].id, q1.queuedSubmission.id);
|
|
370
|
+
|
|
371
|
+
// 删除排队项
|
|
372
|
+
const del = await bridge.request('thread/queue/delete', {
|
|
373
|
+
threadId: qThreadId,
|
|
374
|
+
queuedSubmissionId: q2.queuedSubmission.id,
|
|
375
|
+
});
|
|
376
|
+
assert.equal(del.deleted, true);
|
|
377
|
+
qList = await bridge.request('thread/queue/list', { threadId: qThreadId });
|
|
378
|
+
assert.equal(qList.data.length, 1);
|
|
379
|
+
assert.equal(qList.data[0].id, q1.queuedSubmission.id);
|
|
380
|
+
|
|
381
|
+
// 空闲状态启动排队消息
|
|
382
|
+
const startedQ = await bridge.request('thread/queue/start', {
|
|
383
|
+
threadId: qThreadId,
|
|
384
|
+
queuedSubmissionId: q1.queuedSubmission.id,
|
|
385
|
+
});
|
|
386
|
+
assert.equal(typeof startedQ.turn?.id, 'string');
|
|
387
|
+
assert.deepEqual(await bridge.request('thread/queue/list', { threadId: qThreadId }), { data: [], nextCursor: null });
|
|
388
|
+
|
|
389
|
+
// 运行中打断执行排队消息(中途补充并打断执行)
|
|
390
|
+
let releaseHold;
|
|
391
|
+
const holdGate = new Promise(resolve => { releaseHold = resolve; });
|
|
392
|
+
const holdAdapter = {
|
|
393
|
+
manifest: { id: 'hold-adapter', name: 'Hold', capabilities: { streaming: true, models: true, resume: true } },
|
|
394
|
+
async open() { return {}; },
|
|
395
|
+
async send(session, text, { emit }) {
|
|
396
|
+
if (text.includes('长任务')) {
|
|
397
|
+
await holdGate;
|
|
398
|
+
} else {
|
|
399
|
+
emit({ kind: 'completed', finalAnswer: true });
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
async cancel() { releaseHold(); },
|
|
403
|
+
async close() {},
|
|
404
|
+
};
|
|
405
|
+
runtime.adapters.set('hold-adapter', holdAdapter);
|
|
406
|
+
runtime.status['hold-adapter'] = { available: true };
|
|
407
|
+
const steerThread = await bridge.request('thread/start', { cwd: root, model: routeModel('hold-adapter') });
|
|
408
|
+
const steerThreadId = steerThread.thread.id;
|
|
409
|
+
// 启动初始任务,使其进入运行中状态
|
|
410
|
+
const initTurn = await bridge.request('turn/start', {
|
|
411
|
+
threadId: steerThreadId,
|
|
412
|
+
input: [{ type: 'text', text: '长任务运行中...' }],
|
|
413
|
+
});
|
|
414
|
+
assert.equal(runtime.execution.isRunning(steerThreadId), true);
|
|
415
|
+
// 运行时添加排队补充
|
|
416
|
+
const steerQueue = await bridge.request('thread/queue/add', {
|
|
417
|
+
threadId: steerThreadId,
|
|
418
|
+
input: [{ type: 'text', text: '中途打断补充并立即执行' }],
|
|
419
|
+
});
|
|
420
|
+
assert.equal(typeof steerQueue.queuedSubmission.id, 'string');
|
|
421
|
+
// 执行 thread/queue/start:打断当前任务并立即执行补充内容
|
|
422
|
+
const midTurnStart = await bridge.request('thread/queue/start', {
|
|
423
|
+
threadId: steerThreadId,
|
|
424
|
+
queuedSubmissionId: steerQueue.queuedSubmission.id,
|
|
425
|
+
});
|
|
426
|
+
assert.notEqual(midTurnStart.turn.id, initTurn.turn.id, '排队消息中途启动已成功打断并派生新 Turn');
|
|
427
|
+
releaseHold();
|
|
428
|
+
await wait(() => !runtime.execution.isRunning(steerThreadId));
|
|
429
|
+
|
|
430
|
+
console.log('PASS: native protocol, external steering, command execution, message queue and Usage projection');
|
|
256
431
|
} finally { bridge.close(); await runtime.close(); }
|
|
257
432
|
}
|
|
258
433
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -20,8 +20,8 @@ const helper = [
|
|
|
20
20
|
assert.equal(redactText('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), '[redacted]');
|
|
21
21
|
assert.equal(redactText('skeleton key'), 'skeleton key'); // no false positive on prose
|
|
22
22
|
assert.deepEqual(
|
|
23
|
-
redact({ token: 'x', name: 'ok', nested: { api_key: 'y', list: ['Bearer zzzzzzzzzzzz'] } }),
|
|
24
|
-
{ token: '[redacted]', name: 'ok', nested: { api_key: '[redacted]', list: ['[redacted]'] } },
|
|
23
|
+
redact({ token: 'x', name: 'ok', userCode: 'ABCD-EFGH', loginId: 'login-1', verificationUrl: 'https://auth.example/device', nested: { api_key: 'y', list: ['Bearer zzzzzzzzzzzz'] } }),
|
|
24
|
+
{ token: '[redacted]', name: 'ok', userCode: '[redacted]', loginId: '[redacted]', verificationUrl: '[redacted]', nested: { api_key: '[redacted]', list: ['[redacted]'] } },
|
|
25
25
|
);
|
|
26
26
|
|
|
27
27
|
if (!helper) {
|
|
@@ -10,6 +10,7 @@ const { terminateTree } = require('../src/main/native/process-utils');
|
|
|
10
10
|
async function main() {
|
|
11
11
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hm shim spaces-'));
|
|
12
12
|
const host = path.join(work, 'fixture host.cjs');
|
|
13
|
+
const stockProbe = path.join(work, 'stock probe.cjs');
|
|
13
14
|
const env = { ...process.env, HARNESS_MIX_NODE_PATH: process.execPath,
|
|
14
15
|
CODEXHOST_STOCK_CODEX_PATH: process.execPath, HARNESS_MIX_HOST_SCRIPT: host, CODEX_CLI_PATH: 'must-be-removed' };
|
|
15
16
|
let child;
|
|
@@ -19,8 +20,23 @@ if (process.env.CODEX_CLI_PATH) process.exit(90);
|
|
|
19
20
|
readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
20
21
|
process.stdout.write(JSON.stringify({ text: line, args: process.argv.slice(2) }) + '\\n');
|
|
21
22
|
}).on('close', () => process.exit(0));`);
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
fs.writeFileSync(stockProbe, `process.stdout.write(JSON.stringify(process.argv.slice(1))); process.exit(0);`);
|
|
24
|
+
const passthrough = args => execFileSync(nativePaths().shim, args, { env, encoding: 'utf8', timeout: 10000 }).trim();
|
|
25
|
+
assert.equal(passthrough(['--version']), process.version);
|
|
26
|
+
const probeEnv = { ...env, NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${JSON.stringify(stockProbe)}`].filter(Boolean).join(' ') };
|
|
27
|
+
const probeStock = args => JSON.parse(execFileSync(nativePaths().shim, args, { env: probeEnv, encoding: 'utf8', timeout: 10000 }));
|
|
28
|
+
const assertStock = (args, message) => {
|
|
29
|
+
const observed = probeStock(args);
|
|
30
|
+
assert.equal(observed.length, args.length, message);
|
|
31
|
+
assert.equal(path.basename(observed[0]), args[0], message);
|
|
32
|
+
assert.deepEqual(observed.slice(1), args.slice(1), message);
|
|
33
|
+
};
|
|
34
|
+
assertStock(['app-server', 'proxy'], 'app-server proxy stays on the official CLI');
|
|
35
|
+
assertStock(['app-server', 'daemon'], 'app-server daemon stays on the official CLI');
|
|
36
|
+
assertStock(['app-server', '--future-option'], 'unknown app-server forms fail open to the official CLI');
|
|
37
|
+
assertStock(['exec', 'app-server'], 'an argument named app-server is not treated as the subcommand');
|
|
38
|
+
const serverArgs = ['-c', 'feature.label=argument with spaces', 'app-server', '--listen', 'stdio://', '--analytics-default-enabled'];
|
|
39
|
+
child = spawn(nativePaths().shim, serverArgs, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
40
|
let stdout = '', stderr = '';
|
|
25
41
|
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
26
42
|
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
@@ -31,8 +47,8 @@ readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
|
31
47
|
});
|
|
32
48
|
child.stdin.end('你好 Linux/macOS\n');
|
|
33
49
|
assert.equal(await done, 0, stderr);
|
|
34
|
-
assert.deepEqual(JSON.parse(stdout), { text: '你好 Linux/macOS', args:
|
|
35
|
-
console.log('PASS:
|
|
50
|
+
assert.deepEqual(JSON.parse(stdout), { text: '你好 Linux/macOS', args: serverArgs });
|
|
51
|
+
console.log('PASS: precise app-server routing, official management passthrough, Unicode stdio, arguments, environment and EOF');
|
|
36
52
|
} finally {
|
|
37
53
|
if (child && child.exitCode === null) await terminateTree(child.pid);
|
|
38
54
|
fs.rmSync(work, { recursive: true, force: true });
|
|
@@ -21,6 +21,13 @@ assert.deepEqual(merged.data.map(t => t.id), ['official', 'external']);
|
|
|
21
21
|
assert.equal(merged.nextCursor, 'next');
|
|
22
22
|
assert.equal(mergeThreadPage(page, [thread], { cursor: 'next' }, t => t), page);
|
|
23
23
|
|
|
24
|
+
const archivedThread = { id: 'archived-ext', archived: true, createdAt: 15 };
|
|
25
|
+
const pageWithArchived = { data: [{ id: 'official', createdAt: 20 }, archivedThread], nextCursor: null };
|
|
26
|
+
const mergedWithoutArchived = mergeThreadPage(pageWithArchived, [archivedThread], { archived: false }, t => t);
|
|
27
|
+
assert.deepEqual(mergedWithoutArchived.data.map(t => t.id), ['official']);
|
|
28
|
+
const mergedWithArchived = mergeThreadPage({ data: [{ id: 'official-archived', archived: true, createdAt: 20 }, archivedThread], nextCursor: null }, [archivedThread], { archived: true }, t => t);
|
|
29
|
+
assert.deepEqual(mergedWithArchived.data.map(t => t.id), ['official-archived', 'archived-ext']);
|
|
30
|
+
|
|
24
31
|
const root = fs.mkdtempSync(path.resolve('output/native-runtime-cache-test-'));
|
|
25
32
|
const resources = path.join(root, 'resources');
|
|
26
33
|
const cache = path.join(root, 'cache');
|