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:
YeonGyu-Kim
2026-05-17 13:57:12 +09:00
parent 75223149dd
commit f4f1efcb6f
3 changed files with 251 additions and 0 deletions
@@ -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) {