@swarmclawai/swarmclaw 1.7.0 → 1.7.1

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.
@@ -1,12 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
- import {
4
- DEFAULT_BUILDER_ROUTE,
5
- deriveHomeMode,
6
- getLaunchPathCards,
7
- isSparseWorkspace,
8
- resolveLaunchPathHref,
9
- } from './home-launchpad'
3
+ import { deriveHomeMode, isSparseWorkspace } from './home-launchpad'
10
4
 
11
5
  test('isSparseWorkspace detects a fresh workspace', () => {
12
6
  assert.equal(isSparseWorkspace({
@@ -53,27 +47,3 @@ test('deriveHomeMode falls back to ops for active workspaces', () => {
53
47
  todayCost: 0,
54
48
  }), 'ops')
55
49
  })
56
-
57
- test('getLaunchPathCards returns the three first-run paths in order', () => {
58
- const cards = getLaunchPathCards({ firstAgentName: 'Ada' })
59
- assert.deepEqual(cards.map((card) => card.id), ['assistant', 'workflow', 'mission'])
60
- assert.equal(cards[0]?.title, 'Work with Ada')
61
- assert.equal(cards[0]?.primaryLabel, 'Open Chat')
62
- assert.equal(cards[1]?.primaryLabel, 'Open Builder')
63
- assert.equal(cards[2]?.secondaryLabel, 'Quality Center')
64
- })
65
-
66
- test('getLaunchPathCards falls back to agent creation copy when no agent exists', () => {
67
- const [assistant] = getLaunchPathCards()
68
- assert.equal(assistant?.title, 'Create the first agent')
69
- assert.equal(assistant?.primaryLabel, 'Open Agents')
70
- })
71
-
72
- test('resolveLaunchPathHref builds primary and secondary destinations', () => {
73
- assert.equal(resolveLaunchPathHref('assistant', 'primary', 'agent one'), '/agents/agent%20one')
74
- assert.equal(resolveLaunchPathHref('assistant', 'secondary', 'agent one'), '/connectors')
75
- assert.equal(resolveLaunchPathHref('workflow', 'primary'), DEFAULT_BUILDER_ROUTE)
76
- assert.equal(resolveLaunchPathHref('workflow', 'secondary'), '/protocols')
77
- assert.equal(resolveLaunchPathHref('mission', 'primary'), '/missions')
78
- assert.equal(resolveLaunchPathHref('mission', 'secondary'), '/quality')
79
- })
@@ -2,17 +2,6 @@ export const HOME_LAUNCHPAD_AFTER_SETUP_KEY = 'sc_launchpad_after_setup_v1'
2
2
  export const DEFAULT_BUILDER_ROUTE = '/protocols/builder/facilitated_discussion'
3
3
 
4
4
  export type HomeMode = 'launchpad' | 'ops'
5
- export type LaunchPathId = 'assistant' | 'workflow' | 'mission'
6
- export type LaunchPathAction = 'primary' | 'secondary'
7
-
8
- export interface LaunchPathCardCopy {
9
- id: LaunchPathId
10
- kicker: string
11
- title: string
12
- description: string
13
- primaryLabel: string
14
- secondaryLabel: string
15
- }
16
5
 
17
6
  export interface HomeModeInput {
18
7
  hasLaunchpadFlag: boolean
@@ -39,50 +28,3 @@ export function deriveHomeMode(input: HomeModeInput): HomeMode {
39
28
  if (input.hasLaunchpadFlag) return 'launchpad'
40
29
  return isSparseWorkspace(input) ? 'launchpad' : 'ops'
41
30
  }
42
-
43
- export function getLaunchPathCards(input: { firstAgentName?: string | null } = {}): LaunchPathCardCopy[] {
44
- const firstAgentName = input.firstAgentName?.trim() || null
45
- return [
46
- {
47
- id: 'assistant',
48
- kicker: 'Assistant',
49
- title: firstAgentName ? `Work with ${firstAgentName}` : 'Create the first agent',
50
- description: 'Open a live agent chat, then add memory, local tools, provider routing, or connector access as the work demands.',
51
- primaryLabel: firstAgentName ? 'Open Chat' : 'Open Agents',
52
- secondaryLabel: 'Connect Platform',
53
- },
54
- {
55
- id: 'workflow',
56
- kicker: 'Workflow',
57
- title: 'Shape a reusable run',
58
- description: 'Use protocol templates and the builder to turn review, research, planning, or release checks into durable workflows.',
59
- primaryLabel: 'Open Builder',
60
- secondaryLabel: 'Use Templates',
61
- },
62
- {
63
- id: 'mission',
64
- kicker: 'Mission',
65
- title: 'Run with budgets',
66
- description: 'Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps.',
67
- primaryLabel: 'Open Missions',
68
- secondaryLabel: 'Quality Center',
69
- },
70
- ]
71
- }
72
-
73
- export function resolveLaunchPathHref(
74
- id: LaunchPathId,
75
- action: LaunchPathAction,
76
- firstAgentId?: string | null,
77
- ): string {
78
- if (id === 'assistant') {
79
- if (action === 'primary') {
80
- return firstAgentId ? `/agents/${encodeURIComponent(firstAgentId)}` : '/agents'
81
- }
82
- return '/connectors'
83
- }
84
- if (id === 'workflow') {
85
- return action === 'primary' ? DEFAULT_BUILDER_ROUTE : '/protocols'
86
- }
87
- return action === 'primary' ? '/missions' : '/quality'
88
- }
@@ -1,7 +1,17 @@
1
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'
2
5
  import { describe, it } from 'node:test'
3
6
 
4
- import { isStderrNoise, buildCliEnv, isCliProvider, CLI_PROVIDER_CAPABILITIES } from './cli-utils'
7
+ import {
8
+ isStderrNoise,
9
+ buildCliEnv,
10
+ isCliProvider,
11
+ CLI_PROVIDER_CAPABILITIES,
12
+ resolveCodexProbeInvocation,
13
+ ensureCliWorkingDirectory,
14
+ } from './cli-utils'
5
15
 
6
16
  // ---------------------------------------------------------------------------
7
17
  // isStderrNoise
@@ -142,3 +152,57 @@ describe('CLI_PROVIDER_CAPABILITIES', () => {
142
152
  }
143
153
  })
144
154
  })
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // resolveCodexProbeInvocation
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe('resolveCodexProbeInvocation', () => {
161
+ it('uses node directly for codex JS wrappers discovered through a symlink', () => {
162
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-codex-probe-'))
163
+ try {
164
+ const realScript = path.join(tmpRoot, 'codex.js')
165
+ fs.writeFileSync(realScript, '#!/usr/bin/env node\nconsole.log("ok")\n')
166
+ fs.chmodSync(realScript, 0o755)
167
+
168
+ const wrapperPath = path.join(tmpRoot, 'codex')
169
+ fs.symlinkSync(realScript, wrapperPath)
170
+
171
+ const invocation = resolveCodexProbeInvocation(wrapperPath)
172
+ assert.equal(invocation.command, process.execPath)
173
+ assert.deepEqual(invocation.args, [fs.realpathSync(realScript), 'login', 'status'])
174
+ } finally {
175
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
176
+ }
177
+ })
178
+
179
+ it('uses the binary directly for non-JS executables', () => {
180
+ const invocation = resolveCodexProbeInvocation('/usr/local/bin/codex-bin')
181
+ assert.equal(invocation.command, '/usr/local/bin/codex-bin')
182
+ assert.deepEqual(invocation.args, ['login', 'status'])
183
+ })
184
+ })
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // ensureCliWorkingDirectory
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe('ensureCliWorkingDirectory', () => {
191
+ it('creates a missing working directory recursively', () => {
192
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-cwd-'))
193
+ const target = path.join(tmpRoot, 'nested', 'room')
194
+ try {
195
+ assert.equal(fs.existsSync(target), false)
196
+ const resolved = ensureCliWorkingDirectory(target)
197
+ assert.equal(resolved, target)
198
+ assert.equal(fs.existsSync(target), true)
199
+ assert.equal(fs.statSync(target).isDirectory(), true)
200
+ } finally {
201
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
202
+ }
203
+ })
204
+
205
+ it('falls back to process.cwd when cwd is blank', () => {
206
+ assert.equal(ensureCliWorkingDirectory(''), process.cwd())
207
+ })
208
+ })
@@ -10,6 +10,7 @@ import os from 'os'
10
10
  import { findBinaryOnPath } from '../server/session-tools/context'
11
11
  import path from 'path'
12
12
  import { spawnSync, type ChildProcess } from 'child_process'
13
+ import { realpathSync } from 'fs'
13
14
  import { log } from '../server/logger'
14
15
 
15
16
  // ---------------------------------------------------------------------------
@@ -168,6 +169,25 @@ export interface AuthProbeResult {
168
169
  errorMessage?: string
169
170
  }
170
171
 
172
+ export function resolveCodexProbeInvocation(binary: string): { command: string; args: string[] } {
173
+ try {
174
+ const resolved = realpathSync(binary)
175
+ if (resolved.toLowerCase().endsWith('.js')) {
176
+ return { command: process.execPath, args: [resolved, 'login', 'status'] }
177
+ }
178
+ } catch {
179
+ // Fall through to direct execution when the binary cannot be resolved.
180
+ }
181
+
182
+ return { command: binary, args: ['login', 'status'] }
183
+ }
184
+
185
+ export function ensureCliWorkingDirectory(cwd?: string | null): string {
186
+ const resolved = typeof cwd === 'string' && cwd.trim() ? cwd.trim() : process.cwd()
187
+ fs.mkdirSync(resolved, { recursive: true })
188
+ return resolved
189
+ }
190
+
171
191
  /**
172
192
  * Unified auth check for supported CLI-backed providers.
173
193
  */
@@ -198,7 +218,8 @@ export function probeCliAuth(
198
218
  }
199
219
 
200
220
  if (backend === 'codex') {
201
- const probe = spawnSync(binary, ['login', 'status'], {
221
+ const invocation = resolveCodexProbeInvocation(binary)
222
+ const probe = spawnSync(invocation.command, invocation.args, {
202
223
  cwd, env, encoding: 'utf-8', timeout: 8000,
203
224
  })
204
225
  const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
@@ -5,7 +5,7 @@ import { spawn } from 'child_process'
5
5
  import type { StreamChatOptions } from './index'
6
6
  import { log } from '../server/logger'
7
7
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
8
- import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
8
+ import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise, ensureCliWorkingDirectory } from './cli-utils'
9
9
  import { getAgent } from '@/lib/server/agents/agent-repository'
10
10
  import { loadMcpServers } from '@/lib/server/storage'
11
11
 
@@ -27,6 +27,9 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
27
27
 
28
28
  const prompt = message
29
29
  const args: string[] = ['exec']
30
+ // Use ~/.codex-sessions/ not /tmp — codex refuses to create helper binaries under /tmp.
31
+ const sessionsDir = path.join(os.homedir(), '.codex-sessions')
32
+ const perSessionHome = path.join(sessionsDir, session.id)
30
33
 
31
34
  // Session resume
32
35
  if (session.codexThreadId) {
@@ -53,15 +56,16 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
53
56
 
54
57
  // Build clean env — preserves user's CODEX_HOME for auth
55
58
  const env = buildCliEnv()
59
+ const effectiveCwd = ensureCliWorkingDirectory(session.cwd)
56
60
 
57
61
  // Pass API key if available
58
62
  if (session.apiKey) {
59
63
  env.OPENAI_API_KEY = session.apiKey
60
64
  }
61
65
 
62
- // Auth probe BEFORE creating temp CODEX_HOME — uses real config dir
66
+ // Auth probe BEFORE creating the session CODEX_HOME — uses real config dir
63
67
  if (!session.apiKey) {
64
- const auth = probeCliAuth(binary, 'codex', env, session.cwd)
68
+ const auth = probeCliAuth(binary, 'codex', env, effectiveCwd)
65
69
  if (!auth.authenticated) {
66
70
  log.error('codex-cli', auth.errorMessage || 'Auth failed')
67
71
  write(`data: ${JSON.stringify({ t: 'err', text: auth.errorMessage || 'Codex CLI is not authenticated.' })}\n\n`)
@@ -69,86 +73,80 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
69
73
  }
70
74
  }
71
75
 
72
- // System prompt + MCP injection: create a temp CODEX_HOME when needed
73
- // Symlink auth files from the real config dir so auth still works
74
- let tempCodexHome: string | null = null
76
+ // Always use a stable per-session CODEX_HOME for the actual Codex run. A first
77
+ // turn can emit a thread id even without system-prompt or MCP injection, and
78
+ // the next turn needs the same local metadata to resume that thread.
79
+ const sessionCodexHome = perSessionHome
75
80
  const agentForMcp = session.agentId ? getAgent(session.agentId as string) : null
76
81
  const agentMcpServerIds: string[] = agentForMcp?.mcpServerIds || []
77
- const needsTempHome = (systemPrompt && !session.codexThreadId) || agentMcpServerIds.length > 0
78
- if (needsTempHome) {
79
- const realCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
80
- // Use ~/.codex-sessions/ not /tmp — codex refuses to create helper binaries under /tmp
81
- const sessionsDir = path.join(os.homedir(), '.codex-sessions')
82
- tempCodexHome = path.join(sessionsDir, session.id)
83
- fs.mkdirSync(tempCodexHome, { recursive: true })
84
-
85
- // Symlink auth/config files from real CODEX_HOME into temp dir
86
- symlinkConfigFiles(realCodexHome, tempCodexHome)
87
-
88
- // Write system prompt as AGENTS.override.md (first turn only)
89
- if (systemPrompt && !session.codexThreadId) {
90
- fs.writeFileSync(path.join(tempCodexHome, 'AGENTS.override.md'), systemPrompt)
91
- }
82
+ const realCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
83
+ fs.mkdirSync(sessionCodexHome, { recursive: true })
92
84
 
93
- // Inject agent-assigned MCP servers into config.toml
94
- if (agentMcpServerIds.length > 0) {
95
- try {
96
- const allMcpServers = loadMcpServers()
97
- const tomlParts: string[] = []
98
- for (const serverId of agentMcpServerIds) {
99
- const config = allMcpServers[serverId]
100
- if (!config) continue
101
- const name = config.name.replace(/[^a-zA-Z0-9_]/g, '_')
102
- if (config.transport === 'stdio' && config.command) {
103
- tomlParts.push(`[mcp_servers.${name}]`)
104
- tomlParts.push(`command = ${JSON.stringify(config.command)}`)
105
- const argsStr = (config.args || []).map((a: string) => JSON.stringify(a)).join(', ')
106
- tomlParts.push(`args = [${argsStr}]`)
107
- if (config.cwd) tomlParts.push(`cwd = ${JSON.stringify(config.cwd)}`)
108
- tomlParts.push('')
109
- // Env vars go in a separate subsection: [mcp_servers.name.env]
110
- if (config.env && Object.keys(config.env).length > 0) {
111
- tomlParts.push(`[mcp_servers.${name}.env]`)
112
- for (const [k, v] of Object.entries(config.env as Record<string, string>)) {
113
- tomlParts.push(`${k} = ${JSON.stringify(v)}`)
114
- }
115
- tomlParts.push('')
85
+ // Symlink auth/config files from real CODEX_HOME into session dir
86
+ symlinkConfigFiles(realCodexHome, sessionCodexHome)
87
+
88
+ // Write system prompt as AGENTS.override.md (first turn only)
89
+ if (systemPrompt && !session.codexThreadId) {
90
+ fs.writeFileSync(path.join(sessionCodexHome, 'AGENTS.override.md'), systemPrompt)
91
+ }
92
+
93
+ // Inject agent-assigned MCP servers into config.toml
94
+ if (agentMcpServerIds.length > 0) {
95
+ try {
96
+ const allMcpServers = loadMcpServers()
97
+ const tomlParts: string[] = []
98
+ for (const serverId of agentMcpServerIds) {
99
+ const config = allMcpServers[serverId]
100
+ if (!config) continue
101
+ const name = config.name.replace(/[^a-zA-Z0-9_]/g, '_')
102
+ if (config.transport === 'stdio' && config.command) {
103
+ tomlParts.push(`[mcp_servers.${name}]`)
104
+ tomlParts.push(`command = ${JSON.stringify(config.command)}`)
105
+ const argsStr = (config.args || []).map((a: string) => JSON.stringify(a)).join(', ')
106
+ tomlParts.push(`args = [${argsStr}]`)
107
+ if (config.cwd) tomlParts.push(`cwd = ${JSON.stringify(config.cwd)}`)
108
+ tomlParts.push('')
109
+ // Env vars go in a separate subsection: [mcp_servers.name.env]
110
+ if (config.env && Object.keys(config.env).length > 0) {
111
+ tomlParts.push(`[mcp_servers.${name}.env]`)
112
+ for (const [k, v] of Object.entries(config.env as Record<string, string>)) {
113
+ tomlParts.push(`${k} = ${JSON.stringify(v)}`)
116
114
  }
117
- } else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
118
- tomlParts.push(`[mcp_servers.${name}]`)
119
- tomlParts.push(`url = ${JSON.stringify(config.url)}`)
120
115
  tomlParts.push('')
121
116
  }
117
+ } else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
118
+ tomlParts.push(`[mcp_servers.${name}]`)
119
+ tomlParts.push(`url = ${JSON.stringify(config.url)}`)
120
+ tomlParts.push('')
122
121
  }
123
- if (tomlParts.length > 0) {
124
- const realConfigPath = path.join(realCodexHome, 'config.toml')
125
- const existingConfig = fs.existsSync(realConfigPath)
126
- ? fs.readFileSync(realConfigPath, 'utf-8')
127
- : ''
128
- const tempConfigPath = path.join(tempCodexHome, 'config.toml')
129
- // Remove symlink created by symlinkConfigFiles before writing our own file
130
- try { fs.unlinkSync(tempConfigPath) } catch { /* no symlink — ignore */ }
131
- fs.writeFileSync(tempConfigPath, existingConfig + '\n' + tomlParts.join('\n'))
132
- log.info('codex-cli', `Injecting ${agentMcpServerIds.length} MCP server(s) via config.toml`)
133
- }
134
- } catch (mcpErr) {
135
- log.warn('codex-cli', `Failed to build MCP config: ${mcpErr}`)
136
122
  }
123
+ if (tomlParts.length > 0) {
124
+ const realConfigPath = path.join(realCodexHome, 'config.toml')
125
+ const existingConfig = fs.existsSync(realConfigPath)
126
+ ? fs.readFileSync(realConfigPath, 'utf-8')
127
+ : ''
128
+ const tempConfigPath = path.join(sessionCodexHome, 'config.toml')
129
+ // Remove symlink created by symlinkConfigFiles before writing our own file
130
+ try { fs.unlinkSync(tempConfigPath) } catch { /* no symlink — ignore */ }
131
+ fs.writeFileSync(tempConfigPath, existingConfig + '\n' + tomlParts.join('\n'))
132
+ log.info('codex-cli', `Injecting ${agentMcpServerIds.length} MCP server(s) via config.toml`)
133
+ }
134
+ } catch (mcpErr) {
135
+ log.warn('codex-cli', `Failed to build MCP config: ${mcpErr}`)
137
136
  }
138
-
139
- env.CODEX_HOME = tempCodexHome
140
137
  }
138
+ env.CODEX_HOME = sessionCodexHome
141
139
 
142
140
  log.info('codex-cli', `Spawning: ${binary}`, {
143
141
  args: args.map(a => a.length > 100 ? a.slice(0, 100) + '...' : a),
144
- cwd: session.cwd,
142
+ cwd: effectiveCwd,
145
143
  promptLen: prompt.length,
146
144
  hasSystemPrompt: !!systemPrompt,
147
- tempCodexHome,
145
+ sessionCodexHome,
148
146
  })
149
147
 
150
148
  const proc = spawn(binary, args, {
151
- cwd: session.cwd,
149
+ cwd: effectiveCwd,
152
150
  env,
153
151
  stdio: ['pipe', 'pipe', 'pipe'],
154
152
  timeout: processTimeoutMs,
@@ -185,9 +183,14 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
185
183
  eventCount++
186
184
 
187
185
  // Track thread ID for session resume
188
- if (ev.type === 'thread.started' && ev.thread_id) {
189
- session.codexThreadId = ev.thread_id
190
- log.info('codex-cli', `Got thread_id: ${ev.thread_id}`)
186
+ const threadId = typeof ev.thread_id === 'string'
187
+ ? ev.thread_id
188
+ : (typeof ev.thread?.id === 'string' ? ev.thread.id : null)
189
+ if (threadId) {
190
+ session.codexThreadId = threadId
191
+ if (eventCount <= 3 || ev.type === 'thread.started') {
192
+ log.info('codex-cli', `Got thread_id: ${threadId} (${ev.type})`)
193
+ }
191
194
  }
192
195
 
193
196
  // Streaming text deltas (if codex adds streaming support)
@@ -269,10 +272,6 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
269
272
  proc.on('close', (code, sig) => {
270
273
  log.info('codex-cli', `Process closed: code=${code} signal=${sig} events=${eventCount} response=${fullResponse.length}chars`)
271
274
  active.delete(session.id)
272
- // Clean up temp CODEX_HOME
273
- if (tempCodexHome) {
274
- try { fs.rmSync(tempCodexHome, { recursive: true }) } catch { /* ignore */ }
275
- }
276
275
  if ((code ?? 0) !== 0 && !fullResponse.trim()) {
277
276
  const msg = stderrText.trim()
278
277
  ? `Codex CLI exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''}: ${stderrText.trim().slice(0, 1200)}`
@@ -285,9 +284,6 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
285
284
  proc.on('error', (e) => {
286
285
  log.error('codex-cli', `Process error: ${e.message}`)
287
286
  active.delete(session.id)
288
- if (tempCodexHome) {
289
- try { fs.rmSync(tempCodexHome, { recursive: true }) } catch { /* ignore */ }
290
- }
291
287
  write(`data: ${JSON.stringify({ t: 'err', text: e.message })}\n\n`)
292
288
  resolve(fullResponse)
293
289
  })
@@ -109,6 +109,195 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
109
109
  assert.equal(output.connectorContext, null)
110
110
  })
111
111
 
112
+ test('executeSessionChatTurn persists codex thread id discovered on run-session object', () => {
113
+ const output = runWithTempDataDir<{
114
+ codexThreadId: string | null
115
+ }>(`
116
+ const storageMod = await import('@/lib/server/storage')
117
+ const providersMod = await import('@/lib/providers/index')
118
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
119
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
120
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
121
+ || execMod.default?.executeSessionChatTurn
122
+ || execMod['module.exports']?.executeSessionChatTurn
123
+ const providers = providersMod.PROVIDERS
124
+ || providersMod.default?.PROVIDERS
125
+ || providersMod['module.exports']?.PROVIDERS
126
+
127
+ providers['test-codex-sync-provider'] = {
128
+ id: 'test-codex-sync-provider',
129
+ name: 'Codex Sync Test Provider',
130
+ models: ['unit'],
131
+ requiresApiKey: false,
132
+ requiresEndpoint: false,
133
+ handler: {
134
+ async streamChat(opts) {
135
+ opts.session.codexThreadId = 'thread_sync_123'
136
+ return 'ok'
137
+ },
138
+ },
139
+ }
140
+
141
+ const now = Date.now()
142
+ storage.saveAgents({
143
+ codexsync: {
144
+ id: 'codexsync',
145
+ name: 'Codex Sync Agent',
146
+ description: 'Codex thread id sync test',
147
+ provider: 'test-codex-sync-provider',
148
+ model: 'unit',
149
+ credentialId: null,
150
+ apiEndpoint: null,
151
+ fallbackCredentialIds: [],
152
+ disabled: false,
153
+ heartbeatEnabled: false,
154
+ heartbeatIntervalSec: null,
155
+ extensions: [],
156
+ createdAt: now,
157
+ updatedAt: now,
158
+ },
159
+ })
160
+
161
+ storage.saveSessions({
162
+ codex_session: {
163
+ id: 'codex_session',
164
+ name: 'Codex Session',
165
+ cwd: process.env.WORKSPACE_DIR,
166
+ user: 'default',
167
+ provider: 'test-codex-sync-provider',
168
+ model: 'unit',
169
+ claudeSessionId: null,
170
+ codexThreadId: null,
171
+ opencodeSessionId: null,
172
+ geminiSessionId: null,
173
+ copilotSessionId: null,
174
+ droidSessionId: null,
175
+ cursorSessionId: null,
176
+ qwenSessionId: null,
177
+ acpSessionId: null,
178
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null, copilot: null, droid: null, cursor: null, qwen: null },
179
+ messages: [],
180
+ createdAt: now,
181
+ lastActiveAt: now,
182
+ sessionType: 'human',
183
+ agentId: 'codexsync',
184
+ extensions: [],
185
+ },
186
+ })
187
+
188
+ await executeSessionChatTurn({
189
+ sessionId: 'codex_session',
190
+ message: 'hello',
191
+ runId: 'run-codex-sync',
192
+ })
193
+
194
+ const persisted = storage.loadSession('codex_session')
195
+ console.log(JSON.stringify({
196
+ codexThreadId: persisted?.codexThreadId || null,
197
+ }))
198
+ `)
199
+
200
+ assert.equal(output.codexThreadId, 'thread_sync_123')
201
+ })
202
+
203
+ test('executeSessionChatTurn persists intentional codex resume id clears from run-session object', () => {
204
+ const output = runWithTempDataDir<{
205
+ codexThreadId: string | null
206
+ delegateCodex: string | null
207
+ }>(`
208
+ const storageMod = await import('@/lib/server/storage')
209
+ const providersMod = await import('@/lib/providers/index')
210
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
211
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
212
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
213
+ || execMod.default?.executeSessionChatTurn
214
+ || execMod['module.exports']?.executeSessionChatTurn
215
+ const providers = providersMod.PROVIDERS
216
+ || providersMod.default?.PROVIDERS
217
+ || providersMod['module.exports']?.PROVIDERS
218
+
219
+ providers['test-codex-clear-provider'] = {
220
+ id: 'test-codex-clear-provider',
221
+ name: 'Codex Clear Test Provider',
222
+ models: ['unit'],
223
+ requiresApiKey: false,
224
+ requiresEndpoint: false,
225
+ handler: {
226
+ async streamChat(opts) {
227
+ opts.session.codexThreadId = null
228
+ opts.session.delegateResumeIds = {
229
+ ...(opts.session.delegateResumeIds || {}),
230
+ codex: null,
231
+ }
232
+ return 'ok'
233
+ },
234
+ },
235
+ }
236
+
237
+ const now = Date.now()
238
+ storage.saveAgents({
239
+ codexclear: {
240
+ id: 'codexclear',
241
+ name: 'Codex Clear Agent',
242
+ description: 'Codex resume id clear test',
243
+ provider: 'test-codex-clear-provider',
244
+ model: 'unit',
245
+ credentialId: null,
246
+ apiEndpoint: null,
247
+ fallbackCredentialIds: [],
248
+ disabled: false,
249
+ heartbeatEnabled: false,
250
+ heartbeatIntervalSec: null,
251
+ extensions: [],
252
+ createdAt: now,
253
+ updatedAt: now,
254
+ },
255
+ })
256
+
257
+ storage.saveSessions({
258
+ codex_clear_session: {
259
+ id: 'codex_clear_session',
260
+ name: 'Codex Clear Session',
261
+ cwd: process.env.WORKSPACE_DIR,
262
+ user: 'default',
263
+ provider: 'test-codex-clear-provider',
264
+ model: 'unit',
265
+ claudeSessionId: null,
266
+ codexThreadId: 'thread_old_123',
267
+ opencodeSessionId: null,
268
+ geminiSessionId: null,
269
+ copilotSessionId: null,
270
+ droidSessionId: null,
271
+ cursorSessionId: null,
272
+ qwenSessionId: null,
273
+ acpSessionId: null,
274
+ delegateResumeIds: { claudeCode: null, codex: 'thread_old_123', opencode: null, gemini: null, copilot: null, droid: null, cursor: null, qwen: null },
275
+ messages: [],
276
+ createdAt: now,
277
+ lastActiveAt: now,
278
+ sessionType: 'human',
279
+ agentId: 'codexclear',
280
+ extensions: [],
281
+ },
282
+ })
283
+
284
+ await executeSessionChatTurn({
285
+ sessionId: 'codex_clear_session',
286
+ message: 'hello',
287
+ runId: 'run-codex-clear',
288
+ })
289
+
290
+ const persisted = storage.loadSession('codex_clear_session')
291
+ console.log(JSON.stringify({
292
+ codexThreadId: persisted?.codexThreadId ?? null,
293
+ delegateCodex: persisted?.delegateResumeIds?.codex ?? null,
294
+ }))
295
+ `)
296
+
297
+ assert.equal(output.codexThreadId, null)
298
+ assert.equal(output.delegateCodex, null)
299
+ })
300
+
112
301
  test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thread history and clears stale connector state', () => {
113
302
  const output = runWithTempDataDir<{
114
303
  connectorContext: Record<string, unknown> | null