From bf1453541f179d97cee3865fa2075b1b329ccb14 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 23 Mar 2026 18:23:46 +0900 Subject: [PATCH] fix: buffer progress to prevent final-like text from showing Progress updates are now buffered: each new progress flushes the previous one. The latest pending progress is discarded when final arrives with matching text, preventing duplicate display in Discord. --- src/message-runtime.test.ts | 165 +++++++++++++++++++++++++-------- src/message-turn-controller.ts | 58 +++++++----- 2 files changed, 158 insertions(+), 65 deletions(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 06f4dd5..8d5e06b 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -639,6 +639,7 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered only (not sent to Discord yet) await onOutput?.({ status: 'success', phase: 'progress', @@ -646,6 +647,14 @@ describe('createMessageRuntime', () => { newSessionId: 'session-progress', }); expect(notifyIdle).not.toHaveBeenCalled(); + // Second progress: flushes the first one to Discord (creates tracked message) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '테스트 실행 중입니다.', + newSessionId: 'session-progress', + }); + // Timer advance triggers progress ticker → edits the tracked message await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', @@ -689,20 +698,18 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when the second progress arrives expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('CI 상태 확인 중입니다.\n\n0초'), ); + // Timer tick edits the tracked progress with updated elapsed time expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', P('CI 상태 확인 중입니다.\n\n10초'), ); - expect(channel.editMessage).toHaveBeenCalledWith( - chatJid, - 'progress-1', - P('CI 상태 확인 중입니다.\n\n10초'), - ); + // finish() promotes the last flushed progress text to a final message expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -740,12 +747,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '진행 중입니다.', newSessionId: 'session-progress-fallback', }); + // Second progress: flushes first (sendAndTrack throws -> falls back to sendMessage) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '계속 진행 중입니다.', + newSessionId: 'session-progress-fallback', + }); return { status: 'success', result: null, @@ -782,6 +797,7 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives — sendAndTrack throws, falls back to sendMessage expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('진행 중입니다.\n\n0초'), @@ -812,12 +828,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '진행 중입니다.', newSessionId: 'session-progress-null-fallback', }); + // Second progress: flushes first (sendAndTrack returns null -> falls back to sendMessage) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '계속 진행 중입니다.', + newSessionId: 'session-progress-null-fallback', + }); return { status: 'success', result: null, @@ -854,6 +878,7 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives — sendAndTrack returns null, falls back to sendMessage expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('진행 중입니다.\n\n0초'), @@ -884,12 +909,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered only await onOutput?.({ status: 'success', phase: 'progress', result: '첫 번째 진행상황입니다.', newSessionId: 'session-terminal', }); + // Second progress: flushes the first to Discord + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '두 번째 진행상황입니다.', + newSessionId: 'session-terminal', + }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', @@ -897,6 +930,7 @@ describe('createMessageRuntime', () => { result: '최종 답변입니다.', newSessionId: 'session-terminal', }); + // Late output after terminal final — should be discarded await onOutput?.({ status: 'success', phase: 'progress', @@ -920,7 +954,7 @@ describe('createMessageRuntime', () => { const runtime = createMessageRuntime({ assistantName: 'Andy', - idleTimeout: 1_000, + idleTimeout: 20_000, pollInterval: 1_000, timezone: 'UTC', triggerPattern: /^@Andy\b/i, @@ -951,25 +985,23 @@ describe('createMessageRuntime', () => { runId: 'run-terminal-final', reason: 'output-delivered-close', }); + // First progress flushed when the second arrives expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('첫 번째 진행상황입니다.\n\n0초'), ); + // Timer tick updates tracked progress via edit expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', P('첫 번째 진행상황입니다.\n\n10초'), ); + // Late progress and duplicate final are discarded expect(channel.editMessage).not.toHaveBeenCalledWith( chatJid, 'progress-1', - P('늦게 도착한 진행상황입니다.\n\n0초'), - ); - expect(channel.editMessage).not.toHaveBeenCalledWith( - chatJid, - 'progress-1', - '중복 최종 답변입니다.', + expect.stringContaining('늦게 도착한 진행상황입니다.'), ); expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( @@ -1000,12 +1032,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered only await onOutput?.({ status: 'success', phase: 'progress', result: '오래 걸리는 작업입니다.', newSessionId: 'session-long-progress', }); + // Second progress: flushes first to Discord, starts timer tracking + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '아직 진행 중입니다.', + newSessionId: 'session-long-progress', + }); await vi.advanceTimersByTimeAsync(70_000); await vi.advanceTimersByTimeAsync(50_000); await vi.advanceTimersByTimeAsync(3_480_000); @@ -1052,10 +1092,12 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('오래 걸리는 작업입니다.\n\n0초'), ); + // Timer ticks update the tracked progress with longer durations expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', @@ -1066,6 +1108,7 @@ describe('createMessageRuntime', () => { 'progress-1', P('오래 걸리는 작업입니다.\n\n1시간 10초'), ); + // finish() promotes the last flushed progress text to a final message expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, '오래 걸리는 작업입니다.', @@ -1095,12 +1138,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered only await onOutput?.({ status: 'success', phase: 'progress', result: '테스트를 돌리는 중입니다.', newSessionId: 'session-final', }); + // Second progress: flushes first to Discord + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '빌드 중입니다.', + newSessionId: 'session-final', + }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', @@ -1145,20 +1196,18 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('테스트를 돌리는 중입니다.\n\n0초'), ); + // Timer tick updates tracked progress expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', P('테스트를 돌리는 중입니다.\n\n10초'), ); - expect(channel.editMessage).toHaveBeenCalledWith( - chatJid, - 'progress-1', - P('테스트를 돌리는 중입니다.\n\n10초'), - ); + // Final delivered as separate message expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -1253,25 +1302,42 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '첫 번째 진행상황입니다.', newSessionId: 'session-empty-final', }); + // Second progress: flushes first to Discord + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '계속 진행 중입니다.', + newSessionId: 'session-empty-final', + }); await vi.advanceTimersByTimeAsync(10_000); + // Empty final: resets tracked progress state (pending cleared by finalizeProgressMessage) await onOutput?.({ status: 'success', phase: 'final', result: 'hidden final', newSessionId: 'session-empty-final', }); + // Third progress after reset: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '두 번째 진행상황입니다.', newSessionId: 'session-empty-final', }); + // Fourth progress: flushes third to Discord (new progress-2 message) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '거의 완료입니다.', + newSessionId: 'session-empty-final', + }); await vi.advanceTimersByTimeAsync(10_000); await onOutput?.({ status: 'success', @@ -1315,33 +1381,31 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 1, chatJid, P('첫 번째 진행상황입니다.\n\n0초'), ); + // After empty final resets state, third progress flushed when fourth arrives (new message) expect(channel.sendAndTrack).toHaveBeenNthCalledWith( 2, chatJid, P('두 번째 진행상황입니다.\n\n0초'), ); - expect(channel.editMessage).toHaveBeenNthCalledWith( - 1, + // Timer tick edits the first tracked progress + expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', P('첫 번째 진행상황입니다.\n\n10초'), ); - expect(channel.editMessage).toHaveBeenNthCalledWith( - 2, - chatJid, - 'progress-2', - P('두 번째 진행상황입니다.\n\n10초'), - ); + // Timer tick edits the second tracked progress expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-2', P('두 번째 진행상황입니다.\n\n10초'), ); + // finish() promotes the last flushed progress text to a final message expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -1373,19 +1437,28 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '검증 중입니다.', newSessionId: 'session-progress-only', }); - await vi.advanceTimersByTimeAsync(10_000); + // Second progress: flushes first to Discord (creates tracked message) await onOutput?.({ status: 'success', phase: 'progress', result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.', newSessionId: 'session-progress-only', }); + await vi.advanceTimersByTimeAsync(10_000); + // Third progress: flushes second (edits tracked message with new text) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '테스트도 통과했습니다.', + newSessionId: 'session-progress-only', + }); await onOutput?.({ status: 'success', result: null, @@ -1428,20 +1501,18 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('검증 중입니다.\n\n0초'), ); + // Second progress flushed when third arrives — edits tracked message expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'), ); - expect(channel.editMessage).toHaveBeenCalledWith( - chatJid, - 'progress-1', - P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'), - ); + // finish() promotes the last flushed progress text to a final message expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -1476,20 +1547,28 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '진행 중입니다.', newSessionId: 'session-progress-recreate', }); - await vi.advanceTimersByTimeAsync(10_000); - // Second progress triggers ticker → edit fails → retries next tick + // Second progress: flushes first (creates tracked message via sendAndTrack) await onOutput?.({ status: 'success', phase: 'progress', result: '아직 진행 중.', newSessionId: 'session-progress-recreate', }); + await vi.advanceTimersByTimeAsync(10_000); + // Third progress: flushes second (edit fails once, then succeeds on retry in syncTrackedProgressMessage) + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '거의 완료.', + newSessionId: 'session-progress-recreate', + }); await onOutput?.({ status: 'success', result: null, @@ -1532,23 +1611,19 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); - // Only one progress message created — no duplicate + // Only one progress message created via sendAndTrack — no duplicate expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('진행 중입니다.\n\n0초'), ); - // 같은 progress 메시지는 유지하고, final은 별도 메시지로 보낸다. + // Edit is attempted on the tracked message (first fails, subsequent succeed) expect(channel.editMessage).toHaveBeenCalledWith( chatJid, 'progress-1', expect.any(String), ); - expect(channel.editMessage).toHaveBeenLastCalledWith( - chatJid, - 'progress-1', - P('아직 진행 중.\n\n10초'), - ); + // finish() promotes the last flushed progress text to a final message expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -1718,12 +1793,20 @@ describe('createMessageRuntime', () => { vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { + // First progress: buffered await onOutput?.({ status: 'success', phase: 'progress', result: '중간 진행상황입니다.', newSessionId: 'session-error', }); + // Second progress: flushes first to Discord + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '계속 진행 중입니다.', + newSessionId: 'session-error', + }); await onOutput?.({ status: 'error', result: null, @@ -1767,10 +1850,12 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); + // First progress flushed when second arrives expect(channel.sendAndTrack).toHaveBeenCalledWith( chatJid, P('중간 진행상황입니다.\n\n0초'), ); + // Error causes failure final to be published expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledWith( chatJid, diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 1f01bd2..bfb7e88 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -27,6 +27,7 @@ export class MessageTurnController { private producedDeliverySucceeded = true; private latestProgressText: string | null = null; private previousProgressText: string | null = null; + private pendingProgressText: string | null = null; private latestProgressRendered: string | null = null; private progressMessageId: string | null = null; private progressStartedAt: number | null = null; @@ -104,7 +105,7 @@ export class MessageTurnController { if (result.phase === 'progress') { if (text) { - await this.sendProgressMessage(text); + this.bufferProgress(text); } if (!this.poisonedSessionDetected) { this.resetIdleTimer(); @@ -115,8 +116,11 @@ export class MessageTurnController { return; } + // Final arrived — flush any buffered progress that isn't the same text, + // then discard the pending buffer so it never shows up. if (text) { - await this.finalizeProgressMessage(text); + await this.flushPendingProgress(text); + await this.finalizeProgressMessage(); await this.deliverFinalText(text); } else if (raw) { logger.info( @@ -228,6 +232,7 @@ export class MessageTurnController { private resetProgressState(): void { this.clearProgressTicker(); + this.pendingProgressText = null; this.latestProgressText = null; this.previousProgressText = null; this.latestProgressRendered = null; @@ -236,6 +241,30 @@ export class MessageTurnController { this.progressEditFailCount = 0; } + /** + * Buffer a progress update. The previous pending text gets flushed + * immediately, and the new text waits until the next event arrives. + * If a final result arrives before another progress, the pending + * text is discarded — so it never shows up in Discord. + */ + private bufferProgress(text: string): void { + if (this.pendingProgressText) { + void this.sendProgressMessage(this.pendingProgressText); + } + this.pendingProgressText = text; + } + + /** + * Flush pending progress before a final result, but only if the + * pending text differs from the final text. + */ + private async flushPendingProgress(finalText: string): Promise { + if (this.pendingProgressText && this.pendingProgressText !== finalText) { + await this.sendProgressMessage(this.pendingProgressText); + } + this.pendingProgressText = null; + } + private async syncTrackedProgressMessage(): Promise { if ( !this.progressMessageId || @@ -293,7 +322,7 @@ export class MessageTurnController { }, 10_000); } - private async finalizeProgressMessage(finalText?: string): Promise { + private async finalizeProgressMessage(): Promise { logger.info( { chatJid: this.options.chatJid, @@ -305,28 +334,7 @@ export class MessageTurnController { }, 'Finalizing tracked progress message', ); - // If the last progress text matches the final result, revert to the - // previous progress so the same text doesn't appear twice in chat. - if ( - finalText && - this.latestProgressText === finalText && - this.previousProgressText && - this.progressMessageId && - this.options.channel.editMessage - ) { - try { - const rendered = this.renderProgressMessage(this.previousProgressText); - await this.options.channel.editMessage( - this.options.chatJid, - this.progressMessageId, - rendered, - ); - } catch { - // Best effort - } - } else { - await this.syncTrackedProgressMessage(); - } + await this.syncTrackedProgressMessage(); this.resetProgressState(); }