diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index e2b023d1ccf..b903b9421fd 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -593,6 +593,13 @@ export function buildContainerMcpServers( }; } +export function getAgentBackendModel( + containerInput: RuntimeContainerInput, +): string | undefined { + const model = containerInput.agentBackend?.model?.trim(); + return model || undefined; +} + function tomlString(value: string): string { return JSON.stringify(value); } @@ -747,7 +754,7 @@ class ClaudeCodeQueryRunner< ), ], env: sdkEnv, - model: containerInput.agentBackend?.model, + model: getAgentBackendModel(containerInput), permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: ['project', 'user'], diff --git a/src/agent/agentlite-impl.test.ts b/src/agent/agentlite-impl.test.ts index eb7f3860377..12f159ee44b 100644 --- a/src/agent/agentlite-impl.test.ts +++ b/src/agent/agentlite-impl.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../box-runtime.js', () => ({ @@ -105,6 +106,67 @@ describe('AgentLite platform registry', () => { expect((restored as unknown as { _started: boolean })._started).toBe(false); }); + it('migrates registry rows created before backend_model existed', async () => { + const registryPath = getAgentRegistryDbPath(tmpDir); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const raw = new Database(registryPath); + raw.exec(` + CREATE TABLE agents ( + name TEXT PRIMARY KEY, + agent_id TEXT NOT NULL UNIQUE, + workdir TEXT NOT NULL, + assistant_name TEXT NOT NULL, + backend_type TEXT NOT NULL DEFAULT 'claudeCode', + mount_allowlist_json TEXT, + instructions TEXT, + skills_sources_json TEXT, + mcp_servers_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO agents ( + name, + agent_id, + workdir, + assistant_name, + backend_type, + mount_allowlist_json, + instructions, + skills_sources_json, + mcp_servers_json, + created_at, + updated_at + ) VALUES ( + 'legacy', + 'legacy01', + '${path.join(tmpDir, 'legacy-agent').replaceAll("'", "''")}', + 'Legacy', + 'codex', + NULL, + NULL, + NULL, + NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + `); + raw.close(); + + const platform = await createAgentLiteImpl({ workdir: tmpDir }); + platforms.push(platform); + + const restored = platform.agents.get('legacy') as AgentImpl | undefined; + expect(restored).toBeDefined(); + expect(restored!.getBackend()).toEqual({ type: 'codex' }); + + const registry = initAgentRegistryDb(tmpDir); + try { + expect(registry.getAgent('legacy')!.backend).toEqual({ type: 'codex' }); + } finally { + registry.close(); + } + }); + it('getOrCreateAgent merges runtime-only options and rejects conflicting serializable options', async () => { const firstPlatform = await createAgentLiteImpl({ workdir: tmpDir }); platforms.push(firstPlatform); @@ -246,6 +308,154 @@ describe('AgentLite platform registry', () => { expect(agent.backendRevision).toBe(0); }); + it('same-backend model changes can clear overrides without replacing the native session', async () => { + const platform = await createAgentLiteImpl({ workdir: tmpDir }); + platforms.push(platform); + + const agent = platform.createAgent('alice', { + backend: { type: 'codex', model: 'gpt-5.4' }, + }) as AgentImpl; + agent.db = _initTestDatabase(); + agent.registeredGroups['self-chat'] = { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: new Date().toISOString(), + isMain: true, + }; + agent.sessions.main = 'codex-thread'; + agent.db.setSession('main', 'codex-thread', 'codex'); + const closeSpy = vi.spyOn(agent.queue, 'closeStdin'); + (agent as unknown as { _started: boolean })._started = true; + + const result = await agent.setBackend({ type: 'codex' }); + + expect(result).toMatchObject({ + previous: { type: 'codex', model: 'gpt-5.4' }, + current: { type: 'codex' }, + applies: 'nextTurn', + handoff: 'notNeeded', + }); + expect(agent.getBackend()).toEqual({ type: 'codex' }); + expect(agent.sessions.main).toBe('codex-thread'); + expect(agent.db.getSession('main', 'codex')).toBe('codex-thread'); + expect(agent.backendRevision).toBe(0); + expect(closeSpy).toHaveBeenCalledWith('self-chat'); + + const registry = initAgentRegistryDb(tmpDir); + try { + expect(registry.getAgent('alice')!.backend).toEqual({ type: 'codex' }); + } finally { + registry.close(); + } + }); + + it('backend switches can request fresh context without creating handoff state', async () => { + const platform = await createAgentLiteImpl({ workdir: tmpDir }); + platforms.push(platform); + + const agent = platform.createAgent('alice') as AgentImpl; + agent.db = _initTestDatabase(); + agent.registeredGroups['self-chat'] = { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: new Date().toISOString(), + isMain: true, + }; + agent.sessions.main = 'claude-session'; + agent.db.setSession('main', 'claude-session', 'claudeCode'); + agent.db.setSession('main', 'stale-codex-thread', 'codex'); + (agent as unknown as { _started: boolean })._started = true; + + const result = await agent.setBackend( + { type: 'codex' }, + { context: 'fresh' }, + ); + + expect(result.handoff).toBe('skipped'); + expect(agent.getBackend()).toEqual({ type: 'codex' }); + expect(agent.sessions.main).toBeUndefined(); + expect(agent.db.getSession('main', 'claudeCode')).toBe('claude-session'); + expect(agent.db.getSession('main', 'codex')).toBeUndefined(); + expect(agent.db.getBackendHandoff('main', 'codex')).toBeUndefined(); + expect(agent.backendRevision).toBe(1); + }); + + it.each([ + { + label: 'before start', + started: false, + groups: { + 'self-chat': { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: '2026-01-01T00:00:00.000Z', + isMain: true, + }, + }, + }, + { + label: 'with no registered groups', + started: true, + groups: {}, + }, + ])('backend switches skip handoff $label', async ({ started, groups }) => { + const platform = await createAgentLiteImpl({ workdir: tmpDir }); + platforms.push(platform); + + const agent = platform.createAgent('alice') as AgentImpl; + agent.db = _initTestDatabase(); + agent.registeredGroups = groups; + const closeSpy = vi.spyOn(agent.queue, 'closeStdin'); + (agent as unknown as { _started: boolean })._started = started; + + const result = await agent.setBackend({ type: 'codex' }); + + expect(result).toMatchObject({ + previous: { type: 'claudeCode' }, + current: { type: 'codex' }, + applies: 'nextTurn', + handoff: 'skipped', + }); + expect(agent.getBackend()).toEqual({ type: 'codex' }); + expect(agent.backendRevision).toBe(1); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('rejects invalid backend updates without mutating the current backend', async () => { + const platform = await createAgentLiteImpl({ workdir: tmpDir }); + platforms.push(platform); + + const agent = platform.createAgent('alice', { + backend: { type: 'claudeCode', model: 'claude-sonnet-4-6' }, + }) as AgentImpl; + const closeSpy = vi.spyOn(agent.queue, 'closeStdin'); + (agent as unknown as { _started: boolean })._started = true; + + await expect( + agent.setBackend({ type: 'unknown' } as never), + ).rejects.toThrow('Invalid agent backend'); + + expect(agent.getBackend()).toEqual({ + type: 'claudeCode', + model: 'claude-sonnet-4-6', + }); + expect(agent.backendRevision).toBe(0); + expect(closeSpy).not.toHaveBeenCalled(); + + const registry = initAgentRegistryDb(tmpDir); + try { + expect(registry.getAgent('alice')!.backend).toEqual({ + type: 'claudeCode', + model: 'claude-sonnet-4-6', + }); + } finally { + registry.close(); + } + }); + it('setBackend no-ops when backend and model are unchanged', async () => { const platform = await createAgentLiteImpl({ workdir: tmpDir }); platforms.push(platform); diff --git a/src/agent/backend-switching.e2e.test.ts b/src/agent/backend-switching.e2e.test.ts new file mode 100644 index 00000000000..41cc01cb704 --- /dev/null +++ b/src/agent/backend-switching.e2e.test.ts @@ -0,0 +1,265 @@ +/* eslint-disable no-catch-all/no-catch-all -- test cleanup should ignore missing temp dirs. */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../container-runner.js', async () => { + const actual = await vi.importActual( + '../container-runner.js', + ); + return { + ...actual, + runContainerAgent: vi.fn(), + }; +}); + +import { AgentImpl } from './agent-impl.js'; +import { + buildAgentConfig, + resolveSerializableAgentSettings, +} from './config.js'; +import { _initTestDatabase, AgentDb } from '../db.js'; +import { buildRuntimeConfig } from '../runtime-config.js'; +import { runContainerAgent } from '../container-runner.js'; +import type { AgentBackendOptions } from '../api/options.js'; +import type { Channel, RegisteredGroup } from '../types.js'; + +const runtimeConfig = buildRuntimeConfig( + { timezone: 'UTC' }, + '/tmp/agentlite-test-pkg', +); + +const MAIN_GROUP: RegisteredGroup = { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: '2026-01-01T00:00:00.000Z', + isMain: true, +}; + +let tmpDir: string; +let db: AgentDb; + +function createAgent( + name: string, + backend: AgentBackendOptions = { type: 'claudeCode' }, +): AgentImpl { + const config = buildAgentConfig({ + agentId: `${name}00000000`.slice(0, 8), + ...resolveSerializableAgentSettings( + name, + { workdir: path.join(tmpDir, 'agents', name), backend }, + tmpDir, + ), + }); + return new AgentImpl(config, runtimeConfig); +} + +function createMockChannel(): { channel: Channel; sent: string[] } { + const sent: string[] = []; + return { + sent, + channel: { + name: 'mock', + async connect(): Promise {}, + async disconnect(): Promise {}, + async sendMessage(_jid: string, text: string): Promise { + sent.push(text); + }, + isConnected(): boolean { + return true; + }, + ownsJid(jid: string): boolean { + return jid === 'main@g.us'; + }, + async setTyping(): Promise {}, + }, + }; +} + +function setupAgent(backend?: AgentBackendOptions): AgentImpl { + const agent = createAgent('switch', backend); + agent._setDbForTests(db); + agent._setRegisteredGroups({ 'main@g.us': MAIN_GROUP }); + (agent as unknown as { _started: boolean })._started = true; + const { channel } = createMockChannel(); + (agent as unknown as { channels: Map }).channels.set( + 'mock', + channel, + ); + fs.mkdirSync(path.join(agent.config.groupsDir, 'main'), { recursive: true }); + fs.writeFileSync( + path.join(agent.config.groupsDir, 'main', 'AGENTS.md'), + '# Main\nKeep backend handoff summaries compact.\n', + ); + db.storeChatMetadata('main@g.us', '2026-01-01T00:00:00.000Z', 'Main'); + return agent; +} + +function storeUserMessage( + id: string, + content: string, + timestamp: string, +): void { + db.storeMessage({ + id, + chat_jid: 'main@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content, + timestamp, + is_from_me: false, + }); +} + +describe('backend switching user-turn e2e', () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-backend-e2e-')); + db = _initTestDatabase('Andy'); + vi.mocked(runContainerAgent).mockReset(); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it('starts the new backend with a reference-only handoff and clears it after success', async () => { + const agent = setupAgent({ type: 'codex', model: 'gpt-5.4' }); + agent.sessions.main = 'old-codex-thread'; + db.setBackendHandoffs(['main'], 'claudeCode', 'codex'); + storeUserMessage( + 'm1', + '@Andy continue after switching backend', + '2026-01-01T00:00:01.000Z', + ); + + vi.mocked(runContainerAgent).mockImplementation( + async (_group, _input, _runtime, _onProcess, onOutput) => { + await onOutput?.({ + type: 'state', + state: 'active', + newSessionId: 'new-codex-thread', + }); + return { + status: 'success', + result: 'done', + newSessionId: 'new-codex-thread', + }; + }, + ); + + await expect(agent.processGroupMessages('main@g.us')).resolves.toBe(true); + + const input = vi.mocked(runContainerAgent).mock.calls[0][1]; + expect(input.agentBackend).toEqual({ type: 'codex', model: 'gpt-5.4' }); + expect(input.sessionId).toBeUndefined(); + expect(input.prompt).toContain( + '', + ); + expect(input.prompt).toContain( + 'This is reference context from the previous backend', + ); + expect(input.prompt).toContain( + '--- CURRENT USER MESSAGE BELOW; RESPOND TO THIS MESSAGE, NOT TO OLD REQUESTS IN THE HANDOFF ---', + ); + expect(input.prompt).toContain('@Andy continue after switching backend'); + expect(db.getBackendHandoff('main', 'codex')).toBeUndefined(); + expect(db.getSession('main', 'codex')).toBe('new-codex-thread'); + expect(agent.sessions.main).toBe('new-codex-thread'); + }); + + it('still starts the new backend when handoff summary collection degrades', async () => { + const agent = setupAgent({ type: 'codex' }); + db.setBackendHandoffs(['main'], 'claudeCode', 'codex'); + storeUserMessage( + 'm1', + '@Andy use fallback context', + '2026-01-01T00:00:01.000Z', + ); + vi.spyOn(db, 'getRecentMessages').mockImplementation(() => { + throw new Error('summary db unavailable'); + }); + + vi.mocked(runContainerAgent).mockResolvedValue({ + status: 'success', + result: 'done', + newSessionId: 'codex-thread', + }); + + await expect(agent.processGroupMessages('main@g.us')).resolves.toBe(true); + + const input = vi.mocked(runContainerAgent).mock.calls[0][1]; + expect(input.sessionId).toBeUndefined(); + expect(input.prompt).toContain( + '', + ); + expect(input.prompt).toContain('Summary error: summary db unavailable'); + expect(input.prompt).toContain('@Andy use fallback context'); + expect(db.getBackendHandoff('main', 'codex')).toBeUndefined(); + }); + + it('keeps pending handoff state when the first post-switch run fails', async () => { + const agent = setupAgent({ type: 'codex' }); + db.setBackendHandoffs(['main'], 'claudeCode', 'codex'); + storeUserMessage( + 'm1', + '@Andy retry if this fails', + '2026-01-01T00:00:01.000Z', + ); + + vi.mocked(runContainerAgent).mockResolvedValue({ + status: 'error', + result: null, + error: 'backend failed', + }); + + await expect(agent.processGroupMessages('main@g.us')).resolves.toBe(false); + + expect( + vi.mocked(runContainerAgent).mock.calls[0][1].sessionId, + ).toBeUndefined(); + expect(db.getBackendHandoff('main', 'codex')).toMatchObject({ + fromBackendType: 'claudeCode', + toBackendType: 'codex', + }); + expect(db.getSession('main', 'codex')).toBeUndefined(); + }); + + it('does not let an old-backend turn repopulate session state after a switch', async () => { + const agent = setupAgent({ type: 'claudeCode' }); + storeUserMessage( + 'm1', + '@Andy this turn will switch while running', + '2026-01-01T00:00:01.000Z', + ); + + vi.mocked(runContainerAgent).mockImplementation(async () => { + await agent.setBackend({ type: 'codex' }); + return { + status: 'success', + result: 'late success', + newSessionId: 'late-claude-session', + }; + }); + + await expect(agent.processGroupMessages('main@g.us')).resolves.toBe(true); + + expect(vi.mocked(runContainerAgent).mock.calls[0][1].agentBackend).toEqual({ + type: 'claudeCode', + }); + expect(agent.getBackend()).toEqual({ type: 'codex' }); + expect(agent.sessions.main).toBeUndefined(); + expect(db.getSession('main', 'claudeCode')).toBeUndefined(); + expect(db.getBackendHandoff('main', 'codex')).toMatchObject({ + fromBackendType: 'claudeCode', + toBackendType: 'codex', + }); + }); +}); diff --git a/src/container-agent-backend.test.ts b/src/container-agent-backend.test.ts index 631093f002c..e60f20098df 100644 --- a/src/container-agent-backend.test.ts +++ b/src/container-agent-backend.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildCodexArgs } from '../container/agent-runner/src/agent-backend.js'; +import { + buildCodexArgs, + getAgentBackendModel, +} from '../container/agent-runner/src/agent-backend.js'; describe('container agent backend helpers', () => { it('passes model to codex exec', () => { @@ -41,5 +44,33 @@ describe('container agent backend helpers', () => { 'session-123', '-', ]); + expect(buildCodexArgs({ sessionId: 'session-123', model: ' ' })).toEqual([ + 'exec', + 'resume', + '--json', + '--skip-git-repo-check', + '--dangerously-bypass-approvals-and-sandbox', + 'session-123', + '-', + ]); + }); + + it('normalizes model overrides before runner-specific option wiring', () => { + expect( + getAgentBackendModel({ + groupFolder: 'main', + chatJid: 'main@g.us', + isMain: true, + agentBackend: { type: 'claudeCode', model: ' claude-opus-4-6 ' }, + }), + ).toBe('claude-opus-4-6'); + expect( + getAgentBackendModel({ + groupFolder: 'main', + chatJid: 'main@g.us', + isMain: true, + agentBackend: { type: 'codex', model: ' ' }, + }), + ).toBeUndefined(); }); }); diff --git a/src/db.test.ts b/src/db.test.ts index f73705805cc..5082504783f 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -218,6 +218,67 @@ describe('getMessagesSince', () => { }); }); +describe('getRecentMessages', () => { + it('returns the newest user messages in chronological order and filters bot backfills', () => { + db.storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + db.storeChatMetadata('other@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'm1', + chat_jid: 'group@g.us', + sender: 'alice', + sender_name: 'Alice', + content: 'first', + timestamp: '2024-01-01T00:00:01.000Z', + }); + db.storeMessage({ + id: 'bot-flag', + chat_jid: 'group@g.us', + sender: 'bot', + sender_name: 'Andy', + content: 'bot by flag', + timestamp: '2024-01-01T00:00:02.000Z', + is_bot_message: true, + }); + store({ + id: 'bot-prefix', + chat_jid: 'group@g.us', + sender: 'bot', + sender_name: 'Andy', + content: 'Andy: bot by prefix', + timestamp: '2024-01-01T00:00:03.000Z', + }); + store({ + id: 'm2', + chat_jid: 'group@g.us', + sender: 'bob', + sender_name: 'Bob', + content: 'second', + timestamp: '2024-01-01T00:00:04.000Z', + }); + store({ + id: 'm3', + chat_jid: 'group@g.us', + sender: 'carol', + sender_name: 'Carol', + content: 'third', + timestamp: '2024-01-01T00:00:05.000Z', + }); + store({ + id: 'other', + chat_jid: 'other@g.us', + sender: 'mallory', + sender_name: 'Mallory', + content: 'wrong chat', + timestamp: '2024-01-01T00:00:06.000Z', + }); + + const messages = db.getRecentMessages('group@g.us', 'Andy', 2); + + expect(messages.map((m) => m.id)).toEqual(['m2', 'm3']); + }); +}); + // --- getNewMessages --- describe('getNewMessages', () => { @@ -356,6 +417,25 @@ describe('backend handoffs', () => { expect(db.getBackendHandoff('main', 'codex')).toBeUndefined(); }); + + it('scopes pending handoffs by destination backend across groups', () => { + db.setBackendHandoffs(['main', 'team'], 'claudeCode', 'codex'); + db.setBackendHandoffs(['ops'], 'codex', 'claudeCode'); + + expect(db.getBackendHandoff('main', 'claudeCode')).toBeUndefined(); + expect(db.getBackendHandoff('main', 'codex')).toMatchObject({ + groupFolder: 'main', + fromBackendType: 'claudeCode', + toBackendType: 'codex', + }); + expect(db.getBackendHandoff('team', 'codex')).toMatchObject({ + groupFolder: 'team', + }); + expect(db.getBackendHandoff('ops', 'claudeCode')).toMatchObject({ + fromBackendType: 'codex', + toBackendType: 'claudeCode', + }); + }); }); // --- storeChatMetadata --- diff --git a/src/task-lifecycle.test.ts b/src/task-lifecycle.test.ts index a6a5d8f4e6f..96c7004d789 100644 --- a/src/task-lifecycle.test.ts +++ b/src/task-lifecycle.test.ts @@ -98,13 +98,18 @@ function immediateQueue() { function startScheduler( agent: AgentImpl, queue: ReturnType, - opts?: { registeredGroups?: Record }, + opts?: { + registeredGroups?: Record; + sessions?: Record; + }, ): { stop(): void } { return startSchedulerLoop({ db, agentId: agent.id, assistantName: 'Andy', agentBackend: agent.config.backend, + getAgentBackend: () => agent.config.backend, + getBackendRevision: () => agent.backendRevision, schedulerPollInterval: 60000, timezone: 'UTC', workDir: agent.config.workDir, @@ -116,7 +121,7 @@ function startScheduler( 'main@g.us': MAIN_GROUP, 'team@g.us': TEAM_GROUP, }, - getSessions: () => ({}), + getSessions: () => opts?.sessions ?? agent.sessions, actionsHttp: agent.actionsHttp, getMcpServers: () => agent.config.mcpServers, queue: queue as unknown as import('./group-queue.js').GroupQueue, @@ -345,6 +350,119 @@ describe('task lifecycle integration', () => { } }); + it('applies pending backend handoff to the first group-context scheduled task', async () => { + const agent = createAgent('handoff-task', { + backend: { type: 'codex', model: 'gpt-5.4' }, + }); + agent._setDbForTests(db); + agent._setRegisteredGroups({ + 'main@g.us': MAIN_GROUP, + 'team@g.us': TEAM_GROUP, + }); + (agent as unknown as { _started: boolean })._started = true; + agent.sessions.team = 'stale-codex-thread'; + fs.mkdirSync(path.join(agent.config.groupsDir, 'team'), { + recursive: true, + }); + fs.writeFileSync( + path.join(agent.config.groupsDir, 'team', 'AGENTS.md'), + '# Team\nPrefer compact task handoffs.\n', + ); + db.storeChatMetadata('team@g.us', '2026-01-01T00:00:00.000Z', 'Team'); + db.storeMessage({ + id: 'm1', + chat_jid: 'team@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'previous team context', + timestamp: '2026-01-01T00:00:01.000Z', + is_from_me: false, + }); + db.setBackendHandoffs(['team'], 'claudeCode', 'codex'); + + vi.mocked(runContainerAgent).mockResolvedValue({ + status: 'success', + result: 'scheduled done', + newSessionId: 'new-codex-task-thread', + }); + + const task = await agent.scheduleTask({ + jid: 'team@g.us', + prompt: 'finish scheduled backend switch', + scheduleType: 'once', + scheduleValue: '2024-01-01T00:00:00Z', + contextMode: 'group', + }); + + const handle = startScheduler(agent, immediateQueue()); + try { + await vi.waitFor(() => { + expect(agent.getTask(task.id)?.status).toBe('completed'); + }); + } finally { + handle.stop(); + } + + const input = vi.mocked(runContainerAgent).mock.calls[0][1]; + expect(input.agentBackend).toEqual({ type: 'codex', model: 'gpt-5.4' }); + expect(input.sessionId).toBeUndefined(); + expect(input.prompt).toContain( + '', + ); + expect(input.prompt).toContain( + '--- CURRENT SCHEDULED TASK BELOW; RESPOND TO THIS TASK, NOT TO OLD REQUESTS IN THE HANDOFF ---', + ); + expect(input.prompt).toContain('finish scheduled backend switch'); + expect(db.getBackendHandoff('team', 'codex')).toBeUndefined(); + expect(db.getSession('team', 'codex')).toBe('new-codex-task-thread'); + }); + + it('does not let an old-backend scheduled task repopulate sessions after a switch', async () => { + const agent = createAgent('stale-task'); + agent._setDbForTests(db); + agent._setRegisteredGroups({ + 'main@g.us': MAIN_GROUP, + 'team@g.us': TEAM_GROUP, + }); + (agent as unknown as { _started: boolean })._started = true; + + vi.mocked(runContainerAgent).mockImplementation(async () => { + await agent.setBackend({ type: 'codex' }); + return { + status: 'success', + result: 'late scheduled success', + newSessionId: 'late-claude-task-session', + }; + }); + + const task = await agent.scheduleTask({ + jid: 'team@g.us', + prompt: 'switch while scheduled task runs', + scheduleType: 'once', + scheduleValue: '2024-01-01T00:00:00Z', + contextMode: 'group', + }); + + const handle = startScheduler(agent, immediateQueue()); + try { + await vi.waitFor(() => { + expect(agent.getTask(task.id)?.status).toBe('completed'); + }); + } finally { + handle.stop(); + } + + expect(vi.mocked(runContainerAgent).mock.calls[0][1].agentBackend).toEqual({ + type: 'claudeCode', + }); + expect(agent.getBackend()).toEqual({ type: 'codex' }); + expect(db.getSession('team', 'claudeCode')).toBeUndefined(); + expect(db.getBackendHandoff('team', 'codex')).toMatchObject({ + fromBackendType: 'claudeCode', + toBackendType: 'codex', + }); + }); + it('emits CRUD and run lifecycle events in the expected order', async () => { const agent = createAgent('events'); agent._setDbForTests(db);