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.
This commit is contained in:
@@ -639,6 +639,7 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered only (not sent to Discord yet)
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
@@ -646,6 +647,14 @@ describe('createMessageRuntime', () => {
|
|||||||
newSessionId: 'session-progress',
|
newSessionId: 'session-progress',
|
||||||
});
|
});
|
||||||
expect(notifyIdle).not.toHaveBeenCalled();
|
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 vi.advanceTimersByTimeAsync(10_000);
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -689,20 +698,18 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when the second progress arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('CI 상태 확인 중입니다.\n\n0초'),
|
P('CI 상태 확인 중입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Timer tick edits the tracked progress with updated elapsed time
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('CI 상태 확인 중입니다.\n\n10초'),
|
P('CI 상태 확인 중입니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
// finish() promotes the last flushed progress text to a final message
|
||||||
chatJid,
|
|
||||||
'progress-1',
|
|
||||||
P('CI 상태 확인 중입니다.\n\n10초'),
|
|
||||||
);
|
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -740,12 +747,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '진행 중입니다.',
|
result: '진행 중입니다.',
|
||||||
newSessionId: 'session-progress-fallback',
|
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 {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -782,6 +797,7 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives — sendAndTrack throws, falls back to sendMessage
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('진행 중입니다.\n\n0초'),
|
P('진행 중입니다.\n\n0초'),
|
||||||
@@ -812,12 +828,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '진행 중입니다.',
|
result: '진행 중입니다.',
|
||||||
newSessionId: 'session-progress-null-fallback',
|
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 {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -854,6 +878,7 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives — sendAndTrack returns null, falls back to sendMessage
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('진행 중입니다.\n\n0초'),
|
P('진행 중입니다.\n\n0초'),
|
||||||
@@ -884,12 +909,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered only
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '첫 번째 진행상황입니다.',
|
result: '첫 번째 진행상황입니다.',
|
||||||
newSessionId: 'session-terminal',
|
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 vi.advanceTimersByTimeAsync(10_000);
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -897,6 +930,7 @@ describe('createMessageRuntime', () => {
|
|||||||
result: '최종 답변입니다.',
|
result: '최종 답변입니다.',
|
||||||
newSessionId: 'session-terminal',
|
newSessionId: 'session-terminal',
|
||||||
});
|
});
|
||||||
|
// Late output after terminal final — should be discarded
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
@@ -920,7 +954,7 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
const runtime = createMessageRuntime({
|
const runtime = createMessageRuntime({
|
||||||
assistantName: 'Andy',
|
assistantName: 'Andy',
|
||||||
idleTimeout: 1_000,
|
idleTimeout: 20_000,
|
||||||
pollInterval: 1_000,
|
pollInterval: 1_000,
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
triggerPattern: /^@Andy\b/i,
|
triggerPattern: /^@Andy\b/i,
|
||||||
@@ -951,25 +985,23 @@ describe('createMessageRuntime', () => {
|
|||||||
runId: 'run-terminal-final',
|
runId: 'run-terminal-final',
|
||||||
reason: 'output-delivered-close',
|
reason: 'output-delivered-close',
|
||||||
});
|
});
|
||||||
|
// First progress flushed when the second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
|
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('첫 번째 진행상황입니다.\n\n0초'),
|
P('첫 번째 진행상황입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Timer tick updates tracked progress via edit
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('첫 번째 진행상황입니다.\n\n10초'),
|
P('첫 번째 진행상황입니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
|
// Late progress and duplicate final are discarded
|
||||||
expect(channel.editMessage).not.toHaveBeenCalledWith(
|
expect(channel.editMessage).not.toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('늦게 도착한 진행상황입니다.\n\n0초'),
|
expect.stringContaining('늦게 도착한 진행상황입니다.'),
|
||||||
);
|
|
||||||
expect(channel.editMessage).not.toHaveBeenCalledWith(
|
|
||||||
chatJid,
|
|
||||||
'progress-1',
|
|
||||||
'중복 최종 답변입니다.',
|
|
||||||
);
|
);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
@@ -1000,12 +1032,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered only
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '오래 걸리는 작업입니다.',
|
result: '오래 걸리는 작업입니다.',
|
||||||
newSessionId: 'session-long-progress',
|
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(70_000);
|
||||||
await vi.advanceTimersByTimeAsync(50_000);
|
await vi.advanceTimersByTimeAsync(50_000);
|
||||||
await vi.advanceTimersByTimeAsync(3_480_000);
|
await vi.advanceTimersByTimeAsync(3_480_000);
|
||||||
@@ -1052,10 +1092,12 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('오래 걸리는 작업입니다.\n\n0초'),
|
P('오래 걸리는 작업입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Timer ticks update the tracked progress with longer durations
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
@@ -1066,6 +1108,7 @@ describe('createMessageRuntime', () => {
|
|||||||
'progress-1',
|
'progress-1',
|
||||||
P('오래 걸리는 작업입니다.\n\n1시간 10초'),
|
P('오래 걸리는 작업입니다.\n\n1시간 10초'),
|
||||||
);
|
);
|
||||||
|
// finish() promotes the last flushed progress text to a final message
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'오래 걸리는 작업입니다.',
|
'오래 걸리는 작업입니다.',
|
||||||
@@ -1095,12 +1138,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered only
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '테스트를 돌리는 중입니다.',
|
result: '테스트를 돌리는 중입니다.',
|
||||||
newSessionId: 'session-final',
|
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 vi.advanceTimersByTimeAsync(10_000);
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -1145,20 +1196,18 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('테스트를 돌리는 중입니다.\n\n0초'),
|
P('테스트를 돌리는 중입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Timer tick updates tracked progress
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('테스트를 돌리는 중입니다.\n\n10초'),
|
P('테스트를 돌리는 중입니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
// Final delivered as separate message
|
||||||
chatJid,
|
|
||||||
'progress-1',
|
|
||||||
P('테스트를 돌리는 중입니다.\n\n10초'),
|
|
||||||
);
|
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -1253,25 +1302,42 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '첫 번째 진행상황입니다.',
|
result: '첫 번째 진행상황입니다.',
|
||||||
newSessionId: 'session-empty-final',
|
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);
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
// Empty final: resets tracked progress state (pending cleared by finalizeProgressMessage)
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'final',
|
phase: 'final',
|
||||||
result: '<internal>hidden final</internal>',
|
result: '<internal>hidden final</internal>',
|
||||||
newSessionId: 'session-empty-final',
|
newSessionId: 'session-empty-final',
|
||||||
});
|
});
|
||||||
|
// Third progress after reset: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '두 번째 진행상황입니다.',
|
result: '두 번째 진행상황입니다.',
|
||||||
newSessionId: 'session-empty-final',
|
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 vi.advanceTimersByTimeAsync(10_000);
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -1315,33 +1381,31 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
chatJid,
|
chatJid,
|
||||||
P('첫 번째 진행상황입니다.\n\n0초'),
|
P('첫 번째 진행상황입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// After empty final resets state, third progress flushed when fourth arrives (new message)
|
||||||
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
chatJid,
|
chatJid,
|
||||||
P('두 번째 진행상황입니다.\n\n0초'),
|
P('두 번째 진행상황입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenNthCalledWith(
|
// Timer tick edits the first tracked progress
|
||||||
1,
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('첫 번째 진행상황입니다.\n\n10초'),
|
P('첫 번째 진행상황입니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenNthCalledWith(
|
// Timer tick edits the second tracked progress
|
||||||
2,
|
|
||||||
chatJid,
|
|
||||||
'progress-2',
|
|
||||||
P('두 번째 진행상황입니다.\n\n10초'),
|
|
||||||
);
|
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-2',
|
'progress-2',
|
||||||
P('두 번째 진행상황입니다.\n\n10초'),
|
P('두 번째 진행상황입니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
|
// finish() promotes the last flushed progress text to a final message
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -1373,19 +1437,28 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '검증 중입니다.',
|
result: '검증 중입니다.',
|
||||||
newSessionId: 'session-progress-only',
|
newSessionId: 'session-progress-only',
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(10_000);
|
// Second progress: flushes first to Discord (creates tracked message)
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
|
result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
|
||||||
newSessionId: 'session-progress-only',
|
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?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -1428,20 +1501,18 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('검증 중입니다.\n\n0초'),
|
P('검증 중입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Second progress flushed when third arrives — edits tracked message
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'),
|
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
// finish() promotes the last flushed progress text to a final message
|
||||||
chatJid,
|
|
||||||
'progress-1',
|
|
||||||
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'),
|
|
||||||
);
|
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -1476,20 +1547,28 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '진행 중입니다.',
|
result: '진행 중입니다.',
|
||||||
newSessionId: 'session-progress-recreate',
|
newSessionId: 'session-progress-recreate',
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(10_000);
|
// Second progress: flushes first (creates tracked message via sendAndTrack)
|
||||||
// Second progress triggers ticker → edit fails → retries next tick
|
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '아직 진행 중.',
|
result: '아직 진행 중.',
|
||||||
newSessionId: 'session-progress-recreate',
|
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?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -1532,23 +1611,19 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('진행 중입니다.\n\n0초'),
|
P('진행 중입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
// 같은 progress 메시지는 유지하고, final은 별도 메시지로 보낸다.
|
// Edit is attempted on the tracked message (first fails, subsequent succeed)
|
||||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
expect(channel.editMessage).toHaveBeenLastCalledWith(
|
// finish() promotes the last flushed progress text to a final message
|
||||||
chatJid,
|
|
||||||
'progress-1',
|
|
||||||
P('아직 진행 중.\n\n10초'),
|
|
||||||
);
|
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -1718,12 +1793,20 @@ describe('createMessageRuntime', () => {
|
|||||||
|
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
// First progress: buffered
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: '중간 진행상황입니다.',
|
result: '중간 진행상황입니다.',
|
||||||
newSessionId: 'session-error',
|
newSessionId: 'session-error',
|
||||||
});
|
});
|
||||||
|
// Second progress: flushes first to Discord
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result: '계속 진행 중입니다.',
|
||||||
|
newSessionId: 'session-error',
|
||||||
|
});
|
||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -1767,10 +1850,12 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
// First progress flushed when second arrives
|
||||||
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
expect(channel.sendAndTrack).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
P('중간 진행상황입니다.\n\n0초'),
|
P('중간 진행상황입니다.\n\n0초'),
|
||||||
);
|
);
|
||||||
|
// Error causes failure final to be published
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class MessageTurnController {
|
|||||||
private producedDeliverySucceeded = true;
|
private producedDeliverySucceeded = true;
|
||||||
private latestProgressText: string | null = null;
|
private latestProgressText: string | null = null;
|
||||||
private previousProgressText: string | null = null;
|
private previousProgressText: string | null = null;
|
||||||
|
private pendingProgressText: string | null = null;
|
||||||
private latestProgressRendered: string | null = null;
|
private latestProgressRendered: string | null = null;
|
||||||
private progressMessageId: string | null = null;
|
private progressMessageId: string | null = null;
|
||||||
private progressStartedAt: number | null = null;
|
private progressStartedAt: number | null = null;
|
||||||
@@ -104,7 +105,7 @@ export class MessageTurnController {
|
|||||||
|
|
||||||
if (result.phase === 'progress') {
|
if (result.phase === 'progress') {
|
||||||
if (text) {
|
if (text) {
|
||||||
await this.sendProgressMessage(text);
|
this.bufferProgress(text);
|
||||||
}
|
}
|
||||||
if (!this.poisonedSessionDetected) {
|
if (!this.poisonedSessionDetected) {
|
||||||
this.resetIdleTimer();
|
this.resetIdleTimer();
|
||||||
@@ -115,8 +116,11 @@ export class MessageTurnController {
|
|||||||
return;
|
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) {
|
if (text) {
|
||||||
await this.finalizeProgressMessage(text);
|
await this.flushPendingProgress(text);
|
||||||
|
await this.finalizeProgressMessage();
|
||||||
await this.deliverFinalText(text);
|
await this.deliverFinalText(text);
|
||||||
} else if (raw) {
|
} else if (raw) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -228,6 +232,7 @@ export class MessageTurnController {
|
|||||||
|
|
||||||
private resetProgressState(): void {
|
private resetProgressState(): void {
|
||||||
this.clearProgressTicker();
|
this.clearProgressTicker();
|
||||||
|
this.pendingProgressText = null;
|
||||||
this.latestProgressText = null;
|
this.latestProgressText = null;
|
||||||
this.previousProgressText = null;
|
this.previousProgressText = null;
|
||||||
this.latestProgressRendered = null;
|
this.latestProgressRendered = null;
|
||||||
@@ -236,6 +241,30 @@ export class MessageTurnController {
|
|||||||
this.progressEditFailCount = 0;
|
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<void> {
|
||||||
|
if (this.pendingProgressText && this.pendingProgressText !== finalText) {
|
||||||
|
await this.sendProgressMessage(this.pendingProgressText);
|
||||||
|
}
|
||||||
|
this.pendingProgressText = null;
|
||||||
|
}
|
||||||
|
|
||||||
private async syncTrackedProgressMessage(): Promise<void> {
|
private async syncTrackedProgressMessage(): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!this.progressMessageId ||
|
!this.progressMessageId ||
|
||||||
@@ -293,7 +322,7 @@ export class MessageTurnController {
|
|||||||
}, 10_000);
|
}, 10_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finalizeProgressMessage(finalText?: string): Promise<void> {
|
private async finalizeProgressMessage(): Promise<void> {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
chatJid: this.options.chatJid,
|
chatJid: this.options.chatJid,
|
||||||
@@ -305,28 +334,7 @@ export class MessageTurnController {
|
|||||||
},
|
},
|
||||||
'Finalizing tracked progress message',
|
'Finalizing tracked progress message',
|
||||||
);
|
);
|
||||||
// If the last progress text matches the final result, revert to the
|
await this.syncTrackedProgressMessage();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
this.resetProgressState();
|
this.resetProgressState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user