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 <internal>-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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-13 14:37:42 +09:00
parent ee4c5559b1
commit 80ddb1aa0d
2 changed files with 89 additions and 2 deletions

View File

@@ -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<string, unknown>) => Promise<void>,
) => {
// 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>) => {
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({

View File

@@ -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 (