@muyichengshayu/promptx 0.2.7 → 0.2.8

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 (43) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/apps/runner/src/engines/claudeCodeRunner.js +69 -1
  3. package/apps/runner/src/engines/claudeCodeRunner.test.js +279 -0
  4. package/apps/runner/src/engines/openCodeRunner.test.js +73 -0
  5. package/apps/runner/src/engines/shellRunner.test.js +46 -0
  6. package/apps/runner/src/runManager.js +110 -11
  7. package/apps/runner/src/runManager.test.js +913 -0
  8. package/apps/runner/src/serverClient.test.js +93 -0
  9. package/apps/server/src/agentSessionDiscovery.test.js +127 -0
  10. package/apps/server/src/agents/claudeCodeRunner.test.js +433 -0
  11. package/apps/server/src/agents/openCodeRunner.test.js +236 -0
  12. package/apps/server/src/agents/runnerContract.test.js +382 -0
  13. package/apps/server/src/appPaths.test.js +52 -0
  14. package/apps/server/src/assetRoutes.test.js +168 -0
  15. package/apps/server/src/codex.test.js +518 -0
  16. package/apps/server/src/codexRoutes.test.js +376 -0
  17. package/apps/server/src/codexRuns.test.js +160 -0
  18. package/apps/server/src/codexSessions.test.js +369 -0
  19. package/apps/server/src/db.test.js +182 -0
  20. package/apps/server/src/gitDiff.test.js +542 -0
  21. package/apps/server/src/gitDiffClient.test.js +140 -0
  22. package/apps/server/src/internalRoutes.test.js +134 -0
  23. package/apps/server/src/maintenance.test.js +154 -0
  24. package/apps/server/src/processControl.test.js +147 -0
  25. package/apps/server/src/relayClient.test.js +478 -0
  26. package/apps/server/src/relayConfig.test.js +73 -0
  27. package/apps/server/src/relayProtocol.test.js +49 -0
  28. package/apps/server/src/relayServer.test.js +798 -0
  29. package/apps/server/src/relayTenants.test.js +137 -0
  30. package/apps/server/src/relayUsageStore.test.js +65 -0
  31. package/apps/server/src/repository.test.js +150 -0
  32. package/apps/server/src/runDispatchService.test.js +563 -0
  33. package/apps/server/src/runEventIngest.test.js +225 -0
  34. package/apps/server/src/runRecovery.test.js +73 -0
  35. package/apps/server/src/runnerClient.test.js +80 -0
  36. package/apps/server/src/runnerDispatch.test.js +136 -0
  37. package/apps/server/src/systemConfig.test.js +112 -0
  38. package/apps/server/src/systemRoutes.test.js +319 -0
  39. package/apps/server/src/taskRoutes.test.js +726 -0
  40. package/apps/server/src/upload.test.js +30 -0
  41. package/apps/server/src/webAppRoutes.test.js +67 -0
  42. package/apps/server/src/workspaceFiles.test.js +262 -0
  43. package/package.json +14 -21
@@ -0,0 +1,168 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import Fastify from 'fastify'
6
+ import multipart from '@fastify/multipart'
7
+ import test from 'node:test'
8
+ import { createCanvas } from '@napi-rs/canvas'
9
+
10
+ import { registerAssetRoutes } from './assetRoutes.js'
11
+
12
+ function createMultipartBody(fileName, mimeType, buffer) {
13
+ const boundary = `----promptx-test-${Date.now()}`
14
+ const head = Buffer.from(
15
+ `--${boundary}\r\n`
16
+ + `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`
17
+ + `Content-Type: ${mimeType}\r\n\r\n`
18
+ )
19
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`)
20
+
21
+ return {
22
+ boundary,
23
+ body: Buffer.concat([head, buffer, tail]),
24
+ }
25
+ }
26
+
27
+ function createTestPngBuffer(width = 2000, height = 1000) {
28
+ const canvas = createCanvas(width, height)
29
+ const context = canvas.getContext('2d')
30
+ context.fillStyle = '#0ea5e9'
31
+ context.fillRect(0, 0, width, height)
32
+ return canvas.toBuffer('image/png')
33
+ }
34
+
35
+ function createTestSvgBuffer(width = 120, height = 80) {
36
+ return Buffer.from(
37
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
38
+ + '<rect width="100%" height="100%" fill="#0ea5e9"/>'
39
+ + '</svg>',
40
+ 'utf8'
41
+ )
42
+ }
43
+
44
+ test('asset routes reject missing upload file', async () => {
45
+ const app = Fastify()
46
+ await app.register(multipart)
47
+ registerAssetRoutes(app, {
48
+ createTempFilePath: () => '',
49
+ importPdfBlocks: async () => ({ blocks: [] }),
50
+ normalizeUploadFileName: (name) => name,
51
+ removeAssetFiles: () => {},
52
+ tmpDir: 'tmp',
53
+ uploadsDir: 'uploads',
54
+ })
55
+ await app.ready()
56
+
57
+ try {
58
+ const uploadResponse = await app.inject({
59
+ method: 'POST',
60
+ url: '/api/uploads',
61
+ })
62
+ assert.equal(uploadResponse.statusCode, 406)
63
+
64
+ const pdfResponse = await app.inject({
65
+ method: 'POST',
66
+ url: '/api/imports/pdf',
67
+ })
68
+ assert.equal(pdfResponse.statusCode, 406)
69
+ } finally {
70
+ await app.close()
71
+ }
72
+ })
73
+
74
+ test('asset routes keep uploaded png format and original bytes', async () => {
75
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-asset-routes-'))
76
+ const uploadsDir = path.join(rootDir, 'uploads')
77
+ const tmpDir = path.join(rootDir, 'tmp')
78
+ fs.mkdirSync(uploadsDir, { recursive: true })
79
+ fs.mkdirSync(tmpDir, { recursive: true })
80
+
81
+ const app = Fastify()
82
+ await app.register(multipart)
83
+ registerAssetRoutes(app, {
84
+ createTempFilePath: (_dir, fileName) => path.join(tmpDir, fileName),
85
+ importPdfBlocks: async () => ({ blocks: [] }),
86
+ normalizeUploadFileName: (name) => name,
87
+ removeAssetFiles: () => {},
88
+ tmpDir,
89
+ uploadsDir,
90
+ })
91
+ await app.ready()
92
+
93
+ try {
94
+ const sourceBuffer = createTestPngBuffer()
95
+ const payload = createMultipartBody('demo.png', 'image/png', sourceBuffer)
96
+ const response = await app.inject({
97
+ method: 'POST',
98
+ url: '/api/uploads',
99
+ headers: {
100
+ 'content-type': `multipart/form-data; boundary=${payload.boundary}`,
101
+ },
102
+ payload: payload.body,
103
+ })
104
+
105
+ assert.equal(response.statusCode, 201)
106
+ const body = response.json()
107
+ assert.match(body.url || '', /^\/uploads\/.+\.png$/)
108
+ assert.equal(body.width, 2000)
109
+ assert.equal(body.height, 1000)
110
+ assert.equal(body.mimeType, 'image/png')
111
+ assert.equal(typeof body.size, 'number')
112
+ assert.ok(body.size > 0)
113
+
114
+ const outputPath = path.join(uploadsDir, path.basename(body.url))
115
+ assert.equal(fs.existsSync(outputPath), true)
116
+ assert.deepEqual(fs.readFileSync(outputPath), sourceBuffer)
117
+ } finally {
118
+ await app.close()
119
+ fs.rmSync(rootDir, { recursive: true, force: true })
120
+ }
121
+ })
122
+
123
+ test('asset routes keep uploaded svg format and original bytes', async () => {
124
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-asset-routes-svg-'))
125
+ const uploadsDir = path.join(rootDir, 'uploads')
126
+ const tmpDir = path.join(rootDir, 'tmp')
127
+ fs.mkdirSync(uploadsDir, { recursive: true })
128
+ fs.mkdirSync(tmpDir, { recursive: true })
129
+
130
+ const app = Fastify()
131
+ await app.register(multipart)
132
+ registerAssetRoutes(app, {
133
+ createTempFilePath: (_dir, fileName) => path.join(tmpDir, fileName),
134
+ importPdfBlocks: async () => ({ blocks: [] }),
135
+ normalizeUploadFileName: (name) => name,
136
+ removeAssetFiles: () => {},
137
+ tmpDir,
138
+ uploadsDir,
139
+ })
140
+ await app.ready()
141
+
142
+ try {
143
+ const sourceBuffer = createTestSvgBuffer()
144
+ const payload = createMultipartBody('logo.svg', 'image/svg+xml', sourceBuffer)
145
+ const response = await app.inject({
146
+ method: 'POST',
147
+ url: '/api/uploads',
148
+ headers: {
149
+ 'content-type': `multipart/form-data; boundary=${payload.boundary}`,
150
+ },
151
+ payload: payload.body,
152
+ })
153
+
154
+ assert.equal(response.statusCode, 201)
155
+ const body = response.json()
156
+ assert.match(body.url || '', /^\/uploads\/.+\.svg$/)
157
+ assert.equal(body.mimeType, 'image/svg+xml')
158
+ assert.equal(typeof body.size, 'number')
159
+ assert.ok(body.size > 0)
160
+
161
+ const outputPath = path.join(uploadsDir, path.basename(body.url))
162
+ assert.equal(fs.existsSync(outputPath), true)
163
+ assert.deepEqual(fs.readFileSync(outputPath), sourceBuffer)
164
+ } finally {
165
+ await app.close()
166
+ fs.rmSync(rootDir, { recursive: true, force: true })
167
+ }
168
+ })
@@ -0,0 +1,518 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createRequire } from 'node:module'
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import Database from 'better-sqlite3'
8
+ import initSqlJs from 'sql.js'
9
+
10
+ const require = createRequire(import.meta.url)
11
+ const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm')
12
+ const SQL = await initSqlJs({
13
+ locateFile: () => wasmPath,
14
+ })
15
+
16
+ function withEnv(overrides, fn) {
17
+ const previous = new Map()
18
+
19
+ for (const [key, value] of Object.entries(overrides)) {
20
+ previous.set(key, process.env[key])
21
+ if (typeof value === 'undefined') {
22
+ delete process.env[key]
23
+ } else {
24
+ process.env[key] = value
25
+ }
26
+ }
27
+
28
+ return Promise.resolve()
29
+ .then(fn)
30
+ .finally(() => {
31
+ for (const [key, value] of previous.entries()) {
32
+ if (typeof value === 'undefined') {
33
+ delete process.env[key]
34
+ } else {
35
+ process.env[key] = value
36
+ }
37
+ }
38
+ })
39
+ }
40
+
41
+ async function importFreshCodexModule() {
42
+ return import(`./codex.js?test=${Date.now()}-${Math.random().toString(16).slice(2)}`)
43
+ }
44
+
45
+ function createFakeCodexBinary(tempDir) {
46
+ const scriptPath = path.join(tempDir, process.platform === 'win32' ? 'fake-codex.js' : 'fake-codex')
47
+ const script = `#!/usr/bin/env node
48
+ const fs = require('node:fs')
49
+
50
+ const args = process.argv.slice(2)
51
+ const outputIndex = args.indexOf('--output-last-message')
52
+ const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : ''
53
+ const resumeIndex = args.indexOf('resume')
54
+ const resumeTarget = resumeIndex >= 0 ? args[resumeIndex + 1] || '' : ''
55
+ const threadId = resumeTarget || 'thread-new-123'
56
+
57
+ let prompt = ''
58
+ process.stdin.setEncoding('utf8')
59
+ process.stdin.on('data', (chunk) => {
60
+ prompt += chunk
61
+ })
62
+ process.stdin.on('end', () => {
63
+ if (prompt.includes('fail-case')) {
64
+ process.stderr.write('mocked codex failure\\n')
65
+ process.exit(2)
66
+ return
67
+ }
68
+
69
+ if (prompt.includes('disconnect-case')) {
70
+ process.stdout.write(JSON.stringify({
71
+ type: 'error',
72
+ message: 'Reconnecting... 1/5 (stream disconnected before completion: error sending request for url (https://api.codexzh.com/v1/responses))',
73
+ }) + '\\n')
74
+ process.stdout.write(JSON.stringify({
75
+ type: 'turn.failed',
76
+ error: {
77
+ message: 'stream disconnected before completion: error sending request for url (https://api.codexzh.com/v1/responses)',
78
+ },
79
+ }) + '\\n')
80
+ process.stderr.write('Warning: no last agent message; wrote empty content to tmp-file\\n')
81
+ process.exit(1)
82
+ return
83
+ }
84
+
85
+ if (prompt.includes('mojibake-case')) {
86
+ const garbled = '鑾峰彇娴嬭瘯鏁版嵁'
87
+ if (outputFile) {
88
+ fs.writeFileSync(outputFile, garbled)
89
+ }
90
+ process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: threadId }) + '\\n')
91
+ process.stdout.write(JSON.stringify({
92
+ type: 'item.completed',
93
+ item: {
94
+ type: 'command_execution',
95
+ command: 'Get-Content demo.txt',
96
+ aggregated_output: garbled,
97
+ exit_code: 0,
98
+ status: 'completed',
99
+ },
100
+ }) + '\\n')
101
+ return
102
+ }
103
+
104
+ if (prompt.includes('args-case')) {
105
+ if (outputFile) {
106
+ fs.writeFileSync(outputFile, JSON.stringify(args))
107
+ }
108
+ process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: threadId }) + '\\n')
109
+ process.stdout.write(JSON.stringify({
110
+ type: 'item.completed',
111
+ item: {
112
+ type: 'agent_message',
113
+ text: 'args-ok',
114
+ },
115
+ }) + '\\n')
116
+ return
117
+ }
118
+
119
+ process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: threadId }) + '\\n')
120
+
121
+ if (prompt.includes('stream-tail-case')) {
122
+ process.stdout.write(JSON.stringify({
123
+ type: 'item.completed',
124
+ item: {
125
+ type: 'agent_message',
126
+ text: 'stream tail message',
127
+ },
128
+ }))
129
+ return
130
+ }
131
+
132
+ if (outputFile) {
133
+ fs.writeFileSync(outputFile, \`thread:\${threadId}\\nprompt:\${prompt.trim()}\\ncwd:\${process.cwd()}\\n\`)
134
+ }
135
+
136
+ process.stdout.write(JSON.stringify({
137
+ type: 'item.completed',
138
+ item: {
139
+ type: 'agent_message',
140
+ text: 'ok',
141
+ },
142
+ }) + '\\n')
143
+ })
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-codex.cmd')
153
+ fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-codex.js" %*\r\n')
154
+ return cmdPath
155
+ }
156
+
157
+ function createWindowsCodexCommand(tempDir) {
158
+ const scriptPath = path.join(tempDir, 'fake-codex.js')
159
+ const cmdPath = path.join(tempDir, 'codex.cmd')
160
+ const script = `const fs = require('node:fs')
161
+ const args = process.argv.slice(2)
162
+ const outputIndex = args.indexOf('--output-last-message')
163
+ const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : ''
164
+ let prompt = ''
165
+ process.stdin.setEncoding('utf8')
166
+ process.stdin.on('data', (chunk) => {
167
+ prompt += chunk
168
+ })
169
+ process.stdin.on('end', () => {
170
+ process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: 'thread-win' }) + '\\n')
171
+ if (outputFile) {
172
+ fs.writeFileSync(outputFile, \`prompt:\${prompt.trim()}\\n\`)
173
+ }
174
+ process.stdout.write(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }) + '\\n')
175
+ })
176
+ `
177
+
178
+ fs.writeFileSync(scriptPath, script)
179
+ fs.writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0fake-codex.js" %*\r\n')
180
+ return cmdPath
181
+ }
182
+
183
+ function writeThreadsDb(tempHome, rows = []) {
184
+ const dbPath = path.join(tempHome, 'state_5.sqlite')
185
+ const db = new SQL.Database()
186
+
187
+ try {
188
+ db.run(`
189
+ CREATE TABLE threads (
190
+ id TEXT NOT NULL,
191
+ cwd TEXT,
192
+ title TEXT,
193
+ updated_at INTEGER
194
+ );
195
+ `)
196
+
197
+ const statement = db.prepare('INSERT INTO threads (id, cwd, title, updated_at) VALUES (?, ?, ?, ?)')
198
+
199
+ try {
200
+ for (const row of rows) {
201
+ statement.run([
202
+ row.id,
203
+ row.cwd || '',
204
+ row.title || '',
205
+ Number(row.updated_at || 0),
206
+ ])
207
+ }
208
+ } finally {
209
+ statement.free()
210
+ }
211
+
212
+ fs.writeFileSync(dbPath, Buffer.from(db.export()))
213
+ } finally {
214
+ db.close()
215
+ }
216
+ }
217
+
218
+ function getSessionCwd() {
219
+ return process.cwd()
220
+ }
221
+
222
+ test('listKnownCodexWorkspaces dedupes cwd values', async () => {
223
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-home-'))
224
+ writeThreadsDb(tempHome, [
225
+ { id: 'thread-1', cwd: 'D:\\code\\yuyang-web', title: 'A', updated_at: 10 },
226
+ { id: 'thread-2', cwd: 'D:\\code\\promptx', title: 'B', updated_at: 9 },
227
+ { id: 'thread-3', cwd: 'D:\\code\\yuyang-web', title: 'C', updated_at: 8 },
228
+ ])
229
+
230
+ await withEnv(
231
+ {
232
+ CODEX_HOME: tempHome,
233
+ CODEX_BIN: undefined,
234
+ },
235
+ async () => {
236
+ const { listKnownCodexWorkspaces } = await importFreshCodexModule()
237
+ assert.deepEqual(listKnownCodexWorkspaces(), ['D:\\code\\yuyang-web', 'D:\\code\\promptx'])
238
+ }
239
+ )
240
+ })
241
+
242
+ test('listKnownCodexSessions reads latest rows from WAL-backed sqlite', async () => {
243
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-wal-'))
244
+ const dbPath = path.join(tempHome, 'state_5.sqlite')
245
+ const writer = new Database(dbPath)
246
+
247
+ try {
248
+ writer.pragma('journal_mode = WAL')
249
+ writer.exec(`
250
+ CREATE TABLE threads (
251
+ id TEXT NOT NULL,
252
+ cwd TEXT,
253
+ title TEXT,
254
+ updated_at INTEGER
255
+ );
256
+ `)
257
+
258
+ const insert = writer.prepare('INSERT INTO threads (id, cwd, title, updated_at) VALUES (?, ?, ?, ?)')
259
+ insert.run('thread-old', '/tmp/demo', 'old', 100)
260
+ insert.run('thread-new-1', '/tmp/demo', 'new-1', 300)
261
+ insert.run('thread-new-2', '/tmp/demo', 'new-2', 200)
262
+
263
+ await withEnv(
264
+ {
265
+ CODEX_HOME: tempHome,
266
+ CODEX_BIN: undefined,
267
+ },
268
+ async () => {
269
+ const { listKnownCodexSessions } = await importFreshCodexModule()
270
+ const items = listKnownCodexSessions({ cwd: '/tmp/demo', limit: 3 })
271
+ assert.deepEqual(
272
+ items.map((item) => item.id),
273
+ ['thread-new-1', 'thread-new-2', 'thread-old']
274
+ )
275
+ }
276
+ )
277
+ } finally {
278
+ writer.close()
279
+ }
280
+ })
281
+
282
+ test('streamPromptToCodexSession handles a tail event without newline', async () => {
283
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-stream-'))
284
+ const fakeBin = createFakeCodexBinary(tempHome)
285
+
286
+ await withEnv(
287
+ {
288
+ CODEX_HOME: tempHome,
289
+ CODEX_BIN: fakeBin,
290
+ },
291
+ async () => {
292
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
293
+ const events = []
294
+ const seenThreadIds = []
295
+ const stream = streamPromptToCodexSession(
296
+ { id: 'session-xyz', cwd: getSessionCwd() },
297
+ 'stream-tail-case',
298
+ {
299
+ onEvent(event) {
300
+ events.push(event)
301
+ },
302
+ onThreadStarted(threadId) {
303
+ seenThreadIds.push(threadId)
304
+ },
305
+ }
306
+ )
307
+
308
+ const result = await stream.result
309
+
310
+ assert.equal(result.sessionId, 'session-xyz')
311
+ assert.equal(result.message, '')
312
+ assert.equal(result.threadId, 'thread-new-123')
313
+ assert.deepEqual(seenThreadIds, ['thread-new-123'])
314
+ assert.deepEqual(
315
+ events.filter((event) => event.type === 'agent_event').map((event) => event.event.type),
316
+ ['thread.started', 'item.completed']
317
+ )
318
+ assert.equal(
319
+ events.find((event) => event.type === 'agent_event' && event.event.type === 'item.completed')?.event?.item?.text,
320
+ 'stream tail message'
321
+ )
322
+ assert.equal(events.at(-1)?.type, 'completed')
323
+ }
324
+ )
325
+ })
326
+
327
+ test('streamPromptToCodexSession emits starting status for new sessions and resuming status for existing threads', async () => {
328
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-status-'))
329
+ const fakeBin = createFakeCodexBinary(tempHome)
330
+
331
+ await withEnv(
332
+ {
333
+ CODEX_HOME: tempHome,
334
+ CODEX_BIN: fakeBin,
335
+ },
336
+ async () => {
337
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
338
+
339
+ const startingEvents = []
340
+ const startingStream = streamPromptToCodexSession(
341
+ { id: 'session-new', cwd: getSessionCwd(), codexThreadId: '' },
342
+ 'hello start',
343
+ {
344
+ onEvent(event) {
345
+ startingEvents.push(event)
346
+ },
347
+ }
348
+ )
349
+ await startingStream.result
350
+
351
+ const resumingEvents = []
352
+ const resumingStream = streamPromptToCodexSession(
353
+ { id: 'session-old', cwd: getSessionCwd(), codexThreadId: 'thread-existing-1' },
354
+ 'hello resume',
355
+ {
356
+ onEvent(event) {
357
+ resumingEvents.push(event)
358
+ },
359
+ }
360
+ )
361
+ await resumingStream.result
362
+
363
+ assert.deepEqual(
364
+ startingEvents.find((event) => event.type === 'status'),
365
+ {
366
+ type: 'status',
367
+ stage: 'starting',
368
+ message: '已创建 PromptX 项目,正在启动第一轮执行。',
369
+ }
370
+ )
371
+
372
+ assert.deepEqual(
373
+ resumingEvents.find((event) => event.type === 'status'),
374
+ {
375
+ type: 'status',
376
+ stage: 'resuming',
377
+ message: '已连接 PromptX 项目,正在继续这轮执行。',
378
+ }
379
+ )
380
+ }
381
+ )
382
+ })
383
+
384
+ test('streamPromptToCodexSession includes full-access and repo-check bypass args by default', async () => {
385
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-args-'))
386
+ const fakeBin = createFakeCodexBinary(tempHome)
387
+
388
+ await withEnv(
389
+ {
390
+ CODEX_HOME: tempHome,
391
+ CODEX_BIN: fakeBin,
392
+ },
393
+ async () => {
394
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
395
+ const stream = streamPromptToCodexSession(
396
+ { id: 'session-args', cwd: getSessionCwd(), codexThreadId: '' },
397
+ 'args-case'
398
+ )
399
+
400
+ const result = await stream.result
401
+ const args = JSON.parse(result.message)
402
+
403
+ assert.deepEqual(
404
+ args.slice(0, 5),
405
+ ['exec', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check', '-C', getSessionCwd()]
406
+ )
407
+ assert.equal(args.at(-2), '--output-last-message')
408
+ }
409
+ )
410
+ })
411
+
412
+ test('streamPromptToCodexSession surfaces stderr failures', async () => {
413
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-fail-'))
414
+ const fakeBin = createFakeCodexBinary(tempHome)
415
+
416
+ await withEnv(
417
+ {
418
+ CODEX_HOME: tempHome,
419
+ CODEX_BIN: fakeBin,
420
+ },
421
+ async () => {
422
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
423
+ const stream = streamPromptToCodexSession(
424
+ { id: 'session-xyz', cwd: getSessionCwd() },
425
+ 'fail-case'
426
+ )
427
+
428
+ await assert.rejects(() => stream.result, /mocked codex failure/)
429
+ }
430
+ )
431
+ })
432
+
433
+ test('streamPromptToCodexSession prefers structured codex errors over trailing stderr warnings', async () => {
434
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-disconnect-'))
435
+ const fakeBin = createFakeCodexBinary(tempHome)
436
+
437
+ await withEnv(
438
+ {
439
+ CODEX_HOME: tempHome,
440
+ CODEX_BIN: fakeBin,
441
+ },
442
+ async () => {
443
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
444
+ const stream = streamPromptToCodexSession(
445
+ { id: 'session-xyz', cwd: getSessionCwd() },
446
+ 'disconnect-case'
447
+ )
448
+
449
+ await assert.rejects(
450
+ () => stream.result,
451
+ /stream disconnected before completion: error sending request/
452
+ )
453
+ }
454
+ )
455
+ })
456
+
457
+ test('streamPromptToCodexSession repairs garbled command output', async () => {
458
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-mojibake-'))
459
+ const fakeBin = createFakeCodexBinary(tempHome)
460
+
461
+ await withEnv(
462
+ {
463
+ CODEX_HOME: tempHome,
464
+ CODEX_BIN: fakeBin,
465
+ },
466
+ async () => {
467
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
468
+ const events = []
469
+ const stream = streamPromptToCodexSession(
470
+ { id: 'session-xyz', cwd: getSessionCwd() },
471
+ 'mojibake-case',
472
+ {
473
+ onEvent(event) {
474
+ events.push(event)
475
+ },
476
+ }
477
+ )
478
+
479
+ const streamResult = await stream.result
480
+
481
+ assert.equal(streamResult.message, '获取测试数据')
482
+ assert.equal(
483
+ events.find((event) => event.type === 'agent_event' && event.event.type === 'item.completed')?.event?.item?.aggregated_output,
484
+ '获取测试数据'
485
+ )
486
+ }
487
+ )
488
+ })
489
+
490
+ test('Windows resolves codex.cmd when CODEX_BIN is omitted', async (t) => {
491
+ if (process.platform !== 'win32') {
492
+ t.skip('Windows-only validation')
493
+ return
494
+ }
495
+
496
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-codex-win-'))
497
+ createWindowsCodexCommand(tempHome)
498
+
499
+ await withEnv(
500
+ {
501
+ CODEX_HOME: tempHome,
502
+ CODEX_BIN: undefined,
503
+ PATH: `${tempHome};${process.env.PATH || ''}`,
504
+ },
505
+ async () => {
506
+ const { streamPromptToCodexSession } = await importFreshCodexModule()
507
+ const stream = streamPromptToCodexSession(
508
+ { id: 'session-win', cwd: 'D:\\code\\promptx' },
509
+ 'hello from windows'
510
+ )
511
+ const result = await stream.result
512
+
513
+ assert.equal(result.sessionId, 'session-win')
514
+ assert.equal(result.threadId, 'thread-win')
515
+ assert.match(result.message, /prompt:hello from windows/)
516
+ }
517
+ )
518
+ })