From 1ea4dfe215fe2d73ac9b72ae0aff5c823144f387 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 12:55:36 +0900 Subject: [PATCH] fix(hooks): settle idle prompt continuations --- src/hooks/atlas/idle-event.ts | 3 ++ src/hooks/atlas/index.test.ts | 35 +++++++++++++++++++ src/hooks/atlas/types.ts | 1 + src/hooks/shared/session-idle-settle.ts | 5 +++ .../team-idle-wake-hint.test.ts | 32 +++++++++++++++++ .../team-idle-wake-hint.ts | 5 ++- .../unstable-agent-babysitter/index.test.ts | 35 +++++++++++++++++++ .../unstable-agent-babysitter-hook.ts | 3 ++ 8 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/hooks/shared/session-idle-settle.ts diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 26d38642d..22a755468 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -11,6 +11,7 @@ import { getLastAgentFromSession } from "./session-last-agent" import { isSessionInBoulderLineage } from "./boulder-session-lineage" import { getAgentConfigKey } from "../../shared/agent-display-names" import { log } from "../../shared/logger" +import { settleAfterSessionIdle } from "../shared/session-idle-settle" import { injectBoulderContinuation } from "./boulder-continuation-injector" import { HOOK_NAME } from "./hook-name" import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" @@ -302,6 +303,8 @@ export async function handleAtlasSessionIdle(input: { return } + await settleAfterSessionIdle(options?.idleSettleMs) + await injectContinuation({ ctx, sessionID, diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 44f432831..412cc9631 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -68,6 +68,7 @@ describe("atlas hook", () => { ): ReturnType { const resolvedOptions: AtlasHookOptions = { directory: TEST_DIR, + idleSettleMs: 0, isCallerOrchestrator: async (sessionID) => callerAgentBySession.get(sessionID ?? "") === "atlas", ...options, } @@ -1346,6 +1347,40 @@ session_id: ses_untrusted_999 expect(callArgs.body.parts[0].text).toContain("2 remaining") }) + test("should settle idle before injecting boulder continuation", async () => { + // given + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput, { idleSettleMs: 50 }) + + // when + const startedAt = Date.now() + const eventPromise = hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + await Promise.resolve() + + // then + expect(mockInput._promptMock).not.toHaveBeenCalled() + + await eventPromise + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45) + expect(mockInput._promptMock).toHaveBeenCalledTimes(1) + }) + test("should not inject when no boulder state exists", async () => { // given - no boulder state const mockInput = createMockPluginInput() diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 84955884e..8b39867e8 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -13,6 +13,7 @@ export interface AtlasHookOptions { isContinuationStopped?: (sessionID: string) => boolean isCallerOrchestrator?: (sessionID: string | undefined) => Promise agentOverrides?: AgentOverrides + idleSettleMs?: number /** Enable auto-commit after each atomic task completion (default: true) */ autoCommit?: boolean } diff --git a/src/hooks/shared/session-idle-settle.ts b/src/hooks/shared/session-idle-settle.ts new file mode 100644 index 000000000..c76d2955f --- /dev/null +++ b/src/hooks/shared/session-idle-settle.ts @@ -0,0 +1,5 @@ +export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150 + +export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise { + return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve() +} diff --git a/src/hooks/team-session-events/team-idle-wake-hint.test.ts b/src/hooks/team-session-events/team-idle-wake-hint.test.ts index cf9c64e7e..1d5d0370e 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.test.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.test.ts @@ -113,6 +113,38 @@ afterEach(async () => { }) describe("createTeamIdleWakeHint", () => { + test("settles idle before sending the wake hint", async () => { + // given + const baseDir = await createTemporaryBaseDir() + const config = createConfig(baseDir) + const teamRunId = randomUUID() + await seedRuntimeState(createRuntimeState(teamRunId), config) + await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100) + + const promptAsyncSpy = mock(async (_input: WakeHintPromptInput) => ({})) + const handler = createTeamIdleWakeHint({ + directory: "/tmp/project", + client: { session: { promptAsync: promptAsyncSpy } }, + }, config, { idleSettleMs: 50 }) + + // when + const startedAt = Date.now() + const eventPromise = handler({ + event: { + type: "session.idle", + properties: { sessionID: "member-session" }, + }, + }) + await Promise.resolve() + + // then + expect(promptAsyncSpy).not.toHaveBeenCalled() + + await eventPromise + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45) + expect(promptAsyncSpy).toHaveBeenCalledTimes(1) + }) + test("sends a trigger-only wake hint when new unread mail exists", async () => { // given const baseDir = await createTemporaryBaseDir() diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index c7e013a9a..0bb99ab23 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -8,6 +8,7 @@ import { buildMemberPromptBody, } from "../../features/team-mode/member-session-routing" import { log } from "../../shared/logger" +import { settleAfterSessionIdle } from "../shared/session-idle-settle" type PromptAsyncInput = { path: { id: string } @@ -31,6 +32,7 @@ type TeamIdleWakeHintContext = { type HookInput = { event: { type: string; properties?: unknown } } export type HookImpl = (input: HookInput) => Promise +type TeamIdleWakeHintOptions = { idleSettleMs?: number } function getIdleSessionID(properties: unknown): string | undefined { const record = properties as { sessionID?: string } | undefined @@ -41,7 +43,7 @@ function buildWakeHint(unreadCount: number): string { return `You have ${unreadCount} new team messages. They will be injected on your next turn.` } -export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig): HookImpl { +export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig, options?: TeamIdleWakeHintOptions): HookImpl { return async ({ event }: HookInput): Promise => { if (event.type !== "session.idle") return @@ -97,6 +99,7 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea } applyMemberSessionRouting(sessionID, memberEntry) + await settleAfterSessionIdle(options?.idleSettleMs) await ctx.client.session.promptAsync({ path: { id: sessionID }, diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index d2f89abb4..558003643 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -63,6 +63,41 @@ describe("unstable-agent-babysitter hook", () => { _resetForTesting() }) + test("settles idle before injecting a reminder", async () => { + // #given + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { + "main-1": [ + { info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } }, + ], + "bg-1": [ + { info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] }, + ], + }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask()]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + idleSettleMs: 50, + }) + + // #when + const startedAt = Date.now() + const eventPromise = hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + await Promise.resolve() + + // #then + expect(promptCalls.length).toBe(0) + + await eventPromise + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45) + expect(promptCalls.length).toBe(1) + }) + test("fires reminder for hung gemini task", async () => { // #given setMainSession("main-1") diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 1bceb8650..c5168759d 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -11,6 +11,7 @@ import { isUnstableTask, THINKING_SUMMARY_MAX_CHARS, } from "./task-message-analyzer" +import { settleAfterSessionIdle } from "../shared/session-idle-settle" const HOOK_NAME = "unstable-agent-babysitter" const DEFAULT_TIMEOUT_MS = 120000 @@ -54,6 +55,7 @@ type BabysitterContext = { type BabysitterOptions = { backgroundManager: Pick config?: BabysittingConfig + idleSettleMs?: number } @@ -212,6 +214,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option ? { providerID: model.providerID, modelID: model.modelID } : undefined const launchVariant = model?.variant + await settleAfterSessionIdle(options.idleSettleMs) await ctx.client.session.promptAsync({ path: { id: mainSessionID },