@harness-mix/cli 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +12 -0
- package/README.md +51 -43
- package/docs/harness-management.md +1 -1
- package/docs/native-acp.md +7 -2
- package/docs/task-handoff-design.md +65 -0
- package/licenses/codex-styler-Apache-2.0.txt +193 -0
- package/licenses/heige-codex-skin-studio-MIT.txt +22 -0
- package/output/native-build/desktop-controller.mjs +2 -1
- package/output/native-build/renderer-extension.js +651 -9
- package/package.json +188 -188
- package/scripts/acp-capabilities-probe.cjs +1 -0
- package/scripts/build-native.cjs +1 -1
- package/scripts/codex-adapter-test.cjs +44 -0
- package/scripts/collaboration-recovery-test.cjs +3 -0
- package/scripts/e2e-codex.cjs +3 -1
- package/scripts/e2e-native-acp.cjs +1 -1
- package/scripts/error-kind-test.cjs +107 -0
- package/scripts/handoff-checkpoint-test.cjs +38 -0
- package/scripts/integrations-test.cjs +5 -2
- package/scripts/kiro-cursor-adapters-test.cjs +18 -0
- package/scripts/native-acp-depth-test.cjs +1 -1
- package/scripts/native-protocol-test.cjs +60 -4
- package/scripts/native-ui-smoke.cjs +92 -2
- package/scripts/switch-harness-test.cjs +79 -12
- package/scripts/tool-concurrency-regression-test.cjs +8 -6
- package/src/assets/icons/cline-color.svg +1 -0
- package/src/assets/skins/codex-styler/ASSET_LICENSES.md +36 -0
- package/src/assets/skins/codex-styler/NOTICE +11 -0
- package/src/assets/skins/codex-styler/themes/gilded-grandeur/hero.webp +0 -0
- package/src/assets/skins/codex-styler/themes/merry-big-top/hero.webp +0 -0
- package/src/assets/skins/codex-styler/themes/nocturne-studio/hero.webp +0 -0
- package/src/assets/skins/codex-styler/themes/quiet-garden/hero.webp +0 -0
- package/src/assets/skins/heige/ASSET_PROVENANCE.md +56 -0
- package/src/assets/skins/heige/NOTICE.md +13 -0
- package/src/assets/skins/heige/themes/dalao-dianyan/hero.webp +0 -0
- package/src/assets/skins/heige/themes/dalao-dianyan/theme.json +14 -0
- package/src/assets/skins/heige/themes/deepspace-dawn/hero.webp +0 -0
- package/src/assets/skins/heige/themes/deepspace-dawn/theme.json +14 -0
- package/src/assets/skins/heige/themes/deepspace-star/hero.webp +0 -0
- package/src/assets/skins/heige/themes/deepspace-star/theme.json +14 -0
- package/src/assets/skins/heige/themes/dragonball-nimbus/hero.webp +0 -0
- package/src/assets/skins/heige/themes/dragonball-nimbus/theme.json +16 -0
- package/src/assets/skins/heige/themes/dragonball-super-saiyan/hero.webp +0 -0
- package/src/assets/skins/heige/themes/dragonball-super-saiyan/theme.json +16 -0
- package/src/assets/skins/heige/themes/genshin-dawn/hero.webp +0 -0
- package/src/assets/skins/heige/themes/genshin-dawn/theme.json +14 -0
- package/src/assets/skins/heige/themes/genshin-night/hero.webp +0 -0
- package/src/assets/skins/heige/themes/genshin-night/theme.json +14 -0
- package/src/assets/skins/heige/themes/miku-488137/hero.webp +0 -0
- package/src/assets/skins/heige/themes/miku-488137/logo.webp +0 -0
- package/src/assets/skins/heige/themes/miku-488137/polaroid.webp +0 -0
- package/src/assets/skins/heige/themes/miku-488137/theme.json +16 -0
- package/src/assets/skins/heige/themes/naruto-hokage/hero.webp +0 -0
- package/src/assets/skins/heige/themes/naruto-hokage/theme.json +14 -0
- package/src/assets/skins/heige/themes/naruto-sasuke/hero.webp +0 -0
- package/src/assets/skins/heige/themes/naruto-sasuke/theme.json +14 -0
- package/src/assets/skins/heige/themes/wuthering-echo/hero.webp +0 -0
- package/src/assets/skins/heige/themes/wuthering-echo/theme.json +14 -0
- package/src/assets/skins/heige/themes/wuthering-tide/hero.webp +0 -0
- package/src/assets/skins/heige/themes/wuthering-tide/theme.json +14 -0
- package/src/main/adapters/cline.js +22 -0
- package/src/main/adapters/codex.js +78 -8
- package/src/main/adapters/index.js +2 -1
- package/src/main/adapters/native-acp-command.js +1 -0
- package/src/main/harness-adapter/error-kind.js +82 -0
- package/src/main/harness-adapter/event-normalizer.js +7 -1
- package/src/main/host/core-session.js +1 -1
- package/src/main/host/handoff-access.js +23 -4
- package/src/main/host/handoff-checkpoints.js +9 -2
- package/src/main/host/handoff-tools.js +3 -0
- package/src/main/host/handoff.js +11 -2
- package/src/main/host/runtime.js +272 -163
- package/src/main/host/session-history.js +54 -15
- package/src/main/native/icons.js +3 -2
- package/src/main/native/pets.js +234 -0
- package/src/main/native/protocol.js +197 -56
- package/src/main/protocol-core/projector.js +44 -41
- package/src/native-ui/desktop-control/dist/production-controller.d.ts.map +1 -1
- package/src/native-ui/desktop-control/dist/production-controller.js +1 -0
- package/src/native-ui/desktop-control/dist/production-controller.js.map +1 -1
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/desktop-control/src/production-controller.ts +1 -0
- package/src/native-ui/desktop-control/test/production-controller.test.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts +4 -2
- package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-agent-icon.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-sidebar-agent-icons.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/shell.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/skin-market.d.ts +4 -0
- package/src/native-ui/renderer-extension/dist/types/settings/skin-market.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +31 -0
- package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/dist/types/versioned-renderer-adapter.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/package.json +1 -1
- package/src/native-ui/renderer-extension/src/agent-selection-state.ts +16 -0
- package/src/native-ui/renderer-extension/src/assets.d.ts +5 -0
- package/src/native-ui/renderer-extension/src/renderer-agent-icon.ts +5 -4
- package/src/native-ui/renderer-extension/src/renderer-agent-picker.ts +1 -0
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +9 -6
- package/src/native-ui/renderer-extension/src/renderer-harness-handoff.ts +2 -2
- package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +2 -0
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
- package/src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts +1 -0
- package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -0
- package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -0
- package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
- package/src/native-ui/renderer-extension/src/settings/pages.ts +5 -2
- package/src/native-ui/renderer-extension/src/settings/shell.ts +3 -2
- package/src/native-ui/renderer-extension/src/settings/skin-market.css +213 -0
- package/src/native-ui/renderer-extension/src/settings/skin-market.ts +174 -0
- package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +399 -0
- package/src/native-ui/renderer-extension/src/versioned-renderer-adapter.ts +2 -1
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +11 -1
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
- package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +136 -0
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts +7 -0
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts.map +1 -1
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.js +3 -0
- package/src/native-ui/shared-contracts/dist/thread-harness-switch.js.map +1 -1
- package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/shared-contracts/src/thread-harness-switch.ts +3 -0
|
@@ -3,6 +3,7 @@ const fs = require('node:fs/promises');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { HandoffCheckpoints } = require('../src/main/host/handoff-checkpoints');
|
|
5
5
|
const { HandoffAccess } = require('../src/main/host/handoff-access');
|
|
6
|
+
const { SessionHistory } = require('../src/main/host/session-history');
|
|
6
7
|
const { dispatch } = require('../src/main/host/handoff-mcp.cjs');
|
|
7
8
|
|
|
8
9
|
async function main() {
|
|
@@ -47,6 +48,17 @@ async function main() {
|
|
|
47
48
|
thread.messages[0].text = 'mutated after handoff';
|
|
48
49
|
assert.doesNotMatch(JSON.stringify(runtime.handoffs.owned(thread.id, checkpoint.checkpointId)), /mutated after handoff/);
|
|
49
50
|
|
|
51
|
+
// 引用会话只读工具:pi 原生历史存根(30 轮),用户消息里显式给出 harness-mix://session/ 链接才可读
|
|
52
|
+
const sessionRef = Buffer.from(JSON.stringify(['pi', 'past-1'])).toString('base64url');
|
|
53
|
+
thread.messages.push({ id: 'user-2', role: 'user', text: `看看 [旧会话](harness-mix://session/${sessionRef}) 的结论` });
|
|
54
|
+
runtime.adapters.set('pi', { manifest: { integrations: { mcp: false } } });
|
|
55
|
+
runtime.threads = [thread];
|
|
56
|
+
runtime.execution = { isRunning: () => false };
|
|
57
|
+
runtime.history = new SessionHistory(runtime, {
|
|
58
|
+
async listNative() { return [{ nativeSessionId: 'past-1', title: '旧会话', cwd: process.cwd(), updatedAt: 5, running: null }]; },
|
|
59
|
+
async readNative() { return Array.from({ length: 30 }, (_, i) => ({ id: `m${i}`, role: i % 2 ? 'assistant' : 'user', text: `turn-${i}`, at: i })); },
|
|
60
|
+
});
|
|
61
|
+
|
|
50
62
|
const access = new HandoffAccess(runtime);
|
|
51
63
|
const connection = await access.connection({ ...thread, harnessId: 'target', pendingHandoff: { checkpointId: checkpoint.checkpointId } });
|
|
52
64
|
assert.ok(connection);
|
|
@@ -59,6 +71,7 @@ async function main() {
|
|
|
59
71
|
assert.deepEqual(catalog.tools.map(tool => tool.name), [
|
|
60
72
|
'get_handoff_checkpoint', 'list_handoff_conversation', 'list_handoff_evidence',
|
|
61
73
|
'read_handoff_evidence', 'list_handoff_files', 'read_handoff_plan',
|
|
74
|
+
'get_session_info', 'list_session_messages',
|
|
62
75
|
]);
|
|
63
76
|
const result = await dispatch({ id: 2, method: 'tools/call', params: { name: 'get_handoff_checkpoint', arguments: { checkpoint_id: checkpoint.checkpointId } } });
|
|
64
77
|
const summary = JSON.parse(result.content[0].text);
|
|
@@ -67,6 +80,31 @@ async function main() {
|
|
|
67
80
|
const denied = await dispatch({ id: 3, method: 'tools/call', params: { name: 'get_handoff_checkpoint', arguments: { checkpoint_id: 'handoff_outside_scope' } } });
|
|
68
81
|
assert.equal(denied.isError, true);
|
|
69
82
|
assert.match(denied.content[0].text, /outside this native session scope/);
|
|
83
|
+
|
|
84
|
+
// 引用会话:元数据廉价读取(无内嵌消息时 messageCount 为 null)
|
|
85
|
+
const info = JSON.parse((await dispatch({ id: 4, method: 'tools/call', params: { name: 'get_session_info', arguments: { session_id: sessionRef } } })).content[0].text);
|
|
86
|
+
assert.equal(info.harnessId, 'pi');
|
|
87
|
+
assert.equal(info.title, '旧会话');
|
|
88
|
+
assert.equal(info.messageCount, null);
|
|
89
|
+
assert.equal(info.branch, null);
|
|
90
|
+
// 引用会话:正文分页(offset 0 = 最近一页,nextOffset 逐页向更早翻)
|
|
91
|
+
const recent = JSON.parse((await dispatch({ id: 5, method: 'tools/call', params: { name: 'list_session_messages', arguments: { session_id: sessionRef, limit: 12 } } })).content[0].text);
|
|
92
|
+
assert.equal(recent.messageCount, 30);
|
|
93
|
+
assert.equal(recent.returned, 12);
|
|
94
|
+
assert.equal(recent.hasMore, true);
|
|
95
|
+
assert.equal(recent.nextOffset, 12);
|
|
96
|
+
assert.match(recent.transcript, /User: turn-28/);
|
|
97
|
+
assert.doesNotMatch(recent.transcript, /turn-17/);
|
|
98
|
+
const oldest = JSON.parse((await dispatch({ id: 6, method: 'tools/call', params: { name: 'list_session_messages', arguments: { session_id: sessionRef, offset: 24, limit: 12 } } })).content[0].text);
|
|
99
|
+
assert.equal(oldest.returned, 6);
|
|
100
|
+
assert.equal(oldest.hasMore, false);
|
|
101
|
+
assert.equal(oldest.nextOffset, null);
|
|
102
|
+
assert.match(oldest.transcript, /User: turn-0/);
|
|
103
|
+
// 未被用户引用的会话拒绝读取
|
|
104
|
+
const otherRef = Buffer.from(JSON.stringify(['pi', 'past-2'])).toString('base64url');
|
|
105
|
+
const deniedSession = await dispatch({ id: 7, method: 'tools/call', params: { name: 'get_session_info', arguments: { session_id: otherRef } } });
|
|
106
|
+
assert.equal(deniedSession.isError, true);
|
|
107
|
+
assert.match(deniedSession.content[0].text, /not referenced/);
|
|
70
108
|
} finally {
|
|
71
109
|
if (oldUrl === undefined) delete process.env.HARNESS_MIX_HANDOFF_URL; else process.env.HARNESS_MIX_HANDOFF_URL = oldUrl;
|
|
72
110
|
if (oldKey === undefined) delete process.env.HARNESS_MIX_HANDOFF_KEY; else process.env.HARNESS_MIX_HANDOFF_KEY = oldKey;
|
|
@@ -78,7 +78,7 @@ async function main() {
|
|
|
78
78
|
const acp = acpServers(selected.servers, collab);
|
|
79
79
|
assert.equal(acp.length, 2); assert.equal(acp[1].name, 'harness-mix'); assert.deepEqual(acp[0].env, []);
|
|
80
80
|
assert.ok(namedServers(selected.servers, collab)['hm-user-probe']);
|
|
81
|
-
const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length,
|
|
81
|
+
const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length, 17);
|
|
82
82
|
for (const a of adapters.values()) {
|
|
83
83
|
const entry = catalog.harnesses.find(h => h.id === a.manifest.id);
|
|
84
84
|
assert.equal(entry.mcp, a.manifest.integrations?.mcp === true);
|
|
@@ -93,6 +93,9 @@ async function main() {
|
|
|
93
93
|
assert.deepEqual(manager.roots(adapters.get('zcode'), { scope: 'project', cwd: project }), [path.join(project, '.zcode/skills'), path.join(project, '.agents/skills')]);
|
|
94
94
|
// Trae IDE (.trae) and TraeCode CLI (.traecli) share one managed root set.
|
|
95
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
|
+
// Cline 官方文档:全局 ~/.cline/skills 与项目 .cline/skills。
|
|
97
|
+
assert.deepEqual(manager.roots(adapters.get('cline'), { scope: 'global', cwd: null }), [path.join(home, '.cline/skills')]);
|
|
98
|
+
assert.deepEqual(manager.roots(adapters.get('cline'), { scope: 'project', cwd: project }), [path.join(project, '.cline/skills')]);
|
|
96
99
|
// Hermes documents no project-scope skills directory, so only its global root is managed.
|
|
97
100
|
assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'project', cwd: project }), []);
|
|
98
101
|
assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'global', cwd: null }), [path.join(home, '.hermes/skills')]);
|
|
@@ -116,7 +119,7 @@ async function main() {
|
|
|
116
119
|
const globalOnly = await blocked.ensureSkillRoots({ cwd: 'relative-not-absolute' }, adapters.get('codex'), {});
|
|
117
120
|
assert.equal(globalOnly.length, 1);
|
|
118
121
|
// Exercise the real protocol dispatcher without starting native processes.
|
|
119
|
-
assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length,
|
|
122
|
+
assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length, 17);
|
|
120
123
|
assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/list', local)).skills.length, 3);
|
|
121
124
|
console.log('integrations: scope precedence, persistence, native status projection, no credential fields, unsupported capabilities, skill install/disable/restore, links, and protocol PASS');
|
|
122
125
|
}
|
|
@@ -57,6 +57,24 @@ if (process.argv.includes('--fixture')) {
|
|
|
57
57
|
assert.deepEqual(nativeCommand(module.manifest.id, ['acp']), { command: process.execPath, args: ['acp'] });
|
|
58
58
|
} finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; }
|
|
59
59
|
}
|
|
60
|
+
// Cline:官方 `cline --acp`;诚实能力声明(无 plan/原生 diff/thinking 档/独立提问),
|
|
61
|
+
// 保留 resume 与 plan/act 权限模式;命令只认 cline 与 HARNESS_MIX_CLINE_EXECUTABLE。
|
|
62
|
+
{
|
|
63
|
+
const module = require('../src/main/adapters/cline');
|
|
64
|
+
const caps = module.manifest.capabilities;
|
|
65
|
+
assert.deepEqual(module.manifest.integrations.skills, { global: ['.cline/skills'], project: ['.cline/skills'] });
|
|
66
|
+
for (const [capability, expected] of [['fork', false], ['questions', false], ['thinkingLevels', false], ['plan', false], ['nativeDiff', false], ['usage', false], ['resume', true], ['permissionModes', true], ['models', true], ['attachments', true], ['approvals', true]])
|
|
67
|
+
assert.equal(caps[capability], expected, `cline ${capability}`);
|
|
68
|
+
const previous = process.env.HARNESS_MIX_CLINE_EXECUTABLE;
|
|
69
|
+
try {
|
|
70
|
+
delete process.env.HARNESS_MIX_CLINE_EXECUTABLE;
|
|
71
|
+
process.env.HARNESS_MIX_CLINE_EXECUTABLE = __filename + '.missing';
|
|
72
|
+
assert.throws(() => nativeCommand('cline', ['--acp']), /未安装/);
|
|
73
|
+
assert.equal((await module.create().inspect()).available, false);
|
|
74
|
+
process.env.HARNESS_MIX_CLINE_EXECUTABLE = process.execPath;
|
|
75
|
+
assert.deepEqual(nativeCommand('cline', ['--acp']), { command: process.execPath, args: ['--acp'] });
|
|
76
|
+
} finally { if (previous === undefined) delete process.env.HARNESS_MIX_CLINE_EXECUTABLE; else process.env.HARNESS_MIX_CLINE_EXECUTABLE = previous; }
|
|
77
|
+
}
|
|
60
78
|
const adapter = acpAdapter({ id: 'fixture', name: 'Fixture', bin: () => ({ command: process.execPath, args: [__filename, '--fixture'] }), args: [], requestTimeoutMs: 5000 }).create();
|
|
61
79
|
const events = [];
|
|
62
80
|
let session;
|
|
@@ -47,7 +47,7 @@ if (process.argv.includes('--fixture')) {
|
|
|
47
47
|
});
|
|
48
48
|
} else {
|
|
49
49
|
(async () => {
|
|
50
|
-
for (const vendor of ['codebuddy', 'kiro-cli', 'cursor-cli', 'qoder', 'zcode', 'trae']) {
|
|
50
|
+
for (const vendor of ['codebuddy', 'kiro-cli', 'cursor-cli', 'qoder', 'zcode', 'trae', 'cline']) {
|
|
51
51
|
const module = nativeAcp({ id: vendor, name: vendor, args: [], timeoutMs: 2000, command: () => ({ command: process.execPath, args: [__filename, '--fixture'] }) });
|
|
52
52
|
const adapter = module.create(), events = [];
|
|
53
53
|
let s;
|
|
@@ -16,13 +16,20 @@ async function main() {
|
|
|
16
16
|
const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true } },
|
|
17
17
|
async open(input) { emits.push(input.emit); emit = input.emit; return {}; },
|
|
18
18
|
async describe() { return { models: [{ id: 'demo', name: 'Demo', provider: 'test' }], thinkingLevels: [{ id: 'high', label: 'High', default: true }, { id: 'low', label: 'Low' }], permissionModes: [] }; },
|
|
19
|
-
async send() {}, async cancel() {}, async close() {},
|
|
19
|
+
async send(session, text, hooks, extras) { sendExtras.push(extras); }, async cancel() {}, async close() {},
|
|
20
20
|
async respond(session, id, answer) { answers.push({ id, answer }); } };
|
|
21
|
+
const sendExtras = [];
|
|
21
22
|
runtime.adapters.set('pi', adapter); runtime.status.pi = { available: true };
|
|
22
23
|
const events = [];
|
|
24
|
+
let observeQueueOrder = false;
|
|
25
|
+
let queueResponseResolved = false;
|
|
26
|
+
let queueNotificationBeforeResponse = false;
|
|
23
27
|
const section = { id: 'test-pinned-section', name: 'Pinned', appearance: null };
|
|
24
28
|
const officialRequests = [];
|
|
25
|
-
const bridge = new NativeProtocol(runtime, event =>
|
|
29
|
+
const bridge = new NativeProtocol(runtime, event => {
|
|
30
|
+
if (observeQueueOrder && event?.method === 'thread/queue/changed' && !queueResponseResolved) queueNotificationBeforeResponse = true;
|
|
31
|
+
events.push(event);
|
|
32
|
+
}, async (method, params) => {
|
|
26
33
|
officialRequests.push({ method, params });
|
|
27
34
|
if (method === 'threadSection/list') return { data: [section], nextCursor: null };
|
|
28
35
|
if (method === 'account/read') return { account: { type: 'chatgpt', email: 'native@example.com', planType: 'plus' }, requiresOpenaiAuth: true };
|
|
@@ -149,13 +156,18 @@ async function main() {
|
|
|
149
156
|
await wait(() => !runtime.threads.find(t => t.id === prewarmed.thread.id).reviewPending && !runtime.sending.has(prewarmed.thread.id));
|
|
150
157
|
emit = emits[0]; // 恢复主线程的事件源(open 顺序:主线程序,预热线程后)
|
|
151
158
|
schemas.threadInspectionSchema.parse(await bridge.request('codexhost/thread/inspect', { threadId }));
|
|
152
|
-
const turn = await bridge.request('turn/start', { threadId, input: [{ type: 'text', text: 'test' }] });
|
|
159
|
+
const turn = await bridge.request('turn/start', { threadId, input: [{ type: 'text', text: 'test' }], approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } });
|
|
160
|
+
// turn/start 在 currentTurn 建立后即返回,adapter.send 的调用在其后;等待透传到达
|
|
161
|
+
await wait(() => sendExtras.at(-1)?.turnPermissions != null);
|
|
162
|
+
assert.deepEqual(sendExtras.at(-1).turnPermissions, { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } }, 'turn/start 权限参数透传到适配器');
|
|
153
163
|
assert.ok(runtime.core.getTurn(turn.turn.id), 'Native turn IDs come from the existing ProtocolCore');
|
|
154
164
|
emit({ kind: 'thinking-delta', text: 'reason' });
|
|
155
165
|
emit({ kind: 'text-delta', text: 'hello' });
|
|
156
166
|
emit({ kind: 'text-delta', text: ' world' });
|
|
157
167
|
emit({ kind: 'tool', toolCallId: 't', title: 'Read', state: 'running', input: '{}' });
|
|
158
168
|
emit({ kind: 'tool', toolCallId: 't', title: 'Read', state: 'completed', output: 'done' });
|
|
169
|
+
emit({ kind: 'tool', toolCallId: 'sh', title: 'Bash', state: 'running', input: '{"command":"cat package.json"}' });
|
|
170
|
+
emit({ kind: 'tool', toolCallId: 'sh', title: 'Bash', state: 'completed', output: '{}' });
|
|
159
171
|
emit({ kind: 'approval', requestId: 'permission', method: 'confirm', title: 'Allow?' });
|
|
160
172
|
const approval = events.find(e => e.id?.startsWith('harness-mix:approval:'));
|
|
161
173
|
assert.equal(answers.length, 0, 'Approval is not fabricated');
|
|
@@ -169,7 +181,13 @@ async function main() {
|
|
|
169
181
|
emit({ kind: 'completed', finalAnswer: true });
|
|
170
182
|
await wait(() => !runtime.threads.find(t => t.id === threadId).reviewPending && !runtime.sending.has(threadId));
|
|
171
183
|
assert.equal(events.filter(e => e.method === 'item/agentMessage/delta').map(e => e.params.delta).join(''), 'hello world');
|
|
172
|
-
|
|
184
|
+
// 非终端工具投影为 dynamicToolCall(摘要显示真实工具名),终端命令投影为原生 commandExecution
|
|
185
|
+
assert.ok(events.some(e => e.method === 'item/completed' && e.params.item.type === 'dynamicToolCall' && e.params.item.tool === 'Read'));
|
|
186
|
+
const execItem = events.filter(e => e.method === 'item/completed').map(e => e.params.item).find(i => i.type === 'commandExecution');
|
|
187
|
+
assert.equal(execItem?.command, 'cat package.json', 'Shell tool projects the real command text');
|
|
188
|
+
assert.equal(execItem?.commandActions?.[0]?.type, 'read', 'Simple cat command classifies as a read action');
|
|
189
|
+
assert.equal(execItem?.commandActions?.[0]?.path, 'package.json');
|
|
190
|
+
assert.equal(typeof execItem?.durationMs, 'number', 'Command execution carries a real duration');
|
|
173
191
|
// Desktop 以 `diff --git a/x b/x` 切分文件并提取路径,且只在 @@ hunk 头之后计数增删行
|
|
174
192
|
const turnDiff = events.filter(e => e.method === 'turn/diff/updated').at(-1).params.diff;
|
|
175
193
|
assert.ok(turnDiff.includes('diff --git a/a.txt b/a.txt'), 'turn diff carries git-style file headers');
|
|
@@ -177,11 +195,26 @@ async function main() {
|
|
|
177
195
|
assert.ok(turnDiff.includes('@@ -0,0 +1,1 @@') && turnDiff.includes('+hello'), 'turn diff carries a countable hunk');
|
|
178
196
|
assert.ok(events.some(e => e.method === 'turn/diff/updated' && e.params.diff.includes('a.txt')));
|
|
179
197
|
assert.equal(events.filter(e => e.method === 'turn/completed' && e.params.threadId === threadId).length, 1);
|
|
198
|
+
// Desktop「已处理/用时」计时契约:item/started 带 startedAtMs、item/completed 带
|
|
199
|
+
// completedAtMs,终态 Turn 带 startedAt/completedAt(epoch 秒)与 durationMs(毫秒);
|
|
200
|
+
// 缺这些字段时 Desktop 无法合成 worked-for 计时项,完成回合只显示集成摘要。
|
|
201
|
+
const completedTurn = events.filter(e => e.method === 'turn/completed' && e.params.threadId === threadId && e.params.turn.status === 'completed').at(-1).params.turn;
|
|
202
|
+
assert.equal(typeof completedTurn.durationMs, 'number', 'Completed turn carries durationMs for the Desktop worked-for timer');
|
|
203
|
+
assert.ok(Number.isInteger(completedTurn.startedAt) && Number.isInteger(completedTurn.completedAt), 'Completed turn carries epoch-second startedAt/completedAt');
|
|
204
|
+
assert.ok(completedTurn.durationMs >= 0 && completedTurn.completedAt >= completedTurn.startedAt, 'Turn timing fields are coherent');
|
|
205
|
+
assert.ok(events.filter(e => e.method === 'item/started').every(e => typeof e.params.startedAtMs === 'number'), 'item/started carries startedAtMs');
|
|
206
|
+
assert.ok(events.filter(e => e.method === 'item/completed').every(e => typeof e.params.completedAtMs === 'number'), 'item/completed carries completedAtMs');
|
|
180
207
|
const history = await bridge.request('thread/read', { threadId });
|
|
181
208
|
assert.equal(history.thread.turns[0].status, 'completed');
|
|
209
|
+
assert.equal(typeof history.thread.turns[0].durationMs, 'number', 'Restored turns keep durationMs');
|
|
182
210
|
const projectedChange = history.thread.turns[0].items.flatMap(i => i.type === 'fileChange' ? i.changes : []).find(change => change.path === 'a.txt');
|
|
183
211
|
assert.equal(projectedChange?.kind.type, 'add', 'fileChange item projects the added kind');
|
|
184
212
|
assert.ok(projectedChange?.diff.includes('diff --git a/a.txt b/a.txt') && projectedChange.diff.includes('@@ -0,0 +1,1 @@'), 'fileChange item carries a full unified diff');
|
|
213
|
+
await bridge.request('thread/settings/update', { threadId, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } });
|
|
214
|
+
assert.deepEqual(runtime.threads.find(t => t.id === threadId).options.turnPermissions, { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } }, 'Desktop 权限菜单设置存储到线程');
|
|
215
|
+
const resumed = await bridge.request('thread/resume', { threadId });
|
|
216
|
+
assert.equal(resumed.approvalPolicy, 'never', 'thread/resume 回显实际生效的权限');
|
|
217
|
+
assert.deepEqual(resumed.sandbox, { type: 'dangerFullAccess' }, 'thread/resume 回显实际生效的沙箱');
|
|
185
218
|
await bridge.request('thread/name/set', { threadId, name: 'Local Core' });
|
|
186
219
|
await bridge.request('thread/archive', { threadId });
|
|
187
220
|
assert.equal(runtime.threads.find(t => t.id === threadId).archived, true);
|
|
@@ -341,13 +374,18 @@ async function main() {
|
|
|
341
374
|
const qThread = await bridge.request('thread/start', { cwd: root, model: routeModel('pi') });
|
|
342
375
|
const qThreadId = qThread.thread.id;
|
|
343
376
|
assert.deepEqual(await bridge.request('thread/queue/list', { threadId: qThreadId }), { data: [], nextCursor: null });
|
|
377
|
+
observeQueueOrder = true;
|
|
344
378
|
const q1 = await bridge.request('thread/queue/add', {
|
|
345
379
|
threadId: qThreadId,
|
|
346
380
|
input: [{ type: 'text', text: '排队补充需求 1' }],
|
|
347
381
|
clientUserMessageId: 'client-msg-1',
|
|
348
382
|
});
|
|
383
|
+
queueResponseResolved = true;
|
|
349
384
|
assert.equal(typeof q1.queuedSubmission.id, 'string');
|
|
350
385
|
assert.equal(q1.queuedSubmission.clientUserMessageId, 'client-msg-1');
|
|
386
|
+
await wait(() => events.at(-1)?.method === 'thread/queue/changed' && events.at(-1)?.params?.threadId === qThreadId);
|
|
387
|
+
assert.equal(queueNotificationBeforeResponse, false, 'Queue mutation response precedes changed notification so Desktop edits keep the current id');
|
|
388
|
+
observeQueueOrder = false;
|
|
351
389
|
assert.equal(events.at(-1)?.method, 'thread/queue/changed');
|
|
352
390
|
assert.equal(events.at(-1)?.params?.threadId, qThreadId);
|
|
353
391
|
|
|
@@ -388,6 +426,24 @@ async function main() {
|
|
|
388
426
|
assert.equal(qList.data.length, 1);
|
|
389
427
|
assert.equal(qList.data[0].id, q1.queuedSubmission.id);
|
|
390
428
|
|
|
429
|
+
// 输入准备或原生启动失败时不得丢失排队项,仍可编辑后重试。
|
|
430
|
+
await bridge.request('thread/queue/update', {
|
|
431
|
+
threadId: qThreadId,
|
|
432
|
+
queuedSubmissionId: q1.queuedSubmission.id,
|
|
433
|
+
input: [{ type: 'unsupported-test-input' }],
|
|
434
|
+
});
|
|
435
|
+
await assert.rejects(bridge.request('thread/queue/start', {
|
|
436
|
+
threadId: qThreadId,
|
|
437
|
+
queuedSubmissionId: q1.queuedSubmission.id,
|
|
438
|
+
}), /Unsupported native input type/);
|
|
439
|
+
qList = await bridge.request('thread/queue/list', { threadId: qThreadId });
|
|
440
|
+
assert.equal(qList.data[0]?.id, q1.queuedSubmission.id, 'Failed queue start keeps the item for editing or retry');
|
|
441
|
+
await bridge.request('thread/queue/update', {
|
|
442
|
+
threadId: qThreadId,
|
|
443
|
+
queuedSubmissionId: q1.queuedSubmission.id,
|
|
444
|
+
input: [{ type: 'text', text: '修正后的排队消息' }],
|
|
445
|
+
});
|
|
446
|
+
|
|
391
447
|
// 空闲状态启动排队消息
|
|
392
448
|
const startedQ = await bridge.request('thread/queue/start', {
|
|
393
449
|
threadId: qThreadId,
|
|
@@ -12,7 +12,7 @@ app.whenReady().then(async () => {
|
|
|
12
12
|
const icons = getAllIconsDictionary();
|
|
13
13
|
icons.harnesses['claude-code'] = icons.harnesses.claude;
|
|
14
14
|
icons.harnesses['deepseek-harness'] = icons.harnesses.dsh;
|
|
15
|
-
const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { installRendererSettingsLifecycle } from './src/native-ui/renderer-extension/src/harness-mix-settings.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { createStorageSettingsPage } from './src/native-ui/renderer-extension/src/settings/storage-page.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
|
|
15
|
+
const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { installRendererSettingsLifecycle } from './src/native-ui/renderer-extension/src/harness-mix-settings.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { createStorageSettingsPage } from './src/native-ui/renderer-extension/src/settings/storage-page.ts'; export { createSkinSettingsPage } from './src/native-ui/renderer-extension/src/settings/skin-market.ts'; export { applyRendererSkin } from './src/native-ui/renderer-extension/src/settings/skin-runtime.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.webp': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
|
|
16
16
|
const win = new BrowserWindow({ show: false, width: 900, height: 650, webPreferences: { contextIsolation: true, sandbox: true, offscreen: true, backgroundThrottling: false } });
|
|
17
17
|
await win.loadURL('data:text/html,<html><head><style>body{background:%2317191d;color:white;font:16px Arial;padding:40px}button{background:%23272a30;color:white;border:0;padding:12px}img{vertical-align:middle}main{display:flex;gap:25px;margin-bottom:40px}</style></head><body><h2>Harness Mix native components</h2><main></main></body></html>');
|
|
18
18
|
await win.webContents.executeJavaScript(`globalThis.__HARNESS_MIX_ICONS__=${JSON.stringify(icons)};globalThis.__HARNESS_MIX_MODEL_FAMILIES__=${JSON.stringify(MODEL_FAMILIES.map(f => ({ id: f.id, pattern: f.regex.source })))};${bundle.outputFiles[0].text}`);
|
|
@@ -22,7 +22,7 @@ app.whenReady().then(async () => {
|
|
|
22
22
|
lifecycle.dispose();
|
|
23
23
|
return pageIds;
|
|
24
24
|
})()`);
|
|
25
|
-
assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'updates', 'about']);
|
|
25
|
+
assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'skins', 'updates', 'about']);
|
|
26
26
|
const { NativeProtocol } = require('../src/main/native/protocol');
|
|
27
27
|
const nativeThreads = ['pi', 'claude', 'dsh', 'antigravity'].map((harnessId, index) => ({ id: 'sidebar-' + index, harnessId, model: { id: 'model-' + index, provider: 'native' }, options: {} }));
|
|
28
28
|
const protocol = new NativeProtocol({ threads: nativeThreads, getThread: id => nativeThreads.find(thread => thread.id === id), subscribe: () => () => {}, core: { subscribe: () => () => {} }, describe: async () => ({}), getCapabilities: () => ({}) }, () => {});
|
|
@@ -137,6 +137,7 @@ app.whenReady().then(async () => {
|
|
|
137
137
|
inspectStorage:async () => ({storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0}),
|
|
138
138
|
optimizeStorage:async () => ({before:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0},after:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:4194304,recordCount:12,legacyBytes:0}}),
|
|
139
139
|
})),
|
|
140
|
+
NativeUI.createSkinSettingsPage(messages),
|
|
140
141
|
NativeUI.aboutPage(messages, () => ({ readCurrentVersion:async () => ({version:'0.1.4'}), checkUpdate:async () => { throw new Error('not expected'); } })),
|
|
141
142
|
], messages, document);
|
|
142
143
|
shell.openSettings(undefined, 'accounts');
|
|
@@ -156,6 +157,35 @@ app.whenReady().then(async () => {
|
|
|
156
157
|
assert.match(storageText, /Schema v3/);
|
|
157
158
|
assert.match(storageText, /12 个任务/);
|
|
158
159
|
fs.writeFileSync(path.join(out, 'settings-storage.png'), (await win.webContents.capturePage()).toPNG());
|
|
160
|
+
const skinResult = await win.webContents.executeJavaScript(`(async () => {
|
|
161
|
+
globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'skins');
|
|
162
|
+
const shadow = globalThis.__codexhostSettingsShellV1.root.shadowRoot;
|
|
163
|
+
const firstButton = shadow.querySelector('button[data-skin-action="styler-nocturne-studio"]');
|
|
164
|
+
const mikuButton = shadow.querySelector('button[data-skin-action="miku-488137"]');
|
|
165
|
+
firstButton.click();
|
|
166
|
+
mikuButton.click();
|
|
167
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
168
|
+
return {
|
|
169
|
+
text: shadow.textContent,
|
|
170
|
+
active: document.documentElement.getAttribute('data-harness-mix-skin'),
|
|
171
|
+
style: Boolean(document.getElementById('harness-mix-renderer-skin')),
|
|
172
|
+
cardCount: shadow.querySelectorAll('.skin-card').length,
|
|
173
|
+
bundledPreviewCount: [...shadow.querySelectorAll('.skin-preview')].filter(preview => preview.style.backgroundImage.includes('data:image/webp')).length,
|
|
174
|
+
};
|
|
175
|
+
})()`);
|
|
176
|
+
assert.match(skinResult.text, /Native Codex.*Miku 488137.*原神 · 晨曦.*鸣潮 · 共鸣.*龙珠 · 筋斗云.*金辉盛境.*夜曲工作室.*静谧花园/s);
|
|
177
|
+
assert.equal(skinResult.active, 'miku-488137');
|
|
178
|
+
assert.equal(skinResult.style, true);
|
|
179
|
+
assert.equal(skinResult.cardCount, 17);
|
|
180
|
+
assert.equal(skinResult.bundledPreviewCount, 16);
|
|
181
|
+
fs.writeFileSync(path.join(out, 'settings-skins.png'), (await win.webContents.capturePage()).toPNG());
|
|
182
|
+
await win.webContents.executeJavaScript(`(() => {
|
|
183
|
+
const shadow = globalThis.__codexhostSettingsShellV1.root.shadowRoot;
|
|
184
|
+
const page = shadow.querySelector('.settings-page');
|
|
185
|
+
page.scrollTop = page.scrollHeight;
|
|
186
|
+
})()`);
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
188
|
+
fs.writeFileSync(path.join(out, 'settings-skins-discovery.png'), (await win.webContents.capturePage()).toPNG());
|
|
159
189
|
const aboutText = await win.webContents.executeJavaScript(`(async () => {
|
|
160
190
|
globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'about');
|
|
161
191
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -163,6 +193,66 @@ app.whenReady().then(async () => {
|
|
|
163
193
|
})()`);
|
|
164
194
|
assert.match(aboutText, /v0\.1\.4/);
|
|
165
195
|
fs.writeFileSync(path.join(out, 'settings-about.png'), (await win.webContents.capturePage()).toPNG());
|
|
196
|
+
await win.setSize(1055, 616);
|
|
197
|
+
const skinShowcase = await win.webContents.executeJavaScript(`(async () => {
|
|
198
|
+
document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新建任务</strong><span>拉取请求</span><span>云端</span><span>已安排</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">Miku Codex</span><span>开源项目</span><span>Harness Mix</span></nav><footer>Harness Mix</footer></aside><main class="main-surface" data-app-shell-main-surface="default"><section class="welcome"><h1>你想让我们在 Miku Codex 中构建什么?</h1><div class="suggestions"><button class="bg-card">探索并理解代码</button><button class="bg-card">构建新功能、应用或工具</button><button class="bg-card">审查代码并提出修改建议</button><button class="bg-card">修复问题和失败</button></div></section><section class="composer-surface-chrome"><small>Miku Codex 本地 main</small><div>随心输入</div><footer>+ 完全访问 <span>5.6 Luna ↑</span></footer></section></main></div>';
|
|
199
|
+
const fixtureStyle = document.createElement('style');
|
|
200
|
+
fixtureStyle.textContent = '*{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0}body{padding:0!important;overflow:hidden;font-family:Arial,"Microsoft YaHei",sans-serif}#root{display:flex}.app-shell-left-panel{display:flex;flex:0 0 204px;flex-direction:column;padding:6px 10px 12px}.app-shell-left-panel button{border:0;color:inherit;font:700 16px inherit;text-align:left}.app-shell-left-panel nav{display:flex;flex-direction:column;gap:12px;padding:5px 2px;font-size:13px}.app-shell-left-panel nav small{margin-top:6px;opacity:.55}.app-shell-left-panel nav .active{padding:7px;border-radius:7px}.app-shell-left-panel>footer{margin-top:auto;font-size:12px}.main-surface{position:relative;display:flex;flex:1;align-items:center;justify-content:center;overflow:hidden}.welcome{width:min(660px,70vw);text-align:center;transform:translateY(-35px)}.welcome h1{margin:0 0 26px;font-size:23px;font-weight:500}.suggestions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.suggestions button{min-height:76px;padding:12px;border:1px solid #fff8;border-radius:9px;color:inherit;text-align:left;font-weight:600}.composer-surface-chrome{position:absolute;right:13%;bottom:12px;left:13%;min-height:94px;padding:12px 18px;border:1px solid transparent;border-radius:16px}.composer-surface-chrome>div{padding:13px 0;color:#52617a99}.composer-surface-chrome footer{font-size:11px}.composer-surface-chrome footer span{float:right}';
|
|
201
|
+
document.head.append(fixtureStyle);
|
|
202
|
+
NativeUI.applyRendererSkin('miku-488137', document);
|
|
203
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
204
|
+
const root = getComputedStyle(document.getElementById('root'));
|
|
205
|
+
const main = getComputedStyle(document.querySelector('.main-surface'));
|
|
206
|
+
const logo = getComputedStyle(document.querySelector('.app-shell-left-panel button'));
|
|
207
|
+
const polaroid = getComputedStyle(document.body, '::after');
|
|
208
|
+
return { rootBackground:root.backgroundImage, mainBackground:main.backgroundImage, logoBackground:logo.backgroundImage, polaroidBackground:polaroid.backgroundImage };
|
|
209
|
+
})()`);
|
|
210
|
+
assert.match(skinShowcase.rootBackground, /data:image\/webp/);
|
|
211
|
+
assert.match(skinShowcase.mainBackground, /linear-gradient/);
|
|
212
|
+
assert.match(skinShowcase.logoBackground, /data:image\/webp/);
|
|
213
|
+
assert.match(skinShowcase.polaroidBackground, /data:image\/webp/);
|
|
214
|
+
fs.writeFileSync(path.join(out, 'skin-miku-full.png'), (await win.webContents.capturePage()).toPNG());
|
|
215
|
+
await win.setSize(1020, 716);
|
|
216
|
+
const readableConversation = await win.webContents.executeJavaScript(`(async () => {
|
|
217
|
+
document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新建任务</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span>snipaste-pro</span><span>harness-mix</span><span class="active" data-app-action-sidebar-thread-active="true">查找换肤界面差异原因</span></nav><footer>Harness Mix</footer></aside><main class="main-surface chat" data-app-shell-main-surface="default"><header>查找换肤界面差异原因</header><div class="chat-scroll"><article data-local-conversation-final-assistant><div data-response-annotation-conversation><small>用时 19秒</small><p>是的,准确说是修改了 Codex UI 的运行时表现层。</p><p>它会:</p><ul><li>注入 CSS、替换颜色变量和背景图。</li><li>把部分原生面板变成半透明。</li><li>增加 Logo、拍立得等纯视觉装饰。</li><li>保留原有按钮、输入框、会话和交互逻辑。</li></ul><p>它不会修改 Codex 的安装包、模型调用、账号、权限或会话归属。</p></div></article></div><section class="composer-surface-chrome"><div>随心输入</div><footer>+ 完全访问 <span>GPT-5.6 Sol ↑</span></footer></section></main></div>';
|
|
218
|
+
const chatStyle = document.createElement('style');
|
|
219
|
+
chatStyle.textContent = '.app-shell-left-panel button{background:transparent}.chat.main-surface{display:block;padding:0 22px}.chat>header{height:56px;padding:18px 4px;border-bottom:1px solid #6b789633;font-size:14px}.chat-scroll{height:calc(100% - 180px);overflow:auto;padding:42px 8px 80px}.chat article{max-width:760px;margin:0 auto;font-size:15px;line-height:1.7}.chat article small{opacity:.55}.chat article p{margin:13px 0}.chat article li{margin:4px 0}.chat .composer-surface-chrome{right:22px;bottom:14px;left:22px;background:#fff}';
|
|
220
|
+
document.head.append(chatStyle);
|
|
221
|
+
NativeUI.applyRendererSkin('dragonball-nimbus', document);
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
223
|
+
const response = getComputedStyle(document.querySelector('[data-response-annotation-conversation]'));
|
|
224
|
+
return { background:response.backgroundColor, color:response.color, padding:response.padding, radius:response.borderRadius };
|
|
225
|
+
})()`);
|
|
226
|
+
assert.notEqual(readableConversation.background, 'rgba(0, 0, 0, 0)');
|
|
227
|
+
assert.notEqual(readableConversation.background, 'transparent');
|
|
228
|
+
assert.equal(readableConversation.padding, '14px 16px 12px');
|
|
229
|
+
assert.equal(readableConversation.radius, '18px');
|
|
230
|
+
fs.writeFileSync(path.join(out, 'skin-conversation-readable.png'), (await win.webContents.capturePage()).toPNG());
|
|
231
|
+
const darkSurfaceResult = await win.webContents.executeJavaScript(`(async () => {
|
|
232
|
+
document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新对话</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">暗色皮肤全界面适配</span></nav></aside><main class="main-surface dark-fixture" data-app-shell-main-surface="default"><header>暗色皮肤全界面适配</header><article data-response-annotation-conversation><p>正文、工具状态、评审卡和输入区都需要保持清晰。</p><section class="review-card"><div>已编辑 3 个文件</div><button class="review-action">审核</button><button class="review-row">src/settings/skin-runtime.ts</button><button class="review-row">scripts/native-ui-smoke.cjs</button></section></article><section class="composer-real" data-composer-layout="multiline"><input placeholder="随心输入"><button>完全访问</button><button>GPT-5.6 Sol</button></section></main></div>';
|
|
233
|
+
const darkFixtureStyle = document.createElement('style');
|
|
234
|
+
darkFixtureStyle.textContent = 'html,body,#root{width:100%;height:100%;margin:0}.dark-fixture{position:relative;display:block;padding:24px}.dark-fixture>header{padding:12px 0}.dark-fixture article{max-width:700px;margin:80px auto}.review-card{margin-top:24px;padding:14px;border:1px solid var(--color-token-border-default);border-radius:12px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 50%,transparent)}.review-card button{color:var(--color-text-primary)}.review-action{float:right;background:var(--color-background-primary-soft-alpha)}.review-row{display:block;width:100%;margin-top:10px;padding:10px;text-align:left;border:0;background:color-mix(in srgb,var(--color-surface) 70%,transparent)}.composer-real{position:absolute;right:24px;bottom:16px;left:24px;padding:18px;border-radius:20px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 86%,transparent)}.composer-real input{width:70%;padding:8px;color:var(--color-text-primary);background:transparent;border:0}.composer-real button{margin-left:8px;color:var(--color-text-secondary);background:transparent;border:0}';
|
|
235
|
+
document.head.append(darkFixtureStyle);
|
|
236
|
+
NativeUI.applyRendererSkin('dragonball-nimbus', document);
|
|
237
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
238
|
+
const before = { buttons:document.querySelectorAll('button').length, inputs:document.querySelectorAll('input').length };
|
|
239
|
+
const lightReviewBackground = getComputedStyle(document.querySelector('.review-card')).backgroundColor;
|
|
240
|
+
NativeUI.applyRendererSkin('genshin-night', document);
|
|
241
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
242
|
+
const review = getComputedStyle(document.querySelector('.review-card'));
|
|
243
|
+
const row = getComputedStyle(document.querySelector('.review-row'));
|
|
244
|
+
const composer = getComputedStyle(document.querySelector('.composer-real'));
|
|
245
|
+
const after = { buttons:document.querySelectorAll('button').length, inputs:document.querySelectorAll('input').length };
|
|
246
|
+
return { before, after, active:document.documentElement.getAttribute('data-harness-mix-skin'), lightReviewBackground, reviewBackground:review.backgroundColor, rowBackground:row.backgroundColor, composerBackground:composer.backgroundColor, color:review.color };
|
|
247
|
+
})()`);
|
|
248
|
+
assert.deepEqual(darkSurfaceResult.after, darkSurfaceResult.before);
|
|
249
|
+
assert.equal(darkSurfaceResult.active, 'genshin-night');
|
|
250
|
+
assert.notEqual(darkSurfaceResult.reviewBackground, darkSurfaceResult.lightReviewBackground);
|
|
251
|
+
assert.doesNotMatch(darkSurfaceResult.reviewBackground, /255, 255, 255/);
|
|
252
|
+
assert.doesNotMatch(darkSurfaceResult.rowBackground, /255, 255, 255/);
|
|
253
|
+
assert.doesNotMatch(darkSurfaceResult.composerBackground, /255, 255, 255/);
|
|
254
|
+
assert.equal(darkSurfaceResult.color, 'rgb(240, 230, 200)');
|
|
255
|
+
fs.writeFileSync(path.join(out, 'skin-dark-all-surfaces.png'), (await win.webContents.capturePage()).toPNG());
|
|
166
256
|
win.destroy();
|
|
167
257
|
console.log('PASS: real Electron native picker, graphical Harness handoff, official Account/Usage, sharded Storage metrics, live About version and all project Harness/model SVGs load');
|
|
168
258
|
app.quit();
|
|
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
|
|
|
2
2
|
const fs = require('node:fs/promises');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { HostRuntime } = require('../src/main/host/runtime');
|
|
5
|
-
const { CAP, buildHandoffContext } = require('../src/main/host/handoff');
|
|
5
|
+
const { CAP, buildHandoffContext, composeHandoffEnvelope } = require('../src/main/host/handoff');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 跨 Harness 原地切换(switchHarness)脚本级测试:
|
|
@@ -13,15 +13,18 @@ const { CAP, buildHandoffContext } = require('../src/main/host/handoff');
|
|
|
13
13
|
const wait = async fn => { for (let i = 0; i < 300; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 10)); } throw new Error('Timed out'); };
|
|
14
14
|
|
|
15
15
|
function fakeHarness(id, name) {
|
|
16
|
-
const state = { sent: [], opens: [], closed: 0, hold: null, withFileChange: false };
|
|
16
|
+
const state = { sent: [], opens: [], closed: 0, hold: null, withFileChange: false, openError: null, openWait: null, sendError: null };
|
|
17
17
|
const adapter = {
|
|
18
18
|
manifest: { id, name, aliases: [id], capabilities: {} },
|
|
19
19
|
async open(input) {
|
|
20
20
|
state.opens.push({ restore: input.thread.restore === true, nativeSessionId: input.thread.nativeSessionId, nativeSessionFile: input.thread.nativeSessionFile });
|
|
21
|
+
if (state.openWait) await state.openWait;
|
|
22
|
+
if (state.openError) throw new Error(state.openError);
|
|
21
23
|
return { nativeSessionId: input.thread.nativeSessionId };
|
|
22
24
|
},
|
|
23
25
|
async send(session, text, hooks) {
|
|
24
26
|
state.sent.push(text);
|
|
27
|
+
if (state.sendError) throw new Error(state.sendError);
|
|
25
28
|
if (state.hold) { state.hold.hooks = hooks; return; } // 挂起:Turn 保持 running,等待测试手动 settle
|
|
26
29
|
if (state.withFileChange) hooks.emit({ kind: 'file-change', changes: [{ path: 'src/login.ts', before: '', after: 'x', complete: true, changeType: 'added' }] });
|
|
27
30
|
hooks.emit({ kind: 'text-delta', text: `${name} 回复` });
|
|
@@ -46,6 +49,8 @@ async function main() {
|
|
|
46
49
|
assert.deepEqual(optimized.conversationTail.map(message => message.role), ['user', 'assistant', 'user', 'assistant']);
|
|
47
50
|
assert.equal(optimized.conversationTail.at(-1).text, longSolution, '最新方案应获得更大的交接预算');
|
|
48
51
|
assert.ok(optimized.conversationTail.reduce((sum, message) => sum + message.text.length, 0) <= CAP.totalChars);
|
|
52
|
+
assert.match(composeHandoffEnvelope({ fromHarnessId: 'a', context: {}, intent: 'review' }), /Do not modify files/);
|
|
53
|
+
assert.match(composeHandoffEnvelope({ fromHarnessId: 'a', context: {}, intent: 'reanalyze' }), /Reanalyze the task independently/);
|
|
49
54
|
|
|
50
55
|
await fs.mkdir('output', { recursive: true });
|
|
51
56
|
const root = await fs.mkdtemp(path.resolve('output/switch-harness-'));
|
|
@@ -90,17 +95,21 @@ async function main() {
|
|
|
90
95
|
const checkpointBeforeSend = rt.handoffs.get(thread.id, firstCheckpointId);
|
|
91
96
|
assert.equal(checkpointBeforeSend.intent, 'continue');
|
|
92
97
|
assert.equal(checkpointBeforeSend.fileState.files.some(file => file.path === 'src/login.ts' && file.changeType === 'added'), true);
|
|
93
|
-
assert.equal(rt.handoffs.owned(thread.id, firstCheckpointId).status, '
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
98
|
+
assert.equal(rt.handoffs.owned(thread.id, firstCheckpointId).status, 'ready');
|
|
99
|
+
assert.equal(b.state.opens[0].restore, false, '新 Harness 上是全新原生会话');
|
|
100
|
+
assert.equal(thread.restore, undefined, '目标 Session 打开后清理瞬态 restore 标记');
|
|
101
|
+
assert.equal(thread.pendingHandoff.phase, 'ready');
|
|
102
|
+
assert.equal(rt.sessions.has(thread.id), true, '目标 Harness 连接成功后才完成切换');
|
|
96
103
|
assert.equal(a.state.closed, 1);
|
|
97
|
-
assert.equal(b.state.opens.length,
|
|
104
|
+
assert.equal(b.state.opens.length, 1, '切换阶段预先连接目标 Harness');
|
|
98
105
|
assert.equal(thread.messages.filter(m => m.role === 'user').length, 1, '/switch 指令本身不落在会话里');
|
|
106
|
+
await assert.rejects(rt.switchHarness(thread.id, 'a'), /上一次 Harness 接力尚未完成/);
|
|
107
|
+
assert.ok((await rt.listCommands({ threadId: thread.id })).some(command => command.id === 'switch-cancel'));
|
|
99
108
|
|
|
100
109
|
// 4) 切换后首轮:信封注入 prompt,可见消息保持干净,注入后清除标记
|
|
101
110
|
await rt.send(thread.id, '继续');
|
|
102
111
|
await settle(thread.id);
|
|
103
|
-
assert.equal(b.state.opens.length, 1);
|
|
112
|
+
assert.equal(b.state.opens.length, 1, '首轮发送复用切换阶段已连接的目标 Session');
|
|
104
113
|
assert.equal(b.state.opens[0].restore, false);
|
|
105
114
|
assert.notEqual(thread.nativeSessionId, aNativeId);
|
|
106
115
|
assert.match(b.state.sent[0], /\[Harness Mix handoff\]/);
|
|
@@ -119,18 +128,18 @@ async function main() {
|
|
|
119
128
|
await settle(thread.id);
|
|
120
129
|
assert.ok(!b.state.sent[1].includes('[Harness Mix handoff]'), '信封只注入一次');
|
|
121
130
|
|
|
122
|
-
// 6) 切回 A
|
|
131
|
+
// 6) 切回 A:原生会话引用恢复并在提交切换前验证连接,chain 压栈两条
|
|
123
132
|
await rt.send(thread.id, '/switch a');
|
|
124
133
|
assert.equal(thread.harnessId, 'a');
|
|
125
134
|
assert.equal(thread.harnessChain.length, 2);
|
|
126
135
|
assert.equal(thread.harnessChain[1].harnessId, 'b');
|
|
127
136
|
assert.equal(thread.nativeSessionId, aNativeId, '切回恢复原原生会话标识');
|
|
128
|
-
assert.equal(thread.restore,
|
|
129
|
-
await rt.send(thread.id, '我回来了');
|
|
130
|
-
await settle(thread.id);
|
|
137
|
+
assert.equal(thread.restore, undefined);
|
|
131
138
|
assert.equal(a.state.opens.length, 2);
|
|
132
139
|
assert.equal(a.state.opens[1].restore, true, '切回走原生恢复路径');
|
|
133
140
|
assert.equal(a.state.opens[1].nativeSessionId, aNativeId);
|
|
141
|
+
await rt.send(thread.id, '我回来了');
|
|
142
|
+
await settle(thread.id);
|
|
134
143
|
assert.match(a.state.sent[1], /\[Harness Mix handoff\]/, '切回首轮同样注入信封');
|
|
135
144
|
|
|
136
145
|
// 7) 守卫:执行中 / 同 Harness / 未知 Harness / 草稿 / 协作子任务
|
|
@@ -152,7 +161,65 @@ async function main() {
|
|
|
152
161
|
const child = await rt.createThread({ harnessId: 'a', cwd: root, parentThreadId: thread.id });
|
|
153
162
|
await assert.rejects(rt.switchHarness(child.id, 'b'), /协作子任务/);
|
|
154
163
|
|
|
155
|
-
// 8)
|
|
164
|
+
// 8) 待投递接力可显式取消:关闭目标 Session,恢复源 Harness,不污染既有链
|
|
165
|
+
const chainBeforeCancel = thread.harnessChain.length;
|
|
166
|
+
const cancelCandidate = await rt.switchHarness(thread.id, 'b', { intent: 'review' });
|
|
167
|
+
assert.equal(thread.pendingHandoff.phase, 'ready');
|
|
168
|
+
await rt.send(thread.id, '/switch cancel');
|
|
169
|
+
assert.equal(thread.harnessId, 'a');
|
|
170
|
+
assert.equal(thread.pendingHandoff, undefined);
|
|
171
|
+
assert.equal(thread.harnessChain.length, chainBeforeCancel);
|
|
172
|
+
assert.equal(rt.handoffs.owned(thread.id, cancelCandidate.checkpointId).status, 'cancelled');
|
|
173
|
+
assert.equal(rt.sessions.get(thread.id)?.adapter, a.adapter);
|
|
174
|
+
|
|
175
|
+
// 9) 目标连接失败时事务回滚:线程归属、原生源 Session 与 chain 都恢复
|
|
176
|
+
const c = fakeHarness('c', 'Gamma');
|
|
177
|
+
c.state.openError = 'Gamma auth expired';
|
|
178
|
+
rt.adapters.set('c', c.adapter); rt.status.c = { available: true };
|
|
179
|
+
await assert.rejects(rt.switchHarness(thread.id, 'c'), /已恢复原 Harness.*Gamma auth expired/);
|
|
180
|
+
assert.equal(thread.harnessId, 'a');
|
|
181
|
+
assert.equal(thread.pendingHandoff, undefined);
|
|
182
|
+
assert.equal(thread.harnessChain.length, chainBeforeCancel);
|
|
183
|
+
assert.equal(rt.sessions.get(thread.id)?.adapter, a.adapter);
|
|
184
|
+
const rolledBack = [...rt.handoffs.checkpoints.values()].find(row => row.targetHarnessId === 'c');
|
|
185
|
+
assert.equal(rolledBack.status, 'rolled-back');
|
|
186
|
+
|
|
187
|
+
// 10) 首轮投递失败保留检查点;下一次发送复用同一目标 Session 和同一检查点重试
|
|
188
|
+
a.state.withFileChange = false;
|
|
189
|
+
const retryThread = await rt.createThread({ harnessId: 'a', cwd: root });
|
|
190
|
+
await rt.send(retryThread.id, '准备接力重试');
|
|
191
|
+
await settle(retryThread.id);
|
|
192
|
+
const retrySwitch = await rt.switchHarness(retryThread.id, 'b', { intent: 'review' });
|
|
193
|
+
b.state.sendError = 'temporary transport failure';
|
|
194
|
+
await rt.send(retryThread.id, '先审查');
|
|
195
|
+
await settle(retryThread.id);
|
|
196
|
+
assert.equal(retryThread.pendingHandoff.phase, 'failed');
|
|
197
|
+
assert.equal(rt.handoffs.owned(retryThread.id, retrySwitch.checkpointId).status, 'failed');
|
|
198
|
+
b.state.sendError = null;
|
|
199
|
+
await rt.send(retryThread.id, '重试审查');
|
|
200
|
+
await settle(retryThread.id);
|
|
201
|
+
assert.equal(retryThread.pendingHandoff, undefined);
|
|
202
|
+
assert.equal(rt.handoffs.owned(retryThread.id, retrySwitch.checkpointId).status, 'active');
|
|
203
|
+
assert.match(b.state.sent.at(-1), /Handoff mode: review.*Do not modify files/s);
|
|
204
|
+
|
|
205
|
+
// 11) 并发切换按 Thread 串行化:第二个 RPC 不得在第一个连接期间再创建检查点
|
|
206
|
+
const concurrentThread = await rt.createThread({ harnessId: 'a', cwd: root });
|
|
207
|
+
await rt.send(concurrentThread.id, '准备并发切换');
|
|
208
|
+
await settle(concurrentThread.id);
|
|
209
|
+
const d = fakeHarness('d', 'Delta');
|
|
210
|
+
let releaseDelta;
|
|
211
|
+
d.state.openWait = new Promise(resolve => { releaseDelta = resolve; });
|
|
212
|
+
rt.adapters.set('d', d.adapter); rt.status.d = { available: true };
|
|
213
|
+
const firstSwitch = rt.switchHarness(concurrentThread.id, 'd');
|
|
214
|
+
await wait(() => rt.switching.has(concurrentThread.id));
|
|
215
|
+
await assert.rejects(rt.switchHarness(concurrentThread.id, 'b'), /正在切换 Harness/);
|
|
216
|
+
releaseDelta();
|
|
217
|
+
await firstSwitch;
|
|
218
|
+
assert.equal(concurrentThread.harnessId, 'd');
|
|
219
|
+
await rt.cancelHarnessSwitch(concurrentThread.id);
|
|
220
|
+
assert.equal(concurrentThread.harnessId, 'a');
|
|
221
|
+
|
|
222
|
+
// 12) 持久化:threads.json 里切换链完整(含原生会话引用,供重启后惰性恢复)
|
|
156
223
|
await rt.store.save(rt.threads);
|
|
157
224
|
const persisted = (await rt.store.load()).find(t => t.id === thread.id);
|
|
158
225
|
assert.equal(persisted.harnessId, 'a');
|
|
@@ -89,20 +89,22 @@ async function main() {
|
|
|
89
89
|
const call = { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'same-id', name: 'Read', input: { file_path: 'a.txt' } }] } };
|
|
90
90
|
for (const event of projectEvent(call)) emitA(event);
|
|
91
91
|
emitB({ kind: 'tool', toolCallId: 'same-id', title: 'Read', state: 'running', input: 'b.txt' });
|
|
92
|
-
assert.equal(notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === '
|
|
92
|
+
assert.equal(notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall').length, 0);
|
|
93
93
|
for (const event of projectEvent({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'same-id', content: [{ type: 'text', text: 'only A' }] }] } })) emitA(event);
|
|
94
94
|
emitB({ kind: 'tool', toolCallId: 'same-id', state: 'error', output: 'only B failed' });
|
|
95
|
-
const completed = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === '
|
|
95
|
+
const completed = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall');
|
|
96
96
|
assert.equal(completed.length, 2);
|
|
97
|
-
assert.equal(completed.find(n => n.params.threadId === a.id).params.item.
|
|
98
|
-
|
|
97
|
+
assert.equal(completed.find(n => n.params.threadId === a.id).params.item.contentItems[0].text, 'only A');
|
|
98
|
+
const failedB = completed.find(n => n.params.threadId === b.id).params.item;
|
|
99
|
+
assert.equal(failedB.status, 'failed');
|
|
100
|
+
assert.equal(failedB.contentItems[0].text, 'only B failed');
|
|
99
101
|
assert.equal(a.tools[0].input, '{"file_path":"a.txt"}');
|
|
100
102
|
assert.equal(a.tools[0].output, 'only A');
|
|
101
103
|
assert.equal(b.tools[0].output, 'only B failed');
|
|
102
104
|
emitB({ kind: 'tool', toolCallId: 'same-id', state: 'error', output: { error: 'late detail' } });
|
|
103
|
-
const late = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === '
|
|
105
|
+
const late = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall').at(-1);
|
|
104
106
|
assert.equal(late.params.threadId, b.id);
|
|
105
|
-
assert.equal(late.params.item.
|
|
107
|
+
assert.equal(late.params.item.contentItems[0].text, '{"error":"late detail"}');
|
|
106
108
|
emitA({ kind: 'completed', finalAnswer: true });
|
|
107
109
|
assert.equal(rt.execution.isRunning(b.id), true);
|
|
108
110
|
emitB({ kind: 'completed', finalAnswer: true });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cline</title><path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path><path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path></svg>
|