diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 14acf25..3858412 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -181,11 +181,17 @@ function makeDeps() { describe('runAgentForGroup room memory', () => { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({ - status: 'success', - result: 'ok', - newSessionId: 'session-123', - }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: 'ok', + output: { visibility: 'public', text: 'ok' }, + phase: 'final', + }); + return { status: 'success', result: 'ok', newSessionId: 'session-123' }; + }, + ); vi.mocked(buildRoomMemoryBriefing).mockResolvedValue( '## Shared Room Memory\n- remembered context', ); @@ -461,12 +467,15 @@ describe('runAgentForGroup room memory', () => { EJCLAW_PAIRED_ROLE: 'owner', }), ); + // Owner produced no visible output (mock doesn't go through streamed + // evaluator) → treated as interrupted, status is 'failed' to prevent + // auto-triggering the reviewer. expect( pairedExecutionContext.completePairedExecutionContext, ).toHaveBeenCalledWith({ taskId: 'paired-task-1', role: 'owner', - status: 'succeeded', + status: 'failed', summary: 'ok', }); }); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 3bafec1..28550c3 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -227,6 +227,7 @@ export async function runAgentForGroup( let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionSummary: string | null = null; let pairedExecutionCompleted = false; + let pairedSawOutput = false; const shouldHandoffToCodex = ( reason: AgentTriggerReason, @@ -708,6 +709,7 @@ export async function runAgentForGroup( switch (outcome.type) { case 'success': + pairedSawOutput = outcome.sawOutput; return 'success'; case 'error': return 'error'; @@ -998,21 +1000,35 @@ export async function runAgentForGroup( } pairedExecutionStatus = 'succeeded'; + pairedSawOutput = primaryAttempt.sawOutput; return 'success'; } finally { if (pairedExecutionContext && !pairedExecutionCompleted) { const completedRole = roomRoleContext?.role ?? 'owner'; + // Owner was interrupted without producing output (e.g. /stop) — + // treat as failed so reviewer is not auto-triggered. + const effectiveStatus = + completedRole === 'owner' && + pairedExecutionStatus === 'succeeded' && + !pairedSawOutput + ? 'failed' + : pairedExecutionStatus; completePairedExecutionContext({ taskId: pairedExecutionContext.task.id, role: completedRole, - status: pairedExecutionStatus, + status: effectiveStatus, summary: pairedExecutionSummary, }); } // After owner/reviewer completes, enqueue the next turn so // the message loop picks it up without waiting for a new message. - if (pairedExecutionContext && pairedExecutionStatus === 'succeeded') { + // Skip if owner produced no output — likely interrupted by /stop. + if ( + pairedExecutionContext && + pairedExecutionStatus === 'succeeded' && + pairedSawOutput + ) { deps.queue.enqueueMessageCheck(chatJid); } } diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 6a74717..798dbdb 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -147,6 +147,7 @@ export class MessageTurnController { this.previousProgressText = this.latestProgressText; this.latestProgressText = text; this.latestProgressTextForFinal = text; + this.pendingProgressText = null; // discard stale buffer this.toolActivities = []; void this.syncTrackedProgressMessage(); } else { diff --git a/src/provider-retry.test.ts b/src/provider-retry.test.ts index 1b40f49..53c9efc 100644 --- a/src/provider-retry.test.ts +++ b/src/provider-retry.test.ts @@ -41,7 +41,7 @@ describe('runClaudeRotationLoop', () => { { runId: 'rotate-org-access' }, ); - expect(outcome).toEqual({ type: 'success' }); + expect(outcome).toEqual({ type: 'success', sawOutput: true }); expect(rotateToken).toHaveBeenCalledTimes(1); expect(markTokenHealthy).toHaveBeenCalledTimes(1); }); diff --git a/src/provider-retry.ts b/src/provider-retry.ts index 1b21fcb..da6c783 100644 --- a/src/provider-retry.ts +++ b/src/provider-retry.ts @@ -37,7 +37,7 @@ export interface RotationAttemptResult { } export type RotationOutcome = - | { type: 'success' } + | { type: 'success'; sawOutput: boolean } | { type: 'error'; trigger?: TriggerInfo }; // ── Shared rotation loop ───────────────────────────────────────── @@ -161,7 +161,7 @@ export async function runClaudeRotationLoop( // ── Success ── markTokenHealthy(); clearGlobalFailover(); - return { type: 'success' }; + return { type: 'success', sawOutput: attempt.sawOutput }; } // ── All tokens exhausted ── diff --git a/src/session-commands.ts b/src/session-commands.ts index a6d05c5..794551a 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -166,6 +166,7 @@ export async function handleSessionCommand(opts: { if (command === '/stop') { const killed = deps.killProcess(); + deps.resetPairedTask?.(); deps.advanceCursor(cmdMsg.timestamp); await deps.sendMessage( killed ? 'Agent stopped.' : 'No agent is currently running in this room.',