diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts b/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts
new file mode 100644
index 000000000..732497137
--- /dev/null
+++ b/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts
@@ -0,0 +1,90 @@
+///
+
+import { describe, expect, it } from "bun:test"
+
+import { observeEventForWatchdog, type FirstPromptWatchdog } from "./first-prompt-watchdog"
+
+interface RecordedWatchdogCalls {
+ readonly user: string[]
+ readonly progress: string[]
+ readonly terminal: string[]
+}
+
+function createRecordingWatchdog(calls: RecordedWatchdogCalls): FirstPromptWatchdog {
+ return {
+ onUserMessage(sessionID) {
+ calls.user.push(sessionID)
+ },
+ onAssistantProgress(sessionID) {
+ calls.progress.push(sessionID)
+ },
+ onSessionTerminal(sessionID) {
+ calls.terminal.push(sessionID)
+ },
+ dispose() {},
+ }
+}
+
+function freshCalls(): RecordedWatchdogCalls {
+ return { user: [], progress: [], terminal: [] }
+}
+
+describe("observeEventForWatchdog progress markers", () => {
+ const sessionID = "session-observed-progress"
+
+ for (const [label, marker] of [
+ ["finished boolean", { finished: true }],
+ ["completed boolean", { completed: true }],
+ ["completed time", { time: { completed: 1779267000000 } }],
+ ] as const) {
+ it(`#given a message.updated assistant event with ${label} #when observed #then onAssistantProgress is called`, () => {
+ const calls = freshCalls()
+ observeEventForWatchdog(
+ {
+ type: "message.updated",
+ properties: { info: { sessionID, role: "assistant", ...marker } },
+ },
+ createRecordingWatchdog(calls),
+ )
+ expect(calls.progress).toEqual([sessionID])
+ })
+ }
+
+ it("#given a message.updated assistant event with false completion markers and no parts #when observed #then no progress is signalled", () => {
+ const calls = freshCalls()
+ observeEventForWatchdog(
+ {
+ type: "message.updated",
+ properties: {
+ info: {
+ sessionID,
+ role: "assistant",
+ completed: false,
+ finish: false,
+ finished: false,
+ },
+ parts: [],
+ },
+ },
+ createRecordingWatchdog(calls),
+ )
+ expect(calls.progress).toEqual([])
+ })
+
+ for (const [eventType] of [
+ ["session.next.text.delta"],
+ ["session.next.reasoning.delta"],
+ ["session.next.tool.called"],
+ ["session.next.tool.success"],
+ ["session.next.step.ended"],
+ ] as const) {
+ it(`#given a ${eventType} event with a sessionID #when observed #then onAssistantProgress is called`, () => {
+ const calls = freshCalls()
+ observeEventForWatchdog(
+ { type: eventType, properties: { sessionID } },
+ createRecordingWatchdog(calls),
+ )
+ expect(calls.progress).toEqual([sessionID])
+ })
+ }
+})
diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.ts
index 7ee8f2265..72ff2230d 100644
--- a/src/hooks/runtime-fallback/first-prompt-watchdog.ts
+++ b/src/hooks/runtime-fallback/first-prompt-watchdog.ts
@@ -11,6 +11,7 @@ import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
const SOURCE = "first-prompt-watchdog"
+const SESSION_NEXT_EVENT_PREFIX = "session.next."
declare function setTimeout(callback: () => void | Promise, delay?: number): RuntimeFallbackTimeout
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
@@ -29,6 +30,19 @@ const TERMINAL_EVENT_TYPES = new Set([
"session.error",
])
+function isCompletionMarker(value: unknown): boolean {
+ if (typeof value === "boolean") return value
+ return value !== undefined && value !== null
+}
+
+function hasAssistantCompletionMarker(info: Record): boolean {
+ const time = isRecord(info.time) ? info.time : undefined
+ return isCompletionMarker(info.finish)
+ || isCompletionMarker(info.finished)
+ || isCompletionMarker(info.completed)
+ || isCompletionMarker(time?.completed)
+}
+
/**
* Translate an OpenCode session event into the appropriate watchdog signal.
*
@@ -48,6 +62,12 @@ export function observeEventForWatchdog(
const props = isRecord(event.properties) ? event.properties : undefined
if (!props) return
+ if (event.type.startsWith(SESSION_NEXT_EVENT_PREFIX)) {
+ const sessionID = resolveSessionEventID(props) ?? resolveMessageEventSessionID(props)
+ if (sessionID) watchdog.onAssistantProgress(sessionID)
+ return
+ }
+
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
const sessionID = resolveMessageEventSessionID(props)
const part = isRecord(props.part) ? props.part : undefined
@@ -63,6 +83,7 @@ export function observeEventForWatchdog(
if (event.type === "message.updated") {
const info = isRecord(props.info) ? props.info : undefined
+ if (!info) return
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
const role = typeof info?.role === "string" ? info.role : undefined
if (!sessionID || !role) return
@@ -76,7 +97,7 @@ export function observeEventForWatchdog(
if (role === "assistant") {
const hasError = info?.error !== undefined
- const hasFinish = info?.finish !== undefined
+ const hasFinish = hasAssistantCompletionMarker(info)
const eventParts = Array.isArray(props.parts) ? props.parts : undefined
const infoParts = Array.isArray(info?.parts) ? info.parts : undefined
const parts = eventParts ?? infoParts ?? []