diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 146b550..4acc849 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -60,10 +60,12 @@ function makeChannel(chatJid: string): Channel { 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), }; } @@ -221,7 +223,8 @@ describe('createMessageRuntime', () => { expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2'); }); - it('streams Codex progress as a normal room message and only marks idle after the turn settles', async () => { + it('tracks Codex progress in one editable message and updates elapsed time every 10 seconds', async () => { + vi.useFakeTimers(); const chatJid = 'group@test'; const group = makeGroup('codex'); const channel = makeChannel(chatJid); @@ -248,6 +251,7 @@ describe('createMessageRuntime', () => { newSessionId: 'session-progress', }); expect(notifyIdle).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', result: null, @@ -283,25 +287,124 @@ describe('createMessageRuntime', () => { clearSession: vi.fn(), }); - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-progress', - reason: 'messages', + 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).not.toHaveBeenCalled(); + expect(notifyIdle).toHaveBeenCalledTimes(1); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress'); + expect(persistSession).toHaveBeenCalledWith( + group.folder, + 'session-progress', + ); + } 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(3_600_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(), }); - expect(result).toBe(true); - expect(channel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'CI 상태 확인 중입니다.', - ); - expect(notifyIdle).toHaveBeenCalledTimes(1); - expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress'); - expect(persistSession).toHaveBeenCalledWith( - group.folder, - 'session-progress', - ); + 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시간 1분 10초', + ); + } 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); @@ -326,6 +429,7 @@ describe('createMessageRuntime', () => { result: '테스트를 돌리는 중입니다.', newSessionId: 'session-final', }); + await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', phase: 'final', @@ -362,24 +466,31 @@ describe('createMessageRuntime', () => { clearSession: vi.fn(), }); - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-final', - reason: 'messages', - }); + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-final', + reason: 'messages', + }); - expect(result).toBe(true); - expect(channel.sendMessage).toHaveBeenNthCalledWith( - 1, - chatJid, - '테스트를 돌리는 중입니다.', - ); - expect(channel.sendMessage).toHaveBeenNthCalledWith( - 2, - chatJid, - '테스트가 끝났습니다.', - ); - expect(notifyIdle).toHaveBeenCalledTimes(1); - expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final'); + 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('does not roll back when a streamed progress message was already posted before an error', async () => { @@ -451,9 +562,9 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); - expect(channel.sendMessage).toHaveBeenCalledWith( + expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, - '중간 진행상황입니다.', + '중간 진행상황입니다.\n\n0초', ); expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z'); expect(saveState).toHaveBeenCalled(); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 56f88b5..0451cd2 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -93,7 +93,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { onOutput?: (output: AgentOutput) => Promise, ): Promise<'success' | 'error'> => { const isMain = group.isMain === true; - const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code'; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; const sessions = deps.getSessions(); const sessionId = sessions[group.folder]; @@ -121,10 +122,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if (output.newSessionId) { deps.persistSession(group.folder, output.newSessionId); } - if ( - isClaudeCodeAgent && - shouldResetSessionOnAgentFailure(output) - ) { + if (isClaudeCodeAgent && shouldResetSessionOnAgentFailure(output)) { resetSessionRequested = true; } await onOutput(output); @@ -154,8 +152,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if ( isClaudeCodeAgent && - (resetSessionRequested || - shouldResetSessionOnAgentFailure(output)) + (resetSessionRequested || shouldResetSessionOnAgentFailure(output)) ) { deps.clearSession(group.folder); logger.warn( @@ -207,11 +204,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const lastAgentTimestamps = deps.getLastAgentTimestamps(); const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const missedMessages = filterOwnChannelMessages( - getMessagesSince( - chatJid, - sinceTimestamp, - deps.assistantName, - ), + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), ); if (missedMessages.length === 0) { @@ -318,6 +311,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { let idleTimer: ReturnType | null = null; let latestProgressText: string | null = null; + let latestProgressRendered: string | null = null; + let progressMessageId: string | null = null; + let progressStartedAt: number | null = null; + let progressTicker: ReturnType | null = null; const resetIdleTimer = () => { if (idleTimer) clearTimeout(idleTimer); @@ -337,15 +334,98 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { let finalOutputSentToUser = false; let progressOutputSentToUser = false; let poisonedSessionDetected = false; - const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code'; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; + + const clearProgressTicker = () => { + if (progressTicker) { + clearInterval(progressTicker); + progressTicker = null; + } + }; + + const renderProgressMessage = (text: string) => { + const elapsedSeconds = + progressStartedAt === null + ? 0 + : Math.floor((Date.now() - progressStartedAt) / 10_000) * 10; + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + const elapsedParts: string[] = []; + + if (hours > 0) elapsedParts.push(`${hours}시간`); + if (minutes > 0) elapsedParts.push(`${minutes}분`); + if (seconds > 0 || elapsedParts.length === 0) { + elapsedParts.push(`${seconds}초`); + } + + return `${text}\n\n${elapsedParts.join(' ')}`; + }; + + const syncTrackedProgressMessage = async () => { + if (!progressMessageId || !channel.editMessage || !latestProgressText) { + return; + } + + const rendered = renderProgressMessage(latestProgressText); + if (rendered === latestProgressRendered) { + return; + } + + try { + await channel.editMessage(chatJid, progressMessageId, rendered); + latestProgressRendered = rendered; + } catch { + clearProgressTicker(); + progressMessageId = null; + } + }; + + const ensureProgressTicker = () => { + if (!progressMessageId || !channel.editMessage || progressTicker) { + return; + } + + progressTicker = setInterval(() => { + void syncTrackedProgressMessage(); + }, 10_000); + }; + + const finalizeProgressMessage = async () => { + await syncTrackedProgressMessage(); + clearProgressTicker(); + }; const sendProgressMessage = async (text: string) => { if (!text || text === latestProgressText) { return; } + if (progressStartedAt === null) { + progressStartedAt = Date.now(); + } latestProgressText = text; - await channel.sendMessage(chatJid, text); + const rendered = renderProgressMessage(text); + + if (progressMessageId && channel.editMessage) { + await syncTrackedProgressMessage(); + progressOutputSentToUser = true; + return; + } + + if (channel.sendAndTrack) { + progressMessageId = await channel.sendAndTrack(chatJid, rendered); + if (progressMessageId) { + latestProgressRendered = rendered; + ensureProgressTicker(); + progressOutputSentToUser = true; + return; + } + } + + latestProgressRendered = rendered; + await channel.sendMessage(chatJid, rendered); progressOutputSentToUser = true; }; @@ -399,9 +479,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } if (text) { + await finalizeProgressMessage(); await channel.sendMessage(chatJid, text); finalOutputSentToUser = true; } + } else { + await finalizeProgressMessage(); } await channel.setTyping?.(chatJid, false); @@ -426,6 +509,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hadError = true; } + clearProgressTicker(); + if (idleTimer) clearTimeout(idleTimer); if (hadError) { @@ -612,11 +697,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { for (const [chatJid, group] of Object.entries(registeredGroups)) { const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; const pending = filterOwnChannelMessages( - getMessagesSince( - chatJid, - sinceTimestamp, - deps.assistantName, - ), + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), ); if (pending.length > 0) { logger.info(