fix(runtime-fallback): broaden watchdog progress detection + harden test timing

Addresses two issues identified by cubic on PR #3952.

1. Watchdog cancellation was too narrow — only `text`/`reasoning` parts
   counted as progress, so a subagent that immediately ran tools
   (Read/Bash/Edit) emitted `tool`/`tool_use`/`tool_result`/`tool-call`/
   `step-start` parts that the watchdog ignored, risking a false fire
   on actively-working subagents. Broaden to: any assistant part of any
   known type counts as progress (the model has started responding,
   whether or not visible text has arrived yet). `info.error` and
   `info.finish` continue to cancel.

2. Test timing margins were tight (15ms pre-cancel against a 40ms
   timer), risking CI flakiness on loaded runners. Bumped to a 100ms
   threshold with a 40ms pre-cancel window and a 250ms post-fire wait,
   giving a 60ms margin before the timer fires and ~2.5x the threshold
   after — robust against scheduler delay.

Refactor for testability: extracted the OpenCode-event→watchdog-signal
translation out of `hook.ts` into an exported `observeEventForWatchdog`
helper on the watchdog module. This let me add direct unit tests for
every part-type case (text, reasoning, tool, tool_use, tool_result,
tool-call, step-start, file) plus the error/finish/empty-parts branches
without spinning up the full hook. Net diff: hook.ts shrinks, watchdog
module gains a small pure function with parametrised coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ivan Smetanin
2026-05-11 18:47:40 +01:00
committed by YeonGyu-Kim
parent a130fa70d1
commit 3199bd3d90
3 changed files with 207 additions and 56 deletions
@@ -20,6 +20,67 @@ export interface FirstPromptWatchdog {
dispose(): void
}
const TERMINAL_EVENT_TYPES = new Set([
"session.idle",
"session.stop",
"session.deleted",
"session.error",
])
/**
* Translate an OpenCode session event into the appropriate watchdog signal.
*
* Progress semantics for cancelling the watchdog:
* - assistant `info.error` set: the existing message-update-handler will
* deal with the error path; the watchdog has done its job.
* - assistant `info.finish` set: the response completed.
* - any assistant part with a known type (`text`, `reasoning`, `tool`,
* `tool_use`, `tool_result`, `tool-call`, `step-start`, `file`, ...):
* the model has started responding. A subagent that immediately runs
* tools is *working*, not silent — so any part presence cancels.
*/
export function observeEventForWatchdog(
event: { type: string; properties?: unknown },
watchdog: FirstPromptWatchdog,
): void {
const props = event.properties as Record<string, unknown> | undefined
if (!props) return
if (event.type === "message.updated") {
const info = props.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (!sessionID || !role) return
if (role === "user") {
const model = info?.model as string | undefined
const agent = info?.agent as string | undefined
watchdog.onUserMessage(sessionID, model, agent)
return
}
if (role === "assistant") {
const hasError = info?.error !== undefined
const hasFinish = info?.finish !== undefined
const eventParts = props.parts as Array<{ type?: string }> | undefined
const infoParts = info?.parts as Array<{ type?: string }> | undefined
const parts = eventParts ?? infoParts ?? []
const hasAnyPart = parts.some((part) => typeof part?.type === "string")
if (hasError || hasFinish || hasAnyPart) {
watchdog.onAssistantProgress(sessionID)
}
}
return
}
if (TERMINAL_EVENT_TYPES.has(event.type)) {
const sessionID =
(props.sessionID as string | undefined) ??
((props.info as Record<string, unknown> | undefined)?.id as string | undefined)
if (sessionID) watchdog.onSessionTerminal(sessionID)
}
}
export function createFirstPromptWatchdog(
deps: HookDeps,
helpers: AutoRetryHelpers,