diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 5bce53e..c8c2ba2 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1483,6 +1483,100 @@ describe('createMessageRuntime', () => { expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true); }); + it('promotes the last visible progress to a final message when the final output is structured silent', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const lastAgentTimestamps: Record = {}; + const saveState = 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', + seq: 1, + }, + ]); + + 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-structured-silent', + }); + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '두 번째 진행상황입니다.', + newSessionId: 'session-progress-structured-silent', + }); + await vi.runOnlyPendingTimersAsync(); + await onOutput?.({ + status: 'success', + phase: 'final', + result: null, + output: { visibility: 'silent' }, + newSessionId: 'session-progress-structured-silent', + }); + + return { + status: 'success', + result: null, + output: { visibility: 'silent' }, + newSessionId: 'session-progress-structured-silent', + }; + }, + ); + + 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(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-progress-structured-silent', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + '첫 번째 진행상황입니다.', + ); + } finally { + vi.useRealTimers(); + } + }); + it('defers typing-on until the first visible output for suppress-capable turns', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 4abdaee..4d4c356 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -311,8 +311,13 @@ export class MessageTurnController { await this.deliverFinalText(text); } } else if (silentOutput || suppressState !== 'none') { + const shouldPromoteVisibleProgressToFinal = + this.visiblePhase === 'progress' && + typeof this.latestProgressTextForFinal === 'string'; await this.finalizeProgressMessage(); - this.latestProgressTextForFinal = null; + if (!shouldPromoteVisibleProgressToFinal) { + this.latestProgressTextForFinal = null; + } } else if (raw) { logger.info( { diff --git a/src/provider-retry.test.ts b/src/provider-retry.test.ts index 056e60a..1b40f49 100644 --- a/src/provider-retry.test.ts +++ b/src/provider-retry.test.ts @@ -86,9 +86,7 @@ describe('runClaudeRotationLoop', () => { it('uses structured public output text as the next rotation message', async () => { vi.mocked(getTokenCount).mockReturnValue(2); - vi.mocked(rotateToken) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); + vi.mocked(rotateToken).mockReturnValueOnce(true).mockReturnValueOnce(false); let attempts = 0; const outcome = await runClaudeRotationLoop( diff --git a/src/streamed-output-evaluator.ts b/src/streamed-output-evaluator.ts index 4729944..b78fc1d 100644 --- a/src/streamed-output-evaluator.ts +++ b/src/streamed-output-evaluator.ts @@ -117,10 +117,7 @@ export function evaluateStreamedOutput( } } - if ( - countsAsFinalOutput && - hasAgentOutputPayload(output) - ) { + if (countsAsFinalOutput && hasAgentOutputPayload(output)) { nextState.sawOutput = true; } else if ( options.trackSuccessNullResult &&