diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 7bca002..814c77b 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -2192,6 +2192,69 @@ describe('createMessageRuntime', () => { ); }); + it('treats missing streamed phase as final output', async () => { + const chatJid = 'group@test'; + const group = makeGroup('claude-code'); + 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).mockImplementationOnce( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: 'phase 없는 최종 응답입니다.', + }); + return { + status: 'success', + result: null, + }; + }, + ); + + 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(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-missing-phase-final', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'phase 없는 최종 응답입니다.', + ); + }); + it('recovery queues a group when an open work item is waiting for delivery', () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 5c2fd33..b492fdc 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -5,6 +5,9 @@ import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { formatElapsedKorean } from './utils.js'; import { + normalizeAgentOutputPhase, + toVisiblePhase, + type AgentOutputPhase, type Channel, type RegisteredGroup, type VisiblePhase, @@ -117,87 +120,108 @@ export class MessageTurnController { ); } - if (result.phase === 'intermediate') { - if (text) { - if (this.progressMessageId) { - // Progress exists — update heading (works with or without subagents) - this.previousProgressText = this.latestProgressText; - this.latestProgressText = text; - this.latestProgressTextForFinal = text; - this.toolActivities = []; - void this.syncTrackedProgressMessage(); - } else { - // No progress yet — buffer (creates on next event) - this.bufferProgress(text); - } - } - if (!this.poisonedSessionDetected) { - this.resetIdleTimer(); - } - return; - } + const phase: AgentOutputPhase = normalizeAgentOutputPhase(result.phase); - if (result.phase === 'tool-activity') { - if (result.agentId) { - // Subagent tool activity - let track = this.subagents.get(result.agentId); - if (!track) { - track = { label: '작업 중...', activities: [] }; - this.subagents.set(result.agentId, track); - } + switch (phase) { + case 'intermediate': if (text) { - const MAX = 2; - track.activities.push(text); - if (track.activities.length > MAX) { - track.activities = track.activities.slice(-MAX); + if (this.progressMessageId) { + // Progress exists — update heading (works with or without subagents) + this.previousProgressText = this.latestProgressText; + this.latestProgressText = text; + this.latestProgressTextForFinal = text; + this.toolActivities = []; + void this.syncTrackedProgressMessage(); + } else { + // No progress yet — buffer (creates on next event) + this.bufferProgress(text); } } - this.ensureProgressMessageExists(); - this.ensureProgressTicker(); if (!this.poisonedSessionDetected) { this.resetIdleTimer(); } return; - } - // Main agent tool activity - this.ensureProgressMessageExists(); - if (text) { - this.addToolActivity(text); - } - if (!this.poisonedSessionDetected) { - this.resetIdleTimer(); - } - return; - } - if (result.phase === 'progress') { - if (result.agentId) { - if (result.agentDone) { - const done = this.subagents.get(result.agentId); - if (done) { - done.label = done.label.replace('🔄', '✅'); - done.activities = []; + case 'tool-activity': + if (result.agentId) { + // Subagent tool activity + let track = this.subagents.get(result.agentId); + if (!track) { + track = { label: '작업 중...', activities: [] }; + this.subagents.set(result.agentId, track); } - } else { - const label = - text || - (result.agentLabel ? `🔄 ${result.agentLabel}` : '작업 중...'); - const existing = this.subagents.get(result.agentId); - if (existing) { - existing.label = label; - existing.activities = []; - } else { - this.subagents.set(result.agentId, { label, activities: [] }); + if (text) { + const MAX = 2; + track.activities.push(text); + if (track.activities.length > MAX) { + track.activities = track.activities.slice(-MAX); + } } + this.ensureProgressMessageExists(); + this.ensureProgressTicker(); + if (!this.poisonedSessionDetected) { + this.resetIdleTimer(); + } + return; } - if (!this.latestProgressText) { - this.latestProgressText = '작업 중...'; - this.latestProgressTextForFinal = '작업 중...'; - } + // Main agent tool activity this.ensureProgressMessageExists(); - this.ensureProgressTicker(); - if (this.progressMessageId) { - void this.syncTrackedProgressMessage(); + if (text) { + this.addToolActivity(text); + } + if (!this.poisonedSessionDetected) { + this.resetIdleTimer(); + } + return; + + case 'progress': + if (result.agentId) { + if (result.agentDone) { + const done = this.subagents.get(result.agentId); + if (done) { + done.label = done.label.replace('🔄', '✅'); + done.activities = []; + } + } else { + const label = + text || + (result.agentLabel ? `🔄 ${result.agentLabel}` : '작업 중...'); + const existing = this.subagents.get(result.agentId); + if (existing) { + existing.label = label; + existing.activities = []; + } else { + this.subagents.set(result.agentId, { label, activities: [] }); + } + } + if (!this.latestProgressText) { + this.latestProgressText = '작업 중...'; + this.latestProgressTextForFinal = '작업 중...'; + } + this.ensureProgressMessageExists(); + this.ensureProgressTicker(); + if (this.progressMessageId) { + void this.syncTrackedProgressMessage(); + } + if (!this.poisonedSessionDetected) { + this.resetIdleTimer(); + } + if (result.status === 'error') { + this.hadError = true; + } + return; + } + // Main agent progress + if (text) { + if (this.progressMessageId) { + // Progress message already visible — update heading directly + this.previousProgressText = this.latestProgressText; + this.latestProgressText = text; + this.toolActivities = []; + void this.syncTrackedProgressMessage(); + } else { + this.bufferProgress(text); + } } if (!this.poisonedSessionDetected) { this.resetIdleTimer(); @@ -206,26 +230,14 @@ export class MessageTurnController { this.hadError = true; } return; + + case 'final': + break; + + default: { + const exhaustive: never = phase; + throw new Error(`Unhandled message turn phase: ${exhaustive}`); } - // Main agent progress - if (text) { - if (this.progressMessageId) { - // Progress message already visible — update heading directly - this.previousProgressText = this.latestProgressText; - this.latestProgressText = text; - this.toolActivities = []; - void this.syncTrackedProgressMessage(); - } else { - this.bufferProgress(text); - } - } - if (!this.poisonedSessionDetected) { - this.resetIdleTimer(); - } - if (result.status === 'error') { - this.hadError = true; - } - return; } // Final arrived — flush any buffered progress that isn't the same text, @@ -235,7 +247,7 @@ export class MessageTurnController { // Already sent as intermediate — skip duplicate, just finalize this.lastIntermediateText = null; await this.finalizeProgressMessage(); - this.visiblePhase = 'final'; + this.visiblePhase = toVisiblePhase(phase); this.latestProgressTextForFinal = null; } else { this.lastIntermediateText = null; @@ -531,7 +543,7 @@ export class MessageTurnController { } private async deliverFinalText(text: string): Promise { - this.visiblePhase = 'final'; + this.visiblePhase = toVisiblePhase('final'); const delivered = await this.options.deliverFinalText(text); if (!delivered) { this.producedDeliverySucceeded = false; @@ -579,14 +591,14 @@ export class MessageTurnController { 'Updating tracked progress message', ); await this.syncTrackedProgressMessage(); - this.visiblePhase = 'progress'; + this.visiblePhase = toVisiblePhase('progress'); return; } if (!this.options.channel.sendAndTrack) { this.latestProgressRendered = rendered; await this.options.channel.sendMessage(this.options.chatJid, rendered); - this.visiblePhase = 'progress'; + this.visiblePhase = toVisiblePhase('progress'); return; } @@ -608,7 +620,7 @@ export class MessageTurnController { ); this.latestProgressRendered = rendered; await this.options.channel.sendMessage(this.options.chatJid, rendered); - this.visiblePhase = 'progress'; + this.visiblePhase = toVisiblePhase('progress'); return; } @@ -626,13 +638,13 @@ export class MessageTurnController { ); this.latestProgressRendered = rendered; this.ensureProgressTicker(); - this.visiblePhase = 'progress'; + this.visiblePhase = toVisiblePhase('progress'); return; } this.latestProgressRendered = rendered; await this.options.channel.sendMessage(this.options.chatJid, rendered); - this.visiblePhase = 'progress'; + this.visiblePhase = toVisiblePhase('progress'); } private resetIdleTimer(): void { diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 0000000..f8afc97 --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeAgentOutputPhase, toVisiblePhase } from './types.js'; + +describe('phase helpers', () => { + it('maps agent output phases to visible phases', () => { + expect(toVisiblePhase('intermediate')).toBe('silent'); + expect(toVisiblePhase('tool-activity')).toBe('silent'); + expect(toVisiblePhase('progress')).toBe('progress'); + expect(toVisiblePhase('final')).toBe('final'); + }); + + it('normalizes missing agent output phases to final', () => { + expect(normalizeAgentOutputPhase(undefined)).toBe('final'); + expect(normalizeAgentOutputPhase('progress')).toBe('progress'); + }); +}); diff --git a/src/types.ts b/src/types.ts index 8211111..d16be88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,28 @@ export type AgentOutputPhase = /** Phase as visible in the UI (mapped from AgentOutputPhase). */ export type VisiblePhase = 'silent' | 'progress' | 'final'; +export function normalizeAgentOutputPhase( + phase?: AgentOutputPhase, +): AgentOutputPhase { + return phase ?? 'final'; +} + +export function toVisiblePhase(phase: AgentOutputPhase): VisiblePhase { + switch (phase) { + case 'intermediate': + case 'tool-activity': + return 'silent'; + case 'progress': + return 'progress'; + case 'final': + return 'final'; + default: { + const exhaustive: never = phase; + throw new Error(`Unknown agent output phase: ${exhaustive}`); + } + } +} + export interface RegisteredGroup { name: string; folder: string;