diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index 2e84d40..7137190 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -461,7 +461,8 @@ describe('MessageTurnController outbound audit logging', () => { const finishResult = await controller.finish('success'); expect(finishResult.deliverySucceeded).toBe(true); - expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(channel.sendAndTrack).not.toHaveBeenCalled(); + expect(channel.sendMessage).not.toHaveBeenCalled(); expect(deliverFinalText).not.toHaveBeenCalled(); expect(getAuditEntries()).not.toEqual( expect.arrayContaining([ @@ -520,6 +521,122 @@ describe('MessageTurnController outbound audit logging', () => { expect(deliverFinalText).not.toHaveBeenCalled(); }); + it('does not emit a tracked progress message when a stale owner turn buffers multiple progress updates', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-stale-owner-progress-buffer', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + canDeliverFinalText: () => false, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn', + taskId: 'paired-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '첫 진행 상황', + output: { visibility: 'public', text: '첫 진행 상황' }, + } as any); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '둘째 진행 상황', + output: { visibility: 'public', text: '둘째 진행 상황' }, + } as any); + + const finishResult = await controller.finish('success'); + + expect(finishResult.deliverySucceeded).toBe(true); + expect(channel.sendAndTrack).not.toHaveBeenCalled(); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(deliverFinalText).not.toHaveBeenCalled(); + }); + + it('does not edit an existing tracked progress message after a stale owner turn loses delivery ownership', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + let canDeliver = true; + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-stale-owner-progress-edit', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + canDeliverFinalText: () => canDeliver, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn', + taskId: 'paired-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '첫 진행 상황', + output: { visibility: 'public', text: '첫 진행 상황' }, + } as any); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '둘째 진행 상황', + output: { visibility: 'public', text: '둘째 진행 상황' }, + } as any); + await flushAsync(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '셋째 진행 상황', + output: { visibility: 'public', text: '셋째 진행 상황' }, + } as any); + await flushAsync(); + + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(channel.editMessage).toHaveBeenCalledTimes(1); + + canDeliver = false; + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '넷째 진행 상황', + output: { visibility: 'public', text: '넷째 진행 상황' }, + } as any); + await flushAsync(); + + const finishResult = await controller.finish('success'); + + expect(finishResult.deliverySucceeded).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(channel.editMessage).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(deliverFinalText).not.toHaveBeenCalled(); + }); + it('replaces the tracked progress message when finish() publishes a failure final', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 42e8277..31424a4 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -561,6 +561,23 @@ export class MessageTurnController { } private async syncTrackedProgressMessage(): Promise { + if ( + this.options.canDeliverFinalText && + !this.options.canDeliverFinalText() + ) { + this.log.info( + { + runId: this.options.runId, + deliveryRole: this.options.deliveryRole ?? null, + turnId: this.options.pairedTurnIdentity?.turnId ?? null, + progressMessageId: this.progressMessageId, + latestProgressLength: this.latestProgressText?.length ?? 0, + }, + 'Skipped editing tracked progress because this run no longer owns the active paired turn attempt', + ); + return; + } + if ( !this.progressMessageId || !this.options.channel.editMessage || @@ -713,6 +730,24 @@ export class MessageTurnController { } private async sendProgressMessage(text: string): Promise { + if ( + this.options.canDeliverFinalText && + !this.options.canDeliverFinalText() + ) { + this.log.info( + { + runId: this.options.runId, + deliveryRole: this.options.deliveryRole ?? null, + turnId: this.options.pairedTurnIdentity?.turnId ?? null, + progressMessageId: this.progressMessageId, + textLength: text.length, + }, + 'Skipped progress delivery because this run no longer owns the active paired turn attempt', + ); + this.pendingProgressText = null; + return; + } + if (!text || (text === this.latestProgressText && this.progressMessageId)) { return; }