import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./agent-runner.js', () => ({ runAgentProcess: vi.fn(), writeGroupsSnapshot: vi.fn(), writeTasksSnapshot: vi.fn(), })); vi.mock('./config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-test-data', isSessionCommandSenderAllowed: vi.fn(() => false), })); vi.mock('./db.js', () => { const getMessagesSince = vi.fn( ( _chatJid?: string, _sinceCursor?: string, _botPrefix?: string, _limit?: number, ) => [], ); const getNewMessages = vi.fn( ( _jids?: string[], _lastSeqCursor?: string, _botPrefix?: string, _limit?: number, ) => ({ messages: [], newSeqCursor: '0' }), ); const withSeqs = (messages: Array>) => messages.map((message, index) => ({ ...message, seq: typeof message.seq === 'number' ? message.seq : index + 1, })); return { getAllChats: vi.fn(() => []), getAllTasks: vi.fn(() => []), getLastHumanMessageTimestamp: vi.fn(() => null), getMessagesSince, getNewMessages, getLatestMessageSeqAtOrBefore: vi.fn(() => 0), getMessagesSinceSeq: vi.fn( ( chatJid: string, sinceSeqCursor: string, botPrefix: string, limit?: number, ) => withSeqs(getMessagesSince(chatJid, sinceSeqCursor, botPrefix, limit)), ), getNewMessagesBySeq: vi.fn( ( jids: string[], lastSeqCursor: string, botPrefix: string, limit?: number, ) => { const result: | { messages?: Array>; newSeqCursor?: string; newTimestamp?: string; } | undefined = getNewMessages( jids, lastSeqCursor, botPrefix, limit, ) || { messages: [], newSeqCursor: '0', }; const messages = withSeqs(result.messages || []); const lastSeq = messages.length > 0 ? String(messages[messages.length - 1].seq) : String(lastSeqCursor || '0'); return { messages, newSeqCursor: result.newSeqCursor || result.newTimestamp || lastSeq, }; }, ), getOpenWorkItem: vi.fn(() => undefined), createProducedWorkItem: vi.fn((input) => ({ id: 1, group_folder: input.group_folder, chat_jid: input.chat_jid, agent_type: input.agent_type || 'claude-code', status: 'produced', start_seq: input.start_seq, end_seq: input.end_seq, result_payload: input.result_payload, delivery_attempts: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), delivered_at: null, delivery_message_id: null, last_error: null, })), markWorkItemDelivered: vi.fn(), markWorkItemDeliveryRetry: vi.fn(), isPairedRoomJid: vi.fn(() => false), }; }); vi.mock('./logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); vi.mock('./provider-fallback.js', () => ({ detectFallbackTrigger: vi.fn(() => ({ shouldFallback: false, reason: '' })), getActiveProvider: vi.fn(() => 'claude'), getFallbackEnvOverrides: vi.fn(() => ({ ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', ANTHROPIC_AUTH_TOKEN: 'test-kimi-key', ANTHROPIC_MODEL: 'kimi-k2.5', })), getFallbackProviderName: vi.fn(() => 'kimi'), hasGroupProviderOverride: vi.fn(() => false), isFallbackEnabled: vi.fn(() => true), markPrimaryCooldown: vi.fn(), })); vi.mock('./sender-allowlist.js', () => ({ isTriggerAllowed: vi.fn(() => true), loadSenderAllowlist: vi.fn(() => ({})), })); vi.mock('./session-commands.js', () => ({ extractSessionCommand: vi.fn(() => null), handleSessionCommand: vi.fn(async () => ({ handled: false })), isSessionCommandAllowed: vi.fn(() => true), isSessionCommandControlMessage: vi.fn(() => false), })); import * as agentRunner from './agent-runner.js'; import * as db from './db.js'; import { createMessageRuntime } from './message-runtime.js'; import * as providerFallback from './provider-fallback.js'; import type { Channel, RegisteredGroup } from './types.js'; function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup { return { name: 'Test Group', folder: `test-${agentType}`, trigger: '@Andy', added_at: new Date().toISOString(), requiresTrigger: false, agentType, }; } function makeChannel(chatJid: string): Channel { return { name: 'discord', connect: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined), sendAndTrack: vi.fn().mockResolvedValue('progress-1'), isConnected: vi.fn(() => true), ownsJid: vi.fn((jid: string) => jid === chatJid), disconnect: vi.fn().mockResolvedValue(undefined), setTyping: vi.fn().mockResolvedValue(undefined), editMessage: vi.fn().mockResolvedValue(undefined), }; } describe('createMessageRuntime', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(providerFallback.getActiveProvider).mockReturnValue('claude'); vi.mocked(providerFallback.getFallbackProviderName).mockReturnValue('kimi'); vi.mocked(providerFallback.getFallbackEnvOverrides).mockReturnValue({ ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', ANTHROPIC_AUTH_TOKEN: 'test-kimi-key', ANTHROPIC_MODEL: 'kimi-k2.5', }); vi.mocked(providerFallback.hasGroupProviderOverride).mockReturnValue(false); vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true); vi.mocked(providerFallback.detectFallbackTrigger).mockReturnValue({ shouldFallback: false, reason: '', }); }); it('clears Claude sessions and closes stdin immediately on poisoned output', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const channel = makeChannel(chatJid); const closeStdin = vi.fn(); const notifyIdle = vi.fn(); const persistSession = vi.fn(); const clearSession = vi.fn(); const saveState = vi.fn(); const lastAgentTimestamps: Record = {}; vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-18T09:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', result: 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', newSessionId: 'session-123', }); return { status: 'success', result: null, newSessionId: 'session-123', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin, notifyIdle, } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => lastAgentTimestamps, saveState, persistSession, clearSession, }); const result = await runtime.processGroupMessages(chatJid, { runId: 'run-1', reason: 'messages', }); expect(result).toBe(true); expect(persistSession).toHaveBeenCalledWith(group.folder, 'session-123'); expect(clearSession).toHaveBeenCalledWith(group.folder); expect(closeStdin).toHaveBeenCalledWith(chatJid, { runId: 'run-1', reason: 'poisoned-session-detected', }); expect(notifyIdle).not.toHaveBeenCalled(); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', ); expect(lastAgentTimestamps[chatJid]).toBe('1'); expect(saveState).toHaveBeenCalled(); }); it('does not apply the poisoned-session handling to Codex groups', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); const closeStdin = vi.fn(); const notifyIdle = vi.fn(); const clearSession = vi.fn(); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-18T09:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', result: 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', newSessionId: 'session-456', }); return { status: 'success', result: null, newSessionId: 'session-456', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin, notifyIdle, } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession, }); const result = await runtime.processGroupMessages(chatJid, { runId: 'run-2', reason: 'messages', }); expect(result).toBe(true); expect(clearSession).not.toHaveBeenCalled(); expect(closeStdin).not.toHaveBeenCalled(); expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2'); }); it('tracks Codex progress in one editable message and promotes the last progress when the run ends without a final phase', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); const notifyIdle = vi.fn(); const persistSession = vi.fn(); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: 'CI 상태 확인 중입니다.', newSessionId: 'session-progress', }); expect(notifyIdle).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-progress', }); return { status: 'success', result: null, newSessionId: 'session-progress', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle, } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession, clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-progress', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, 'CI 상태 확인 중입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', 'CI 상태 확인 중입니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, 'CI 상태 확인 중입니다.', ); expect(notifyIdle).toHaveBeenCalledTimes(1); expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress'); expect(persistSession).toHaveBeenCalledWith( group.folder, 'session-progress', ); } finally { vi.useRealTimers(); } }); it('resets the idle timeout when follow-up Codex progress keeps arriving', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); const closeStdin = vi.fn(); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', result: '초기 응답입니다.', newSessionId: 'session-follow-up', }); await vi.advanceTimersByTimeAsync(800); expect(closeStdin).not.toHaveBeenCalled(); await onOutput?.({ status: 'success', phase: 'progress', result: '후속 작업 진행 중입니다.', newSessionId: 'session-follow-up', }); await vi.advanceTimersByTimeAsync(800); expect(closeStdin).not.toHaveBeenCalled(); await onOutput?.({ status: 'success', phase: 'progress', result: '후속 작업 계속 진행 중입니다.', newSessionId: 'session-follow-up', }); await vi.advanceTimersByTimeAsync(800); expect(closeStdin).not.toHaveBeenCalled(); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-follow-up', }); return { status: 'success', result: null, newSessionId: 'session-follow-up', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin, notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-follow-up-progress', reason: 'messages', }); expect(result).toBe(true); expect(closeStdin).not.toHaveBeenCalled(); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, '초기 응답입니다.', ); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '후속 작업 진행 중입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', '후속 작업 계속 진행 중입니다.\n\n0초', ); } finally { vi.useRealTimers(); } }); it('formats longer Codex progress durations with minutes and hours', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '오래 걸리는 작업입니다.', newSessionId: 'session-long-progress', }); await vi.advanceTimersByTimeAsync(70_000); expect(channel.editMessage).toHaveBeenLastCalledWith( chatJid, 'progress-1', '오래 걸리는 작업입니다.\n\n1분 10초', ); await vi.advanceTimersByTimeAsync(50_000); expect(channel.editMessage).toHaveBeenLastCalledWith( chatJid, 'progress-1', '오래 걸리는 작업입니다.\n\n2분 0초', ); await vi.advanceTimersByTimeAsync(3_480_000); expect(channel.editMessage).toHaveBeenLastCalledWith( chatJid, 'progress-1', '오래 걸리는 작업입니다.\n\n1시간 0분 0초', ); await vi.advanceTimersByTimeAsync(70_000); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-long-progress', }); return { status: 'success', result: null, newSessionId: 'session-long-progress', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-long-progress', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '오래 걸리는 작업입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenLastCalledWith( chatJid, 'progress-1', '오래 걸리는 작업입니다.\n\n1시간 0초', ); } finally { vi.useRealTimers(); } }); it('keeps progress separate from the final Codex answer', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); const notifyIdle = vi.fn(); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '테스트를 돌리는 중입니다.', newSessionId: 'session-final', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', result: '테스트가 끝났습니다.', newSessionId: 'session-final', }); return { status: 'success', result: null, newSessionId: 'session-final', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle, } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-final', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '테스트를 돌리는 중입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', '테스트를 돌리는 중입니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, '테스트가 끝났습니다.', ); expect(notifyIdle).toHaveBeenCalledTimes(1); expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final'); } finally { vi.useRealTimers(); } }); it('starts a fresh tracked progress message for each Codex turn in one runner session', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(channel.sendAndTrack!) .mockResolvedValueOnce('progress-1') .mockResolvedValueOnce('progress-2'); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '첫 번째 진행상황입니다.', newSessionId: 'session-multi-turn', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', result: '첫 번째 결과입니다.', newSessionId: 'session-multi-turn', }); await onOutput?.({ status: 'success', phase: 'progress', result: '두 번째 진행상황입니다.', newSessionId: 'session-multi-turn', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', result: '두 번째 결과입니다.', newSessionId: 'session-multi-turn', }); return { status: 'success', result: null, newSessionId: 'session-multi-turn', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-multi-turn', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 1, chatJid, '첫 번째 진행상황입니다.\n\n0초', ); expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 2, chatJid, '두 번째 진행상황입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenNthCalledWith( 1, chatJid, 'progress-1', '첫 번째 진행상황입니다.\n\n10초', ); expect(channel.editMessage).toHaveBeenNthCalledWith( 2, chatJid, 'progress-2', '두 번째 진행상황입니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenNthCalledWith( 1, chatJid, '첫 번째 결과입니다.', ); expect(channel.sendMessage).toHaveBeenNthCalledWith( 2, chatJid, '두 번째 결과입니다.', ); } finally { vi.useRealTimers(); } }); it('resets tracked progress after a final output that becomes empty after formatting', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(channel.sendAndTrack!) .mockResolvedValueOnce('progress-1') .mockResolvedValueOnce('progress-2'); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '첫 번째 진행상황입니다.', newSessionId: 'session-empty-final', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', result: 'hidden final', newSessionId: 'session-empty-final', }); await onOutput?.({ status: 'success', phase: 'progress', result: '두 번째 진행상황입니다.', newSessionId: 'session-empty-final', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-empty-final', }); return { status: 'success', result: null, newSessionId: 'session-empty-final', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-empty-final', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 1, chatJid, '첫 번째 진행상황입니다.\n\n0초', ); expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 2, chatJid, '두 번째 진행상황입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenNthCalledWith( 1, chatJid, 'progress-1', '첫 번째 진행상황입니다.\n\n10초', ); expect(channel.editMessage).toHaveBeenNthCalledWith( 2, chatJid, 'progress-2', '두 번째 진행상황입니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, '두 번째 진행상황입니다.', ); } finally { vi.useRealTimers(); } }); it('promotes the last progress output to a final message when the agent completes without a final phase', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(channel.sendAndTrack!).mockResolvedValueOnce('progress-1'); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '검증 중입니다.', newSessionId: 'session-progress-only', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'progress', result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.', newSessionId: 'session-progress-only', }); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-progress-only', }); return { status: 'success', result: null, newSessionId: 'session-progress-only', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-progress-only', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '검증 중입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', '커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, '커밋은 정상 들어갔고 pre-commit도 통과했습니다.', ); } finally { vi.useRealTimers(); } }); it('promotes progress-only output for a follow-up turn after a prior final in the same run', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(channel.sendAndTrack!).mockResolvedValueOnce( 'progress-follow-up', ); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'final', result: '첫 번째 턴 최종 답변', newSessionId: 'session-follow-up', }); await onOutput?.({ status: 'success', phase: 'progress', result: '두 번째 턴 진행상황입니다.', newSessionId: 'session-follow-up', }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', result: null, newSessionId: 'session-follow-up', }); return { status: 'success', result: null, newSessionId: 'session-follow-up', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); try { const result = await runtime.processGroupMessages(chatJid, { runId: 'run-follow-up-progress-only', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendMessage).toHaveBeenNthCalledWith( 1, chatJid, '첫 번째 턴 최종 답변', ); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '두 번째 턴 진행상황입니다.\n\n0초', ); expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-follow-up', '두 번째 턴 진행상황입니다.\n\n10초', ); expect(channel.sendMessage).toHaveBeenNthCalledWith( 2, chatJid, '두 번째 턴 진행상황입니다.', ); } finally { vi.useRealTimers(); } }); it('does not roll back when a streamed progress message was already posted before an error', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); const saveState = vi.fn(); const lastAgentTimestamps: Record = {}; vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'progress', result: '중간 진행상황입니다.', newSessionId: 'session-error', }); await onOutput?.({ status: 'error', result: null, newSessionId: 'session-error', error: 'temporary failure', }); return { status: 'error', result: null, newSessionId: 'session-error', error: 'temporary failure', }; }, ); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => lastAgentTimestamps, saveState, persistSession: vi.fn(), clearSession: vi.fn(), }); const result = await runtime.processGroupMessages(chatJid, { runId: 'run-progress-error', reason: 'messages', }); expect(result).toBe(true); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, '중간 진행상황입니다.\n\n0초', ); expect(lastAgentTimestamps[chatJid]).toBe('1'); expect(saveState).toHaveBeenCalled(); }); it('retries with the fallback provider when Claude returns a 429 error before any output', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(providerFallback.detectFallbackTrigger).mockReturnValue({ shouldFallback: true, reason: '429', retryAfterMs: 60_000, }); vi.mocked(agentRunner.runAgentProcess) .mockResolvedValueOnce({ status: 'error', result: null, error: '429 rate limited retry after 60', }) .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'final', result: 'fallback 응답입니다.', }); return { status: 'success', result: null, }; }); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); const result = await runtime.processGroupMessages(chatJid, { runId: 'run-fallback-429', reason: 'messages', }); expect(result).toBe(true); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(agentRunner.runAgentProcess).toHaveBeenNthCalledWith( 2, expect.anything(), expect.objectContaining({ sessionId: undefined }), expect.any(Function), expect.any(Function), expect.objectContaining({ ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', ANTHROPIC_MODEL: 'kimi-k2.5', }), ); expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith( '429', 60_000, ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, 'fallback 응답입니다.', ); }); it('retries with the fallback provider when Claude ends with success-null-result before any output', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const channel = makeChannel(chatJid); vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', chat_jid: chatJid, sender: 'user@test', sender_name: 'User', content: 'hello', timestamp: '2026-03-19T00:00:00.000Z', }, ]); vi.mocked(agentRunner.runAgentProcess) .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', result: null, }); return { status: 'success', result: null, }; }) .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ status: 'success', phase: 'final', result: 'success-null-result 폴백 응답입니다.', }); return { status: 'success', result: null, }; }); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [channel], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); const result = await runtime.processGroupMessages(chatJid, { runId: 'run-fallback-success-null', reason: 'messages', }); expect(result).toBe(true); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith( 'success-null-result', undefined, ); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, 'success-null-result 폴백 응답입니다.', ); }); it('recovery queues a group when an open work item is waiting for delivery', () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); const enqueueMessageCheck = vi.fn(); vi.mocked(db.getOpenWorkItem).mockReturnValue({ id: 99, group_folder: group.folder, chat_jid: chatJid, agent_type: 'claude-code', status: 'produced', start_seq: 1, end_seq: 1, result_payload: '미전달 결과', delivery_attempts: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), delivered_at: null, delivery_message_id: null, last_error: null, }); const runtime = createMessageRuntime({ assistantName: 'Andy', idleTimeout: 1_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, channels: [makeChannel(chatJid)], queue: { registerProcess: vi.fn(), closeStdin: vi.fn(), notifyIdle: vi.fn(), enqueueMessageCheck, } as any, getRegisteredGroups: () => ({ [chatJid]: group }), getSessions: () => ({}), getLastTimestamp: () => '', setLastTimestamp: vi.fn(), getLastAgentTimestamps: () => ({}), saveState: vi.fn(), persistSession: vi.fn(), clearSession: vi.fn(), }); runtime.recoverPendingMessages(); expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid, group.folder); expect(db.getMessagesSinceSeq).not.toHaveBeenCalled(); }); });