fix(server): steer scheduled system prompts

Finish notifications and agent-target schedules now use the same steering-first delivery path as user prompts. Scheduling still decides whether a busy target should be prompted before delivery begins.
This commit is contained in:
Mohamed Boudra
2026-08-18 14:36:27 +02:00
parent ce9026f264
commit 05ee7a1d31
4 changed files with 81 additions and 14 deletions
@@ -53,6 +53,7 @@ interface FinishNotificationScenario {
finishChildAndReadParentPrompt(): Promise<string>; finishChildAndReadParentPrompt(): Promise<string>;
closeChildAndReadParentPrompt(): Promise<string>; closeChildAndReadParentPrompt(): Promise<string>;
parentPrompts(): string[]; parentPrompts(): string[];
steerAttemptCount(): number;
wasParentPrompted(): boolean; wasParentPrompted(): boolean;
} }
@@ -62,6 +63,7 @@ function createFinishNotificationScenario(
let subscriber: ((event: AgentManagerEvent) => void) | null = null; let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null; let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false; let parentPrompted = false;
let steerAttemptCount = 0;
const parentPrompts: string[] = []; const parentPrompts: string[] = [];
const childAgent: ManagedAgent = Object.create(null); const childAgent: ManagedAgent = Object.create(null);
@@ -96,6 +98,10 @@ function createFinishNotificationScenario(
}); });
Reflect.set(agentManager, "tryRunOutOfBand", () => false); Reflect.set(agentManager, "tryRunOutOfBand", () => false);
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "steerOrReplaceActiveTurn", async () => {
steerAttemptCount += 1;
return { status: "inactive" };
});
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true; parentPrompted = true;
parentPrompts.push(prompt); parentPrompts.push(prompt);
@@ -233,6 +239,9 @@ function createFinishNotificationScenario(
parentPrompts() { parentPrompts() {
return parentPrompts; return parentPrompts;
}, },
steerAttemptCount() {
return steerAttemptCount;
},
wasParentPrompted() { wasParentPrompted() {
return parentPrompted; return parentPrompted;
}, },
@@ -257,6 +266,7 @@ test("finish notifications tell the parent the child's last assistant message",
"Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>", "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>",
), ),
); );
expect(scenario.steerAttemptCount()).toBe(1);
}); });
test("finish notifications truncate oversized child responses", async () => { test("finish notifications truncate oversized child responses", async () => {
@@ -402,6 +402,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
agentStorage, agentStorage,
agentId: callerAgentId, agentId: callerAgentId,
prompt: formatSystemNotificationPrompt(body), prompt: formatSystemNotificationPrompt(body),
activeTurnBehavior: "steer",
unarchive: false, unarchive: false,
logger, logger,
}); });
@@ -463,6 +463,40 @@ describe("ScheduleService", () => {
); );
}); });
test("delivers agent-target schedules through the steer-or-interrupt path", async () => {
const manager = new AgentManager({
logger: createTestLogger(),
clients: createTestAgentClients(),
registry: agentStorage,
});
const agent = await manager.createAgent({ provider: "claude", cwd: tempDir }, undefined, {
workspaceId: undefined,
});
const steerOrReplace = vi.spyOn(manager, "steerOrReplaceActiveTurn");
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const schedule = await service.create({
prompt: "Check scheduled work",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agent.id },
});
await service.runOnce(schedule.id);
expect(steerOrReplace).toHaveBeenCalledTimes(1);
expect(steerOrReplace.mock.calls[0]).toEqual([
agent.id,
expect.stringContaining(`Schedule fired (id=${schedule.id}, run=`),
undefined,
]);
});
test("titles scheduled new agents from the schedule prompt", async () => { test("titles scheduled new agents from the schedule prompt", async () => {
const manager = new AgentManager({ const manager = new AgentManager({
logger: createTestLogger(), logger: createTestLogger(),
+36 -14
View File
@@ -7,7 +7,11 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
import type { AgentStorage } from "../agent/agent-storage.js"; import type { AgentStorage } from "../agent/agent-storage.js";
import { curateAgentActivity } from "../agent/activity-curator.js"; import { curateAgentActivity } from "../agent/activity-curator.js";
import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js";
import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js"; import {
formatSystemNotificationPrompt,
startAgentRun,
type AgentRunController,
} from "../agent/agent-prompt.js";
import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js";
import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js"; import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js";
import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
@@ -196,17 +200,24 @@ function buildRunOutput(params: {
} }
type ScheduleAgentManager = Pick< type ScheduleAgentManager = Pick<
AgentManager, AgentRunController,
| "createAgent"
| "getAgent" | "getAgent"
| "getRegisteredProviderIds" | "tryRunOutOfBand"
| "hasInFlightRun" | "hasInFlightRun"
| "hydrateTimelineFromProvider" | "replaceAgentRun"
| "resumeAgentFromPersistence" | "steerOrReplaceActiveTurn"
| "runAgent" | "streamAgent"
| "waitForAgentEvent" > &
| "waitForAgentClose" Pick<
>; AgentManager,
| "createAgent"
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
| "runAgent"
| "waitForAgentEvent"
| "waitForAgentClose"
>;
interface ScheduleWorkspaceCreateInput { interface ScheduleWorkspaceCreateInput {
cwd: string; cwd: string;
@@ -843,14 +854,25 @@ export class ScheduleService {
if (this.agentManager.hasInFlightRun(agent.id)) { if (this.agentManager.hasInFlightRun(agent.id)) {
throw new Error(`Agent ${agent.id} already has an active run`); throw new Error(`Agent ${agent.id} already has an active run`);
} }
const result = await this.agentManager.runAgent(agent.id, wrappedPrompt); await startAgentRun(this.agentManager, agent.id, wrappedPrompt, this.logger, {
const timelineText = curateAgentActivity(result.timeline); replaceRunning: true,
activeTurnBehavior: "steer",
});
const waitResult = await this.agentManager.waitForAgentEvent(agent.id, {
waitForActive: true,
});
if (waitResult.permission) {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
}
if (waitResult.status === "error") {
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
}
return { return {
agentId: agent.id, agentId: agent.id,
output: buildRunOutput({ output: buildRunOutput({
output: null, output: null,
timelineText, timelineText: "",
finalText: result.finalText, finalText: waitResult.lastMessage ?? "",
}), }),
}; };
} }