fix(unstable-agent-babysitter): respect active sessions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||||
import type { BackgroundTask } from "../../features/background-agent"
|
import type { BackgroundTask } from "../../features/background-agent"
|
||||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
import { createUnstableAgentBabysitterHook } from "./index"
|
import { createUnstableAgentBabysitterHook } from "./index"
|
||||||
|
|
||||||
const projectDir = process.cwd()
|
const projectDir = process.cwd()
|
||||||
@@ -49,7 +50,7 @@ function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
|||||||
status: "running",
|
status: "running",
|
||||||
progress: {
|
progress: {
|
||||||
toolCalls: 1,
|
toolCalls: 1,
|
||||||
lastUpdate: new Date(),
|
lastUpdate: new Date(Date.now() - 121000),
|
||||||
lastMessage: "still working",
|
lastMessage: "still working",
|
||||||
lastMessageAt: new Date(Date.now() - 121000),
|
lastMessageAt: new Date(Date.now() - 121000),
|
||||||
},
|
},
|
||||||
@@ -61,6 +62,7 @@ function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
|||||||
describe("unstable-agent-babysitter hook", () => {
|
describe("unstable-agent-babysitter hook", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("settles idle before injecting a reminder", async () => {
|
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?.model).toEqual({ providerID: "openai", modelID: "gpt-4" })
|
||||||
expect(payload.body?.variant).toBe("max")
|
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)")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isAbortError } from "../../shared/is-abort-error"
|
|||||||
import {
|
import {
|
||||||
buildReminder,
|
buildReminder,
|
||||||
extractMessages,
|
extractMessages,
|
||||||
|
getMessageCreatedAt,
|
||||||
getMessageInfo,
|
getMessageInfo,
|
||||||
getMessageParts,
|
getMessageParts,
|
||||||
isUnstableTask,
|
isUnstableTask,
|
||||||
@@ -17,6 +18,7 @@ import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
|||||||
const HOOK_NAME = "unstable-agent-babysitter"
|
const HOOK_NAME = "unstable-agent-babysitter"
|
||||||
const DEFAULT_TIMEOUT_MS = 120000
|
const DEFAULT_TIMEOUT_MS = 120000
|
||||||
const COOLDOWN_MS = 5 * 60 * 1000
|
const COOLDOWN_MS = 5 * 60 * 1000
|
||||||
|
const USER_MESSAGE_IN_PROGRESS_WINDOW_MS = 2000
|
||||||
|
|
||||||
type BabysittingConfig = {
|
type BabysittingConfig = {
|
||||||
timeout_ms?: number
|
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<boolean> {
|
||||||
|
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) {
|
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
||||||
const reminderCooldowns = new Map<string, number>()
|
const reminderCooldowns = new Map<string, number>()
|
||||||
const cancelledSessions = new Set<string>()
|
const cancelledSessions = new Set<string>()
|
||||||
@@ -134,7 +168,6 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
if (!sessionID || !isAbortError(props?.error)) return
|
if (!sessionID || !isAbortError(props?.error)) return
|
||||||
|
|
||||||
cancelledSessions.add(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
reminderCooldowns.clear()
|
|
||||||
log(`[${HOOK_NAME}] Marked session cancelled`, { sessionID })
|
log(`[${HOOK_NAME}] Marked session cancelled`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,7 +177,6 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
cancelledSessions.add(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
reminderCooldowns.clear()
|
|
||||||
log(`[${HOOK_NAME}] Marked session cancelled via session.stop`, { sessionID })
|
log(`[${HOOK_NAME}] Marked session cancelled via session.stop`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,15 +225,19 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
|
|
||||||
const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS
|
const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS
|
||||||
const now = Date.now()
|
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) {
|
for (const task of tasks) {
|
||||||
if (task.status !== "running") continue
|
if (task.status !== "running") continue
|
||||||
if (!isUnstableTask(task)) continue
|
if (!isUnstableTask(task)) continue
|
||||||
|
|
||||||
const lastMessageAt = task.progress?.lastMessageAt
|
const lastActivityAt = getTaskLastActivityAt(task)
|
||||||
if (!lastMessageAt) continue
|
if (!lastActivityAt) continue
|
||||||
|
|
||||||
const idleMs = now - lastMessageAt.getTime()
|
const idleMs = now - lastActivityAt.getTime()
|
||||||
if (idleMs < timeoutMs) continue
|
if (idleMs < timeoutMs) continue
|
||||||
|
|
||||||
const lastReminderAt = reminderCooldowns.get(task.id)
|
const lastReminderAt = reminderCooldowns.get(task.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user