diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2f41ed22..2899d8b1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+## v1.1.1 (2026-08-31)
+- Added Code and Minimal tool modes, on-demand tool discovery, and permission-scoped CLI tool access
+- Added a Tape Trace Inspector for Agent runs, provider requests, tool calls, and Skill usage
+- Improved long-context compaction and overflow recovery while preserving full conversation history
+- Unified Skills into a shared library with per-Agent enablement and progressive loading, and restored manual installation from folders, ZIP archives, URLs, and Git repositories
+- Added managed Node.js and uv toolchains with detection, installation, repair, and recovery, plus terminal authentication for ACP agents
+- Redesigned provider Settings with staged credential verification, API key copying, and direct chat entry; added validated custom request headers with cross-origin protections
+- Added APIMart, Synthorai, and RunInfra providers, native APIMart image and video generation, and Codex image generation support
+- Upgraded Markstream to 2.0.6 and reduced Markdown streaming stalls with split rendering and a managed worker pool
+- Improved workspace navigation, conversation retry, and permission prompt recovery, and reduced startup delays
+- Made Agent stop requests interrupt stalled provider and browser waits, and added configurable Code Mode cell timeouts
+- Repaired usage statistics and MCP startup, retained corrupt databases for recovery, and fixed Ollama context budgets and provider timeout handling
+- Refreshed the built-in model and ACP catalogs
+- 新增 Code 与 Minimal 工具模式、按需工具发现,以及受权限约束的 CLI 工具访问
+- 新增 Tape Trace Inspector,用于查看 Agent 运行、Provider 请求、工具调用与 Skill 使用记录
+- 改进长上下文压缩与溢出恢复,同时保留完整对话历史
+- 将 Skills 统一为共享库,支持逐 Agent 启用与渐进式加载,并恢复从文件夹、ZIP 压缩包、URL 与 Git 仓库手动安装
+- 新增托管式 Node.js 与 uv 工具链,支持检测、安装、修复与恢复,并为 ACP Agent 新增终端身份验证
+- 重做 Provider 设置,支持暂存并验证凭据、复制 API Key 和直接开始聊天;新增经过验证且具备跨域保护的自定义请求头
+- 新增 APIMart、Synthorai 与 RunInfra Provider,支持 APIMart 原生图片与视频生成,以及 Codex 图片生成
+- 将 Markstream 升级至 2.0.6,并通过分段渲染与受控 Worker 池减少 Markdown 流式渲染卡顿
+- 改进工作区导航、对话重试与权限提示恢复,并减少启动延迟
+- 使 Agent 停止请求能够中断卡住的 Provider 与浏览器等待,并支持配置 Code Mode 代码单元超时
+- 修复用量统计与 MCP 启动,保留损坏数据库以便恢复,并修复 Ollama 上下文预算与 Provider 超时处理
+- 刷新内置模型与 ACP 目录
+
## v1.1.1-beta.5 (2026-08-27)
- Added built-in APIMart and Synthorai providers, including native APIMart image and video generation
- Added validated per-provider custom request headers with cross-origin protections
diff --git a/README.jp.md b/README.jp.md
index a6fe3569d..0f600caa5 100644
--- a/README.jp.md
+++ b/README.jp.md
@@ -42,6 +42,37 @@
href="https://go.apimart.ai/gh-deepchat">こちらから登録して利用を開始できます。
+
+
+
+
+
+
+
+ 本プロジェクトをご支援いただいている OpenModel に感謝します!OpenModel は、対応モデルで
+ Codex に最大 90%、Claude Code に最大 60%
+ のクライアント専用割引を提供し、開発者が作業方法を変えずに API
+ コストを削減できるよう支援します。OpenModel API キーを接続するだけで、これまでどおり Codex
+ または Claude Code
+ を使用できます。対応クライアントは自動的に検出され、追加パラメーターや手動ルーティングなしで割引ルートが適用されます。また、API
+ キーごとにフォールバック動作を設定し、割引ルートが一時的に利用できない場合に、標準料金で利用を続けるか停止するかを選択できます。DeepChat
+ ユーザーはこちら から登録できます。
+
+
+
+
+
+
+
+
+
+ PackyCode は、Claude Code、Codex、Gemini
+ などの中継サービスを提供する、安定性と高いパフォーマンスを備えた API
+ 中継プロバイダーです。自動フェイルオーバー、スマートルーティング、無制限の同時実行などの機能により、AI
+ を真の生産性向上ツールにします。こちら から登録して、今すぐ利用を開始できます。
+
+
## 📑 目次
diff --git a/README.md b/README.md
index 629a98c8d..776f702f4 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,36 @@
here to get started.
+
+
+
+
+
+
+
+ Thanks to OpenModel for sponsoring this project! OpenModel offers client-specific discounts of
+ up to 90% for Codex and 60% for Claude Code across supported models, helping developers cut API
+ costs without changing how they work. Just connect your OpenModel API key and keep using Codex
+ or Claude Code as usual — supported clients are detected automatically and discounted routes
+ are applied with no extra parameters or manual routing required. You can also configure
+ fallback behavior per API key, choosing whether to continue at standard pricing or stop when a
+ discounted route is temporarily unavailable. DeepChat users can register via this link .
+
+
+
+
+
+
+
+
+
+ PackyCode is a stable, high-performance API relay provider, offering relay services for Claude
+ Code, Codex, Gemini, and more. With automatic failover, smart routing, and unlimited
+ concurrency, it turns AI into a real productivity tool. Register via this link and get started!
+
+
## 📑 Table of Contents
diff --git a/README.zh.md b/README.zh.md
index 2039f2aeb..61562a5c8 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -40,6 +40,34 @@
href="https://go.apimart.ai/gh-deepchat">注册链接注册即可开用。
+
+
+
+
+
+
+
+ 感谢 OpenModel 对本项目的支持!OpenModel 为 Codex 和 Claude Code 提供高达 90%
+ 的客户专属折扣,涵盖所有支持的模型,帮助开发者在不改变工作方式的前提下降低 API
+ 成本。只需连接您的 OpenModel API 密钥,即可像往常一样继续使用 Codex 或 Claude
+ Code——系统会自动检测支持的客户端,并应用折扣路由,无需额外参数或手动路由。您还可以为每个
+ API 密钥配置备用方案,选择在折扣路由暂时不可用时继续按标准价格计费或停止计费。DeepChat
+ 用户可以通过此链接 注册。
+
+
+
+
+
+
+
+
+
+ PackyCode 是一家稳定、高效的 API 中转服务商,提供 Claude Code、Codex、Gemini
+ 等多种中转服务。具备自动故障转移、智能路由和无限并发等多种功能,让 AI
+ 编程成为真正的生产力工具。点击此链接 注册,立即开始使用!
+
+
## 📑 目录
diff --git a/assets/sponsors/openmodel.png b/assets/sponsors/openmodel.png
new file mode 100644
index 000000000..d5e78451e
Binary files /dev/null and b/assets/sponsors/openmodel.png differ
diff --git a/assets/sponsors/packycode.png b/assets/sponsors/packycode.png
new file mode 100644
index 000000000..7c91a1c8d
Binary files /dev/null and b/assets/sponsors/packycode.png differ
diff --git a/docs/features/experimental-tool-modes/spec.md b/docs/features/experimental-tool-modes/spec.md
index 0c89b52b5..a55294c48 100644
--- a/docs/features/experimental-tool-modes/spec.md
+++ b/docs/features/experimental-tool-modes/spec.md
@@ -40,8 +40,8 @@ Agent Mode 是兼容基线。工具 registry、disabled-tool 配置、权限、
Code Mode 保留当前已启用工具的能力,但不把这些工具逐个发送给模型:
- `openai-codex` 路由使用 Codex 风格的 raw JavaScript `exec` 和续跑 `wait`;
-- 其他 function-tool 路由只发送 `run_code`,参数固定为
- `{ code: string, description: string }`;
+- Function-tool routes expose `run_code` with
+ `{ code: string, description: string, timeout_ms?: number }`;
- 两种入口都进入同一个 `RunCodeRuntimeManager`,不存在 `transport` 或第二套 runtime;
- function-tool 路由把当前目录生成成 TypeScript SDK,Codex 路由把嵌套声明写入 `exec`
描述;
@@ -188,11 +188,18 @@ IPC allowlist、V8 内存限制、heartbeat 和强制回收的组合。
| READY | 5 秒 |
| heartbeat 丢失 | 约 3.5 秒后终止 |
| VM 同步执行 slice | 2 秒 |
-| cell 总执行时间 | 5 分钟 |
+| cell 总执行时间 | Default 5 minutes; optional `timeout_ms` override |
| yielded / permission cell lease | 60 秒 |
| RSS hard ceiling | 512 MiB |
| STOP grace | 500 ms 后 `kill()` |
+`timeout_ms` is a positive integer in milliseconds, up to `2147483647`. Function-tool routes pass
+it as an optional `run_code` argument; Codex routes use the first-line `exec` pragma. Models omit
+it for routine work and request a larger value only when the operation is expected to exceed five
+minutes. The deadline covers the cell and its awaited subtools, is not reset by `wait` or permission
+continuation, and does not extend a subtool's own timeout. User cancellation remains independent
+of the selected duration. Invalid timeout values fail before dispatch.
+
成功、异常、取消、超时、进程退出、会话清理和应用退出都进入同一个幂等 cleanup:取消嵌套
调用,清理 timer/listener/active map,发送 `STOP`,超时后强制 `kill()`。失败 cell 不自动
重放,避免重复执行 Shell、文件或 MCP 副作用。
diff --git a/docs/issues/agent-cancellation-code-timeout/spec.md b/docs/issues/agent-cancellation-code-timeout/spec.md
new file mode 100644
index 000000000..7e9e498b0
--- /dev/null
+++ b/docs/issues/agent-cancellation-code-timeout/spec.md
@@ -0,0 +1,92 @@
+# Agent Cancellation and Code Cell Timeouts
+
+## Context
+
+[Issue #2221](https://github.com/ThinkInAIXYZ/deepchat/issues/2221) reports a Code Mode run that
+stopped progressing and could not be stopped by the user. Its partial trace records a provider
+429 followed by a scheduled retry; it does not identify the complete incident sequence.
+
+Independent reproduction identifies two cancellation boundaries that require protection:
+
+- A provider iterator can remain pending after the Run signal is aborted. Waiting for its next
+ event or asynchronous cleanup must not prevent attempt settlement.
+- An unresolved browser Promise can leave a YoBrowser CDP command and its activity waiting
+ indefinitely unless both observe the tool cancellation signal.
+
+Code cells already have a five-minute execution deadline. That default is useful, but a longer
+operation must be able to request a longer cell lifetime without weakening user cancellation.
+
+## Contract
+
+### User Cancellation
+
+- Deliver the Run signal to the provider immediately. The provider-attempt owner also interrupts
+ its own iterator wait if the provider ignores that signal.
+- Request iterator cleanup without waiting indefinitely after cancellation. Observe late
+ rejections, discard late output, and record the attempt as aborted exactly once.
+- Preserve usage observed before cancellation and existing no-replay rules for committed output.
+- Browser tools observe cancellation while waiting and check the signal immediately before
+ dispatch. A canceled readiness wait must not dispatch a CDP command later.
+- Cancellation ends browser activity without projecting a late response into the canceled run.
+ It does not claim to undo browser or external side effects already dispatched.
+- Code cell startup and execution both observe cancellation. An abandoned utility process must
+ be reclaimed even if it becomes ready after cancellation.
+- Do not add a whole-run deadline or infer failure merely from a lack of output.
+
+### Code Cell Execution Limit
+
+- `run_code` accepts optional `timeout_ms`, an integer duration in milliseconds. Omitting it uses
+ `300000` milliseconds, or five minutes. `code` and `description` remain the only required fields.
+- The valid range is `1` through `2147483647`, the host timer's supported range. Invalid values fail
+ before a utility process or tool dispatch starts.
+- The Code Mode `exec` frontend accepts the same optional field in its first-line pragma, because
+ both frontends share the same cell runtime.
+- The limit covers one cell, including awaited subtools. It starts when the cell is attached and
+ does not restart on yield, `wait`, or a permission continuation.
+- The tool description, parameter description, and SDK prompt all instruct the model to omit the
+ field for routine work and set a larger value only when the current operation is expected to
+ exceed five minutes. A cell override does not extend a subtool's own timeout.
+- User cancellation always takes precedence over a longer requested timeout. Existing source,
+ output, concurrency, heartbeat, memory, startup, and abandoned-cell limits remain unchanged.
+
+## Ownership and Scope
+
+`DeepChatContextCoordinator` owns provider iterator cancellation and attempt provenance.
+`AgentToolManager`, `YoBrowserToolHandler`, and the existing browser command path carry the tool
+signal through dispatch and response handling. `ToolService` validates the public timeout input;
+`RunCodeRuntimeManager` applies the selected cell deadline. Code Mode definitions and generated
+SDK text expose the same contract.
+
+This change does not modify provider retry budgets, add dependencies, introduce a new task
+scheduler, change UI layout, automatically replay canceled work, or add an unlimited timeout mode.
+
+## Acceptance Criteria
+
+- Stopping a silent provider settles the attempt without needing another provider event or a
+ cooperative iterator `return()`; late output does not start a tool or create another outcome.
+- A normal long-running operation is not canceled merely because it is silent.
+- Stopping a pending CDP call returns control and prevents delayed dispatch after readiness.
+- An omitted cell timeout still expires at five minutes; an explicit longer timeout survives that
+ boundary and expires at the requested duration.
+- A cell with an extended timeout still stops promptly when the user cancels.
+- Invalid timeout values do not dispatch work, and both model-facing frontends document the
+ default, omission guidance, and extension semantics.
+
+## Implementation and Validation
+
+- [x] Provider and browser waits observe cancellation without requiring downstream cooperation.
+- [x] Utility startup observes cancellation; both frontends accept the optional cell timeout.
+- [x] The maintained Code Mode contract and model-facing instructions describe the same behavior.
+- [x] Regression coverage protects cancellation, late completion, and timeout overrides.
+- [x] Formatting, i18n, lint, type checks, and relevant test suites pass.
+
+Validation uses Node 24.18.0 and pnpm 10.34.5:
+
+- `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck` pass.
+- 407 main-process tests pass across 13 relevant suites: Code Mode, ToolService, browser tools,
+ provider attempts and retries, stream processing, Run lifecycle, and abortable waits.
+- All three ChatPage stop-request tests pass, covering duplicate requests, unsuccessful stop
+ responses, and rejected stop requests.
+- Fake-clock cases verify the five-minute default, a ten-minute override, and explicit
+ cancellation of a provider silent for fifteen minutes and a CDP command silent for ten minutes.
+ Browser integration cases use mocked Electron boundaries; no live provider credentials are used.
diff --git a/package.json b/package.json
index f398a3093..5bbded7c7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "DeepChat",
- "version": "1.1.1-beta.5",
+ "version": "1.1.1",
"description": "DeepChat,一个简单易用的 Agent 客户端",
"main": "./out/main/index.js",
"author": "ThinkInAIXYZ",
@@ -172,9 +172,9 @@
"@iconify/vue": "^5.0.1",
"@lingual/i18n-check": "0.9.5",
"@lucide/vue": "^1.24.0",
+ "@pierre/diffs": "^1.2.12",
"@pinia/colada": "^0.20.0",
"@playwright/test": "^1.60.0",
- "@pierre/diffs": "^1.2.12",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tiptap/core": "^2.27.2",
@@ -206,7 +206,7 @@
"electron-vite": "5.0.0",
"jsdom": "^26.1.0",
"katex": "^0.16.47",
- "markstream-vue": "2.0.1-beta.1",
+ "markstream-vue": "2.0.6",
"mermaid": "^11.16.1",
"minimatch": "^10.2.5",
"monaco-editor": "^0.55.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b090b9a13..7c5505f9c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -318,8 +318,8 @@ importers:
specifier: ^0.16.47
version: 0.16.47
markstream-vue:
- specifier: 2.0.1-beta.1
- version: 2.0.1-beta.1(@antv/infographic@0.2.19)(katex@0.16.47)(mermaid@11.16.1)(stream-diffs@0.0.2(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue-i18n@11.4.7(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2))
+ specifier: 2.0.6
+ version: 2.0.6(@antv/infographic@0.2.19)(katex@0.16.47)(mermaid@11.16.1)(stream-diffs@0.0.2(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue-i18n@11.4.7(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2))
mermaid:
specifier: ^11.16.1
version: 11.16.1
@@ -5312,8 +5312,8 @@ packages:
markdown-it-task-checkbox@1.0.6:
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
- markdown-it-ts@1.0.10:
- resolution: {integrity: sha512-8FIKgiTAewMr67TMwikpwMj2u3dVSBLMJ9RZOG4HdqjE0Q+g0o3wDT1LFOXSddQr+RAr6ZT4PFiICU4kRX1l5Q==}
+ markdown-it-ts@1.1.0:
+ resolution: {integrity: sha512-ycYoj3jeP0zHW9u4vxtSuuq9JaitIRxxYbRtns9D4fDR5mzSpFnb855KclFBGiKFJxVi+8EkMB938oZpdDVc0A==}
engines: {node: '>=20.19'}
markdown-it@14.3.0:
@@ -5330,11 +5330,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
- markstream-core@2.0.0:
- resolution: {integrity: sha512-9SXYsUiZfV7CUC41c6hEkxFD6VbQKMk+RjGOvfr6iKTAtv/aPAnA2IuQYrpwkvbrrZUgYVjgL1I30yg6y/mtIw==}
+ markstream-core@2.0.6:
+ resolution: {integrity: sha512-kSGNAoTArTu9LxwyfZI+fuxanjhxwPtlW+yJM+5IJhW2jtsH9p7mSpApHC6YtJLnzeXCGS2XlqIoivzIdzy6iA==}
- markstream-vue@2.0.1-beta.1:
- resolution: {integrity: sha512-Yq4/fbT9AzRjEv/FqL3QbM2no2rgTt7Xt0R6G37dpcXiJauXL/FBWtM6/VP3APJVkWJ1EMQg1Ryut4PIS+zawg==}
+ markstream-vue@2.0.6:
+ resolution: {integrity: sha512-87skNadimBbQFZbb6kNgIrCpXr5x63YW4MYkRbWHh/g/1+BHJ2t3iIzEsSIIES1yn29STf4ZLw1ruepZqXZeXg==}
peerDependencies:
'@antv/infographic': ^0.2.3
'@terrastruct/d2': '>=0.1.33'
@@ -6382,8 +6382,8 @@ packages:
vue:
optional: true
- stream-markdown-parser@1.2.8:
- resolution: {integrity: sha512-9owCFbTxBLWMWh0cXEkYq1OS2EsYpUX7WFCcZ4RRmevWDHxLJvCeI8glU0aw70GtFhGuNgNjL9cUkoleVcIzfg==}
+ stream-markdown-parser@1.2.12:
+ resolution: {integrity: sha512-gT6GWlOfNTVGBEM66Me1DZ1cZZ/Mm4tcpJ4br8Zvz2CQhiWueEv+xFBBHCwls/XG4E63DygTd890rDdV+D6JxA==}
stream-monaco@0.0.49:
resolution: {integrity: sha512-ksZYJXw45NralnpgWqcptqrgDdbGZaTyYmvATvhlK8eY0tyeTxs0iuQzMTF4cpGf5OlIgkNJOfbFh4M97OozSw==}
@@ -12178,7 +12178,7 @@ snapshots:
markdown-it-task-checkbox@1.0.6: {}
- markdown-it-ts@1.0.10:
+ markdown-it-ts@1.1.0:
dependencies:
entities: 8.0.0
linkify-it: 6.1.0
@@ -12198,14 +12198,14 @@ snapshots:
marked@16.4.2: {}
- markstream-core@2.0.0: {}
+ markstream-core@2.0.6: {}
- markstream-vue@2.0.1-beta.1(@antv/infographic@0.2.19)(katex@0.16.47)(mermaid@11.16.1)(stream-diffs@0.0.2(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue-i18n@11.4.7(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)):
+ markstream-vue@2.0.6(@antv/infographic@0.2.19)(katex@0.16.47)(mermaid@11.16.1)(stream-diffs@0.0.2(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue-i18n@11.4.7(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)))(vue@3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)):
dependencies:
'@chenglou/pretext': 0.0.8
'@floating-ui/dom': 1.8.0
- markstream-core: 2.0.0
- stream-markdown-parser: 1.2.8
+ markstream-core: 2.0.6
+ stream-markdown-parser: 1.2.12
vue: 3.5.40(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2)
optionalDependencies:
'@antv/infographic': 0.2.19
@@ -13375,7 +13375,7 @@ snapshots:
- react
- react-dom
- stream-markdown-parser@1.2.8:
+ stream-markdown-parser@1.2.12:
dependencies:
markdown-it-container: 4.0.0
markdown-it-footnote: 4.0.0
@@ -13383,7 +13383,7 @@ snapshots:
markdown-it-mark: 4.0.0
markdown-it-sup: 2.0.0
markdown-it-task-checkbox: 1.0.6
- markdown-it-ts: 1.0.10
+ markdown-it-ts: 1.1.0
stream-monaco@0.0.49(monaco-editor@0.55.1):
dependencies:
diff --git a/src/main/agent/deepchat/loop/contextCoordinator.ts b/src/main/agent/deepchat/loop/contextCoordinator.ts
index 325f09bff..5122b059e 100644
--- a/src/main/agent/deepchat/loop/contextCoordinator.ts
+++ b/src/main/agent/deepchat/loop/contextCoordinator.ts
@@ -23,6 +23,7 @@ import type { MCPToolDefinition } from '@shared/types/core/mcp'
import type { ModelConfig } from '@shared/types/provider'
import type { DeepChatExecutionContract } from '@shared/types/execution-contract'
import { isDeepStrictEqual } from 'node:util'
+import { awaitWithAbort } from '@/lib/awaitWithAbort'
import type {
DeepChatProviderAttemptIdentity,
DeepChatProviderAttemptOrigin,
@@ -559,30 +560,52 @@ async function* observeProviderAttempt(input: {
}): AsyncGenerator {
const { observation } = input
try {
- for await (const event of input.provider.stream(input.streamInput)) {
- if (!input.bypassContextBudget && input.isContextOverflowEvent(event)) {
- observation.contextOverflowObserved = true
- const facts = input.inspectContextOverflow?.(event)
- if (facts?.matched) {
- observation.contextOverflowFacts = facts
- input.onContextOverflowFacts?.(facts)
+ const stream = input.provider.stream(input.streamInput)
+ let finished = false
+ try {
+ while (true) {
+ input.streamInput.signal.throwIfAborted()
+ const next = await awaitWithAbort(stream.next(), input.streamInput.signal)
+ input.streamInput.signal.throwIfAborted()
+ if (next.done) {
+ finished = true
+ break
}
- }
- if (event.type === 'usage') {
- observation.usage = providerAttemptUsageFromEvent(event)
- } else if (event.type === 'error') {
- observation.errorEvent = event
- observation.stopReason = 'error'
- } else if (event.type === 'stop') {
- observation.stopEvent = event
- if (observation.stopReason !== 'error') {
- observation.stopReason = event.stop_reason
+ const event = next.value
+ if (!input.bypassContextBudget && input.isContextOverflowEvent(event)) {
+ observation.contextOverflowObserved = true
+ const facts = input.inspectContextOverflow?.(event)
+ if (facts?.matched) {
+ observation.contextOverflowFacts = facts
+ input.onContextOverflowFacts?.(facts)
+ }
+ }
+ if (event.type === 'usage') {
+ observation.usage = providerAttemptUsageFromEvent(event)
+ } else if (event.type === 'error') {
+ observation.errorEvent = event
+ observation.stopReason = 'error'
+ } else if (event.type === 'stop') {
+ observation.stopEvent = event
+ if (observation.stopReason !== 'error') {
+ observation.stopReason = event.stop_reason
+ }
}
- }
- if (!isProviderControlEvent(event)) {
- observation.outputCommitted = true
- yield event
+ if (!isProviderControlEvent(event)) {
+ observation.outputCommitted = true
+ yield event
+ }
+ }
+ } finally {
+ if (!finished) {
+ // An async generator's return() can wait for its outstanding next() forever.
+ const closing = stream.return(undefined)
+ if (input.streamInput.signal.aborted) {
+ void closing.catch(() => undefined)
+ } else {
+ await awaitWithAbort(closing, input.streamInput.signal)
+ }
}
}
} catch (error) {
diff --git a/src/main/desktop/browser/BrowserTab.ts b/src/main/desktop/browser/BrowserTab.ts
index be7f35048..ad447da10 100644
--- a/src/main/desktop/browser/BrowserTab.ts
+++ b/src/main/desktop/browser/BrowserTab.ts
@@ -1,5 +1,6 @@
import { WebContents } from 'electron'
import { nanoid } from 'nanoid'
+import { awaitWithAbort } from '@/lib/awaitWithAbort'
import {
BrowserPageStatus,
type BrowserPageInfo,
@@ -72,10 +73,13 @@ export class BrowserTab {
url: string,
timeoutMs: number = 30000,
beforeDispatch?: () => void,
- onDispatched?: () => void
+ onDispatched?: () => void,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
this.ensureAvailable()
beforeDispatch?.()
+ signal?.throwIfAborted()
this.beginMainFrameNavigation(url)
const loadPromise = this.webContents.loadURL(url)
@@ -90,13 +94,15 @@ export class BrowserTab {
onDispatched?.()
try {
- await Promise.race([this.waitForInteractiveReady(timeoutMs), loadPromise])
+ await Promise.race([this.waitForInteractiveReady(timeoutMs, signal), loadPromise])
+ signal?.throwIfAborted()
if (!this.interactiveReady) {
throw new Error(`Navigation finished before dom-ready for ${url}`)
}
this.title = this.webContents.getTitle() || url
this.updatedAt = Date.now()
} catch (error) {
+ signal?.throwIfAborted()
this.markNavigationError(error)
throw error
}
@@ -118,19 +124,24 @@ export class BrowserTab {
method: string,
params?: Record,
beforeDispatch?: () => void,
- onDispatched?: () => void
+ onDispatched?: () => void,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
if (NAVIGATION_CDP_METHODS.has(method)) {
this.ensureAvailable()
} else {
- await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`)
+ await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`, undefined, signal)
}
- const session = await this.ensureSession()
+ const session = await awaitWithAbort(this.ensureSession(), signal)
+ signal?.throwIfAborted()
beforeDispatch?.()
+ signal?.throwIfAborted()
const responsePromise = session.sendCommand(method, params ?? {})
onDispatched?.()
- const response = await responsePromise
+ const response = await awaitWithAbort(responsePromise, signal)
+ signal?.throwIfAborted()
if (method === 'Page.navigate') {
const navigationResponse = response as {
@@ -558,8 +569,10 @@ export class BrowserTab {
private async ensureInteractiveReadyOrWait(
action: string,
- timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS
+ timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
this.ensureAvailable()
if (this.interactiveReady) {
@@ -568,7 +581,7 @@ export class BrowserTab {
if (this.awaitingMainFrameInteractive || this.status === BrowserPageStatus.Loading) {
try {
- await this.waitForInteractiveReady(timeoutMs)
+ await this.waitForInteractiveReady(timeoutMs, signal)
} catch (error) {
if (!this.isInteractiveReadyTimeoutError(error)) {
throw error
@@ -579,7 +592,7 @@ export class BrowserTab {
return
}
- if (await this.probeInteractiveReadiness()) {
+ if (await this.probeInteractiveReadiness(signal)) {
return
}
}
@@ -790,7 +803,8 @@ export class BrowserTab {
})
}
- private async waitForInteractiveReady(timeoutMs: number): Promise {
+ private async waitForInteractiveReady(timeoutMs: number, signal?: AbortSignal): Promise {
+ signal?.throwIfAborted()
if (this.interactiveReady) {
return
}
@@ -808,6 +822,7 @@ export class BrowserTab {
this.webContents.removeListener('dom-ready', onDomReady)
this.webContents.removeListener('did-fail-load', onFailLoad as any)
this.webContents.removeListener('destroyed', onDestroyed)
+ signal?.removeEventListener('abort', onAbort)
}
const onDomReady = () => {
@@ -834,6 +849,11 @@ export class BrowserTab {
reject(new Error('Page was destroyed before dom-ready'))
}
+ const onAbort = () => {
+ cleanup()
+ reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
+ }
+
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`${INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX} ${this.url}`))
@@ -842,6 +862,8 @@ export class BrowserTab {
this.webContents.once('dom-ready', onDomReady)
this.webContents.on('did-fail-load', onFailLoad as any)
this.webContents.once('destroyed', onDestroyed)
+ signal?.addEventListener('abort', onAbort, { once: true })
+ if (signal?.aborted) onAbort()
})
}
@@ -851,12 +873,15 @@ export class BrowserTab {
)
}
- private async probeInteractiveReadiness(): Promise {
+ private async probeInteractiveReadiness(signal?: AbortSignal): Promise {
try {
- const session = await this.ensureSession()
- const probe = (await this.cdpManager.evaluateScript(
- session,
- `(() => {
+ signal?.throwIfAborted()
+ const session = await awaitWithAbort(this.ensureSession(), signal)
+ signal?.throwIfAborted()
+ const probe = (await awaitWithAbort(
+ this.cdpManager.evaluateScript(
+ session,
+ `(() => {
try {
return {
readyState: document.readyState,
@@ -867,7 +892,10 @@ export class BrowserTab {
return null
}
})()`
+ ),
+ signal
)) as { readyState?: unknown; hasBody?: unknown; href?: unknown } | null
+ signal?.throwIfAborted()
const readyState = typeof probe?.readyState === 'string' ? probe.readyState : ''
const hasBody = probe?.hasBody === true
@@ -888,6 +916,7 @@ export class BrowserTab {
}
return true
} catch {
+ signal?.throwIfAborted()
return false
}
}
diff --git a/src/main/desktop/browser/YoBrowserPresenter.ts b/src/main/desktop/browser/YoBrowserPresenter.ts
index 6ad11393f..93aba72f9 100644
--- a/src/main/desktop/browser/YoBrowserPresenter.ts
+++ b/src/main/desktop/browser/YoBrowserPresenter.ts
@@ -132,8 +132,10 @@ export class YoBrowserPresenter implements IYoBrowserPresenter {
hostWindowId?: number,
activitySource?: YoBrowserActivitySource,
agentRunId?: string,
- beforeDispatch?: () => void
+ beforeDispatch?: () => void,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
const normalizedSessionId = sessionId.trim()
if (!normalizedSessionId) {
throw new Error('sessionId is required')
@@ -201,17 +203,24 @@ export class YoBrowserPresenter implements IYoBrowserPresenter {
normalizedSessionId,
{ kind: 'navigation', action: 'navigate' },
(startActivity) =>
- state.page.navigateUntilDomReady(url, timeoutMs ?? 30000, beforeDispatch, () => {
- projectDispatch()
- startActivity()
- })
+ state.page.navigateUntilDomReady(
+ url,
+ timeoutMs ?? 30000,
+ beforeDispatch,
+ () => {
+ projectDispatch()
+ startActivity()
+ },
+ signal
+ )
)
} else {
await state.page.navigateUntilDomReady(
url,
timeoutMs ?? 30000,
beforeDispatch,
- projectDispatch
+ projectDispatch,
+ signal
)
}
@@ -532,7 +541,8 @@ export class YoBrowserPresenter implements IYoBrowserPresenter {
params?: Record,
activitySource?: YoBrowserActivitySource,
agentRunId?: string,
- beforeDispatch?: () => void
+ beforeDispatch?: () => void,
+ signal?: AbortSignal
): Promise {
const state = this.sessionBrowsers.get(sessionId)
if (!state) {
@@ -557,14 +567,20 @@ export class YoBrowserPresenter implements IYoBrowserPresenter {
}
if (activitySource === 'agent' && descriptor) {
return await this.runAgentActivity(sessionId, descriptor, (startActivity) =>
- state.page.sendCdpCommand(method, params, beforeDispatch, () => {
- projectDispatch()
- startActivity()
- })
+ state.page.sendCdpCommand(
+ method,
+ params,
+ beforeDispatch,
+ () => {
+ projectDispatch()
+ startActivity()
+ },
+ signal
+ )
)
}
- return await state.page.sendCdpCommand(method, params, beforeDispatch, projectDispatch)
+ return await state.page.sendCdpCommand(method, params, beforeDispatch, projectDispatch, signal)
}
async startDownload(url: string, savePath?: string): Promise {
diff --git a/src/main/desktop/browser/YoBrowserToolHandler.ts b/src/main/desktop/browser/YoBrowserToolHandler.ts
index 1a814b409..2d4ff0248 100644
--- a/src/main/desktop/browser/YoBrowserToolHandler.ts
+++ b/src/main/desktop/browser/YoBrowserToolHandler.ts
@@ -1,4 +1,5 @@
import logger from '@shared/logger'
+import { awaitWithAbort } from '@/lib/awaitWithAbort'
import { getYoBrowserToolDefinitions } from '@/tool/browser/definitions'
import type { YoBrowserPresenter } from './YoBrowserPresenter'
import { BrowserPageStatus, type YoBrowserStatus } from '@shared/types/browser'
@@ -24,9 +25,11 @@ export class YoBrowserToolHandler {
args: Record,
conversationId?: string,
runId?: string,
- beforeInvoke?: (normalizedArguments: Record) => void
+ beforeInvoke?: (normalizedArguments: Record) => void,
+ signal?: AbortSignal
): Promise {
try {
+ signal?.throwIfAborted()
const sessionId = conversationId?.trim()
if (!sessionId) {
throw new Error('conversationId is required for YoBrowser tools')
@@ -34,7 +37,9 @@ export class YoBrowserToolHandler {
switch (toolName) {
case 'get_browser_status':
- return JSON.stringify(await this.presenter.getBrowserStatus(sessionId))
+ return JSON.stringify(
+ await awaitWithAbort(this.presenter.getBrowserStatus(sessionId), signal)
+ )
case 'load_url': {
const url = typeof args.url === 'string' ? args.url : ''
if (!url) {
@@ -42,7 +47,7 @@ export class YoBrowserToolHandler {
}
const beforeDispatch = beforeInvoke ? () => beforeInvoke({ url }) : undefined
return JSON.stringify(
- runId || beforeDispatch
+ runId || beforeDispatch || signal
? await this.presenter.loadUrl(
sessionId,
url,
@@ -50,7 +55,8 @@ export class YoBrowserToolHandler {
undefined,
'agent',
runId,
- beforeDispatch
+ beforeDispatch,
+ signal
)
: await this.presenter.loadUrl(sessionId, url, undefined, undefined, 'agent')
)
@@ -61,7 +67,7 @@ export class YoBrowserToolHandler {
throw new Error('CDP method is required')
}
- const status = await this.presenter.getBrowserStatus(sessionId)
+ const status = await awaitWithAbort(this.presenter.getBrowserStatus(sessionId), signal)
const page = status.page
if (!status.initialized || !page || page.status === BrowserPageStatus.Closed) {
throw await this.createUnavailableError(sessionId, method, status)
@@ -71,14 +77,15 @@ export class YoBrowserToolHandler {
const params = this.normalizeCdpParams(args.params)
const beforeDispatch = beforeInvoke ? () => beforeInvoke({ method, params }) : undefined
const response =
- runId || beforeDispatch
+ runId || beforeDispatch || signal
? await this.presenter.sendCdpCommand(
sessionId,
method,
params,
'agent',
runId,
- beforeDispatch
+ beforeDispatch,
+ signal
)
: await this.presenter.sendCdpCommand(sessionId, method, params, 'agent')
return JSON.stringify(response ?? {})
diff --git a/src/main/provider/defaults.ts b/src/main/provider/defaults.ts
index 0e184fb65..34be9aba2 100644
--- a/src/main/provider/defaults.ts
+++ b/src/main/provider/defaults.ts
@@ -31,6 +31,21 @@ export const DEFAULT_PROVIDERS: LLM_PROVIDER_BASE[] = [
defaultBaseUrl: 'https://api.deepseek.com/v1'
}
},
+ {
+ id: 'runinfra',
+ name: 'RunInfra',
+ apiType: 'openai-completions',
+ apiKey: '',
+ baseUrl: 'https://api.runinfra.ai/v1',
+ enable: false,
+ websites: {
+ official: 'https://runinfra.ai/',
+ apiKey: 'https://runinfra.ai/settings/api-keys',
+ docs: 'https://runinfra.ai/docs/api-reference/model-apis-quickstart',
+ models: 'https://runinfra.ai/inference-api',
+ defaultBaseUrl: 'https://api.runinfra.ai/v1'
+ }
+ },
{
id: 'greenpt',
name: 'GreenPT',
diff --git a/src/main/provider/providerRegistry.ts b/src/main/provider/providerRegistry.ts
index ef9e8b511..f4dae9a1a 100644
--- a/src/main/provider/providerRegistry.ts
+++ b/src/main/provider/providerRegistry.ts
@@ -498,6 +498,14 @@ const PROVIDER_ID_REGISTRY = new Map([
keyStatusStrategy: 'ppio'
})
],
+ [
+ 'runinfra',
+ createDefinition({
+ ...OPENAI_BASE,
+ credentialStrategy: 'api-key',
+ embeddingStrategy: 'none'
+ })
+ ],
[
'silicon',
createDefinition({
diff --git a/src/main/tool/agentTools/agentToolManager.ts b/src/main/tool/agentTools/agentToolManager.ts
index cadb78145..ddd4590d7 100644
--- a/src/main/tool/agentTools/agentToolManager.ts
+++ b/src/main/tool/agentTools/agentToolManager.ts
@@ -1005,7 +1005,8 @@ export class AgentToolManager {
args,
conversationId,
options?.runId,
- this.createAgentDispatchCommit(toolName, 'yobrowser', args, options)
+ this.createAgentDispatchCommit(toolName, 'yobrowser', args, options),
+ options?.signal
)
return {
content: response
diff --git a/src/main/tool/codeMode/runCodeRuntimeManager.ts b/src/main/tool/codeMode/runCodeRuntimeManager.ts
index 60cb10a29..ecc527fd8 100644
--- a/src/main/tool/codeMode/runCodeRuntimeManager.ts
+++ b/src/main/tool/codeMode/runCodeRuntimeManager.ts
@@ -7,6 +7,7 @@ import type { MCPToolDefinition, MCPToolResponse, ToolDispatchCommitInput } from
import type { ToolCallOptions } from '@shared/types/tool'
import {
RUN_CODE_MAX_NESTED_CALLS,
+ RUN_CODE_DEFAULT_TIMEOUT_MS,
RUN_CODE_PROTOCOL_VERSION,
RUN_CODE_SOURCE_MAX_BYTES,
type RunCodeFrontend,
@@ -16,6 +17,7 @@ import {
} from '@shared/codeModeProtocol'
import { buildCanonicalToolCatalog } from '@/agent/deepchat/runtime/toolSurface'
import { MAX_EXECUTION_JOURNAL_NESTED_CHILDREN } from '@/tape/domain/executionJournal'
+import { awaitWithAbort } from '@/lib/awaitWithAbort'
import { normalizeCodexToolName } from './toolModeTools'
type CodeModeUtilityProcess = Pick & {
@@ -49,6 +51,7 @@ export interface RunCodeExecutionInput {
toolCallId: string
frontend: RunCodeFrontend
source: string
+ timeoutMs?: number
yieldTimeMs?: number
maxOutputTokens?: number
executionCatalog: readonly MCPToolDefinition[]
@@ -120,6 +123,7 @@ type ActiveCell = {
waiter: DeferredResult | null
yieldTimeMs: number
maxOutputTokens: number
+ timeoutMs: number
yieldTimer: NodeJS.Timeout | null
pausedAtYield: boolean
pendingYieldOutput: unknown[] | null
@@ -147,7 +151,6 @@ type ActiveCell = {
const READY_TIMEOUT_MS = 5_000
const HEARTBEAT_TIMEOUT_MS = 3_500
-const CELL_EXECUTION_DEADLINE_MS = 5 * 60_000
const YIELD_LEASE_MS = 60_000
const MAX_JOURNALED_NESTED_CALLS = Math.min(
RUN_CODE_MAX_NESTED_CALLS,
@@ -316,7 +319,13 @@ export class RunCodeRuntimeManager {
}
input.options.commitDispatch?.(input.outerDispatch)
- const host = await this.spawnReadyHost()
+ const host = await this.spawnReadyHost(input.options.signal)
+ if (input.options.signal?.aborted) {
+ try {
+ host.kill()
+ } catch {}
+ input.options.signal.throwIfAborted()
+ }
const cell = this.createCell({
id: cellId,
@@ -329,6 +338,7 @@ export class RunCodeRuntimeManager {
capabilityHash: canonicalCatalog.fullCatalogHash,
yieldTimeMs: input.yieldTimeMs ?? 10_000,
maxOutputTokens: input.maxOutputTokens ?? 10_000,
+ timeoutMs: input.timeoutMs ?? RUN_CODE_DEFAULT_TIMEOUT_MS,
options: input.options,
committedDispatchToolCallId: input.toolCallId
})
@@ -461,6 +471,7 @@ export class RunCodeRuntimeManager {
bindings: ActiveCell['bindings']
yieldTimeMs: number
maxOutputTokens: number
+ timeoutMs: number
options: ToolCallOptions
capabilityHash: string
committedDispatchToolCallId: string | null
@@ -516,7 +527,7 @@ export class RunCodeRuntimeManager {
cell.rssTimer = setInterval(() => this.inspectRss(cell), 1_000)
cell.executionDeadlineTimer = setTimeout(
() => this.failAndCleanup(cell, new Error('Code cell execution deadline exceeded.')),
- CELL_EXECUTION_DEADLINE_MS
+ cell.timeoutMs
)
cell.executionDeadlineTimer.unref?.()
@@ -759,6 +770,7 @@ export class RunCodeRuntimeManager {
}
private handleCellMessage(cell: ActiveCell, rawMessage: unknown): void {
+ if (cell.terminal || cell.state === 'stopping') return
const message = unwrapMessage(rawMessage)
if (!isHostMessage(message)) return
if (message.type === 'READY') return
@@ -1059,10 +1071,18 @@ export class RunCodeRuntimeManager {
})
}
- private async spawnReadyHost(): Promise {
- const host = this.managerOptions.spawnHost
- ? await this.managerOptions.spawnHost()
- : await this.spawnDefaultHost()
+ private async spawnReadyHost(signal?: AbortSignal): Promise {
+ signal?.throwIfAborted()
+ const spawning = this.managerOptions.spawnHost
+ ? this.managerOptions.spawnHost()
+ : this.spawnDefaultHost()
+ let host: CodeModeUtilityProcess
+ try {
+ host = await awaitWithAbort(spawning, signal)
+ } catch (error) {
+ void spawning.then((abandonedHost) => abandonedHost.kill()).catch(() => undefined)
+ throw error
+ }
return await new Promise((resolve, reject) => {
let settled = false
const settle = (callback: () => void) => {
@@ -1072,6 +1092,7 @@ export class RunCodeRuntimeManager {
host.off('message', onMessage)
host.off('exit', onExit)
host.off('error', onError)
+ signal?.removeEventListener('abort', onAbort)
callback()
}
const onMessage = (rawMessage: unknown) => {
@@ -1090,6 +1111,13 @@ export class RunCodeRuntimeManager {
settle(() =>
reject(new Error(`Code mode utility failed before ready: ${type} at ${location}`))
)
+ const onAbort = () =>
+ settle(() => {
+ try {
+ host.kill()
+ } catch {}
+ reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
+ })
const timeout = setTimeout(() => {
settle(() => {
try {
@@ -1101,6 +1129,8 @@ export class RunCodeRuntimeManager {
host.on('message', onMessage)
host.on('exit', onExit)
host.on('error', onError)
+ signal?.addEventListener('abort', onAbort, { once: true })
+ if (signal?.aborted) onAbort()
})
}
diff --git a/src/main/tool/codeMode/toolModeTools.ts b/src/main/tool/codeMode/toolModeTools.ts
index f6c316a6e..3c1bab06e 100644
--- a/src/main/tool/codeMode/toolModeTools.ts
+++ b/src/main/tool/codeMode/toolModeTools.ts
@@ -4,7 +4,11 @@ import {
formatExecCommandDescription,
type ResolvedCommandShell
} from '@shared/commandShell'
-import { CODE_MODE_TOOL_SERVER_NAME } from '@shared/codeModeProtocol'
+import {
+ CODE_MODE_TOOL_SERVER_NAME,
+ RUN_CODE_DEFAULT_TIMEOUT_MS,
+ RUN_CODE_MAX_TIMEOUT_MS
+} from '@shared/codeModeProtocol'
import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools'
import { UPDATE_PLAN_TOOL_NAME } from '@shared/types/agent-plan'
import { QUESTION_TOOL_NAME } from '../agentTools/questionTool'
@@ -73,6 +77,9 @@ const RUN_CODE_DESCRIPTION =
const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION =
'Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; "Read failing test and its fixture"; "Rename config key in every cordis.yml".'
+const CODE_MODE_TIMEOUT_DESCRIPTION =
+ "`timeout_ms` is an optional maximum wall-clock execution time for this code cell, including awaited subtools, in milliseconds. Defaults to 300000 ms (5 minutes). Omit it for routine work; provide a larger value only when you expect the current operation to take longer than 5 minutes. It does not extend a subtool's own timeout. User cancellation still stops the cell regardless of this value."
+
const CODE_MODE_EXEC_DESCRIPTION = `Run JavaScript code to orchestrate/compose tool calls
- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
- Invoke SDK-declared subtools only inside this program on the global \`tools\` object through \`await tools.name(args)\`. Subtool names are exposed as normalized JavaScript identifiers, for example \`await tools.mcp__ologs__get_profile(...)\`.
@@ -83,6 +90,7 @@ const CODE_MODE_EXEC_DESCRIPTION = `Run JavaScript code to orchestrate/compose t
- You may optionally start the tool input with a first-line pragma like \`// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}\`.
- \`yield_time_ms\` asks \`exec\` to yield early if the script is still running. Defaults to 10000 ms.
- \`max_output_tokens\` sets the token budget for direct \`exec\` results. Defaults to 10000 tokens.
+- ${CODE_MODE_TIMEOUT_DESCRIPTION} Set it in the first-line pragma when needed. Yielding or calling \`wait\` does not reset or extend the cell's execution timeout.
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
- Global helpers:
@@ -200,7 +208,7 @@ export function createRunCodeToolDefinition(): MCPToolDefinition {
providerPresentation: { type: 'function' },
function: {
name: RUN_CODE_TOOL_NAME,
- description: RUN_CODE_DESCRIPTION,
+ description: `${RUN_CODE_DESCRIPTION} ${CODE_MODE_TIMEOUT_DESCRIPTION}`,
parameters: {
type: 'object',
properties: {
@@ -211,6 +219,13 @@ export function createRunCodeToolDefinition(): MCPToolDefinition {
description: {
type: 'string',
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
+ },
+ timeout_ms: {
+ type: 'integer',
+ minimum: 1,
+ maximum: RUN_CODE_MAX_TIMEOUT_MS,
+ default: RUN_CODE_DEFAULT_TIMEOUT_MS,
+ description: CODE_MODE_TIMEOUT_DESCRIPTION
}
},
required: ['code', 'description']
@@ -500,6 +515,8 @@ ${toolBoundary}
The top-level \`exec\` starts a Code Mode cell; \`tools.exec\` inside that cell runs the selected Shell command subtool.
+${CODE_MODE_TIMEOUT_DESCRIPTION} Set it in the first-line \`// @exec: {...}\` pragma when needed. Yielding or calling \`wait\` does not reset or extend the cell's execution timeout.
+
${progressPrompt}
\`\`\`ts
@@ -517,7 +534,11 @@ declare function yield_control(): Promise
${toolBoundary}
-\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. Inside the program:
+\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does.
+
+${CODE_MODE_TIMEOUT_DESCRIPTION}
+
+Inside the program:
- Call subtools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the subtool's typed canonical JSON value. Subtool arguments must be lossless JSON.
- A FAILED subtool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed subtool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts
index 325c85044..0484a4066 100644
--- a/src/main/tool/index.ts
+++ b/src/main/tool/index.ts
@@ -140,7 +140,11 @@ import {
isCodexToolFrontend,
normalizeCodexToolName
} from './codeMode/toolModeTools'
-import { CODE_MODE_TOOL_SERVER_NAME } from '@shared/codeModeProtocol'
+import {
+ CODE_MODE_TOOL_SERVER_NAME,
+ RUN_CODE_DEFAULT_TIMEOUT_MS,
+ RUN_CODE_MAX_TIMEOUT_MS
+} from '@shared/codeModeProtocol'
type McpToolPort = Pick<
McpServicePort,
@@ -233,6 +237,7 @@ type InternalToolCallOptions = ToolCallOptions & {
type CodexExecInput = {
source: string
+ timeoutMs: number
yieldTimeMs: number
maxOutputTokens: number
}
@@ -1269,11 +1274,12 @@ export class ToolService implements ToolServicePort {
const waitInput = isWait ? this.requireCodeModeWaitInput(request.function.arguments) : null
const execInput = isExec ? this.requireCodexExecInput(request.function.arguments) : null
+ const codeInput = isRunCode ? this.requireRunCodeInput(request.function.arguments) : execInput
const outerDispatch: ToolDispatchCommitInput = {
toolName,
toolSource: 'agent',
normalizedArguments: execInput
- ? { source: execInput.source }
+ ? { source: execInput.source, timeout_ms: execInput.timeoutMs }
: this.parseAgentToolArguments(request.function.arguments, toolName),
target: { serverName: CODE_MODE_TOOL_SERVER_NAME, originalName: toolName }
}
@@ -1293,7 +1299,8 @@ export class ToolService implements ToolServicePort {
runId: options?.runId,
toolCallId: request.id,
frontend: context.frontend,
- source: execInput?.source ?? this.requireRunCodeSource(request.function.arguments),
+ source: codeInput!.source,
+ timeoutMs: codeInput!.timeoutMs,
...(execInput
? {
yieldTimeMs: execInput.yieldTimeMs,
@@ -1321,13 +1328,22 @@ export class ToolService implements ToolServicePort {
}
}
- private requireRunCodeSource(argumentsText: string): string {
+ private requireRunCodeInput(argumentsText: string): { source: string; timeoutMs: number } {
const args = this.parseAgentToolArguments(argumentsText)
const code = typeof args.code === 'string' ? args.code : ''
const description = typeof args.description === 'string' ? args.description.trim() : ''
if (!code) throw new Error('run_code requires a non-empty code string.')
if (!description) throw new Error('run_code requires a non-empty description string.')
- return code
+ return {
+ source: code,
+ timeoutMs: this.readBoundedCodeModeNumber(
+ args.timeout_ms,
+ 'timeout_ms',
+ RUN_CODE_DEFAULT_TIMEOUT_MS,
+ 1,
+ RUN_CODE_MAX_TIMEOUT_MS
+ )
+ }
}
private requireCodexExecInput(argumentsText: string): CodexExecInput {
@@ -1336,6 +1352,7 @@ export class ToolService implements ToolServicePort {
if (!argumentsText.trim()) throw new Error('exec requires non-empty JavaScript source.')
return {
source: argumentsText,
+ timeoutMs: RUN_CODE_DEFAULT_TIMEOUT_MS,
yieldTimeMs: CODE_MODE_DEFAULT_YIELD_TIME_MS,
maxOutputTokens: CODE_MODE_DEFAULT_MAX_OUTPUT_TOKENS
}
@@ -1354,6 +1371,13 @@ export class ToolService implements ToolServicePort {
if (!source.trim()) throw new Error('exec requires non-empty JavaScript source.')
return {
source,
+ timeoutMs: this.readBoundedCodeModeNumber(
+ pragma.timeout_ms,
+ 'timeout_ms',
+ RUN_CODE_DEFAULT_TIMEOUT_MS,
+ 1,
+ RUN_CODE_MAX_TIMEOUT_MS
+ ),
yieldTimeMs: this.readBoundedCodeModeNumber(
pragma.yield_time_ms,
'yield_time_ms',
diff --git a/src/main/tool/runtimePorts.ts b/src/main/tool/runtimePorts.ts
index 38bb333ce..d08994e06 100644
--- a/src/main/tool/runtimePorts.ts
+++ b/src/main/tool/runtimePorts.ts
@@ -215,7 +215,8 @@ export interface AgentBrowserToolPort {
args: Record,
conversationId?: string,
runId?: string,
- beforeInvoke?: (normalizedArguments: Record) => void
+ beforeInvoke?: (normalizedArguments: Record) => void,
+ signal?: AbortSignal
): Promise
}
diff --git a/src/renderer/src/assets/llm-icons/runinfra.svg b/src/renderer/src/assets/llm-icons/runinfra.svg
new file mode 100644
index 000000000..6b195330e
--- /dev/null
+++ b/src/renderer/src/assets/llm-icons/runinfra.svg
@@ -0,0 +1,11 @@
+
+ RunInfra
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/icons/modelIconRegistry.ts b/src/renderer/src/components/icons/modelIconRegistry.ts
index 11662c408..b9c22eb80 100644
--- a/src/renderer/src/components/icons/modelIconRegistry.ts
+++ b/src/renderer/src/components/icons/modelIconRegistry.ts
@@ -64,6 +64,7 @@ import qiniuIcon from '@/assets/llm-icons/qiniu.svg?url'
import grokColorIcon from '@/assets/llm-icons/grok.svg?url'
import groqColorIcon from '@/assets/llm-icons/groq.svg?url'
import greenptColorIcon from '@/assets/llm-icons/greenpt.svg?url'
+import runinfraIcon from '@/assets/llm-icons/runinfra.svg?url'
import amdIcon from '@/assets/llm-icons/amd.svg?url'
import nvidiaColorIcon from '@/assets/llm-icons/nvidia-color.svg?url'
import huggingfaceColorIcon from '@/assets/llm-icons/huggingface-color.svg?url'
@@ -111,6 +112,7 @@ export const modelIcons = {
grok: grokColorIcon,
groq: groqColorIcon,
greenpt: greenptColorIcon,
+ runinfra: runinfraIcon,
'amd-developer': amdIcon,
nvidia: nvidiaColorIcon,
huggingface: huggingfaceColorIcon,
@@ -242,6 +244,7 @@ const monoIconUrls = new Set([
qiniuIcon,
grokColorIcon,
groqColorIcon,
+ runinfraIcon,
metaColorIcon,
lmstudioColorIcon,
_302aiIcon,
diff --git a/src/shared/codeModeProtocol.ts b/src/shared/codeModeProtocol.ts
index f5287bf44..21187e902 100644
--- a/src/shared/codeModeProtocol.ts
+++ b/src/shared/codeModeProtocol.ts
@@ -4,6 +4,8 @@ export const RUN_CODE_SOURCE_MAX_BYTES = 256 * 1024
export const RUN_CODE_OUTPUT_MAX_BYTES = 1024 * 1024
export const RUN_CODE_MAX_NESTED_CALLS = 128
export const RUN_CODE_MAX_NESTED_CONCURRENCY = 8
+export const RUN_CODE_DEFAULT_TIMEOUT_MS = 5 * 60_000
+export const RUN_CODE_MAX_TIMEOUT_MS = 2_147_483_647
export type RunCodeFrontend = 'codex' | 'function'
diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts
index 8694aceea..e8651eb3b 100644
--- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts
+++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts
@@ -3431,6 +3431,68 @@ describe('DeepChatContextCoordinator', () => {
})
})
+ it.each(['output', 'rejection'])(
+ 'cancels a silent provider without awaiting cleanup or accepting late %s',
+ async (lateResult) => {
+ vi.useFakeTimers()
+ const fixture = createAttemptInput()
+ const usageEvent: LLMCoreStreamEvent = {
+ type: 'usage',
+ usage: { prompt_tokens: 8, completion_tokens: 1, total_tokens: 9 }
+ }
+ let release!: () => void
+ const pending = new Promise((resolve) => {
+ release = resolve
+ })
+ const waiting = vi.fn()
+ const cleanup = vi.fn()
+ fixture.input.provider.stream = async function* () {
+ try {
+ yield usageEvent
+ waiting()
+ await pending
+ if (lateResult === 'rejection') throw new Error('Late provider failure')
+ yield { type: 'tool_call_start', tool_call_id: 'late-call', tool_call_name: 'exec' }
+ } finally {
+ cleanup()
+ }
+ }
+ const projected: LLMCoreStreamEvent[] = []
+ const execution = (async () => {
+ for await (const event of new DeepChatContextCoordinator().streamProviderAttempts(
+ fixture.input
+ )) {
+ projected.push(event)
+ }
+ })()
+ const cancellation = expect(execution).rejects.toMatchObject({ name: 'AbortError' })
+
+ await vi.advanceTimersByTimeAsync(15 * 60_000)
+ expect(waiting).toHaveBeenCalledOnce()
+ expect(fixture.outcomes).toEqual([])
+ fixture.run.abortController.abort(new DOMException('Stopped', 'AbortError'))
+ await cancellation
+
+ expect(projected).toEqual([usageEvent])
+ expect(fixture.outcomes).toEqual([
+ expectedAttemptOutcome({
+ status: 'aborted',
+ stopReason: null,
+ failureClassification: 'aborted',
+ retryDecision: 'not_retryable',
+ usage: { inputTokens: 8, outputTokens: 1, totalTokens: 9 }
+ })
+ ])
+ expect(cleanup).not.toHaveBeenCalled()
+
+ release()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(cleanup).toHaveBeenCalledOnce()
+ expect(projected).toEqual([usageEvent])
+ expect(fixture.outcomes).toHaveLength(1)
+ }
+ )
+
it('settles an attempt as aborted when a provider ignores cancellation and stops cleanly', async () => {
const fixture = createAttemptInput({
providerEvents: [[{ type: 'stop', stop_reason: 'complete' }]]
diff --git a/test/main/desktop/browser/BrowserTab.test.ts b/test/main/desktop/browser/BrowserTab.test.ts
index 6130ffc98..4460c85a6 100644
--- a/test/main/desktop/browser/BrowserTab.test.ts
+++ b/test/main/desktop/browser/BrowserTab.test.ts
@@ -174,6 +174,125 @@ describe('BrowserTab', () => {
webContents.finishLoad()
})
+ it('cancels readiness waits without leaking listeners or dispatching after dom-ready', async () => {
+ const { tab, webContents, cdpManager } = createTab()
+ await makePageInteractive(tab, webContents)
+ webContents.emitStartNavigation('https://example.com/next')
+ const initialListeners = webContents.listenerCount('dom-ready')
+ const controller = new AbortController()
+ const beforeDispatch = vi.fn()
+ const command = tab.sendCdpCommand(
+ 'Runtime.evaluate',
+ undefined,
+ beforeDispatch,
+ undefined,
+ controller.signal
+ )
+
+ controller.abort()
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ expect(webContents.listenerCount('dom-ready')).toBe(initialListeners)
+ await vi.advanceTimersByTimeAsync(2000)
+ webContents.emitDomReady()
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(beforeDispatch).not.toHaveBeenCalled()
+ expect(cdpManager.createSession).not.toHaveBeenCalled()
+ expect(cdpManager.evaluateScript).not.toHaveBeenCalled()
+ expect(webContents.debugger.sendCommand).not.toHaveBeenCalled()
+ })
+
+ it('does not dispatch a command after canceled CDP session preparation finishes', async () => {
+ const { tab, webContents, cdpManager } = createTab()
+ const controller = new AbortController()
+ let finishSession!: () => void
+ cdpManager.createSession.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishSession = resolve
+ })
+ )
+ const command = tab.sendCdpCommand(
+ 'Page.reload',
+ undefined,
+ undefined,
+ undefined,
+ controller.signal
+ )
+
+ controller.abort()
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ finishSession()
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(webContents.debugger.sendCommand).not.toHaveBeenCalled()
+ })
+
+ it('cancels a silent CDP command and discards its late navigation response', async () => {
+ const { tab, webContents } = createTab()
+ await makePageInteractive(tab, webContents)
+ const controller = new AbortController()
+ let finishCommand!: (result: { loaderId: string }) => void
+ webContents.debugger.sendCommand.mockImplementationOnce(
+ () =>
+ new Promise<{ loaderId: string }>((resolve) => {
+ finishCommand = resolve
+ })
+ )
+ const command = tab.sendCdpCommand(
+ 'Page.navigate',
+ { url: 'https://example.com/next' },
+ undefined,
+ undefined,
+ controller.signal
+ )
+ const failed = vi.fn()
+ void command.catch(failed)
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ expect(webContents.debugger.sendCommand).toHaveBeenCalledOnce()
+ expect(failed).not.toHaveBeenCalled()
+
+ controller.abort()
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ finishCommand({ loaderId: 'late-loader' })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(tab.url).toBe('https://example.com')
+ expect(tab.status).toBe(BrowserPageStatus.Ready)
+ })
+
+ it('cancels a pending readiness probe without dispatching the requested command', async () => {
+ const { tab, webContents, cdpManager } = createTab()
+ await makePageInteractive(tab, webContents)
+ webContents.emitStartNavigation('https://example.com/next')
+ const controller = new AbortController()
+ let finishProbe!: (result: any) => void
+ cdpManager.evaluateScript.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishProbe = resolve
+ })
+ )
+ const command = tab.sendCdpCommand(
+ 'Runtime.evaluate',
+ undefined,
+ undefined,
+ undefined,
+ controller.signal
+ )
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(cdpManager.evaluateScript).toHaveBeenCalledOnce()
+
+ controller.abort()
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ finishProbe({ readyState: 'complete', hasBody: true, href: 'https://example.com/late' })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(webContents.debugger.sendCommand).not.toHaveBeenCalled()
+ expect(tab.url).toBe('https://example.com/next')
+ expect(tab.status).toBe(BrowserPageStatus.Loading)
+ })
+
it('still fails immediately when the page is not loading', async () => {
const { tab, webContents } = createTab()
diff --git a/test/main/desktop/browser/YoBrowserPresenter.test.ts b/test/main/desktop/browser/YoBrowserPresenter.test.ts
index b39a4f491..e4e27dc87 100644
--- a/test/main/desktop/browser/YoBrowserPresenter.test.ts
+++ b/test/main/desktop/browser/YoBrowserPresenter.test.ts
@@ -1059,6 +1059,68 @@ describe('YoBrowserPresenter', () => {
)
})
+ it.each(['load_url', 'cdp_send'])(
+ 'stops activity for a canceled %s and ignores late completion',
+ async (toolName) => {
+ const { presenter, windows, getSessionWebContents } = await setupPresenter()
+ windows.set(1, new MockBrowserWindow(1))
+ const initialLoad = presenter.loadUrl('session-a', 'https://example.com')
+ const webContents = getSessionWebContents('session-a')!
+ webContents.emitDomReady()
+ await initialLoad
+ webContents.finishLoad()
+ sendToAllWindowsMock.mockClear()
+
+ let finishCommand!: () => void
+ if (toolName === 'cdp_send') {
+ webContents.debugger.sendCommand.mockImplementation(async (method: string) => {
+ if (method === 'Runtime.evaluate') {
+ await new Promise((resolve) => {
+ finishCommand = resolve
+ })
+ }
+ return {}
+ })
+ }
+ const controller = new AbortController()
+ const command = presenter.toolHandler.callTool(
+ toolName,
+ toolName === 'load_url'
+ ? { url: 'https://example.com/next' }
+ : {
+ method: 'Runtime.evaluate',
+ params: {
+ expression: 'new Promise(() => { document.querySelector("button").click() })',
+ awaitPromise: true
+ }
+ },
+ 'session-a',
+ 'run-a',
+ undefined,
+ controller.signal
+ )
+ const cancellation = expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ const phases = () =>
+ sendToAllWindowsMock.mock.calls
+ .map(([, envelope]) => envelope)
+ .filter((envelope: any) => envelope.name === 'browser.activity.changed')
+ .map((envelope: any) => envelope.payload.phase)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(phases()).toEqual(['started'])
+
+ controller.abort()
+ await cancellation
+ await vi.advanceTimersByTimeAsync(0)
+ expect(phases()).toEqual(['started', 'failed'])
+
+ if (toolName === 'cdp_send') finishCommand()
+ else webContents.finishLoad()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(phases()).toEqual(['started', 'failed'])
+ await presenter.shutdown()
+ }
+ )
+
it('maps agent CDP mouse and screenshot commands to overlay activity', async () => {
const { presenter, windows, getSessionWebContents } = await setupPresenter()
windows.set(1, new MockBrowserWindow(1))
diff --git a/test/main/desktop/browser/YoBrowserToolHandler.test.ts b/test/main/desktop/browser/YoBrowserToolHandler.test.ts
index 583345bee..5fbdf110b 100644
--- a/test/main/desktop/browser/YoBrowserToolHandler.test.ts
+++ b/test/main/desktop/browser/YoBrowserToolHandler.test.ts
@@ -91,7 +91,8 @@ describe('YoBrowserToolHandler', () => {
undefined,
'agent',
'run-a',
- expect.any(Function)
+ expect.any(Function),
+ undefined
)
})
@@ -174,10 +175,39 @@ describe('YoBrowserToolHandler', () => {
{ type: 'mousePressed', x: 24, y: 48 },
'agent',
'run-a',
- expect.any(Function)
+ expect.any(Function),
+ undefined
)
})
+ it('cancels browser status resolution without dispatching a delayed CDP command', async () => {
+ const presenter = createPresenter()
+ const controller = new AbortController()
+ let finishStatus!: (status: typeof readyStatus) => void
+ presenter.getBrowserStatus.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishStatus = resolve
+ })
+ )
+ const handler = new YoBrowserToolHandler(presenter)
+ const command = handler.callTool(
+ 'cdp_send',
+ { method: 'Runtime.evaluate' },
+ 'session-a',
+ 'run-a',
+ undefined,
+ controller.signal
+ )
+
+ controller.abort()
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ finishStatus(readyStatus)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(presenter.sendCdpCommand).not.toHaveBeenCalled()
+ })
+
it('rejects old tool names as unknown tools', async () => {
const handler = new YoBrowserToolHandler(createPresenter())
diff --git a/test/main/provider/basicApiKeyProviders.test.ts b/test/main/provider/basicApiKeyProviders.test.ts
index 72742b0e1..be9c36336 100644
--- a/test/main/provider/basicApiKeyProviders.test.ts
+++ b/test/main/provider/basicApiKeyProviders.test.ts
@@ -1,6 +1,7 @@
import type { ProviderSettingsPort } from '@/provider/settings'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LLM_PROVIDER } from '@shared/types/provider'
+import { DEFAULT_PROVIDERS } from '../../../src/main/provider/defaults'
import { AiSdkProvider } from '../../../src/main/provider/providers/aiSdkProvider'
import { resolveAiSdkProviderDefinition } from '../../../src/main/provider/providerRegistry'
@@ -158,6 +159,55 @@ describe('basic API-key provider registrations', () => {
})
})
+ it('discovers RunInfra models and checks credentials without generating text', async () => {
+ const defaults = DEFAULT_PROVIDERS.find((provider) => provider.id === 'runinfra')!
+ expect(defaults).toBeDefined()
+ const fetchMock = vi.fn().mockImplementation(async () =>
+ Response.json({
+ object: 'list',
+ data: [{ id: 'deepseek-v4-flash', object: 'model', owned_by: 'runinfra' }]
+ })
+ )
+ global.fetch = fetchMock as unknown as typeof fetch
+
+ const config = { ...defaults, apiKey: 'test-key' }
+ const provider = new AiSdkProvider(config, createProviderSettings())
+ expect(resolveAiSdkProviderDefinition(config)).toMatchObject({
+ runtimeKind: 'openai-compatible',
+ modelSource: 'openai',
+ checkStrategy: 'fetch-models',
+ credentialStrategy: 'api-key',
+ embeddingStrategy: 'none'
+ })
+ await expect(provider.fetchModels({ suppressErrors: false })).resolves.toEqual([
+ expect.objectContaining({
+ id: 'deepseek-v4-flash',
+ providerId: 'runinfra',
+ ownedBy: 'runinfra'
+ })
+ ])
+ await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null })
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://api.runinfra.ai/v1/models',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({ Authorization: 'Bearer test-key' })
+ })
+ )
+
+ fetchMock.mockResolvedValueOnce(
+ Response.json(
+ { error: { message: 'Invalid API key', type: 'authentication_error' } },
+ { status: 401 }
+ )
+ )
+ await expect(provider.check()).resolves.toMatchObject({
+ isOk: false,
+ errorMsg: expect.stringContaining('Invalid API key')
+ })
+ expect(mockRunAiSdkGenerateText).not.toHaveBeenCalled()
+ })
+
it('discovers AMD GPU Cloud models through the OpenAI-compatible endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
diff --git a/test/main/tool/agentTools/agentToolManagerYoBrowser.test.ts b/test/main/tool/agentTools/agentToolManagerYoBrowser.test.ts
index 8255d2ccc..3bd372c8c 100644
--- a/test/main/tool/agentTools/agentToolManagerYoBrowser.test.ts
+++ b/test/main/tool/agentTools/agentToolManagerYoBrowser.test.ts
@@ -65,6 +65,24 @@ describe('AgentToolManager YoBrowser routing', () => {
})
})
+ it('cancels a pending browser tool through the Run signal', async () => {
+ const controller = new AbortController()
+ yoBrowserCallTool.mockImplementation(
+ (_name, _args, _sessionId, _runId, _beforeInvoke, signal?: AbortSignal) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
+ })
+ )
+ const command = manager.callTool('cdp_send', { method: 'Runtime.evaluate' }, 'session-a', {
+ signal: controller.signal
+ })
+ await vi.waitFor(() => expect(yoBrowserCallTool).toHaveBeenCalledOnce())
+
+ controller.abort()
+
+ await expect(command).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
it('returns recoverable YoBrowser CDP failures as errored structured tool results', async () => {
const browserStatus = {
initialized: false,
diff --git a/test/main/tool/codeMode/runCodeRuntimeManager.test.ts b/test/main/tool/codeMode/runCodeRuntimeManager.test.ts
index 91eb12aa2..0cdd2ee89 100644
--- a/test/main/tool/codeMode/runCodeRuntimeManager.test.ts
+++ b/test/main/tool/codeMode/runCodeRuntimeManager.test.ts
@@ -172,7 +172,10 @@ describe('RunCodeRuntimeManager', () => {
await manager.shutdown()
})
- it('expires unresolved async execution despite healthy heartbeats', async () => {
+ it.each([
+ { timeoutMs: undefined, deadlineMs: 300000 },
+ { timeoutMs: 600000, deadlineMs: 600000 }
+ ])('expires at $deadlineMs ms despite healthy heartbeats', async ({ timeoutMs, deadlineMs }) => {
vi.useFakeTimers()
try {
const host = new FakeUtilityProcess(false)
@@ -183,6 +186,7 @@ describe('RunCodeRuntimeManager', () => {
toolCallId: 'call-1',
frontend: 'function',
source: 'await new Promise(() => {})',
+ timeoutMs,
executionCatalog: [tool('exec')],
outerDispatch: outerDispatch(),
options: {}
@@ -204,7 +208,9 @@ describe('RunCodeRuntimeManager', () => {
now: Date.now()
})
}, 1_000)
- await vi.advanceTimersByTimeAsync(5 * 60_000 + 1)
+ await vi.advanceTimersByTimeAsync(deadlineMs - 1)
+ expect(host.messages.some((message) => message.type === 'STOP')).toBe(false)
+ await vi.advanceTimersByTimeAsync(1)
clearInterval(heartbeat)
await executionFailure
@@ -221,6 +227,91 @@ describe('RunCodeRuntimeManager', () => {
}
})
+ it.each(['spawn', 'ready', 'ready race'])(
+ 'cancels utility startup during %s and reclaims the process without running code',
+ async (stage) => {
+ const host = new FakeUtilityProcess(false)
+ const controller = new AbortController()
+ let resolveHost!: (host: FakeUtilityProcess) => void
+ const spawning = new Promise((resolve) => {
+ resolveHost = resolve
+ })
+ const manager = new RunCodeRuntimeManager({
+ spawnHost: () => spawning,
+ executeNested: vi.fn()
+ })
+ const execution = manager.execute({
+ sessionId: 'session-1',
+ toolCallId: 'call-1',
+ frontend: 'function',
+ source: 'return true',
+ executionCatalog: [],
+ outerDispatch: outerDispatch(),
+ options: { signal: controller.signal }
+ })
+ const cancellation = expect(execution).rejects.toMatchObject({ name: 'AbortError' })
+ if (stage !== 'spawn') {
+ resolveHost(host)
+ await vi.waitFor(() => expect(host.listenerCount('message')).toBe(1))
+ }
+ if (stage === 'ready race') host.becomeReady()
+
+ controller.abort()
+ if (stage === 'spawn') resolveHost(host)
+ await cancellation
+ await vi.waitFor(() => expect(host.kill).toHaveBeenCalledOnce())
+
+ expect(host.messages).toEqual([])
+ expect(host.listenerCount('message')).toBe(0)
+ expect(host.listenerCount('exit')).toBe(0)
+ expect(host.listenerCount('error')).toBe(0)
+ await manager.shutdown()
+ }
+ )
+
+ it('discards a late utility result after cancellation instead of changing the session store', async () => {
+ const host = new FakeUtilityProcess(false)
+ const nextHost = new FakeUtilityProcess(true)
+ const hosts = [host, nextHost]
+ const manager = new RunCodeRuntimeManager({
+ spawnHost: async () => {
+ const current = hosts.shift()!
+ setTimeout(() => current.becomeReady(), 0)
+ return current
+ },
+ executeNested: vi.fn()
+ })
+ const controller = new AbortController()
+ const input = {
+ sessionId: 'session-1',
+ toolCallId: 'call-1',
+ frontend: 'function' as const,
+ source: 'return true',
+ executionCatalog: [],
+ outerDispatch: outerDispatch(),
+ options: { signal: controller.signal }
+ }
+ const execution = manager.execute(input)
+ const cancellation = expect(execution).rejects.toMatchObject({ name: 'AbortError' })
+ await vi.waitFor(() => expect(host.messages[0]?.type).toBe('START'))
+ const start = host.messages[0] as Extract
+
+ controller.abort()
+ host.emit('message', {
+ type: 'RESULT',
+ version: RUN_CODE_PROTOCOL_VERSION,
+ cellId: start.cellId,
+ output: [],
+ returnValue: 'late result',
+ store: { canceled: true }
+ })
+ await cancellation
+ await manager.execute({ ...input, toolCallId: 'call-2', options: {} })
+
+ expect(nextHost.messages[0]).toMatchObject({ type: 'START', store: {} })
+ await manager.shutdown()
+ })
+
it('normalizes Codex binding names and serializes mutating tools', async () => {
const host = new FakeUtilityProcess(true)
const manager = createManager(host)
@@ -808,51 +899,60 @@ describe('RunCodeRuntimeManager', () => {
}
})
- it('aborts an in-flight nested call when its session is cancelled', async () => {
- const host = new FakeUtilityProcess(false)
- let nestedSignal: AbortSignal | undefined
- const executeNested = vi.fn(
- async (input: RunCodeNestedExecutionInput) =>
- await new Promise((_resolve, reject) => {
- nestedSignal = input.options.signal
- input.options.signal?.addEventListener(
- 'abort',
- () => reject(input.options.signal?.reason),
- { once: true }
- )
+ it.each(['session', 'run signal'])(
+ 'cancels an extended cell through %s even when a nested call ignores cancellation',
+ async (source) => {
+ const host = new FakeUtilityProcess(false)
+ let nestedSignal: AbortSignal | undefined
+ const controller = new AbortController()
+ let finishNested!: () => void
+ const executeNested = vi.fn(async (input: RunCodeNestedExecutionInput) => {
+ nestedSignal = input.options.signal
+ await new Promise((resolve) => {
+ finishNested = resolve
})
- )
- const manager = createManager(host, executeNested)
- const execution = manager.execute({
- sessionId: 'session-1',
- toolCallId: 'call-1',
- frontend: 'function',
- source: 'return await tools.exec({ command: "pwd" })',
- executionCatalog: [tool('exec')],
- outerDispatch: outerDispatch(),
- options: {}
- })
- await vi.waitFor(() =>
- expect(host.messages.some((message) => message.type === 'START')).toBe(true)
- )
- const start = host.messages.find(
- (message): message is Extract =>
- message.type === 'START'
- )!
- host.emit('message', {
- type: 'NESTED_CALL',
- version: RUN_CODE_PROTOCOL_VERSION,
- cellId: start.cellId,
- callId: 'nested-1',
- bindingId: start.bindings[0].id,
- arguments: { command: 'pwd' }
- })
- await vi.waitFor(() => expect(executeNested).toHaveBeenCalledOnce())
+ return { content: 'late result', rawData: { content: 'late result' } }
+ })
+ const manager = createManager(host, executeNested)
+ const execution = manager.execute({
+ sessionId: 'session-1',
+ toolCallId: 'call-1',
+ frontend: 'function',
+ source: 'return await tools.exec({ command: "pwd" })',
+ timeoutMs: 600000,
+ executionCatalog: [tool('exec')],
+ outerDispatch: outerDispatch(),
+ options: { signal: controller.signal }
+ })
+ const cancellation = expect(execution).rejects.toThrow(
+ source === 'session' ? 'test cancellation' : 'Aborted'
+ )
+ await vi.waitFor(() =>
+ expect(host.messages.some((message) => message.type === 'START')).toBe(true)
+ )
+ const start = host.messages.find(
+ (message): message is Extract =>
+ message.type === 'START'
+ )!
+ host.emit('message', {
+ type: 'NESTED_CALL',
+ version: RUN_CODE_PROTOCOL_VERSION,
+ cellId: start.cellId,
+ callId: 'nested-1',
+ bindingId: start.bindings[0].id,
+ arguments: { command: 'pwd' }
+ })
+ await vi.waitFor(() => expect(executeNested).toHaveBeenCalledOnce())
- manager.cancelSession('session-1', 'test cancellation')
+ if (source === 'session') manager.cancelSession('session-1', 'test cancellation')
+ else controller.abort()
- await expect(execution).rejects.toThrow('test cancellation')
- expect(nestedSignal?.aborted).toBe(true)
- await manager.shutdown()
- })
+ await cancellation
+ expect(nestedSignal?.aborted).toBe(true)
+ finishNested()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(host.messages.some((message) => message.type === 'NESTED_RESULT')).toBe(false)
+ await manager.shutdown()
+ }
+ )
})
diff --git a/test/main/tool/codeMode/toolModeTools.test.ts b/test/main/tool/codeMode/toolModeTools.test.ts
index e0caf5647..ed99f332f 100644
--- a/test/main/tool/codeMode/toolModeTools.test.ts
+++ b/test/main/tool/codeMode/toolModeTools.test.ts
@@ -117,7 +117,8 @@ describe('Tool Mode provider contracts', () => {
required: ['code', 'description'],
properties: {
code: { type: 'string' },
- description: { type: 'string' }
+ description: { type: 'string' },
+ timeout_ms: { type: 'integer', minimum: 1, maximum: 2147483647, default: 300000 }
}
})
expect(runCode.function.description).toContain('`run_code` is the only code entrypoint')
@@ -135,6 +136,24 @@ describe('Tool Mode provider contracts', () => {
expect(sdk).toContain('Independent read-only calls MAY overlap under `Promise.all`')
})
+ it('explains timeout omission, extension, and cancellation in both frontends', () => {
+ const runCode = createRunCodeToolDefinition()
+ const prompts = [
+ runCode.function.description,
+ runCode.function.parameters.properties.timeout_ms.description,
+ createCodexCodeModeToolDefinitions([])[0].function.description,
+ renderCodeModeSdk('function', []),
+ renderCodeModeSdk('codex', [])
+ ]
+
+ for (const prompt of prompts) {
+ expect(prompt).toContain('300000 ms (5 minutes)')
+ expect(prompt).toContain('Omit it for routine work')
+ expect(prompt).toContain('longer than 5 minutes')
+ expect(prompt).toContain('User cancellation still stops the cell')
+ }
+ })
+
it('omits direct Loop tools from generated SDK declarations', () => {
const question = {
...nestedTool,
diff --git a/test/main/tool/toolService.test.ts b/test/main/tool/toolService.test.ts
index f13be439a..c49c8e812 100644
--- a/test/main/tool/toolService.test.ts
+++ b/test/main/tool/toolService.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/mcp'
import { ToolService } from '@/tool'
+import { RunCodeRuntimeManager } from '@/tool/codeMode/runCodeRuntimeManager'
+import { POSIX_COMMAND_SHELL } from '../../helpers/commandShell'
import { createToolCatalogPort } from '@/agent/deepchat/runtime/toolAdapters'
import {
AgentToolManager,
@@ -1256,6 +1258,91 @@ describe('ToolService', () => {
active.batch.discard()
})
+ describe.each([
+ { providerId: 'deepseek', toolName: 'run_code' },
+ { providerId: 'openai-codex', toolName: 'exec' }
+ ])('$toolName execution timeout', ({ providerId, toolName }) => {
+ const createService = () => {
+ const service = new ToolService({
+ skillSettings: { isEnabled: () => false } as any,
+ mcpService: { getAllToolDefinitions: vi.fn().mockResolvedValue([]) } as any,
+ agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any,
+ providerSettings: { getModelConfig: vi.fn() } as any,
+ settings: { get: vi.fn() },
+ commandPermissionHandler: new CommandPermissionService(),
+ agentTools: buildAgentToolRuntimeMock()
+ })
+ service.configureToolMode({
+ conversationId: 'session-1',
+ mode: 'code',
+ providerId,
+ commandShell: POSIX_COMMAND_SHELL,
+ executionCatalog: []
+ })
+ return service
+ }
+ const request = (timeoutMs: unknown) => ({
+ id: 'code-call',
+ type: 'function' as const,
+ function: {
+ name: toolName,
+ arguments:
+ toolName === 'run_code'
+ ? JSON.stringify({
+ code: 'return true',
+ description: 'Return true',
+ timeout_ms: timeoutMs
+ })
+ : timeoutMs === undefined
+ ? 'return true'
+ : `// @exec: ${JSON.stringify({ timeout_ms: timeoutMs })}\nreturn true`
+ },
+ conversationId: 'session-1'
+ })
+
+ it.each([
+ { timeoutMs: undefined, expected: 300000 },
+ { timeoutMs: 900000, expected: 900000 },
+ { timeoutMs: 1, expected: 1 },
+ { timeoutMs: 2147483647, expected: 2147483647 }
+ ])('uses $expected ms when timeout_ms is $timeoutMs', async ({ timeoutMs, expected }) => {
+ const execute = vi.spyOn(RunCodeRuntimeManager.prototype, 'execute').mockResolvedValue({
+ content: 'completed'
+ })
+ try {
+ await createService().callTool(request(timeoutMs), { permissionMode: 'full_access' })
+
+ expect(execute).toHaveBeenCalledOnce()
+ expect(execute).toHaveBeenCalledWith(
+ expect.objectContaining({ source: 'return true', timeoutMs: expected })
+ )
+ } finally {
+ execute.mockRestore()
+ }
+ })
+
+ it.each([0, -1, 1.5, '900000', null, 2147483648])(
+ 'rejects invalid timeout_ms %j before starting code',
+ async (timeoutMs) => {
+ const execute = vi.spyOn(RunCodeRuntimeManager.prototype, 'execute')
+ const commitDispatch = vi.fn()
+ try {
+ await expect(
+ createService().callTool(request(timeoutMs), {
+ permissionMode: 'full_access',
+ commitDispatch
+ })
+ ).rejects.toThrow('timeout_ms must be')
+
+ expect(execute).not.toHaveBeenCalled()
+ expect(commitDispatch).not.toHaveBeenCalled()
+ } finally {
+ execute.mockRestore()
+ }
+ }
+ )
+ })
+
it('projects one execution catalog into Agent, Code, and Minimal modes', async () => {
const toolService = new ToolService({
skillSettings: { isEnabled: () => false } as any,