zen-gitsync 2.17.16 → 2.17.18

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 (144) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +13 -0
  4. package/src/ui/public/assets/AgentView-AMFKSDeH.css +1 -0
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/AppVersionBadge-B5VG1_Lu.js +2 -0
  7. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  8. package/src/ui/public/assets/BranchSelector-Cj6athRO.js +1 -0
  9. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  10. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  11. package/src/ui/public/assets/CommitForm-a2tMsfF9.js +6 -0
  12. package/src/ui/public/assets/CommonDialog-I-GCqRMC.js +1 -0
  13. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  14. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  15. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  16. package/src/ui/public/assets/FileDiffViewer-B7i4BmbH.js +15 -0
  17. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  20. package/src/ui/public/assets/FlowOrchestrationWorkspace-R0QKmUpG.js +21 -0
  21. package/src/ui/public/assets/IconButton-B6eqvssJ.js +1 -0
  22. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  23. package/src/ui/public/assets/LogList-BPqurE9g.js +7 -0
  24. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  25. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  26. package/src/ui/public/assets/MindmapView-NdFNlkTF.js +2 -0
  27. package/src/ui/public/assets/MonitorView-DuSZa8a3.js +1 -0
  28. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-BrfKVP-w.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  31. package/src/ui/public/assets/RemoteManagerDialog-BI7fRsOQ.js +4 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  33. package/src/ui/public/assets/RemoteRepoCard-B4E8Ud3F.js +7 -0
  34. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  35. package/src/ui/public/assets/SourceMapView-B0OzHjxP.js +3 -0
  36. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  37. package/src/ui/public/assets/SvgIcon-B2h7B2yF.js +1 -0
  38. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  39. package/src/ui/public/assets/TemplateManager-mIWE3Qf8.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-BafC-B2U.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-BseGz8hJ.js +56 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +7 -7
  45. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  46. package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-CX0FkQ94.js} +1 -1
  47. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  48. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  49. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  50. package/src/ui/public/assets/gitStore-CMuWWr2d.js +6 -0
  51. package/src/ui/public/assets/index-CB2cIiHl.js +115 -0
  52. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  53. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  54. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  55. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  56. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  57. package/src/ui/public/assets/previewSandbox-DxR4iN9P.js +0 -0
  58. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  59. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  60. package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
  61. package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
  62. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  63. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  64. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  65. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  66. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  67. package/src/ui/public/index.html +28 -27
  68. package/src/ui/server/index.js +28 -2
  69. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  70. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  71. package/src/ui/server/routes/aiContext/index.js +444 -0
  72. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  73. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  74. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  75. package/src/ui/server/routes/aiContext/render.js +366 -0
  76. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  77. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  78. package/src/ui/server/routes/fs.js +43 -0
  79. package/src/ui/server/routes/mindmap.js +36 -24
  80. package/src/ui/server/routes/monitor.js +44 -35
  81. package/src/ui/server/routes/remoteRepos.js +57 -48
  82. package/src/ui/server/routes/workbench/agentChat.js +94 -10
  83. package/src/ui/server/routes/workbench/agentChatContext.test.js +171 -0
  84. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  85. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  86. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  87. package/src/ui/server/routes/workbench/agentRoutes.js +208 -48
  88. package/src/ui/server/routes/workbench/agentRoutes.test.js +160 -4
  89. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  90. package/src/ui/server/routes/workbench/envContext.js +61 -64
  91. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  92. package/src/ui/server/routes/workbench/index.js +78 -32
  93. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  94. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  95. package/src/ui/server/routes/workbench/shared.js +17 -0
  96. package/src/ui/server/utils/agentAttachments.js +169 -0
  97. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  98. package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
  99. package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
  100. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  101. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
  102. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  103. package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
  104. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  105. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
  106. package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
  107. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  108. package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
  109. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  110. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  111. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
  112. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
  113. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  114. package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
  115. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  116. package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
  117. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  118. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  119. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
  120. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
  121. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  122. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
  123. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  124. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  125. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
  126. package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
  127. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  128. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  129. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
  130. package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
  131. package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
  132. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  133. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
  134. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  135. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  136. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
  137. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  138. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  139. package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
  140. package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
  141. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  142. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  143. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  144. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -0,0 +1,275 @@
1
+ // 外部引擎一轮对话的适配器单测。
2
+ //
3
+ // 这里**故意不 mock 事件处理器** —— 用的是 taskRunner 里真的那三个
4
+ // (createClaudeEventHandler / createOpencodeEventHandler / createCodexEventHandler),
5
+ // 只把 spawn 换成假的子进程喂固定的 NDJSON。这样测的是"三家协议 → SSE 事件"这条真链路:
6
+ // mock 掉处理器就只能测到我自己写的胶水,而那恰恰是最不容易错的部分。
7
+ //
8
+ // 每个引擎的样例 NDJSON 都取自实测(见 taskRunner 各 handler 的头部注释与
9
+ // taskRunner.opencode.test.js / taskRunner.codex.test.js 的 fixture 口径)。
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { EventEmitter } from 'node:events';
13
+ import { Readable } from 'node:stream';
14
+
15
+ import { runExternalTurn } from './runExternalTurn.js';
16
+
17
+ /** 假子进程:按顺序把 lines 推给 stdout,然后 close。 */
18
+ function fakeChild(lines, { exitCode = 0 } = {}) {
19
+ const child = new EventEmitter();
20
+ child.pid = 4242;
21
+ child.exitCode = null;
22
+ child.stdout = new Readable({ read() {} });
23
+ child.stderr = new Readable({ read() {} });
24
+ child.kill = () => { child.exitCode = 0; child.emit('close', 0); };
25
+ setImmediate(() => {
26
+ for (const l of lines) child.stdout.push(`${l}\n`);
27
+ child.stdout.push(null);
28
+ child.exitCode = exitCode;
29
+ child.emit('close', exitCode);
30
+ });
31
+ return child;
32
+ }
33
+
34
+ /** 收事件的小工具 */
35
+ function collector() {
36
+ const events = [];
37
+ return {
38
+ events,
39
+ send: (evt) => events.push(evt),
40
+ of: (type) => events.filter(e => e.type === type),
41
+ types: () => events.map(e => e.type),
42
+ };
43
+ }
44
+
45
+ const baseSession = () => ({ messages: [], engineSessionId: '' });
46
+
47
+ test('claude:stream-json → content / thinking / tool_call_start / tool_result / done', async () => {
48
+ const lines = [
49
+ JSON.stringify({ type: 'system', subtype: 'init', session_id: 'claude-sess-1' }),
50
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '你好' }] } }),
51
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'thinking', thinking: '先看看' }] } }),
52
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] } }),
53
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'a.txt\nb.txt' }] } }),
54
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '结束' }] } }),
55
+ ];
56
+ const session = baseSession();
57
+ const c = collector();
58
+ await runExternalTurn({
59
+ session, engine: 'claude', userMessage: '列个目录', cwd: 'D:/ws',
60
+ send: c.send,
61
+ launchers: { claude: async () => ({ pid: 4242, child: fakeChild(lines) }) },
62
+ });
63
+
64
+ assert.equal(c.of('content').map(e => e.delta).join(''), '你好结束');
65
+ assert.equal(c.of('thinking').map(e => e.delta).join(''), '先看看');
66
+ const starts = c.of('tool_call_start');
67
+ assert.equal(starts.length, 1);
68
+ assert.equal(starts[0].toolCallId, 't1');
69
+ assert.equal(starts[0].name, 'Bash');
70
+ assert.match(starts[0].argsPreview, /ls/);
71
+ const results = c.of('tool_result');
72
+ assert.equal(results.length, 1);
73
+ assert.match(String(results[0].result), /a\.txt/);
74
+ assert.equal(c.of('done').length, 1);
75
+ assert.equal(c.of('error').length, 0);
76
+ assert.equal(c.of('done')[0].content, '你好结束');
77
+
78
+ // 续聊标识搬运:claude 的 session_id → session.engineSessionId
79
+ assert.equal(session.engineSessionId, 'claude-sess-1');
80
+ });
81
+
82
+ test('opencode:--format json → 同一组事件(含 completed 态工具)', async () => {
83
+ const lines = [
84
+ JSON.stringify({ type: 'step_start', sessionID: 'oc-1', part: { type: 'step-start' } }),
85
+ JSON.stringify({ type: 'text', sessionID: 'oc-1', part: { id: 'p1', type: 'text', text: '看看' } }),
86
+ JSON.stringify({ type: 'reasoning', sessionID: 'oc-1', part: { id: 'p2', type: 'reasoning', text: '思考中' } }),
87
+ JSON.stringify({ type: 'tool_use', sessionID: 'oc-1', part: { id: 'p3', callID: 'c1', type: 'tool', tool: 'bash', state: { status: 'completed', input: { cmd: 'ls' }, output: 'ok' } } }),
88
+ JSON.stringify({ type: 'step_finish', sessionID: 'oc-1', part: { type: 'step-finish' } }),
89
+ ];
90
+ const session = baseSession();
91
+ const c = collector();
92
+ await runExternalTurn({
93
+ session, engine: 'opencode', userMessage: 'hi', cwd: 'D:/ws',
94
+ send: c.send,
95
+ launchers: { opencode: async () => ({ pid: 4242, child: fakeChild(lines) }) },
96
+ });
97
+
98
+ assert.equal(c.of('content').map(e => e.delta).join(''), '看看');
99
+ assert.equal(c.of('thinking').map(e => e.delta).join(''), '思考中');
100
+ assert.equal(c.of('tool_call_start')[0].name, 'bash');
101
+ assert.match(String(c.of('tool_result')[0].result), /ok/);
102
+ assert.equal(c.of('done').length, 1);
103
+ assert.equal(session.engineSessionId, 'oc-1');
104
+ });
105
+
106
+ test('codex:exec --json → 同一组事件(item.* 分派)', async () => {
107
+ const lines = [
108
+ JSON.stringify({ type: 'thread.started', thread_id: 'th-1' }),
109
+ JSON.stringify({ type: 'item.completed', item: { id: 'i1', type: 'agent_message', text: '好了' } }),
110
+ JSON.stringify({ type: 'item.completed', item: { id: 'i2', type: 'reasoning', text: '想想' } }),
111
+ JSON.stringify({ type: 'item.completed', item: { id: 'i3', type: 'command_execution', command: 'ls', status: 'completed', aggregated_output: 'files', exit_code: 0 } }),
112
+ JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }),
113
+ ];
114
+ const session = baseSession();
115
+ const c = collector();
116
+ await runExternalTurn({
117
+ session, engine: 'codex', userMessage: 'hi', cwd: 'D:/ws',
118
+ send: c.send,
119
+ launchers: { codex: async () => ({ pid: 4242, child: fakeChild(lines) }) },
120
+ });
121
+
122
+ assert.equal(c.of('content').map(e => e.delta).join(''), '好了');
123
+ assert.equal(c.of('thinking').map(e => e.delta).join(''), '想想');
124
+ assert.equal(c.of('tool_call_start')[0].name, 'command_execution');
125
+ assert.match(String(c.of('tool_result')[0].result), /files/);
126
+ assert.equal(c.of('done').length, 1);
127
+ assert.equal(session.engineSessionId, 'th-1');
128
+ });
129
+
130
+ test('非 JSON 行(CLI 的告警)原样贴出来,不当成事件丢掉', async () => {
131
+ const session = baseSession();
132
+ const c = collector();
133
+ await runExternalTurn({
134
+ session, engine: 'claude', userMessage: 'hi', cwd: 'D:/ws',
135
+ send: c.send,
136
+ launchers: { claude: async () => ({ pid: 4242, child: fakeChild(['Warning: something odd', '{"type":"result","subtype":"success"}']) }) },
137
+ });
138
+ assert.match(c.of('content').map(e => e.delta).join(''), /Warning: something odd/);
139
+ assert.equal(c.of('error').length, 0, '告警不该被判成失败');
140
+ });
141
+
142
+ test('跨数据块被切断的 NDJSON 行要能拼回来', async () => {
143
+ const session = baseSession();
144
+ const c = collector();
145
+ const child = new EventEmitter();
146
+ child.pid = 1; child.exitCode = null;
147
+ child.stdout = new Readable({ read() {} });
148
+ child.stderr = new Readable({ read() {} });
149
+ child.kill = () => {};
150
+ setImmediate(() => {
151
+ // 一条 JSON 被从中间切开推送
152
+ child.stdout.push('{"type":"assistant","message":{"content":[{"type":"te');
153
+ child.stdout.push('xt","text":"拼回来了"}]}}\n');
154
+ child.stdout.push(null);
155
+ child.emit('close', 0);
156
+ });
157
+ await runExternalTurn({
158
+ session, engine: 'claude', userMessage: 'hi', cwd: 'D:/ws',
159
+ send: c.send,
160
+ launchers: { claude: async () => ({ pid: 1, child }) },
161
+ });
162
+ assert.equal(c.of('content').map(e => e.delta).join(''), '拼回来了');
163
+ });
164
+
165
+ test('工作区状态块拼在用户消息前面(不写进用户的仓库文件)', async () => {
166
+ const session = baseSession();
167
+ const c = collector();
168
+ let seenPrompt = '';
169
+ await runExternalTurn({
170
+ session, engine: 'claude', userMessage: '我的仓库有哪些', cwd: 'D:/ws',
171
+ promptPrefix: '【工作区状态】快照目录: C:/u/.zen-gitsync/ai-context',
172
+ send: c.send,
173
+ launchers: { claude: async (_cwd, prompt) => { seenPrompt = prompt; return { pid: 1, child: fakeChild([]) }; } },
174
+ });
175
+ assert.match(seenPrompt, /【工作区状态】/);
176
+ assert.match(seenPrompt, /我的仓库有哪些/);
177
+ assert.ok(seenPrompt.indexOf('【工作区状态】') < seenPrompt.indexOf('我的仓库有哪些'), '状态块必须在用户消息之前');
178
+ });
179
+
180
+ test('附件只传绝对路径,内容不进 prompt', async () => {
181
+ const session = baseSession();
182
+ const c = collector();
183
+ let seenPrompt = '';
184
+ await runExternalTurn({
185
+ session, engine: 'claude', userMessage: '看看附件', cwd: 'D:/ws',
186
+ filePaths: ['C:/u/.zen-gitsync/agent-attachments/2026-09-28/a.pdf'],
187
+ send: c.send,
188
+ launchers: { claude: async (_cwd, prompt) => { seenPrompt = prompt; return { pid: 1, child: fakeChild([]) }; } },
189
+ });
190
+ assert.match(seenPrompt, /a\.pdf/);
191
+ assert.match(seenPrompt, /绝对路径/);
192
+ });
193
+
194
+ test('续聊:把上次的 engineSessionId 交给 launcher(--resume / --session)', async () => {
195
+ const session = { messages: [], engineSessionId: 'prev-id' };
196
+ const c = collector();
197
+ let resumeArg = null;
198
+ await runExternalTurn({
199
+ session, engine: 'claude', userMessage: '继续', cwd: 'D:/ws',
200
+ send: c.send,
201
+ launchers: { claude: async (_cwd, _prompt, resumeId) => { resumeArg = resumeId; return { pid: 1, child: fakeChild([]) }; } },
202
+ });
203
+ assert.equal(resumeArg, 'prev-id');
204
+ });
205
+
206
+ test('launcher 抛错(CLI 没装)→ 发 error,不让整轮静默失败', async () => {
207
+ const session = baseSession();
208
+ const c = collector();
209
+ await runExternalTurn({
210
+ session, engine: 'codex', userMessage: 'hi', cwd: 'D:/ws',
211
+ send: c.send,
212
+ launchers: { codex: async () => { throw new Error('spawn codex ENOENT'); } },
213
+ });
214
+ assert.equal(c.of('error').length, 1);
215
+ assert.match(c.of('error')[0].error, /Codex 启动失败/);
216
+ assert.match(c.of('error')[0].error, /ENOENT/);
217
+ assert.equal(c.of('done').length, 0, '启动失败不该再发 done');
218
+ });
219
+
220
+ test('未知引擎 → 明确报错(不静默当内置处理)', async () => {
221
+ const c = collector();
222
+ await runExternalTurn({ session: baseSession(), engine: 'gemini', userMessage: 'hi', cwd: 'D:/ws', send: c.send });
223
+ assert.equal(c.of('error').length, 1);
224
+ assert.match(c.of('error')[0].error, /未知的外部引擎/);
225
+ });
226
+
227
+ test('落盘:user + assistant(带 tool_calls) + tool 结果,形状与 g ai 一致', async () => {
228
+ const lines = [
229
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '看完了' }] } }),
230
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't9', name: 'Read', input: { path: 'a.md' } }] } }),
231
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't9', content: '内容' }] } }),
232
+ ];
233
+ const session = baseSession();
234
+ const c = collector();
235
+ await runExternalTurn({
236
+ session, engine: 'claude', userMessage: '看看', cwd: 'D:/ws',
237
+ send: c.send,
238
+ launchers: { claude: async () => ({ pid: 1, child: fakeChild(lines) }) },
239
+ });
240
+
241
+ assert.equal(session.messages[0].role, 'user');
242
+ const assistant = session.messages.find(m => m.role === 'assistant');
243
+ assert.equal(assistant.content, '看完了');
244
+ assert.equal(assistant.tool_calls[0].id, 't9');
245
+ assert.equal(assistant.tool_calls[0].type, 'function');
246
+ assert.equal(assistant.tool_calls[0].function.name, 'Read');
247
+ const tool = session.messages.find(m => m.role === 'tool');
248
+ assert.equal(tool.tool_call_id, 't9');
249
+ assert.equal(tool.content, '内容');
250
+ });
251
+
252
+ test('中止:用户点停止 → 杀掉子进程树并标 [已停止]', async () => {
253
+ const session = baseSession();
254
+ const c = collector();
255
+ const ac = new AbortController();
256
+ let killed = false;
257
+ const child = new EventEmitter();
258
+ child.pid = 999; child.exitCode = null;
259
+ child.stdout = new Readable({ read() {} });
260
+ child.stderr = new Readable({ read() {} });
261
+ child.kill = () => { child.exitCode = 143; child.emit('close', 143); };
262
+ setImmediate(() => { child.stdout.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '写了一半' }] } }) + '\n'); ac.abort(); });
263
+
264
+ await runExternalTurn({
265
+ session, engine: 'claude', userMessage: 'hi', cwd: 'D:/ws',
266
+ signal: ac.signal,
267
+ send: c.send,
268
+ launchers: { claude: async () => ({ pid: 999, child }) },
269
+ // 注入杀掉实现:真实现走 taskkill,假子进程上观察不到,只能注入
270
+ killTree: () => { killed = true; child.kill(); },
271
+ });
272
+
273
+ assert.equal(killed, true, '中止必须杀掉子进程(否则界面停了、机器上还在跑)');
274
+ assert.match(c.of('done')[0].content, /已停止/);
275
+ });
@@ -49,6 +49,23 @@ export const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
49
49
  export const ORCHESTRATOR_FILE = path.join(DATA_DIR, 'orchestrator.json');
50
50
  export const MAX_ORCHESTRATOR_INSTRUCTIONS = 200;
51
51
 
52
+ /**
53
+ * 「真相源文件」清单 —— 注入给模型、让它需要细节时自己去读全文的那几个文件。
54
+ *
55
+ * 为什么集中在这一处:有**两条**链路都要把这份清单报给模型 ——
56
+ * · aiContext(智能体页对话,七个板块的注入块)
57
+ * · envContext(多项目编排台派发任务时的运行环境块)
58
+ * 以前是各写各的(一个写三个文件名、另一个写四个),加一个可读文件就得改两处,
59
+ * 而漏改的那一处**不会报错**:模型只是永远不知道那个文件存在,于是回答"我看不到"。
60
+ * 现在路径只有这一份,标签/取舍仍由各视图自己决定(那是视图的职责,不是口径)。
61
+ */
62
+ export const TRUTH_FILES = {
63
+ tasksFile: TASKS_FILE,
64
+ jobsFile: JOBS_FILE,
65
+ orchestratorFile: ORCHESTRATOR_FILE,
66
+ configFile: CONFIG_FILE,
67
+ };
68
+
52
69
  // 派发默认提示词(全局 / 各项目级)**单条**长度上限。
53
70
  // 它不是"指令",而是一条每次派发都会被拼进 prompt 的约束 —— 4000 字足够写下一整套
54
71
  // 规范,再长只会让每一次执行都白烧一遍 token。
@@ -0,0 +1,169 @@
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
+ * 智能体对话的**非图片附件**落盘。
17
+ *
18
+ * 设计口径(2026-09-27 与用户确认的方案"附件当路径提示"):
19
+ * - 图片继续走多模态:前端转 dataURL → 请求体的 images[] → image_url part,模型直接"看"。
20
+ * - 非图片不进消息体,只落盘 + 把**绝对路径**写进 system 提示,模型需要时用 read / grep
21
+ * 工具自己去读。理由:
22
+ * a) .log / .csv / 代码文件动辄几百 KB,内联进消息既费 token 又容易把上下文撑爆;
23
+ * b) 模型本来就有文件工具,给路径让它按需取,比一次全塞进去划算得多;
24
+ * c) 这也是唯一可行路径 —— 浏览器 <input type=file> 出于安全**不提供磁盘路径**,
25
+ * 只有 File 对象,所以想"只给路径"就必须由服务端落盘一次。
26
+ *
27
+ * 落盘位置:~/.zen-gitsync/agent-attachments/<轮次目录>/<文件名>(见 src/paths.js)。
28
+ * 按轮次分子目录,是为了同名文件在第二轮不被覆盖、第一次给过的路径一直有效。
29
+ *
30
+ * 安全口径:客户端传来的 name 一律当作**不可信输入** —— 只取 basename、剥掉控制字符与
31
+ * 路径分隔符、限长;大小与数量在服务端再卡一遍(不能只信前端)。
32
+ */
33
+ import fs from 'fs/promises'
34
+ import path from 'path'
35
+ import { AGENT_ATTACHMENTS_DIR } from '../../../paths.js'
36
+
37
+ /** 单轮最多几个附件 */
38
+ export const MAX_ATTACHMENTS = 10
39
+ /** 单个附件解密后最大字节数 */
40
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
41
+ /** 单轮附件合计最大字节数(前端 base64 后约 ×1.37,须留在 /api/agent/chat 的 50mb 之内) */
42
+ export const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024
43
+ /** 保留最近多少轮的上传目录,多余的顺手清掉 */
44
+ export const KEEP_UPLOAD_TURNS = 30
45
+ /** 文件名最长字符数(保留扩展名) */
46
+ const MAX_NAME_LEN = 80
47
+
48
+ /**
49
+ * 把客户端给的文件名洗成安全的单段文件名。
50
+ * 只做"文件名"该做的事:app 后面所有路径都是自己拼的,这里防的是 `../../x` 这类穿越与
51
+ * Windows 保留字符。
52
+ */
53
+ export function sanitizeFileName(raw, fallback = 'file') {
54
+ let name = String(raw || '')
55
+ // 归一化分隔符后只取最后一段 —— Windows 的 \\ 与 POSIX 的 / 都要挡
56
+ name = name.split(/[\\/]/).pop() || ''
57
+ // 控制字符(含 \0)与 Windows 非法字符
58
+ name = name.replace(/[\x00-\x1f\x7f<>:"|?*]/g, '_')
59
+ // 纯点名".."/"." 不算文件名
60
+ name = name.replace(/^\.+$/, '')
61
+ name = name.trim()
62
+ if (!name) return fallback
63
+ if (name.length <= MAX_NAME_LEN) return name
64
+ // 超长时保留扩展名,截前半段
65
+ const ext = path.extname(name).slice(0, 16)
66
+ const base = name.slice(0, MAX_NAME_LEN - ext.length)
67
+ return base + ext
68
+ }
69
+
70
+ /** `data:<mime>;base64,<payload>` → Buffer;不是 base64 dataURL 就返回 null */
71
+ export function decodeDataUrl(dataUrl) {
72
+ if (typeof dataUrl !== 'string') return null
73
+ const m = /^data:[^,]*;base64,(.+)$/s.exec(dataUrl)
74
+ if (!m) return null
75
+ try {
76
+ return Buffer.from(m[1], 'base64')
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /** 一轮一个子目录名:时间戳 + 随机尾巴(同一毫秒内两次调用也不会撞) */
83
+ function turnDirName(now = new Date()) {
84
+ const p = (n) => String(n).padStart(2, '0')
85
+ const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
86
+ const rand = Math.random().toString(36).slice(2, 6)
87
+ return `${stamp}-${rand}`
88
+ }
89
+
90
+ /**
91
+ * 落盘一批附件。
92
+ *
93
+ * @param {Array<{name?: string, dataUrl?: string}>} list 客户端传来的原始数组(不可信)
94
+ * @param {{ dir?: string, now?: Date, onSkip?: (info: {name: string, reason: string}) => void }} [opts]
95
+ * @returns {Promise<Array<{ name: string, path: string, bytes: number }>>} 落盘成功的附件(路径为绝对路径)
96
+ */
97
+ export async function saveAgentAttachments(list, opts = {}) {
98
+ const dir = opts.dir || AGENT_ATTACHMENTS_DIR
99
+ const onSkip = typeof opts.onSkip === 'function' ? opts.onSkip : () => {}
100
+ const items = (Array.isArray(list) ? list : []).slice(0, MAX_ATTACHMENTS)
101
+ if (items.length === 0) return []
102
+
103
+ const saved = []
104
+ const used = new Set()
105
+ let total = 0
106
+ // 目录按需创建:一次都没上传过附件的用户不会平白多一个目录
107
+ let ensured = false
108
+ let turnDir = ''
109
+
110
+ for (const raw of items) {
111
+ const name = sanitizeFileName(raw?.name)
112
+ if (total >= MAX_ATTACHMENT_TOTAL_BYTES) {
113
+ onSkip({ name, reason: 'total-too-large' })
114
+ continue
115
+ }
116
+ const buf = decodeDataUrl(raw?.dataUrl)
117
+ if (!buf || buf.length === 0) {
118
+ onSkip({ name, reason: 'bad-payload' })
119
+ continue
120
+ }
121
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
122
+ onSkip({ name, reason: 'too-large' })
123
+ continue
124
+ }
125
+ if (total + buf.length > MAX_ATTACHMENT_TOTAL_BYTES) {
126
+ onSkip({ name, reason: 'total-too-large' })
127
+ continue
128
+ }
129
+ if (!ensured) {
130
+ await fs.mkdir(dir, { recursive: true })
131
+ turnDir = path.join(dir, turnDirName(opts.now))
132
+ await fs.mkdir(turnDir, { recursive: true })
133
+ ensured = true
134
+ }
135
+ // 同一轮里两个文件同名:加 -1 / -2 后缀,不要互相覆盖
136
+ let finalName = name
137
+ let n = 1
138
+ while (used.has(finalName.toLowerCase())) {
139
+ const ext = path.extname(name)
140
+ finalName = `${name.slice(0, name.length - ext.length)}-${n++}${ext}`
141
+ }
142
+ used.add(finalName.toLowerCase())
143
+ const target = path.join(turnDir, finalName)
144
+ try {
145
+ await fs.writeFile(target, buf)
146
+ } catch (err) {
147
+ onSkip({ name, reason: `write-failed:${err?.code || err?.message || 'unknown'}` })
148
+ continue
149
+ }
150
+ total += buf.length
151
+ saved.push({ name: finalName, path: target, bytes: buf.length })
152
+ }
153
+
154
+ if (saved.length > 0) {
155
+ await pruneUploadDirs(dir, opts.now).catch(() => {})
156
+ }
157
+ return saved
158
+ }
159
+
160
+ /** 只保留最近 keep 轮目录(尽力而为:失败不抛,不影响本轮对话) */
161
+ export async function pruneUploadDirs(dir = AGENT_ATTACHMENTS_DIR, now = new Date(), keep = KEEP_UPLOAD_TURNS) {
162
+ const dirs = (await fs.readdir(dir, { withFileTypes: true }).catch(() => []))
163
+ .filter(e => e.isDirectory()).map(e => e.name).sort()
164
+ const extra = dirs.slice(0, Math.max(0, dirs.length - keep))
165
+ for (const name of extra) {
166
+ await fs.rm(path.join(dir, name), { recursive: true, force: true }).catch(() => {})
167
+ }
168
+ return extra.length
169
+ }
@@ -0,0 +1,140 @@
1
+ // 智能体非图片附件落盘:文件名消毒 / 体积与数量上限 / 同名不覆盖 / 轮次目录清理。
2
+ // 关键口径:客户端传的 name 是不可信输入,任何情况下都不能拼进目录外。
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import fs from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import {
9
+ saveAgentAttachments,
10
+ sanitizeFileName,
11
+ decodeDataUrl,
12
+ pruneUploadDirs,
13
+ MAX_ATTACHMENT_BYTES,
14
+ MAX_ATTACHMENTS
15
+ } from './agentAttachments.js'
16
+
17
+ async function tmpDir(prefix = 'zen-att-') {
18
+ return await fs.mkdtemp(path.join(os.tmpdir(), prefix))
19
+ }
20
+
21
+ function dataUrl(text, mime = 'text/plain') {
22
+ return `data:${mime};base64,${Buffer.from(text, 'utf8').toString('base64')}`
23
+ }
24
+
25
+ test('sanitizeFileName: 剥路径穿越、控制字符与 Windows 非法字符', () => {
26
+ assert.equal(sanitizeFileName('../../etc/passwd'), 'passwd')
27
+ assert.equal(sanitizeFileName('C:\\Windows\\evil.txt'), 'evil.txt')
28
+ assert.equal(sanitizeFileName('a\u0000b.txt'), 'a_b.txt')
29
+ assert.equal(sanitizeFileName('bad:name?.log'), 'bad_name_.log')
30
+ assert.equal(sanitizeFileName('..'), 'file', '纯点名要回落默认名')
31
+ assert.equal(sanitizeFileName(''), 'file')
32
+ assert.equal(sanitizeFileName(null), 'file')
33
+ })
34
+
35
+ test('sanitizeFileName: 超长名截断但保留扩展名', () => {
36
+ const long = 'x'.repeat(200) + '.log'
37
+ const out = sanitizeFileName(long)
38
+ assert.ok(out.length <= 80, `长度应被截断,实际 ${out.length}`)
39
+ assert.ok(out.endsWith('.log'), '扩展名要保留')
40
+ })
41
+
42
+ test('decodeDataUrl: 只认 base64 dataURL', () => {
43
+ assert.equal(decodeDataUrl(dataUrl('hi')).toString('utf8'), 'hi')
44
+ assert.equal(decodeDataUrl('data:text/plain,hi'), null, '非 base64 不认')
45
+ assert.equal(decodeDataUrl('not-a-data-url'), null)
46
+ assert.equal(decodeDataUrl(undefined), null)
47
+ })
48
+
49
+ test('saveAgentAttachments: 落盘到指定目录并返回绝对路径,内容一致', async () => {
50
+ const dir = await tmpDir()
51
+ const saved = await saveAgentAttachments(
52
+ [{ name: '错误日志.log', dataUrl: dataUrl('boom\n', 'text/plain') }],
53
+ { dir }
54
+ )
55
+ assert.equal(saved.length, 1)
56
+ assert.equal(saved[0].name, '错误日志.log')
57
+ assert.ok(path.isAbsolute(saved[0].path), '返回的必须是绝对路径(模型按绝对路径读)')
58
+ assert.equal(await fs.readFile(saved[0].path, 'utf8'), 'boom\n')
59
+ // 落在 dir 的子目录里(一轮一个目录)
60
+ assert.equal(path.dirname(path.dirname(saved[0].path)), dir)
61
+ await fs.rm(dir, { recursive: true, force: true })
62
+ })
63
+
64
+ test('saveAgentAttachments: 目录穿越的 name 写不到目录外', async () => {
65
+ const dir = await tmpDir()
66
+ const saved = await saveAgentAttachments(
67
+ [{ name: '../../escaped.txt', dataUrl: dataUrl('x') }],
68
+ { dir }
69
+ )
70
+ assert.equal(saved.length, 1)
71
+ assert.equal(saved[0].name, 'escaped.txt')
72
+ assert.ok(saved[0].path.startsWith(dir), '必须仍在传入目录内')
73
+ await fs.rm(dir, { recursive: true, force: true })
74
+ })
75
+
76
+ test('saveAgentAttachments: 超大 / 空 payload / 数量超限都会被跳过并回调', async () => {
77
+ const dir = await tmpDir()
78
+ const skipped = []
79
+ const big = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, 0x61).toString('base64')
80
+ const list = [
81
+ { name: 'ok.txt', dataUrl: dataUrl('fine') },
82
+ { name: 'big.bin', dataUrl: `data:application/octet-stream;base64,${big}` },
83
+ { name: 'broken.txt', dataUrl: 'not-a-data-url' },
84
+ { name: 'empty.txt', dataUrl: 'data:text/plain;base64,' }
85
+ ]
86
+ const saved = await saveAgentAttachments(list, { dir, onSkip: i => skipped.push(i.reason) })
87
+ assert.deepEqual(saved.map(s => s.name), ['ok.txt'])
88
+ assert.ok(skipped.includes('too-large'))
89
+ assert.ok(skipped.includes('bad-payload'))
90
+ await fs.rm(dir, { recursive: true, force: true })
91
+ })
92
+
93
+ test('saveAgentAttachments: 超过 MAX_ATTACHMENTS 的余量直接截掉', async () => {
94
+ const dir = await tmpDir()
95
+ const list = Array.from({ length: MAX_ATTACHMENTS + 3 }, (_, i) => ({
96
+ name: `f${i}.txt`,
97
+ dataUrl: dataUrl(String(i))
98
+ }))
99
+ const saved = await saveAgentAttachments(list, { dir })
100
+ assert.equal(saved.length, MAX_ATTACHMENTS)
101
+ await fs.rm(dir, { recursive: true, force: true })
102
+ })
103
+
104
+ test('saveAgentAttachments: 同一轮内同名文件加序号,不互相覆盖', async () => {
105
+ const dir = await tmpDir()
106
+ const saved = await saveAgentAttachments(
107
+ [
108
+ { name: 'a.txt', dataUrl: dataUrl('first') },
109
+ { name: 'a.txt', dataUrl: dataUrl('second') }
110
+ ],
111
+ { dir }
112
+ )
113
+ assert.equal(saved.length, 2)
114
+ assert.notEqual(saved[0].path, saved[1].path)
115
+ assert.equal(await fs.readFile(saved[0].path, 'utf8'), 'first')
116
+ assert.equal(await fs.readFile(saved[1].path, 'utf8'), 'second')
117
+ await fs.rm(dir, { recursive: true, force: true })
118
+ })
119
+
120
+ test('saveAgentAttachments: 空数组不建目录(没人上传就别留垃圾)', async () => {
121
+ const dir = path.join(await tmpDir(), 'never')
122
+ const saved = await saveAgentAttachments([], { dir })
123
+ assert.deepEqual(saved, [])
124
+ await assert.rejects(fs.stat(dir), '目录不该被创建')
125
+ })
126
+
127
+ test('pruneUploadDirs: 只保留最近 KEEP_UPLOAD_TURNS 轮', async () => {
128
+ const dir = await tmpDir()
129
+ // 目录名按时间戳字典序排序,这里直接造有序名字
130
+ for (const n of ['20260101-000000-aaaa', '20260102-000000-bbbb', '20260103-000000-cccc']) {
131
+ const d = path.join(dir, n)
132
+ await fs.mkdir(d, { recursive: true })
133
+ await fs.writeFile(path.join(d, 'x.txt'), 'x')
134
+ }
135
+ const removed = await pruneUploadDirs(dir, new Date(), 2)
136
+ assert.equal(removed, 1)
137
+ const left = (await fs.readdir(dir)).sort()
138
+ assert.deepEqual(left, ['20260102-000000-bbbb', '20260103-000000-cccc'])
139
+ await fs.rm(dir, { recursive: true, force: true })
140
+ })