diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 1da4580f2..8af595b33 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -951,6 +951,7 @@ export function attachSessionBridge( stallTimeoutMs, isProcessing: bag.turn.isProcessing, streamingType: bag.turn.streamingType, + activeToolCalls: bag.turn.activeToolCalls, } if (shouldAbortForStall(stallArgs)) { diff --git a/src/tui-opentui/stall-watchdog.test.ts b/src/tui-opentui/stall-watchdog.test.ts index 885ae7115..6eac1df12 100644 --- a/src/tui-opentui/stall-watchdog.test.ts +++ b/src/tui-opentui/stall-watchdog.test.ts @@ -20,8 +20,19 @@ describe("shouldAbortForStall", () => { stallTimeoutMs: STALL_TIMEOUT_MS, isProcessing: true, streamingType: null, + activeToolCalls: [], } + test("a parallel fan-out with sibling tools still running is not a stall", () => { + const args = { + ...base, + activeToolCalls: ["call-2"], + lastActivityAt: 0, + nowMs: 20 * 60_000, + } + expect(shouldAbortForStall(args)).toBe(false) + }) + test("aborts an awaiting run past the timeout", () => { expect(shouldAbortForStall(base)).toBe(true) }) @@ -183,8 +194,15 @@ describe("shouldNoticeStall", () => { isProcessing: true, streamingType: null, repeating: false, + activeToolCalls: [], } + test("a parallel fan-out with sibling tools still running does not notice", () => { + expect( + shouldNoticeStall({ ...base, activeToolCalls: ["call-2"] }), + ).toBe(false) + }) + test("stays quiet while repeating, even if also silent by the clock", () => { expect(shouldNoticeStall({ ...base, repeating: true })).toBe(false) }) diff --git a/src/tui-opentui/stall-watchdog.ts b/src/tui-opentui/stall-watchdog.ts index 3ae4c4416..0ca82147d 100644 --- a/src/tui-opentui/stall-watchdog.ts +++ b/src/tui-opentui/stall-watchdog.ts @@ -19,6 +19,7 @@ export type ShouldAbortForStallArgs = { readonly stallTimeoutMs: number readonly isProcessing: boolean readonly streamingType: "text" | "thinking" | "tool" | null + readonly activeToolCalls: readonly string[] } // The captured incident looped two sentences with no line break between them @@ -116,7 +117,10 @@ function silentPastThreshold( ): boolean { if (args.status !== "running") return false if (args.nowMs - args.lastActivityAt < thresholdMs) return false - if (args.awaitingResponse) return true + // A parallel fan-out flips `awaitingResponse` true the moment any one + // sub-agent's tool call finishes, even while siblings are still running. + // Outstanding calls mean the run is not silent, regardless of that flag. + if (args.awaitingResponse && args.activeToolCalls.length === 0) return true // Mid-stream hang: model stream stalled after first token. Long in-flight // tool runs do not emit parent stream events; do not abort those. return (