From 0ee45aa6ab8b65ccf1b0b23c5a60f58fe26e7dec Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 15:48:43 +0900 Subject: [PATCH] fix(unstable-agent-babysitter): respect active sessions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../unstable-agent-babysitter/index.test.ts | 281 +++++++++++++++++- .../unstable-agent-babysitter-hook.ts | 46 ++- 2 files changed, 321 insertions(+), 6 deletions(-) diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index 558003643..d90a258f3 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" import type { BackgroundTask } from "../../features/background-agent" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" +import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate" import { createUnstableAgentBabysitterHook } from "./index" const projectDir = process.cwd() @@ -49,7 +50,7 @@ function createTask(overrides: Partial = {}): BackgroundTask { status: "running", progress: { toolCalls: 1, - lastUpdate: new Date(), + lastUpdate: new Date(Date.now() - 121000), lastMessage: "still working", lastMessageAt: new Date(Date.now() - 121000), }, @@ -61,6 +62,7 @@ function createTask(overrides: Partial = {}): BackgroundTask { describe("unstable-agent-babysitter hook", () => { afterEach(() => { _resetForTesting() + releaseAllPromptAsyncReservationsForTesting() }) test("settles idle before injecting a reminder", async () => { @@ -290,4 +292,281 @@ describe("unstable-agent-babysitter hook", () => { expect(payload.body?.model).toEqual({ providerID: "openai", modelID: "gpt-4" }) expect(payload.body?.variant).toBe("max") }) + + test("#given the main session has a fresh user message #when it becomes idle #then babysitter does not inject a reminder", async () => { + // given + const originalNow = Date.now + Date.now = () => 10 * 60 * 1000 + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { + "main-1": [ + { info: { role: "user", time: { created: Date.now() - 1_500 } } }, + ], + "bg-1": [ + { info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] }, + ], + }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 1, + lastUpdate: new Date(0), + lastMessage: "still working", + lastMessageAt: new Date(0), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(0) + } finally { + Date.now = originalNow + } + }) + + test("#given the latest main-session message is assistant output after a fresh user message #when it becomes idle #then babysitter may inject a reminder", async () => { + // given + const originalNow = Date.now + Date.now = () => 10 * 60 * 1000 + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { + "main-1": [ + { info: { role: "user", time: { created: Date.now() - 1_500 } } }, + { info: { role: "assistant", time: { created: Date.now() - 500 }, agent: "sisyphus" } }, + ], + "bg-1": [ + { info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] }, + ], + }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 1, + lastUpdate: new Date(0), + lastMessage: "still working", + lastMessageAt: new Date(0), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(1) + } finally { + Date.now = originalNow + } + }) + + test("#given an unstable task has a fresh progress update after its last message #when the main session idles #then babysitter does not treat it as hung", async () => { + // given + const originalNow = Date.now + Date.now = () => 10 * 60 * 1000 + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 2, + lastUpdate: new Date(Date.now() - 1_000), + lastMessage: "still working", + lastMessageAt: new Date(Date.now() - 5 * 60 * 1000), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(0) + } finally { + Date.now = originalNow + } + }) + + test("#given an unstable task has stale progress and stale last message #when the main session idles #then babysitter still reminds", async () => { + // given + const originalNow = Date.now + Date.now = () => 10 * 60 * 1000 + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 2, + lastUpdate: new Date(Date.now() - 5 * 60 * 1000), + lastMessage: "still working", + lastMessageAt: new Date(Date.now() - 5 * 60 * 1000), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(1) + } finally { + Date.now = originalNow + } + }) + + test("#given a reminder was already sent before an abort #when a user message resumes the session within cooldown #then no duplicate reminder is injected", async () => { + // given + const originalNow = Date.now + let currentNow = 10 * 60 * 1000 + Date.now = () => currentNow + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 1, + lastUpdate: new Date(0), + lastMessage: "still working", + lastMessageAt: new Date(0), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + await hook.event({ event: { type: "session.error", properties: { sessionID: "main-1", error: { name: "AbortError" } } } }) + currentNow += 1_000 + await hook.event({ event: { type: "message.updated", properties: { sessionID: "main-1", info: { role: "user" } } } }) + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(1) + } finally { + Date.now = originalNow + } + }) + + test("#given a reminder was already sent before a stop event #when assistant activity resumes within cooldown #then no duplicate reminder is injected", async () => { + // given + const originalNow = Date.now + let currentNow = 10 * 60 * 1000 + Date.now = () => currentNow + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ + progress: { + toolCalls: 1, + lastUpdate: new Date(0), + lastMessage: "still working", + lastMessageAt: new Date(0), + }, + })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + try { + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + await hook.event({ event: { type: "session.stop", properties: { sessionID: "main-1" } } }) + currentNow += 1_000 + await hook.event({ event: { type: "message.updated", properties: { sessionID: "main-1", info: { role: "assistant" } } } }) + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + expect(promptCalls.length).toBe(1) + } finally { + Date.now = originalNow + } + }) + + test("#given unstable task agent is a config key #when babysitter builds a reminder #then the reminder uses the canonical display name", async () => { + // given + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ agent: "sisyphus" })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + const payload = promptCalls[0]?.input as { body?: { parts?: Array<{ text?: string }> } } | undefined + const text = payload?.body?.parts?.[0]?.text ?? "" + expect(text).toContain("Agent: Sisyphus - Ultraworker") + expect(text).not.toContain("Agent: sisyphus") + }) + + test("#given unstable task agent is a legacy display name #when babysitter builds a reminder #then the reminder uses the current display name", async () => { + // given + setMainSession("main-1") + const promptCalls: Array<{ input: unknown }> = [] + const ctx = createMockPluginInput({ + messagesBySession: { "main-1": [], "bg-1": [] }, + promptCalls, + }) + const backgroundManager = createBackgroundManager([createTask({ agent: "Sisyphus (Ultraworker)" })]) + const hook = createUnstableAgentBabysitterHook(ctx, { + backgroundManager, + config: { timeout_ms: 120000 }, + }) + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } }) + + // then + const payload = promptCalls[0]?.input as { body?: { parts?: Array<{ text?: string }> } } | undefined + const text = payload?.body?.parts?.[0]?.text ?? "" + expect(text).toContain("Agent: Sisyphus - Ultraworker") + expect(text).not.toContain("Agent: Sisyphus (Ultraworker)") + }) }) 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 3d39dee93..fe7806f25 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -7,6 +7,7 @@ import { isAbortError } from "../../shared/is-abort-error" import { buildReminder, extractMessages, + getMessageCreatedAt, getMessageInfo, getMessageParts, isUnstableTask, @@ -17,6 +18,7 @@ import { dispatchInternalPrompt } from "../shared/prompt-async-gate" const HOOK_NAME = "unstable-agent-babysitter" const DEFAULT_TIMEOUT_MS = 120000 const COOLDOWN_MS = 5 * 60 * 1000 +const USER_MESSAGE_IN_PROGRESS_WINDOW_MS = 2000 type BabysittingConfig = { timeout_ms?: number @@ -122,6 +124,38 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr } } +async function latestMainSessionUserMessageIsInProgress(ctx: BabysitterContext, sessionID: string, now: number): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = extractMessages(messagesResp) + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + const role = getMessageInfo(message)?.role + if (role === "user") { + const createdAt = getMessageCreatedAt(message) + return createdAt !== undefined && now - createdAt <= USER_MESSAGE_IN_PROGRESS_WINDOW_MS + } + if (role === "assistant" || role === "tool") { + return false + } + } + return false + } catch (error) { + log(`[${HOOK_NAME}] Failed to inspect recent main session user activity`, { sessionID, error: String(error) }) + return false + } +} + +function getTaskLastActivityAt(task: { progress?: { lastUpdate?: Date; lastMessageAt?: Date } }): Date | undefined { + const lastMessageAt = task.progress?.lastMessageAt + const lastUpdate = task.progress?.lastUpdate + if (!lastMessageAt) return lastUpdate + if (!lastUpdate) return lastMessageAt + return lastUpdate.getTime() > lastMessageAt.getTime() ? lastUpdate : lastMessageAt +} + export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { const reminderCooldowns = new Map() const cancelledSessions = new Set() @@ -134,7 +168,6 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option if (!sessionID || !isAbortError(props?.error)) return cancelledSessions.add(sessionID) - reminderCooldowns.clear() log(`[${HOOK_NAME}] Marked session cancelled`, { sessionID }) return } @@ -144,7 +177,6 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option if (!sessionID) return cancelledSessions.add(sessionID) - reminderCooldowns.clear() log(`[${HOOK_NAME}] Marked session cancelled via session.stop`, { sessionID }) return } @@ -193,15 +225,19 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS const now = Date.now() + if (await latestMainSessionUserMessageIsInProgress(ctx, mainSessionID, now)) { + log(`[${HOOK_NAME}] Skipped reminder: main session has recent user activity`, { sessionID: mainSessionID }) + return + } for (const task of tasks) { if (task.status !== "running") continue if (!isUnstableTask(task)) continue - const lastMessageAt = task.progress?.lastMessageAt - if (!lastMessageAt) continue + const lastActivityAt = getTaskLastActivityAt(task) + if (!lastActivityAt) continue - const idleMs = now - lastMessageAt.getTime() + const idleMs = now - lastActivityAt.getTime() if (idleMs < timeoutMs) continue const lastReminderAt = reminderCooldowns.get(task.id)