From 80ddb1aa0d4bb427a8c348fca192efa1591648ef Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 14:37:42 +0900 Subject: [PATCH] fix(scheduler): forward only final agent output to chat Scheduled tasks only suppressed phase 'progress', so intermediate preambles (e.g. "I'll run the watchdog checks.") leaked to the chat each run while the actual -wrapped result was correctly stripped. The interactive path treats intermediate/tool-activity as silent (toVisiblePhase); align the scheduler to forward only the final message, with error outputs still falling through to rotation/error handling. Co-Authored-By: Claude Opus 4.7 --- src/task-scheduler.test.ts | 78 ++++++++++++++++++++++++++++++++++++++ src/task-scheduler.ts | 13 ++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index ca8a1fd..fbd9fdb 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -541,6 +541,84 @@ describe('task scheduler', () => { expect(enqueueTask.mock.calls[0][1]).toBe('task-group-context'); }); + it('forwards only the final message, not intermediate preambles, for scheduled tasks', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-intermediate-preamble', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'claude-code', + prompt: 'watchdog task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + (runAgentProcessMock as any).mockImplementationOnce( + async ( + _group: unknown, + _input: unknown, + _onProcess: unknown, + onOutput?: (output: Record) => Promise, + ) => { + // Preamble the model emits before running checks — must not leak. + await onOutput?.({ + status: 'success', + phase: 'intermediate', + result: "I'll run the watchdog checks.", + }); + // Tool activity — also silent. + await onOutput?.({ + status: 'success', + phase: 'tool-activity', + result: 'running df -m', + }); + // The actual final answer — this is the only thing that should send. + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'Disk at 80%, please clean up.', + }); + return { status: 'success', result: null }; + }, + ); + + const enqueueTask = vi.fn( + (_groupJid: string, _taskId: string, fn: () => Promise) => { + void fn(); + }, + ); + const sendMessage = vi.fn(async () => {}); + + startSchedulerLoop({ + serviceAgentType: 'claude-code', + roomBindings: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Claude', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'claude-code', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'Disk at 80%, please clean up.', + ); + }); + it('keeps watch_ci tasks on a dedicated queue even in group context', async () => { const dueAt = new Date(Date.now() - 60_000).toISOString(); createTask({ diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 0d54daa..a8a7601 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -45,7 +45,7 @@ import { suspendTask, } from './task-suspension.js'; import { getTaskQueueJid, isGitHubCiTask } from './task-watch-status.js'; -import { ScheduledTask } from './types.js'; +import { normalizeAgentOutputPhase, ScheduledTask } from './types.js'; import { resolveGroupIpcPath } from './group-folder.js'; import { schedulePairedFollowUpOnce } from './paired-follow-up-scheduler.js'; import { @@ -216,7 +216,16 @@ async function runTask( outputText, evaluation, }) => { - if (streamedOutput.phase === 'progress') { + // Only the agent's final message should reach the chat. Progress, + // intermediate (e.g. a preamble like "I'll run the watchdog + // checks."), and tool-activity outputs are silent in the + // interactive path (see toVisiblePhase); the scheduler must honor + // the same contract. Errors still fall through so rotation/error + // handling below can run. + if ( + streamedOutput.status !== 'error' && + normalizeAgentOutputPhase(streamedOutput.phase) !== 'final' + ) { return; } if (