@muyichengshayu/promptx 0.2.9 → 0.2.11

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 (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/apps/runner/src/engines/claudeCodeRunner.js +71 -9
  3. package/apps/runner/src/engines/claudeCodeRunner.test.js +189 -1
  4. package/apps/runner/src/engines/kimiCodeRunner.js +34 -11
  5. package/apps/runner/src/engines/openCodeRunner.test.js +164 -1
  6. package/apps/{server/src/agents → runner/src/engines}/runnerContract.test.js +67 -0
  7. package/apps/runner/src/index.js +2 -1
  8. package/apps/runner/src/runManager.test.js +1 -1
  9. package/apps/server/src/agents/claudeCodeRunner.js +1 -839
  10. package/apps/server/src/agents/kimiCodeRunner.js +1 -552
  11. package/apps/server/src/agents/openCodeRunner.js +1 -636
  12. package/apps/server/src/index.js +5 -3
  13. package/apps/server/src/taskRoutes.js +20 -3
  14. package/apps/server/src/taskRoutes.test.js +49 -0
  15. package/apps/server/src/workspaceFiles.js +37 -1
  16. package/apps/server/src/workspaceFiles.test.js +18 -1
  17. package/apps/web/dist/assets/CodexSessionManagerDialog-BN4cweyO.js +3 -0
  18. package/apps/web/dist/assets/{TaskDiffReviewDialog-DpW8S8yT.js → TaskDiffReviewDialog-B8r9DD0G.js} +2 -2
  19. package/apps/web/dist/assets/{WorkbenchSettingsDialog-CYfh5G7c.js → WorkbenchSettingsDialog-CjNLZ7j9.js} +1 -1
  20. package/apps/web/dist/assets/WorkbenchView-pIUuQsCk.js +60 -0
  21. package/apps/web/dist/assets/index-BAfqUG7o.css +1 -0
  22. package/apps/web/dist/assets/{index-DHF_zkYI.js → index-Ch8uSQYT.js} +2 -2
  23. package/apps/web/dist/index.html +2 -2
  24. package/package.json +1 -1
  25. package/packages/shared/src/dailyLogStream.js +111 -0
  26. package/packages/shared/src/dailyLogStream.test.js +29 -0
  27. package/scripts/relay-service.mjs +8 -1
  28. package/scripts/relay.mjs +4 -1
  29. package/scripts/service.mjs +12 -3
  30. package/apps/server/src/agents/claudeCodeRunner.test.js +0 -433
  31. package/apps/server/src/agents/openCodeRunner.test.js +0 -236
  32. package/apps/web/dist/assets/CodexSessionManagerDialog-_qLljY7F.js +0 -3
  33. package/apps/web/dist/assets/WorkbenchView-A8nm0NH9.js +0 -60
  34. package/apps/web/dist/assets/index-CrBjB0XY.css +0 -1
  35. /package/apps/{server/src/agents → runner/src/engines}/kimiCodeRunner.test.js +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.11
4
+
5
+ - 优化任务文件更改数的刷新策略:Agent turn 进入终态后延迟 800ms 自动刷新对应任务的 workspace diff summary,避免每次全量查询所有任务;多任务同时结束时合并为一次请求,降低后端 git diff 计算开销。
6
+ - 优化 Windows 目录选择器根节点展示:空路径请求时返回 home 目录 + 可用盘符(C 盘 / D 盘等)作为快捷入口;前端解除 homePath 导航边界限制,允许自由退到任意父目录;修复 `parentPath` 始终为空字符串的问题。
7
+ - 新增按日期分割的后台服务日志:server、runner、relay 的日志统一写入 `runtime/logs/` 目录,文件名按天切分(`server-YYYY-MM-DD.log`),支持 14 天自动清理;降级为未配置日志目录时继续使用控制台输出。
8
+ - WeChat Light 主题对比度微调:`appPanelMuted`、`borderDefault`、`borderMuted`、`textMuted` 色值调深,提升文字与边界的可读性;去掉 reasoning 区块的左侧边框线,让过程日志更紧凑。
9
+ - 修复运行计时首次出现时未触发自动滚动的问题:`sendingElapsedSeconds` 从零变为正数时补发一次 `scheduleScrollToBottom`,确保“耗时 X 秒”文案出现时视图跟随到底部。
10
+
11
+ ## 0.2.10
12
+
13
+ - 修复 Claude Code 结果完成时偶发卡死的问题:当 Claude CLI 在 result 事件后延迟退出时,runner 会进入优雅退出等待,避免强制 kill 导致消息丢失。
14
+ - 完善 Kimi Code 执行过程展示:思考过程(thinking)不再被隐藏,可直接在执行面板中查看;工具调用(ReadFile / WriteFile / Shell 等)正确映射到前端分类(读取/写入/命令),便于分组展示。
15
+ - 补齐 Kimi Code 事件流完整性:新增 thread.started 事件发送,修复会话创建元信息泄漏到过程日志的问题。
16
+ - 健壮化 Kimi Code 参数解析:支持 arguments 既为字符串也可为对象,避免空参数展示为 `{}`。
17
+ - 代码变更弹窗轮次选择器日期精确到秒:select option 中显示完整时分秒,方便在密集 run 中快速定位目标轮次。
18
+
3
19
  ## 0.2.9
4
20
 
5
21
  - 接入 `Kimi Code CLI` 执行引擎:工作台 Agent 选择器新增 Kimi Code 选项,支持本地会话发现、线程复用、TodoList 过程展示与停止控制,与现有 Codex / Claude Code / OpenCode 并列为第四类 Agent。
@@ -21,6 +21,14 @@ import { createManagedSpawnOptions, forceStopChildProcess } from '../processCont
21
21
  const CLAUDE_CODE_BIN = process.env.CLAUDE_CODE_BIN || 'claude'
22
22
  const CLAUDE_DEFAULT_ARGS = ['--dangerously-skip-permissions']
23
23
  const RESOLVED_CLAUDE_CODE_BIN = resolveClaudeCodeBinary()
24
+ const CLAUDE_RESULT_EXIT_GRACE_MS = Math.max(
25
+ 0,
26
+ Number(process.env.PROMPTX_CLAUDE_RESULT_EXIT_GRACE_MS) || 3000
27
+ )
28
+ const CLAUDE_RESULT_FORCE_STOP_GRACE_MS = Math.max(
29
+ 200,
30
+ Number(process.env.PROMPTX_CLAUDE_RESULT_FORCE_STOP_GRACE_MS) || 1000
31
+ )
24
32
 
25
33
  function resolveClaudeCodeBinary() {
26
34
  if (process.platform !== 'win32') {
@@ -798,8 +806,60 @@ export function streamPromptToClaudeCodeSession(sessionInput, prompt, callbacks
798
806
  let finalSessionId = String(session.engineSessionId || session.engineThreadId || session.codexThreadId || '').trim()
799
807
  let fatalClaudeErrorMessage = ''
800
808
  let fatalClaudeErrorTriggered = false
809
+ let resultExitGraceTimer = null
810
+ let settled = false
811
+ let resolveResult = null
812
+ let rejectResult = null
801
813
  const normalizationState = createClaudeNormalizationState()
802
814
 
815
+ const clearResultExitGraceTimer = () => {
816
+ if (resultExitGraceTimer) {
817
+ clearTimeout(resultExitGraceTimer)
818
+ resultExitGraceTimer = null
819
+ }
820
+ }
821
+
822
+ const settleCompleted = (options = {}) => {
823
+ if (settled) {
824
+ return
825
+ }
826
+
827
+ settled = true
828
+ clearResultExitGraceTimer()
829
+ onEvent(createCompletedEnvelopeEvent(finalMessage))
830
+ resolveResult?.({
831
+ sessionId: session.id,
832
+ threadId: finalSessionId,
833
+ message: finalMessage,
834
+ })
835
+
836
+ if (options.stopChild && child.exitCode === null && child.signalCode === null) {
837
+ forceStopChildProcess(child, { graceMs: CLAUDE_RESULT_FORCE_STOP_GRACE_MS })
838
+ }
839
+ }
840
+
841
+ const settleError = (error) => {
842
+ if (settled) {
843
+ return
844
+ }
845
+
846
+ settled = true
847
+ clearResultExitGraceTimer()
848
+ rejectResult?.(error)
849
+ }
850
+
851
+ const scheduleResultExitGrace = () => {
852
+ if (settled || resultExitGraceTimer || CLAUDE_RESULT_EXIT_GRACE_MS <= 0) {
853
+ return
854
+ }
855
+
856
+ resultExitGraceTimer = setTimeout(() => {
857
+ resultExitGraceTimer = null
858
+ settleCompleted({ stopChild: true })
859
+ }, CLAUDE_RESULT_EXIT_GRACE_MS)
860
+ resultExitGraceTimer.unref?.()
861
+ }
862
+
803
863
  const rememberSessionId = (sessionId) => {
804
864
  const value = String(sessionId || '').trim()
805
865
  if (!value || value === finalSessionId) {
@@ -836,6 +896,7 @@ export function streamPromptToClaudeCodeSession(sessionInput, prompt, callbacks
836
896
 
837
897
  if (String(event?.type || '').trim().toLowerCase() === 'result') {
838
898
  finalMessage = extractClaudeResultText(event) || finalMessage
899
+ scheduleResultExitGrace()
839
900
  }
840
901
  }
841
902
 
@@ -857,11 +918,18 @@ export function streamPromptToClaudeCodeSession(sessionInput, prompt, callbacks
857
918
  })
858
919
 
859
920
  const result = new Promise((resolve, reject) => {
921
+ resolveResult = resolve
922
+ rejectResult = reject
923
+
860
924
  child.on('error', (error) => {
861
- reject(normalizeSpawnError(error))
925
+ settleError(normalizeSpawnError(error))
862
926
  })
863
927
 
864
928
  child.on('close', (code) => {
929
+ if (settled) {
930
+ return
931
+ }
932
+
865
933
  flushBufferedText(stdoutBuffer).forEach(emitClaudeJsonLine)
866
934
  flushBufferedText(stderrBuffer).forEach((line) => {
867
935
  lastStderrLine = line
@@ -870,17 +938,11 @@ export function streamPromptToClaudeCodeSession(sessionInput, prompt, callbacks
870
938
 
871
939
  if (code !== 0) {
872
940
  const detail = fatalClaudeErrorMessage || lastStderrLine || 'Claude Code 执行失败。'
873
- reject(new Error(detail))
941
+ settleError(new Error(detail))
874
942
  return
875
943
  }
876
944
 
877
- onEvent(createCompletedEnvelopeEvent(finalMessage))
878
-
879
- resolve({
880
- sessionId: session.id,
881
- threadId: finalSessionId,
882
- message: finalMessage,
883
- })
945
+ settleCompleted()
884
946
  })
885
947
  })
886
948
 
@@ -1,7 +1,14 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
- import { createClaudeNormalizationState, normalizeClaudeEvents } from './claudeCodeRunner.js'
4
+ import {
5
+ createClaudeNormalizationState,
6
+ extractClaudeAssistantText,
7
+ extractClaudeResultText,
8
+ extractClaudeSessionId,
9
+ normalizeClaudeEvent,
10
+ normalizeClaudeEvents,
11
+ } from './claudeCodeRunner.js'
5
12
 
6
13
  test('runner claudeCodeRunner maps fatal auth api_retry to error event', () => {
7
14
  assert.deepEqual(
@@ -277,3 +284,184 @@ test('runner claudeCodeRunner maps TodoWrite into todo_list events', () => {
277
284
  }]
278
285
  )
279
286
  })
287
+
288
+ test('extractClaudeAssistantText joins nested text parts', () => {
289
+ const text = extractClaudeAssistantText({
290
+ type: 'assistant',
291
+ message: {
292
+ content: [
293
+ { type: 'text', text: '第一段' },
294
+ { type: 'text', text: '第二段' },
295
+ ],
296
+ },
297
+ })
298
+
299
+ assert.equal(text, '第一段\n第二段')
300
+ })
301
+
302
+ test('extractClaudeSessionId reads common session id fields', () => {
303
+ assert.equal(extractClaudeSessionId({ session_id: 'claude-session-1' }), 'claude-session-1')
304
+ assert.equal(extractClaudeSessionId({ result: { session_id: 'claude-session-2' } }), 'claude-session-2')
305
+ })
306
+
307
+ test('normalizeClaudeEvent maps assistant output to agent message', () => {
308
+ assert.deepEqual(
309
+ normalizeClaudeEvent({
310
+ type: 'assistant',
311
+ message: {
312
+ content: [{ type: 'text', text: '已完成修改' }],
313
+ },
314
+ }),
315
+ {
316
+ type: 'item.completed',
317
+ item: {
318
+ type: 'agent_message',
319
+ text: '已完成修改',
320
+ },
321
+ }
322
+ )
323
+ })
324
+
325
+ test('normalizeClaudeEvent maps result output to turn completion', () => {
326
+ assert.deepEqual(
327
+ normalizeClaudeEvent({
328
+ type: 'result',
329
+ result: '最终回复',
330
+ }),
331
+ {
332
+ type: 'turn.completed',
333
+ result: '最终回复',
334
+ }
335
+ )
336
+
337
+ assert.equal(extractClaudeResultText({ result: '最终回复' }), '最终回复')
338
+ })
339
+
340
+ test('normalizeClaudeEvents maps system init to thread start', () => {
341
+ assert.deepEqual(
342
+ normalizeClaudeEvents({
343
+ type: 'system',
344
+ subtype: 'init',
345
+ session_id: 'claude-session-init',
346
+ }),
347
+ [{
348
+ type: 'thread.started',
349
+ thread_id: 'claude-session-init',
350
+ }]
351
+ )
352
+ })
353
+
354
+ test('normalizeClaudeEvents maps thinking, tool use and text blocks', () => {
355
+ const state = createClaudeNormalizationState()
356
+
357
+ assert.deepEqual(
358
+ normalizeClaudeEvents({
359
+ type: 'assistant',
360
+ message: {
361
+ content: [
362
+ { type: 'thinking', thinking: '先看看目录结构' },
363
+ { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls -1' } },
364
+ { type: 'text', text: '已查看完成' },
365
+ ],
366
+ },
367
+ }, state),
368
+ [
369
+ {
370
+ type: 'item.started',
371
+ item: {
372
+ type: 'reasoning',
373
+ text: '先看看目录结构',
374
+ },
375
+ },
376
+ {
377
+ type: 'item.started',
378
+ item: {
379
+ type: 'command_execution',
380
+ command: 'Bash: ls -1',
381
+ status: 'in_progress',
382
+ },
383
+ },
384
+ {
385
+ type: 'item.completed',
386
+ item: {
387
+ type: 'agent_message',
388
+ text: '已查看完成',
389
+ },
390
+ },
391
+ ]
392
+ )
393
+ })
394
+
395
+ test('normalizeClaudeEvents maps tool results back to remembered tool call', () => {
396
+ const state = createClaudeNormalizationState()
397
+ normalizeClaudeEvents({
398
+ type: 'assistant',
399
+ message: {
400
+ content: [
401
+ { type: 'tool_use', id: 'tool-2', name: 'Bash', input: { command: 'pwd' } },
402
+ ],
403
+ },
404
+ }, state)
405
+
406
+ assert.deepEqual(
407
+ normalizeClaudeEvents({
408
+ type: 'user',
409
+ message: {
410
+ content: [
411
+ { type: 'tool_result', tool_use_id: 'tool-2', content: '/tmp/demo', is_error: false },
412
+ ],
413
+ },
414
+ }, state),
415
+ [{
416
+ type: 'item.completed',
417
+ item: {
418
+ type: 'command_execution',
419
+ command: 'Bash: pwd',
420
+ status: 'completed',
421
+ exit_code: 0,
422
+ aggregated_output: '/tmp/demo',
423
+ },
424
+ }]
425
+ )
426
+ })
427
+
428
+ test('normalizeClaudeEvents stringifies structured tool results', () => {
429
+ const state = createClaudeNormalizationState()
430
+ normalizeClaudeEvents({
431
+ type: 'assistant',
432
+ message: {
433
+ content: [
434
+ { type: 'tool_use', id: 'tool-3', name: 'Read', input: { file_path: '/tmp/demo.txt' } },
435
+ ],
436
+ },
437
+ }, state)
438
+
439
+ assert.deepEqual(
440
+ normalizeClaudeEvents({
441
+ type: 'user',
442
+ message: {
443
+ content: [
444
+ {
445
+ type: 'tool_result',
446
+ tool_use_id: 'tool-3',
447
+ content: [
448
+ { type: 'text', text: '<path>/tmp/demo.txt</path>' },
449
+ { type: 'text', text: '<type>file</type>' },
450
+ ],
451
+ },
452
+ ],
453
+ },
454
+ },
455
+ state),
456
+ [{
457
+ type: 'item.completed',
458
+ item: {
459
+ type: 'command_execution',
460
+ command: 'Read: /tmp/demo.txt',
461
+ status: 'completed',
462
+ exit_code: 0,
463
+ aggregated_output: '<path>/tmp/demo.txt</path>\n<type>file</type>',
464
+ },
465
+ }]
466
+ )
467
+ })
@@ -162,30 +162,46 @@ function stringifyKimiToolResultContent(value) {
162
162
  }
163
163
  }
164
164
 
165
+ function mapKimiToolNameForDisplay(name = '') {
166
+ const normalized = String(name || '').trim().toLowerCase()
167
+ const aliasMap = {
168
+ readfile: 'read',
169
+ writefile: 'write',
170
+ editfile: 'edit',
171
+ search: 'search',
172
+ shell: 'shell',
173
+ settodolist: 'todo',
174
+ }
175
+ return aliasMap[normalized] || normalized || 'kimi tool'
176
+ }
177
+
165
178
  function buildKimiToolCommand(name = '', input = {}) {
166
- const toolName = String(name || 'Kimi tool').trim() || 'Kimi tool'
179
+ const displayName = mapKimiToolNameForDisplay(name)
167
180
  if (!input || typeof input !== 'object') {
168
- return toolName
181
+ return displayName
169
182
  }
170
183
 
171
184
  const command = String(input.command || '').trim()
172
185
  if (command) {
173
- return `${toolName}: ${command}`
186
+ return `${displayName}: ${command}`
174
187
  }
175
188
 
176
189
  const singleValueKeys = ['file_path', 'path', 'pattern', 'query', 'url', 'description']
177
190
  for (const key of singleValueKeys) {
178
191
  const value = String(input[key] || '').trim()
179
192
  if (value) {
180
- return `${toolName}: ${value}`
193
+ return `${displayName}: ${value}`
181
194
  }
182
195
  }
183
196
 
184
197
  try {
185
198
  const compact = JSON.stringify(input)
186
- return compact.length <= 240 ? `${toolName}: ${compact}` : `${toolName}: ${compact.slice(0, 237)}...`
199
+ if (compact === '{}') {
200
+ return displayName
201
+ }
202
+ return compact.length <= 240 ? `${displayName}: ${compact}` : `${displayName}: ${compact.slice(0, 237)}...`
187
203
  } catch {
188
- return toolName
204
+ return displayName
189
205
  }
190
206
  }
191
207
 
@@ -296,12 +312,18 @@ export function normalizeKimiEvents(event = {}, state = createKimiNormalizationS
296
312
  toolCalls.forEach((toolCall) => {
297
313
  const toolUseId = String(toolCall?.id || '').trim()
298
314
  const name = String(toolCall?.function?.name || toolCall?.name || 'Kimi tool').trim() || 'Kimi tool'
299
- const argsText = toolCall?.function?.arguments || toolCall?.arguments || '{}'
300
315
  let parsedArgs = {}
301
- try {
302
- parsedArgs = JSON.parse(argsText)
303
- } catch {
304
- parsedArgs = {}
316
+ const rawArgs = toolCall?.function?.arguments ?? toolCall?.arguments
317
+ if (rawArgs != null) {
318
+ if (typeof rawArgs === 'string') {
319
+ try {
320
+ parsedArgs = JSON.parse(rawArgs)
321
+ } catch {
322
+ parsedArgs = {}
323
+ }
324
+ } else if (typeof rawArgs === 'object') {
325
+ parsedArgs = rawArgs
326
+ }
305
327
  }
306
328
  const command = buildKimiToolCommand(name, parsedArgs)
307
329
  const isTodoTool = isKimiTodoToolName(name)
@@ -446,6 +468,7 @@ export function streamPromptToKimiCodeSession(sessionInput, prompt, callbacks =
446
468
 
447
469
  finalSessionId = value
448
470
  onThreadStarted(value)
471
+ onEvent(createAgentEventEnvelopeEvent(createThreadStartedEvent(value)))
449
472
  }
450
473
 
451
474
  const emitKimiJsonLine = (line) => {
@@ -1,7 +1,15 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
- import { normalizeOpenCodeEvents } from './openCodeRunner.js'
4
+ import {
5
+ createOpenCodeNormalizationState,
6
+ extractOpenCodeErrorMessage,
7
+ extractOpenCodeSessionId,
8
+ extractOpenCodeText,
9
+ extractOpenCodeUsage,
10
+ normalizeOpenCodeEvent,
11
+ normalizeOpenCodeEvents,
12
+ } from './openCodeRunner.js'
5
13
 
6
14
  test('runner openCodeRunner maps sub-agent task tool_use to collaboration events', () => {
7
15
  assert.deepEqual(
@@ -71,3 +79,158 @@ test('runner openCodeRunner maps sub-agent task tool_use to collaboration events
71
79
  ]
72
80
  )
73
81
  })
82
+
83
+ test('extractOpenCodeSessionId reads common session id fields', () => {
84
+ assert.equal(extractOpenCodeSessionId({ sessionID: 'opencode-session-1' }), 'opencode-session-1')
85
+ assert.equal(extractOpenCodeSessionId({ sessionId: 'opencode-session-2' }), 'opencode-session-2')
86
+ })
87
+
88
+ test('extractOpenCodeText trims text payload', () => {
89
+ assert.equal(
90
+ extractOpenCodeText({
91
+ type: 'text',
92
+ part: {
93
+ type: 'text',
94
+ text: '\n\n已完成修改\n',
95
+ },
96
+ }),
97
+ '已完成修改'
98
+ )
99
+ })
100
+
101
+ test('normalizeOpenCodeEvent maps text output to agent message', () => {
102
+ assert.deepEqual(
103
+ normalizeOpenCodeEvent({
104
+ type: 'text',
105
+ part: {
106
+ type: 'text',
107
+ text: '已完成修改',
108
+ },
109
+ }),
110
+ {
111
+ type: 'item.completed',
112
+ item: {
113
+ type: 'agent_message',
114
+ text: '已完成修改',
115
+ },
116
+ }
117
+ )
118
+ })
119
+
120
+ test('normalizeOpenCodeEvents maps first step_start to turn.started only once', () => {
121
+ const state = createOpenCodeNormalizationState()
122
+
123
+ assert.deepEqual(
124
+ normalizeOpenCodeEvents({
125
+ type: 'step_start',
126
+ sessionID: 'ses_1',
127
+ }, state),
128
+ [{ type: 'turn.started' }]
129
+ )
130
+
131
+ assert.deepEqual(
132
+ normalizeOpenCodeEvents({
133
+ type: 'step_start',
134
+ sessionID: 'ses_1',
135
+ }, state),
136
+ []
137
+ )
138
+ })
139
+
140
+ test('normalizeOpenCodeEvents maps completed tool_use to command events', () => {
141
+ assert.deepEqual(
142
+ normalizeOpenCodeEvents({
143
+ type: 'tool_use',
144
+ sessionID: 'ses_2',
145
+ part: {
146
+ type: 'tool',
147
+ tool: 'read',
148
+ state: {
149
+ status: 'completed',
150
+ input: {
151
+ filePath: '/tmp/demo.txt',
152
+ },
153
+ output: '<path>/tmp/demo.txt</path>',
154
+ },
155
+ },
156
+ }),
157
+ [
158
+ {
159
+ type: 'item.started',
160
+ item: {
161
+ type: 'command_execution',
162
+ command: 'read: /tmp/demo.txt',
163
+ status: 'in_progress',
164
+ },
165
+ },
166
+ {
167
+ type: 'item.completed',
168
+ item: {
169
+ type: 'command_execution',
170
+ command: 'read: /tmp/demo.txt',
171
+ status: 'completed',
172
+ exit_code: 0,
173
+ aggregated_output: '<path>/tmp/demo.txt</path>',
174
+ },
175
+ },
176
+ ]
177
+ )
178
+ })
179
+
180
+ test('normalizeOpenCodeEvent maps step_finish stop to turn completion with usage', () => {
181
+ const event = normalizeOpenCodeEvent({
182
+ type: 'step_finish',
183
+ part: {
184
+ type: 'step-finish',
185
+ reason: 'stop',
186
+ tokens: {
187
+ input: 321,
188
+ output: 12,
189
+ cache: {
190
+ read: 256,
191
+ },
192
+ },
193
+ },
194
+ })
195
+
196
+ assert.deepEqual(event, {
197
+ type: 'turn.completed',
198
+ usage: {
199
+ input_tokens: 321,
200
+ output_tokens: 12,
201
+ cached_input_tokens: 256,
202
+ },
203
+ })
204
+
205
+ assert.deepEqual(extractOpenCodeUsage({
206
+ part: {
207
+ tokens: {
208
+ input: 321,
209
+ output: 12,
210
+ cache: {
211
+ read: 256,
212
+ },
213
+ },
214
+ },
215
+ }), {
216
+ input_tokens: 321,
217
+ output_tokens: 12,
218
+ cached_input_tokens: 256,
219
+ })
220
+ })
221
+
222
+ test('extractOpenCodeErrorMessage reads nested API errors', () => {
223
+ assert.equal(
224
+ extractOpenCodeErrorMessage({
225
+ type: 'error',
226
+ error: {
227
+ name: 'APIError',
228
+ data: {
229
+ message: 'openai_error',
230
+ responseBody: '{"error":{"message":"bad gateway"}}',
231
+ },
232
+ },
233
+ }),
234
+ 'openai_error'
235
+ )
236
+ })
@@ -154,6 +154,43 @@ process.stdout.write(JSON.stringify({
154
154
  return cmdPath
155
155
  }
156
156
 
157
+ function createHangingFakeClaudeBinary(tempDir) {
158
+ const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-claude-hang.js' : 'fake-claude-hang')
159
+ const script = `#!/usr/bin/env node
160
+ process.stdout.write(JSON.stringify({
161
+ type: 'system',
162
+ subtype: 'init',
163
+ session_id: 'thread-contract-1',
164
+ }) + '\\n')
165
+
166
+ process.stdout.write(JSON.stringify({
167
+ type: 'assistant',
168
+ message: {
169
+ content: [
170
+ { type: 'text', text: '已完成修改' },
171
+ ],
172
+ },
173
+ }) + '\\n')
174
+
175
+ process.stdout.write(JSON.stringify({
176
+ type: 'result',
177
+ result: '最终回复',
178
+ }) + '\\n')
179
+
180
+ setInterval(() => {}, 1000)
181
+ `
182
+
183
+ fs.writeFileSync(scriptPath, script, { mode: 0o755 })
184
+
185
+ if (process.platform !== 'win32') {
186
+ return scriptPath
187
+ }
188
+
189
+ const cmdPath = path.join(tempDir, 'fake-claude-hang.cmd')
190
+ fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-claude-hang.js" %*\r\n')
191
+ return cmdPath
192
+ }
193
+
157
194
  function createFakeOpenCodeBinary(tempDir) {
158
195
  const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-opencode.js' : 'fake-opencode')
159
196
  const script = `#!/usr/bin/env node
@@ -380,3 +417,33 @@ test('Codex / Claude Code / OpenCode runner 会产出兼容的核心事件结构
380
417
  }
381
418
  )
382
419
  })
420
+
421
+ test('Claude Code runner 在 result 后进程不退出时会按 grace timeout 完成', async () => {
422
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-runner-claude-hang-'))
423
+ const fakeClaudeBin = createHangingFakeClaudeBinary(tempDir)
424
+
425
+ await withEnv(
426
+ {
427
+ CLAUDE_CODE_BIN: fakeClaudeBin,
428
+ PROMPTX_CLAUDE_RESULT_EXIT_GRACE_MS: '30',
429
+ PROMPTX_CLAUDE_RESULT_FORCE_STOP_GRACE_MS: '30',
430
+ },
431
+ async () => {
432
+ const { streamPromptToClaudeCodeSession } = await importFreshRunnerModules()
433
+ const result = await collectRunnerContractEvents(streamPromptToClaudeCodeSession)
434
+
435
+ assert.deepEqual(result.result, {
436
+ sessionId: 'session-1',
437
+ threadId: 'thread-contract-1',
438
+ message: '最终回复',
439
+ })
440
+ assertOrderedSubsequence(projectRunnerContractPhases(result.events), [
441
+ 'status',
442
+ 'thread.started',
443
+ 'agent_message',
444
+ 'turn.completed',
445
+ 'completed',
446
+ ])
447
+ }
448
+ )
449
+ })
@@ -3,8 +3,9 @@ import cors from '@fastify/cors'
3
3
  import { createRunManager } from './runManager.js'
4
4
  import { assertInternalRequest } from './internalAuth.js'
5
5
  import { createServerClient } from './serverClient.js'
6
+ import { createFastifyLoggerOptions } from '@promptx/shared/dailyLogStream'
6
7
 
7
- const app = Fastify({ logger: true })
8
+ const app = Fastify({ logger: createFastifyLoggerOptions({ logName: 'runner' }) })
8
9
  const port = Math.max(1, Number(process.env.PROMPTX_RUNNER_PORT || process.env.RUNNER_PORT || 3002))
9
10
  const host = process.env.PROMPTX_RUNNER_HOST || process.env.HOST || '127.0.0.1'
10
11