zen-gitsync 2.18.20 → 2.18.22

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 (88) hide show
  1. package/package.json +4 -1
  2. package/src/cli/ai/agent.js +9 -2
  3. package/src/cli/ai/extensions.js +1 -1
  4. package/src/cli/ai/runtime.test.js +57 -1
  5. package/src/cli/ai/termui.js +11 -2
  6. package/src/cli/ai/tools.js +266 -5
  7. package/src/cli/ai/tools.test.js +81 -0
  8. package/src/cli/ai/transport.js +1 -1
  9. package/src/cli/ai/turn.js +59 -3
  10. package/src/config.js +60 -4
  11. package/src/mcp/install.js +548 -529
  12. package/src/ui/public/assets/AgentView-DdiyBMDD.js +26 -0
  13. package/src/ui/public/assets/{AgentView-BE7GD8rc.css → AgentView-_DUYxFmp.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-86tbKU20.js → AppVersionBadge-CSEM45L4.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-CsrkmTCA.js → BranchSelector-cSx2ZxYM.js} +1 -1
  16. package/src/ui/public/assets/ChatSilenceBadge-Dtv23ZmV.js +16 -0
  17. package/src/ui/public/assets/{CommitForm-DBK0GXEh.js → CommitForm-CrAsbDkq.js} +2 -2
  18. package/src/ui/public/assets/{CommonDialog-BkuDIWro.js → CommonDialog-BtO3uKdw.js} +1 -1
  19. package/src/ui/public/assets/{EditorView-CnWsMdHg.css → EditorView-B-t-nuf0.css} +1 -1
  20. package/src/ui/public/assets/EditorView-BesiWTV2.js +10 -0
  21. package/src/ui/public/assets/{FlowExecutionViewer-ClUkAmA2.js → FlowExecutionViewer-CfU9DLFG.js} +1 -1
  22. package/src/ui/public/assets/{FlowOrchestrationWorkspace-B3sOK3fz.js → FlowOrchestrationWorkspace-D3CSRMvG.js} +1 -1
  23. package/src/ui/public/assets/{LogList-6aO0-2xh.js → LogList-CT-SRIS3.js} +4 -4
  24. package/src/ui/public/assets/MindmapView-BCGq2zdh.js +35 -0
  25. package/src/ui/public/assets/MindmapView-CoUbyadF.css +1 -0
  26. package/src/ui/public/assets/{MonitorView-fcNrgVaU.js → MonitorView-BuBJJVhR.js} +1 -1
  27. package/src/ui/public/assets/{ProjectManagerView-CZfrML6A.js → ProjectManagerView-DlSfRn9N.js} +2 -2
  28. package/src/ui/public/assets/{ProjectStartupButton-D5MREz6F.js → ProjectStartupButton-DKZttDb3.js} +1 -1
  29. package/src/ui/public/assets/RemoteManagerDialog-CVrhhUPg.js +4 -0
  30. package/src/ui/public/assets/{RemoteRepoCard-CTnmfv2y.js → RemoteRepoCard-CmdYDF24.js} +1 -1
  31. package/src/ui/public/assets/{SourceMapView-B4q5qiE8.js → SourceMapView-DClgTmda.js} +1 -1
  32. package/src/ui/public/assets/{SvgIcon-WpEiC1rI.js → SvgIcon-D5fhBiJr.js} +1 -1
  33. package/src/ui/public/assets/TaskExecutorPicker-CzvWdS_K.js +1 -0
  34. package/src/ui/public/assets/TaskExecutorPicker-Dn8SgrTZ.css +1 -0
  35. package/src/ui/public/assets/{UserInputNode-Deoc3UvQ.js → UserInputNode-CNuW_Dtu.js} +1 -1
  36. package/src/ui/public/assets/WorkbenchView-D61DxNnn.css +1 -0
  37. package/src/ui/public/assets/WorkbenchView-WKNPn-oz.js +341 -0
  38. package/src/ui/public/assets/_plugin-vue_export-helper-E2AaPXn7.js +19 -0
  39. package/src/ui/public/assets/configStore-JIX2MOKo.js +1 -0
  40. package/src/ui/public/assets/{dagre-CpKxpMNH.js → dagre-yiQDclZv.js} +3 -3
  41. package/src/ui/public/assets/{element-plus-Bz6lYur-.js → element-plus-BeZBQw4X.js} +1 -1
  42. package/src/ui/public/assets/{flow-mindmap-cTg78Krq.js → flow-mindmap-CGjPqCv0.js} +1 -1
  43. package/src/ui/public/assets/{index-cDiVh_ZL.js → index-Cjv2Z6nF.js} +134 -125
  44. package/src/ui/public/assets/index-IjYv2goK.css +1 -0
  45. package/src/ui/public/assets/{monaco-CvMddat7.js → monaco-CMQow_j-.js} +1 -1
  46. package/src/ui/public/assets/{office-docx-VOB-i4K2.js → office-docx-nuGPI15f.js} +1 -1
  47. package/src/ui/public/assets/{office-excel-CJ2JvfA8.js → office-excel-CK-HOYi8.js} +1 -1
  48. package/src/ui/public/assets/{office-pptx-Z88k9qnx.js → office-pptx-9onMeLux.js} +1 -1
  49. package/src/ui/public/assets/{relativeTime-CoXAs57j.js → relativeTime-DHhE9Ihn.js} +1 -1
  50. package/src/ui/public/assets/taskExecutionExport-DlBMELzX.js +15 -0
  51. package/src/ui/public/assets/toolsStore-h5iqPyST.js +1 -0
  52. package/src/ui/public/assets/{useWorkbenchProjects-BS59r5Rj.js → useWorkbenchProjects-DW3vBHpK.js} +1 -1
  53. package/src/ui/public/assets/{vendor-CSFGMcMB.css → vendor-CAhoE06w.css} +1 -1
  54. package/src/ui/public/assets/{vendor-C2k0GBzj.js → vendor-KxSgeiUX.js} +303 -303
  55. package/src/ui/public/assets/{vue-flow-Cnxq2qrs.js → vue-flow-BMMiguH1.js} +1 -1
  56. package/src/ui/public/index.html +14 -14
  57. package/src/ui/server/routes/config.js +13 -0
  58. package/src/ui/server/routes/fileOpen.js +1851 -1736
  59. package/src/ui/server/routes/fileOpen.test.js +39 -1
  60. package/src/ui/server/routes/workbench/agentChat.js +48 -8
  61. package/src/ui/server/routes/workbench/agentChatShared.test.js +584 -496
  62. package/src/ui/server/routes/workbench/agentEngines.js +7 -4
  63. package/src/ui/server/routes/workbench/executorModels.js +25 -4
  64. package/src/ui/server/routes/workbench/executorModels.test.js +557 -530
  65. package/src/ui/server/routes/workbench/jobActivity.js +2 -2
  66. package/src/ui/server/routes/workbench/taskRunner.codearts.test.js +252 -0
  67. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +317 -317
  68. package/src/ui/server/routes/workbench/taskRunner.js +216 -7
  69. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +130 -130
  70. package/src/ui/server/routes/workbench/taskRunner.pi.test.js +298 -298
  71. package/src/ui/public/assets/AgentView-Du9akUeZ.js +0 -12
  72. package/src/ui/public/assets/ChatSilenceBadge-BBoFeSqe.js +0 -21
  73. package/src/ui/public/assets/EditorAgentPanel-BMUphuG0.css +0 -1
  74. package/src/ui/public/assets/EditorAgentPanel-KiHbQPJP.js +0 -34
  75. package/src/ui/public/assets/EditorView-B18B4yNJ.js +0 -13
  76. package/src/ui/public/assets/MindmapView-BDI3D_1h.css +0 -1
  77. package/src/ui/public/assets/MindmapView-aJ9TTijE.js +0 -4
  78. package/src/ui/public/assets/RecentDirectoriesChat-DBGoUlkn.css +0 -1
  79. package/src/ui/public/assets/RecentDirectoriesChat-wfkSF6NU.js +0 -3
  80. package/src/ui/public/assets/RemoteManagerDialog-BBwNlK9J.js +0 -4
  81. package/src/ui/public/assets/WorkbenchView-CVqXb__l.css +0 -1
  82. package/src/ui/public/assets/WorkbenchView-CijzpDWN.js +0 -360
  83. package/src/ui/public/assets/_plugin-vue_export-helper-BhdsvhKo.js +0 -19
  84. package/src/ui/public/assets/agentConversations-DaAtF1v4.js +0 -10
  85. package/src/ui/public/assets/configStore-Drlw8KtR.js +0 -1
  86. package/src/ui/public/assets/index-DSQ6mp_N.css +0 -1
  87. package/src/ui/public/assets/toolsStore-CnX34C96.js +0 -1
  88. package/src/ui/public/assets/useOrchestrator-BXmxJsRl.js +0 -1
@@ -1,298 +1,298 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
-
16
- // pi 执行器(Pi Agent CLI,npm @earendil-works/pi-coding-agent)的回归测试
17
- // (2026-10-13)。
18
- //
19
- // 钉住的口径(协议部分来自官方 docs/json.md,**事件形状全部经本机 v1.2.0 实测核对**
20
- // —— 用 mock provider 真跑过一轮"文本 + 工具调用", fixture 就是那次输出的原样):
21
- // 1. 白名单 / 归一化里有 pi(config.js 与 taskRunner 两份清单一致)
22
- // 2. spawn argv:--mode json + -a(信任项目本地配置;pi 没有权限系统,
23
- // 不存在 -y / --dangerously-* 那类档位参数);续聊传 --session <id>
24
- // (**不是 --resume** —— 那是交互式会话选择器,headless 会卡住);
25
- // 不出现 --model(跟随 CLI 自己的配置,与另四家同口径)
26
- // 3. 子进程环境:PI_SKIP_VERSION_CHECK=1(禁 pi.dev 启动版本检查)、
27
- // PI_TELEMETRY=0、LANG=zh_CN.UTF-8;继承来的嵌套会话标记
28
- // PI_SESSION_ID / PI_SESSION_FILE / PI_CODING_AGENT / AI_AGENT 必须剥掉
29
- // (官方环境变量文档警告嵌套 pi 会暴露父会话过期元数据)
30
- // 4. 事件映射(pi 自有 JSONL,与 claude 的 stream-json 一处都对不上):
31
- // session header → 会话 id;message_update.assistantMessageEvent 的
32
- // text_delta / thinking_delta → 正文 / 思考;tool_execution_start /
33
- // tool_execution_end → 工具调用两条;error / auto_retry_end(finalError) /
34
- // compaction_end(errorMessage) → job.agentError
35
- // 5. agent_* / turn_* / message_start|end / queue_* / tool_execution_update
36
- // 一律忽略 —— message_start 里那条 system prompt 有几万字节,
37
- // 当正文吐出去会直接淹没有用输出
38
- //
39
- // 隔离策略同 taskRunner.codebuddy.test.js:import taskRunner.js 之前把
40
- // USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
41
-
42
- import { test } from 'node:test';
43
- import assert from 'node:assert/strict';
44
- import path from 'node:path';
45
- import os from 'node:os';
46
- import { mkdtempSync } from 'node:fs';
47
- import { fileURLToPath, pathToFileURL } from 'node:url';
48
-
49
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
- const projectRoot = path.resolve(__dirname, '../../../../..');
51
-
52
- // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
53
- const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-pi-test-'));
54
- process.env.USERPROFILE = sandbox;
55
- process.env.HOME = sandbox;
56
- delete process.env.HOMEDRIVE;
57
- delete process.env.HOMEPATH;
58
-
59
- const {
60
- createPiEventHandler,
61
- buildPiArgs,
62
- buildPiEnv,
63
- normalizeTaskExecutor,
64
- TASK_EXECUTORS,
65
- } = await import(
66
- pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
67
- );
68
-
69
- /** 可断言的 appendOutput/appendThinking 收集器 */
70
- function makeSink() {
71
- const sink = { output: [], thinking: [] };
72
- sink.appendOutput = (t) => { if (t) sink.output.push(t); };
73
- sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
74
- return sink;
75
- }
76
-
77
- /** tracker 探针:只记 handler 喂了哪些 record(不碰真 tracker 的落盘/推送) */
78
- function makeTrackerSpy() {
79
- const records = [];
80
- return {
81
- records,
82
- record: (r) => records.push(r),
83
- flush: () => {},
84
- };
85
- }
86
-
87
- test('normalizeTaskExecutor:pi 在白名单里,非法值回落 claude', () => {
88
- assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode', 'codex', 'codebuddy', 'pi']);
89
- assert.equal(normalizeTaskExecutor('pi'), 'pi');
90
- assert.equal(normalizeTaskExecutor('PI'), 'pi');
91
- assert.equal(normalizeTaskExecutor(' pi '), 'pi');
92
- // 历史默认不能变:老配置/老 job 缺 executor 时仍落在 claude
93
- assert.equal(normalizeTaskExecutor(undefined), 'claude');
94
- assert.equal(normalizeTaskExecutor('cursor'), 'claude');
95
- });
96
-
97
- test('buildPiArgs:基础 argv 带 --mode json + -a,不带 --model / --resume', () => {
98
- const args = buildPiArgs('');
99
- assert.deepEqual(args, ['--mode', 'json', '-a']);
100
- // 不传模型:跟随 CLI 自己的配置(与另四家同一口径)
101
- assert.ok(!args.includes('--model'));
102
- // ⚠️ --resume 是 pi 的**交互式会话选择器**,headless 下会开 TUI 卡住 ——
103
- // 续聊只能走 --session(见下一条)。这里双向钉住:不许出现。
104
- assert.ok(!args.includes('--resume'));
105
- });
106
-
107
- test('buildPiArgs:有续聊 id 时 --session 在前(不是 --resume)', () => {
108
- const args = buildPiArgs('01a13370-8352-75bb-9ccd-6e56480e704d');
109
- assert.deepEqual(args.slice(0, 2), ['--session', '01a13370-8352-75bb-9ccd-6e56480e704d']);
110
- assert.ok(args.includes('--mode'));
111
- assert.ok(args.includes('-a'));
112
- });
113
-
114
- test('buildPiEnv:禁版本检查 + 关遥测 + LANG,剥掉继承来的嵌套会话标记', () => {
115
- // 模拟"g gui 在某个 pi 会话的 bash 工具里被拉起":父会话的标记会继承过来。
116
- const had = {};
117
- const pollution = {
118
- // 嵌套 pi 的过期会话元数据(官方环境变量文档明确警告的一类)
119
- PI_SESSION_ID: 'parent-session-id',
120
- PI_SESSION_FILE: 'C:/parent/session.jsonl',
121
- PI_CODING_AGENT: 'true',
122
- AI_AGENT: 'pi',
123
- // 反例:用户的合法配置不在剥离范围
124
- ANTHROPIC_API_KEY: 'user-key',
125
- HTTPS_PROXY: 'http://127.0.0.1:10808',
126
- };
127
- for (const [k, v] of Object.entries(pollution)) { had[k] = process.env[k]; process.env[k] = v; }
128
- try {
129
- const env = buildPiEnv();
130
- for (const k of ['PI_SESSION_ID', 'PI_SESSION_FILE', 'PI_CODING_AGENT', 'AI_AGENT']) {
131
- assert.equal(env[k], undefined, `${k} 应被剥掉(嵌套 pi 的父会话元数据)`);
132
- }
133
- // 自己的三项设定
134
- assert.equal(env.PI_SKIP_VERSION_CHECK, '1');
135
- assert.equal(env.PI_TELEMETRY, '0');
136
- assert.equal(env.LANG, 'zh_CN.UTF-8');
137
- // 用户合法变量原样继承(认证 / 代理)
138
- assert.equal(env.ANTHROPIC_API_KEY, 'user-key');
139
- assert.equal(env.HTTPS_PROXY, 'http://127.0.0.1:10808');
140
- } finally {
141
- for (const [k, v] of Object.entries(had)) {
142
- if (v === undefined) delete process.env[k]; else process.env[k] = v;
143
- }
144
- }
145
- });
146
-
147
- test('pi 事件:session header 落会话 id,text_delta/thinking_delta 分派', () => {
148
- const job = {};
149
- const sink = makeSink();
150
- const tracker = makeTrackerSpy();
151
- const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
152
-
153
- // 首条就是 session header(实测输出的第一条,原样)
154
- handle({ type: 'session', version: 3, id: '01a13370-8352-75bb-9ccd-6e56480e704d', timestamp: '2026-10-13T04:06:14.099Z', cwd: 'D:/ws' });
155
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '让我跑个命令。' } });
156
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: '先想想' } });
157
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '跑完了' } });
158
-
159
- assert.equal(job.claudeSessionId, '01a13370-8352-75bb-9ccd-6e56480e704d');
160
- assert.deepEqual(sink.output, ['让我跑个命令。', '跑完了']);
161
- assert.deepEqual(sink.thinking, ['先想想']);
162
- assert.equal(tracker.records.length, 0);
163
- });
164
-
165
- test('pi 事件:tool_execution_start/end 两条式(args 完整、result 抽文本、isError 映射 error)', () => {
166
- const job = {};
167
- const sink = makeSink();
168
- const tracker = makeTrackerSpy();
169
- const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
170
-
171
- // 实测序列(v1.2.0 + mock provider):bash 跑通 → bash 报错
172
- handle({ type: 'tool_execution_start', toolCallId: 'call_mock_1', toolName: 'bash', args: { command: 'echo mock-output' } });
173
- handle({ type: 'tool_execution_update', toolCallId: 'call_mock_1', toolName: 'bash', args: { command: 'echo mock-output' }, partialResult: { content: [{ type: 'text', text: 'partial' }], details: {} } });
174
- handle({ type: 'tool_execution_end', toolCallId: 'call_mock_1', toolName: 'bash', result: { content: [{ type: 'text', text: 'mock-output\n' }], details: {} }, isError: false, durationMs: 54 });
175
- handle({ type: 'tool_execution_start', toolCallId: 'call_mock_2', toolName: 'read', args: { path: 'missing.txt' } });
176
- handle({ type: 'tool_execution_end', toolCallId: 'call_mock_2', toolName: 'read', result: { content: [{ type: 'text', text: 'ENOENT' }], details: {} }, isError: true, durationMs: 3 });
177
-
178
- // end 记录只带 id/status/output/error:name 在 start 那条里,真 tracker 按 id
179
- // 合并(与 codebuddy 的 user(tool_result) 同一口径 —— 那条也不重复带 name)
180
- assert.deepEqual(tracker.records.map(r => [r.id, r.status]), [
181
- ['call_mock_1', 'running'],
182
- ['call_mock_1', 'done'],
183
- ['call_mock_2', 'running'],
184
- ['call_mock_2', 'error'],
185
- ]);
186
- assert.equal(tracker.records[0].name, 'bash');
187
- assert.equal(tracker.records[2].name, 'read');
188
- // start 带的是完整解析值(不用拼 toolcall_delta),原样进 input
189
- assert.deepEqual(tracker.records[0].input, { command: 'echo mock-output' });
190
- // end 的 result.content 是块数组(与 claude 的 tool_result 同形状)
191
- assert.match(String(tracker.records[1].output), /mock-output/);
192
- assert.match(String(tracker.records[3].error), /ENOENT/);
193
- // 工具事件不当正文吐出来
194
- assert.deepEqual(sink.output, []);
195
- });
196
-
197
- test('pi 事件:agent_*/turn_*/message_start|end 等一律忽略(system prompt 有几万字节)', () => {
198
- const job = {};
199
- const sink = makeSink();
200
- const tracker = makeTrackerSpy();
201
- const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
202
-
203
- // message_start 的 system 消息在实测里是**整份系统提示词**(preamble/tools/rules/
204
- // docs/skills 五大段),当正文吐出去会淹没有用输出;message_end 只是权威副本
205
- // (正文已由 text_delta 增量覆盖,再吐一遍就是重复)。
206
- handle({ type: 'agent_start' });
207
- handle({ type: 'turn_start' });
208
- handle({ type: 'message_start', message: { role: 'system', content: '', sections: { preamble: 'You are an expert coding assistant…' }, timestamp: 1 } });
209
- handle({ type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'hi, run echo' }], timestamp: 2 } });
210
- handle({ type: 'message_start', message: { role: 'assistant', content: [], stopReason: 'pending' } });
211
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_start', contentIndex: 0 } });
212
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: '完整文本' } });
213
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'toolcall_start', contentIndex: 1, id: 'c1', toolName: 'bash' } });
214
- handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'toolcall_end', contentIndex: 1, toolCall: { type: 'toolCall', id: 'c1', name: 'bash', arguments: { command: 'ls' } } } });
215
- handle({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '完整文本' }] } });
216
- handle({ type: 'turn_end', message: { role: 'assistant' }, toolResults: [] });
217
- handle({ type: 'queue_update', steering: [], followUp: [] });
218
- handle({ type: 'agent_end', messages: [], willRetry: false });
219
- handle({ type: 'agent_settled', aborted: false });
220
-
221
- assert.deepEqual(sink.output, []);
222
- assert.deepEqual(sink.thinking, []);
223
- assert.equal(tracker.records.length, 0);
224
- assert.equal(job.claudeSessionId, undefined);
225
- });
226
-
227
- test('pi 事件:协议层错误置 job.agentError(message error / auto_retry_end / compaction_end)', () => {
228
- // provider 流中途出错:message_update 的 assistantMessageEvent.type === 'error'
229
- const job1 = {};
230
- createPiEventHandler(job1, () => {}, () => {}, makeTrackerSpy())(
231
- { type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: '529 overloaded' } }
232
- );
233
- assert.equal(job1.agentError, '529 overloaded');
234
-
235
- // 自动重试耗尽:auto_retry_end success:false + finalError
236
- const job2 = {};
237
- createPiEventHandler(job2, () => {}, () => {}, makeTrackerSpy())(
238
- { type: 'auto_retry_end', success: false, attempt: 3, finalError: 'rate limited after 3 attempts' }
239
- );
240
- assert.equal(job2.agentError, 'rate limited after 3 attempts');
241
-
242
- // 压缩失败且不重试:compaction_end 带 errorMessage
243
- const job3 = {};
244
- createPiEventHandler(job3, () => {}, () => {}, makeTrackerSpy())(
245
- { type: 'compaction_end', reason: 'threshold', aborted: false, willRetry: false, errorMessage: 'summarization failed' }
246
- );
247
- assert.equal(job3.agentError, 'summarization failed');
248
-
249
- // 只记第一条(后续事件可能是同一次失败的余波)
250
- const job4 = {};
251
- const h4 = createPiEventHandler(job4, () => {}, () => {}, makeTrackerSpy());
252
- h4({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: 'first' } });
253
- h4({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: 'second' } });
254
- assert.equal(job4.agentError, 'first');
255
-
256
- // 成功的 compaction / auto_retry 不判失败
257
- const job5 = {};
258
- const h5 = createPiEventHandler(job5, () => {}, () => {}, makeTrackerSpy());
259
- h5({ type: 'compaction_end', reason: 'threshold', result: { summary: 'ok' }, aborted: false, willRetry: false });
260
- h5({ type: 'auto_retry_end', success: true, attempt: 2 });
261
- assert.equal(job5.agentError, undefined);
262
- });
263
-
264
- test('pi 事件:畸形/未知事件不能抛错(NDJSON 分行解析里抛错会打死整轮对话)', () => {
265
- const job = {};
266
- const sink = makeSink();
267
- const tracker = makeTrackerSpy();
268
- const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
269
-
270
- handle(null);
271
- handle('not-an-object');
272
- handle({});
273
- handle({ type: 'message_update' }); // 缺 assistantMessageEvent
274
- handle({ type: 'message_update', assistantMessageEvent: null });
275
- handle({ type: 'message_update', assistantMessageEvent: { type: 'text_delta' } }); // 缺 delta
276
- handle({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 42 } }); // delta 非字符串
277
- handle({ type: 'session', id: 123 }); // id 非字符串 → 不当会话标识
278
- handle({ type: 'tool_execution_start', toolName: 'bash' }); // 缺 toolCallId
279
- handle({ type: 'tool_execution_end', toolCallId: 'c9', result: null, isError: false });
280
- handle({ type: 'tool_execution_end', toolCallId: 'c10', result: { content: [] }, isError: true });
281
-
282
- assert.deepEqual(sink.output, []);
283
- assert.equal(job.claudeSessionId, undefined);
284
- // 只有带了合法 toolCallId 的两条 end 进了 tracker
285
- assert.deepEqual(tracker.records.map(r => [r.id, r.status]), [['c9', 'done'], ['c10', 'error']]);
286
- });
287
-
288
- test('pi 事件:session header 重复推送只认第一条', () => {
289
- const job = {};
290
- const sink = makeSink();
291
- const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, makeTrackerSpy());
292
-
293
- handle({ type: 'session', version: 3, id: 'pi-sess-x', cwd: 'D:/ws' });
294
- handle({ type: 'session', version: 3, id: 'pi-sess-x', cwd: 'D:/ws' });
295
-
296
- assert.equal(job.claudeSessionId, 'pi-sess-x');
297
- assert.deepEqual(sink.output, []);
298
- });
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+
16
+ // pi 执行器(Pi Agent CLI,npm @earendil-works/pi-coding-agent)的回归测试
17
+ // (2026-10-13)。
18
+ //
19
+ // 钉住的口径(协议部分来自官方 docs/json.md,**事件形状全部经本机 v1.2.0 实测核对**
20
+ // —— 用 mock provider 真跑过一轮"文本 + 工具调用", fixture 就是那次输出的原样):
21
+ // 1. 白名单 / 归一化里有 pi(config.js 与 taskRunner 两份清单一致)
22
+ // 2. spawn argv:--mode json + -a(信任项目本地配置;pi 没有权限系统,
23
+ // 不存在 -y / --dangerously-* 那类档位参数);续聊传 --session <id>
24
+ // (**不是 --resume** —— 那是交互式会话选择器,headless 会卡住);
25
+ // 不出现 --model(跟随 CLI 自己的配置,与另四家同口径)
26
+ // 3. 子进程环境:PI_SKIP_VERSION_CHECK=1(禁 pi.dev 启动版本检查)、
27
+ // PI_TELEMETRY=0、LANG=zh_CN.UTF-8;继承来的嵌套会话标记
28
+ // PI_SESSION_ID / PI_SESSION_FILE / PI_CODING_AGENT / AI_AGENT 必须剥掉
29
+ // (官方环境变量文档警告嵌套 pi 会暴露父会话过期元数据)
30
+ // 4. 事件映射(pi 自有 JSONL,与 claude 的 stream-json 一处都对不上):
31
+ // session header → 会话 id;message_update.assistantMessageEvent 的
32
+ // text_delta / thinking_delta → 正文 / 思考;tool_execution_start /
33
+ // tool_execution_end → 工具调用两条;error / auto_retry_end(finalError) /
34
+ // compaction_end(errorMessage) → job.agentError
35
+ // 5. agent_* / turn_* / message_start|end / queue_* / tool_execution_update
36
+ // 一律忽略 —— message_start 里那条 system prompt 有几万字节,
37
+ // 当正文吐出去会直接淹没有用输出
38
+ //
39
+ // 隔离策略同 taskRunner.codebuddy.test.js:import taskRunner.js 之前把
40
+ // USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
41
+
42
+ import { test } from 'node:test';
43
+ import assert from 'node:assert/strict';
44
+ import path from 'node:path';
45
+ import os from 'node:os';
46
+ import { mkdtempSync } from 'node:fs';
47
+ import { fileURLToPath, pathToFileURL } from 'node:url';
48
+
49
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
+ const projectRoot = path.resolve(__dirname, '../../../../..');
51
+
52
+ // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
53
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-pi-test-'));
54
+ process.env.USERPROFILE = sandbox;
55
+ process.env.HOME = sandbox;
56
+ delete process.env.HOMEDRIVE;
57
+ delete process.env.HOMEPATH;
58
+
59
+ const {
60
+ createPiEventHandler,
61
+ buildPiArgs,
62
+ buildPiEnv,
63
+ normalizeTaskExecutor,
64
+ TASK_EXECUTORS,
65
+ } = await import(
66
+ pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
67
+ );
68
+
69
+ /** 可断言的 appendOutput/appendThinking 收集器 */
70
+ function makeSink() {
71
+ const sink = { output: [], thinking: [] };
72
+ sink.appendOutput = (t) => { if (t) sink.output.push(t); };
73
+ sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
74
+ return sink;
75
+ }
76
+
77
+ /** tracker 探针:只记 handler 喂了哪些 record(不碰真 tracker 的落盘/推送) */
78
+ function makeTrackerSpy() {
79
+ const records = [];
80
+ return {
81
+ records,
82
+ record: (r) => records.push(r),
83
+ flush: () => {},
84
+ };
85
+ }
86
+
87
+ test('normalizeTaskExecutor:pi 在白名单里,非法值回落 claude', () => {
88
+ assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode', 'codex', 'codebuddy', 'pi', 'codearts']);
89
+ assert.equal(normalizeTaskExecutor('pi'), 'pi');
90
+ assert.equal(normalizeTaskExecutor('PI'), 'pi');
91
+ assert.equal(normalizeTaskExecutor(' pi '), 'pi');
92
+ // 历史默认不能变:老配置/老 job 缺 executor 时仍落在 claude
93
+ assert.equal(normalizeTaskExecutor(undefined), 'claude');
94
+ assert.equal(normalizeTaskExecutor('cursor'), 'claude');
95
+ });
96
+
97
+ test('buildPiArgs:基础 argv 带 --mode json + -a,不带 --model / --resume', () => {
98
+ const args = buildPiArgs('');
99
+ assert.deepEqual(args, ['--mode', 'json', '-a']);
100
+ // 不传模型:跟随 CLI 自己的配置(与另四家同一口径)
101
+ assert.ok(!args.includes('--model'));
102
+ // ⚠️ --resume 是 pi 的**交互式会话选择器**,headless 下会开 TUI 卡住 ——
103
+ // 续聊只能走 --session(见下一条)。这里双向钉住:不许出现。
104
+ assert.ok(!args.includes('--resume'));
105
+ });
106
+
107
+ test('buildPiArgs:有续聊 id 时 --session 在前(不是 --resume)', () => {
108
+ const args = buildPiArgs('01a13370-8352-75bb-9ccd-6e56480e704d');
109
+ assert.deepEqual(args.slice(0, 2), ['--session', '01a13370-8352-75bb-9ccd-6e56480e704d']);
110
+ assert.ok(args.includes('--mode'));
111
+ assert.ok(args.includes('-a'));
112
+ });
113
+
114
+ test('buildPiEnv:禁版本检查 + 关遥测 + LANG,剥掉继承来的嵌套会话标记', () => {
115
+ // 模拟"g gui 在某个 pi 会话的 bash 工具里被拉起":父会话的标记会继承过来。
116
+ const had = {};
117
+ const pollution = {
118
+ // 嵌套 pi 的过期会话元数据(官方环境变量文档明确警告的一类)
119
+ PI_SESSION_ID: 'parent-session-id',
120
+ PI_SESSION_FILE: 'C:/parent/session.jsonl',
121
+ PI_CODING_AGENT: 'true',
122
+ AI_AGENT: 'pi',
123
+ // 反例:用户的合法配置不在剥离范围
124
+ ANTHROPIC_API_KEY: 'user-key',
125
+ HTTPS_PROXY: 'http://127.0.0.1:10808',
126
+ };
127
+ for (const [k, v] of Object.entries(pollution)) { had[k] = process.env[k]; process.env[k] = v; }
128
+ try {
129
+ const env = buildPiEnv();
130
+ for (const k of ['PI_SESSION_ID', 'PI_SESSION_FILE', 'PI_CODING_AGENT', 'AI_AGENT']) {
131
+ assert.equal(env[k], undefined, `${k} 应被剥掉(嵌套 pi 的父会话元数据)`);
132
+ }
133
+ // 自己的三项设定
134
+ assert.equal(env.PI_SKIP_VERSION_CHECK, '1');
135
+ assert.equal(env.PI_TELEMETRY, '0');
136
+ assert.equal(env.LANG, 'zh_CN.UTF-8');
137
+ // 用户合法变量原样继承(认证 / 代理)
138
+ assert.equal(env.ANTHROPIC_API_KEY, 'user-key');
139
+ assert.equal(env.HTTPS_PROXY, 'http://127.0.0.1:10808');
140
+ } finally {
141
+ for (const [k, v] of Object.entries(had)) {
142
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
143
+ }
144
+ }
145
+ });
146
+
147
+ test('pi 事件:session header 落会话 id,text_delta/thinking_delta 分派', () => {
148
+ const job = {};
149
+ const sink = makeSink();
150
+ const tracker = makeTrackerSpy();
151
+ const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
152
+
153
+ // 首条就是 session header(实测输出的第一条,原样)
154
+ handle({ type: 'session', version: 3, id: '01a13370-8352-75bb-9ccd-6e56480e704d', timestamp: '2026-10-13T04:06:14.099Z', cwd: 'D:/ws' });
155
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '让我跑个命令。' } });
156
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: '先想想' } });
157
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: '跑完了' } });
158
+
159
+ assert.equal(job.claudeSessionId, '01a13370-8352-75bb-9ccd-6e56480e704d');
160
+ assert.deepEqual(sink.output, ['让我跑个命令。', '跑完了']);
161
+ assert.deepEqual(sink.thinking, ['先想想']);
162
+ assert.equal(tracker.records.length, 0);
163
+ });
164
+
165
+ test('pi 事件:tool_execution_start/end 两条式(args 完整、result 抽文本、isError 映射 error)', () => {
166
+ const job = {};
167
+ const sink = makeSink();
168
+ const tracker = makeTrackerSpy();
169
+ const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
170
+
171
+ // 实测序列(v1.2.0 + mock provider):bash 跑通 → bash 报错
172
+ handle({ type: 'tool_execution_start', toolCallId: 'call_mock_1', toolName: 'bash', args: { command: 'echo mock-output' } });
173
+ handle({ type: 'tool_execution_update', toolCallId: 'call_mock_1', toolName: 'bash', args: { command: 'echo mock-output' }, partialResult: { content: [{ type: 'text', text: 'partial' }], details: {} } });
174
+ handle({ type: 'tool_execution_end', toolCallId: 'call_mock_1', toolName: 'bash', result: { content: [{ type: 'text', text: 'mock-output\n' }], details: {} }, isError: false, durationMs: 54 });
175
+ handle({ type: 'tool_execution_start', toolCallId: 'call_mock_2', toolName: 'read', args: { path: 'missing.txt' } });
176
+ handle({ type: 'tool_execution_end', toolCallId: 'call_mock_2', toolName: 'read', result: { content: [{ type: 'text', text: 'ENOENT' }], details: {} }, isError: true, durationMs: 3 });
177
+
178
+ // end 记录只带 id/status/output/error:name 在 start 那条里,真 tracker 按 id
179
+ // 合并(与 codebuddy 的 user(tool_result) 同一口径 —— 那条也不重复带 name)
180
+ assert.deepEqual(tracker.records.map(r => [r.id, r.status]), [
181
+ ['call_mock_1', 'running'],
182
+ ['call_mock_1', 'done'],
183
+ ['call_mock_2', 'running'],
184
+ ['call_mock_2', 'error'],
185
+ ]);
186
+ assert.equal(tracker.records[0].name, 'bash');
187
+ assert.equal(tracker.records[2].name, 'read');
188
+ // start 带的是完整解析值(不用拼 toolcall_delta),原样进 input
189
+ assert.deepEqual(tracker.records[0].input, { command: 'echo mock-output' });
190
+ // end 的 result.content 是块数组(与 claude 的 tool_result 同形状)
191
+ assert.match(String(tracker.records[1].output), /mock-output/);
192
+ assert.match(String(tracker.records[3].error), /ENOENT/);
193
+ // 工具事件不当正文吐出来
194
+ assert.deepEqual(sink.output, []);
195
+ });
196
+
197
+ test('pi 事件:agent_*/turn_*/message_start|end 等一律忽略(system prompt 有几万字节)', () => {
198
+ const job = {};
199
+ const sink = makeSink();
200
+ const tracker = makeTrackerSpy();
201
+ const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
202
+
203
+ // message_start 的 system 消息在实测里是**整份系统提示词**(preamble/tools/rules/
204
+ // docs/skills 五大段),当正文吐出去会淹没有用输出;message_end 只是权威副本
205
+ // (正文已由 text_delta 增量覆盖,再吐一遍就是重复)。
206
+ handle({ type: 'agent_start' });
207
+ handle({ type: 'turn_start' });
208
+ handle({ type: 'message_start', message: { role: 'system', content: '', sections: { preamble: 'You are an expert coding assistant…' }, timestamp: 1 } });
209
+ handle({ type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'hi, run echo' }], timestamp: 2 } });
210
+ handle({ type: 'message_start', message: { role: 'assistant', content: [], stopReason: 'pending' } });
211
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_start', contentIndex: 0 } });
212
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: '完整文本' } });
213
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'toolcall_start', contentIndex: 1, id: 'c1', toolName: 'bash' } });
214
+ handle({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'toolcall_end', contentIndex: 1, toolCall: { type: 'toolCall', id: 'c1', name: 'bash', arguments: { command: 'ls' } } } });
215
+ handle({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: '完整文本' }] } });
216
+ handle({ type: 'turn_end', message: { role: 'assistant' }, toolResults: [] });
217
+ handle({ type: 'queue_update', steering: [], followUp: [] });
218
+ handle({ type: 'agent_end', messages: [], willRetry: false });
219
+ handle({ type: 'agent_settled', aborted: false });
220
+
221
+ assert.deepEqual(sink.output, []);
222
+ assert.deepEqual(sink.thinking, []);
223
+ assert.equal(tracker.records.length, 0);
224
+ assert.equal(job.claudeSessionId, undefined);
225
+ });
226
+
227
+ test('pi 事件:协议层错误置 job.agentError(message error / auto_retry_end / compaction_end)', () => {
228
+ // provider 流中途出错:message_update 的 assistantMessageEvent.type === 'error'
229
+ const job1 = {};
230
+ createPiEventHandler(job1, () => {}, () => {}, makeTrackerSpy())(
231
+ { type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: '529 overloaded' } }
232
+ );
233
+ assert.equal(job1.agentError, '529 overloaded');
234
+
235
+ // 自动重试耗尽:auto_retry_end success:false + finalError
236
+ const job2 = {};
237
+ createPiEventHandler(job2, () => {}, () => {}, makeTrackerSpy())(
238
+ { type: 'auto_retry_end', success: false, attempt: 3, finalError: 'rate limited after 3 attempts' }
239
+ );
240
+ assert.equal(job2.agentError, 'rate limited after 3 attempts');
241
+
242
+ // 压缩失败且不重试:compaction_end 带 errorMessage
243
+ const job3 = {};
244
+ createPiEventHandler(job3, () => {}, () => {}, makeTrackerSpy())(
245
+ { type: 'compaction_end', reason: 'threshold', aborted: false, willRetry: false, errorMessage: 'summarization failed' }
246
+ );
247
+ assert.equal(job3.agentError, 'summarization failed');
248
+
249
+ // 只记第一条(后续事件可能是同一次失败的余波)
250
+ const job4 = {};
251
+ const h4 = createPiEventHandler(job4, () => {}, () => {}, makeTrackerSpy());
252
+ h4({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: 'first' } });
253
+ h4({ type: 'message_update', usage: {}, assistantMessageEvent: { type: 'error', reason: 'error', error: 'second' } });
254
+ assert.equal(job4.agentError, 'first');
255
+
256
+ // 成功的 compaction / auto_retry 不判失败
257
+ const job5 = {};
258
+ const h5 = createPiEventHandler(job5, () => {}, () => {}, makeTrackerSpy());
259
+ h5({ type: 'compaction_end', reason: 'threshold', result: { summary: 'ok' }, aborted: false, willRetry: false });
260
+ h5({ type: 'auto_retry_end', success: true, attempt: 2 });
261
+ assert.equal(job5.agentError, undefined);
262
+ });
263
+
264
+ test('pi 事件:畸形/未知事件不能抛错(NDJSON 分行解析里抛错会打死整轮对话)', () => {
265
+ const job = {};
266
+ const sink = makeSink();
267
+ const tracker = makeTrackerSpy();
268
+ const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
269
+
270
+ handle(null);
271
+ handle('not-an-object');
272
+ handle({});
273
+ handle({ type: 'message_update' }); // 缺 assistantMessageEvent
274
+ handle({ type: 'message_update', assistantMessageEvent: null });
275
+ handle({ type: 'message_update', assistantMessageEvent: { type: 'text_delta' } }); // 缺 delta
276
+ handle({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 42 } }); // delta 非字符串
277
+ handle({ type: 'session', id: 123 }); // id 非字符串 → 不当会话标识
278
+ handle({ type: 'tool_execution_start', toolName: 'bash' }); // 缺 toolCallId
279
+ handle({ type: 'tool_execution_end', toolCallId: 'c9', result: null, isError: false });
280
+ handle({ type: 'tool_execution_end', toolCallId: 'c10', result: { content: [] }, isError: true });
281
+
282
+ assert.deepEqual(sink.output, []);
283
+ assert.equal(job.claudeSessionId, undefined);
284
+ // 只有带了合法 toolCallId 的两条 end 进了 tracker
285
+ assert.deepEqual(tracker.records.map(r => [r.id, r.status]), [['c9', 'done'], ['c10', 'error']]);
286
+ });
287
+
288
+ test('pi 事件:session header 重复推送只认第一条', () => {
289
+ const job = {};
290
+ const sink = makeSink();
291
+ const handle = createPiEventHandler(job, sink.appendOutput, sink.appendThinking, makeTrackerSpy());
292
+
293
+ handle({ type: 'session', version: 3, id: 'pi-sess-x', cwd: 'D:/ws' });
294
+ handle({ type: 'session', version: 3, id: 'pi-sess-x', cwd: 'D:/ws' });
295
+
296
+ assert.equal(job.claudeSessionId, 'pi-sess-x');
297
+ assert.deepEqual(sink.output, []);
298
+ });