refactor: centralize visible phase mapping
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
17
src/types.test.ts
Normal file
17
src/types.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
src/types.ts
22
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;
|
||||
|
||||
Reference in New Issue
Block a user