fix(call-omo-agent): fail fast on lost prompts
Detect OpenCode promptAsync calls that return before a child session has any durable message, and surface a prompt acceptance error before the generic five-minute sync poll timeout. Add a failing-first regression for the idle zero-message case and keep the existing durable-message completion path covered. Debugging-Journal: .debugging
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
|
||||
function createToolContext(): Parameters<typeof waitForCompletion>[1] {
|
||||
return {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
metadata: mock(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(args: {
|
||||
status: ReturnType<typeof mock>
|
||||
messages: ReturnType<typeof mock>
|
||||
}): Parameters<typeof waitForCompletion>[2] {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
status: args.status,
|
||||
messages: args.messages,
|
||||
},
|
||||
},
|
||||
} as never
|
||||
}
|
||||
|
||||
describe("waitForCompletion", () => {
|
||||
test("#given promptAsync returned before OpenCode saved a user message #when the child session stays idle with zero messages #then it fails as a prompt acceptance error", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
let currentTime = 0
|
||||
Date.now = () => {
|
||||
currentTime += 60_000
|
||||
return currentTime
|
||||
}
|
||||
globalThis.setTimeout = ((handler: TimerHandler) => {
|
||||
if (typeof handler === "function") {
|
||||
handler()
|
||||
}
|
||||
return originalSetTimeout(() => {}, 0)
|
||||
}) as typeof globalThis.setTimeout
|
||||
|
||||
const status = mock(async () => ({ data: { "ses-undurable": { type: "idle" } } }))
|
||||
const messages = mock(async () => ({ data: [] }))
|
||||
|
||||
try {
|
||||
// when
|
||||
const result = waitForCompletion(
|
||||
"ses-undurable",
|
||||
createToolContext(),
|
||||
createContext({ status, messages }),
|
||||
)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("Prompt was not durably accepted by OpenCode")
|
||||
expect(messages).toHaveBeenCalled()
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
})
|
||||
|
||||
test("#given the child session has durable messages #when it stays idle and stable #then completion succeeds", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
let currentTime = 0
|
||||
Date.now = () => {
|
||||
currentTime += 100
|
||||
return currentTime
|
||||
}
|
||||
globalThis.setTimeout = ((handler: TimerHandler) => {
|
||||
if (typeof handler === "function") {
|
||||
handler()
|
||||
}
|
||||
return originalSetTimeout(() => {}, 0)
|
||||
}) as typeof globalThis.setTimeout
|
||||
|
||||
const status = mock(async () => ({ data: { "ses-complete": { type: "idle" } } }))
|
||||
const messages = mock(async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg-user", role: "user" } },
|
||||
{ info: { id: "msg-assistant", role: "assistant" } },
|
||||
],
|
||||
}))
|
||||
|
||||
try {
|
||||
// when
|
||||
await waitForCompletion(
|
||||
"ses-complete",
|
||||
createToolContext(),
|
||||
createContext({ status, messages }),
|
||||
)
|
||||
|
||||
// then
|
||||
expect(messages).toHaveBeenCalled()
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,10 +17,12 @@ export async function waitForCompletion(
|
||||
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max
|
||||
const PROMPT_ACCEPTANCE_TIMEOUT_MS = 30 * 1000
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
const STABILITY_REQUIRED = 3
|
||||
let sawActiveStatus = false
|
||||
|
||||
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
|
||||
if (toolContext.abort?.aborted) {
|
||||
@@ -35,6 +37,7 @@ export async function waitForCompletion(
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
sawActiveStatus = true
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
@@ -46,6 +49,15 @@ export async function waitForCompletion(
|
||||
})
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount === 0) {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
if (!sawActiveStatus && Date.now() - pollStart >= PROMPT_ACCEPTANCE_TIMEOUT_MS) {
|
||||
throw new Error(`Prompt was not durably accepted by OpenCode for session ${sessionID}.`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= STABILITY_REQUIRED) {
|
||||
|
||||
Reference in New Issue
Block a user