Skip to content

fix: add model stream idle timeout - #117

Open
byapparov wants to merge 7 commits into
mainfrom
backport/issue-80
Open

byapparov wants to merge 7 commits into
mainfrom
backport/issue-80

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Closes #80

Intent

A provider stream can stop producing events indefinitely. The CLI needs a bounded watchdog that reports this as a distinct timeout while allowing long-running local tools to finish.

Expected Impact on Users

Stalled provider streams produce a typed timeout and non-successful headless outcome. Local executable tools are not interrupted merely because their execution exceeds the provider-event timeout.

Expected Outcomes

  • Provider silence still reaches the existing timeout classification and shutdown path.
  • The watchdog suspends during executable local tool calls and resumes after their result or error.
  • Timeout values above the runtime timer limit fall back safely instead of wrapping to an immediate timeout.

Implementation

  • Port the reviewed idle-timeout implementation and apply the provider/tool suspension fix.
  • Add the bounded timer configuration and SDK error mirror for StreamIdleTimeoutError.

Scope Caveat

This does not change provider retry policy or capture raw provider responses.

Test Plan

  • Provider-stall fixtures verify timeout persistence and classification.
  • A local tool longer than the timeout completes before a subsequent provider stall is timed out; boundary values are covered.

Verification

  • 19 focused tests passed.
  • Combined headless/timeout validation passed with 26 tests.
  • CLI and SDK typechecks, formatting, and diff checks passed.

Risks and Rollout

The default remains five minutes. The environment variable is capped at the maximum supported timer delay; no migration is required.

@byapparov byapparov added this to the Enterprise Observability milestone Sep 14, 2026
Comment thread packages/cli/src/session/idle.ts Outdated
let suspended = false
try {
while (true) {
if (suspended) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Idle timeout suspended forever while a tool runs.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/idle.ts:34-40):

Problem: Idle timeout suspended forever while a tool runs
Detail: While `suspended` is true, the loop awaits `iterator.next()` with no timer and no ceiling. Suspension is entered when the processor callback (packages/cli/src/session/processor.ts:67-79) sees a tool-call for a locally-executed tool and is only cleared by a matching tool-result/tool-error. Two consequences: (1) a tool whose execute() never resolves (hung MCP/HTTP call, dropped tool-result) produces no events, so updateSuspended never runs again and the stream never times out — the exact never-terminating-session symptom this PR fixes (#80) persists whenever the stall originates in tool execution; (2) any unpaired tool-call permanently disables the watchdog for the rest of the stream. Suspending during legitimate long tools is clearly intentional (processor-idle.test.ts test 2), but there is no wall-clock bound on suspension at all, so the protection this PR adds is best-effort rather than a true watchdog.
Suggested fix: Bound the suspension instead of disabling the watchdog: arm a generous ceiling timer in the suspended branch too (e.g. a separate AICTRL_TOOL_IDLE_TIMEOUT_MS, or a multiple of ms), or track per-tool start times and fire StreamIdleTimeoutError when the outstanding tool-call exceeds that bound.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

While suspended is true, the loop awaits iterator.next() with no timer and no ceiling. Suspension is entered when the processor callback (packages/cli/src/session/processor.ts:67-79) sees a tool-call for a locally-executed tool and is only cleared by a matching tool-result/tool-error. Two consequences: (1) a tool whose execute() never resolves (hung MCP/HTTP call, dropped tool-result) produces no events, so updateSuspended never runs again and the stream never times out — the exact never-terminating-session symptom this PR fixes (#80) persists whenever the stall originates in tool execution; (2) any unpaired tool-call permanently disables the watchdog for the rest of the stream. Suspending during legitimate long tools is clearly intentional (processor-idle.test.ts test 2), but there is no wall-clock bound on suspension at all, so the protection this PR adds is best-effort rather than a true watchdog.

    let suspended = false
    try {
      while (true) {
        if (suspended) {
          const next = await iterator.next()
          if (next.done) return
          suspended = updateSuspended(next.value)
          yield next.value
          continue
        }

Comment thread packages/cli/src/session/processor.ts Outdated
if (
value.type === "tool-call" &&
!value.providerExecuted &&
typeof streamInput.tools[value.toolName]?.execute === "function"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 TypeError if streamInput.tools is undefined.

--- a/packages/cli/src/session/processor.ts
+++ b/packages/cli/src/session/processor.ts
@@ -68,7 +68,7 @@
               (value) => {
                 if (
                   value.type === "tool-call" &&
                   !value.providerExecuted &&
-                  typeof streamInput.tools[value.toolName]?.execute === "function"
+                  typeof streamInput.tools?.[value.toolName]?.execute === "function"
                 ) {
                   runningTools.add(value.toolCallId)
                 }
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/processor.ts:71):

Problem: TypeError if streamInput.tools is undefined
Detail: The optional chain guards only the element access, not `streamInput.tools` itself. If the LLM.stream input is built without a `tools` property (AI SDK's tools param is optional) and a tool-call part still arrives (e.g. a provider-side/built-in tool), `streamInput.tools[value.toolName]` throws a TypeError inside the updateSuspended callback, killing the stream with an UnknownError instead of being handled by the new error mapping.
Suggested fix: Use `streamInput.tools?.[value.toolName]?.execute === "function"` so an absent tools record degrades to "not a local tool" instead of throwing.

Suggested patch:
--- a/packages/cli/src/session/processor.ts
+++ b/packages/cli/src/session/processor.ts
@@ -68,7 +68,7 @@
               (value) => {
                 if (
                   value.type === "tool-call" &&
                   !value.providerExecuted &&
-                  typeof streamInput.tools[value.toolName]?.execute === "function"
+                  typeof streamInput.tools?.[value.toolName]?.execute === "function"
                 ) {
                   runningTools.add(value.toolCallId)
                 }

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The optional chain guards only the element access, not streamInput.tools itself. If the LLM.stream input is built without a tools property (AI SDK's tools param is optional) and a tool-call part still arrives (e.g. a provider-side/built-in tool), streamInput.tools[value.toolName] throws a TypeError inside the updateSuspended callback, killing the stream with an UnknownError instead of being handled by the new error mapping.

                if (
                  value.type === "tool-call" &&
                  !value.providerExecuted &&
                  typeof streamInput.tools[value.toolName]?.execute === "function"
                ) {
                  runningTools.add(value.toolCallId)
                }

}
}

export type StreamIdleTimeoutError = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify generated SDK types came from codegen.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/sdk/src/gen/types.gen.ts:99-106):

Problem: Verify generated SDK types came from codegen
Detail: The `src/gen/` path indicates generated output. The edit itself is shaped correctly (StreamIdleTimeoutError added to both the AssistantMessage.error and EventSessionError.properties.error unions), but if this was a hand-edit rather than the output of the repo's codegen step, the next regeneration may reorder or drop it. Worth confirming codegen was run and committing its verbatim output.
Suggested fix: Re-run the SDK codegen step from the CLI zod schemas and commit its output verbatim so the hand-applied union additions don't drift on the next regeneration.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The src/gen/ path indicates generated output. The edit itself is shaped correctly (StreamIdleTimeoutError added to both the AssistantMessage.error and EventSessionError.properties.error unions), but if this was a hand-edit rather than the output of the repo's codegen step, the next regeneration may reorder or drop it. Worth confirming codegen was run and committing its verbatim output.

export type MessageAbortedError = {
  name: "MessageAbortedError"
  data: {
    message: string
  }
}

export type StreamIdleTimeoutError = {
  name: "StreamIdleTimeoutError"
  data: {
    message: string
    timeout: number
  }
}

Comment thread README.md Outdated
Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs.

Model streams have a five-minute idle timeout by default. Every stream event resets
the timer, so long-running responses that continue making progress are unaffected.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle-timeout doc nested under CI/CD section.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, README.md:57-62):

Problem: Idle-timeout doc nested under CI/CD section
Detail: The new `AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` paragraph is appended to the `### CI/CD Integration` subsection, but a model-stream runtime timeout applies to every session, not CI/CD. Riding an unrelated heading makes the knob hard to discover and muddies the section's scope.
Suggested fix: Move the paragraph into its own subsection (e.g. `### Model Stream Idle Timeout`) near the other runtime/env-var documentation, keeping CI/CD Integration scoped to headless/CI behavior.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS paragraph is appended to the ### CI/CD Integration subsection, but a model-stream runtime timeout applies to every session, not CI/CD. Riding an unrelated heading makes the knob hard to discover and muddies the section's scope.

### CI/CD Integration
Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs.

Model streams have a five-minute idle timeout by default. Every stream event resets
the timer, so long-running responses that continue making progress are unaffected.
Set `AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` to a decimal integer of milliseconds through
2147483647 to override the timeout, or `0` to disable it. Missing, empty, negative,
fractional, non-decimal, non-numeric, or unsupported values use the 300000 ms default.

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 1 · ⚪ 2 · 0/4 resolved

  • 🟠 packages/cli/src/session/idle.ts:34-40 — Idle timeout suspended forever while a tool runs
  • 🟡 packages/cli/src/session/processor.ts:71 — TypeError if streamInput.tools is undefined
  • packages/sdk/src/gen/types.gen.ts:99-106 — Verify generated SDK types came from codegen
  • README.md:57-62 — Idle-timeout doc nested under CI/CD section
🤖 Fix all 4 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #117 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/session/idle.ts:34-40 — Idle timeout suspended forever while a tool runs
   Detail: While `suspended` is true, the loop awaits `iterator.next()` with no timer and no ceiling. Suspension is entered when the processor callback (packages/cli/src/session/processor.ts:67-79) sees a tool-call for a locally-executed tool and is only cleared by a matching tool-result/tool-error. Two consequences: (1) a tool whose execute() never resolves (hung MCP/HTTP call, dropped tool-result) produces no events, so updateSuspended never runs again and the stream never times out — the exact never-terminating-session symptom this PR fixes (#80) persists whenever the stall originates in tool execution; (2) any unpaired tool-call permanently disables the watchdog for the rest of the stream. Suspending during legitimate long tools is clearly intentional (processor-idle.test.ts test 2), but there is no wall-clock bound on suspension at all, so the protection this PR adds is best-effort rather than a true watchdog.
   Suggested fix: Bound the suspension instead of disabling the watchdog: arm a generous ceiling timer in the suspended branch too (e.g. a separate AICTRL_TOOL_IDLE_TIMEOUT_MS, or a multiple of ms), or track per-tool start times and fire StreamIdleTimeoutError when the outstanding tool-call exceeds that bound.
2. packages/cli/src/session/processor.ts:71 — TypeError if streamInput.tools is undefined
   Detail: The optional chain guards only the element access, not `streamInput.tools` itself. If the LLM.stream input is built without a `tools` property (AI SDK's tools param is optional) and a tool-call part still arrives (e.g. a provider-side/built-in tool), `streamInput.tools[value.toolName]` throws a TypeError inside the updateSuspended callback, killing the stream with an UnknownError instead of being handled by the new error mapping.
   Suggested fix: Use `streamInput.tools?.[value.toolName]?.execute === "function"` so an absent tools record degrades to "not a local tool" instead of throwing.
3. packages/sdk/src/gen/types.gen.ts:99-106 — Verify generated SDK types came from codegen
   Detail: The `src/gen/` path indicates generated output. The edit itself is shaped correctly (StreamIdleTimeoutError added to both the AssistantMessage.error and EventSessionError.properties.error unions), but if this was a hand-edit rather than the output of the repo's codegen step, the next regeneration may reorder or drop it. Worth confirming codegen was run and committing its verbatim output.
   Suggested fix: Re-run the SDK codegen step from the CLI zod schemas and commit its output verbatim so the hand-applied union additions don't drift on the next regeneration.
4. README.md:57-62 — Idle-timeout doc nested under CI/CD section
   Detail: The new `AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` paragraph is appended to the `### CI/CD Integration` subsection, but a model-stream runtime timeout applies to every session, not CI/CD. Riding an unrelated heading makes the knob hard to discover and muddies the section's scope.
   Suggested fix: Move the paragraph into its own subsection (e.g. `### Model Stream Idle Timeout`) near the other runtime/env-var documentation, keeping CI/CD Integration scoped to headless/CI behavior.
📋 Out-of-diff findings (4)
Sev Location Finding
🟠 packages/cli/src/session/idle.ts:34-40 Idle timeout suspended forever while a tool runs
🟡 packages/cli/src/session/processor.ts:71 TypeError if streamInput.tools is undefined
packages/sdk/src/gen/types.gen.ts:99-106 Verify generated SDK types came from codegen
README.md:57-62 Idle-timeout doc nested under CI/CD section

Reviewed 10 files · 0 inline · view all 4 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #117

Verified all four automated findings against 1911854da4f72368bf01db4ac2d04b97bc35239e; three were fixed and one generated-file concern was disproved by the repository's current codegen target.

Issues addressed (pushed to this PR)

  • Idle timeout suspended forever while a tool runspackages/cli/src/session/idle.ts: added a bounded local-tool suspension ceiling and a hung-tool regression test (commit 1911854da4).
  • TypeError if streamInput.tools is undefinedpackages/cli/src/session/processor.ts: guarded the tools record before looking up an executable local tool (commit 1911854da4).
  • Idle-timeout doc nested under CI/CD sectionREADME.md: moved the runtime setting into its own subsection and documented the local-tool ceiling (commit 1911854da4).

Review claims verified false (no change needed)

  • "Verify generated SDK types came from codegen" — verified false. packages/sdk/script/build.ts generates src/v2/gen; it only formats the legacy src/gen tree. The scoped legacy type mirrors the runtime StreamIdleTimeoutError schema exactly, so running current codegen cannot produce or replace this edit.

Not addressed here

  • None.

@github-actions

Copy link
Copy Markdown

Review

Overall this is a solid implementation: the per-event timer reset, the 2^31-1 setTimeout clamp, the Promise.race in idle.ts (both promises get handlers attached, so no unhandled rejections), the fire-and-forget iterator.return?.() to avoid deadlocking behind a stalled next(), the StreamIdleTimeoutError case placed ahead of the generic /timeout/i regex in run.errors.ts, and the fromError case ordering in message-v2.ts are all correct. Tests cover the important paths. No security issues found.

A few reliability/behavior items worth considering:

1. Pending interactive prompts are now killed by the suspended ceiling (medium)

PermissionNext.ask / question prompts raised inside a tool's execute block the stream, so an unattended interactive session with a pending permission prompt now aborts after 12x the model timeout (1h by default) with StreamIdleTimeoutError, where it previously waited indefinitely. Note the asymmetry: the doom-loop permission prompt in the processor loop body (processor.ts:195) is not timed out (the wrapper's timer only spans iterator.next()), but prompts inside tool execute are. If that's intended, a README note would help; if not, an "awaiting permission" state could opt out of the suspended timer.

2. Local tool ceiling silently overrides explicit tool timeouts (medium)

The bash tool accepts an explicit timeout param with no upper bound (tool/bash.ts:102), so a command like timeout: 7200000 is valid per the tool's contract but will be killed at the 3600000ms ceiling (12 x 5min default) with a terminal, non-retryable error. The tool part is then marked "Tool execution aborted" and the session stops. Since the ceiling is derived from the model stream timeout, raising a tool's own timeout doesn't lift it. Consider clamping/validating tool timeouts against the ceiling, or at least documenting that AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS also caps local tool runs.

3. Timeout errors are terminal, not retried (low)

SessionRetry.retryable returns undefined for StreamIdleTimeoutError (its message isn't JSON), so a transient network stall terminates the run rather than retrying. For headless aictrl run a 5-minute stall becomes a hard failure. If that's the intent (the stable MODEL_STREAM_IDLE_TIMEOUT code suggests so), fine — just flagging that a single retry attempt might be friendlier for flaky proxies.

4. Coverage gap: other streams not wrapped (low)

agent.ts:337 iterates streamObject(...).fullStream unwrapped, and generateObject/summary/compaction calls are likewise unbounded — a stalled stream there still hangs forever. Fine to leave for a follow-up, but the README phrasing ("Model streams have a five-minute idle timeout") slightly overstates coverage.

Minor

  • processor.ts:66,82 reads the Flag getter twice per attempt (ms and the Math.min ceiling); snapshotting into a local const would guarantee the two values are consistent. Behaviorally harmless today.
  • processor-idle.test.ts mutates process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS globally for the duration of an end-to-end prompt; any concurrently running stream in the same process would inherit the 20-25ms timeout. Fine under bun's sequential per-file execution, just something to keep in mind if tests are ever parallelized.

Nothing here blocks merge in my view — items 1 and 2 are the ones I'd want a deliberate decision on.

Reviewed SHA: 1911854

NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
StreamIdleTimeoutError.Schema,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New persisted error variant vs older readers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/message-v2.ts:410):

Problem: New persisted error variant vs older readers
Detail: StreamIdleTimeoutError is added to the persisted AssistantMessage.error zod union and the SDK wire types (EventSessionError). Older CLI/SDK builds whose error union lacks this variant will fail to parse (or drop) a persisted assistant message saved by this version after a rollback or in mixed-version setups. Worth confirming the deserialize path degrades gracefully (e.g. falls back to NamedError.Unknown) for unknown error names.
Suggested fix: Verify the name-keyed deserializer for persisted errors falls back to an unknown-error schema for unrecognized names, so older readers can still load sessions containing StreamIdleTimeoutError.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

StreamIdleTimeoutError is added to the persisted AssistantMessage.error zod union and the SDK wire types (EventSessionError). Older CLI/SDK builds whose error union lacks this variant will fail to parse (or drop) a persisted assistant message saved by this version after a rollback or in mixed-version setups. Worth confirming the deserialize path degrades gracefully (e.g. falls back to NamedError.Unknown) for unknown error names.

Comment thread packages/cli/src/session/processor.ts Outdated
(value) => {
if (
value.type === "tool-call" &&
!value.providerExecuted &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Provider-executed tools can falsely hit idle timeout.

Suggested change
!value.providerExecuted &&
Also add provider-executed tool-calls to runningTools on `tool-call` (with providerExecuted=true) and remove them on the corresponding tool-result/tool-error, so server-side execution gets the extended suspended timeout; or document that provider-side silence is intentionally bounded by the base idle timeout.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/processor.ts:71-72):

Problem: Provider-executed tools can falsely hit idle timeout
Detail: The updateSuspended callback only marks a tool as running when `!value.providerExecuted`, so server-side (provider-executed) tool calls are never counted as suspended. While the provider executes such a tool, the fullStream emits no events, so a provider tool running longer than the idle timeout (5 min by default — e.g. long deep-research/computer-use runs) falsely trips StreamIdleTimeoutError and aborts a healthy session. Local tools get a 12x ceiling; provider-executed tools get none. If the base timeout is meant to bound provider silence too, this deserves an explicit comment or doc note; otherwise track provider-executed tool-calls as suspended as well.
Suggested fix: Also add provider-executed tool-calls to runningTools on `tool-call` (with providerExecuted=true) and remove them on the corresponding tool-result/tool-error, so server-side execution gets the extended suspended timeout; or document that provider-side silence is intentionally bounded by the base idle timeout.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The updateSuspended callback only marks a tool as running when !value.providerExecuted, so server-side (provider-executed) tool calls are never counted as suspended. While the provider executes such a tool, the fullStream emits no events, so a provider tool running longer than the idle timeout (5 min by default — e.g. long deep-research/computer-use runs) falsely trips StreamIdleTimeoutError and aborts a healthy session. Local tools get a 12x ceiling; provider-executed tools get none. If the base timeout is meant to bound provider silence too, this deserves an explicit comment or doc note; otherwise track provider-executed tool-calls as suspended as well.

            for await (const value of StreamIdle.timeout(
              stream.fullStream,
              Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS,
              () => idle.controller.abort(),
              (value) => {
                if (
                  value.type === "tool-call" &&
                  !value.providerExecuted &&
                  typeof streamInput.tools?.[value.toolName]?.execute === "function"
                ) {
                  runningTools.add(value.toolCallId)
                }
                if (value.type === "tool-result" || value.type === "tool-error") {
                  runningTools.delete(value.toolCallId)
                }
                return runningTools.size > 0
              },

Comment thread packages/cli/src/session/idle.ts Outdated
try {
while (true) {
const timer = Promise.withResolvers<never>()
const timeout = suspended ? suspendedTimeout : ms

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local const `timeout` shadows generator name.

Suggested change
const timeout = suspended ? suspendedTimeout : ms
Rename the local to `appliedTimeout` (or similar) and use it in the setTimeout delay, the error construction, and the message.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/idle.ts:36):

Problem: Local const `timeout` shadows generator name
Detail: Inside `export async function* timeout<T>(...)` the loop declares `const timeout = suspended ? suspendedTimeout : ms`, shadowing the generator's own name. Harmless at runtime but invites confusion and accidental self-reference in future edits of this loop.
Suggested fix: Rename the local to `appliedTimeout` (or similar) and use it in the setTimeout delay, the error construction, and the message.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Inside export async function* timeout<T>(...) the loop declares const timeout = suspended ? suspendedTimeout : ms, shadowing the generator's own name. Harmless at runtime but invites confusion and accidental self-reference in future edits of this loop.

      while (true) {\n        const timer = Promise.withResolvers<never>()\n        const timeout = suspended ? suspendedTimeout : ms\n        const id = setTimeout(() => {\n          timer.reject(\n            error(\n              timeout,

Comment thread packages/cli/src/session/idle.ts Outdated
import { MessageV2 } from "./message-v2"

export namespace StreamIdle {
function error(ms: number, message = `Model stream produced no events for ${ms}ms`) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead default message param in error helper.

--- a/packages/cli/src/session/idle.ts
+++ b/packages/cli/src/session/idle.ts
@@ -1,5 +1,5 @@
 import { MessageV2 } from "./message-v2"
 
 export namespace StreamIdle {
-  function error(ms: number, message = `Model stream produced no events for ${ms}ms`) {
+  function error(ms: number, message: string) {
     return new MessageV2.StreamIdleTimeoutError({
       message,
       timeout: ms,
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/idle.ts:4-9):

Problem: Dead default message param in error helper
Detail: The default value of the `message` parameter in the unexported error() helper is never used: its only call site always passes an explicit message for both the suspended and non-suspended cases. Dead default left behind by the suspended-timeout feature.
Suggested fix: Drop the unused default: `function error(ms: number, message: string)`.

Suggested patch:
--- a/packages/cli/src/session/idle.ts
+++ b/packages/cli/src/session/idle.ts
@@ -1,5 +1,5 @@
 import { MessageV2 } from "./message-v2"
 
 export namespace StreamIdle {
-  function error(ms: number, message = `Model stream produced no events for ${ms}ms`) {
+  function error(ms: number, message: string) {
     return new MessageV2.StreamIdleTimeoutError({
       message,
       timeout: ms,


Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The default value of the message parameter in the unexported error() helper is never used: its only call site always passes an explicit message for both the suspended and non-suspended cases. Dead default left behind by the suspended-timeout feature.

export namespace StreamIdle {
  function error(ms: number, message = `Model stream produced no events for ${ms}ms`) {
    return new MessageV2.StreamIdleTimeoutError({
      message,
      timeout: ms,
    })
  }

Comment thread packages/cli/src/session/processor.ts Outdated
for await (const value of stream.fullStream) {
for await (const value of StreamIdle.timeout(
stream.fullStream,
Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dynamic flag getter read twice for one stream.

Suggested change
Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS,
Capture the value once before the call: `const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS`, then pass `idleMs` and `Math.min(idleMs * LOCAL_TOOL_TIMEOUT_MULTIPLIER, Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX)`.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/src/session/processor.ts:66):

Problem: Dynamic flag getter read twice for one stream
Detail: Because AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS is a dynamic getter that re-reads process.env on every access, the processor evaluates it twice when wiring one stream — once for the idle timeout and once inside Math.min(... * LOCAL_TOOL_TIMEOUT_MULTIPLIER, ...). Reading it once into a local const makes the stream's configuration a single snapshot and the multiplier expression easier to read.
Suggested fix: Capture the value once before the call: `const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS`, then pass `idleMs` and `Math.min(idleMs * LOCAL_TOOL_TIMEOUT_MULTIPLIER, Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX)`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Because AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS is a dynamic getter that re-reads process.env on every access, the processor evaluates it twice when wiring one stream — once for the idle timeout and once inside Math.min(... * LOCAL_TOOL_TIMEOUT_MULTIPLIER, ...). Reading it once into a local const makes the stream's configuration a single snapshot and the multiplier expression easier to read.

            for await (const value of StreamIdle.timeout(
              stream.fullStream,
              Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS,
              () => idle.controller.abort(),
              (value) => {
                if (
                  value.type === "tool-call" &&

Comment thread packages/cli/test/session/idle.test.ts Outdated
})
})

describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag env-parsing tests live in session/idle.test.ts.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #117, packages/cli/test/session/idle.test.ts:151-179):

Problem: Flag env-parsing tests live in session/idle.test.ts
Detail: The file ends with a describe block testing Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS env parsing (default, override, disable, invalid fallbacks), which is behavior of src/flag/flag.ts, not of StreamIdle. The repo's test layout mirrors source modules (e.g. src/cli/cmd/run.errors.ts -> test/cli/classify-session-error.test.ts), so flag parsing tests belong in a test/flag module, keeping the idle helper tests focused.
Suggested fix: Move the AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS env-var cases into a dedicated flag test file (e.g. packages/cli/test/flag/) next to other Flag getter tests.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The file ends with a describe block testing Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS env parsing (default, override, disable, invalid fallbacks), which is behavior of src/flag/flag.ts, not of StreamIdle. The repo's test layout mirrors source modules (e.g. src/cli/cmd/run.errors.ts -> test/cli/classify-session-error.test.ts), so flag parsing tests belong in a test/flag module, keeping the idle helper tests focused.

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 2 · ⚪ 4 · 0/6 resolved

  • packages/cli/src/session/idle.ts:4-9 — Dead default message param in error helper
  • packages/cli/src/session/idle.ts:36 — Local const `timeout` shadows generator name
  • 🟡 packages/cli/src/session/message-v2.ts:410 — New persisted error variant vs older readers
  • packages/cli/src/session/processor.ts:66 — Dynamic flag getter read twice for one stream
  • 🟡 packages/cli/src/session/processor.ts:71-72 — Provider-executed tools can falsely hit idle timeout
  • packages/cli/test/session/idle.test.ts:151-179 — Flag env-parsing tests live in session/idle.test.ts
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #117 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/session/idle.ts:4-9 — Dead default message param in error helper
   Detail: The default value of the `message` parameter in the unexported error() helper is never used: its only call site always passes an explicit message for both the suspended and non-suspended cases. Dead default left behind by the suspended-timeout feature.
   Suggested fix: Drop the unused default: `function error(ms: number, message: string)`.
2. packages/cli/src/session/idle.ts:36 — Local const `timeout` shadows generator name
   Detail: Inside `export async function* timeout<T>(...)` the loop declares `const timeout = suspended ? suspendedTimeout : ms`, shadowing the generator's own name. Harmless at runtime but invites confusion and accidental self-reference in future edits of this loop.
   Suggested fix: Rename the local to `appliedTimeout` (or similar) and use it in the setTimeout delay, the error construction, and the message.
3. packages/cli/src/session/message-v2.ts:410 — New persisted error variant vs older readers
   Detail: StreamIdleTimeoutError is added to the persisted AssistantMessage.error zod union and the SDK wire types (EventSessionError). Older CLI/SDK builds whose error union lacks this variant will fail to parse (or drop) a persisted assistant message saved by this version after a rollback or in mixed-version setups. Worth confirming the deserialize path degrades gracefully (e.g. falls back to NamedError.Unknown) for unknown error names.
   Suggested fix: Verify the name-keyed deserializer for persisted errors falls back to an unknown-error schema for unrecognized names, so older readers can still load sessions containing StreamIdleTimeoutError.
4. packages/cli/src/session/processor.ts:66 — Dynamic flag getter read twice for one stream
   Detail: Because AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS is a dynamic getter that re-reads process.env on every access, the processor evaluates it twice when wiring one stream — once for the idle timeout and once inside Math.min(... * LOCAL_TOOL_TIMEOUT_MULTIPLIER, ...). Reading it once into a local const makes the stream's configuration a single snapshot and the multiplier expression easier to read.
   Suggested fix: Capture the value once before the call: `const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS`, then pass `idleMs` and `Math.min(idleMs * LOCAL_TOOL_TIMEOUT_MULTIPLIER, Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX)`.
5. packages/cli/src/session/processor.ts:71-72 — Provider-executed tools can falsely hit idle timeout
   Detail: The updateSuspended callback only marks a tool as running when `!value.providerExecuted`, so server-side (provider-executed) tool calls are never counted as suspended. While the provider executes such a tool, the fullStream emits no events, so a provider tool running longer than the idle timeout (5 min by default — e.g. long deep-research/computer-use runs) falsely trips StreamIdleTimeoutError and aborts a healthy session. Local tools get a 12x ceiling; provider-executed tools get none. If the base timeout is meant to bound provider silence too, this deserves an explicit comment or doc note; otherwise track provider-executed tool-calls as suspended as well.
   Suggested fix: Also add provider-executed tool-calls to runningTools on `tool-call` (with providerExecuted=true) and remove them on the corresponding tool-result/tool-error, so server-side execution gets the extended suspended timeout; or document that provider-side silence is intentionally bounded by the base idle timeout.
6. packages/cli/test/session/idle.test.ts:151-179 — Flag env-parsing tests live in session/idle.test.ts
   Detail: The file ends with a describe block testing Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS env parsing (default, override, disable, invalid fallbacks), which is behavior of src/flag/flag.ts, not of StreamIdle. The repo's test layout mirrors source modules (e.g. src/cli/cmd/run.errors.ts -> test/cli/classify-session-error.test.ts), so flag parsing tests belong in a test/flag module, keeping the idle helper tests focused.
   Suggested fix: Move the AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS env-var cases into a dedicated flag test file (e.g. packages/cli/test/flag/) next to other Flag getter tests.
📋 Out-of-diff findings (6)
Sev Location Finding
packages/cli/src/session/idle.ts:4-9 Dead default message param in error helper
packages/cli/src/session/idle.ts:36 Local const `timeout` shadows generator name
🟡 packages/cli/src/session/message-v2.ts:410 New persisted error variant vs older readers
packages/cli/src/session/processor.ts:66 Dynamic flag getter read twice for one stream
🟡 packages/cli/src/session/processor.ts:71-72 Provider-executed tools can falsely hit idle timeout
packages/cli/test/session/idle.test.ts:151-179 Flag env-parsing tests live in session/idle.test.ts

Reviewed 10 files · 0 inline · view all 6 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov byapparov self-assigned this Sep 14, 2026
@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #117

Verified the six findings from the review of 1911854da4f72368bf01db4ac2d04b97bc35239e; five maintainability and timeout-policy issues were fixed, and the persisted-error compatibility claim was disproved against the actual database read path.

Issues addressed (pushed to this PR)

  • Dead default message parameterpackages/cli/src/session/idle.ts: removed the unused default from the private error helper (commit ef850e7eed).
  • Local timeout variable shadowed the generator namepackages/cli/src/session/idle.ts: renamed it to appliedTimeout (commit ef850e7eed).
  • Dynamic flag getter read twice per streampackages/cli/src/session/processor.ts: snapshot the configured timeout once as idleMs (commit ef850e7eed).
  • Provider-executed tools could falsely hit the base idle timeoutpackages/cli/src/session/processor.ts: provider-executed calls now receive the same bounded extended tool ceiling as local calls, with a regression case for each path (commit ef850e7eed).
  • Flag parsing tests lived with stream-helper tests — moved the cases to packages/cli/test/flag/flag.test.ts (commit ef850e7eed).

Review claims verified false (no change needed)

  • "New persisted error variant vs older readers" — verified false. MessageV2.stream() and MessageV2.get() reconstruct database rows with type casts and do not run the Assistant zod discriminated union while deserializing. Older JavaScript readers therefore retain or ignore the additional error object rather than rejecting the persisted message; SDK unions are compile-time declarations and add no runtime parser.

Not addressed here

  • None.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add model stream idle-timeout handling

1 participant