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', () => {
|
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');
|
||||||
|
|||||||
@@ -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
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). */
|
/** 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user