Files
EJClaw/src/message-turn-controller.test.ts
2026-05-27 10:53:31 +09:00

832 lines
25 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./logger.js', () => {
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: (_bindings?: Record<string, unknown>) => mockLogger,
};
return {
logger: mockLogger,
createScopedLogger: (_bindings?: Record<string, unknown>) => mockLogger,
};
});
import { MessageTurnController } from './message-turn-controller.js';
import { logger } from './logger.js';
import type { Channel, RegisteredGroup } from './types.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
function makeGroup(): RegisteredGroup {
return {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: new Date().toISOString(),
requiresTrigger: false,
agentType: 'claude-code',
};
}
function makeChannel(): Channel {
return {
name: 'discord-review',
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
ownsJid: vi.fn(() => true),
sendMessage: vi.fn().mockResolvedValue(undefined),
sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
editMessage: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
getOutboundAuditMeta: vi.fn(() => ({
channelName: 'discord-review',
botUserId: 'bot-review',
botUsername: 'reviewer-bot',
})),
};
}
function makeTurnIdentity(): PairedTurnIdentity {
return {
turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function getAuditEntries(): Array<Record<string, unknown>> {
return vi
.mocked(logger.info)
.mock.calls.map(([payload]) => payload)
.filter(
(payload): payload is Record<string, unknown> =>
!!payload && typeof payload === 'object' && 'auditEvent' in payload,
);
}
describe('MessageTurnController outbound audit logging', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('logs outbound progress and final audit context with role/service/turn metadata', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-1',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
} as any);
await flushAsync();
await controller.handleOutput({
status: 'success',
phase: 'final',
result: '최종 답변',
} as any);
await controller.finish('success');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(channel.sendAndTrack).toHaveBeenCalledWith(
'dc:test-room',
expect.stringContaining('첫 진행 상황'),
);
expect(channel.editMessage).not.toHaveBeenCalled();
expect(deliverFinalText).toHaveBeenCalledWith('최종 답변', {
replaceMessageId: 'progress-1',
});
expect(getAuditEntries()).toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'progress-create',
chatJid: 'dc:test-room',
runId: 'run-review-1',
deliveryRole: 'reviewer',
serviceId: 'codex-review',
turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
turnRole: 'reviewer',
channelName: 'discord-review',
botUserId: 'bot-review',
botUsername: 'reviewer-bot',
messageId: 'progress-1',
}),
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
deliveryRole: 'reviewer',
serviceId: 'codex-review',
turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
messageId: 'progress-1',
deliveryMode: 'edit',
}),
expect.objectContaining({
auditEvent: 'final-delivery-result',
deliveryRole: 'reviewer',
serviceId: 'codex-review',
turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
messageId: 'progress-1',
deliveryMode: 'edit',
delivered: true,
}),
]),
);
});
it('logs fallback progress audit when tracked message creation fails', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
vi.mocked(channel.sendAndTrack!).mockRejectedValueOnce(
new Error('send failed'),
);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-fallback',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
} as any);
await flushAsync();
await controller.finish('success');
expect(channel.sendMessage).toHaveBeenCalledWith(
'dc:test-room',
expect.stringContaining('첫 진행 상황'),
);
expect(getAuditEntries()).toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'progress-fallback-send',
chatJid: 'dc:test-room',
runId: 'run-review-fallback',
deliveryRole: 'reviewer',
serviceId: 'codex-review',
turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
channelName: 'discord-review',
botUserId: 'bot-review',
messageId: null,
fallbackReason: 'tracked-send-error',
}),
]),
);
});
it('does not replay the last progress message as a final delivery for paired reviewer turns', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-no-fake-final',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
allowProgressReplayWithoutFinal: false,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '확인하겠습니다.',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '근거를 다시 대조 중입니다.',
} as any);
await flushAsync();
const finishResult = await controller.finish('success');
expect(finishResult.visiblePhase).toBe('progress');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(channel.editMessage).toHaveBeenCalledWith(
'dc:test-room',
'progress-1',
expect.stringContaining('확인하겠습니다.'),
);
expect(deliverFinalText).not.toHaveBeenCalled();
expect(getAuditEntries()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
}),
]),
);
});
it('does not flush pending progress before final delivery for paired reviewer turns', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-no-pending-flush',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '오너가 대화와 관련 코드 근거를 대조해서 판정을 내리겠습니다.',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'final',
result: 'PROCEED 근거를 확인했습니다.',
} as any);
await controller.finish('success');
expect(channel.sendAndTrack).not.toHaveBeenCalled();
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(deliverFinalText).toHaveBeenCalledTimes(1);
expect(deliverFinalText).toHaveBeenCalledWith(
'PROCEED 근거를 확인했습니다.',
{
replaceMessageId: null,
},
);
});
it('passes structured final attachments to final delivery', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const attachments = [
{
path: '/tmp/e2e-screenshot.png',
name: 'e2e-screenshot.png',
mime: 'image/png',
},
];
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-review-attachments',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'final',
result: '스크린샷을 첨부했습니다.',
output: {
visibility: 'public',
text: '스크린샷을 첨부했습니다.',
attachments,
},
} as any);
await controller.finish('success');
expect(deliverFinalText).toHaveBeenCalledWith('스크린샷을 첨부했습니다.', {
replaceMessageId: null,
attachments,
});
expect(getAuditEntries()).toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
attachmentCount: 1,
}),
]),
);
});
it('sends final attachments as a fresh final message instead of replacing text-only progress', async () => {
const channel = {
...makeChannel(),
name: 'discord',
} satisfies Channel;
const deliverFinalText = vi.fn().mockResolvedValue(true);
const attachments = [
{
path: '/tmp/ejclaw-discord-image-final.png',
name: 'final.png',
mime: 'image/png',
},
];
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-owner-final-attachment-send',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '이미지 렌더링 중',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '이미지 파일 쓰는 중',
} as any);
await flushAsync();
await controller.handleOutput({
status: 'success',
phase: 'final',
result: '이미지 렌더 완료.',
output: {
visibility: 'public',
text: '이미지 렌더 완료.',
attachments,
},
} as any);
await controller.finish('success');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(deliverFinalText).toHaveBeenCalledWith('이미지 렌더 완료.', {
replaceMessageId: null,
attachments,
});
});
it('replaces the tracked progress message when an owner final arrives', async () => {
const channel = {
...makeChannel(),
name: 'discord',
} satisfies Channel;
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-owner-final-replace',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
} as any);
await flushAsync();
await controller.handleOutput({
status: 'success',
phase: 'final',
result: 'DONE 최종 답변',
} as any);
await controller.finish('success');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(deliverFinalText).toHaveBeenCalledWith('DONE 최종 답변', {
replaceMessageId: 'progress-1',
});
});
it('replaces the tracked progress message when finish() replays the last owner progress as final', async () => {
const channel = {
...makeChannel(),
name: 'discord',
} satisfies Channel;
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-owner-progress-replay',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
} as any);
await flushAsync();
await controller.finish('success');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(deliverFinalText).toHaveBeenCalledWith('첫 진행 상황', {
replaceMessageId: 'progress-1',
});
});
it('suppresses replaying the last progress update as final when final delivery is disallowed', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-stale-owner-attempt',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
canDeliverFinalText: () => false,
allowProgressReplayWithoutFinal: true,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'paired-task:2026-04-10T00:00:00.000Z:finalize-owner-turn',
taskId: 'paired-task',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '작업 중 1',
output: { visibility: 'public', text: '작업 중 1' },
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '작업 중 2',
output: { visibility: 'public', text: '작업 중 2' },
} as any);
await flushAsync();
const finishResult = await controller.finish('success');
expect(finishResult.deliverySucceeded).toBe(true);
expect(channel.sendAndTrack).not.toHaveBeenCalled();
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(deliverFinalText).not.toHaveBeenCalled();
expect(getAuditEntries()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
auditEvent: 'final-delivery-attempt',
}),
]),
);
});
it('does not flush buffered progress when final delivery is disallowed for a stale owner turn', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-stale-owner-final-with-buffered-progress',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
canDeliverFinalText: () => false,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'paired-task:2026-04-10T00:00:00.000Z:finalize-owner-turn',
taskId: 'paired-task',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '버퍼된 진행 상황',
output: { visibility: 'public', text: '버퍼된 진행 상황' },
} as any);
await controller.handleOutput({
status: 'success',
phase: 'final',
result: 'DONE 최종 답변',
output: { visibility: 'public', text: 'DONE 최종 답변' },
} as any);
const finishResult = await controller.finish('success');
expect(finishResult.deliverySucceeded).toBe(true);
expect(channel.sendAndTrack).not.toHaveBeenCalled();
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(deliverFinalText).not.toHaveBeenCalled();
});
it('does not emit a tracked progress message when a stale owner turn buffers multiple progress updates', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-stale-owner-progress-buffer',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
canDeliverFinalText: () => false,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn',
taskId: 'paired-task',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
output: { visibility: 'public', text: '첫 진행 상황' },
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
output: { visibility: 'public', text: '둘째 진행 상황' },
} as any);
const finishResult = await controller.finish('success');
expect(finishResult.deliverySucceeded).toBe(true);
expect(channel.sendAndTrack).not.toHaveBeenCalled();
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(deliverFinalText).not.toHaveBeenCalled();
});
it('does not edit an existing tracked progress message after a stale owner turn loses delivery ownership', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
let canDeliver = true;
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-stale-owner-progress-edit',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
canDeliverFinalText: () => canDeliver,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn',
taskId: 'paired-task',
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
output: { visibility: 'public', text: '첫 진행 상황' },
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '둘째 진행 상황',
output: { visibility: 'public', text: '둘째 진행 상황' },
} as any);
await flushAsync();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '셋째 진행 상황',
output: { visibility: 'public', text: '셋째 진행 상황' },
} as any);
await flushAsync();
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(channel.editMessage).toHaveBeenCalledTimes(1);
canDeliver = false;
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '넷째 진행 상황',
output: { visibility: 'public', text: '넷째 진행 상황' },
} as any);
await flushAsync();
const finishResult = await controller.finish('success');
expect(finishResult.deliverySucceeded).toBe(true);
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(channel.editMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(deliverFinalText).not.toHaveBeenCalled();
});
it('replaces the tracked progress message when finish() publishes a failure final', async () => {
const channel = {
...makeChannel(),
name: 'discord',
} satisfies Channel;
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-owner-failure-final',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'finalize-owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '첫 진행 상황',
} as any);
await controller.handleOutput({
status: 'error',
phase: 'progress',
result: '오류 직전 진행 상황',
} as any);
await flushAsync();
await controller.finish('error');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(deliverFinalText).toHaveBeenCalledWith('실패', {
replaceMessageId: 'progress-1',
});
});
it('publishes a failure final when an error finishes before any visible output', async () => {
const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-silent-error-final',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
deliveryRole: 'owner',
});
await controller.start();
const finishResult = await controller.finish('error');
expect(finishResult.visiblePhase).toBe('final');
expect(deliverFinalText).toHaveBeenCalledWith('실패', {
replaceMessageId: null,
});
});
});