Merge pull request #165 from phj1081/codex/owner/ejclaw
fix: handle paired preemption and task scope prompts
This commit is contained in:
@@ -30,6 +30,7 @@ export interface GroupState {
|
|||||||
runPhase: RunPhase;
|
runPhase: RunPhase;
|
||||||
runningTaskId: string | null;
|
runningTaskId: string | null;
|
||||||
currentRunId: string | null;
|
currentRunId: string | null;
|
||||||
|
lastCloseRequest: { runId: string | null; reason: string | null } | null;
|
||||||
directTerminalDeliveries: Map<string, string>;
|
directTerminalDeliveries: Map<string, string>;
|
||||||
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
|
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
@@ -52,6 +53,7 @@ export function createGroupState(): GroupState {
|
|||||||
runPhase: 'idle',
|
runPhase: 'idle',
|
||||||
runningTaskId: null,
|
runningTaskId: null,
|
||||||
currentRunId: null,
|
currentRunId: null,
|
||||||
|
lastCloseRequest: null,
|
||||||
directTerminalDeliveries: new Map(),
|
directTerminalDeliveries: new Map(),
|
||||||
recentDirectTerminalDeliveries: new Map(),
|
recentDirectTerminalDeliveries: new Map(),
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
@@ -139,6 +141,7 @@ export function transitionRunPhase(
|
|||||||
export function resetRunState(state: GroupState, groupJid: string): void {
|
export function resetRunState(state: GroupState, groupJid: string): void {
|
||||||
state.currentRunId = null;
|
state.currentRunId = null;
|
||||||
state.runningTaskId = null;
|
state.runningTaskId = null;
|
||||||
|
state.lastCloseRequest = null;
|
||||||
state.startedAt = null;
|
state.startedAt = null;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
|
|||||||
@@ -747,3 +747,47 @@ describe('GroupQueue', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GroupQueue close reason tracking', () => {
|
||||||
|
let queue: GroupQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
queue = new GroupQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the close reason for the active message run until it finishes', async () => {
|
||||||
|
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
|
||||||
|
let releaseRun!: (value: boolean) => void;
|
||||||
|
let activeRunId = '';
|
||||||
|
const blocker = new Promise<boolean>((resolve) => {
|
||||||
|
releaseRun = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.setProcessMessagesFn(
|
||||||
|
vi.fn(async (_groupJid: string, context: GroupRunContext) => {
|
||||||
|
activeRunId = context.runId;
|
||||||
|
return await blocker;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
queue.enqueueMessageCheck('group1@g.us', ipcDir);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
queue.closeStdin('group1@g.us', { reason: 'human-message-detected' });
|
||||||
|
|
||||||
|
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBe(
|
||||||
|
'human-message-detected',
|
||||||
|
);
|
||||||
|
expect(queue.getCloseReasonForRun('group1@g.us', 'other-run')).toBeNull();
|
||||||
|
|
||||||
|
releaseRun(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -322,7 +322,10 @@ export class GroupQueue {
|
|||||||
}
|
}
|
||||||
return state.directTerminalDeliveries.get(senderRole) ?? null;
|
return state.directTerminalDeliveries.get(senderRole) ?? null;
|
||||||
}
|
}
|
||||||
|
getCloseReasonForRun(groupJid: string, runId: string): string | null {
|
||||||
|
const closeRequest = this.getGroup(groupJid).lastCloseRequest;
|
||||||
|
return closeRequest?.runId === runId ? closeRequest.reason : null;
|
||||||
|
}
|
||||||
hasRecordedDirectTerminalDeliveryForRun(
|
hasRecordedDirectTerminalDeliveryForRun(
|
||||||
groupJid: string,
|
groupJid: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -342,7 +345,6 @@ export class GroupQueue {
|
|||||||
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
|
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPostCloseTimers(state: GroupState): void {
|
private clearPostCloseTimers(state: GroupState): void {
|
||||||
if (state.postCloseTermTimer) {
|
if (state.postCloseTermTimer) {
|
||||||
clearTimeout(state.postCloseTermTimer);
|
clearTimeout(state.postCloseTermTimer);
|
||||||
@@ -428,29 +430,27 @@ export class GroupQueue {
|
|||||||
}, POST_CLOSE_SIGKILL_DELAY_MS);
|
}, POST_CLOSE_SIGKILL_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal the active agent process to wind down by writing a close sentinel.
|
|
||||||
*/
|
|
||||||
closeStdin(
|
closeStdin(
|
||||||
groupJid: string,
|
groupJid: string,
|
||||||
metadata?: { runId?: string; reason?: string },
|
metadata?: { runId?: string; reason?: string },
|
||||||
): void {
|
): void {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
if (state.runPhase === 'idle' || !state.ipcDir) return;
|
if (state.runPhase === 'idle' || !state.ipcDir) return;
|
||||||
|
const runId = metadata?.runId ?? state.currentRunId;
|
||||||
|
state.lastCloseRequest = { runId, reason: metadata?.reason ?? null };
|
||||||
if (state.runPhase === 'running_messages') {
|
if (state.runPhase === 'running_messages') {
|
||||||
transitionRunPhase(state, groupJid, 'closing_messages', {
|
transitionRunPhase(state, groupJid, 'closing_messages', {
|
||||||
reason: metadata?.reason,
|
reason: metadata?.reason,
|
||||||
runId: metadata?.runId ?? state.currentRunId,
|
runId,
|
||||||
});
|
});
|
||||||
assertRunPhaseInvariants(state, groupJid);
|
assertRunPhaseInvariants(state, groupJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeCloseSentinel(state.ipcDir);
|
writeCloseSentinel(state.ipcDir);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
groupJid,
|
groupJid,
|
||||||
runId: metadata?.runId ?? state.currentRunId,
|
runId,
|
||||||
ipcDir: state.ipcDir,
|
ipcDir: state.ipcDir,
|
||||||
reason: metadata?.reason ?? 'unspecified',
|
reason: metadata?.reason ?? 'unspecified',
|
||||||
},
|
},
|
||||||
@@ -460,7 +460,7 @@ export class GroupQueue {
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
groupJid,
|
groupJid,
|
||||||
runId: metadata?.runId ?? state.currentRunId,
|
runId,
|
||||||
ipcDir: state.ipcDir,
|
ipcDir: state.ipcDir,
|
||||||
reason: metadata?.reason ?? 'unspecified',
|
reason: metadata?.reason ?? 'unspecified',
|
||||||
err,
|
err,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ vi.mock('./message-runtime-follow-up.js', () => ({
|
|||||||
|
|
||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
|
import * as pairedExecutionContextModule from './paired-execution-context.js';
|
||||||
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
||||||
|
|
||||||
const log = {
|
const log = {
|
||||||
@@ -109,4 +110,94 @@ describe('createPairedExecutionLifecycle', () => {
|
|||||||
|
|
||||||
expect(outputs).toEqual([]);
|
expect(outputs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('releases an owner turn interrupted by a human message without counting an owner failure', async () => {
|
||||||
|
const enqueueMessageCheck = vi.fn();
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'paired-task-human-interrupted',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecycle = createPairedExecutionLifecycle({
|
||||||
|
pairedExecutionContext: {
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-human-interrupted',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
},
|
||||||
|
pairedTurnIdentity: {
|
||||||
|
turnId:
|
||||||
|
'paired-task-human-interrupted:2026-04-09T00:00:00.000Z:owner-turn',
|
||||||
|
taskId: 'paired-task-human-interrupted',
|
||||||
|
taskUpdatedAt: '2026-04-09T00:00:00.000Z',
|
||||||
|
intentKind: 'owner-turn',
|
||||||
|
role: 'owner',
|
||||||
|
},
|
||||||
|
completedRole: 'owner',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-human-interrupted',
|
||||||
|
enqueueMessageCheck,
|
||||||
|
getCloseReason: () => 'human-message-detected',
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
lifecycle.recordFinalOutputBeforeDelivery(
|
||||||
|
'TASK_DONE\n부분 진행 결과를 닫기 전에 내보냅니다.',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
lifecycle.updateSummary({
|
||||||
|
outputText: '아비터 판단을 내리겠습니다.',
|
||||||
|
});
|
||||||
|
lifecycle.markStatus('succeeded');
|
||||||
|
lifecycle.markSawOutput(false);
|
||||||
|
await lifecycle.asyncFinalize();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
pairedExecutionContextModule.completePairedExecutionContext,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
||||||
|
expect(db.releasePairedTaskExecutionLease).toHaveBeenCalledWith({
|
||||||
|
taskId: 'paired-task-human-interrupted',
|
||||||
|
runId: 'run-human-interrupted',
|
||||||
|
});
|
||||||
|
expect(db.failPairedTurn).toHaveBeenCalledWith({
|
||||||
|
turnIdentity: expect.objectContaining({
|
||||||
|
taskId: 'paired-task-human-interrupted',
|
||||||
|
role: 'owner',
|
||||||
|
}),
|
||||||
|
error: '아비터 판단을 내리겠습니다.',
|
||||||
|
});
|
||||||
|
expect(enqueueMessageCheck).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,12 +19,71 @@ import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules
|
|||||||
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
||||||
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
|
import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
|
||||||
|
import { isHumanMessageCloseReason } from './message-close-reasons.js';
|
||||||
import type { PairedRoomRole } from './types.js';
|
import type { PairedRoomRole } from './types.js';
|
||||||
|
|
||||||
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
|
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
|
||||||
|
|
||||||
const PAIRED_TASK_EXECUTION_LEASE_HEARTBEAT_MS = 30_000;
|
const PAIRED_TASK_EXECUTION_LEASE_HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
|
type PairedTaskRecord = NonNullable<ReturnType<typeof getPairedTaskById>>;
|
||||||
|
|
||||||
|
function releaseInterruptedPairedExecution(
|
||||||
|
taskId: string,
|
||||||
|
runId: string,
|
||||||
|
log: ExecutorLog,
|
||||||
|
): void {
|
||||||
|
log.info(
|
||||||
|
{ pairedTaskId: taskId, runId },
|
||||||
|
'Released paired execution lease without counting a failure because a human message interrupted the turn',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
releasePairedTaskExecutionLease({ taskId, runId });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{ pairedTaskId: taskId, runId, err },
|
||||||
|
'Failed to release paired execution lease after human interruption',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeStoredExecution(
|
||||||
|
taskId: string,
|
||||||
|
role: PairedRoomRole,
|
||||||
|
status: 'succeeded' | 'failed',
|
||||||
|
runId: string,
|
||||||
|
summary: string | null,
|
||||||
|
): void {
|
||||||
|
completePairedExecutionContext({
|
||||||
|
taskId,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
runId,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyPairedCompletionIfNeeded(args: {
|
||||||
|
task: PairedTaskRecord | null | undefined;
|
||||||
|
chatJid: string;
|
||||||
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (args.task?.status !== 'completed' || !args.task.completion_reason) return;
|
||||||
|
const sender = getLastHumanMessageSender(args.chatJid);
|
||||||
|
const mention = sender ? `<@${sender}>` : '';
|
||||||
|
const notifications: Record<string, string> = {
|
||||||
|
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
||||||
|
};
|
||||||
|
const message = notifications[args.task.completion_reason];
|
||||||
|
if (!message) return;
|
||||||
|
await args.onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
result: message,
|
||||||
|
output: { visibility: 'public', text: message },
|
||||||
|
phase: 'final',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface PairedExecutionLifecycle {
|
export interface PairedExecutionLifecycle {
|
||||||
updateSummary(args: {
|
updateSummary(args: {
|
||||||
outputText?: string | null;
|
outputText?: string | null;
|
||||||
@@ -47,6 +106,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
runId: string;
|
runId: string;
|
||||||
enqueueMessageCheck: () => void;
|
enqueueMessageCheck: () => void;
|
||||||
getDirectTerminalDeliveryText?: () => string | null;
|
getDirectTerminalDeliveryText?: () => string | null;
|
||||||
|
getCloseReason?: () => string | null;
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
log: ExecutorLog;
|
log: ExecutorLog;
|
||||||
}): PairedExecutionLifecycle {
|
}): PairedExecutionLifecycle {
|
||||||
@@ -58,6 +118,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
runId,
|
runId,
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
getDirectTerminalDeliveryText,
|
getDirectTerminalDeliveryText,
|
||||||
|
getCloseReason,
|
||||||
onOutput,
|
onOutput,
|
||||||
log,
|
log,
|
||||||
} = args;
|
} = args;
|
||||||
@@ -76,6 +137,8 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
pairedExecutionContext?.requiresVisibleVerdict === true;
|
pairedExecutionContext?.requiresVisibleVerdict === true;
|
||||||
const missingVisibleVerdictSummary =
|
const missingVisibleVerdictSummary =
|
||||||
'Execution completed without a visible terminal verdict.';
|
'Execution completed without a visible terminal verdict.';
|
||||||
|
const wasInterruptedByHumanMessage = (): boolean =>
|
||||||
|
isHumanMessageCloseReason(getCloseReason?.() ?? null);
|
||||||
|
|
||||||
const currentRunOwnsActiveAttempt = (reason: string): boolean => {
|
const currentRunOwnsActiveAttempt = (reason: string): boolean => {
|
||||||
if (!pairedTurnIdentity) {
|
if (!pairedTurnIdentity) {
|
||||||
@@ -276,6 +339,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
recordFinalOutputBeforeDelivery(outputText) {
|
recordFinalOutputBeforeDelivery(outputText) {
|
||||||
|
if (wasInterruptedByHumanMessage()) return false;
|
||||||
if (!currentRunOwnsActiveAttempt('streamed-final-output')) {
|
if (!currentRunOwnsActiveAttempt('streamed-final-output')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -376,26 +440,34 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
const sawOutputForFollowUp = missingVisibleVerdict
|
const sawOutputForFollowUp = missingVisibleVerdict
|
||||||
? false
|
? false
|
||||||
: pairedSawOutput;
|
: pairedSawOutput;
|
||||||
|
const interruptedByHumanMessage = wasInterruptedByHumanMessage();
|
||||||
|
|
||||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
||||||
if (effectiveStatus === 'succeeded') {
|
if (interruptedByHumanMessage) {
|
||||||
try {
|
releaseInterruptedPairedExecution(
|
||||||
persistPairedTurnOutputIfNeeded();
|
pairedExecutionContext.task.id,
|
||||||
} catch (err) {
|
runId,
|
||||||
log.warn(
|
log,
|
||||||
{ pairedTaskId: pairedExecutionContext.task.id, err },
|
);
|
||||||
'Failed to store paired turn output',
|
} else {
|
||||||
);
|
if (effectiveStatus === 'succeeded') {
|
||||||
|
try {
|
||||||
|
persistPairedTurnOutputIfNeeded();
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{ pairedTaskId: pairedExecutionContext.task.id, err },
|
||||||
|
'Failed to store paired turn output',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
completeStoredExecution(
|
||||||
|
pairedExecutionContext.task.id,
|
||||||
|
completedRole,
|
||||||
|
effectiveStatus,
|
||||||
|
runId,
|
||||||
|
pairedExecutionSummary,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
completePairedExecutionContext({
|
|
||||||
taskId: pairedExecutionContext.task.id,
|
|
||||||
role: completedRole,
|
|
||||||
status: effectiveStatus,
|
|
||||||
runId,
|
|
||||||
summary: pairedExecutionSummary,
|
|
||||||
});
|
|
||||||
pairedExecutionCompleted = true;
|
pairedExecutionCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,27 +479,15 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
if (!pairedExecutionContext) {
|
if (!pairedExecutionContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (interruptedByHumanMessage) {
|
||||||
const finishedTask = getPairedTaskById(pairedExecutionContext.task.id);
|
return;
|
||||||
if (
|
|
||||||
finishedTask?.status === 'completed' &&
|
|
||||||
finishedTask.completion_reason
|
|
||||||
) {
|
|
||||||
const sender = getLastHumanMessageSender(chatJid);
|
|
||||||
const mention = sender ? `<@${sender}>` : '';
|
|
||||||
const notifications: Record<string, string> = {
|
|
||||||
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
|
||||||
};
|
|
||||||
const message = notifications[finishedTask.completion_reason];
|
|
||||||
if (message) {
|
|
||||||
await onOutput?.({
|
|
||||||
status: 'success',
|
|
||||||
result: message,
|
|
||||||
output: { visibility: 'public', text: message },
|
|
||||||
phase: 'final',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const finishedTask = getPairedTaskById(pairedExecutionContext.task.id);
|
||||||
|
await notifyPairedCompletionIfNeeded({
|
||||||
|
task: finishedTask,
|
||||||
|
chatJid,
|
||||||
|
onOutput,
|
||||||
|
});
|
||||||
|
|
||||||
const queueAction =
|
const queueAction =
|
||||||
directTerminalOutput &&
|
directTerminalOutput &&
|
||||||
|
|||||||
@@ -92,7 +92,12 @@ async function enrichArbiterPromptWithMoa(args: {
|
|||||||
export interface MessageAgentExecutorDeps {
|
export interface MessageAgentExecutorDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> &
|
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> &
|
||||||
Partial<Pick<GroupQueue, 'getDirectTerminalDeliveryForRun'>>;
|
Partial<
|
||||||
|
Pick<
|
||||||
|
GroupQueue,
|
||||||
|
'getDirectTerminalDeliveryForRun' | 'getCloseReasonForRun'
|
||||||
|
>
|
||||||
|
>;
|
||||||
getRoomBindings: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
persistSession: (groupFolder: string, sessionId: string) => void;
|
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||||
@@ -180,6 +185,8 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
runtimePairedTurnIdentity?.role ?? activeRole,
|
runtimePairedTurnIdentity?.role ?? activeRole,
|
||||||
) ?? null,
|
) ?? null,
|
||||||
|
getCloseReason: () =>
|
||||||
|
deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null,
|
||||||
onOutput,
|
onOutput,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|||||||
7
src/message-close-reasons.ts
Normal file
7
src/message-close-reasons.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const HUMAN_MESSAGE_DETECTED_CLOSE_REASON = 'human-message-detected';
|
||||||
|
|
||||||
|
export function isHumanMessageCloseReason(
|
||||||
|
reason: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return reason === HUMAN_MESSAGE_DETECTED_CLOSE_REASON;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildQueuedTurnDispatch,
|
buildQueuedTurnDispatch,
|
||||||
executeBotOnlyPairedFollowUpAction,
|
executeBotOnlyPairedFollowUpAction,
|
||||||
} from './message-runtime-flow.js';
|
} from './message-runtime-flow.js';
|
||||||
|
import { HUMAN_MESSAGE_DETECTED_CLOSE_REASON } from './message-close-reasons.js';
|
||||||
import {
|
import {
|
||||||
advanceLastAgentCursor,
|
advanceLastAgentCursor,
|
||||||
filterLoopingPairedBotMessages,
|
filterLoopingPairedBotMessages,
|
||||||
@@ -305,7 +306,7 @@ export async function processLoopGroupMessages(args: {
|
|||||||
if (hasExternalHumanMessage(processableGroupMessages)) {
|
if (hasExternalHumanMessage(processableGroupMessages)) {
|
||||||
const interruptedActiveRun = args.isRunningMessageTurn(chatJid);
|
const interruptedActiveRun = args.isRunningMessageTurn(chatJid);
|
||||||
if (interruptedActiveRun) {
|
if (interruptedActiveRun) {
|
||||||
args.closeStdin('human-message-detected');
|
args.closeStdin(HUMAN_MESSAGE_DETECTED_CLOSE_REASON);
|
||||||
}
|
}
|
||||||
args.enqueueMessageCheck();
|
args.enqueueMessageCheck();
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function buildPendingPairedTurn(args: {
|
|||||||
turnOutputs,
|
turnOutputs,
|
||||||
recentHumanMessages,
|
recentHumanMessages,
|
||||||
lastHumanMessage,
|
lastHumanMessage,
|
||||||
|
taskCreatedAt: task.created_at,
|
||||||
}),
|
}),
|
||||||
channel: resolveChannel(taskStatus),
|
channel: resolveChannel(taskStatus),
|
||||||
cursor,
|
cursor,
|
||||||
@@ -188,6 +189,7 @@ export function buildPendingPairedTurn(args: {
|
|||||||
turnOutputs,
|
turnOutputs,
|
||||||
recentHumanMessages,
|
recentHumanMessages,
|
||||||
lastHumanMessage,
|
lastHumanMessage,
|
||||||
|
taskCreatedAt: task.created_at,
|
||||||
}),
|
}),
|
||||||
channel: resolveChannel(taskStatus),
|
channel: resolveChannel(taskStatus),
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
|||||||
const CARRY_FORWARD_MARKER =
|
const CARRY_FORWARD_MARKER =
|
||||||
'[Carried forward context from the previous task: latest owner final]';
|
'[Carried forward context from the previous task: latest owner final]';
|
||||||
|
|
||||||
function makeHumanMessage(content: string): NewMessage {
|
function makeHumanMessage(
|
||||||
|
content: string,
|
||||||
|
timestamp: string = '2026-04-20T01:00:00.000Z',
|
||||||
|
): NewMessage {
|
||||||
return {
|
return {
|
||||||
id: `msg-${content}`,
|
id: `msg-${content}`,
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
sender: 'user@test',
|
sender: 'user@test',
|
||||||
sender_name: 'User',
|
sender_name: 'User',
|
||||||
content,
|
content,
|
||||||
timestamp: '2026-04-20T01:00:00.000Z',
|
timestamp,
|
||||||
is_bot_message: false,
|
is_bot_message: false,
|
||||||
is_from_me: false,
|
is_from_me: false,
|
||||||
};
|
};
|
||||||
@@ -134,6 +137,32 @@ describe('message-runtime-prompts output-only context', () => {
|
|||||||
expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨');
|
expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes current task user scope in reviewer pending prompts without pulling older human messages', () => {
|
||||||
|
const prompt = buildReviewerPendingPrompt({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
timezone: 'UTC',
|
||||||
|
turnOutputs: [
|
||||||
|
makeTurnOutput('TASK_DONE\n현재 owner 결과', 'owner', {
|
||||||
|
turn_number: 1,
|
||||||
|
created_at: '2026-04-20T02:10:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
recentHumanMessages: [
|
||||||
|
makeHumanMessage('이전 작업의 gap 요청', '2026-04-20T01:50:00.000Z'),
|
||||||
|
makeHumanMessage(
|
||||||
|
'뒤쪽 트레일 주변 디졸브 넣어줘',
|
||||||
|
'2026-04-20T02:00:01.000Z',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lastHumanMessage: '뒤쪽 트레일 주변 디졸브 넣어줘',
|
||||||
|
taskCreatedAt: '2026-04-20T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('뒤쪽 트레일 주변 디졸브 넣어줘');
|
||||||
|
expect(prompt).toContain('현재 owner 결과');
|
||||||
|
expect(prompt).not.toContain('이전 작업의 gap 요청');
|
||||||
|
});
|
||||||
|
|
||||||
it('prepends a carry-forward warning to owner pending prompts', () => {
|
it('prepends a carry-forward warning to owner pending prompts', () => {
|
||||||
const prompt = buildOwnerPendingPrompt({
|
const prompt = buildOwnerPendingPrompt({
|
||||||
chatJid: 'group@test',
|
chatJid: 'group@test',
|
||||||
@@ -175,6 +204,38 @@ describe('message-runtime-prompts output-only context', () => {
|
|||||||
expect(prompt).not.toContain('이전 작업의 사용자 메시지');
|
expect(prompt).not.toContain('이전 작업의 사용자 메시지');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes current task user scope in owner pending prompts without pulling older human messages', () => {
|
||||||
|
const prompt = buildOwnerPendingPrompt({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
timezone: 'UTC',
|
||||||
|
turnOutputs: [
|
||||||
|
makeTurnOutput('DONE_WITH_CONCERNS\n현재 reviewer 피드백', 'reviewer', {
|
||||||
|
id: 2,
|
||||||
|
turn_number: 2,
|
||||||
|
created_at: '2026-04-20T02:11:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
recentHumanMessages: [
|
||||||
|
makeHumanMessage(
|
||||||
|
'이전 작업의 사용자 메시지',
|
||||||
|
'2026-04-20T01:50:00.000Z',
|
||||||
|
),
|
||||||
|
makeHumanMessage(
|
||||||
|
'현재 task에서 추가한 조건',
|
||||||
|
'2026-04-20T02:00:01.000Z',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lastHumanMessage: '현재 task에서 추가한 조건',
|
||||||
|
taskCreatedAt: '2026-04-20T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('현재 task에서 추가한 조건');
|
||||||
|
expect(prompt).toContain('현재 reviewer 피드백');
|
||||||
|
expect(prompt).not.toContain('이전 작업의 사용자 메시지');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message-runtime-prompts arbiter output context', () => {
|
||||||
it('keeps arbiter prompts output-only when turn outputs exist', () => {
|
it('keeps arbiter prompts output-only when turn outputs exist', () => {
|
||||||
const prompt = buildArbiterPromptForTask({
|
const prompt = buildArbiterPromptForTask({
|
||||||
task: makeTask({ status: 'arbiter_requested' }),
|
task: makeTask({ status: 'arbiter_requested' }),
|
||||||
@@ -189,8 +250,12 @@ describe('message-runtime-prompts output-only context', () => {
|
|||||||
turn_number: 2,
|
turn_number: 2,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
recentMessages: [makeHumanMessage('예전 유저 지시')],
|
recentMessages: [
|
||||||
labeledRecentMessages: [makeHumanMessage('예전 유저 지시')],
|
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
|
||||||
|
],
|
||||||
|
labeledRecentMessages: [
|
||||||
|
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain('owner 주장');
|
expect(prompt).toContain('owner 주장');
|
||||||
@@ -215,8 +280,12 @@ describe('message-runtime-prompts output-only context', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
recentMessages: [makeHumanMessage('예전 유저 지시')],
|
recentMessages: [
|
||||||
labeledRecentMessages: [makeHumanMessage('예전 유저 지시')],
|
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
|
||||||
|
],
|
||||||
|
labeledRecentMessages: [
|
||||||
|
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).not.toContain('arbiter-context-output-01');
|
expect(prompt).not.toContain('arbiter-context-output-01');
|
||||||
@@ -226,6 +295,46 @@ describe('message-runtime-prompts output-only context', () => {
|
|||||||
expect(prompt).not.toContain('예전 유저 지시');
|
expect(prompt).not.toContain('예전 유저 지시');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes current task user scope in arbiter prompts while keeping the output cap', () => {
|
||||||
|
const prompt = buildArbiterPromptForTask({
|
||||||
|
task: makeTask({
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
created_at: '2026-04-20T02:00:00.000Z',
|
||||||
|
}),
|
||||||
|
chatJid: 'group@test',
|
||||||
|
timezone: 'UTC',
|
||||||
|
turnOutputs: Array.from({ length: 8 }, (_, index) => {
|
||||||
|
const turnNumber = index + 1;
|
||||||
|
return makeTurnOutput(
|
||||||
|
`arbiter-context-output-${String(turnNumber).padStart(2, '0')}`,
|
||||||
|
turnNumber % 2 === 0 ? 'reviewer' : 'owner',
|
||||||
|
{
|
||||||
|
id: turnNumber,
|
||||||
|
turn_number: turnNumber,
|
||||||
|
created_at: `2026-04-20T02:${String(turnNumber).padStart(2, '0')}:00.000Z`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
recentMessages: [
|
||||||
|
makeHumanMessage('이전 작업의 유저 지시', '2026-04-20T01:50:00.000Z'),
|
||||||
|
makeHumanMessage(
|
||||||
|
'현재 task의 트레일 디졸브 요청',
|
||||||
|
'2026-04-20T02:00:01.000Z',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
labeledRecentMessages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('현재 task의 트레일 디졸브 요청');
|
||||||
|
expect(prompt).not.toContain('이전 작업의 유저 지시');
|
||||||
|
expect(prompt).not.toContain('arbiter-context-output-01');
|
||||||
|
expect(prompt).not.toContain('arbiter-context-output-02');
|
||||||
|
expect(prompt).toContain('arbiter-context-output-03');
|
||||||
|
expect(prompt).toContain('arbiter-context-output-08');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message-runtime-prompts prompt hygiene', () => {
|
||||||
it('preserves turn output order instead of timestamp interleaving', () => {
|
it('preserves turn output order instead of timestamp interleaving', () => {
|
||||||
const prompt = buildReviewerPendingPrompt({
|
const prompt = buildReviewerPendingPrompt({
|
||||||
chatJid: 'group@test',
|
chatJid: 'group@test',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note:
|
|||||||
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
|
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
|
||||||
|
|
||||||
const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6;
|
const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6;
|
||||||
|
const TASK_USER_CONTEXT_START_SKEW_MS = 5_000;
|
||||||
|
|
||||||
function turnOutputsToMessages(
|
function turnOutputsToMessages(
|
||||||
outputs: PairedTurnOutput[],
|
outputs: PairedTurnOutput[],
|
||||||
@@ -47,6 +48,23 @@ function latestTurnOutputs(
|
|||||||
return outputs.slice(-limit);
|
return outputs.slice(-limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentTaskHumanMessages(
|
||||||
|
messages: NewMessage[],
|
||||||
|
taskCreatedAt: string | null | undefined,
|
||||||
|
): NewMessage[] {
|
||||||
|
if (!taskCreatedAt) return [];
|
||||||
|
const taskStartMs = Date.parse(taskCreatedAt);
|
||||||
|
if (!Number.isFinite(taskStartMs)) return [];
|
||||||
|
return messages.filter((message) => {
|
||||||
|
if (message.is_bot_message) return false;
|
||||||
|
const messageMs = Date.parse(message.timestamp);
|
||||||
|
return (
|
||||||
|
Number.isFinite(messageMs) &&
|
||||||
|
messageMs >= taskStartMs - TASK_USER_CONTEXT_START_SKEW_MS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean {
|
function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean {
|
||||||
return outputs.some((output) =>
|
return outputs.some((output) =>
|
||||||
output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER),
|
output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER),
|
||||||
@@ -95,9 +113,13 @@ function turnOutputsOnlyPrompt(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
turnOutputs: PairedTurnOutput[],
|
turnOutputs: PairedTurnOutput[],
|
||||||
|
taskHumanMessages: NewMessage[] = [],
|
||||||
): string {
|
): string {
|
||||||
return prependCarriedForwardGuidance(
|
return prependCarriedForwardGuidance(
|
||||||
formatMessages(turnOutputsToMessages(turnOutputs, chatJid), timezone),
|
formatMessages(
|
||||||
|
[...taskHumanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)],
|
||||||
|
timezone,
|
||||||
|
),
|
||||||
turnOutputs,
|
turnOutputs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,9 +130,15 @@ export function buildReviewerPendingPrompt(args: {
|
|||||||
turnOutputs: PairedTurnOutput[];
|
turnOutputs: PairedTurnOutput[];
|
||||||
recentHumanMessages: NewMessage[];
|
recentHumanMessages: NewMessage[];
|
||||||
lastHumanMessage: string | null | undefined;
|
lastHumanMessage: string | null | undefined;
|
||||||
|
taskCreatedAt?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
if (args.turnOutputs.length > 0) {
|
if (args.turnOutputs.length > 0) {
|
||||||
return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs);
|
return turnOutputsOnlyPrompt(
|
||||||
|
args.chatJid,
|
||||||
|
args.timezone,
|
||||||
|
args.turnOutputs,
|
||||||
|
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.lastHumanMessage) {
|
if (!args.lastHumanMessage) {
|
||||||
@@ -126,9 +154,15 @@ export function buildOwnerPendingPrompt(args: {
|
|||||||
turnOutputs: PairedTurnOutput[];
|
turnOutputs: PairedTurnOutput[];
|
||||||
recentHumanMessages: NewMessage[];
|
recentHumanMessages: NewMessage[];
|
||||||
lastHumanMessage: string | null | undefined;
|
lastHumanMessage: string | null | undefined;
|
||||||
|
taskCreatedAt?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
if (args.turnOutputs.length > 0) {
|
if (args.turnOutputs.length > 0) {
|
||||||
return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs);
|
return turnOutputsOnlyPrompt(
|
||||||
|
args.chatJid,
|
||||||
|
args.timezone,
|
||||||
|
args.turnOutputs,
|
||||||
|
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.lastHumanMessage) {
|
if (!args.lastHumanMessage) {
|
||||||
@@ -146,15 +180,22 @@ export function buildArbiterPromptForTask(args: {
|
|||||||
recentMessages: NewMessage[];
|
recentMessages: NewMessage[];
|
||||||
labeledRecentMessages: NewMessage[];
|
labeledRecentMessages: NewMessage[];
|
||||||
}): string {
|
}): string {
|
||||||
|
const taskHumanMessages = currentTaskHumanMessages(
|
||||||
|
args.recentMessages,
|
||||||
|
args.task.created_at,
|
||||||
|
);
|
||||||
const messages =
|
const messages =
|
||||||
args.turnOutputs.length > 0
|
args.turnOutputs.length > 0
|
||||||
? turnOutputsToMessages(
|
? [
|
||||||
latestTurnOutputs(
|
...taskHumanMessages,
|
||||||
args.turnOutputs,
|
...turnOutputsToMessages(
|
||||||
ARBITER_TURN_OUTPUT_CONTEXT_LIMIT,
|
latestTurnOutputs(
|
||||||
|
args.turnOutputs,
|
||||||
|
ARBITER_TURN_OUTPUT_CONTEXT_LIMIT,
|
||||||
|
),
|
||||||
|
args.chatJid,
|
||||||
),
|
),
|
||||||
args.chatJid,
|
]
|
||||||
)
|
|
||||||
: args.labeledRecentMessages;
|
: args.labeledRecentMessages;
|
||||||
|
|
||||||
return buildArbiterContextPrompt({
|
return buildArbiterContextPrompt({
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
|||||||
});
|
});
|
||||||
return ownership.state !== 'inactive';
|
return ownership.state !== 'inactive';
|
||||||
},
|
},
|
||||||
|
getCloseReason: () =>
|
||||||
|
deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null,
|
||||||
deliverFinalText: async (text, options) => {
|
deliverFinalText: async (text, options) => {
|
||||||
try {
|
try {
|
||||||
return await deps.deliverFinalText({
|
return await deps.deliverFinalText({
|
||||||
|
|||||||
@@ -803,3 +803,53 @@ describe('MessageTurnController outbound audit logging', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MessageTurnController human interruptions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not replay owner progress as final when a human message interrupted the turn', 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-human-interrupted',
|
||||||
|
channel,
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
failureFinalText: '실패',
|
||||||
|
isClaudeCodeAgent: true,
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
deliverFinalText,
|
||||||
|
getCloseReason: () => 'human-message-detected',
|
||||||
|
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: '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).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 type { PairedTurnIdentity } from './paired-turn-identity.js';
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
|
import { isHumanMessageCloseReason } from './message-close-reasons.js';
|
||||||
import {
|
import {
|
||||||
normalizeAgentOutputPhase,
|
normalizeAgentOutputPhase,
|
||||||
toVisiblePhase,
|
toVisiblePhase,
|
||||||
@@ -45,6 +46,7 @@ interface MessageTurnControllerOptions {
|
|||||||
},
|
},
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
canDeliverFinalText?: () => boolean;
|
canDeliverFinalText?: () => boolean;
|
||||||
|
getCloseReason?: () => string | null;
|
||||||
allowProgressReplayWithoutFinal?: boolean;
|
allowProgressReplayWithoutFinal?: boolean;
|
||||||
deliveryRole?: PairedRoomRole | null;
|
deliveryRole?: PairedRoomRole | null;
|
||||||
deliveryServiceId?: string | null;
|
deliveryServiceId?: string | null;
|
||||||
@@ -350,11 +352,9 @@ export class MessageTurnController {
|
|||||||
visiblePhase: VisiblePhase;
|
visiblePhase: VisiblePhase;
|
||||||
}> {
|
}> {
|
||||||
await this.deactivateTyping('turn:finish', { outputStatus });
|
await this.deactivateTyping('turn:finish', { outputStatus });
|
||||||
|
|
||||||
if (outputStatus === 'error') {
|
if (outputStatus === 'error') {
|
||||||
this.hadError = true;
|
this.hadError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
outputStatus === 'success' &&
|
outputStatus === 'success' &&
|
||||||
this.visiblePhase === 'progress' &&
|
this.visiblePhase === 'progress' &&
|
||||||
@@ -362,7 +362,9 @@ export class MessageTurnController {
|
|||||||
this.latestProgressTextForFinal
|
this.latestProgressTextForFinal
|
||||||
) {
|
) {
|
||||||
const replayText = this.latestProgressTextForFinal;
|
const replayText = this.latestProgressTextForFinal;
|
||||||
if (this.options.allowProgressReplayWithoutFinal !== false) {
|
if (isHumanMessageCloseReason(this.options.getCloseReason?.() ?? null)) {
|
||||||
|
this.resetProgressState();
|
||||||
|
} else if (this.options.allowProgressReplayWithoutFinal !== false) {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
'Sending a separate final message from the last progress output after agent completion',
|
'Sending a separate final message from the last progress output after agent completion',
|
||||||
);
|
);
|
||||||
@@ -787,13 +789,11 @@ export class MessageTurnController {
|
|||||||
}
|
}
|
||||||
await this.publishTerminalText(this.options.failureFinalText);
|
await this.publishTerminalText(this.options.failureFinalText);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requestAgentClose(reason: string): void {
|
private requestAgentClose(reason: string): void {
|
||||||
if (this.closeRequested) return;
|
if (this.closeRequested) return;
|
||||||
this.closeRequested = true;
|
this.closeRequested = true;
|
||||||
this.options.requestClose(reason);
|
this.options.requestClose(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendProgressMessage(text: string): Promise<void> {
|
private async sendProgressMessage(text: string): Promise<void> {
|
||||||
if (
|
if (
|
||||||
this.options.canDeliverFinalText &&
|
this.options.canDeliverFinalText &&
|
||||||
|
|||||||
Reference in New Issue
Block a user