@harness-mix/cli 0.2.0 → 0.2.2

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 (84) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +14 -0
  3. package/README.md +4 -24
  4. package/docs/multi-agent-collaboration.md +1 -1
  5. package/licenses/anthropic-codex-theme-MIT.txt +21 -0
  6. package/licenses/codex-dream-skin-MIT.txt +21 -0
  7. package/output/native-build/renderer-extension.js +703 -58
  8. package/package.json +192 -189
  9. package/scripts/collaboration-test.cjs +262 -200
  10. package/scripts/collaboration-ui-smoke.cjs +85 -77
  11. package/scripts/core-concurrency-test.cjs +23 -1
  12. package/scripts/e2e-pi-cancel.cjs +99 -0
  13. package/scripts/native-file-change-test.cjs +42 -2
  14. package/scripts/native-sidebar-test.cjs +8 -0
  15. package/scripts/native-vendor-adapters-test.cjs +67 -1
  16. package/scripts/pets-test.cjs +63 -0
  17. package/scripts/pi-cancel-race-test.cjs +102 -0
  18. package/scripts/stuck-turn-test.cjs +73 -0
  19. package/scripts/workspace-test.cjs +9 -0
  20. package/src/assets/skins/dream-skin/NOTICE.md +13 -0
  21. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp +0 -0
  22. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/theme.json +34 -0
  23. package/src/main/adapters/claude.js +9 -6
  24. package/src/main/adapters/pi-family.js +41 -8
  25. package/src/main/harness-adapter/event-normalizer.js +270 -258
  26. package/src/main/host/collaboration-mcp.cjs +8 -1
  27. package/src/main/host/collaboration-tools.js +1 -1
  28. package/src/main/host/collaboration.js +81 -11
  29. package/src/main/host/runtime.js +1462 -1341
  30. package/src/main/native/pets.js +79 -2
  31. package/src/main/native/protocol.js +1164 -1145
  32. package/src/main/native/thread-list.js +6 -1
  33. package/src/main/workspace/core-review.js +75 -0
  34. package/src/main/workspace/diff.js +5 -1
  35. package/src/main/workspace/file-changes.js +9 -4
  36. package/src/main/workspace/review-controller.js +115 -102
  37. package/src/main/workspace/review.js +90 -88
  38. package/src/native-ui/renderer-extension/dist/contract-audit.js +16637 -0
  39. package/src/native-ui/renderer-extension/dist/index.js +34853 -0
  40. package/src/native-ui/renderer-extension/dist/production.js +34263 -0
  41. package/src/native-ui/renderer-extension/dist/renderer-binding-probe.js +34254 -0
  42. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  43. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +11 -0
  44. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  45. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts +73 -0
  46. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts.map +1 -0
  47. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
  48. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  49. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  50. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts +12 -0
  51. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts.map +1 -0
  52. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  53. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  56. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  57. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +1 -1
  58. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -1
  59. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +9 -0
  60. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -1
  61. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
  63. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  64. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3181 -3171
  65. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +14 -0
  66. package/src/native-ui/renderer-extension/src/renderer-pet-switcher.ts +308 -0
  67. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  68. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +76 -21
  69. package/src/native-ui/renderer-extension/src/settings/collaboration-page.ts +139 -0
  70. package/src/native-ui/renderer-extension/src/settings/icons.ts +155 -153
  71. package/src/native-ui/renderer-extension/src/settings/localization.ts +785 -783
  72. package/src/native-ui/renderer-extension/src/settings/pages.ts +694 -681
  73. package/src/native-ui/renderer-extension/src/settings/pet-market.css +110 -1
  74. package/src/native-ui/renderer-extension/src/settings/pet-market.ts +276 -22
  75. package/src/native-ui/renderer-extension/src/settings/pets-client.ts +32 -0
  76. package/src/native-ui/renderer-extension/src/settings/shell.css +2294 -2255
  77. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +51 -1
  78. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  79. package/src/native-ui/renderer-extension/test/renderer-pet-switcher.test.ts +400 -0
  80. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +80 -79
  81. package/src/native-ui/renderer-extension/test/settings/pet-market.test.ts +378 -0
  82. package/src/native-ui/renderer-extension/test/settings/pets-client.test.ts +83 -0
  83. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  84. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +15 -1
@@ -4,33 +4,33 @@ const fs = require('node:fs/promises');
4
4
  const path = require('node:path');
5
5
  app.disableHardwareAcceleration();
6
6
  app.whenReady().then(async () => {
7
- const bundle = await require('esbuild').build({ stdin: { contents: `export * from './src/native-ui/renderer-extension/src/renderer-harness-mentions.ts'; export * from './src/native-ui/renderer-extension/src/renderer-team-cards.ts';`, resolveDir: process.cwd() }, bundle: true, platform: 'browser', format: 'iife', globalName: 'Mentions', write: false });
8
- const win = new BrowserWindow({ show: false, width: 1024, height: 716, webPreferences: { sandbox: true, contextIsolation: true, offscreen: true, backgroundThrottling: false } });
7
+ const bundle = await require('esbuild').build({ stdin: { contents: `export * from './src/native-ui/renderer-extension/src/renderer-harness-mentions.ts'; export * from './src/native-ui/renderer-extension/src/renderer-team-cards.ts';`, resolveDir: process.cwd() }, bundle: true, platform: 'browser', format: 'iife', globalName: 'Mentions', write: false });
8
+ const win = new BrowserWindow({ show: false, width: 1024, height: 716, 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 id="thread-content-anchor"></div><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>'));
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 id="thread-content-anchor"></div><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
- 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 teamState = {action:'message_sent',team_id:'team-1',lead_thread_id:'lead-thread',name:'Release Team',goal:'实现并验证 Agent Team',status:'active',updated_at:2,lead:{id:'lead',name:'Team Lead',role:'Codex · 协调与验收',agent:'codex',display_status:'working'},members:[{id:'member-1',name:'Backend',role:'后端架构与 Host Runtime',agent:'pi',childId:'builder-thread',display_status:'working'},{id:'member-2',name:'Reviewer',role:'独立代码审查与质量验收',agent:'claude',display_status:'ready'},{id:'member-3',name:'Frontend',role:'Codex 原生界面集成',agent:'codex',display_status:'working'},{id:'member-4',name:'QA',role:'测试矩阵与回归验证',agent:'dsh',display_status:'ready'},{id:'member-5',name:'Docs',role:'文档与交付说明',agent:'opencode',display_status:'ready'},{id:'member-6',name:'Release',role:'Git、Diff 与最终发布检查',agent:'antigravity',display_status:'blocked'}],tasks:[{id:'task-1',title:'实现 Team Runtime',assignee:'member-1',dependsOn:[],status:'in_progress'},{id:'task-2',title:'独立验证实现',assignee:'member-2',dependsOn:['task-1'],status:'blocked'},{id:'task-3',title:'实现内嵌团队页面',assignee:'member-3',dependsOn:[],status:'in_progress'},{id:'task-4',title:'执行完整回归',assignee:'member-4',dependsOn:['task-1','task-3'],status:'blocked'},{id:'task-5',title:'更新协作文档',assignee:'member-5',dependsOn:[],status:'completed'},{id:'task-6',title:'核对最终 Diff',assignee:'member-6',dependsOn:['task-2','task-4'],status:'blocked'}],messages:[{id:'message-1',from:'member-1',fromName:'Backend',to:'member-2',body:'Runtime 已就绪,请准备审查。',at:1}]};
16
- globalThis.openedTeamThread = null;
17
- const teamApi = Mentions.installTeamCards({inspectTeam:async () => ({team:teamState,snapshots:[{id:'snapshot-1',action:'team_created',at:1,team:{...teamState,tasks:[],messages:[]}},{id:'snapshot-2',action:'message_sent',at:2,team:teamState}]}),openThread:async id => { globalThis.openedTeamThread=id; },activeThread:() => ({threadId:'lead-thread',anchor:document.querySelector('#thread-content-anchor')})});
18
- const editor = document.querySelector('textarea');
19
- editor.focus(); editor.value = '@cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
20
- await new Promise(r=>setTimeout(r,30));
21
- const atTriggerStayedNative = document.querySelector('[data-harness-mix-mentions]').hidden;
22
- editor.focus(); editor.value = '你负责后端, #cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
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 teamState = {action:'message_sent',team_id:'team-1',lead_thread_id:'lead-thread',name:'Release Team',goal:'实现并验证 Agent Team',status:'active',updated_at:2,lead:{id:'lead',name:'Team Lead',role:'Codex · 协调与验收',agent:'codex',display_status:'working'},members:[{id:'member-1',name:'Backend',role:'后端架构与 Host Runtime',agent:'pi',childId:'builder-thread',display_status:'working'},{id:'member-2',name:'Reviewer',role:'独立代码审查与质量验收',agent:'claude',display_status:'ready'},{id:'member-3',name:'Frontend',role:'Codex 原生界面集成',agent:'codex',display_status:'working'},{id:'member-4',name:'QA',role:'测试矩阵与回归验证',agent:'dsh',display_status:'ready'},{id:'member-5',name:'Docs',role:'文档与交付说明',agent:'opencode',display_status:'ready'},{id:'member-6',name:'Release',role:'Git、Diff 与最终发布检查',agent:'antigravity',display_status:'blocked'}],tasks:[{id:'task-1',title:'实现 Team Runtime',assignee:'member-1',dependsOn:[],status:'in_progress'},{id:'task-2',title:'独立验证实现',assignee:'member-2',dependsOn:['task-1'],status:'blocked'},{id:'task-3',title:'实现内嵌团队页面',assignee:'member-3',dependsOn:[],status:'in_progress'},{id:'task-4',title:'执行完整回归',assignee:'member-4',dependsOn:['task-1','task-3'],status:'blocked'},{id:'task-5',title:'更新协作文档',assignee:'member-5',dependsOn:[],status:'completed'},{id:'task-6',title:'核对最终 Diff',assignee:'member-6',dependsOn:['task-2','task-4'],status:'blocked'}],messages:[{id:'message-1',from:'member-1',fromName:'Backend',to:'member-2',body:'Runtime 已就绪,请准备审查。',at:1}]};
16
+ globalThis.openedTeamThread = null;
17
+ const teamApi = Mentions.installTeamCards({inspectTeam:async () => ({team:teamState,snapshots:[{id:'snapshot-1',action:'team_created',at:1,team:{...teamState,tasks:[],messages:[]}},{id:'snapshot-2',action:'message_sent',at:2,team:teamState}]}),openThread:async id => { globalThis.openedTeamThread=id; },activeThread:() => ({threadId:'lead-thread',anchor:document.querySelector('#thread-content-anchor')})});
18
+ const editor = document.querySelector('textarea');
19
+ editor.focus(); editor.value = '@cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
20
+ await new Promise(r=>setTimeout(r,30));
21
+ const atTriggerStayedNative = document.querySelector('[data-harness-mix-mentions]').hidden;
22
+ editor.focus(); editor.value = '你负责后端, #cl'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
23
23
  await new Promise(r=>setTimeout(r,30));
24
24
  const filtered = document.querySelectorAll('[data-harness-mix-mentions] [role="option"]').length;
25
25
  editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
26
- const text = editor.value;
27
- const badge = document.querySelector('[data-harness-mix-selected-mentions] img')?.dataset.harnessMixIcon;
28
- const badgeButton = document.querySelector('[data-harness-mix-selected-mentions] button');
29
- const inline = badgeButton?.parentElement?.nextElementSibling === editor && editor.parentElement?.dataset.harnessMixHasMentions === 'true';
30
- editor.value = '你负责后端, #pi'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
31
- await new Promise(r=>setTimeout(r,30));
32
- editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
33
- const selectedAgentCount = editor.parentElement.querySelectorAll('[data-harness-mix-mention-badge^="agent:"]').length;
26
+ const text = editor.value;
27
+ const badge = document.querySelector('[data-harness-mix-selected-mentions] img')?.dataset.harnessMixIcon;
28
+ const badgeButton = document.querySelector('[data-harness-mix-selected-mentions] button');
29
+ const inline = badgeButton?.parentElement?.nextElementSibling === editor && editor.parentElement?.dataset.harnessMixHasMentions === 'true';
30
+ editor.value = '你负责后端, #pi'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
31
+ await new Promise(r=>setTimeout(r,30));
32
+ editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
33
+ const selectedAgentCount = editor.parentElement.querySelectorAll('[data-harness-mix-mention-badge^="agent:"]').length;
34
34
  const rich = document.querySelector('[contenteditable]'); rich.focus();
35
35
  const selection = getSelection(), range = document.createRange(); range.selectNodeContents(rich); range.collapse(false); selection.removeAllRanges(); selection.addRange(range);
36
36
  document.execCommand('insertText',false,' #pi');
@@ -45,63 +45,71 @@ app.whenReady().then(async () => {
45
45
  if (tabs !== 2) throw new Error('Missing Agents/session tabs');
46
46
  const icons = document.querySelectorAll('[data-harness-mix-mentions] img').length;
47
47
  const disabled = document.querySelectorAll('[data-harness-mix-mentions] [role="option"]:disabled').length;
48
- editor.value='#旧';editor.setSelectionRange(2,2);editor.dispatchEvent(new Event('input',{bubbles:true}));await new Promise(r=>setTimeout(r,30));
49
- document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
50
- const sessionBadge=[...document.querySelectorAll('[data-harness-mix-selected-mentions] [data-harness-mix-mention-badge]')].some(b=>b.title.startsWith('移除历史会话'));
51
- editor.value='请分别实现并审查这个任务。'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
52
- globalThis.mentionSmokeEditor = editor;
53
- const teamPanel = document.querySelector('.harness-mix-team-panel');
54
- const compact = {name:teamPanel?.querySelector('.harness-mix-team-name')?.textContent,panels:document.querySelectorAll('.harness-mix-team-panel').length,button:teamPanel?.querySelector('.harness-mix-team-open')?.textContent,icons:teamPanel?.querySelectorAll('img').length,topMounted:teamPanel?.nextElementSibling?.id === 'thread-content-anchor'};
55
- teamPanel.querySelector('.harness-mix-team-open').click(); await new Promise(r=>setTimeout(r,30));
56
- const workbench=document.querySelector('.harness-mix-team-workbench');
57
- const builder=workbench.querySelector('[data-agent="pi"]'); builder.click(); await new Promise(r=>setTimeout(r,0));
58
- const teamResult = {compact,name:workbench?.querySelector('.harness-mix-team-name')?.textContent,workbenches:document.querySelectorAll('.harness-mix-team-workbench').length,position:getComputedStyle(workbench).position,bodyOverflow:document.body.style.overflow,icons:workbench?.querySelectorAll('img').length,roles:[...workbench?.querySelectorAll('[data-agent]') ?? []].map(node=>node.getAttribute('data-agent')),lanes:workbench?.querySelectorAll('.harness-mix-team-lanes > div').length,hasTask:workbench?.textContent.includes('实现 Team Runtime'),hasRole:workbench?.textContent.includes('后端架构与 Host Runtime')&&workbench?.textContent.includes('独立代码审查与质量验收'),hasLeader:workbench?.textContent.includes('主导者'),hasMessage:workbench?.textContent.includes('Runtime 已就绪'),timeline:workbench?.querySelector('.harness-mix-team-timeline input')?.max,opened:globalThis.openedTeamThread};
59
- globalThis.teamSmoke = teamApi;
60
- return {atTriggerStayedNative,filtered,text,richText,badge,badgeLabel:badgeButton?.getAttribute('aria-label'),inline,selectedAgentCount,icons,disabled,sessionBadge,teamResult};
61
- })()`);
62
- assert.equal(result.atTriggerStayedNative, true);
63
- assert.equal(result.filtered, 1);
48
+ editor.value='#旧';editor.setSelectionRange(2,2);editor.dispatchEvent(new Event('input',{bubbles:true}));await new Promise(r=>setTimeout(r,30));
49
+ document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
50
+ const sessionBadge=[...document.querySelectorAll('[data-harness-mix-selected-mentions] [data-harness-mix-mention-badge]')].some(b=>b.title.startsWith('移除历史会话'));
51
+ editor.value='请分别实现并审查这个任务。'; editor.setSelectionRange(editor.value.length,editor.value.length); editor.dispatchEvent(new Event('input',{bubbles:true}));
52
+ globalThis.mentionSmokeEditor = editor;
53
+ const teamPanel = document.querySelector('.harness-mix-team-panel');
54
+ const compact = {name:teamPanel?.querySelector('.harness-mix-team-name')?.textContent,panels:document.querySelectorAll('.harness-mix-team-panel').length,button:teamPanel?.querySelector('.harness-mix-team-open')?.textContent,icons:teamPanel?.querySelectorAll('img').length,topMounted:teamPanel?.nextElementSibling?.id === 'thread-content-anchor'};
55
+ const chip = teamPanel.querySelector('.harness-mix-team-summary-members .harness-mix-team-clickable');
56
+ chip.click(); await new Promise(r=>setTimeout(r,0));
57
+ const chipOpened = globalThis.openedTeamThread; globalThis.openedTeamThread = null;
58
+ teamPanel.querySelector('.harness-mix-team-open').click(); await new Promise(r=>setTimeout(r,30));
59
+ const workbench=document.querySelector('.harness-mix-team-workbench');
60
+ const builder=workbench.querySelector('[data-agent="pi"]'); builder.click(); await new Promise(r=>setTimeout(r,0));
61
+ const teamResult = {compact,chipOpened,name:workbench?.querySelector('.harness-mix-team-name')?.textContent,workbenches:document.querySelectorAll('.harness-mix-team-workbench').length,position:getComputedStyle(workbench).position,bodyOverflow:document.body.style.overflow,icons:workbench?.querySelectorAll('img').length,roles:[...workbench?.querySelectorAll('[data-agent]') ?? []].map(node=>node.getAttribute('data-agent')),lanes:workbench?.querySelectorAll('.harness-mix-team-lanes > div').length,hasTask:workbench?.textContent.includes('实现 Team Runtime'),hasRole:workbench?.textContent.includes('后端架构与 Host Runtime')&&workbench?.textContent.includes('独立代码审查与质量验收'),hasLeader:workbench?.textContent.includes('主导者'),hasMessage:workbench?.textContent.includes('Runtime 已就绪'),hasEvent:workbench?.textContent.includes('团队建立'),feedItems:workbench?.querySelectorAll('.harness-mix-team-feed-list article').length,workingLanes:workbench?.querySelectorAll('.harness-mix-team-lane[data-status="working"]').length,workingDots:workbench?.querySelectorAll('.harness-mix-team-status-dot[data-status="working"]').length,timeline:workbench?.querySelector('.harness-mix-team-timeline input')?.max,opened:globalThis.openedTeamThread};
62
+ globalThis.teamSmoke = teamApi;
63
+ return {atTriggerStayedNative,filtered,text,richText,badge,badgeLabel:badgeButton?.getAttribute('aria-label'),inline,selectedAgentCount,icons,disabled,sessionBadge,teamResult};
64
+ })()`);
65
+ assert.equal(result.atTriggerStayedNative, true);
66
+ assert.equal(result.filtered, 1);
64
67
  assert.equal(result.text, '你负责后端, ');
65
- assert.equal(result.disabled, 1);
66
- assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3);
67
- assert.match(result.badgeLabel, /^移除协作 Agent /);
68
- assert.equal(result.inline, true);
69
- assert.equal(result.selectedAgentCount, 2);
70
- assert.equal(result.sessionBadge, true);
71
- assert.equal(result.teamResult.name, 'Release Team');
72
- assert.equal(result.teamResult.compact.panels, 1);
73
- assert.equal(result.teamResult.compact.button, '展开详情');
74
- assert.equal(result.teamResult.compact.topMounted, true, 'Agent Team launcher mounts before the native conversation content');
75
- assert.equal(result.teamResult.workbenches, 0, 'Opening a member native session closes the workbench');
76
- assert.notEqual(result.teamResult.position, 'fixed', 'Team details stay embedded in the Codex conversation layout');
77
- assert.equal(result.teamResult.bodyOverflow, '', 'Opening Team details must not lock or cover the native Codex UI');
78
- assert.equal(result.teamResult.opened, 'builder-thread');
79
- assert.ok(result.teamResult.icons >= 5, 'Lead/member/task/message routes use real Harness icons');
80
- assert.deepEqual(result.teamResult.roles, ['codex', 'pi', 'claude', 'codex', 'dsh', 'opencode', 'antigravity']);
81
- assert.equal(result.teamResult.lanes, 6);
82
- assert.equal(result.teamResult.hasTask, true);
83
- assert.equal(result.teamResult.hasRole, true);
84
- assert.equal(result.teamResult.hasLeader, true);
85
- assert.equal(result.teamResult.hasMessage, true);
86
- assert.equal(result.teamResult.timeline, '1');
87
- const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
88
- await win.webContents.executeJavaScript('window.scrollTo(0,0)');
89
- await fs.writeFile(path.join(out, 'team-inline-compact.png'), (await win.webContents.capturePage()).toPNG());
90
- const compactBounds = await win.webContents.executeJavaScript(`(() => { const r=document.querySelector('.harness-mix-team-panel').getBoundingClientRect(); return {x:Math.max(0,Math.floor(r.x)-8),y:Math.max(0,Math.floor(r.y)-8),width:Math.ceil(r.width)+16,height:Math.ceil(r.height)+16}; })()`);
91
- await fs.writeFile(path.join(out, 'team-inline-compact-focus.png'), (await win.webContents.capturePage(compactBounds)).toPNG());
92
- await win.webContents.executeJavaScript(`document.querySelector('.harness-mix-team-open').click()`);
93
- await new Promise(r => setTimeout(r,100));
94
- const expanded = (await win.webContents.capturePage()).toPNG();
95
- await fs.writeFile(path.join(out, 'team-inline-expanded.png'), expanded);
96
- await fs.writeFile(path.join(out, 'mentions.png'), expanded);
97
- const expandedBounds = await win.webContents.executeJavaScript(`(() => { const a=document.querySelector('.harness-mix-team-panel').getBoundingClientRect(), b=document.querySelector('.harness-mix-team-workbench').getBoundingClientRect(), left=Math.min(a.left,b.left), top=Math.min(a.top,b.top), right=Math.max(a.right,b.right), bottom=Math.max(a.bottom,b.bottom); return {x:Math.max(0,Math.floor(left)-8),y:Math.max(0,Math.floor(top)-8),width:Math.ceil(right-left)+16,height:Math.ceil(bottom-top)+16}; })()`);
98
- await win.webContents.executeJavaScript(`document.querySelector('[data-composer-draft-id="textarea-smoke"]').style.visibility='hidden'`);
99
- await fs.writeFile(path.join(out, 'team-inline-expanded-focus.png'), (await win.webContents.capturePage(expandedBounds)).toPNG());
100
- await win.webContents.executeJavaScript(`document.querySelector('[data-composer-draft-id="textarea-smoke"]').style.visibility=''`);
101
- const submittedText = await win.webContents.executeJavaScript(`(() => { const root=mentionSmokeEditor.closest('[data-codex-composer-root]'); mentionSmoke.prepareSubmission(root); return mentionSmokeEditor.value; })()`);
102
- assert.match(submittedText, /#claude/);
103
- assert.match(submittedText, /#pi/);
104
- await win.webContents.executeJavaScript('mentionSmoke.dispose(); teamSmoke.dispose()');
68
+ assert.equal(result.disabled, 1);
69
+ assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3);
70
+ assert.match(result.badgeLabel, /^移除协作 Agent /);
71
+ assert.equal(result.inline, true);
72
+ assert.equal(result.selectedAgentCount, 2);
73
+ assert.equal(result.sessionBadge, true);
74
+ assert.equal(result.teamResult.name, 'Release Team');
75
+ assert.equal(result.teamResult.compact.panels, 1);
76
+ assert.equal(result.teamResult.compact.button, '展开详情');
77
+ assert.equal(result.teamResult.compact.topMounted, true, 'Agent Team launcher mounts before the native conversation content');
78
+ assert.equal(result.teamResult.workbenches, 0, 'Opening a member native session closes the workbench');
79
+ assert.notEqual(result.teamResult.position, 'fixed', 'Team details stay embedded in the Codex conversation layout');
80
+ assert.equal(result.teamResult.bodyOverflow, '', 'Opening Team details must not lock or cover the native Codex UI');
81
+ assert.equal(result.teamResult.opened, 'builder-thread');
82
+ assert.equal(result.teamResult.chipOpened, 'builder-thread', 'Summary member chip opens the member native session');
83
+ assert.equal(result.teamResult.hasEvent, true, 'Feed renders team history events');
84
+ assert.equal(result.teamResult.feedItems, 2, 'Feed merges messages and history events');
85
+ assert.ok(result.teamResult.workingLanes >= 2, 'Working member lanes carry live connector animation');
86
+ assert.ok(result.teamResult.workingDots >= 3, 'Lead and working members get pulsing status dots');
87
+ assert.ok(result.teamResult.icons >= 5, 'Lead/member/task/message routes use real Harness icons');
88
+ assert.deepEqual(result.teamResult.roles, ['codex', 'pi', 'claude', 'codex', 'dsh', 'opencode', 'antigravity']);
89
+ assert.equal(result.teamResult.lanes, 6);
90
+ assert.equal(result.teamResult.hasTask, true);
91
+ assert.equal(result.teamResult.hasRole, true);
92
+ assert.equal(result.teamResult.hasLeader, true);
93
+ assert.equal(result.teamResult.hasMessage, true);
94
+ assert.equal(result.teamResult.timeline, '1');
95
+ const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
96
+ await win.webContents.executeJavaScript('window.scrollTo(0,0)');
97
+ await fs.writeFile(path.join(out, 'team-inline-compact.png'), (await win.webContents.capturePage()).toPNG());
98
+ const compactBounds = await win.webContents.executeJavaScript(`(() => { const r=document.querySelector('.harness-mix-team-panel').getBoundingClientRect(); return {x:Math.max(0,Math.floor(r.x)-8),y:Math.max(0,Math.floor(r.y)-8),width:Math.ceil(r.width)+16,height:Math.ceil(r.height)+16}; })()`);
99
+ await fs.writeFile(path.join(out, 'team-inline-compact-focus.png'), (await win.webContents.capturePage(compactBounds)).toPNG());
100
+ await win.webContents.executeJavaScript(`document.querySelector('.harness-mix-team-open').click()`);
101
+ await new Promise(r => setTimeout(r,100));
102
+ const expanded = (await win.webContents.capturePage()).toPNG();
103
+ await fs.writeFile(path.join(out, 'team-inline-expanded.png'), expanded);
104
+ await fs.writeFile(path.join(out, 'mentions.png'), expanded);
105
+ const expandedBounds = await win.webContents.executeJavaScript(`(() => { const a=document.querySelector('.harness-mix-team-panel').getBoundingClientRect(), b=document.querySelector('.harness-mix-team-workbench').getBoundingClientRect(), left=Math.min(a.left,b.left), top=Math.min(a.top,b.top), right=Math.max(a.right,b.right), bottom=Math.max(a.bottom,b.bottom); return {x:Math.max(0,Math.floor(left)-8),y:Math.max(0,Math.floor(top)-8),width:Math.ceil(right-left)+16,height:Math.ceil(bottom-top)+16}; })()`);
106
+ await win.webContents.executeJavaScript(`document.querySelector('[data-composer-draft-id="textarea-smoke"]').style.visibility='hidden'`);
107
+ await fs.writeFile(path.join(out, 'team-inline-expanded-focus.png'), (await win.webContents.capturePage(expandedBounds)).toPNG());
108
+ await win.webContents.executeJavaScript(`document.querySelector('[data-composer-draft-id="textarea-smoke"]').style.visibility=''`);
109
+ const submittedText = await win.webContents.executeJavaScript(`(() => { const root=mentionSmokeEditor.closest('[data-codex-composer-root]'); mentionSmoke.prepareSubmission(root); return mentionSmokeEditor.value; })()`);
110
+ assert.match(submittedText, /#claude/);
111
+ assert.match(submittedText, /#pi/);
112
+ await win.webContents.executeJavaScript('mentionSmoke.dispose(); teamSmoke.dispose()');
105
113
  assert.equal(await win.webContents.executeJavaScript('document.querySelectorAll("[data-harness-mix-mentions]").length'), 0);
106
114
  console.log('PASS: Electron native composer # Harness/session icons, badges, keyboard selection, textarea/rich text and cleanup');
107
115
  } finally { win.destroy(); app.quit(); }
@@ -89,11 +89,33 @@ async function until(fn) {
89
89
  assert.equal(review1.concurrent, true, 'review1 should record concurrent execution');
90
90
  assert.ok(review1.note.includes('并发任务'), 'review note should explain concurrency');
91
91
 
92
+ // 6.5 污染防护:并发期间 thread2 的 Harness 上报了带 path 的编辑事件,
93
+ // 该文件只能出现在 thread2 的审查里,不得污染 thread1 的回合卡片
94
+ await rt.send(thread1.id, 'task 1 turn 3 (no own edits)');
95
+ await rt.send(thread2.id, 'task 2 edits foreign file');
96
+ const emit2b = emits.get(thread2.id);
97
+ await fs.writeFile(path.join(root, 'foreign.txt'), 'edited by thread2\n');
98
+ emit2b({ kind: 'tool', toolCallId: 'edit-foreign', title: 'edit', state: 'done', path: 'foreign.txt' });
99
+ emit2b({ kind: 'text-delta', text: 'thread2 edited foreign.txt' });
100
+ emit2b({ kind: 'completed', finalAnswer: true });
101
+ await until(() => thread2.status === 'ready' && !thread2.reviewPending);
102
+ const msg2b = thread2.messages.at(-1);
103
+ const review2b = await rt.readReview(thread2, msg2b);
104
+ assert.ok(review2b.files.some(f => f.path === 'foreign.txt'), 'thread2 review should include its own attributed edit');
105
+
106
+ const emit1b = emits.get(thread1.id);
107
+ emit1b({ kind: 'text-delta', text: 'thread1 turn 3 finished without edits' });
108
+ emit1b({ kind: 'completed', finalAnswer: true });
109
+ await until(() => thread1.status === 'ready' && !thread1.reviewPending);
110
+ const msg1b = thread1.messages.at(-1);
111
+ const review1b = await rt.readReview(thread1, msg1b);
112
+ assert.ok(!review1b.files.some(f => f.path === 'foreign.txt'), 'foreign attributed edit must not pollute thread1 review');
113
+
92
114
  // 7. 全部空闲后,安全撤回可以正常执行
93
115
  await rt.undoFile(thread1.id, msg0.id, 'base.txt');
94
116
  await assert.rejects(fs.access(path.join(root, 'base.txt')));
95
117
 
96
- console.log('core-concurrency-test: same-cwd concurrency, review degradation, and undo guard passed');
118
+ console.log('core-concurrency-test: same-cwd concurrency, review degradation, cross-session pollution guard and undo guard passed');
97
119
  } finally {
98
120
  await rt.close();
99
121
  }
@@ -0,0 +1,99 @@
1
+ // 真实链路回归(起真实 pi 进程、走真实模型,类似 e2e:pi):
2
+ // 场景:会话执行完后追加「图片+文字」,用户 1 秒内停止(落在 prompt preflight 窗口),
3
+ // 再发第二条消息。修复后期望:
4
+ // 1) 原生侧无僵尸运行——被取消的 run 在启动后立即被补停(assistant stopReason=aborted,
5
+ // 且 agent_settled 很快到达),不会在用户看不见的情况下跑完整个任务;
6
+ // 2) 被取消的消息(含图片)仍持久化在原生会话历史中,后续轮次的上下文能看到它。
7
+ const { HostRuntime } = require("../src/main/host/runtime");
8
+ const os = require("node:os");
9
+ const path = require("node:path");
10
+ const fs = require("node:fs");
11
+
12
+ const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+
15
+ (async () => {
16
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "hm-cancel-race-"));
17
+ const rt = new HostRuntime({ dataDirectory: path.join(os.tmpdir(), "hm-e2e-pi-cancel-" + Date.now()) });
18
+ await rt.initialize();
19
+ const thread = await rt.createThread({ harnessId: "pi", cwd, title: "取消竞态回归" });
20
+ if (thread.status === "error") throw new Error("open failed: " + thread.error);
21
+ const id = thread.id;
22
+ const status = () => rt.threads.find((t) => t.id === id)?.status;
23
+
24
+ // 预热一轮,制造「会话执行完之后」的状态(历史存在、原生会话已打开)
25
+ await rt.send(id, "只回复两个字:收到");
26
+ for (let i = 0; i < 90 && status() === "working"; i++) await sleep(1000);
27
+ if (status() !== "ready") throw new Error("预热轮未就绪: " + status());
28
+ console.log("预热完成,原生会话:", rt.threads.find((t) => t.id === id).nativeSessionId);
29
+
30
+ const session = rt.sessions.get(id);
31
+ if (!session) throw new Error("原生会话未保持打开");
32
+ // 旁路记录原生事件(不改 Host 既有 hooks 行为)
33
+ const nativeEvents = [];
34
+ const originalOnEvent = session.process.hooks.onEvent;
35
+ session.process.hooks.onEvent = (e) => {
36
+ nativeEvents.push(e);
37
+ if (["agent_start", "agent_end", "agent_settled", "turn_start", "turn_end"].includes(e.type)) console.log("[native]", e.type, e.message?.stopReason || "");
38
+ if (e.type === "message_end") console.log("[native] message_end", e.message?.role, e.message?.stopReason || "");
39
+ return originalOnEvent?.(e);
40
+ };
41
+
42
+ // 关键场景:图片+文字发出后 ~30ms 用户停止(落在投递/preflight 窗口内)
43
+ const sendP = rt.send(id, "我说的是codex的这个宠物不是另外搞一个宠物 点击显示宠物是codex这个错误", {
44
+ attachments: [{ name: "shot.png", kind: "image", mime: "image/png", data: PNG_B64 }],
45
+ }).catch((e) => console.log("send#1 rejected:", e.message));
46
+ const cancelAt = Date.now();
47
+ await sleep(30);
48
+ await rt.cancel(id);
49
+ await sendP;
50
+ console.log("已停止(+" + (Date.now() - cancelAt) + "ms),等待原生侧收尾…");
51
+
52
+ // 修复后:agent_settled 应在 25s 内到达(补停生效);僵尸运行则会长时间静默执行整个任务
53
+ const settled = await (async () => {
54
+ const deadline = Date.now() + 25_000;
55
+ while (Date.now() < deadline) {
56
+ if (nativeEvents.some((e) => e.type === "agent_settled")) return true;
57
+ await sleep(250);
58
+ }
59
+ return false;
60
+ })();
61
+ if (!settled) throw new Error("取消后 25s 内原生 run 未收尾——疑似僵尸运行(补停未生效)");
62
+ console.log("原生 run 已收尾(+" + (Date.now() - cancelAt) + "ms)");
63
+
64
+ const data = await session.process.command({ type: "get_entries" });
65
+ const msgs = data.entries.filter((e) => e.type === "message");
66
+ const firstIdx = msgs.findIndex((e) => e.message.role === "user" && JSON.stringify(e.message.content).includes("宠物不是另外搞一个"));
67
+ if (firstIdx < 0) throw new Error("被取消的「图片+文字」消息未持久化到原生会话历史");
68
+ const first = msgs[firstIdx].message;
69
+ const hasImage = Array.isArray(first.content) && first.content.some((b) => b.type === "image");
70
+ if (!hasImage) throw new Error("被取消消息的图片块未进入原生会话历史");
71
+ console.log("第一条消息(含图片)已在原生历史中 ✓");
72
+ const after = msgs.slice(firstIdx + 1);
73
+ const zombie = after.find((e) => e.message.role === "assistant" && (e.message.stopReason === "stop" || e.message.stopReason === "toolUse"));
74
+ if (zombie) throw new Error("被取消的 run 仍然跑完了(assistant stopReason=" + zombie.message.stopReason + ")——僵尸运行未消除");
75
+ const toolResult = after.find((e) => e.message.role === "toolResult");
76
+ if (toolResult) throw new Error("被取消的 run 执行了工具调用——僵尸运行未消除");
77
+ console.log("无僵尸运行痕迹(无完成的 assistant / toolResult)✓");
78
+
79
+ // 第二条消息:应作为正常新轮次执行(而非排到僵尸之后),且上下文中能看到第一条消息
80
+ await rt.send(id, "一句话回答:我上面那条带图片的消息里说的宠物是什么?不要调用任何工具");
81
+ for (let i = 0; i < 120 && status() === "working"; i++) {
82
+ await sleep(1000);
83
+ if (i === 30) console.log("30s 仍在执行,原生事件数:", nativeEvents.length, "isRunning:", rt.execution.isRunning(id));
84
+ }
85
+ if (status() !== "ready") throw new Error("第二条消息的轮次未正常结束: " + status());
86
+ const data2 = await session.process.command({ type: "get_entries" });
87
+ const msgs2 = data2.entries.filter((e) => e.type === "message");
88
+ const second = msgs2.find((e, i) => i > firstIdx && e.message.role === "user" && Array.isArray(e.message.content) && e.message.content.some((b) => b.type === "text" && b.text.includes("一句话回答")));
89
+ if (!second) throw new Error("第二条消息「宠物」未进入原生会话历史");
90
+ const answer = msgs2.slice(msgs2.indexOf(second) + 1).find((e) => e.message.role === "assistant");
91
+ const answerText = answer && Array.isArray(answer.message.content) ? answer.message.content.filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
92
+ console.log("第二条消息已作答(stopReason=" + (answer?.message.stopReason || "?") + "):", answerText.slice(0, 120).replace(/\n/g, " "));
93
+ if (answer && answer.message.stopReason !== "stop") throw new Error("第二条消息的回复异常中止: " + answer.message.stopReason);
94
+ console.log("context 顺序:图片消息 → abort 痕迹 → 「宠物」→ 回复,后续轮次可见第一条消息 ✓");
95
+
96
+ await rt.close();
97
+ console.log("E2E PASS: pi cancel-during-preflight race fixed");
98
+ process.exit(0);
99
+ })().catch((e) => { console.error("E2E FAILED:", e.message); process.exit(1); });
@@ -12,8 +12,9 @@ const { canonicalChanges } = require('../src/main/workspace/file-changes');
12
12
  const rt = new HostRuntime({ dataDirectory: directory });
13
13
  await rt.store.load();
14
14
  let emit;
15
+ const emitters = new Map();
15
16
  rt.adapters.set('test', { manifest: { id: 'test', name: 'Test', capabilities: {} },
16
- async open(input) { emit = input.emit; return {}; }, async send() {}, async close() {} });
17
+ async open(input) { emit = input.emit; emitters.set(input.thread.id, input.emit); return {}; }, async send() {}, async close() {} });
17
18
  rt.status.test = { available: true };
18
19
  const apply = events => { for (const event of [events].flat().filter(Boolean)) emit(event); };
19
20
  try {
@@ -75,6 +76,18 @@ const { canonicalChanges } = require('../src/main/workspace/file-changes');
75
76
  const incomplete = files.find(file => file.path === 'd.txt');
76
77
  assert.equal(incomplete.source, 'snapshot', 'contentless native hint must not block the workspace snapshot');
77
78
  assert.equal(incomplete.after, 'disk\n');
79
+ // 工作区外改动(如 Claude 写入计划文件/全局配置/其他盘符):保留展示并标记,
80
+ // 绝不能使回合失败或原生会话崩溃(历史 bug:canonicalChanges 抛错沿 emit 同步
81
+ // 回到 Adapter 事件泵 → 会话误判 crashed → 后续 send 全部“原生会话不可用”)。
82
+ const outsidePath = path.join(directory, 'outside.txt');
83
+ await fs.writeFile(outsidePath, 'outside\n');
84
+ emit({ kind: 'file-change', source: 'native', changes: [{ path: outsidePath, before: '', after: 'outside\n', complete: true }] });
85
+ emit({ kind: 'file-change', source: 'native', changes: [{ before: 'x', after: 'y' }] }); // 无路径:静默丢弃
86
+ assert.equal(thread.error, undefined);
87
+ files = (await rt.readReview(thread, message)).files;
88
+ const outside = files.find(file => file.outsideWorkspace);
89
+ assert.equal(outside.path, outsidePath.replace(/\\/g, '/'));
90
+ assert.equal(outside.after, 'outside\n');
78
91
  emit({ kind: 'completed', finalAnswer: true });
79
92
  for (let i = 0; i < 200 && thread.reviewPending; i++) await new Promise(resolve => setTimeout(resolve, 10));
80
93
  assert.equal(thread.reviewPending, false);
@@ -86,9 +99,36 @@ const { canonicalChanges } = require('../src/main/workspace/file-changes');
86
99
  const beforeLate = structuredClone(message.coreItems);
87
100
  emit({ kind: 'file-change', changes: [{ path: 'late.txt', before: '', after: 'late' }] });
88
101
  assert.deepEqual(message.coreItems, beforeLate, 'late native diff cannot mutate a settled turn');
89
- assert.throws(() => canonicalChanges(root, [{ path: '../escape.txt' }]), /outside/);
102
+ const escaped = canonicalChanges(root, [{ path: '../escape.txt', before: '', after: 'x' }]);
103
+ assert.equal(escaped.length, 1);
104
+ assert.equal(escaped[0].outsideWorkspace, true, 'outside-workspace change is kept and flagged, not thrown');
105
+ assert.equal(escaped[0].path, path.resolve(root, '../escape.txt').replace(/\\/g, '/'));
106
+ assert.equal(canonicalChanges(root, [{ before: 'a' }]).length, 0, 'pathless change is dropped');
90
107
  await rt.undoFile(thread.id, message.id, 'a.txt');
91
108
  assert.equal(await fs.readFile(path.join(root, 'a.txt'), 'utf8'), 'original\n');
109
+ // 会话未被上述异常事件破坏:同一会话可继续新回合并正常结算。
110
+ await rt.send(thread.id, 'after outside change');
111
+ emit({ kind: 'completed', finalAnswer: true });
112
+ for (let i = 0; i < 200 && thread.reviewPending; i++) await new Promise(resolve => setTimeout(resolve, 10));
113
+ assert.notEqual(thread.status, 'error');
114
+ assert.equal(thread.error, undefined);
115
+ // 并发同目录、无原生 patch 的 Harness:各回合审查按工具触碰路径归属,
116
+ // 另一个会话的改动不混入本回合卡片(原生 Codex 的每会话独立 diff 行为)。
117
+ const w1 = await rt.createThread({ harnessId: 'test', cwd: root });
118
+ const w2 = await rt.createThread({ harnessId: 'test', cwd: root });
119
+ await rt.send(w1.id, 'task one');
120
+ await rt.send(w2.id, 'task two');
121
+ const m1 = w1.messages.at(-1), m2 = w2.messages.at(-1);
122
+ assert.equal(m1.concurrent, true); assert.equal(m2.concurrent, true);
123
+ emitters.get(w1.id)({ kind: 'tool', toolCallId: 'w1-edit', title: 'edit', state: 'done', path: path.join(root, 'w1.txt') });
124
+ emitters.get(w2.id)({ kind: 'tool', toolCallId: 'w2-edit', title: 'edit', state: 'done', path: path.join(root, 'w2.txt') });
125
+ await fs.writeFile(path.join(root, 'w1.txt'), 'one\n');
126
+ await fs.writeFile(path.join(root, 'w2.txt'), 'two\n');
127
+ emitters.get(w1.id)({ kind: 'completed', finalAnswer: true });
128
+ emitters.get(w2.id)({ kind: 'completed', finalAnswer: true });
129
+ for (let i = 0; i < 200 && (w1.reviewPending || w2.reviewPending); i++) await new Promise(resolve => setTimeout(resolve, 10));
130
+ assert.deepEqual(m1.review.files.map(file => file.path), ['w1.txt'], 'w1 只归属本轮触碰的文件');
131
+ assert.deepEqual(m2.review.files.map(file => file.path), ['w2.txt'], 'w2 只归属本轮触碰的文件');
92
132
  console.log('native-file-change: native adapters -> Core -> review, repeated edits, snapshot fallback, undo, late events passed');
93
133
  } finally { await rt.close(); }
94
134
  })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -15,6 +15,14 @@ for (const query of [{}, { sectionId: null }, { cwd: ['E:/project'] }, { searchT
15
15
  }
16
16
  assert.equal(includesThread({ ...thread, ephemeral: true }, {}), false);
17
17
  assert.equal(includesThread({ ...thread, section: { id: 'pinned' } }, { sectionId: 'pinned' }), true);
18
+ // 协作子任务线程:按 subAgentThreadSpawn 来源匹配(Desktop 点击子代理入口的查询),
19
+ // 且不再匹配 vscode 来源;根线程仍不匹配 subAgentThreadSpawn(上方已断言)。
20
+ const child = { ...thread, id: 'child', parentThreadId: 'external' };
21
+ assert.equal(includesThread(child, { sourceKinds: ['subAgentThreadSpawn'] }), true);
22
+ assert.equal(includesThread(child, { sourceKinds: ['vscode'] }), false);
23
+ assert.equal(includesThread(child, { ancestorThreadId: 'external', sourceKinds: ['subAgentThreadSpawn'] }), true);
24
+ assert.equal(includesThread(child, { parentThreadId: 'external', sourceKinds: ['subAgentThreadSpawn'] }), true);
25
+ assert.equal(includesThread(child, {}), true, '无来源过滤时子线程照常列出');
18
26
  const page = { data: [{ id: 'official', createdAt: 20 }, thread], nextCursor: 'next' };
19
27
  const merged = mergeThreadPage(page, [thread], {}, t => t);
20
28
  assert.deepEqual(merged.data.map(t => t.id), ['official', 'external']);
@@ -84,5 +84,71 @@ const pi = require('../src/main/adapters/pi');
84
84
  const projected = projectItem({ id: 'norm-item-1', type: 'context_compaction' });
85
85
  assert.deepEqual(projected, { id: 'norm-item-1', type: 'contextCompaction' });
86
86
 
87
+ // Pi 自动重试(stream 错误如 "Anthropic stream ended without a stop reason"):
88
+ // message_end 的 error 不得提前定论为 Turn 失败——agent_end 携带 willRetry,
89
+ // 重试成功的回答必须完整落地,全程零 error 事件(否则 Host 提前结算,
90
+ // 重试结果被丢弃,用户重发还会撞上原生 "Agent is already processing")。
91
+ {
92
+ const emitted = [];
93
+ const fakeProc = { harnessMixAnswer: '', command: async () => ({ leafId: 'leaf-1' }) };
94
+ const fwd = (event) => pi.forwardEvent(fakeProc, event, e => emitted.push(e));
95
+ const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Anthropic stream ended without a stop reason' };
96
+ fwd({ type: 'message_end', message: failedMsg });
97
+ assert.equal(emitted.filter(e => e.kind === 'error').length, 0, 'message_end 不得提前报错');
98
+ fwd({ type: 'agent_end', willRetry: true, messages: [{ role: 'user', content: [{ type: 'text', text: '登录好了' }] }, failedMsg] });
99
+ assert.equal(emitted.at(-1).kind, 'status');
100
+ fwd({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000, errorMessage: failedMsg.errorMessage });
101
+ assert.equal(emitted.at(-1).kind, 'status');
102
+ fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '好的,' } });
103
+ fwd({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '开始执行。' } });
104
+ fwd({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' } });
105
+ assert.equal(emitted.filter(e => e.kind === 'text-delta').map(e => e.text).join(''), '好的,开始执行。', '已流式回答不得重复补发');
106
+ fwd({ type: 'auto_retry_end', success: true, attempt: 1 });
107
+ fwd({ type: 'agent_end', willRetry: false, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的,开始执行。' }], stopReason: 'stop' }] });
108
+ fwd({ type: 'agent_settled', sessionId: 's-retry' });
109
+ await new Promise(resolve => setImmediate(resolve));
110
+ assert.equal(emitted.at(-1).kind, 'completed');
111
+ assert.equal(emitted.at(-1).nativeRef.checkpointId, 'leaf-1');
112
+ assert.equal(emitted.filter(e => e.kind === 'error').length, 0, '重试成功全程不得出现 error');
113
+ }
114
+ // Pi 重试耗尽/不可重试:error 在 agent_end(willRetry:false)处恰好结算一次
115
+ {
116
+ const emitted = [];
117
+ const fwd = (event) => pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) }, event, e => emitted.push(e));
118
+ const failedMsg = { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'native quota exhausted' };
119
+ fwd({ type: 'message_end', message: failedMsg });
120
+ assert.equal(emitted.filter(e => e.kind === 'error').length, 0);
121
+ fwd({ type: 'agent_end', willRetry: false, messages: [failedMsg] });
122
+ assert.equal(emitted.filter(e => e.kind === 'error').length, 1);
123
+ assert.match(emitted.find(e => e.kind === 'error').message, /quota exhausted/);
124
+ }
125
+ // Pi 未流式回答的 message_end 回退补发仍然有效
126
+ {
127
+ const emitted = [];
128
+ pi.forwardEvent({ harnessMixAnswer: '', command: async () => ({}) },
129
+ { type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '非流式回答' }], stopReason: 'stop' } },
130
+ e => emitted.push(e));
131
+ assert.ok(emitted.some(e => e.kind === 'text-delta' && e.text === '非流式回答'));
132
+ }
133
+ // Pi send():原生拒绝 "already processing"(Host 与原生状态偶发分叉,如取消竞态)时
134
+ // 按原生协议提示以 followUp 排队重发;其他错误原样抛出
135
+ {
136
+ const calls = [];
137
+ let rejectOnce = true;
138
+ const busySession = { process: { harnessMixAnswer: '', command: async (payload) => {
139
+ calls.push(payload);
140
+ if (rejectOnce && payload.type === 'prompt') { rejectOnce = false; throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); }
141
+ return {};
142
+ } } };
143
+ await pi.create().send(busySession, '登录好了', { emit: () => {} }, { images: [{ data: 'AA==', mime: 'image/png' }] });
144
+ assert.equal(calls.length, 2);
145
+ assert.equal(calls[0].streamingBehavior, undefined);
146
+ assert.equal(calls[1].streamingBehavior, 'followUp');
147
+ assert.equal(calls[1].message, '登录好了');
148
+ assert.deepEqual(calls[1].images, [{ type: 'image', data: 'AA==', mimeType: 'image/png' }]);
149
+ const failingSession = { process: { harnessMixAnswer: '', command: async () => { throw new Error('Authentication failed'); } } };
150
+ await assert.rejects(pi.create().send(failingSession, 'hi', { emit: () => {} }, {}), /Authentication failed/);
151
+ }
152
+
87
153
  console.log('Native adapters: streaming deduplication, user suppression, question retry, exact native approvals, sanitized usage, grok images, grok compaction & UI projection PASS');
88
- })().catch(error => { console.error(error); process.exitCode = 1; });
154
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -69,7 +69,9 @@ function webpPayload(label) {
69
69
  const env = {
70
70
  HARNESSMIX_STOCK_CODEX_PATH: fakeCodexBin,
71
71
  HARNESS_MIX_PETS_DIR: petsDir,
72
+ HARNESSMIX_DATA_DIR: path.join(testRoot, 'data-dir'),
72
73
  };
74
+ const selectionFile = path.join(env.HARNESSMIX_DATA_DIR, 'pet-selection.json');
73
75
 
74
76
  // 本地 HTTP 服务器模拟社区下载源,覆盖 成功/500/坏签名/超大/截断/慢响应 场景
75
77
  const server = http.createServer((req, res) => {
@@ -357,6 +359,67 @@ function webpPayload(label) {
357
359
  'x',
358
360
  );
359
361
 
362
+ // 19. 选择状态:空选择 -> 设置官方预载 -> 持久化跨实例 -> 非法/未知 id 拒绝 -> 清除
363
+ assert.deepEqual(market.selection(), { id: null }, 'no selection initially');
364
+
365
+ const officialSelection = market.select({ id: 'synthetic-buddy' });
366
+ assert.equal(officialSelection.id, 'synthetic-buddy');
367
+ assert.equal(officialSelection.displayName, 'Synthetic Buddy');
368
+ assert.deepEqual(market.selection(), officialSelection, 'selection round-trips');
369
+
370
+ // 持久化文件落在数据目录内,包含渲染所需的 displayName,且无临时文件残留
371
+ const persisted = JSON.parse(fs.readFileSync(selectionFile, 'utf8'));
372
+ assert.equal(persisted.id, 'synthetic-buddy');
373
+ assert.equal(persisted.displayName, 'Synthetic Buddy');
374
+ assert.ok(typeof persisted.selectedAt === 'string' && persisted.selectedAt, 'selectedAt recorded');
375
+ assert.ok(!fs.existsSync(`${selectionFile}.tmp`), 'atomic tmp file renamed away');
376
+
377
+ // 跨实例持久化:同一 env 新建 market 能读到选择
378
+ const marketReboot = createPetMarket({ env });
379
+ assert.deepEqual(marketReboot.selection(), officialSelection, 'selection survives a new market instance');
380
+
381
+ // 已安装宠物可选,选择随之切换
382
+ const installedSelection = market.select({ id: 'community-pet' });
383
+ assert.equal(installedSelection.id, 'community-pet');
384
+ assert.equal(installedSelection.displayName, 'Community Pet');
385
+ assert.equal(market.selection().id, 'community-pet');
386
+
387
+ // 非法 id / 未知 id 一律拒绝,且不影响当前选择
388
+ assert.throws(() => market.select({ id: '../evil' }), /Invalid pet id/);
389
+ assert.throws(() => market.select({ id: 'UPPERCASE' }), /Invalid pet id/);
390
+ assert.throws(() => market.select({ id: 'a/b' }), /Invalid pet id/);
391
+ assert.throws(() => market.select({ id: 'ghost-pet' }), /Unknown pet: ghost-pet/);
392
+ assert.equal(market.selection().id, 'community-pet', 'rejected selects keep current selection');
393
+ assert.ok(!fs.existsSync(path.join(testRoot, 'evil')), 'selection cannot escape the data dir');
394
+
395
+ // 清除选择
396
+ assert.deepEqual(market.select({ id: null }), { id: null });
397
+ assert.deepEqual(market.selection(), { id: null });
398
+ assert.equal(JSON.parse(fs.readFileSync(selectionFile, 'utf8')).id, null);
399
+
400
+ // 卸载当前选中的桌宠会自动清除选择
401
+ market.select({ id: 'community-pet' });
402
+ assert.equal(market.selection().id, 'community-pet');
403
+ market.uninstall({ id: 'community-pet' });
404
+ assert.deepEqual(market.selection(), { id: null }, 'uninstalling the selected pet clears selection');
405
+ assert.equal(JSON.parse(fs.readFileSync(selectionFile, 'utf8')).id, null);
406
+
407
+ // 过期容忍:持久化的选择指向外部删除的宠物时按无选择处理
408
+ market.select({ id: 'broken-pet' });
409
+ assert.equal(market.selection().id, 'broken-pet');
410
+ fs.rmSync(path.join(petsDir, 'broken-pet'), { recursive: true, force: true });
411
+ assert.deepEqual(market.selection(), { id: null }, 'stale selection resolves to no selection');
412
+
413
+ // rename 失败(目标路径被同名目录占用)时抛错且不残留 .tmp 暂存文件
414
+ const blockedDataDir = path.join(testRoot, 'blocked-data');
415
+ fs.mkdirSync(path.join(blockedDataDir, 'pet-selection.json'), { recursive: true });
416
+ const blockedMarket = createPetMarket({ env: { ...env, HARNESSMIX_DATA_DIR: blockedDataDir } });
417
+ assert.throws(() => blockedMarket.select({ id: 'synthetic-buddy' }));
418
+ assert.ok(
419
+ !fs.existsSync(path.join(blockedDataDir, 'pet-selection.json.tmp')),
420
+ 'rename failure must not leave a tmp file behind',
421
+ );
422
+
360
423
  console.log('Pets market native tests passed successfully!');
361
424
  } finally {
362
425
  try { server.closeAllConnections(); } catch { /* Node < 18.2 无此 API */ }