@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-alpha.0

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 (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. package/studio/server/state/harness-token.ts +0 -0
@@ -0,0 +1,269 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { CodexHarness } from './adapter'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ function fakeCodex(root: string): string {
15
+ const file = join(root, 'fake-codex')
16
+ writeFileSync(
17
+ file,
18
+ `#!/usr/bin/env bun
19
+ const args = process.argv.slice(2)
20
+ if (args[0] === '--version') {
21
+ console.log('codex-cli 0.test')
22
+ process.exit(0)
23
+ }
24
+ if (args[0] === 'app-server') {
25
+ if (process.env.FAKE_APP_MODE !== 'partial-fail') process.exit(2)
26
+ let buffer = ''
27
+ const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n')
28
+ process.stdin.setEncoding('utf8')
29
+ process.stdin.on('data', (chunk) => {
30
+ buffer += chunk
31
+ let newline
32
+ while ((newline = buffer.indexOf('\\n')) >= 0) {
33
+ const line = buffer.slice(0, newline).trim()
34
+ buffer = buffer.slice(newline + 1)
35
+ if (!line) continue
36
+ const message = JSON.parse(line)
37
+ if (message.method === 'initialize') send({ id: message.id, result: { userAgent: 'fake' } })
38
+ if (message.method === 'thread/fork') send({ id: message.id, result: { thread: { id: 'fork-thread' } } })
39
+ if (message.method === 'turn/start') {
40
+ send({ id: message.id, result: { turn: { id: 'turn-1' } } })
41
+ send({ method: 'item/agentMessage/delta', params: { delta: 'partial' } })
42
+ send({ method: 'turn/completed', params: { turn: { status: 'failed', error: { message: 'late failure' } } } })
43
+ }
44
+ }
45
+ })
46
+ await new Promise(() => {})
47
+ }
48
+ if (args[0] !== 'exec') process.exit(2)
49
+
50
+ let prompt = ''
51
+ process.stdin.setEncoding('utf8')
52
+ process.stdin.on('data', (chunk) => { prompt += chunk })
53
+ process.stdin.on('end', () => {
54
+ if (process.env.FAKE_CODEX_LOG) {
55
+ require('node:fs').writeFileSync(
56
+ process.env.FAKE_CODEX_LOG,
57
+ JSON.stringify({ args, prompt }),
58
+ )
59
+ }
60
+ const thread = args[1] === 'resume' ? args[2] : 'new-thread'
61
+ if (thread === 'rejected-thread') {
62
+ console.error('Error: thread/resume: thread/resume failed: no rollout found for thread id rejected-thread')
63
+ process.exit(1)
64
+ }
65
+ if (thread === 'transient-thread') {
66
+ console.error('network unavailable')
67
+ process.exit(1)
68
+ }
69
+ const send = (event) => process.stdout.write(JSON.stringify(event) + '\\n')
70
+ if (prompt === 'hang') {
71
+ const descendant = require('node:child_process').spawn(
72
+ process.execPath,
73
+ ['-e', 'setInterval(() => {}, 1000)'],
74
+ { stdio: 'ignore' },
75
+ )
76
+ if (process.env.FAKE_DESCENDANT_PID) {
77
+ require('node:fs').writeFileSync(process.env.FAKE_DESCENDANT_PID, String(descendant.pid))
78
+ }
79
+ process.on('SIGTERM', () => process.exit(143))
80
+ send({ type: 'thread.started', thread_id: thread })
81
+ setInterval(() => {}, 1000)
82
+ return
83
+ }
84
+ send({ type: 'thread.started', thread_id: thread })
85
+ send({ type: 'turn.started' })
86
+ send({ type: 'item.completed', item: { id: 'answer', type: 'agent_message', text: 'done' } })
87
+ if (process.env.FAKE_CODEX_MODE === 'no-terminal') return
88
+ send({ type: 'turn.completed', usage: { input_tokens: 7, cached_input_tokens: 2, output_tokens: 3 } })
89
+ })
90
+ `,
91
+ )
92
+ chmodSync(file, 0o755)
93
+ return file
94
+ }
95
+
96
+ function turnInput(
97
+ root: string,
98
+ overrides: Partial<Parameters<CodexHarness['run']>[0]> = {},
99
+ ): Parameters<CodexHarness['run']>[0] {
100
+ return {
101
+ root,
102
+ prompt: 'work',
103
+ signal: new AbortController().signal,
104
+ onEvent: () => {},
105
+ ...overrides,
106
+ }
107
+ }
108
+
109
+ async function waitForExit(pid: number): Promise<boolean> {
110
+ for (let i = 0; i < 40; i++) {
111
+ try {
112
+ process.kill(pid, 0)
113
+ } catch {
114
+ return true
115
+ }
116
+ await Bun.sleep(25)
117
+ }
118
+ return false
119
+ }
120
+
121
+ describe('Codex process runner', () => {
122
+ test('streams a real JSONL subprocess turn and pipes the prompt over stdin', async () => {
123
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-run-'))
124
+ roots.push(root)
125
+ const bin = fakeCodex(root)
126
+ const log = join(root, 'invocation.json')
127
+ const events: string[] = []
128
+
129
+ const result = await new CodexHarness(bin).run(
130
+ turnInput(root, {
131
+ prompt: 'inspect this domain',
132
+ env: { FAKE_CODEX_LOG: log },
133
+ onEvent: (event) => events.push(`${event.kind}:${event.text}`),
134
+ }),
135
+ )
136
+
137
+ expect(result).toMatchObject({
138
+ sessionId: 'new-thread',
139
+ finalText: 'done',
140
+ tokens: 10,
141
+ isError: false,
142
+ })
143
+ expect(events).toContain('status:session started')
144
+ expect(events).toContain('message:done')
145
+ const invocation = JSON.parse(readFileSync(log, 'utf8'))
146
+ expect(invocation.prompt).toBe('inspect this domain')
147
+ expect(invocation.args).toContain('--json')
148
+ })
149
+
150
+ test('marks an invalid stored thread but not a transient resumed-turn failure as rejected', async () => {
151
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-resume-'))
152
+ roots.push(root)
153
+ const harness = new CodexHarness(fakeCodex(root))
154
+ const rejected = await harness.run(turnInput(root, { sessionId: 'rejected-thread' }))
155
+ const transient = await harness.run(turnInput(root, { sessionId: 'transient-thread' }))
156
+
157
+ expect(rejected).toMatchObject({
158
+ isError: true,
159
+ resumeRejected: true,
160
+ })
161
+ expect(rejected.errorMessage).toContain('no rollout found')
162
+ expect(transient).toMatchObject({
163
+ isError: true,
164
+ resumeRejected: false,
165
+ })
166
+ expect(transient.errorMessage).toContain('network unavailable')
167
+ })
168
+
169
+ test('terminates a running process group when the turn is canceled', async () => {
170
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-cancel-'))
171
+ roots.push(root)
172
+ const controller = new AbortController()
173
+ const descendantPidFile = join(root, 'descendant.pid')
174
+ let aborted = false
175
+ const result = await new CodexHarness(fakeCodex(root)).run(
176
+ turnInput(root, {
177
+ prompt: 'hang',
178
+ env: { FAKE_DESCENDANT_PID: descendantPidFile },
179
+ signal: controller.signal,
180
+ onEvent: (event) => {
181
+ if (!aborted && event.kind === 'status') {
182
+ aborted = true
183
+ controller.abort()
184
+ }
185
+ },
186
+ }),
187
+ )
188
+
189
+ expect(result).toMatchObject({
190
+ isError: true,
191
+ errorMessage: 'canceled',
192
+ })
193
+ const descendantPid = Number(readFileSync(descendantPidFile, 'utf8'))
194
+ const exited = await waitForExit(descendantPid)
195
+ if (!exited) {
196
+ try {
197
+ process.kill(descendantPid, 'SIGKILL')
198
+ } catch {
199
+ /* already gone */
200
+ }
201
+ }
202
+ expect(exited).toBe(true)
203
+ })
204
+
205
+ test('falls back to a fresh ephemeral exec without resuming the parent Ask thread', async () => {
206
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-ask-fallback-'))
207
+ roots.push(root)
208
+ const log = join(root, 'invocation.json')
209
+ const deltas: string[] = []
210
+ const result = await new CodexHarness(fakeCodex(root)).ask({
211
+ root,
212
+ prompt: 'side question',
213
+ appendSystemPrompt: 'ask protocol',
214
+ sessionId: 'parent-thread',
215
+ model: 'gpt-studio',
216
+ effort: 'high',
217
+ access: 'workspace',
218
+ env: { FAKE_CODEX_LOG: log },
219
+ signal: new AbortController().signal,
220
+ onDelta: (delta) => deltas.push(delta),
221
+ })
222
+
223
+ expect(result).toEqual({ text: 'done', isError: false, errorMessage: undefined })
224
+ expect(deltas).toEqual(['done'])
225
+ const invocation = JSON.parse(readFileSync(log, 'utf8'))
226
+ expect(invocation.args.slice(0, 2)).toEqual(['exec', '--json'])
227
+ expect(invocation.args).not.toContain('resume')
228
+ expect(invocation.args).toContain('--ephemeral')
229
+ const modelAt = invocation.args.indexOf('--model')
230
+ expect(invocation.args.slice(modelAt, modelAt + 2)).toEqual(['--model', 'gpt-studio'])
231
+ })
232
+
233
+ test('does not retry after a failed Ask attempt has already streamed partial text', async () => {
234
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-ask-partial-'))
235
+ roots.push(root)
236
+ const execLog = join(root, 'exec.json')
237
+ const deltas: string[] = []
238
+ const result = await new CodexHarness(fakeCodex(root)).ask({
239
+ root,
240
+ prompt: 'side question',
241
+ sessionId: 'parent-thread',
242
+ env: { FAKE_APP_MODE: 'partial-fail', FAKE_CODEX_LOG: execLog },
243
+ signal: new AbortController().signal,
244
+ onDelta: (delta) => deltas.push(delta),
245
+ })
246
+
247
+ expect(result).toEqual({
248
+ text: 'partial',
249
+ isError: true,
250
+ errorMessage: 'late failure',
251
+ })
252
+ expect(deltas).toEqual(['partial'])
253
+ expect(existsSync(execLog)).toBe(false)
254
+ })
255
+
256
+ test('rejects a zero-exit JSONL stream without a terminal turn event', async () => {
257
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-terminal-'))
258
+ roots.push(root)
259
+ const result = await new CodexHarness(fakeCodex(root)).run(
260
+ turnInput(root, { env: { FAKE_CODEX_MODE: 'no-terminal' } }),
261
+ )
262
+
263
+ expect(result).toMatchObject({
264
+ finalText: 'done',
265
+ isError: true,
266
+ errorMessage: 'codex exited without a terminal turn event',
267
+ })
268
+ })
269
+ })
@@ -0,0 +1,116 @@
1
+ import type { HarnessLoadout } from '../../../../shared/types'
2
+ import type {
3
+ AgentHarness,
4
+ AgentTurnInput,
5
+ AgentTurnResult,
6
+ AskInput,
7
+ AskResult,
8
+ HarnessHealth,
9
+ HarnessLoadoutOptions,
10
+ } from '../adapter'
11
+
12
+ import { captureCommand } from '../process'
13
+ import { readSkillContent } from '../skills'
14
+ import { runCodexForkAsk } from './ask'
15
+ import { runCodexExec } from './exec'
16
+ import { loadCodexConfiguration } from './loadout'
17
+ import { scanCodexSkills } from './skills'
18
+
19
+ const DEFAULT_BIN = process.env.DOMAIN_STUDIO_CODEX_BIN || 'codex'
20
+
21
+ export class CodexHarness implements AgentHarness {
22
+ id = 'codex'
23
+ label = 'Codex (local)'
24
+ capabilities = {
25
+ effortLevels: ['minimal', 'low', 'medium', 'high', 'xhigh'],
26
+ accessLevels: ['workspace', 'full'],
27
+ ask: true,
28
+ loadout: true,
29
+ gateway: 'none',
30
+ } as const
31
+
32
+ private availCache?: { at: number; health: HarnessHealth }
33
+ private loadoutCache?: { at: number; key: string; data: HarnessLoadout }
34
+
35
+ constructor(private readonly bin = DEFAULT_BIN) {}
36
+
37
+ async health(signal?: AbortSignal): Promise<HarnessHealth> {
38
+ const now = Date.now()
39
+ if (this.availCache && now - this.availCache.at < 30_000) return this.availCache.health
40
+ const result = await captureCommand(this.bin, ['--version'], process.cwd(), { signal })
41
+ const health: HarnessHealth = {
42
+ ok: result.code === 0,
43
+ version: result.code === 0 ? result.stdout.trim() || undefined : undefined,
44
+ bin: this.bin,
45
+ detail:
46
+ result.code === 0
47
+ ? undefined
48
+ : result.stderr.trim() ||
49
+ `\`${this.bin}\` was not found on PATH; install it and run \`codex login\``,
50
+ }
51
+ if (!signal?.aborted) this.availCache = { at: now, health }
52
+ return health
53
+ }
54
+
55
+ async isAvailable(signal?: AbortSignal): Promise<boolean> {
56
+ return (await this.health(signal)).ok
57
+ }
58
+
59
+ run(input: AgentTurnInput): Promise<AgentTurnResult> {
60
+ return runCodexExec(this.bin, input)
61
+ }
62
+
63
+ async ask(input: AskInput): Promise<AskResult> {
64
+ let result = await runCodexForkAsk(this.bin, input)
65
+ if (result.isError && result.text) return result
66
+ if (result.isError && result.errorMessage !== 'canceled' && input.sessionId)
67
+ result = await runCodexForkAsk(this.bin, { ...input, sessionId: undefined })
68
+ if (!result.isError || result.errorMessage === 'canceled') return result
69
+ if (result.text) return result
70
+
71
+ let streamed = ''
72
+ const fallback = await runCodexExec(
73
+ this.bin,
74
+ {
75
+ ...input,
76
+ sessionId: undefined,
77
+ mcpServers: [],
78
+ onEvent: (event) => {
79
+ if (event.kind === 'message') {
80
+ streamed = event.text
81
+ input.onDelta(event.text)
82
+ }
83
+ },
84
+ },
85
+ true,
86
+ )
87
+ return {
88
+ text: fallback.finalText || streamed,
89
+ isError: fallback.isError,
90
+ errorMessage: fallback.errorMessage,
91
+ }
92
+ }
93
+
94
+ async loadout(root: string, options?: HarnessLoadoutOptions): Promise<HarnessLoadout> {
95
+ const now = Date.now()
96
+ const key = `${root}\u0000${options?.model ?? ''}\u0000${JSON.stringify(options?.env ?? {})}`
97
+ if (
98
+ !options?.refresh &&
99
+ this.loadoutCache &&
100
+ this.loadoutCache.key === key &&
101
+ now - this.loadoutCache.at < 60_000
102
+ )
103
+ return this.loadoutCache.data
104
+ const data = await loadCodexConfiguration(this.bin, root, options)
105
+ this.loadoutCache = { at: now, key, data }
106
+ return data
107
+ }
108
+
109
+ async skillContent(
110
+ root: string,
111
+ command: string,
112
+ ): Promise<{ command: string; content: string; path: string } | null> {
113
+ const loadout = await this.loadout(root)
114
+ return readSkillContent(loadout.skills, command)
115
+ }
116
+ }
@@ -0,0 +1,176 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { runCodexForkAsk } from './ask'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ function fakeCodex(root: string): string {
15
+ const file = join(root, 'fake-codex')
16
+ writeFileSync(
17
+ file,
18
+ `#!/usr/bin/env bun
19
+ let buffer = ''
20
+ const fs = require('node:fs')
21
+ const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n')
22
+ const record = (message) => {
23
+ if (process.env.FAKE_APP_LOG) fs.appendFileSync(process.env.FAKE_APP_LOG, JSON.stringify(message) + '\\n')
24
+ }
25
+ process.stdin.setEncoding('utf8')
26
+ process.stdin.on('data', (chunk) => {
27
+ buffer += chunk
28
+ let newline
29
+ while ((newline = buffer.indexOf('\\n')) >= 0) {
30
+ const line = buffer.slice(0, newline).trim()
31
+ buffer = buffer.slice(newline + 1)
32
+ if (!line) continue
33
+ const message = JSON.parse(line)
34
+ record(message)
35
+ const fresh = process.env.FAKE_APP_EXPECT_FRESH === '1'
36
+ if (message.method === 'initialize') send({ id: message.id, result: { userAgent: 'fake' } })
37
+ if (message.method === 'thread/fork') {
38
+ if (fresh) {
39
+ send({ id: message.id, error: { message: 'unexpected fork' } })
40
+ } else if (message.params.threadId !== 'parent-thread') {
41
+ send({ id: message.id, error: { message: 'wrong parent' } })
42
+ } else {
43
+ send({ id: message.id, result: { thread: { id: 'fork-thread' } } })
44
+ }
45
+ }
46
+ if (message.method === 'thread/start') {
47
+ if (fresh) send({ id: message.id, result: { thread: { id: 'fresh-thread' } } })
48
+ else send({ id: message.id, error: { message: 'unexpected fresh thread' } })
49
+ }
50
+ if (message.method === 'turn/start') {
51
+ const expectedThread = fresh ? 'fresh-thread' : 'fork-thread'
52
+ if (message.params.threadId !== expectedThread) {
53
+ send({ id: message.id, error: { message: 'turn did not target fork' } })
54
+ } else {
55
+ send({ id: message.id, result: { turn: { id: 'turn-1' } } })
56
+ send({ method: 'item/agentMessage/delta', params: { delta: 'hello ' } })
57
+ send({ method: 'item/agentMessage/delta', params: { delta: 'studio' } })
58
+ send({ method: 'item/completed', params: { item: { type: 'agentMessage', text: 'hello studio' } } })
59
+ send({
60
+ method: 'turn/completed',
61
+ params: { turn: { status: process.env.FAKE_APP_TERMINAL || 'completed' } },
62
+ })
63
+ }
64
+ }
65
+ }
66
+ })
67
+ `,
68
+ )
69
+ chmodSync(file, 0o755)
70
+ return file
71
+ }
72
+
73
+ test('forks the parent thread and streams the side answer without mutating it', async () => {
74
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-app-server-'))
75
+ roots.push(root)
76
+ const bin = fakeCodex(root)
77
+ const log = join(root, 'app-server.jsonl')
78
+ const deltas: string[] = []
79
+ const result = await runCodexForkAsk(bin, {
80
+ root,
81
+ prompt: 'question',
82
+ appendSystemPrompt: 'ask protocol',
83
+ sessionId: 'parent-thread',
84
+ model: 'gpt-studio',
85
+ effort: 'high',
86
+ access: 'workspace',
87
+ env: { FAKE_APP_LOG: log },
88
+ signal: new AbortController().signal,
89
+ onDelta: (delta) => deltas.push(delta),
90
+ })
91
+
92
+ expect(result).toEqual({ text: 'hello studio', isError: false })
93
+ expect(deltas).toEqual(['hello ', 'studio'])
94
+ const requests = readFileSync(log, 'utf8')
95
+ .trim()
96
+ .split('\n')
97
+ .map((line) => JSON.parse(line))
98
+ expect(requests.map((request) => request.method)).toEqual([
99
+ 'initialize',
100
+ 'initialized',
101
+ 'thread/fork',
102
+ 'turn/start',
103
+ ])
104
+ expect(requests[2].params).toMatchObject({
105
+ threadId: 'parent-thread',
106
+ cwd: root,
107
+ approvalPolicy: 'never',
108
+ sandbox: 'workspace-write',
109
+ ephemeral: true,
110
+ model: 'gpt-studio',
111
+ config: { 'mcp_servers.domain-studio.enabled': false },
112
+ })
113
+ expect(requests[3].params).toMatchObject({
114
+ threadId: 'fork-thread',
115
+ model: 'gpt-studio',
116
+ effort: 'high',
117
+ })
118
+ })
119
+
120
+ test('starts a fresh Ask thread and normalizes Claude-only effort modes', async () => {
121
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-app-server-fresh-'))
122
+ roots.push(root)
123
+ const log = join(root, 'app-server-fresh.jsonl')
124
+ const result = await runCodexForkAsk(fakeCodex(root), {
125
+ root,
126
+ prompt: 'first question',
127
+ model: 'gpt-fresh',
128
+ effort: 'ultracode',
129
+ access: 'full',
130
+ env: { FAKE_APP_LOG: log, FAKE_APP_EXPECT_FRESH: '1' },
131
+ signal: new AbortController().signal,
132
+ onDelta: () => {},
133
+ })
134
+
135
+ expect(result).toEqual({ text: 'hello studio', isError: false })
136
+ const requests = readFileSync(log, 'utf8')
137
+ .trim()
138
+ .split('\n')
139
+ .map((line) => JSON.parse(line))
140
+ expect(requests.map((request) => request.method)).toEqual([
141
+ 'initialize',
142
+ 'initialized',
143
+ 'thread/start',
144
+ 'turn/start',
145
+ ])
146
+ expect(requests[2].params).toMatchObject({
147
+ cwd: root,
148
+ sandbox: 'danger-full-access',
149
+ ephemeral: true,
150
+ model: 'gpt-fresh',
151
+ })
152
+ expect(requests[3].params).toMatchObject({
153
+ threadId: 'fresh-thread',
154
+ model: 'gpt-fresh',
155
+ effort: 'xhigh',
156
+ })
157
+ })
158
+
159
+ test('does not report canceled app-server turns as successful answers', async () => {
160
+ const root = mkdtempSync(join(tmpdir(), 'studio-codex-app-server-canceled-'))
161
+ roots.push(root)
162
+ const result = await runCodexForkAsk(fakeCodex(root), {
163
+ root,
164
+ prompt: 'question',
165
+ sessionId: 'parent-thread',
166
+ env: { FAKE_APP_TERMINAL: 'canceled' },
167
+ signal: new AbortController().signal,
168
+ onDelta: () => {},
169
+ })
170
+
171
+ expect(result).toEqual({
172
+ text: 'hello studio',
173
+ isError: true,
174
+ errorMessage: 'Codex side question ended with status canceled',
175
+ })
176
+ })