From 286db1a407cd974371c845642ec2b2b2c9af51b5 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 19:32:32 +0100 Subject: [PATCH 1/8] feat(session): add model stream idle timeout --- README.md | 6 ++ packages/cli/src/cli/cmd/run.errors.ts | 3 + packages/cli/src/flag/flag.ts | 13 +++ packages/cli/src/session/idle.ts | 27 ++++++ packages/cli/src/session/message-v2.ts | 10 +++ packages/cli/src/session/processor.ts | 14 ++- .../test/cli/classify-session-error.test.ts | 13 +++ packages/cli/test/session/idle.test.ts | 85 +++++++++++++++++++ .../cli/test/session/processor-idle.test.ts | 78 +++++++++++++++++ 9 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/session/idle.ts create mode 100644 packages/cli/test/session/idle.test.ts create mode 100644 packages/cli/test/session/processor-idle.test.ts diff --git a/README.md b/README.md index 5baf5ed..7cdde13 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ In headless mode, Aictrl automatically rejects all interactive permission reques ### 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 positive number of milliseconds to +override the timeout, or `0` to disable it. Missing, empty, negative, fractional, and +non-numeric values use the 300000 ms default. + ## GitHub Integration Aictrl includes a specialized GitHub agent that can be installed into your repositories to automate PR reviews, issue triage, and code generation. diff --git a/packages/cli/src/cli/cmd/run.errors.ts b/packages/cli/src/cli/cmd/run.errors.ts index a0cbeaf..e8e572c 100644 --- a/packages/cli/src/cli/cmd/run.errors.ts +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -24,6 +24,9 @@ export function classifySessionError(err: unknown): ClassifiedSessionError { if (status === 429) return { reason: "rate_limit", code: "429", message } if (status === 401 || status === 403) return { reason: "auth", code: String(status), message } if (name === "ProviderAuthError") return { reason: "auth", code: status ? String(status) : undefined, message } + if (name === "StreamIdleTimeoutError") { + return { reason: "timeout", code: "MODEL_STREAM_IDLE_TIMEOUT", message } + } if (name === "AbortError" || /timeout/i.test(message)) { return { reason: "timeout", code: status ? String(status) : undefined, message } } diff --git a/packages/cli/src/flag/flag.ts b/packages/cli/src/flag/flag.ts index 82efe56..95a3432 100644 --- a/packages/cli/src/flag/flag.ts +++ b/packages/cli/src/flag/flag.ts @@ -4,6 +4,7 @@ function truthy(key: string) { } export namespace Flag { + export const MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 5 * 60 * 1000 export const AICTRL_GIT_BASH_PATH = process.env["AICTRL_GIT_BASH_PATH"] export const AICTRL_CONFIG = process.env["AICTRL_CONFIG"] export declare const AICTRL_CONFIG_DIR: string | undefined @@ -20,6 +21,7 @@ export namespace Flag { export const AICTRL_FAKE_VCS = process.env["AICTRL_FAKE_VCS"] export declare const AICTRL_CLIENT: string export const AICTRL_ENABLE_QUESTION_TOOL = truthy("AICTRL_ENABLE_QUESTION_TOOL") + export declare const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS: number // Experimental export const AICTRL_EXPERIMENTAL = truthy("AICTRL_EXPERIMENTAL") @@ -43,6 +45,17 @@ export namespace Flag { } } +Object.defineProperty(Flag, "AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", { + get() { + const value = process.env["AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS"] + if (value === undefined || value.trim() === "") return Flag.MODEL_STREAM_IDLE_TIMEOUT_DEFAULT + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : Flag.MODEL_STREAM_IDLE_TIMEOUT_DEFAULT + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for AICTRL_DISABLE_PROJECT_CONFIG // This must be evaluated at access time, not module load time, // because external tooling may set this env var at runtime diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts new file mode 100644 index 0000000..bee2cca --- /dev/null +++ b/packages/cli/src/session/idle.ts @@ -0,0 +1,27 @@ +import { MessageV2 } from "./message-v2" + +export namespace StreamIdle { + export async function* timeout(stream: AsyncIterable, ms: number, abort: () => void) { + if (ms === 0) { + yield* stream + return + } + + const iterator = stream[Symbol.asyncIterator]() + while (true) { + const timer = Promise.withResolvers() + const id = setTimeout(() => { + timer.reject( + new MessageV2.StreamIdleTimeoutError({ + message: `Model stream produced no events for ${ms}ms`, + timeout: ms, + }), + ) + abort() + }, ms) + const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) + if (next.done) return + yield next.value + } + } +} diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index ec31da0..84339eb 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -33,6 +33,13 @@ export namespace MessageV2 { } export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) + export const StreamIdleTimeoutError = NamedError.create( + "StreamIdleTimeoutError", + z.object({ + message: z.string(), + timeout: z.number(), + }), + ) export const StructuredOutputError = NamedError.create( "StructuredOutputError", z.object({ @@ -415,6 +422,7 @@ export namespace MessageV2 { NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, + StreamIdleTimeoutError.Schema, StructuredOutputError.Schema, ContextOverflowError.Schema, APIError.Schema, @@ -836,6 +844,8 @@ export namespace MessageV2 { cause: e, }, ).toObject() + case MessageV2.StreamIdleTimeoutError.isInstance(e): + return e.toObject() case MessageV2.OutputLengthError.isInstance(e): return e case LoadAPIKeyError.isInstance(e): diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index b6c87f2..6cadec2 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -17,6 +17,8 @@ import { PermissionNext } from "@/permission/next" import { Question } from "@/question" import { NamedError } from "@aictrl/util/error" import { ProviderTermination } from "@/provider/termination" +import { StreamIdle } from "./idle" +import { Flag } from "@/flag/flag" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -91,9 +93,17 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = await LLM.stream(streamInput) + const controller = new AbortController() + const stream = await LLM.stream({ + ...streamInput, + abort: AbortSignal.any([streamInput.abort, controller.signal]), + }) - for await (const value of stream.fullStream) { + for await (const value of StreamIdle.timeout( + stream.fullStream, + Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS, + () => controller.abort(), + )) { input.abort.throwIfAborted() switch (value.type) { case "start": diff --git a/packages/cli/test/cli/classify-session-error.test.ts b/packages/cli/test/cli/classify-session-error.test.ts index 544eff9..fc173e4 100644 --- a/packages/cli/test/cli/classify-session-error.test.ts +++ b/packages/cli/test/cli/classify-session-error.test.ts @@ -2,6 +2,19 @@ import { describe, expect, test } from "bun:test" import { classifySessionError } from "../../src/cli/cmd/run.errors" describe("classifySessionError (#63)", () => { + test("model stream idle timeout has a stable timeout code", () => { + expect( + classifySessionError({ + name: "StreamIdleTimeoutError", + data: { message: "Model stream produced no events for 300000ms", timeout: 300000 }, + }), + ).toEqual({ + reason: "timeout", + code: "MODEL_STREAM_IDLE_TIMEOUT", + message: "Model stream produced no events for 300000ms", + }) + }) + test("HTTP 429 → rate_limit", () => { const res = classifySessionError({ status: 429, message: "Rate limit exceeded" }) expect(res.reason).toBe("rate_limit") diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts new file mode 100644 index 0000000..9008f13 --- /dev/null +++ b/packages/cli/test/session/idle.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { StreamIdle } from "../../src/session/idle" +import { MessageV2 } from "../../src/session/message-v2" +import { Flag } from "../../src/flag/flag" + +describe("model stream idle timeout", () => { + test("fails and aborts a stream whose next event stalls", async () => { + const pending = Promise.withResolvers>() + let aborted = false + const stream = { + [Symbol.asyncIterator]() { + return { + next: () => pending.promise, + } + }, + } + + const result = StreamIdle.timeout(stream, 10, () => { + aborted = true + }) + const error = await result.next().catch((error) => error) + + expect(aborted).toBe(true) + expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true) + expect(error.data).toEqual({ + message: "Model stream produced no events for 10ms", + timeout: 10, + }) + }) + + test("resets after each event instead of limiting total stream duration", async () => { + async function* stream() { + yield 1 + await Bun.sleep(8) + yield 2 + await Bun.sleep(8) + yield 3 + } + + const values: number[] = [] + for await (const value of StreamIdle.timeout(stream(), 20, () => { + throw new Error("active stream should not abort") + })) { + values.push(value) + } + + expect(values).toEqual([1, 2, 3]) + }) + + test("zero disables the timeout", async () => { + async function* stream() { + await Bun.sleep(15) + yield "done" + } + + const values = [] + for await (const value of StreamIdle.timeout(stream(), 0, () => { + throw new Error("disabled timeout should not abort") + })) { + values.push(value) + } + + expect(values).toEqual(["done"]) + }) +}) + +describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { + test("supports default, override, disable, and invalid fallback", () => { + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + + try { + delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1234" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(1234) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) +}) diff --git a/packages/cli/test/session/processor-idle.test.ts b/packages/cli/test/session/processor-idle.test.ts new file mode 100644 index 0000000..e550c00 --- /dev/null +++ b/packages/cli/test/session/processor-idle.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { MessageV2 } from "../../src/session/message-v2" +import { tmpdir } from "../fixture/fixture" + +describe("session processor model stream idle timeout", () => { + test("aborts a stalled provider stream, records the timeout, and returns the session to idle", async () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response( + new ReadableStream({ + pull() { + return new Promise(() => {}) + }, + }), + { headers: { "Content-Type": "text/event-stream" } }, + ) + }, + }) + await using tmp = await tmpdir({ + git: true, + init: (dir) => + Bun.write( + path.join(dir, "aictrl.json"), + JSON.stringify({ + provider: { + stalled: { + name: "Stalled", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + test: { + name: "Test", + tool_call: true, + limit: { context: 128000, output: 4096 }, + }, + }, + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ), + }) + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "25" + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const result = await SessionPrompt.prompt({ + sessionID: session.id, + model: { providerID: "stalled", modelID: "test" }, + parts: [{ type: "text", text: "hello" }], + }) + + expect(result.info.role).toBe("assistant") + if (result.info.role !== "assistant") return + expect(MessageV2.StreamIdleTimeoutError.isInstance(result.info.error)).toBe(true) + expect(result.info.error?.data.message).toContain("25ms") + expect(SessionStatus.get(session.id)).toEqual({ type: "idle" }) + }, + }) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) +}) From 388f8e8736ede727b81ca8ba89ad835d31fe525f Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 19:50:34 +0100 Subject: [PATCH 2/8] fix(session): address idle timeout review --- README.md | 4 +- packages/cli/src/flag/flag.ts | 10 +++-- packages/cli/src/session/idle.ts | 45 +++++++++++++------ packages/cli/src/session/processor.ts | 10 ++--- packages/cli/test/session/idle.test.ts | 62 ++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7cdde13..7f75a83 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even i 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 positive number of milliseconds to +Set `AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` to a positive decimal integer of milliseconds to override the timeout, or `0` to disable it. Missing, empty, negative, fractional, and -non-numeric values use the 300000 ms default. +non-decimal, non-numeric, or unsafe integer values use the 300000 ms default. ## GitHub Integration diff --git a/packages/cli/src/flag/flag.ts b/packages/cli/src/flag/flag.ts index 95a3432..62077b6 100644 --- a/packages/cli/src/flag/flag.ts +++ b/packages/cli/src/flag/flag.ts @@ -4,7 +4,7 @@ function truthy(key: string) { } export namespace Flag { - export const MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 5 * 60 * 1000 + export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 5 * 60 * 1000 export const AICTRL_GIT_BASH_PATH = process.env["AICTRL_GIT_BASH_PATH"] export const AICTRL_CONFIG = process.env["AICTRL_CONFIG"] export declare const AICTRL_CONFIG_DIR: string | undefined @@ -45,12 +45,14 @@ export namespace Flag { } } +// Dynamic getter for AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS. +// Evaluated at access time so environment overrides take effect for each model stream. Object.defineProperty(Flag, "AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", { get() { const value = process.env["AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS"] - if (value === undefined || value.trim() === "") return Flag.MODEL_STREAM_IDLE_TIMEOUT_DEFAULT - const parsed = Number(value) - return Number.isInteger(parsed) && parsed >= 0 ? parsed : Flag.MODEL_STREAM_IDLE_TIMEOUT_DEFAULT + if (value === undefined || value.trim() === "") return Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT + const parsed = /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN + return Number.isSafeInteger(parsed) ? parsed : Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT }, enumerable: true, configurable: false, diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts index bee2cca..3ddc2f7 100644 --- a/packages/cli/src/session/idle.ts +++ b/packages/cli/src/session/idle.ts @@ -1,6 +1,14 @@ import { MessageV2 } from "./message-v2" export namespace StreamIdle { + export function signal(input?: AbortSignal) { + const controller = new AbortController() + return { + controller, + signal: input ? AbortSignal.any([input, controller.signal]) : controller.signal, + } + } + export async function* timeout(stream: AsyncIterable, ms: number, abort: () => void) { if (ms === 0) { yield* stream @@ -8,20 +16,29 @@ export namespace StreamIdle { } const iterator = stream[Symbol.asyncIterator]() - while (true) { - const timer = Promise.withResolvers() - const id = setTimeout(() => { - timer.reject( - new MessageV2.StreamIdleTimeoutError({ - message: `Model stream produced no events for ${ms}ms`, - timeout: ms, - }), - ) - abort() - }, ms) - const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) - if (next.done) return - yield next.value + try { + while (true) { + const timer = Promise.withResolvers() + const id = setTimeout(() => { + timer.reject( + new MessageV2.StreamIdleTimeoutError({ + message: `Model stream produced no events for ${ms}ms`, + timeout: ms, + }), + ) + abort() + }, ms) + const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) + if (next.done) return + yield next.value + } + } finally { + // Do not await cleanup: an async generator queues return() behind an + // in-flight next(), which may never settle for the stalled stream we are + // escaping. The abort above gives cooperative providers a chance to close. + try { + iterator.return?.().catch(() => {}) + } catch {} } } } diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 6cadec2..6963e60 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -13,12 +13,12 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" +import { StreamIdle } from "./idle" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" -import { NamedError } from "@aictrl/util/error" import { ProviderTermination } from "@/provider/termination" -import { StreamIdle } from "./idle" import { Flag } from "@/flag/flag" +import { NamedError } from "@aictrl/util/error" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -93,16 +93,16 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const controller = new AbortController() + const idle = StreamIdle.signal(streamInput.abort) const stream = await LLM.stream({ ...streamInput, - abort: AbortSignal.any([streamInput.abort, controller.signal]), + abort: idle.signal, }) for await (const value of StreamIdle.timeout( stream.fullStream, Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS, - () => controller.abort(), + () => idle.controller.abort(), )) { input.abort.throwIfAborted() switch (value.type) { diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index 9008f13..9d49eab 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -4,6 +4,22 @@ import { MessageV2 } from "../../src/session/message-v2" import { Flag } from "../../src/flag/flag" describe("model stream idle timeout", () => { + test("uses its own abort signal when the caller signal is undefined", () => { + const idle = StreamIdle.signal() + + expect(idle.signal.aborted).toBe(false) + idle.controller.abort() + expect(idle.signal.aborted).toBe(true) + }) + + test("combines a supplied caller signal with its timeout controller", () => { + const caller = new AbortController() + const idle = StreamIdle.signal(caller.signal) + + caller.abort() + expect(idle.signal.aborted).toBe(true) + }) + test("fails and aborts a stream whose next event stalls", async () => { const pending = Promise.withResolvers>() let aborted = false @@ -28,6 +44,46 @@ describe("model stream idle timeout", () => { }) }) + test("releases the inner iterator after an idle timeout", async () => { + const pending = Promise.withResolvers>() + let released = false + const stream = { + [Symbol.asyncIterator]() { + return { + next: () => pending.promise, + async return() { + released = true + return { done: true as const, value: undefined } + }, + } + }, + } + + await StreamIdle.timeout(stream, 10, () => {}).next().catch(() => {}) + expect(released).toBe(true) + }) + + test("releases the inner iterator when the consumer stops early", async () => { + let released = false + const stream = { + [Symbol.asyncIterator]() { + return { + value: 0, + async next() { + return { done: false as const, value: ++this.value } + }, + async return() { + released = true + return { done: true as const, value: undefined } + }, + } + }, + } + + for await (const _ of StreamIdle.timeout(stream, 100, () => {})) break + expect(released).toBe(true) + }) + test("resets after each event instead of limiting total stream duration", async () => { async function* stream() { yield 1 @@ -77,6 +133,12 @@ describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1e3" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) } finally { if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original From 5fe46e7195fb611d29cf8d6c35548860308c8343 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 20:13:26 +0100 Subject: [PATCH 3/8] test(session): clarify iterator cleanup assertion --- packages/cli/test/session/idle.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index 9d49eab..ee46783 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -44,23 +44,25 @@ describe("model stream idle timeout", () => { }) }) - test("releases the inner iterator after an idle timeout", async () => { + test("calls return on the inner iterator after an idle timeout", async () => { const pending = Promise.withResolvers>() - let released = false + let called = false const stream = { [Symbol.asyncIterator]() { return { next: () => pending.promise, async return() { - released = true + called = true return { done: true as const, value: undefined } }, } }, } - await StreamIdle.timeout(stream, 10, () => {}).next().catch(() => {}) - expect(released).toBe(true) + await StreamIdle.timeout(stream, 10, () => {}) + .next() + .catch(() => {}) + expect(called).toBe(true) }) test("releases the inner iterator when the consumer stops early", async () => { From aca0989ba20260d66086331727f4baad03867320 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 12:26:28 +0100 Subject: [PATCH 4/8] fix(session): suspend idle timeout for local tools --- README.md | 6 +- packages/cli/src/flag/flag.ts | 5 +- packages/cli/src/session/idle.ts | 30 +++-- packages/cli/src/session/processor.ts | 14 +++ packages/cli/test/session/idle.test.ts | 4 + .../cli/test/session/processor-idle.test.ts | 103 +++++++++++++++++- 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7f75a83..cf34b33 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even i 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 positive decimal integer of milliseconds to -override the timeout, or `0` to disable it. Missing, empty, negative, fractional, and -non-decimal, non-numeric, or unsafe integer values use the 300000 ms default. +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. ## GitHub Integration diff --git a/packages/cli/src/flag/flag.ts b/packages/cli/src/flag/flag.ts index 62077b6..0c45cda 100644 --- a/packages/cli/src/flag/flag.ts +++ b/packages/cli/src/flag/flag.ts @@ -5,6 +5,7 @@ function truthy(key: string) { export namespace Flag { export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 5 * 60 * 1000 + export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX = 2_147_483_647 export const AICTRL_GIT_BASH_PATH = process.env["AICTRL_GIT_BASH_PATH"] export const AICTRL_CONFIG = process.env["AICTRL_CONFIG"] export declare const AICTRL_CONFIG_DIR: string | undefined @@ -52,7 +53,9 @@ Object.defineProperty(Flag, "AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", { const value = process.env["AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS"] if (value === undefined || value.trim() === "") return Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT const parsed = /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN - return Number.isSafeInteger(parsed) ? parsed : Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT + return Number.isSafeInteger(parsed) && parsed <= Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX + ? parsed + : Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT }, enumerable: true, configurable: false, diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts index 3ddc2f7..fa8c634 100644 --- a/packages/cli/src/session/idle.ts +++ b/packages/cli/src/session/idle.ts @@ -1,6 +1,13 @@ import { MessageV2 } from "./message-v2" export namespace StreamIdle { + function error(ms: number) { + return new MessageV2.StreamIdleTimeoutError({ + message: `Model stream produced no events for ${ms}ms`, + timeout: ms, + }) + } + export function signal(input?: AbortSignal) { const controller = new AbortController() return { @@ -9,27 +16,36 @@ export namespace StreamIdle { } } - export async function* timeout(stream: AsyncIterable, ms: number, abort: () => void) { + export async function* timeout( + stream: AsyncIterable, + ms: number, + abort: () => void, + updateSuspended: (value: T) => boolean = () => false, + ) { if (ms === 0) { yield* stream return } const iterator = stream[Symbol.asyncIterator]() + 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 + } const timer = Promise.withResolvers() const id = setTimeout(() => { - timer.reject( - new MessageV2.StreamIdleTimeoutError({ - message: `Model stream produced no events for ${ms}ms`, - timeout: ms, - }), - ) + timer.reject(error(ms)) abort() }, ms) const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) if (next.done) return + suspended = updateSuspended(next.value) yield next.value } } finally { diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 6963e60..8f7a7c2 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -98,11 +98,25 @@ export namespace SessionProcessor { ...streamInput, abort: idle.signal, }) + const runningTools = new Set() 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 + }, )) { input.abort.throwIfAborted() switch (value.type) { diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index ee46783..b0aa5d1 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -139,6 +139,10 @@ describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483647" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(2_147_483_647) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483648" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) } finally { diff --git a/packages/cli/test/session/processor-idle.test.ts b/packages/cli/test/session/processor-idle.test.ts index e550c00..ca60209 100644 --- a/packages/cli/test/session/processor-idle.test.ts +++ b/packages/cli/test/session/processor-idle.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" +import { jsonSchema, tool } from "ai" import path from "path" +import { Agent } from "../../src/agent/agent" +import { Identifier } from "../../src/id/id" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" +import { SessionProcessor } from "../../src/session/processor" import { SessionStatus } from "../../src/session/status" import { MessageV2 } from "../../src/session/message-v2" import { tmpdir } from "../fixture/fixture" @@ -75,4 +81,99 @@ describe("session processor model stream idle timeout", () => { else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original } }) + + test("suspends while a local tool executes and resumes for provider events", async () => { + await using tmp = await tmpdir({ + config: { + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key" } } }, + }, + }) + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "20" + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Slow local tool fixture" }) + const agent = await Agent.get("build") + const model = await Provider.getModel("alibaba", "qwen-plus") + const user = (await Session.updateMessage({ + id: Identifier.ascending("message"), + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: model.providerID, modelID: model.id }, + })) as MessageV2.User + const assistant = (await Session.updateMessage({ + id: Identifier.ascending("message"), + sessionID: session.id, + role: "assistant", + parentID: user.id, + modelID: model.id, + providerID: model.providerID, + mode: agent.name, + agent: agent.name, + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + })) as MessageV2.Assistant + let sideEffectCompleted = false + const runSlowTool = async () => { + await Bun.sleep(60) + sideEffectCompleted = true + return { output: "done", title: "slow", metadata: {} } + } + const slowTool = tool({ + inputSchema: jsonSchema({ type: "object", additionalProperties: false }), + execute: runSlowTool, + }) + const stream = spyOn(LLM, "stream").mockResolvedValue({ + fullStream: (async function* () { + yield { type: "tool-input-start", id: "call_1", toolName: "slow" } + yield { type: "tool-call", toolCallId: "call_1", toolName: "slow", input: {} } + const output = await runSlowTool() + yield { type: "tool-result", toolCallId: "call_1", toolName: "slow", input: {}, output } + await new Promise(() => {}) + })(), + } as unknown as Awaited>) + + try { + const processor = SessionProcessor.create({ + assistantMessage: assistant, + sessionID: session.id, + model, + abort: new AbortController().signal, + }) + const result = await processor.process({ + user, + sessionID: session.id, + model, + agent, + abort: new AbortController().signal, + system: [], + messages: [], + tools: { slow: slowTool }, + }) + + expect(result).toBe("stop") + expect(sideEffectCompleted).toBe(true) + expect(MessageV2.StreamIdleTimeoutError.isInstance(assistant.error)).toBe(true) + const part = (await MessageV2.parts(assistant.id)).find( + (item) => item.type === "tool" && item.callID === "call_1", + ) + expect(part?.type === "tool" ? part.state.status : undefined).toBe("completed") + } finally { + stream.mockRestore() + } + }, + }) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) }) From 498a5808fdea78b6063cf9441830a9dd3fb8332a Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 12:37:50 +0100 Subject: [PATCH 5/8] fix(sdk): expose stream idle timeout errors --- packages/sdk/src/gen/types.gen.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts index c8d7e43..7432044 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -96,6 +96,14 @@ export type MessageAbortedError = { } } +export type StreamIdleTimeoutError = { + name: "StreamIdleTimeoutError" + data: { + message: string + timeout: number + } +} + export type ApiError = { name: "APIError" data: { @@ -117,7 +125,13 @@ export type AssistantMessage = { created: number completed?: number } - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StreamIdleTimeoutError + | ApiError parentID: string modelID: string providerID: string @@ -576,7 +590,13 @@ export type EventSessionError = { type: "session.error" properties: { sessionID?: string - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StreamIdleTimeoutError + | ApiError } } From b4cbb3c10908ffa4102b7798d5c33cd1faf14082 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 13:27:00 +0100 Subject: [PATCH 6/8] fix(session): bound local tool timeout suspension --- README.md | 5 ++++- packages/cli/src/session/idle.ts | 24 +++++++++++++----------- packages/cli/src/session/processor.ts | 7 ++++++- packages/cli/test/session/idle.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf34b33..0168c67 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,11 @@ In headless mode, Aictrl automatically rejects all interactive permission reques ### CI/CD Integration Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs. +### Model Stream Idle Timeout + 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. +the timer, so long-running responses that continue making progress are unaffected. Local +tool execution uses a ceiling twelve times the configured model timeout (one hour by default). 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. diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts index fa8c634..5e55e0c 100644 --- a/packages/cli/src/session/idle.ts +++ b/packages/cli/src/session/idle.ts @@ -1,9 +1,9 @@ import { MessageV2 } from "./message-v2" export namespace StreamIdle { - function error(ms: number) { + function error(ms: number, message = `Model stream produced no events for ${ms}ms`) { return new MessageV2.StreamIdleTimeoutError({ - message: `Model stream produced no events for ${ms}ms`, + message, timeout: ms, }) } @@ -21,6 +21,7 @@ export namespace StreamIdle { ms: number, abort: () => void, updateSuspended: (value: T) => boolean = () => false, + suspendedTimeout = ms, ) { if (ms === 0) { yield* stream @@ -31,18 +32,19 @@ export namespace StreamIdle { 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 - } const timer = Promise.withResolvers() + const timeout = suspended ? suspendedTimeout : ms const id = setTimeout(() => { - timer.reject(error(ms)) + timer.reject( + error( + timeout, + suspended + ? `Local tool execution produced no result for ${timeout}ms` + : `Model stream produced no events for ${timeout}ms`, + ), + ) abort() - }, ms) + }, timeout) const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) if (next.done) return suspended = updateSuspended(next.value) diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 8f7a7c2..8da5fe4 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -22,6 +22,7 @@ import { NamedError } from "@aictrl/util/error" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 + const LOCAL_TOOL_TIMEOUT_MULTIPLIER = 12 const log = Log.create({ service: "session.processor" }) export type Info = Awaited> @@ -108,7 +109,7 @@ export namespace SessionProcessor { 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) } @@ -117,6 +118,10 @@ export namespace SessionProcessor { } return runningTools.size > 0 }, + Math.min( + Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS * LOCAL_TOOL_TIMEOUT_MULTIPLIER, + Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX, + ), )) { input.abort.throwIfAborted() switch (value.type) { diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index b0aa5d1..abbce58 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -105,6 +105,32 @@ describe("model stream idle timeout", () => { expect(values).toEqual([1, 2, 3]) }) + test("bounds a suspended local tool wait", async () => { + async function* stream() { + yield "tool-call" + await new Promise(() => {}) + } + let aborted = false + const result = StreamIdle.timeout( + stream(), + 10, + () => { + aborted = true + }, + (value) => value === "tool-call", + 30, + ) + + expect(await result.next()).toEqual({ done: false, value: "tool-call" }) + const error = await result.next().catch((value) => value) + expect(aborted).toBe(true) + expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true) + expect(error.data).toEqual({ + message: "Local tool execution produced no result for 30ms", + timeout: 30, + }) + }) + test("zero disables the timeout", async () => { async function* stream() { await Bun.sleep(15) From 4ecd159a0a22004dbfde8180bdda1cd4be86f54f Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 19:50:10 +0100 Subject: [PATCH 7/8] fix(session): cover provider tool execution --- packages/cli/src/session/idle.ts | 12 +- packages/cli/src/session/processor.ts | 11 +- packages/cli/test/flag/flag.test.ts | 32 +++ packages/cli/test/session/idle.test.ts | 33 +-- .../cli/test/session/processor-idle.test.ts | 191 ++++++++++-------- 5 files changed, 145 insertions(+), 134 deletions(-) create mode 100644 packages/cli/test/flag/flag.test.ts diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts index 5e55e0c..1739532 100644 --- a/packages/cli/src/session/idle.ts +++ b/packages/cli/src/session/idle.ts @@ -1,7 +1,7 @@ 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, @@ -33,18 +33,18 @@ export namespace StreamIdle { try { while (true) { const timer = Promise.withResolvers() - const timeout = suspended ? suspendedTimeout : ms + const appliedTimeout = suspended ? suspendedTimeout : ms const id = setTimeout(() => { timer.reject( error( - timeout, + appliedTimeout, suspended - ? `Local tool execution produced no result for ${timeout}ms` - : `Model stream produced no events for ${timeout}ms`, + ? `Tool execution produced no result for ${appliedTimeout}ms` + : `Model stream produced no events for ${appliedTimeout}ms`, ), ) abort() - }, timeout) + }, appliedTimeout) const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id)) if (next.done) return suspended = updateSuspended(next.value) diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 8da5fe4..f853a6d 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -100,16 +100,16 @@ export namespace SessionProcessor { abort: idle.signal, }) const runningTools = new Set() + const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS for await (const value of StreamIdle.timeout( stream.fullStream, - Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS, + idleMs, () => idle.controller.abort(), (value) => { if ( value.type === "tool-call" && - !value.providerExecuted && - typeof streamInput.tools?.[value.toolName]?.execute === "function" + (value.providerExecuted || typeof streamInput.tools?.[value.toolName]?.execute === "function") ) { runningTools.add(value.toolCallId) } @@ -118,10 +118,7 @@ export namespace SessionProcessor { } return runningTools.size > 0 }, - Math.min( - Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS * LOCAL_TOOL_TIMEOUT_MULTIPLIER, - Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX, - ), + Math.min(idleMs * LOCAL_TOOL_TIMEOUT_MULTIPLIER, Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX), )) { input.abort.throwIfAborted() switch (value.type) { diff --git a/packages/cli/test/flag/flag.test.ts b/packages/cli/test/flag/flag.test.ts new file mode 100644 index 0000000..b94d99d --- /dev/null +++ b/packages/cli/test/flag/flag.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { Flag } from "../../src/flag/flag" + +describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { + test("supports default, override, disable, and invalid fallback", () => { + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + + try { + delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1234" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(1234) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1e3" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483647" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(2_147_483_647) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483648" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) +}) diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index abbce58..b6259f1 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { StreamIdle } from "../../src/session/idle" import { MessageV2 } from "../../src/session/message-v2" -import { Flag } from "../../src/flag/flag" describe("model stream idle timeout", () => { test("uses its own abort signal when the caller signal is undefined", () => { @@ -126,7 +125,7 @@ describe("model stream idle timeout", () => { expect(aborted).toBe(true) expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true) expect(error.data).toEqual({ - message: "Local tool execution produced no result for 30ms", + message: "Tool execution produced no result for 30ms", timeout: 30, }) }) @@ -147,33 +146,3 @@ describe("model stream idle timeout", () => { expect(values).toEqual(["done"]) }) }) - -describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { - test("supports default, override, disable, and invalid fallback", () => { - const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - - try { - delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1234" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(1234) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1e3" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483647" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(2_147_483_647) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483648" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) - } finally { - if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original - } - }) -}) diff --git a/packages/cli/test/session/processor-idle.test.ts b/packages/cli/test/session/processor-idle.test.ts index ca60209..d0c549a 100644 --- a/packages/cli/test/session/processor-idle.test.ts +++ b/packages/cli/test/session/processor-idle.test.ts @@ -82,98 +82,111 @@ describe("session processor model stream idle timeout", () => { } }) - test("suspends while a local tool executes and resumes for provider events", async () => { - await using tmp = await tmpdir({ - config: { - enabled_providers: ["alibaba"], - provider: { alibaba: { options: { apiKey: "test-key" } } }, - }, - }) - const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "20" - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({ title: "Slow local tool fixture" }) - const agent = await Agent.get("build") - const model = await Provider.getModel("alibaba", "qwen-plus") - const user = (await Session.updateMessage({ - id: Identifier.ascending("message"), - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent: agent.name, - model: { providerID: model.providerID, modelID: model.id }, - })) as MessageV2.User - const assistant = (await Session.updateMessage({ - id: Identifier.ascending("message"), - sessionID: session.id, - role: "assistant", - parentID: user.id, - modelID: model.id, - providerID: model.providerID, - mode: agent.name, - agent: agent.name, - path: { cwd: tmp.path, root: tmp.path }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: Date.now() }, - })) as MessageV2.Assistant - let sideEffectCompleted = false - const runSlowTool = async () => { - await Bun.sleep(60) - sideEffectCompleted = true - return { output: "done", title: "slow", metadata: {} } - } - const slowTool = tool({ - inputSchema: jsonSchema({ type: "object", additionalProperties: false }), - execute: runSlowTool, - }) - const stream = spyOn(LLM, "stream").mockResolvedValue({ - fullStream: (async function* () { - yield { type: "tool-input-start", id: "call_1", toolName: "slow" } - yield { type: "tool-call", toolCallId: "call_1", toolName: "slow", input: {} } - const output = await runSlowTool() - yield { type: "tool-result", toolCallId: "call_1", toolName: "slow", input: {}, output } - await new Promise(() => {}) - })(), - } as unknown as Awaited>) + test.each([ + ["local", false, true], + ["provider-executed", true, false], + ] as const)( + "suspends while a %s tool executes and resumes for provider events", + async (label, providerExecuted, local) => { + await using tmp = await tmpdir({ + config: { + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key" } } }, + }, + }) + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "20" - try { - const processor = SessionProcessor.create({ - assistantMessage: assistant, + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: `Slow ${label} tool fixture` }) + const agent = await Agent.get("build") + const model = await Provider.getModel("alibaba", "qwen-plus") + const user = (await Session.updateMessage({ + id: Identifier.ascending("message"), sessionID: session.id, - model, - abort: new AbortController().signal, - }) - const result = await processor.process({ - user, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: model.providerID, modelID: model.id }, + })) as MessageV2.User + const assistant = (await Session.updateMessage({ + id: Identifier.ascending("message"), sessionID: session.id, - model, - agent, - abort: new AbortController().signal, - system: [], - messages: [], - tools: { slow: slowTool }, + role: "assistant", + parentID: user.id, + modelID: model.id, + providerID: model.providerID, + mode: agent.name, + agent: agent.name, + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + })) as MessageV2.Assistant + let sideEffectCompleted = false + const runSlowTool = async () => { + await Bun.sleep(60) + sideEffectCompleted = true + return { output: "done", title: "slow", metadata: {} } + } + const slowTool = tool({ + inputSchema: jsonSchema({ type: "object", additionalProperties: false }), + execute: runSlowTool, }) + const stream = spyOn(LLM, "stream").mockResolvedValue({ + fullStream: (async function* () { + yield { type: "tool-input-start", id: "call_1", toolName: "slow" } + yield { type: "tool-call", toolCallId: "call_1", toolName: "slow", input: {}, providerExecuted } + const output = await runSlowTool() + yield { + type: "tool-result", + toolCallId: "call_1", + toolName: "slow", + input: {}, + output, + providerExecuted, + } + await new Promise(() => {}) + })(), + } as unknown as Awaited>) - expect(result).toBe("stop") - expect(sideEffectCompleted).toBe(true) - expect(MessageV2.StreamIdleTimeoutError.isInstance(assistant.error)).toBe(true) - const part = (await MessageV2.parts(assistant.id)).find( - (item) => item.type === "tool" && item.callID === "call_1", - ) - expect(part?.type === "tool" ? part.state.status : undefined).toBe("completed") - } finally { - stream.mockRestore() - } - }, - }) - } finally { - if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original - } - }) + try { + const processor = SessionProcessor.create({ + assistantMessage: assistant, + sessionID: session.id, + model, + abort: new AbortController().signal, + }) + const result = await processor.process({ + user, + sessionID: session.id, + model, + agent, + abort: new AbortController().signal, + system: [], + messages: [], + tools: local ? { slow: slowTool } : {}, + }) + + expect(result).toBe("stop") + expect(sideEffectCompleted).toBe(true) + expect(MessageV2.StreamIdleTimeoutError.isInstance(assistant.error)).toBe(true) + const part = (await MessageV2.parts(assistant.id)).find( + (item) => item.type === "tool" && item.callID === "call_1", + ) + expect(part?.type === "tool" ? part.state.status : undefined).toBe("completed") + } finally { + stream.mockRestore() + } + }, + }) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }, + ) }) From 3ba7a6d150405767541bef31c274d97fd1caa59e Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 30 Sep 2026 15:13:11 +0100 Subject: [PATCH 8/8] fix(session): make stream idle timeout opt-in and preserve history --- EVENTS.md | 4 +- README.md | 29 +++++--- packages/cli/src/cli/cmd/run.errors.ts | 6 +- packages/cli/src/flag/flag.ts | 2 +- packages/cli/src/session/idle.ts | 10 +++ packages/cli/src/session/message-v2.ts | 3 +- packages/cli/src/session/processor.ts | 14 ++-- .../test/cli/classify-session-error.test.ts | 10 +++ packages/cli/test/flag/flag.test.ts | 24 +++++-- packages/cli/test/session/idle.test.ts | 44 ++++++++++++ packages/cli/test/session/message-v2.test.ts | 61 ++++++++++++++++ .../cli/test/session/processor-idle.test.ts | 72 +++++++++++++++++++ 12 files changed, 253 insertions(+), 26 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index fb735bc..e3b7201 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -170,8 +170,8 @@ Emitted immediately before `session_complete` when the session terminates abnorm } ``` -- `reason` (string, **required**) — one of `rate_limit`, `auth`, `timeout`, `oom`, `provider`, `interrupted`, `terminated`, `unknown`. `SIGINT` produces `interrupted`; `SIGTERM` produces `terminated`. Signals are not inferred to be timeouts. -- `code` (string, optional) — provider HTTP status code, error code, or conventional signal-derived exit code (`130` for `SIGINT`, `143` for `SIGTERM`) when available. +- `reason` (string, **required**) — one of `rate_limit`, `auth`, `timeout`, `oom`, `provider`, `interrupted`, `terminated`, `unknown`. A model stream idle timeout produces `timeout`; `SIGINT` produces `interrupted`; `SIGTERM` produces `terminated`. Signals are not inferred to be timeouts. +- `code` (string, optional) — provider HTTP status code, error code, or conventional signal-derived exit code (`130` for `SIGINT`, `143` for `SIGTERM`) when available. A model stream idle timeout emits `MODEL_STREAM_IDLE_TIMEOUT` and persists a `StreamIdleTimeoutError` on the assistant message. - `message` (string, **required**) — human-readable error message. ## Message Events diff --git a/README.md b/README.md index 0168c67..1011452 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ aictrl run --model anthropic/claude-3-5-sonnet-latest "refactor the auth module" Aictrl is "headless first". When run in a non-TTY environment, it automatically switches to a mode optimized for automation. ### Stdin Piping + You can pipe content directly into `aictrl`. This is useful for processing logs, code, or command output. ```bash @@ -41,6 +42,7 @@ cat logs.txt | aictrl run "summarize these errors" ``` ### JSON Output + For programmatic consumption, use `--format json` to get raw events. ```bash @@ -48,31 +50,37 @@ aictrl run --format json "review this PR" | jq '.type' ``` ### Non-Interactive Execution + In headless mode, Aictrl automatically rejects all interactive permission requests (like `question` or `plan_enter`), ensuring your pipelines never hang. ### CI/CD Integration + Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs. ### Model Stream Idle Timeout -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. Local -tool execution uses a ceiling twelve times the configured model timeout (one hour by default). -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. +Model stream idle timeouts are disabled by default. Set +`AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` to a decimal integer of milliseconds from 1 +through 2147483647 to enable one; for example, `300000` sets a five-minute timeout. +`0` disables it. Missing, empty, negative, fractional, non-decimal, non-numeric, or +unsupported values leave it disabled. The timer covers model stream setup and +resets after every stream event, so responses that keep making progress are unaffected. +Tool execution (local or provider-executed) uses a ceiling twelve times the +configured model timeout, capped at 2147483647 ms (one hour for a five-minute timeout). ## GitHub Integration Aictrl includes a specialized GitHub agent that can be installed into your repositories to automate PR reviews, issue triage, and code generation. ### Setup + ```bash # Install the GitHub agent in the current repo aictrl github install ``` ### Features + - **Auto-Push:** The agent can commit and push changes directly to your branches. - **PR Creation:** It can automatically open Pull Requests for its changes. - **Context Aware:** In GitHub Actions, it automatically fetches PR diffs, issue comments, and review history. @@ -81,17 +89,21 @@ aictrl github install ## Developer Workflow ### PR Checkout + Engineers can quickly checkout a PR and import the associated agent session: ```bash aictrl pr 123 ``` + This command will: + 1. Fetch and checkout PR #123. 2. Detect if an Aictrl session was used to generate the PR. 3. Import that session locally so you can continue the conversation. ### MCP & Custom Tools + Aictrl supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). ```bash @@ -110,11 +122,11 @@ Embed Aictrl directly into your TypeScript applications. import { createAictrlClient } from "@aictrl/sdk" const client = createAictrlClient({ - baseUrl: "http://localhost:4096" + baseUrl: "http://localhost:4096", }) const session = await client.session.create({ - title: "My Automation Task" + title: "My Automation Task", }) ``` @@ -131,4 +143,5 @@ aictrl acp Aictrl is a fork of the [OpenCode](https://opencode.ai) project and is licensed under the MIT License. --- + [aictrl.dev](https://aictrl.dev/?utm_medium=referral&utm_source=github&utm_campaign=cli&utm_content=readme) diff --git a/packages/cli/src/cli/cmd/run.errors.ts b/packages/cli/src/cli/cmd/run.errors.ts index e8e572c..5c34894 100644 --- a/packages/cli/src/cli/cmd/run.errors.ts +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -40,13 +40,13 @@ export function classifySessionError(err: unknown): ClassifiedSessionError { } function extractMessage(err: unknown): string { - if (err instanceof Error) return err.message - if (typeof err === "string") return err - if (err && typeof err === "object" && "message" in err) return String((err as { message: unknown }).message) if (err && typeof err === "object" && "data" in err) { const data = (err as { data: unknown }).data if (data && typeof data === "object" && "message" in data) return String((data as { message: unknown }).message) } + if (err instanceof Error) return err.message + if (typeof err === "string") return err + if (err && typeof err === "object" && "message" in err) return String((err as { message: unknown }).message) return String(err) } diff --git a/packages/cli/src/flag/flag.ts b/packages/cli/src/flag/flag.ts index 0c45cda..fe66672 100644 --- a/packages/cli/src/flag/flag.ts +++ b/packages/cli/src/flag/flag.ts @@ -4,7 +4,7 @@ function truthy(key: string) { } export namespace Flag { - export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 5 * 60 * 1000 + export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_DEFAULT = 0 export const AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX = 2_147_483_647 export const AICTRL_GIT_BASH_PATH = process.env["AICTRL_GIT_BASH_PATH"] export const AICTRL_CONFIG = process.env["AICTRL_CONFIG"] diff --git a/packages/cli/src/session/idle.ts b/packages/cli/src/session/idle.ts index 1739532..3759b11 100644 --- a/packages/cli/src/session/idle.ts +++ b/packages/cli/src/session/idle.ts @@ -16,6 +16,16 @@ export namespace StreamIdle { } } + export async function wait(promise: Promise, ms: number, abort: () => void): Promise { + if (ms === 0) return promise + const timer = Promise.withResolvers() + const id = setTimeout(() => { + timer.reject(error(ms, `Model stream setup produced no result for ${ms}ms`)) + abort() + }, ms) + return Promise.race([promise, timer.promise]).finally(() => clearTimeout(id)) + } + export async function* timeout( stream: AsyncIterable, ms: number, diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 84339eb..972f6f3 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -616,7 +616,8 @@ export namespace MessageV2 { if ( msg.info.error && !( - MessageV2.AbortedError.isInstance(msg.info.error) && + (MessageV2.AbortedError.isInstance(msg.info.error) || + MessageV2.StreamIdleTimeoutError.isInstance(msg.info.error)) && msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") ) ) { diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index f853a6d..d2da3a7 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -94,13 +94,17 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} + const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS const idle = StreamIdle.signal(streamInput.abort) - const stream = await LLM.stream({ - ...streamInput, - abort: idle.signal, - }) + const stream = await StreamIdle.wait( + LLM.stream({ + ...streamInput, + abort: idle.signal, + }), + idleMs, + () => idle.controller.abort(), + ) const runningTools = new Set() - const idleMs = Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS for await (const value of StreamIdle.timeout( stream.fullStream, diff --git a/packages/cli/test/cli/classify-session-error.test.ts b/packages/cli/test/cli/classify-session-error.test.ts index fc173e4..6f7e875 100644 --- a/packages/cli/test/cli/classify-session-error.test.ts +++ b/packages/cli/test/cli/classify-session-error.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { classifySessionError } from "../../src/cli/cmd/run.errors" +import { MessageV2 } from "../../src/session/message-v2" describe("classifySessionError (#63)", () => { test("model stream idle timeout has a stable timeout code", () => { @@ -15,6 +16,15 @@ describe("classifySessionError (#63)", () => { }) }) + test("live model stream timeout reports its human-readable message", () => { + const error = new MessageV2.StreamIdleTimeoutError({ message: "Model stream stalled", timeout: 25 }) + expect(classifySessionError(error)).toEqual({ + reason: "timeout", + code: "MODEL_STREAM_IDLE_TIMEOUT", + message: "Model stream stalled", + }) + }) + test("HTTP 429 → rate_limit", () => { const res = classifySessionError({ status: 429, message: "Rate limit exceeded" }) expect(res.reason).toBe("rate_limit") diff --git a/packages/cli/test/flag/flag.test.ts b/packages/cli/test/flag/flag.test.ts index b94d99d..94bff49 100644 --- a/packages/cli/test/flag/flag.test.ts +++ b/packages/cli/test/flag/flag.test.ts @@ -2,28 +2,40 @@ import { describe, expect, test } from "bun:test" import { Flag } from "../../src/flag/flag" describe("AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", () => { + test("runtime and event documentation describe the opt-in timeout contract", async () => { + const [readme, events] = await Promise.all([ + Bun.file(new URL("../../../../README.md", import.meta.url)).text(), + Bun.file(new URL("../../../../EVENTS.md", import.meta.url)).text(), + ]) + expect(readme).toContain("idle timeouts are disabled by default") + expect(readme).toContain("`300000` sets a five-minute timeout") + expect(readme).toContain("provider-executed") + expect(events).toContain("A model stream idle timeout produces `timeout`") + expect(events).toContain("MODEL_STREAM_IDLE_TIMEOUT") + }) + test("supports default, override, disable, and invalid fallback", () => { const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS try { delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1234" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(1234) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "invalid" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "1e3" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "0x10" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483647" expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(2_147_483_647) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "2147483648" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "9007199254740992" - expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(300_000) + expect(Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS).toBe(0) } finally { if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original diff --git a/packages/cli/test/session/idle.test.ts b/packages/cli/test/session/idle.test.ts index b6259f1..d6162ac 100644 --- a/packages/cli/test/session/idle.test.ts +++ b/packages/cli/test/session/idle.test.ts @@ -1,8 +1,52 @@ import { describe, expect, test } from "bun:test" import { StreamIdle } from "../../src/session/idle" import { MessageV2 } from "../../src/session/message-v2" +import { Flag } from "../../src/flag/flag" describe("model stream idle timeout", () => { + test("missing configuration lets a quiet stream complete", async () => { + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + try { + async function* stream() { + await Bun.sleep(20) + yield "ready" + } + const values = [] + for await (const value of StreamIdle.timeout(stream(), Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS, () => { + throw new Error("default timeout should not abort") + })) { + values.push(value) + } + expect(values).toEqual(["ready"]) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) + + test("disabled setup timeout waits for provider preparation", async () => { + const result = await StreamIdle.wait( + Bun.sleep(20).then(() => "ready"), + 0, + () => { + throw new Error("disabled timeout should not abort") + }, + ) + expect(result).toBe("ready") + }) + + test("enabled setup timeout aborts stalled provider preparation", async () => { + const pending = Promise.withResolvers() + let aborted = false + const error = await StreamIdle.wait(pending.promise, 10, () => { + aborted = true + }).catch((error) => error) + expect(aborted).toBe(true) + expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true) + expect(error.data).toEqual({ message: "Model stream setup produced no result for 10ms", timeout: 10 }) + }) + test("uses its own abort signal when the caller signal is undefined", () => { const idle = StreamIdle.signal() diff --git a/packages/cli/test/session/message-v2.test.ts b/packages/cli/test/session/message-v2.test.ts index 6604f80..c698957 100644 --- a/packages/cli/test/session/message-v2.test.ts +++ b/packages/cli/test/session/message-v2.test.ts @@ -104,6 +104,67 @@ function basePart(messageID: string, id: string) { } describe("session.message-v2.toModelMessage", () => { + test("retains completed tool results after an idle timeout", () => { + const userID = "m-user" + const assistantID = "m-assistant" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [{ ...basePart(userID, "u1"), type: "text", text: "deploy" }] as MessageV2.Part[], + }, + { + info: assistantInfo( + assistantID, + userID, + new MessageV2.StreamIdleTimeoutError({ message: "stream stalled", timeout: 25 }).toObject(), + ), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-deploy", + tool: "bash", + state: { + status: "completed", + input: { cmd: "deploy" }, + output: "deployed", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { role: "user", content: [{ type: "text", text: "deploy" }] }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-deploy", + toolName: "bash", + input: { cmd: "deploy" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-deploy", + toolName: "bash", + output: { type: "text", value: "deployed" }, + }, + ], + }, + ]) + }) + test("drops reasoning-only assistant turns from rebuilt history", () => { const input: MessageV2.WithParts[] = [ { diff --git a/packages/cli/test/session/processor-idle.test.ts b/packages/cli/test/session/processor-idle.test.ts index d0c549a..c1cb619 100644 --- a/packages/cli/test/session/processor-idle.test.ts +++ b/packages/cli/test/session/processor-idle.test.ts @@ -14,6 +14,78 @@ import { MessageV2 } from "../../src/session/message-v2" import { tmpdir } from "../fixture/fixture" describe("session processor model stream idle timeout", () => { + test("times out stalled model stream setup when enabled", async () => { + await using tmp = await tmpdir({ + config: { + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key" } } }, + }, + }) + const original = process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = "20" + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Stalled setup fixture" }) + const agent = await Agent.get("build") + const model = await Provider.getModel("alibaba", "qwen-plus") + const user = (await Session.updateMessage({ + id: Identifier.ascending("message"), + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: model.providerID, modelID: model.id }, + })) as MessageV2.User + const assistant = (await Session.updateMessage({ + id: Identifier.ascending("message"), + sessionID: session.id, + role: "assistant", + parentID: user.id, + modelID: model.id, + providerID: model.providerID, + mode: agent.name, + agent: agent.name, + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + })) as MessageV2.Assistant + const stream = spyOn(LLM, "stream").mockImplementation(() => new Promise(() => {})) + + try { + const processor = SessionProcessor.create({ + assistantMessage: assistant, + sessionID: session.id, + model, + abort: new AbortController().signal, + }) + const result = await processor.process({ + user, + sessionID: session.id, + model, + agent, + abort: new AbortController().signal, + system: [], + messages: [], + tools: {}, + }) + expect(result).toBe("stop") + expect(MessageV2.StreamIdleTimeoutError.isInstance(assistant.error)).toBe(true) + expect(assistant.error?.data.message).toContain("setup") + } finally { + stream.mockRestore() + } + }, + }) + } finally { + if (original === undefined) delete process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS + else process.env.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS = original + } + }) + test("aborts a stalled provider stream, records the timeout, and returns the session to idle", async () => { using server = Bun.serve({ port: 0,