refactor: centralize visible phase mapping

This commit is contained in:
Eyejoker
2026-03-25 12:04:29 +09:00
parent 1ad23269fa
commit cc7cfbbc5f
4 changed files with 208 additions and 94 deletions

View File

@@ -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', () => { it('recovery queues a group when an open work item is waiting for delivery', () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('claude-code'); const group = makeGroup('claude-code');

View File

@@ -5,6 +5,9 @@ import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import { formatElapsedKorean } from './utils.js'; import { formatElapsedKorean } from './utils.js';
import { import {
normalizeAgentOutputPhase,
toVisiblePhase,
type AgentOutputPhase,
type Channel, type Channel,
type RegisteredGroup, type RegisteredGroup,
type VisiblePhase, type VisiblePhase,
@@ -117,87 +120,108 @@ export class MessageTurnController {
); );
} }
if (result.phase === 'intermediate') { const phase: AgentOutputPhase = normalizeAgentOutputPhase(result.phase);
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;
}
if (result.phase === 'tool-activity') { switch (phase) {
if (result.agentId) { case 'intermediate':
// Subagent tool activity
let track = this.subagents.get(result.agentId);
if (!track) {
track = { label: '작업 중...', activities: [] };
this.subagents.set(result.agentId, track);
}
if (text) { if (text) {
const MAX = 2; if (this.progressMessageId) {
track.activities.push(text); // Progress exists — update heading (works with or without subagents)
if (track.activities.length > MAX) { this.previousProgressText = this.latestProgressText;
track.activities = track.activities.slice(-MAX); 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) { if (!this.poisonedSessionDetected) {
this.resetIdleTimer(); this.resetIdleTimer();
} }
return; return;
}
// Main agent tool activity
this.ensureProgressMessageExists();
if (text) {
this.addToolActivity(text);
}
if (!this.poisonedSessionDetected) {
this.resetIdleTimer();
}
return;
}
if (result.phase === 'progress') { case 'tool-activity':
if (result.agentId) { if (result.agentId) {
if (result.agentDone) { // Subagent tool activity
const done = this.subagents.get(result.agentId); let track = this.subagents.get(result.agentId);
if (done) { if (!track) {
done.label = done.label.replace('🔄', '✅'); track = { label: '작업 중...', activities: [] };
done.activities = []; this.subagents.set(result.agentId, track);
} }
} else { if (text) {
const label = const MAX = 2;
text || track.activities.push(text);
(result.agentLabel ? `🔄 ${result.agentLabel}` : '작업 중...'); if (track.activities.length > MAX) {
const existing = this.subagents.get(result.agentId); track.activities = track.activities.slice(-MAX);
if (existing) { }
existing.label = label;
existing.activities = [];
} else {
this.subagents.set(result.agentId, { label, activities: [] });
} }
this.ensureProgressMessageExists();
this.ensureProgressTicker();
if (!this.poisonedSessionDetected) {
this.resetIdleTimer();
}
return;
} }
if (!this.latestProgressText) { // Main agent tool activity
this.latestProgressText = '작업 중...';
this.latestProgressTextForFinal = '작업 중...';
}
this.ensureProgressMessageExists(); this.ensureProgressMessageExists();
this.ensureProgressTicker(); if (text) {
if (this.progressMessageId) { this.addToolActivity(text);
void this.syncTrackedProgressMessage(); }
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) { if (!this.poisonedSessionDetected) {
this.resetIdleTimer(); this.resetIdleTimer();
@@ -206,26 +230,14 @@ export class MessageTurnController {
this.hadError = true; this.hadError = true;
} }
return; 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, // 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 // Already sent as intermediate — skip duplicate, just finalize
this.lastIntermediateText = null; this.lastIntermediateText = null;
await this.finalizeProgressMessage(); await this.finalizeProgressMessage();
this.visiblePhase = 'final'; this.visiblePhase = toVisiblePhase(phase);
this.latestProgressTextForFinal = null; this.latestProgressTextForFinal = null;
} else { } else {
this.lastIntermediateText = null; this.lastIntermediateText = null;
@@ -531,7 +543,7 @@ export class MessageTurnController {
} }
private async deliverFinalText(text: string): Promise<void> { private async deliverFinalText(text: string): Promise<void> {
this.visiblePhase = 'final'; this.visiblePhase = toVisiblePhase('final');
const delivered = await this.options.deliverFinalText(text); const delivered = await this.options.deliverFinalText(text);
if (!delivered) { if (!delivered) {
this.producedDeliverySucceeded = false; this.producedDeliverySucceeded = false;
@@ -579,14 +591,14 @@ export class MessageTurnController {
'Updating tracked progress message', 'Updating tracked progress message',
); );
await this.syncTrackedProgressMessage(); await this.syncTrackedProgressMessage();
this.visiblePhase = 'progress'; this.visiblePhase = toVisiblePhase('progress');
return; return;
} }
if (!this.options.channel.sendAndTrack) { if (!this.options.channel.sendAndTrack) {
this.latestProgressRendered = rendered; this.latestProgressRendered = rendered;
await this.options.channel.sendMessage(this.options.chatJid, rendered); await this.options.channel.sendMessage(this.options.chatJid, rendered);
this.visiblePhase = 'progress'; this.visiblePhase = toVisiblePhase('progress');
return; return;
} }
@@ -608,7 +620,7 @@ export class MessageTurnController {
); );
this.latestProgressRendered = rendered; this.latestProgressRendered = rendered;
await this.options.channel.sendMessage(this.options.chatJid, rendered); await this.options.channel.sendMessage(this.options.chatJid, rendered);
this.visiblePhase = 'progress'; this.visiblePhase = toVisiblePhase('progress');
return; return;
} }
@@ -626,13 +638,13 @@ export class MessageTurnController {
); );
this.latestProgressRendered = rendered; this.latestProgressRendered = rendered;
this.ensureProgressTicker(); this.ensureProgressTicker();
this.visiblePhase = 'progress'; this.visiblePhase = toVisiblePhase('progress');
return; return;
} }
this.latestProgressRendered = rendered; this.latestProgressRendered = rendered;
await this.options.channel.sendMessage(this.options.chatJid, rendered); await this.options.channel.sendMessage(this.options.chatJid, rendered);
this.visiblePhase = 'progress'; this.visiblePhase = toVisiblePhase('progress');
} }
private resetIdleTimer(): void { private resetIdleTimer(): void {

17
src/types.test.ts Normal file
View File

@@ -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');
});
});

View File

@@ -21,6 +21,28 @@ export type AgentOutputPhase =
/** Phase as visible in the UI (mapped from AgentOutputPhase). */ /** Phase as visible in the UI (mapped from AgentOutputPhase). */
export type VisiblePhase = 'silent' | 'progress' | 'final'; 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 { export interface RegisteredGroup {
name: string; name: string;
folder: string; folder: string;