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 (