agent-thread-session.test.ts
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 { spawnSync } from 'node:child_process' 6 import { describe, it } from 'node:test' 7 8 const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..') 9 10 function runWithTempDataDir(script: string) { 11 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-agent-thread-')) 12 try { 13 const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], { 14 cwd: repoRoot, 15 env: { 16 ...process.env, 17 DATA_DIR: path.join(tempDir, 'data'), 18 WORKSPACE_DIR: path.join(tempDir, 'workspace'), 19 BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'), 20 }, 21 encoding: 'utf-8', 22 }) 23 assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed') 24 const lines = (result.stdout || '') 25 .trim() 26 .split('\n') 27 .map((line) => line.trim()) 28 .filter(Boolean) 29 const jsonLine = [...lines].reverse().find((line) => line.startsWith('{')) 30 return JSON.parse(jsonLine || '{}') 31 } finally { 32 fs.rmSync(tempDir, { recursive: true, force: true }) 33 } 34 } 35 36 describe('ensureAgentThreadSession', () => { 37 it('creates and reuses an agent shortcut chat for heartbeat-enabled agents', () => { 38 const output = runWithTempDataDir(` 39 const storageMod = await import('@/lib/server/storage') 40 const storage = storageMod.default || storageMod['module.exports'] || storageMod 41 const helperMod = await import('@/lib/server/agents/agent-thread-session') 42 const ensureAgentThreadSession = helperMod.ensureAgentThreadSession 43 || helperMod.default?.ensureAgentThreadSession 44 || helperMod['module.exports']?.ensureAgentThreadSession 45 46 const now = Date.now() 47 storage.saveAgents({ 48 molly: { 49 id: 'molly', 50 name: 'Molly', 51 description: 'Autonomous helper', 52 provider: 'openai', 53 model: 'gpt-test', 54 credentialId: null, 55 apiEndpoint: null, 56 fallbackCredentialIds: [], 57 heartbeatEnabled: true, 58 heartbeatIntervalSec: 600, 59 memoryScopeMode: 'agent', 60 memoryTierPreference: 'blended', 61 projectId: 'proj-1', 62 createdAt: now, 63 updatedAt: now, 64 extensions: ['memory', 'web_search'], 65 }, 66 }) 67 68 const first = ensureAgentThreadSession('molly') 69 const second = ensureAgentThreadSession('molly') 70 const agents = storage.loadAgents() 71 const sessions = storage.loadSessions() 72 73 console.log(JSON.stringify({ 74 firstId: first?.id, 75 secondId: second?.id, 76 threadSessionId: agents.molly?.threadSessionId || null, 77 session: first ? sessions[first.id] : null, 78 })) 79 `) 80 81 assert.equal(output.firstId, output.secondId) 82 assert.equal(output.threadSessionId, output.firstId) 83 assert.equal(output.session.shortcutForAgentId, 'molly') 84 assert.equal(output.session.agentId, 'molly') 85 assert.equal(output.session.heartbeatEnabled, true) 86 assert.equal(output.session.memoryScopeMode, 'agent') 87 assert.equal(output.session.memoryTierPreference, 'blended') 88 assert.equal(output.session.projectId, 'proj-1') 89 }) 90 91 it('does not create a new shortcut chat when the agent is disabled', () => { 92 const output = runWithTempDataDir(` 93 const storageMod = await import('@/lib/server/storage') 94 const storage = storageMod.default || storageMod['module.exports'] || storageMod 95 const helperMod = await import('@/lib/server/agents/agent-thread-session') 96 const ensureAgentThreadSession = helperMod.ensureAgentThreadSession 97 || helperMod.default?.ensureAgentThreadSession 98 || helperMod['module.exports']?.ensureAgentThreadSession 99 100 const now = Date.now() 101 storage.saveAgents({ 102 molly: { 103 id: 'molly', 104 name: 'Molly', 105 description: 'Temporarily disabled helper', 106 provider: 'openai', 107 model: 'gpt-test', 108 credentialId: null, 109 apiEndpoint: null, 110 fallbackCredentialIds: [], 111 disabled: true, 112 heartbeatEnabled: true, 113 heartbeatIntervalSec: 600, 114 createdAt: now, 115 updatedAt: now, 116 extensions: ['memory'], 117 }, 118 }) 119 120 const session = ensureAgentThreadSession('molly') 121 const agents = storage.loadAgents() 122 const sessions = storage.loadSessions() 123 124 console.log(JSON.stringify({ 125 sessionId: session?.id || null, 126 threadSessionId: agents.molly?.threadSessionId || null, 127 sessionCount: Object.keys(sessions).length, 128 })) 129 `) 130 131 assert.equal(output.sessionId, null) 132 assert.equal(output.threadSessionId, null) 133 assert.equal(output.sessionCount, 0) 134 }) 135 136 it('propagates explicit OpenClaw gateway agent ids into the shortcut session', () => { 137 const output = runWithTempDataDir(` 138 const storageMod = await import('@/lib/server/storage') 139 const storage = storageMod.default || storageMod['module.exports'] || storageMod 140 const helperMod = await import('@/lib/server/agents/agent-thread-session') 141 const ensureAgentThreadSession = helperMod.ensureAgentThreadSession 142 || helperMod.default?.ensureAgentThreadSession 143 || helperMod['module.exports']?.ensureAgentThreadSession 144 145 const now = Date.now() 146 storage.saveAgents({ 147 oc: { 148 id: 'oc', 149 name: 'OpenClaw Ops', 150 description: 'OpenClaw-backed helper', 151 provider: 'openclaw', 152 model: 'default', 153 credentialId: null, 154 apiEndpoint: null, 155 gatewayProfileId: 'gateway-test', 156 fallbackCredentialIds: [], 157 openclawAgentId: 'main', 158 heartbeatEnabled: true, 159 heartbeatIntervalSec: 600, 160 createdAt: now, 161 updatedAt: now, 162 extensions: [], 163 }, 164 }) 165 166 const session = ensureAgentThreadSession('oc') 167 const sessions = storage.loadSessions() 168 169 console.log(JSON.stringify({ 170 session: session ? sessions[session.id] : null, 171 })) 172 `) 173 174 assert.equal(output.session.openclawAgentId, 'main') 175 }) 176 177 it('clears stale connector routing state from an existing agent shortcut session', () => { 178 const output = runWithTempDataDir(` 179 const storageMod = await import('@/lib/server/storage') 180 const storage = storageMod.default || storageMod['module.exports'] || storageMod 181 const helperMod = await import('@/lib/server/agents/agent-thread-session') 182 const ensureAgentThreadSession = helperMod.ensureAgentThreadSession 183 || helperMod.default?.ensureAgentThreadSession 184 || helperMod['module.exports']?.ensureAgentThreadSession 185 186 const now = Date.now() 187 storage.saveAgents({ 188 molly: { 189 id: 'molly', 190 name: 'Molly', 191 provider: 'openai', 192 model: 'gpt-test', 193 credentialId: null, 194 apiEndpoint: null, 195 fallbackCredentialIds: [], 196 heartbeatEnabled: true, 197 heartbeatIntervalSec: 600, 198 threadSessionId: 'agent-chat-molly-existing', 199 createdAt: now, 200 updatedAt: now, 201 extensions: ['memory'], 202 }, 203 }) 204 storage.saveSessions({ 205 'agent-chat-molly-existing': { 206 id: 'agent-chat-molly-existing', 207 name: 'Molly', 208 cwd: process.env.WORKSPACE_DIR, 209 user: 'default', 210 provider: 'openai', 211 model: 'gpt-old', 212 claudeSessionId: null, 213 codexThreadId: null, 214 opencodeSessionId: null, 215 delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null }, 216 messages: [], 217 createdAt: now, 218 lastActiveAt: now, 219 sessionType: 'human', 220 agentId: 'molly', 221 extensions: ['memory'], 222 connectorContext: { 223 connectorId: 'conn-1', 224 channelId: 'wrong-chat', 225 senderId: 'wrong-user', 226 }, 227 }, 228 }) 229 230 const session = ensureAgentThreadSession('molly') 231 const persisted = storage.loadSessions()[session.id] 232 233 console.log(JSON.stringify({ 234 connectorContext: persisted.connectorContext || null, 235 })) 236 `) 237 238 assert.equal(output.connectorContext, null) 239 }) 240 241 it('repairs an existing Ollama Cloud thread session away from a stale local endpoint', () => { 242 const output = runWithTempDataDir(` 243 const storageMod = await import('@/lib/server/storage') 244 const storage = storageMod.default || storageMod['module.exports'] || storageMod 245 const helperMod = await import('@/lib/server/agents/agent-thread-session') 246 const ensureAgentThreadSession = helperMod.ensureAgentThreadSession 247 || helperMod.default?.ensureAgentThreadSession 248 || helperMod['module.exports']?.ensureAgentThreadSession 249 250 const now = Date.now() 251 storage.saveCredentials({ 252 'cred-ollama-cloud': { 253 id: 'cred-ollama-cloud', 254 provider: 'ollama', 255 name: 'Ollama Cloud', 256 encryptedKey: storage.encryptKey('ollama-cloud-key'), 257 createdAt: now, 258 }, 259 }) 260 storage.saveAgents({ 261 hal: { 262 id: 'hal', 263 name: 'Hal2k', 264 provider: 'ollama', 265 model: 'glm-5:cloud', 266 ollamaMode: 'cloud', 267 credentialId: 'cred-ollama-cloud', 268 apiEndpoint: null, 269 fallbackCredentialIds: [], 270 heartbeatEnabled: true, 271 heartbeatIntervalSec: 600, 272 threadSessionId: 'agent-chat-hal-existing', 273 createdAt: now, 274 updatedAt: now, 275 }, 276 }) 277 storage.saveSessions({ 278 'agent-chat-hal-existing': { 279 id: 'agent-chat-hal-existing', 280 name: 'Hal2k', 281 cwd: process.env.WORKSPACE_DIR, 282 user: 'default', 283 provider: 'ollama', 284 model: 'glm-5:cloud', 285 ollamaMode: 'cloud', 286 credentialId: 'cred-ollama-cloud', 287 apiEndpoint: 'http://localhost:11434', 288 claudeSessionId: null, 289 codexThreadId: null, 290 opencodeSessionId: null, 291 delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null }, 292 messages: [], 293 createdAt: now, 294 lastActiveAt: now, 295 sessionType: 'human', 296 agentId: 'hal', 297 shortcutForAgentId: 'hal', 298 }, 299 }) 300 301 const session = ensureAgentThreadSession('hal') 302 const persisted = storage.loadSessions()[session.id] 303 const healedAgent = storage.loadAgents().hal 304 305 console.log(JSON.stringify({ 306 sessionId: session.id, 307 ollamaMode: persisted.ollamaMode || null, 308 apiEndpoint: persisted.apiEndpoint || null, 309 credentialId: persisted.credentialId || null, 310 agentCredentialId: healedAgent?.credentialId || null, 311 })) 312 `) 313 314 assert.equal(output.sessionId, 'agent-chat-hal-existing') 315 assert.equal(output.ollamaMode, 'cloud') 316 assert.equal(output.credentialId, 'cred-ollama-cloud') 317 assert.equal(output.agentCredentialId, 'cred-ollama-cloud') 318 assert.equal(output.apiEndpoint, 'https://ollama.com') 319 }) 320 })