@harness-mix/cli 0.1.5 → 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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/docs/harness-management.md +4 -4
  3. package/docs/multi-agent-collaboration.md +9 -9
  4. package/output/native-build/renderer-extension.js +1959 -435
  5. package/package.json +9 -8
  6. package/scripts/collaboration-test.cjs +8 -3
  7. package/scripts/collaboration-ui-smoke.cjs +40 -26
  8. package/scripts/integrations-test.cjs +63 -4
  9. package/scripts/integrations-ui-smoke.cjs +77 -16
  10. package/scripts/native-protocol-test.cjs +102 -1
  11. package/scripts/native-sidebar-test.cjs +7 -0
  12. package/scripts/native-skill-roots-test.cjs +90 -0
  13. package/scripts/test-live-mentions.cjs +152 -0
  14. package/src/main/adapters/antigravity.js +20 -17
  15. package/src/main/adapters/claude.js +41 -8
  16. package/src/main/adapters/codebuddy.js +1 -0
  17. package/src/main/adapters/codex.js +20 -2
  18. package/src/main/adapters/cursor.js +5 -1
  19. package/src/main/adapters/dsh.js +8 -1
  20. package/src/main/adapters/grok.js +4 -1
  21. package/src/main/adapters/hermes.js +8 -0
  22. package/src/main/adapters/kiro.js +2 -1
  23. package/src/main/adapters/managed-mcp.js +15 -2
  24. package/src/main/adapters/omp.js +11 -0
  25. package/src/main/adapters/openclaw.js +6 -0
  26. package/src/main/adapters/opencode.js +5 -1
  27. package/src/main/adapters/pi.js +5 -1
  28. package/src/main/adapters/qoder.js +1 -0
  29. package/src/main/adapters/trae.js +4 -0
  30. package/src/main/adapters/zcode.js +3 -0
  31. package/src/main/host/collaboration.js +57 -11
  32. package/src/main/host/integrations.js +164 -26
  33. package/src/main/host/runtime.js +126 -86
  34. package/src/main/native/protocol.js +189 -78
  35. package/src/main/native/thread-list.js +3 -0
  36. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  37. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  38. package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts.map +1 -1
  39. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts +11 -0
  40. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
  41. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +29 -4
  42. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -1
  43. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts +2 -1
  44. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts.map +1 -1
  45. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  46. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -1
  47. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  48. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  49. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +3 -2
  50. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +149 -171
  51. package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +5 -1
  52. package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +556 -123
  53. package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +36 -3
  54. package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +1434 -62
  55. package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
  56. package/src/native-ui/renderer-extension/src/settings/pages.ts +5 -3
  57. package/src/native-ui/renderer-extension/src/settings/shell.css +96 -6
  58. package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +4 -1
  59. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
  60. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-mix/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Harness Mix native Codex UI bridge for Codex, Pi, Claude Code, DeepSeek Harness, Antigravity, CodeBuddy, Kiro CLI, Cursor CLI and other native coding harnesses.",
6
6
  "keywords": [
@@ -30,7 +30,8 @@
30
30
  "harness-mix": "scripts/launch-codex.cjs"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "public"
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org"
34
35
  },
35
36
  "postinstall": "node scripts/install-native.cjs",
36
37
  "files": [
@@ -176,11 +177,11 @@
176
177
  "zod": "4.4.3"
177
178
  },
178
179
  "optionalDependencies": {
179
- "@harness-mix/native-win32-x64": "0.1.5",
180
- "@harness-mix/native-win32-arm64": "0.1.5",
181
- "@harness-mix/native-darwin-x64": "0.1.5",
182
- "@harness-mix/native-darwin-arm64": "0.1.5",
183
- "@harness-mix/native-linux-x64": "0.1.5",
184
- "@harness-mix/native-linux-arm64": "0.1.5"
180
+ "@harness-mix/native-win32-x64": "0.1.6",
181
+ "@harness-mix/native-win32-arm64": "0.1.6",
182
+ "@harness-mix/native-darwin-x64": "0.1.6",
183
+ "@harness-mix/native-darwin-arm64": "0.1.6",
184
+ "@harness-mix/native-linux-x64": "0.1.6",
185
+ "@harness-mix/native-linux-arm64": "0.1.6"
185
186
  }
186
187
  }
@@ -29,10 +29,11 @@ async function main() {
29
29
  const call = (name, args) => rt.collaboration.call(parent.id, name, args);
30
30
  const finish = (id, answer) => { const { s } = pending.get(id); pending.delete(id); active--; s.emit({ kind: 'text-delta', text: answer }); s.emit({ kind: 'completed', finalAnswer: true }); };
31
31
  try {
32
- assert.deepEqual(mentionedAgents('ask @w and [Worker](harness-mix://agent/worker) `@lead` mail@lead.com @worker/foo', rt), ['worker']);
32
+ assert.deepEqual(mentionedAgents('ask #w and [Worker](harness-mix://agent/worker) `#lead` issue#lead #worker/foo', rt), ['worker']);
33
+ assert.deepEqual(mentionedAgents('leave @w to native Codex mentions', rt), []);
33
34
  await assert.rejects(call('list_agents', {}), /no longer active/);
34
35
  rt.history.context = async ({ nativeSessionId }) => { assert.equal(nativeSessionId, 'c2Vzc2lvbg'); return { harnessId: 'pi', title: 'Old session', cwd: root, transcript: 'User: prior question\nAssistant: prior answer' }; };
35
- await rt.send(parent.id, '@worker review files @[Old session](harness-mix://session/c2Vzc2lvbg)');
36
+ await rt.send(parent.id, '#worker review files #[Old session](harness-mix://session/c2Vzc2lvbg)');
36
37
  assert.ok(rt.execution.isRunning(parent.id));
37
38
  assert.match(leadPrompt, /untrusted historical data[\s\S]*prior answer/);
38
39
  assert.match(leadPrompt, /Recovery checkpoint:[\s\S]*interrupted-fixture \(worker\)[\s\S]*call list_delegations now/);
@@ -88,7 +89,11 @@ async function main() {
88
89
  assert.equal((await call('get_delegation_status', { task_ids: [first.task_id], wait_ms: 3000 }))[0].result, 'follow-up-result');
89
90
  await assert.rejects(call('get_delegation_status', { task_ids: ['foreign-task'] }), /Unknown task/);
90
91
  await assert.rejects(rt.collaboration.call(jobs[0].childId, 'delegate_to_agent', { agent_type: 'worker', task: 'recursive' }), /Only lead/);
91
- await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'x', unexpected: true }));
92
+ await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'x', unexpected: true }));
93
+ const savedMentions = parent.activeMentions;
94
+ delete parent.activeMentions;
95
+ await assert.rejects(call('delegate_to_agent', { agent_type: 'worker', task: 'unauthorized' }), /用户本轮未显式委派/);
96
+ parent.activeMentions = savedMentions;
92
97
  const denied = await fetch(ownerConnection.env.HARNESS_MIX_COLLAB_URL, { method: 'POST', headers: { Authorization: 'Bearer invalid' }, body: '{}' });
93
98
  assert.equal(denied.status, 403);
94
99
  const outsider = await rt.createThread({ harnessId: 'worker', cwd: root });
@@ -5,53 +5,67 @@ const path = require('node:path');
5
5
  app.disableHardwareAcceleration();
6
6
  app.whenReady().then(async () => {
7
7
  const bundle = await require('esbuild').build({ stdin: { contents: `export * from './src/native-ui/renderer-extension/src/renderer-harness-mentions.ts';`, resolveDir: process.cwd() }, bundle: true, platform: 'browser', format: 'iife', globalName: 'Mentions', write: false });
8
- const win = new BrowserWindow({ show: false, width: 900, height: 650, webPreferences: { sandbox: true, contextIsolation: true, offscreen: true } });
8
+ const win = new BrowserWindow({ show: false, width: 900, height: 650, webPreferences: { sandbox: true, contextIsolation: true, offscreen: true, backgroundThrottling: false } });
9
9
  try {
10
- await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<body style="font:16px system-ui;padding:32px"><h2>Harness Mix · 多 Agent 协作</h2><p>主任务负责分工、收集结果与验证。</p><div data-codex-composer-root style="position:absolute;bottom:50px;width:750px"><textarea style="width:100%;height:90px;font:16px system-ui" placeholder="输入 @ 选择协作 Harness"></textarea><div contenteditable="true" style="border:1px solid gray;padding:16px">你负责后端,</div></div></body>'));
10
+ await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<body style="font:16px system-ui;padding:32px"><h2>Harness Mix · 多 Agent 协作</h2><p>主任务负责分工、收集结果与验证。</p><div data-codex-composer-root data-composer-draft-id="textarea-smoke" style="position:absolute;left:40px;bottom:50px;width:750px;box-sizing:border-box;padding:14px;border:2px solid #aaa;border-radius:16px"><textarea style="width:100%;height:90px;border:0;outline:0;resize:none;font:16px system-ui" placeholder="输入 # 选择协作 Harness"></textarea></div><div data-codex-composer-root data-composer-draft-id="rich-smoke" style="position:fixed;left:-1200px;width:400px"><div contenteditable="true" style="border:1px solid gray;padding:16px">你负责后端,</div></div></body>'));
11
11
  await win.webContents.executeJavaScript(`globalThis.__HARNESS_MIX_ICONS__ = ${JSON.stringify(require('../src/main/native/icons').getAllIconsDictionary())}`);
12
12
  await win.webContents.executeJavaScript(bundle.outputFiles[0].text);
13
13
  const result = await win.webContents.executeJavaScript(`(async () => {
14
- const api = Mentions.installHarnessMentions(async () => ({agents:[{id:'pi',name:'Pi',available:true,lead:true},{id:'claude',name:'Claude Code',available:true,lead:true},{id:'dsh',name:'DeepSeek Harness',available:false,lead:false}],sessions:[{id:'c2Vzc2lvbg',title:'旧版登录审查',harnessId:'claude',cwd:'E:/project',running:false}]}));
15
- const editor = document.querySelector('textarea');
16
- editor.focus(); editor.value = '你负责后端, @cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
14
+ const api = Mentions.installHarnessMentions(async () => ({agents:[{id:'pi',name:'Pi',available:true,lead:true},{id:'claude',name:'Claude Code',available:true,lead:true},{id:'dsh',name:'DeepSeek Harness',available:false,lead:false}],sessions:[{id:'c2Vzc2lvbg',title:'旧版登录审查',harnessId:'claude',cwd:'E:/project',running:false}]}));
15
+ const editor = document.querySelector('textarea');
16
+ editor.focus(); editor.value = '@cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
17
+ await new Promise(r=>setTimeout(r,30));
18
+ const atTriggerStayedNative = document.querySelector('[data-harness-mix-mentions]').hidden;
19
+ editor.focus(); editor.value = '你负责后端, #cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
17
20
  await new Promise(r=>setTimeout(r,30));
18
21
  const filtered = document.querySelectorAll('[data-harness-mix-mentions] [role="option"]').length;
19
22
  editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
20
- const text = editor.value;
21
- const badge = document.querySelector('[data-harness-mix-selected-mentions] img')?.dataset.harnessMixIcon;
23
+ const text = editor.value;
24
+ const badge = document.querySelector('[data-harness-mix-selected-mentions] img')?.dataset.harnessMixIcon;
25
+ const badgeButton = document.querySelector('[data-harness-mix-selected-mentions] button');
26
+ const inline = badgeButton?.parentElement?.nextElementSibling === editor && editor.parentElement?.dataset.harnessMixHasMentions === 'true';
27
+ editor.value = '你负责后端, #pi'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
28
+ await new Promise(r=>setTimeout(r,30));
29
+ editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
30
+ const selectedAgentCount = editor.parentElement.querySelectorAll('[data-harness-mix-mention-badge^="agent:"]').length;
22
31
  const rich = document.querySelector('[contenteditable]'); rich.focus();
23
32
  const selection = getSelection(), range = document.createRange(); range.selectNodeContents(rich); range.collapse(false); selection.removeAllRanges(); selection.addRange(range);
24
- document.execCommand('insertText',false,' @pi');
33
+ document.execCommand('insertText',false,' #pi');
25
34
  await new Promise(r=>setTimeout(r,30));
26
35
  rich.textContent = rich.textContent; // Native editor redraw replaces the original Range nodes.
27
36
  document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
28
37
  const richText = rich.textContent;
29
- editor.focus(); editor.value='@'; editor.setSelectionRange(1,1); editor.dispatchEvent(new Event('input',{bubbles:true}));
38
+ editor.focus(); editor.value='#'; editor.setSelectionRange(1,1); editor.dispatchEvent(new Event('input',{bubbles:true}));
30
39
  await new Promise(r=>setTimeout(r,30));
31
40
  globalThis.mentionSmoke = api;
32
41
  const tabs = document.querySelectorAll('[data-harness-mix-mentions] [role=tab]').length;
33
42
  if (tabs !== 2) throw new Error('Missing Agents/session tabs');
34
43
  const icons = document.querySelectorAll('[data-harness-mix-mentions] img').length;
35
44
  const disabled = document.querySelectorAll('[data-harness-mix-mentions] [role="option"]:disabled').length;
36
- editor.value='@旧';editor.setSelectionRange(2,2);editor.dispatchEvent(new Event('input',{bubbles:true}));await new Promise(r=>setTimeout(r,30));
37
- document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
38
- const sessionBadge=[...document.querySelectorAll('[data-harness-mix-selected-mentions] span')].some(b=>b.title.startsWith('历史会话'));
39
- const root = editor.closest('[data-codex-composer-root]');
40
- root.dispatchEvent(new Event('submit', { bubbles: true }));
41
- const submittedText = editor.value;
42
- return {filtered,text,submittedText,richText,badge,icons,disabled,sessionText:editor.value,sessionBadge};
43
- })()`);
44
- assert.equal(result.filtered, 1);
45
+ editor.value='#旧';editor.setSelectionRange(2,2);editor.dispatchEvent(new Event('input',{bubbles:true}));await new Promise(r=>setTimeout(r,30));
46
+ document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
47
+ const sessionBadge=[...document.querySelectorAll('[data-harness-mix-selected-mentions] [data-harness-mix-mention-badge]')].some(b=>b.title.startsWith('移除历史会话'));
48
+ editor.value='请分别实现并审查这个任务。'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
49
+ globalThis.mentionSmokeEditor = editor;
50
+ return {atTriggerStayedNative,filtered,text,richText,badge,badgeLabel:badgeButton?.getAttribute('aria-label'),inline,selectedAgentCount,icons,disabled,sessionBadge};
51
+ })()`);
52
+ assert.equal(result.atTriggerStayedNative, true);
53
+ assert.equal(result.filtered, 1);
45
54
  assert.equal(result.text, '你负责后端, ');
46
- assert.match(result.submittedText, /@claude/);
47
- assert.equal(result.disabled, 1);
48
- assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3);
49
- assert.equal(result.sessionBadge, true);
50
- const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
51
- await new Promise(r => setTimeout(r,100));
52
- await fs.writeFile(path.join(out, 'mentions.png'), (await win.webContents.capturePage()).toPNG());
55
+ assert.equal(result.disabled, 1);
56
+ assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3);
57
+ assert.match(result.badgeLabel, /^移除协作 Agent /);
58
+ assert.equal(result.inline, true);
59
+ assert.equal(result.selectedAgentCount, 2);
60
+ assert.equal(result.sessionBadge, true);
61
+ const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
62
+ await new Promise(r => setTimeout(r,100));
63
+ await fs.writeFile(path.join(out, 'mentions.png'), (await win.webContents.capturePage()).toPNG());
64
+ const submittedText = await win.webContents.executeJavaScript(`(() => { const root=mentionSmokeEditor.closest('[data-codex-composer-root]'); root.dispatchEvent(new Event('submit',{bubbles:true})); return mentionSmokeEditor.value; })()`);
65
+ assert.match(submittedText, /#claude/);
66
+ assert.match(submittedText, /#pi/);
53
67
  await win.webContents.executeJavaScript('mentionSmoke.dispose()');
54
68
  assert.equal(await win.webContents.executeJavaScript('document.querySelectorAll("[data-harness-mix-mentions]").length'), 0);
55
- console.log('PASS: Electron native composer @ Harness/session icons, badges, keyboard selection, textarea/rich text and cleanup');
69
+ console.log('PASS: Electron native composer # Harness/session icons, badges, keyboard selection, textarea/rich text and cleanup');
56
70
  } finally { win.destroy(); app.quit(); }
57
71
  }).catch(error => { console.error(error); app.exit(1); });
@@ -12,7 +12,7 @@ async function main() {
12
12
  await fs.mkdir(project, { recursive: true }); await fs.mkdir(home);
13
13
  const adapters = new Map(buildAdapters(() => {}).map(a => [a.manifest.id, a]));
14
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 });
15
+ const manager = runtime.integrations = new Integrations(runtime, { home, environment: {} });
16
16
  const global = { harnessId: 'claude', scope: 'global' }, local = { harnessId: 'claude', scope: 'project', cwd: project };
17
17
  const server = { name: 'probe', command: process.execPath, args: ['probe.cjs'], enabled: true };
18
18
  await Promise.all([manager.save({ ...global, server }), manager.save({ ...local, server: { ...server, enabled: false } })]);
@@ -29,8 +29,27 @@ async function main() {
29
29
  await manager.save({ ...global, server: { ...server, args: ['changed.cjs'] } });
30
30
  assert.equal((await manager.list(global)).servers[0].appliedSessions, 0, 'changed config not claimed as applied');
31
31
  const reload = new Integrations(runtime, { home }); assert.equal((await reload.list(global)).servers.length, 1);
32
- await assert.rejects(manager.save({ ...global, server: { ...server, env: { SECRET: 'never' } } }));
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' } }));
33
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 } }));
34
53
  await assert.rejects(manager.save({ ...global, harnessId: 'pi', server }), /no supported/);
35
54
  await assert.rejects(manager.remove({ ...local, id: snapshot.servers[0].id }), /scope/);
36
55
  await assert.rejects(manager.list({ ...local, cwd: 'relative' }));
@@ -44,6 +63,11 @@ async function main() {
44
63
  await assert.rejects(fs.access(nativeFile)); assert.equal((await manager.list(local)).skills[0].enabled, false);
45
64
  await manager.skillChange({ ...local, name: 'sample', action: 'enable' });
46
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/);
47
71
  await assert.rejects(manager.skillChange({ ...local, name: '../escape', source, action: 'install' }));
48
72
  await assert.rejects(manager.skillChange({ ...local, name: 'sample', root, action: 'disable' }));
49
73
  const link = path.join(project, '.claude/skills/linked');
@@ -55,10 +79,45 @@ async function main() {
55
79
  assert.equal(acp.length, 2); assert.equal(acp[1].name, 'harness-mix'); assert.deepEqual(acp[0].env, []);
56
80
  assert.ok(namedServers(selected.servers, collab)['hm-user-probe']);
57
81
  const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length, 16);
58
- for (const a of adapters.values()) assert.equal(catalog.harnesses.find(h => h.id === a.manifest.id).mcp, a.manifest.integrations?.mcp === true);
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);
59
118
  // Exercise the real protocol dispatcher without starting native processes.
60
119
  assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length, 16);
61
- assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/list', local)).skills.length, 2);
120
+ assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/list', local)).skills.length, 3);
62
121
  console.log('integrations: scope precedence, persistence, native status projection, no credential fields, unsupported capabilities, skill install/disable/restore, links, and protocol PASS');
63
122
  }
64
123
  main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -14,14 +14,13 @@ app.whenReady().then(async () => {
14
14
  const adapters = new Map(buildAdapters(() => {}).map(a => [a.manifest.id, a]));
15
15
  const runtime = { adapters, sessions: new Map(), status: { claude: { available: true } }, store: { directory: path.join(out, 'data') }, resolveHarnessId: id => id };
16
16
  runtime.integrations = new Integrations(runtime, { home: path.join(out, 'home') });
17
- const source = path.join(out, 'skill-source'); await fs.mkdir(source); await fs.writeFile(path.join(source, 'SKILL.md'), '---\nname: ui-skill\ndescription: UI fixture\n---\n# Test\n');
18
17
  const preload = path.join(out, 'preload.cjs');
19
18
  await fs.writeFile(preload, `const {contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('integrationTest',{request:(method,params)=>ipcRenderer.invoke('integrations-test',method,params)});`);
20
19
  ipcMain.handle('integrations-test', (_event, method, params) => {
21
20
  assert.ok(method.startsWith('codexhost/integrations/'));
22
21
  return NativeProtocol.prototype.request.call({ runtime }, method, params);
23
22
  });
24
- const build = await esbuild.build({ stdin: { contents: `export {createIntegrationsSettingsPage} 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' } });
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' } });
25
24
  const consoleProblems = [];
26
25
  const win = new BrowserWindow({ show: false, width: 1100, height: 900, webPreferences: { preload, contextIsolation: true, sandbox: true, offscreen: true } });
27
26
  win.webContents.on('console-message', (_event, details) => { if (details.level === 'error' || details.level === 'warning') consoleProblems.push(details.message); });
@@ -31,32 +30,94 @@ app.whenReady().then(async () => {
31
30
  const result = await win.webContents.executeJavaScript(`(async()=>{
32
31
  const messages=IntegrationUI.rendererSettingsMessages('zh-CN');
33
32
  const client=IntegrationUI.createRendererIntegrationsClient((m,p)=>window.integrationTest.request(m,p));
34
- const registry=IntegrationUI.createRendererSettingsPageRegistry([IntegrationUI.createIntegrationsSettingsPage(messages,()=>client)]);
35
- const shell=IntegrationUI.mountRendererSettingsShell(registry,document,messages); shell.openSettings();
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');
36
35
  const root=shell.root.shadowRoot;
37
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');};
38
- await wait(()=>root.querySelector('select')?.options.length===16 && !root.querySelector('select').disabled);
37
+ await wait(()=>root.querySelector('select')?.options.length===17 && !root.querySelector('select').disabled);
39
38
  const [harness,scope]=root.querySelectorAll('select');harness.value='claude';harness.dispatchEvent(new Event('change'));
40
- await wait(()=>root.querySelector('.settings-integrations-form')&&!harness.disabled);
39
+ await wait(()=>root.querySelector('.settings-integrations-toolbar')&&!harness.disabled);
41
40
  scope.value='project';scope.dispatchEvent(new Event('change'));
42
41
  const cwd=root.querySelector('.settings-integrations-controls input');cwd.value=${JSON.stringify(project)};cwd.dispatchEvent(new Event('change'));
43
- await wait(()=>root.querySelector('.settings-integrations-form')&&!harness.disabled);
44
- const form=root.querySelector('form'); const fields=form.querySelectorAll('input');fields[0].value='ui-probe';fields[1].value='node';form.querySelector('textarea').value='["probe.cjs"]';form.requestSubmit();
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
45
  await wait(()=>root.textContent.includes('ui-probe')&&!harness.disabled&&!!root.querySelector('.settings-integrations-row'));
46
46
  const mcpRow=()=>Array.from(root.querySelectorAll('.settings-integrations-row')).find(r=>r.querySelector('strong')?.textContent==='ui-probe');
47
- mcpRow().querySelector('button').click();await wait(()=>mcpRow()?.textContent.includes('已停用')&&!harness.disabled);
48
- mcpRow().querySelector('button').click();await wait(()=>mcpRow()?.textContent.includes('已配置')&&!harness.disabled);
49
- const skillForm=root.querySelectorAll('form')[1];const skillFields=skillForm.querySelectorAll('input');skillFields[0].value='ui-skill';skillFields[1].value=${JSON.stringify(source)};skillForm.requestSubmit();
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}));
50
61
  const skillRow=()=>Array.from(root.querySelectorAll('.settings-integrations-row')).find(r=>r.querySelector('strong')?.textContent==='ui-skill');
51
- await wait(()=>skillRow()&&!harness.disabled);skillRow().querySelector('button').click();await wait(()=>skillRow()?.textContent.includes('已停用')&&!harness.disabled);
52
- skillRow().querySelector('button').click();await wait(()=>skillRow()?.textContent.includes('已发现')&&!harness.disabled);
53
- return {mcpSaved:true,mcpToggled:true,skillInstalled:true,skillRestored:true,scope:scope.value,text:root.textContent.slice(-2000)};
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)};
54
67
  })()`);
55
68
  assert.equal(await win.webContents.executeJavaScript('document.title'), 'Harness Mix Integrations QA');
56
- assert.ok(result.mcpSaved && result.skillRestored); assert.deepEqual(consoleProblems, []);
69
+ assert.ok(result.mcpSaved && result.mcpOverviewRendered && result.skillDropped && result.skillRestored && result.overviewRendered); assert.deepEqual(result.separatePages, ['mcp', 'skills']); assert.deepEqual(consoleProblems, []);
57
70
  const list = await runtime.integrations.list({ harnessId: 'claude', scope: 'project', cwd: project });
58
71
  assert.equal(list.servers[0].enabled, true); assert.equal(list.skills[0].enabled, true);
59
- await fs.writeFile(path.join(out, 'settings.png'), (await win.webContents.capturePage()).toPNG());
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());
60
121
  await fs.writeFile(path.join(out, 'report.json'), JSON.stringify(result, null, 2));
61
122
  console.log(`Integrations rendered UI -> typed client -> NativeProtocol -> real files PASS: ${out}`);
62
123
  } finally { win.destroy(); app.quit(); }
@@ -326,7 +326,108 @@ async function main() {
326
326
  assert.equal(discardResult.discarded, true);
327
327
  assert.equal(discardResult.branch, 'codexhost/test-wt-branch');
328
328
  assert.equal(dummyTargetThread.workspace, undefined, 'Discard deletes thread.workspace');
329
- console.log('PASS: native protocol, external steering, command execution and Usage projection');
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');
330
431
  } finally { bridge.close(); await runtime.close(); }
331
432
  }
332
433
  main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -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');