@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.
- package/README.md +15 -9
- package/bin/swarmclaw.js +87 -0
- package/package.json +1 -1
- package/src/app/home/page.tsx +19 -10
- package/src/cli/index.js +8 -2
- package/src/cli/index.ts +12 -3
- package/src/components/agents/inspector-panel.tsx +25 -3
- package/src/components/auth/setup-wizard/index.tsx +6 -2
- package/src/components/auth/setup-wizard/step-next.tsx +46 -39
- package/src/components/auth/setup-wizard/step-providers.tsx +76 -142
- package/src/components/auth/setup-wizard/types.ts +5 -2
- package/src/components/auth/setup-wizard/utils.test.ts +0 -19
- package/src/components/auth/setup-wizard/utils.ts +0 -69
- package/src/components/chat/chat-card.tsx +5 -0
- package/src/components/home/home-launchpad.tsx +123 -71
- package/src/lib/home-launchpad.test.ts +1 -31
- package/src/lib/home-launchpad.ts +0 -58
- package/src/lib/providers/cli-utils.test.ts +65 -1
- package/src/lib/providers/cli-utils.ts +22 -1
- package/src/lib/providers/codex-cli.ts +71 -75
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +189 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +26 -19
- package/src/stores/slices/session-slice.test.ts +40 -2
- package/src/stores/slices/session-slice.ts +41 -1
|
@@ -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 {
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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:
|
|
142
|
+
cwd: effectiveCwd,
|
|
145
143
|
promptLen: prompt.length,
|
|
146
144
|
hasSystemPrompt: !!systemPrompt,
|
|
147
|
-
|
|
145
|
+
sessionCodexHome,
|
|
148
146
|
})
|
|
149
147
|
|
|
150
148
|
const proc = spawn(binary, args, {
|
|
151
|
-
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|