@muyichengshayu/promptx 0.2.16 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/apps/runner/src/codex.js +114 -16
- package/apps/runner/src/engines/claudeCodeRunner.test.js +467 -0
- package/apps/runner/src/engines/kimiCodeRunner.test.js +127 -0
- package/apps/runner/src/engines/openCodeRunner.test.js +236 -0
- package/apps/runner/src/engines/runnerContract.test.js +510 -0
- package/apps/runner/src/engines/shellRunner.test.js +46 -0
- package/apps/runner/src/runManager.test.js +913 -0
- package/apps/runner/src/serverClient.test.js +93 -0
- package/apps/server/src/agentSessionDiscovery.test.js +572 -0
- package/apps/server/src/appPaths.test.js +52 -0
- package/apps/server/src/assetRoutes.test.js +168 -0
- package/apps/server/src/codex.test.js +518 -0
- package/apps/server/src/codexRoutes.test.js +376 -0
- package/apps/server/src/codexRuns.test.js +160 -0
- package/apps/server/src/codexSessions.test.js +369 -0
- package/apps/server/src/db.test.js +182 -0
- package/apps/server/src/gitDiff.test.js +542 -0
- package/apps/server/src/gitDiffClient.test.js +140 -0
- package/apps/server/src/internalRoutes.test.js +134 -0
- package/apps/server/src/maintenance.test.js +154 -0
- package/apps/server/src/processControl.test.js +147 -0
- package/apps/server/src/relayClient.test.js +478 -0
- package/apps/server/src/relayConfig.test.js +73 -0
- package/apps/server/src/relayProtocol.test.js +49 -0
- package/apps/server/src/relayServer.test.js +798 -0
- package/apps/server/src/relayTenants.test.js +137 -0
- package/apps/server/src/relayUsageStore.test.js +65 -0
- package/apps/server/src/repository.test.js +150 -0
- package/apps/server/src/runDispatchService.test.js +563 -0
- package/apps/server/src/runEventIngest.test.js +225 -0
- package/apps/server/src/runRecovery.test.js +73 -0
- package/apps/server/src/runnerClient.test.js +80 -0
- package/apps/server/src/runnerDispatch.test.js +136 -0
- package/apps/server/src/systemConfig.test.js +112 -0
- package/apps/server/src/systemRoutes.test.js +319 -0
- package/apps/server/src/taskRoutes.test.js +775 -0
- package/apps/server/src/upload.test.js +30 -0
- package/apps/server/src/webAppRoutes.test.js +67 -0
- package/apps/server/src/workspaceFiles.test.js +279 -0
- package/package.json +14 -21
- package/packages/shared/src/dailyLogStream.test.js +29 -0
- package/packages/shared/src/shellCommands.test.js +45 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
function withEnv(overrides, fn) {
|
|
8
|
+
const previous = new Map()
|
|
9
|
+
|
|
10
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
11
|
+
previous.set(key, process.env[key])
|
|
12
|
+
if (typeof value === 'undefined') {
|
|
13
|
+
delete process.env[key]
|
|
14
|
+
} else {
|
|
15
|
+
process.env[key] = value
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Promise.resolve()
|
|
20
|
+
.then(fn)
|
|
21
|
+
.finally(() => {
|
|
22
|
+
for (const [key, value] of previous.entries()) {
|
|
23
|
+
if (typeof value === 'undefined') {
|
|
24
|
+
delete process.env[key]
|
|
25
|
+
} else {
|
|
26
|
+
process.env[key] = value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function importFreshRunnerModules() {
|
|
33
|
+
const suffix = `test=${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
34
|
+
const [
|
|
35
|
+
{ streamPromptToCodexSession },
|
|
36
|
+
{ streamPromptToClaudeCodeSession },
|
|
37
|
+
{ streamPromptToOpenCodeSession },
|
|
38
|
+
] = await Promise.all([
|
|
39
|
+
import(`../codex.js?${suffix}`),
|
|
40
|
+
import(`./claudeCodeRunner.js?${suffix}`),
|
|
41
|
+
import(`./openCodeRunner.js?${suffix}`),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
streamPromptToCodexSession,
|
|
46
|
+
streamPromptToClaudeCodeSession,
|
|
47
|
+
streamPromptToOpenCodeSession,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createFakeCodexBinary(tempDir) {
|
|
52
|
+
const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-codex.js' : 'fake-codex')
|
|
53
|
+
const script = `#!/usr/bin/env node
|
|
54
|
+
const fs = require('node:fs')
|
|
55
|
+
|
|
56
|
+
const args = process.argv.slice(2)
|
|
57
|
+
const outputIndex = args.indexOf('--output-last-message')
|
|
58
|
+
const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : ''
|
|
59
|
+
const threadId = 'thread-contract-1'
|
|
60
|
+
|
|
61
|
+
let prompt = ''
|
|
62
|
+
process.stdin.setEncoding('utf8')
|
|
63
|
+
process.stdin.on('data', (chunk) => {
|
|
64
|
+
prompt += chunk
|
|
65
|
+
})
|
|
66
|
+
process.stdin.on('end', () => {
|
|
67
|
+
if (outputFile) {
|
|
68
|
+
fs.writeFileSync(outputFile, '最终回复')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: threadId }) + '\\n')
|
|
72
|
+
process.stdout.write(JSON.stringify({ type: 'item.started', item: { type: 'reasoning', text: '先分析' } }) + '\\n')
|
|
73
|
+
process.stdout.write(JSON.stringify({ type: 'item.started', item: { type: 'command_execution', command: 'Bash: pwd', status: 'in_progress' } }) + '\\n')
|
|
74
|
+
process.stdout.write(JSON.stringify({ type: 'item.completed', item: { type: 'command_execution', command: 'Bash: pwd', status: 'completed', exit_code: 0, aggregated_output: '/tmp/demo' } }) + '\\n')
|
|
75
|
+
process.stdout.write(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: '已完成修改' } }) + '\\n')
|
|
76
|
+
process.stdout.write(JSON.stringify({ type: 'turn.completed', result: '最终回复' }) + '\\n')
|
|
77
|
+
})
|
|
78
|
+
`
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o755 })
|
|
81
|
+
|
|
82
|
+
if (process.platform !== 'win32') {
|
|
83
|
+
return scriptPath
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cmdPath = path.join(tempDir, 'fake-codex.cmd')
|
|
87
|
+
fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-codex.js" %*\r\n')
|
|
88
|
+
return cmdPath
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createFakeClaudeBinary(tempDir) {
|
|
92
|
+
const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-claude.js' : 'fake-claude')
|
|
93
|
+
const script = `#!/usr/bin/env node
|
|
94
|
+
const args = process.argv.slice(2)
|
|
95
|
+
const promptIndex = args.indexOf('-p')
|
|
96
|
+
const prompt = promptIndex >= 0 ? args[promptIndex + 1] || '' : ''
|
|
97
|
+
const resumeIndex = args.indexOf('--resume')
|
|
98
|
+
const threadId = resumeIndex >= 0 ? args[resumeIndex + 1] || 'thread-contract-1' : 'thread-contract-1'
|
|
99
|
+
|
|
100
|
+
if (!prompt) {
|
|
101
|
+
process.stderr.write('missing prompt\\n')
|
|
102
|
+
process.exit(1)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
process.stdout.write(JSON.stringify({
|
|
107
|
+
type: 'system',
|
|
108
|
+
subtype: 'init',
|
|
109
|
+
session_id: threadId,
|
|
110
|
+
}) + '\\n')
|
|
111
|
+
|
|
112
|
+
process.stdout.write(JSON.stringify({
|
|
113
|
+
type: 'assistant',
|
|
114
|
+
message: {
|
|
115
|
+
content: [
|
|
116
|
+
{ type: 'thinking', thinking: '先分析' },
|
|
117
|
+
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'pwd' } },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
}) + '\\n')
|
|
121
|
+
|
|
122
|
+
process.stdout.write(JSON.stringify({
|
|
123
|
+
type: 'user',
|
|
124
|
+
message: {
|
|
125
|
+
content: [
|
|
126
|
+
{ type: 'tool_result', tool_use_id: 'tool-1', content: '/tmp/demo', is_error: false },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
}) + '\\n')
|
|
130
|
+
|
|
131
|
+
process.stdout.write(JSON.stringify({
|
|
132
|
+
type: 'assistant',
|
|
133
|
+
message: {
|
|
134
|
+
content: [
|
|
135
|
+
{ type: 'text', text: '已完成修改' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
}) + '\\n')
|
|
139
|
+
|
|
140
|
+
process.stdout.write(JSON.stringify({
|
|
141
|
+
type: 'result',
|
|
142
|
+
result: '最终回复',
|
|
143
|
+
}) + '\\n')
|
|
144
|
+
`
|
|
145
|
+
|
|
146
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o755 })
|
|
147
|
+
|
|
148
|
+
if (process.platform !== 'win32') {
|
|
149
|
+
return scriptPath
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cmdPath = path.join(tempDir, 'fake-claude.cmd')
|
|
153
|
+
fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-claude.js" %*\r\n')
|
|
154
|
+
return cmdPath
|
|
155
|
+
}
|
|
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
|
+
|
|
194
|
+
function createHangingFakeCodexBinary(tempDir) {
|
|
195
|
+
const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-codex-hang.js' : 'fake-codex-hang')
|
|
196
|
+
const script = `#!/usr/bin/env node
|
|
197
|
+
const fs = require('node:fs')
|
|
198
|
+
|
|
199
|
+
const args = process.argv.slice(2)
|
|
200
|
+
const outputIndex = args.indexOf('--output-last-message')
|
|
201
|
+
const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : ''
|
|
202
|
+
|
|
203
|
+
if (outputFile) {
|
|
204
|
+
fs.writeFileSync(outputFile, '最终回复')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: 'thread-contract-1' }) + '\\n')
|
|
208
|
+
process.stdout.write(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: '已完成修改' } }) + '\\n')
|
|
209
|
+
process.stdout.write(JSON.stringify({ type: 'turn.completed', result: '最终回复' }) + '\\n')
|
|
210
|
+
|
|
211
|
+
setInterval(() => {}, 1000)
|
|
212
|
+
`
|
|
213
|
+
|
|
214
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o755 })
|
|
215
|
+
|
|
216
|
+
if (process.platform !== 'win32') {
|
|
217
|
+
return scriptPath
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cmdPath = path.join(tempDir, 'fake-codex-hang.cmd')
|
|
221
|
+
fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-codex-hang.js" %*\r\n')
|
|
222
|
+
return cmdPath
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createFakeOpenCodeBinary(tempDir) {
|
|
226
|
+
const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-opencode.js' : 'fake-opencode')
|
|
227
|
+
const script = `#!/usr/bin/env node
|
|
228
|
+
const args = process.argv.slice(2)
|
|
229
|
+
const sessionIndex = args.indexOf('--session')
|
|
230
|
+
const sessionId = sessionIndex >= 0 ? args[sessionIndex + 1] || 'thread-contract-1' : 'thread-contract-1'
|
|
231
|
+
const prompt = args[args.length - 1] || ''
|
|
232
|
+
|
|
233
|
+
if (!prompt) {
|
|
234
|
+
process.stderr.write('missing prompt\\n')
|
|
235
|
+
process.exit(1)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
process.stdout.write(JSON.stringify({
|
|
240
|
+
type: 'step_start',
|
|
241
|
+
sessionID: sessionId,
|
|
242
|
+
part: {
|
|
243
|
+
type: 'step-start',
|
|
244
|
+
},
|
|
245
|
+
}) + '\\n')
|
|
246
|
+
|
|
247
|
+
process.stdout.write(JSON.stringify({
|
|
248
|
+
type: 'tool_use',
|
|
249
|
+
sessionID: sessionId,
|
|
250
|
+
part: {
|
|
251
|
+
type: 'tool',
|
|
252
|
+
tool: 'read',
|
|
253
|
+
state: {
|
|
254
|
+
status: 'completed',
|
|
255
|
+
input: {
|
|
256
|
+
filePath: '/tmp/demo',
|
|
257
|
+
},
|
|
258
|
+
output: 'demo output',
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
}) + '\\n')
|
|
262
|
+
|
|
263
|
+
process.stdout.write(JSON.stringify({
|
|
264
|
+
type: 'text',
|
|
265
|
+
sessionID: sessionId,
|
|
266
|
+
part: {
|
|
267
|
+
type: 'text',
|
|
268
|
+
text: '最终回复',
|
|
269
|
+
},
|
|
270
|
+
}) + '\\n')
|
|
271
|
+
|
|
272
|
+
process.stdout.write(JSON.stringify({
|
|
273
|
+
type: 'step_finish',
|
|
274
|
+
sessionID: sessionId,
|
|
275
|
+
part: {
|
|
276
|
+
type: 'step-finish',
|
|
277
|
+
reason: 'stop',
|
|
278
|
+
tokens: {
|
|
279
|
+
input: 100,
|
|
280
|
+
output: 20,
|
|
281
|
+
cache: {
|
|
282
|
+
read: 10,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}) + '\\n')
|
|
287
|
+
`
|
|
288
|
+
|
|
289
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o755 })
|
|
290
|
+
|
|
291
|
+
if (process.platform !== 'win32') {
|
|
292
|
+
return scriptPath
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const cmdPath = path.join(tempDir, 'fake-opencode.cmd')
|
|
296
|
+
fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-opencode.js" %*\r\n')
|
|
297
|
+
return cmdPath
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function simplifyEvent(event = {}) {
|
|
301
|
+
if (event.type === 'status') {
|
|
302
|
+
return {
|
|
303
|
+
type: 'status',
|
|
304
|
+
stage: event.stage,
|
|
305
|
+
message: event.message,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (event.type === 'completed') {
|
|
310
|
+
return {
|
|
311
|
+
type: 'completed',
|
|
312
|
+
message: event.message,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (event.type !== 'agent_event') {
|
|
317
|
+
return { type: event.type }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
type: 'agent_event',
|
|
322
|
+
event: event.event,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function collectRunnerContractEvents(streamSessionPrompt) {
|
|
327
|
+
const events = []
|
|
328
|
+
const stream = streamSessionPrompt(
|
|
329
|
+
{ id: 'session-1', cwd: process.cwd() },
|
|
330
|
+
'runner-contract-case',
|
|
331
|
+
{
|
|
332
|
+
onEvent(event) {
|
|
333
|
+
events.push(simplifyEvent(event))
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const result = await stream.result
|
|
339
|
+
return {
|
|
340
|
+
events,
|
|
341
|
+
result,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function projectRunnerContractPhases(events = []) {
|
|
346
|
+
return events.map((event) => {
|
|
347
|
+
if (event.type === 'status') {
|
|
348
|
+
return 'status'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (event.type === 'completed') {
|
|
352
|
+
return 'completed'
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (event.type !== 'agent_event') {
|
|
356
|
+
return ''
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const payload = event.event || {}
|
|
360
|
+
if (payload.type === 'thread.started') {
|
|
361
|
+
return 'thread.started'
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (payload.type === 'turn.started') {
|
|
365
|
+
return 'turn.started'
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (payload.type === 'item.started' && payload.item?.type === 'command_execution') {
|
|
369
|
+
return 'command.started'
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (payload.type === 'item.completed' && payload.item?.type === 'command_execution') {
|
|
373
|
+
return 'command.completed'
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (payload.type === 'item.completed' && payload.item?.type === 'agent_message') {
|
|
377
|
+
return 'agent_message'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (payload.type === 'turn.completed') {
|
|
381
|
+
return 'turn.completed'
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return ''
|
|
385
|
+
}).filter(Boolean)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function assertOrderedSubsequence(actual = [], expected = []) {
|
|
389
|
+
let actualIndex = 0
|
|
390
|
+
|
|
391
|
+
expected.forEach((item) => {
|
|
392
|
+
while (actualIndex < actual.length && actual[actualIndex] !== item) {
|
|
393
|
+
actualIndex += 1
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
assert.ok(actualIndex < actual.length, `未找到预期阶段:${item},实际阶段:${actual.join(' -> ')}`)
|
|
397
|
+
actualIndex += 1
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function assertRunnerContract(result, expectedThreadId) {
|
|
402
|
+
assert.deepEqual(result.result, {
|
|
403
|
+
sessionId: 'session-1',
|
|
404
|
+
threadId: expectedThreadId,
|
|
405
|
+
message: '最终回复',
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const phases = projectRunnerContractPhases(result.events)
|
|
409
|
+
assertOrderedSubsequence(phases, [
|
|
410
|
+
'status',
|
|
411
|
+
'thread.started',
|
|
412
|
+
'command.started',
|
|
413
|
+
'command.completed',
|
|
414
|
+
'agent_message',
|
|
415
|
+
'turn.completed',
|
|
416
|
+
'completed',
|
|
417
|
+
])
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
test('Codex / Claude Code / OpenCode runner 会产出兼容的核心事件结构', async () => {
|
|
421
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-runner-contract-'))
|
|
422
|
+
const fakeCodexBin = createFakeCodexBinary(tempDir)
|
|
423
|
+
const fakeClaudeBin = createFakeClaudeBinary(tempDir)
|
|
424
|
+
const fakeOpenCodeBin = createFakeOpenCodeBinary(tempDir)
|
|
425
|
+
|
|
426
|
+
await withEnv(
|
|
427
|
+
{
|
|
428
|
+
CODEX_BIN: fakeCodexBin,
|
|
429
|
+
CLAUDE_CODE_BIN: fakeClaudeBin,
|
|
430
|
+
OPENCODE_BIN: fakeOpenCodeBin,
|
|
431
|
+
},
|
|
432
|
+
async () => {
|
|
433
|
+
const {
|
|
434
|
+
streamPromptToCodexSession,
|
|
435
|
+
streamPromptToClaudeCodeSession,
|
|
436
|
+
streamPromptToOpenCodeSession,
|
|
437
|
+
} = await importFreshRunnerModules()
|
|
438
|
+
|
|
439
|
+
const [codexResult, claudeResult, openCodeResult] = await Promise.all([
|
|
440
|
+
collectRunnerContractEvents(streamPromptToCodexSession),
|
|
441
|
+
collectRunnerContractEvents(streamPromptToClaudeCodeSession),
|
|
442
|
+
collectRunnerContractEvents(streamPromptToOpenCodeSession),
|
|
443
|
+
])
|
|
444
|
+
|
|
445
|
+
assertRunnerContract(codexResult, 'thread-contract-1')
|
|
446
|
+
assertRunnerContract(claudeResult, 'thread-contract-1')
|
|
447
|
+
assertRunnerContract(openCodeResult, 'thread-contract-1')
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('Claude Code runner 在 result 后进程不退出时会按 grace timeout 完成', async () => {
|
|
453
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-runner-claude-hang-'))
|
|
454
|
+
const fakeClaudeBin = createHangingFakeClaudeBinary(tempDir)
|
|
455
|
+
|
|
456
|
+
await withEnv(
|
|
457
|
+
{
|
|
458
|
+
CLAUDE_CODE_BIN: fakeClaudeBin,
|
|
459
|
+
PROMPTX_CLAUDE_RESULT_EXIT_GRACE_MS: '30',
|
|
460
|
+
PROMPTX_CLAUDE_RESULT_FORCE_STOP_GRACE_MS: '30',
|
|
461
|
+
},
|
|
462
|
+
async () => {
|
|
463
|
+
const { streamPromptToClaudeCodeSession } = await importFreshRunnerModules()
|
|
464
|
+
const result = await collectRunnerContractEvents(streamPromptToClaudeCodeSession)
|
|
465
|
+
|
|
466
|
+
assert.deepEqual(result.result, {
|
|
467
|
+
sessionId: 'session-1',
|
|
468
|
+
threadId: 'thread-contract-1',
|
|
469
|
+
message: '最终回复',
|
|
470
|
+
})
|
|
471
|
+
assertOrderedSubsequence(projectRunnerContractPhases(result.events), [
|
|
472
|
+
'status',
|
|
473
|
+
'thread.started',
|
|
474
|
+
'agent_message',
|
|
475
|
+
'turn.completed',
|
|
476
|
+
'completed',
|
|
477
|
+
])
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('Codex runner 在 turn.completed 后进程不退出时会按 grace timeout 完成', async () => {
|
|
483
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-runner-codex-hang-'))
|
|
484
|
+
const fakeCodexBin = createHangingFakeCodexBinary(tempDir)
|
|
485
|
+
|
|
486
|
+
await withEnv(
|
|
487
|
+
{
|
|
488
|
+
CODEX_BIN: fakeCodexBin,
|
|
489
|
+
PROMPTX_CODEX_RESULT_EXIT_GRACE_MS: '30',
|
|
490
|
+
PROMPTX_CODEX_RESULT_FORCE_STOP_GRACE_MS: '30',
|
|
491
|
+
},
|
|
492
|
+
async () => {
|
|
493
|
+
const { streamPromptToCodexSession } = await importFreshRunnerModules()
|
|
494
|
+
const result = await collectRunnerContractEvents(streamPromptToCodexSession)
|
|
495
|
+
|
|
496
|
+
assert.deepEqual(result.result, {
|
|
497
|
+
sessionId: 'session-1',
|
|
498
|
+
threadId: 'thread-contract-1',
|
|
499
|
+
message: '最终回复',
|
|
500
|
+
})
|
|
501
|
+
assertOrderedSubsequence(projectRunnerContractPhases(result.events), [
|
|
502
|
+
'status',
|
|
503
|
+
'thread.started',
|
|
504
|
+
'agent_message',
|
|
505
|
+
'turn.completed',
|
|
506
|
+
'completed',
|
|
507
|
+
])
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { shellRunner } from './shellRunner.js'
|
|
5
|
+
|
|
6
|
+
function collectShellEvents(command) {
|
|
7
|
+
const events = []
|
|
8
|
+
const stream = shellRunner.streamSessionPrompt({
|
|
9
|
+
id: 'session-shell-test',
|
|
10
|
+
cwd: process.cwd(),
|
|
11
|
+
}, command, {
|
|
12
|
+
onEvent(event) {
|
|
13
|
+
events.push(event)
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
events,
|
|
19
|
+
stream,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('shellRunner runs a one-shot shell command and emits command events', async () => {
|
|
24
|
+
const command = `"${process.execPath}" -e "console.log('hello shell')"`
|
|
25
|
+
const { events, stream } = collectShellEvents(command)
|
|
26
|
+
const result = await stream.result
|
|
27
|
+
|
|
28
|
+
assert.match(result.message, /hello shell/)
|
|
29
|
+
assert.ok(events.some((event) => event?.event?.type === 'item.started'))
|
|
30
|
+
assert.ok(events.some((event) => event?.event?.type === 'item.completed'))
|
|
31
|
+
assert.ok(events.some((event) => event?.type === 'stdout' && /hello shell/.test(event.text || '')))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('shellRunner surfaces non-zero exit output on errors', async () => {
|
|
35
|
+
const command = `"${process.execPath}" -e "console.error('shell failed'); process.exit(3)"`
|
|
36
|
+
const { events, stream } = collectShellEvents(command)
|
|
37
|
+
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
stream.result,
|
|
40
|
+
(error) => /exit 3/.test(String(error?.message || '')) && /shell failed/.test(String(error?.output || ''))
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const completed = events.find((event) => event?.event?.type === 'item.completed')
|
|
44
|
+
assert.equal(completed?.event?.item?.exit_code, 3)
|
|
45
|
+
assert.equal(completed?.event?.item?.status, 'failed')
|
|
46
|
+
})
|