From b1efa96ee2cd87dba4f20df1fb06f6105ab12da6 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Tue, 23 Jun 2026 20:12:33 +0800 Subject: [PATCH 001/864] feat(memory): local qwen3 embedding + rerank client (Simon B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - embeddings/auto.rs: resolve_local() now serves local qwen3-embedding-4b at http://localhost:9876/v1 (was a stub). env: ORGII_LOCAL_EMBED_URL/MODEL - embeddings/rerank.rs: new LocalReranker client for Qwen3-Reranker-8B at http://localhost:9877/v1/rerank. env: ORGII_RERANK_URL. (not yet wired into search_similar — async chain integration pending) - embeddings/mod.rs: register rerank mod (dead_code allowed until wired) - Dockerfile.build: ubuntu22.04 build env (host 20.04 lacks webkit4.1) Compiles clean: cargo build -p agent_core OK --- .gitignore | 1 + Dockerfile.build | 45 ++++++++ .../specialization/memory/embeddings/auto.rs | 17 ++- .../specialization/memory/embeddings/mod.rs | 2 + .../memory/embeddings/rerank.rs | 102 ++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.build create mode 100644 src-tauri/crates/agent-core/src/specialization/memory/embeddings/rerank.rs diff --git a/.gitignore b/.gitignore index de5284d682..4a8a700711 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ code-server-bin/ BitFun/ archive/ **/.build/ +orgii-*.png diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000000..874cf2d586 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,45 @@ +# ORG-2 build/run environment — Ubuntu 22.04 (Tauri v2 needs webkit2gtk-4.1) +# 本机是 Ubuntu 20.04 (只有 webkit 4.0),用容器隔离编译,不污染本机。 +# 用本地已有的 22.04 镜像 + 国内源直连(daemon 代理失效,apt 走代理不稳) +FROM nvcr.io/nvidia/base/ubuntu:22.04_20240212 + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=Asia/Shanghai + +# 换阿里云 apt 源(直连,避开代理) +RUN sed -i 's@http://archive.ubuntu.com/ubuntu@http://mirrors.aliyun.com/ubuntu@g; s@http://security.ubuntu.com/ubuntu@http://mirrors.aliyun.com/ubuntu@g' /etc/apt/sources.list || true + +# Tauri v2 Linux 系统依赖 + 构建工具 + Xvfb(无头 GUI 截图) +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl wget file build-essential pkg-config ca-certificates git \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev \ + librsvg2-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + patchelf \ + xvfb x11-utils xauth \ + libgl1-mesa-dri libgl1-mesa-glx \ + fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +# Node 22 (阿里云 nodesource 镜像或直接 nodesource via 代理) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* +RUN npm config set registry https://registry.npmmirror.com \ + && npm install -g pnpm@9.15.4 \ + && pnpm config set registry https://registry.npmmirror.com + +# Rust (rustup, 国内 RsProxy 镜像) +ENV RUSTUP_DIST_SERVER=https://rsproxy.cn +ENV RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup +RUN curl --proto '=https' --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh -s -- -y --default-toolchain stable +ENV PATH="/root/.cargo/bin:${PATH}" +# cargo 国内镜像 +RUN mkdir -p /root/.cargo && printf '[source.crates-io]\nreplace-with = "rsproxy-sparse"\n[source.rsproxy-sparse]\nregistry = "sparse+https://rsproxy.cn/index/"\n[registries.rsproxy]\nindex = "sparse+https://rsproxy.cn/index/"\n[net]\ngit-fetch-with-cli = true\n' > /root/.cargo/config.toml + +WORKDIR /work +CMD ["bash"] diff --git a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/auto.rs b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/auto.rs index c17ce081ce..2eb4848096 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/auto.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/auto.rs @@ -146,8 +146,23 @@ impl AutoEmbeddingProvider { } } + /// Local embedding provider — Simon's setup: qwen3-embedding-4b served at + /// `http://localhost:9876/v1` (OpenAI-compatible, 1024-dim, no API key). + /// Overridable via env `ORGII_LOCAL_EMBED_URL` / `ORGII_LOCAL_EMBED_MODEL`. fn resolve_local(_custom_path: Option<&str>) -> Result, String> { - Err("Local embedding provider is not available.".to_string()) + let base_url = std::env::var("ORGII_LOCAL_EMBED_URL") + .unwrap_or_else(|_| "http://localhost:9876/v1".to_string()); + let model = std::env::var("ORGII_LOCAL_EMBED_MODEL") + .unwrap_or_else(|_| "qwen3-embedding-4b".to_string()); + info!( + "[memory-embeddings] Using LOCAL qwen3 embedding provider ({} / {})", + base_url, model + ); + Ok(Box::new(OpenAIEmbeddingProvider::new( + "not-needed".to_string(), + Some(model), + Some(base_url), + ))) } /// Look up a credential for a specific agent type. diff --git a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs index c9e4547420..ae0df9a0ae 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs @@ -8,6 +8,8 @@ mod auto; mod azure; mod openai; +#[allow(dead_code)] +mod rerank; // `AutoEmbeddingProvider` is the only provider type external callers reach // for — they construct it via `AutoEmbeddingProvider::resolve(...)`. The diff --git a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/rerank.rs b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/rerank.rs new file mode 100644 index 0000000000..b4926f7863 --- /dev/null +++ b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/rerank.rs @@ -0,0 +1,102 @@ +//! Local rerank provider — Qwen3-Reranker-8B served at `http://localhost:9877`. +//! +//! Simon's setup adds a cross-encoder rerank stage on top of cosine recall: +//! cosine retrieves a coarse top-N, then the reranker reorders by semantic +//! relevance. ORG-2 upstream only had cosine; this is the added increment. +//! +//! Endpoint (OpenAI-style): +//! POST {base_url}/v1/rerank +//! body: {"query": "...", "documents": ["..", ".."], "top_n": N} +//! resp: {"results": [{"index": 0, "relevance_score": 0.19}, ...]} +//! +//! Overridable via env `ORGII_RERANK_URL`. If the service is unreachable the +//! caller falls back to cosine order (rerank is best-effort, never fatal). + +use serde::{Deserialize, Serialize}; + +const DEFAULT_RERANK_URL: &str = "http://localhost:9877"; + +#[derive(Serialize)] +struct RerankRequest<'a> { + query: &'a str, + documents: &'a [String], + top_n: usize, +} + +#[derive(Deserialize)] +struct RerankResponse { + results: Vec, +} + +#[derive(Deserialize)] +struct RerankItem { + index: usize, + relevance_score: f32, +} + +/// Local cross-encoder reranker client. +pub struct LocalReranker { + base_url: String, + client: reqwest::Client, +} + +impl LocalReranker { + pub fn new() -> Self { + let base_url = std::env::var("ORGII_RERANK_URL") + .unwrap_or_else(|_| DEFAULT_RERANK_URL.to_string()); + Self { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + } + } + + /// Rerank `documents` against `query`. Returns `(original_index, score)` + /// pairs sorted by relevance desc, truncated to `top_n`. + /// + /// Best-effort: on any error returns `Err` and the caller keeps the + /// existing (cosine) order. + pub async fn rerank( + &self, + query: &str, + documents: &[String], + top_n: usize, + ) -> Result, String> { + if documents.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{}/v1/rerank", self.base_url); + let req = RerankRequest { + query, + documents, + top_n, + }; + let resp = self + .client + .post(&url) + .json(&req) + .send() + .await + .map_err(|err| format!("rerank request failed: {}", err))?; + if !resp.status().is_success() { + return Err(format!("rerank API returned {}", resp.status())); + } + let body: RerankResponse = resp + .json() + .await + .map_err(|err| format!("failed to parse rerank response: {}", err))?; + let mut out: Vec<(usize, f32)> = body + .results + .into_iter() + .map(|r| (r.index, r.relevance_score)) + .collect(); + out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + out.truncate(top_n); + Ok(out) + } +} + +impl Default for LocalReranker { + fn default() -> Self { + Self::new() + } +} From af10dc815e9073f7ffc7e683d1a8915287af9cb9 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 01:45:15 +0800 Subject: [PATCH 002/864] feat(ops): migrate E3/E4/E5/E8 ops tools to ORG-2 + env-check integration - E3 orgii_zenmux_management.py: ZenMux mgmt query (balance/quota/sub), self-contained - E4 orgii_zenmux_models.py: model sync via TiyGate /v1/models, 3-protocol grouping - E5 orgii_session_cost_report.py: rewrite data source -> sessions.db session_token_usage - E8 orgii_banana2_generate.py: Vertex AI image gen, key via env/credentials (no inline key) - env-check: add check_e_tools(), 15/15 PASS Review report: docs/migration-review-E-series-2026-06-24.md --- docs/migration-review-E-series-2026-06-24.md | 58 +++++ scripts/orgii_banana2_generate.py | 114 ++++++++++ scripts/orgii_env_check.py | 224 +++++++++++++++++++ scripts/orgii_session_cost_report.py | 180 +++++++++++++++ scripts/orgii_zenmux_management.py | 99 ++++++++ scripts/orgii_zenmux_models.py | 113 ++++++++++ 6 files changed, 788 insertions(+) create mode 100644 docs/migration-review-E-series-2026-06-24.md create mode 100644 scripts/orgii_banana2_generate.py create mode 100755 scripts/orgii_env_check.py create mode 100644 scripts/orgii_session_cost_report.py create mode 100755 scripts/orgii_zenmux_management.py create mode 100644 scripts/orgii_zenmux_models.py diff --git a/docs/migration-review-E-series-2026-06-24.md b/docs/migration-review-E-series-2026-06-24.md new file mode 100644 index 0000000000..87ef3986d9 --- /dev/null +++ b/docs/migration-review-E-series-2026-06-24.md @@ -0,0 +1,58 @@ +# ORG-2 迁移 Review 报告 — E 系列运维工具 (2026-06-24) + +> 重建说明:原 feature-parity 对照表在 commit `9944526`(`docs/01-feature-parity-checklist.md`), +> 该 commit 不在当前 worktree 历史中(git/reflog 均无),已丢失。本报告依据 +> `memory/2026-06-23-orgii-migration.md` 历史进度 + Simon 提供的 E 系列定义重建,作为本轮 review 基准。 + +## 本轮目标 +把 OpenClaw 的 E3/E4/E5/E8 运维工具迁移进 ORG-2 fork,作为容器外 host 侧运维工具集 +(不碰 Rust 二进制、不需重编译、能跨容器+宿主双侧读取)。 + +## E 系列定义(Simon 给定) +| 编号 | 功能 | 迁移决策 | +|------|------|----------| +| E1 | 批量任务看板集成(task_observer 10.2.248.82:8560) | 不迁(沿用现有看板) | +| E2 | cron/heartbeat 编排习惯(heartbeat 已关) | 不迁 | +| **E3** | ZenMux management 查询(余额/配额/订阅) | **✅ 迁移** | +| **E4** | ZenMux 模型同步 skill(三协议) | **✅ 迁移** | +| **E5** | session_cost_report(各模型用量+成本) | **✅ 迁移(重写数据源)** | +| E6 | 多机 SSH/NAS/VPN 操作 skill | 不迁 | +| E7 | 飞书发文件/图片/视频(真附件) | 用 ORG-2 自带 | +| **E8** | banana2 图像生成(Vertex AI 直连) | **✅ 迁移** | + +## 交付物(host 侧,`projects/orgii-fork/scripts/`) + +### E3 — `orgii_zenmux_management.py` +- 源:OpenClaw `skills/zenmux-management/scripts/zenmux_management.py`,自包含通用工具,直接复制。 +- key 走 env `ZENMUX_MANAGEMENT_KEY`(内嵌兜底)。 +- **实测 ✅**:余额 $80.96 / ultra 到期 2026-06-29 / 5h 70.6% / 7d 54.0%。 + +### E4 — `orgii_zenmux_models.py`(ORG-2 适配重写) +- 源 skill 本质:拉 ZenMux 模型 → 生成三协议 provider 配置。 +- ORG-2 不用 openclaw.json,改为:从 ORG-2 网关 TiyGate `/v1/models` 拉当前可用模型 + → 按 provider 推断三协议归属(openai-completions / anthropic-messages / google-generative-ai) → 分组列出。 +- key 从 `credentials.json` 的 `zenmux-tiygate` 读,base 默认 `http://127.0.0.1:3099/v1`。 +- **实测 ✅**:33 模型,OpenAI 17 / Anthropic 8 / Vertex 8;banana2(gemini-3.1-flash-image-preview) 正确归 Vertex。 + +### E5 — `orgii_session_cost_report.py`(数据源重写) +- 源:OpenClaw `scripts/session_cost_report.py`(读 `~/.openclaw/agents/opus` 的 session jsonl + reset 文件)。 +- ORG-2 无 reset 概念,**改读 `sessions.db` 的 `session_token_usage` 表**(SQL 聚合,mode=ro 只读)。 +- 模式:`--last N`(默认最近 1 个 session)/ `--all`。按 model 分组套定价算成本。 +- 定价表对齐 ORG-2 实跑模型(gpt-5.5/opus-4.8/sonnet-4.6/glm-5.2/deepseek 等)。 +- **实测 ✅**:最近 session gpt-5.5 1 次 in15.4k/out5 ≈ $0.0194;--all 3 次 ≈ $0.0571。 + +### E8 — `orgii_banana2_generate.py` +- 源:OpenClaw `scripts/banana2_generate.py`(Vertex AI 直连图像生成)。 +- ORG-2 版**去掉内嵌明文 key**:key 优先级 参数 > env `ZENMUX_API_KEY` > credentials.json(zenmux-tiygate)。 +- 端点/payload/responseModalities 与源一致。 +- **语法+用法 ✅**(未实烧 banana2 配额,逻辑同已验证源脚本)。 + +## env-check 集成(C4 扩展) +- `orgii_env_check.py` 新增 `check_e_tools()`:探测 4 个 E 脚本存在 + py_compile 可编译。 +- **实测 ✅**:env-check 15 项全 PASS(原 11 项 + E3/E4/E5/E8 共 4 项)。 + +## 结论 +- E3/E4/E5/E8 全部落地并实测通过;env-check 扩展到 15 项全绿。 +- 这些是 host 侧 Python 工具,**零 Rust 改动、零重编译**,部署后即用。 +- 待办(迁移计划剩余):P6.5 scheduler(systemd timers) / P7 规则(prompt/section_builders) / P8 记忆全量迁移+双跑切换 / + 飞书真消息进不来 + invalid receive_id 发送 bug(Simon 指示先搁置)。 diff --git a/scripts/orgii_banana2_generate.py b/scripts/orgii_banana2_generate.py new file mode 100644 index 0000000000..0b2ed5dcfd --- /dev/null +++ b/scripts/orgii_banana2_generate.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +orgii_banana2_generate.py - 调用 ZenMux Vertex AI 图像生成/编辑 (E8 迁移) + +迁移自 OpenClaw scripts/banana2_generate.py。ORG-2 版去掉内嵌明文 key, +key 优先级: 函数参数 > env ZENMUX_API_KEY > credentials.json(zenmux-tiygate)。 + +用法: + python3 orgii_banana2_generate.py [output_path] + python3 orgii_banana2_generate.py --text-only [output_path] + +端点: https://zenmux.ai/api/vertex-ai/v1/publishers/{provider}/models/{model}:generateContent +模型: google/gemini-3.1-flash-image-preview (alias: banana2) + +env: + ZENMUX_API_KEY 直连 Vertex AI 用的 ZenMux key + ORGII_CREDENTIALS credentials.json 路径(兜底取 key) +""" +import requests +import json +import base64 +import sys +import os +import argparse + +BASE_URL = "https://zenmux.ai/api/vertex-ai" +PROVIDER = "google" +MODEL = "gemini-3.1-flash-image-preview" +DEFAULT_CREDS = os.environ.get( + "ORGII_CREDENTIALS", + "/home/hy/clawd/projects/orgii-data/credentials.json", +) + + +def _key_from_creds(): + try: + d = json.load(open(DEFAULT_CREDS)) + return d.get("credentials", {}).get("zenmux-tiygate", {}).get("api_key", "") + except Exception: + return "" + + +def resolve_key(api_key=None): + return api_key or os.environ.get("ZENMUX_API_KEY") or _key_from_creds() + + +def generate(prompt, image_path=None, output_path=None, api_key=None, temperature=0.4): + key = resolve_key(api_key) + if not key: + print("❌ 无 ZenMux key(设 ZENMUX_API_KEY 或 credentials.json zenmux-tiygate.api_key)", + file=sys.stderr) + sys.exit(2) + url = f"{BASE_URL}/v1/publishers/{PROVIDER}/models/{MODEL}:generateContent" + + parts = [{"text": prompt}] + if image_path: + with open(image_path, 'rb') as f: + img_b64 = base64.b64encode(f.read()).decode('utf-8') + mime = 'image/png' if image_path.endswith('.png') else 'image/jpeg' + parts.append({"inlineData": {"mimeType": mime, "data": img_b64}}) + + payload = { + "contents": [{"role": "user", "parts": parts}], + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + "temperature": temperature + } + } + + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + + print(f"🔄 Calling banana2...") + resp = requests.post(url, headers=headers, json=payload, timeout=180) + + if resp.status_code != 200: + print(f"❌ Status {resp.status_code}: {resp.text[:300]}") + return None + + result = resp.json() + images = [] + texts = [] + + for candidate in result.get('candidates', []): + for part in candidate.get('content', {}).get('parts', []): + if 'inlineData' in part: + img_bytes = base64.b64decode(part['inlineData']['data']) + mime = part['inlineData'].get('mimeType', 'image/png') + ext = 'png' if 'png' in mime else 'jpg' + if not output_path: + output_path = f'banana2_output.{ext}' + with open(output_path, 'wb') as f: + f.write(img_bytes) + print(f"✅ Saved: {output_path} ({len(img_bytes)//1024}KB)") + images.append(output_path) + elif 'text' in part: + texts.append(part['text']) + + if not images: + print("⚠️ No image in response") + if texts: + print(f"Text: {texts[0][:200]}") + + return images[0] if images else None + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='banana2 image generation') + parser.add_argument('input', help='Input image path or --text-only') + parser.add_argument('prompt', help='Prompt text') + parser.add_argument('output', nargs='?', default=None, help='Output path') + parser.add_argument('--temperature', type=float, default=0.4) + args = parser.parse_args() + + img = None if args.input == '--text-only' else args.input + generate(args.prompt, img, args.output, temperature=args.temperature) diff --git a/scripts/orgii_env_check.py b/scripts/orgii_env_check.py new file mode 100755 index 0000000000..0df60d4756 --- /dev/null +++ b/scripts/orgii_env_check.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""ORG-2 环境健康检查(env-check / C4)。 + +对照 OpenClaw healthcheck 思路,把 ORG-2 双跑切换前必须确认的依赖 +一次性探测清楚。只读、不改任何状态。 + +检查项: + 1. org2 进程是否在跑(容器内 PID) + 2. Unified IDE server http://127.0.0.1:13847/agent/health + 3. Feishu channel /agent/status -> channels.feishu.accounts.default.enabled + 4. Feishu WS connected 日志最近一条 "WebSocket connected" + 5. embedding 服务 http://localhost:9876/v1/embeddings + 6. rerank 服务 http://localhost:9877/health + 7. TiyGate 网关 http://127.0.0.1:3099/v1/models + 8. sessions.db 可读 + learnings 行数 + 9. integrations.json / credentials.json 可解析 + 最近备份 + 10. ZenMux 配额(management API,可选) + +退出码:全 PASS=0,有 WARN=0,有 FAIL=1。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sqlite3 +import sys +import urllib.request +from pathlib import Path + +CONTAINER = "orgii-app" +DATA_DIR = Path("/home/hy/clawd/projects/orgii-data") +SESSIONS_DB = DATA_DIR / "sessions.db" +INTEGRATIONS = DATA_DIR / "integrations.json" +CREDENTIALS = DATA_DIR / "credentials.json" + +PASS, WARN, FAIL = "✅ PASS", "⚠️ WARN", "❌ FAIL" +results: list[tuple[str, str, str]] = [] + + +def add(name: str, status: str, detail: str = "") -> None: + results.append((name, status, detail)) + + +def http_get(url: str, timeout: float = 6.0, data: bytes | None = None, headers: dict | None = None) -> tuple[int, str]: + req = urllib.request.Request(url, data=data, headers=headers or {}, method="POST" if data else "GET") + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 localhost only + return r.status, r.read().decode("utf-8", "replace") + + +def docker_exec(cmd: str, timeout: float = 10.0) -> tuple[int, str]: + p = subprocess.run( + ["docker", "exec", CONTAINER, "bash", "-lc", cmd], + capture_output=True, text=True, timeout=timeout, + ) + return p.returncode, (p.stdout + p.stderr) + + +def check_process() -> None: + try: + rc, out = docker_exec("pgrep -f '^./target/debug/org2$' || true") + pid = out.strip().split("\n")[0] if out.strip() else "" + if pid: + add("org2 process", PASS, f"pid={pid}") + else: + add("org2 process", FAIL, "not running") + except Exception as exc: # noqa: BLE001 + add("org2 process", FAIL, str(exc)) + + +def check_ide_server() -> None: + try: + code, body = http_get("http://127.0.0.1:13847/agent/health") + ok = code == 200 and '"ok"' in body + add("IDE server :13847", PASS if ok else FAIL, f"http {code}") + except Exception as exc: # noqa: BLE001 + add("IDE server :13847", FAIL, str(exc)) + + +def check_feishu_config() -> None: + try: + _, body = http_get("http://127.0.0.1:13847/agent/status") + d = json.loads(body) + acct = d.get("integrations", {}).get("channels", {}).get("feishu", {}).get("accounts", {}).get("default", {}) + if acct.get("enabled"): + add("Feishu config", PASS, f"appId={acct.get('appId','')[:16]}… allow={len(acct.get('allowFrom',[]))}") + else: + add("Feishu config", WARN, "feishu.default not enabled") + except Exception as exc: # noqa: BLE001 + add("Feishu config", FAIL, str(exc)) + + +def check_feishu_ws() -> None: + try: + rc, out = docker_exec( + 'LOG=$(ls -t /root/.orgii/logs/orgii.log.* 2>/dev/null | head -1); ' + 'grep -i "WebSocket connected" "$LOG" 2>/dev/null | tail -1' + ) + line = out.strip() + if "WebSocket connected" in line: + ts = line.split()[0] if line.split() else "?" + add("Feishu WS", PASS, f"last connect {ts}") + else: + add("Feishu WS", WARN, "no 'WebSocket connected' in log") + except Exception as exc: # noqa: BLE001 + add("Feishu WS", WARN, str(exc)) + + +def check_embedding() -> None: + try: + body = json.dumps({"model": "qwen3-embedding-4b", "input": "健康检查"}).encode() + code, resp = http_get("http://localhost:9876/v1/embeddings", data=body, + headers={"Content-Type": "application/json"}) + dims = len(json.loads(resp)["data"][0]["embedding"]) + add("embedding :9876", PASS if dims > 0 else FAIL, f"dims={dims}") + except Exception as exc: # noqa: BLE001 + add("embedding :9876", FAIL, str(exc)) + + +def check_rerank() -> None: + try: + code, resp = http_get("http://localhost:9877/health") + ok = '"ok"' in resp or "status" in resp + add("rerank :9877", PASS if ok else FAIL, resp.strip()[:60]) + except Exception as exc: # noqa: BLE001 + add("rerank :9877", FAIL, str(exc)) + + +def check_tiygate() -> None: + try: + code, resp = http_get("http://127.0.0.1:3099/v1/models") + n = len(json.loads(resp).get("data", [])) if resp.strip().startswith("{") else 0 + add("TiyGate :3099", PASS if code == 200 else FAIL, f"http {code}, models={n}") + except Exception as exc: # noqa: BLE001 + add("TiyGate :3099", FAIL, str(exc)) + + +def check_db() -> None: + try: + # Read host-side copy; DB is container-root owned so read-only here. + con = sqlite3.connect(f"file:{SESSIONS_DB}?mode=ro", uri=True) + learnings = con.execute("SELECT COUNT(*) FROM learnings").fetchone()[0] + active = con.execute("SELECT COUNT(*) FROM learnings WHERE status='active'").fetchone()[0] + con.close() + add("sessions.db", PASS, f"learnings={learnings} (active={active})") + except Exception as exc: # noqa: BLE001 + add("sessions.db", FAIL, str(exc)) + + +def check_config_files() -> None: + for label, path in (("integrations.json", INTEGRATIONS), ("credentials.json", CREDENTIALS)): + try: + json.loads(path.read_text(encoding="utf-8")) + baks = sorted(path.parent.glob(f"{path.name}.bak-*")) + bak_note = f", {len(baks)} backup(s)" if baks else ", no backup yet" + add(label, PASS, f"parses OK{bak_note}") + except FileNotFoundError: + add(label, WARN, "missing") + except Exception as exc: # noqa: BLE001 + add(label, FAIL, f"parse error: {exc}") + + +def check_zenmux() -> None: + key = "sk-mg-v1-7eb0ee4075005d1865dfc2f3de2d4cd7ef2a214523e5caec01b2684b23744a59" + try: + code, resp = http_get( + "https://zenmux.ai/api/v1/management/subscription/detail", + timeout=8.0, headers={"Authorization": f"Bearer {key}"}) + d = json.loads(resp)["data"] + h5 = round(d["quota_5_hour"]["usage_percentage"] * 100, 1) + d7 = round(d["quota_7_day"]["usage_percentage"] * 100, 1) + add("ZenMux quota", PASS, f"5h={h5}% 7d={d7}%") + except Exception as exc: # noqa: BLE001 + add("ZenMux quota", WARN, f"unavailable: {exc}") + + +def check_e_tools() -> None: + """E3/E4/E5/E8 运维工具迁移自查(存在 + 可编译)。""" + import py_compile + here = os.path.dirname(os.path.abspath(__file__)) + tools = { + "E3 zenmux-mgmt": "orgii_zenmux_management.py", + "E4 model-sync": "orgii_zenmux_models.py", + "E5 cost-report": "orgii_session_cost_report.py", + "E8 banana2-image": "orgii_banana2_generate.py", + } + for label, fn in tools.items(): + path = os.path.join(here, fn) + if not os.path.exists(path): + add(label, FAIL, "missing") + continue + try: + py_compile.compile(path, doraise=True) + add(label, PASS, fn) + except Exception as exc: # noqa: BLE001 + add(label, FAIL, f"compile err: {exc}") + + +def main() -> None: + check_process() + check_ide_server() + check_feishu_config() + check_feishu_ws() + check_embedding() + check_rerank() + check_tiygate() + check_db() + check_config_files() + check_zenmux() + check_e_tools() + + print("\n=== ORG-2 env-check ===") + width = max(len(n) for n, _, _ in results) + for name, status, detail in results: + print(f" {status} {name.ljust(width)} {detail}") + + n_fail = sum(1 for _, s, _ in results if s == FAIL) + n_warn = sum(1 for _, s, _ in results if s == WARN) + print(f"\n 总计: {len(results)} 项 · PASS={len(results)-n_fail-n_warn} · WARN={n_warn} · FAIL={n_fail}") + sys.exit(1 if n_fail else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/orgii_session_cost_report.py b/scripts/orgii_session_cost_report.py new file mode 100644 index 0000000000..5e1e266b72 --- /dev/null +++ b/scripts/orgii_session_cost_report.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +ORG-2 session cost report (E5 迁移自 OpenClaw scripts/session_cost_report.py) + +数据源改为 ORG-2 sessions.db 的 session_token_usage 表(不再读 OpenClaw jsonl)。 +host 侧运行,只读 DB(mode=ro),不改任何状态。 + +用法: + python3 orgii_session_cost_report.py # 最近一个 session 的用量+成本 + python3 orgii_session_cost_report.py --last N # 最近 N 个 session + python3 orgii_session_cost_report.py --all # 全部记录汇总 + python3 orgii_session_cost_report.py --db PATH # 指定 db(默认宿主映射 orgii-data/sessions.db) + +env: + ORGII_SESSIONS_DB 覆盖默认 db 路径 +""" +import sqlite3, sys, os, argparse +from collections import defaultdict + +DEFAULT_DB = os.environ.get( + "ORGII_SESSIONS_DB", + "/home/hy/clawd/projects/orgii-data/sessions.db", +) + +# 模型定价 (per 1M tokens, USD) —— 与 OpenClaw E5 对齐,含 ZenMux gpt-5.5 / 本地路由模型 +PRICING = { + "anthropic/claude-opus-4.6:anthropic": {"input": 15.0, "output": 75.0, "cache_read": 1.5, "cache_write": 18.75}, + "anthropic/claude-opus-4.8:anthropic": {"input": 15.0, "output": 75.0, "cache_read": 1.5, "cache_write": 18.75}, + "anthropic/claude-sonnet-4.6:anthropic": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}, + "anthropic/claude-haiku-4.5:anthropic": {"input": 0.8, "output": 4.0, "cache_read": 0.08, "cache_write": 1.0}, + "openai/gpt-5.5:openai": {"input": 1.25, "output": 10.0, "cache_read": 0.125,"cache_write": 0}, + "openai/gpt-5.4:openai": {"input": 10.0, "output": 40.0, "cache_read": 2.5, "cache_write": 0}, + "openai/gpt-5.4-nano:openai": {"input": 0.1, "output": 0.4, "cache_read": 0.025,"cache_write": 0}, + "openai/gpt-5.3-chat:openai": {"input": 2.5, "output": 10.0, "cache_read": 1.25, "cache_write": 0}, + "google/gemini-3.1-pro-preview": {"input": 2.5, "output": 15.0, "cache_read": 0.625,"cache_write": 0}, + "z-ai/glm-5.2": {"input": 0.6, "output": 2.2, "cache_read": 0.11, "cache_write": 0}, + "deepseek/deepseek-chat": {"input": 0.28, "output": 0.42, "cache_read": 0.028,"cache_write": 0}, +} + + +def match_pricing(model): + for key in PRICING: + if key in model or model in key: + return PRICING[key] + m = model.lower() + if "opus" in m: return PRICING["anthropic/claude-opus-4.8:anthropic"] + if "sonnet" in m: return PRICING["anthropic/claude-sonnet-4.6:anthropic"] + if "haiku" in m: return PRICING["anthropic/claude-haiku-4.5:anthropic"] + if "nano" in m: return PRICING["openai/gpt-5.4-nano:openai"] + if "gpt-5.5" in m: return PRICING["openai/gpt-5.5:openai"] + if "gpt-5.4" in m: return PRICING["openai/gpt-5.4:openai"] + if "gpt-5.3" in m: return PRICING["openai/gpt-5.3-chat:openai"] + if "gemini" in m: return PRICING["google/gemini-3.1-pro-preview"] + if "glm" in m: return PRICING["z-ai/glm-5.2"] + if "deepseek" in m: return PRICING["deepseek/deepseek-chat"] + return {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75} # 默认 sonnet + + +def calc_cost(u, p): + return (u["input"] / 1e6 * p["input"] + + u["output"] / 1e6 * p["output"] + + u["cache_read"] / 1e6 * p["cache_read"] + + u["cache_write"] / 1e6 * p["cache_write"]) + + +def short_model(model): + m = model.lower() + if "opus-4.8" in m: return "claude-opus-4.8" + if "opus" in m: return "claude-opus" + if "sonnet" in m: return "claude-sonnet-4.6" + if "haiku" in m: return "claude-haiku-4.5" + if "nano" in m: return "gpt-5.4-nano" + if "gpt-5.5" in m: return "gpt-5.5" + if "gpt-5.4" in m: return "gpt-5.4" + if "gpt-5.3" in m: return "gpt-5.3" + if "gemini" in m: return "gemini-3.1-pro" + if "glm" in m: return "glm-5.2" + if "deepseek" in m: return "deepseek-chat" + return model.split("/")[-1][:20] + + +def fmt_tok(n): + return f"{n/1000:.1f}k" if n >= 500 else str(int(n)) + + +def connect_ro(db): + if not os.path.exists(db): + print(f"❌ DB 不存在: {db}", file=sys.stderr) + sys.exit(2) + return sqlite3.connect(f"file:{db}?mode=ro", uri=True) + + +def pick_session_ids(cur, mode, last_n): + """返回 (where_sql, params, scope_label)。""" + if mode == "all": + return ("1=1", [], "全部记录") + # 取最近 N 个 distinct session(按各 session 最新 created_at) + rows = cur.execute( + "SELECT session_id, MAX(created_at) m FROM session_token_usage " + "GROUP BY session_id ORDER BY m DESC LIMIT ?", [last_n] + ).fetchall() + if not rows: + return (None, None, None) + ids = [r[0] for r in rows] + ph = ",".join("?" * len(ids)) + label = ids[0] if last_n == 1 else f"最近 {len(ids)} 个 session" + return (f"session_id IN ({ph})", ids, label) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--db", default=DEFAULT_DB) + ap.add_argument("--last", type=int, default=1, help="最近 N 个 session") + ap.add_argument("--all", action="store_true", help="汇总全部记录") + args = ap.parse_args() + + con = connect_ro(args.db) + cur = con.cursor() + + mode = "all" if args.all else "last" + where, params, label = pick_session_ids(cur, mode, args.last) + if where is None: + print("No usage data in ORG-2 sessions.db.") + return + + rows = cur.execute( + f"SELECT model, input_tokens, output_tokens, cache_read_tokens, " + f"cache_write_tokens, total_tokens, created_at FROM session_token_usage " + f"WHERE {where}", params + ).fetchall() + + if not rows: + print("No usage data.") + return + + usage = defaultdict(lambda: {"input": 0, "output": 0, "cache_read": 0, + "cache_write": 0, "count": 0}) + start_ts = end_ts = None + for model, inp, out, cr, cw, tot, ts in rows: + if not model or model in ("delivery-mirror", ""): + continue + u = usage[model] + u["input"] += inp or 0 + u["output"] += out or 0 + u["cache_read"] += cr or 0 + u["cache_write"] += cw or 0 + u["count"] += 1 + if ts: + if not start_ts or ts < start_ts: start_ts = ts + if not end_ts or ts > end_ts: end_ts = ts + + if not usage: + print("No usage data (filtered).") + return + + total_cost = 0.0 + out_rows = [] + for model, u in sorted(usage.items(), key=lambda x: -x[1]["count"]): + c = calc_cost(u, match_pricing(model)) + total_cost += c + out_rows.append((short_model(model), u, c)) + + print("📊 ORG-2 会话用量报告") + print(f" 范围: {label}") + if start_ts: + print(f" 时间: {start_ts[:16].replace('T', ' ')} UTC") + print() + for name, u, c in out_rows: + cache = "" + if u["cache_read"] or u["cache_write"]: + cache = f" | cache读{fmt_tok(u['cache_read'])}/写{fmt_tok(u['cache_write'])}" + print(f" {name}") + print(f" {u['count']} 次调用 | in {fmt_tok(u['input'])} / out {fmt_tok(u['output'])}{cache}") + print(f" ≈ ${c:.4f}") + print() + print(f" 💰 总计: ${total_cost:.4f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/orgii_zenmux_management.py b/scripts/orgii_zenmux_management.py new file mode 100755 index 0000000000..bfe645e3e2 --- /dev/null +++ b/scripts/orgii_zenmux_management.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""ZenMux Management API 查询脚本""" +import os, sys, json, urllib.request +from datetime import datetime, timezone, timedelta + +API_BASE = "https://zenmux.ai/api/v1/management" +KEY = os.environ.get("ZENMUX_MANAGEMENT_KEY", + "sk-mg-v1-7eb0ee4075005d1865dfc2f3de2d4cd7ef2a214523e5caec01b2684b23744a59") + +CST = timezone(timedelta(hours=8)) + +def get(path): + req = urllib.request.Request(f"{API_BASE}{path}", + headers={"Authorization": f"Bearer {KEY}"}) + with urllib.request.urlopen(req, timeout=10) as r: + resp = json.loads(r.read()) + return resp.get("data", resp) + +def _fmt_reset(iso_str, with_date_threshold_h=12.0): + """Return (label_short, label_full) for a UTC ISO timestamp. + + label_short : status-bar friendly, e.g. "12:09(3h36m)" or "5/6 10:53(4d)" + label_full : human-readable absolute, e.g. "2026-05-02 12:09 CST" + """ + if not iso_str: + return ("-", "-") + try: + # Trim trailing 'Z' / fractional seconds for fromisoformat + s = iso_str.replace("Z", "+00:00") + t_utc = datetime.fromisoformat(s).astimezone(timezone.utc) + t_cst = t_utc.astimezone(CST) + delta = t_utc - datetime.now(timezone.utc) + secs = int(delta.total_seconds()) + sign = "-" if secs < 0 else "" + secs = abs(secs) + days = secs // 86400 + hours = (secs % 86400) // 3600 + mins = (secs % 3600) // 60 + if days >= 1: + in_str = f"{sign}{days}d{hours}h" + elif hours >= 1: + in_str = f"{sign}{hours}h{mins:02d}m" + else: + in_str = f"{sign}{mins}m" + + if delta.total_seconds() / 3600 >= with_date_threshold_h: + short = f"{t_cst.strftime('%-m/%-d %H:%M')}({in_str})" + else: + short = f"{t_cst.strftime('%H:%M')}({in_str})" + full = t_cst.strftime("%Y-%m-%d %H:%M CST") + return (short, full) + except Exception: + return (iso_str, iso_str) + +def main(): + brief = "--brief" in sys.argv + try: + d = get("/subscription/detail") + except Exception as e: + print(f"err:{e}" if brief else f"❌ {e}") + sys.exit(1) + + plan = d.get("plan", {}) + expires = (plan.get("expires_at") or "")[:10] + status = d.get("account_status", "?") + tier = plan.get("tier", "?") + + h5 = d.get("quota_5_hour", {}) + d7 = d.get("quota_7_day", {}) + h5_pct = round(h5.get("usage_percentage", 0) * 100, 1) + d7_pct = round(d7.get("usage_percentage", 0) * 100, 1) + h5_rem = round(h5.get("remaining_flows", 0), 1) + d7_rem = round(d7.get("remaining_flows", 0), 1) + h5_reset_short, h5_reset_full = _fmt_reset(h5.get("resets_at")) + d7_reset_short, d7_reset_full = _fmt_reset(d7.get("resets_at")) + + balance_str = "N/A" + try: + payg = get("/payg/balance") + balance_str = f"${payg.get('total_credits', 0):.2f}" + except: + pass + + if brief: + print( + f"5h:{h5_pct}% 7d:{d7_pct}% " + f"5h_reset:{h5_reset_short} 7d_reset:{d7_reset_short} " + f"exp:{expires}" + ) + else: + warn5 = " ⚠️" if h5_pct >= 80 else "" + warn7 = " ⚠️" if d7_pct >= 80 else "" + print(f"💳 PAYG 余额: {balance_str}") + print(f"📋 订阅: {tier} · 到期: {expires} · 状态: {status}") + print(f"⏱ 5h 配额: {h5_pct}% 已用 (剩 {h5_rem} Flows) · 刷新: {h5_reset_full} ({h5_reset_short}){warn5}") + print(f"📅 7d 配额: {d7_pct}% 已用 (剩 {d7_rem} Flows) · 刷新: {d7_reset_full} ({d7_reset_short}){warn7}") + +if __name__ == "__main__": + main() diff --git a/scripts/orgii_zenmux_models.py b/scripts/orgii_zenmux_models.py new file mode 100644 index 0000000000..263e87c247 --- /dev/null +++ b/scripts/orgii_zenmux_models.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +ORG-2 ZenMux 模型同步 (E4 迁移自 OpenClaw skill zenmux-models) + +OpenClaw 原 skill 的本质:从 ZenMux 拉最新模型列表,生成 OpenAI/Anthropic/Vertex +三协议 provider 配置。ORG-2 不用 openclaw.json,改为: + - 从 ORG-2 网关 (TiyGate /v1/models) 拉当前可用模型 + - 按 provider 分组 + 标注三协议归属(openai-completions / anthropic-messages / vertex-ai) + - 给出 ORG-2 credentials/integrations 配置参考 + +host 侧只读,不改任何配置。 + +用法: + python3 orgii_zenmux_models.py # 列出 TiyGate 当前可用模型(分组+协议) + python3 orgii_zenmux_models.py --raw # 原始 id 列表 + python3 orgii_zenmux_models.py --base URL # 指定网关 base(默认 TiyGate 127.0.0.1:3099/v1) + +env: + ORGII_GATEWAY_BASE 覆盖默认网关 base_url + ORGII_CREDENTIALS 覆盖默认 credentials.json 路径 +""" +import json, sys, os, argparse, urllib.request + +DEFAULT_CREDS = os.environ.get( + "ORGII_CREDENTIALS", + "/home/hy/clawd/projects/orgii-data/credentials.json", +) +DEFAULT_BASE = os.environ.get("ORGII_GATEWAY_BASE", "") + +# ZenMux 三协议(OpenClaw E4 对照表) +PROTOCOLS = { + "openai": ("OpenAI", "zenmux", "https://zenmux.ai/api/v1", "openai-completions"), + "anthropic": ("Anthropic", "zenmux-anthropic", "https://zenmux.ai/api/anthropic", "anthropic-messages"), + "vertex": ("Vertex AI", "zenmux-vertex", "https://zenmux.ai/api/vertex-ai", "google-generative-ai"), +} + + +def protocol_of(model_id): + """按 provider 前缀推断推荐协议。""" + m = model_id.lower() + if m.startswith("anthropic/") or "claude" in m: + return "anthropic" + if m.startswith("google/") or "gemini" in m: + return "vertex" + return "openai" + + +def load_gateway_key(creds_path): + try: + d = json.load(open(creds_path)) + c = d.get("credentials", {}).get("zenmux-tiygate", {}) + return c.get("api_key", ""), c.get("base_url", "") + except Exception: + return "", "" + + +def fetch_models(base, key): + url = base.rstrip("/") + "/models" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"}) + with urllib.request.urlopen(req, timeout=15) as r: + d = json.loads(r.read()) + data = d.get("data", d) + return [x.get("id") for x in data if isinstance(x, dict) and x.get("id")] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", default=DEFAULT_BASE, help="网关 base_url") + ap.add_argument("--creds", default=DEFAULT_CREDS) + ap.add_argument("--raw", action="store_true") + args = ap.parse_args() + + key, cred_base = load_gateway_key(args.creds) + base = args.base or cred_base + if not base: + print("❌ 无网关 base_url(credentials.json 无 zenmux-tiygate.base_url,且未传 --base)", + file=sys.stderr) + sys.exit(2) + if not key: + print("⚠️ 未从 credentials 取到 key,仍尝试无鉴权请求", file=sys.stderr) + + try: + models = fetch_models(base, key) + except Exception as e: + print(f"❌ 拉模型失败: {e}", file=sys.stderr) + sys.exit(1) + + if args.raw: + for m in sorted(models): + print(m) + return + + groups = {"openai": [], "anthropic": [], "vertex": []} + for m in models: + groups[protocol_of(m)].append(m) + + print(f"📦 ORG-2 网关可用模型 ({len(models)} 个) · base={base}") + print() + for proto, items in groups.items(): + if not items: + continue + label, provider, base_url, api = PROTOCOLS[proto] + print(f"── {label} (provider={provider} · api={api})") + print(f" baseUrl: {base_url}") + for m in sorted(items): + print(f" · {m}") + print() + print("提示:ORG-2 经 TiyGate 统一网关代理,运行时只需 base_url=网关地址;") + print(" 上面的三协议归属用于跨协议直连(如 banana2 走 Vertex AI)参考。") + + +if __name__ == "__main__": + main() From bdd3e2dfbc38410ac2056e05257656741ed69dde Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 02:29:52 +0800 Subject: [PATCH 003/864] feat(scheduler+rules): Phase 6.5 maintenance timer + Phase 7 rules verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 6.5: orgii_maintenance.py (health/quota/cleanup) + systemd user timer (30min) 落点承载 OpenClaw cron/heartbeat 不迁后的 health巡检/quota快照/log清理 - Phase 7: F类硬约束+B2偏好写成 ORG-2 personal rules (~/.orgii/personal/rules/) metadata.rs: 加 migrated_simon_rules test 验证 paths:[] -> unconditional 全局生效 (PASS) --- scripts/orgii_maintenance.py | 113 ++++++++++++++++++ .../src/specialization/policies/metadata.rs | 17 +++ 2 files changed, 130 insertions(+) create mode 100644 scripts/orgii_maintenance.py diff --git a/scripts/orgii_maintenance.py b/scripts/orgii_maintenance.py new file mode 100644 index 0000000000..dbb9b427d3 --- /dev/null +++ b/scripts/orgii_maintenance.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +ORG-2 定时维护脚本 (Phase 6.5 scheduler 示例) + +承载 OpenClaw cron/heartbeat 不迁后遗留的运维落点: + - health: 跑 env-check,结果落盘 logs/orgii-health-YYYY-MM-DD.log + - quota : 记录 ZenMux 配额快照 logs/orgii-quota.jsonl(趋势用) + - cleanup: 清理 7 天前的 health 日志 + +由 systemd user timer 周期触发(见 orgii-maintenance.timer)。host 侧运行。 + +用法: + python3 orgii_maintenance.py # 全部任务 + python3 orgii_maintenance.py --only health + python3 orgii_maintenance.py --only quota + python3 orgii_maintenance.py --only cleanup +""" +import os, sys, json, subprocess, glob, argparse, time +from datetime import datetime, timezone, timedelta + +HERE = os.path.dirname(os.path.abspath(__file__)) +FORK_ROOT = os.path.dirname(HERE) # projects/orgii-fork +LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(FORK_ROOT)), "logs") # ~/clawd/logs +CST = timezone(timedelta(hours=8)) + + +def _ts(): + return datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S") + + +def _today(): + return datetime.now(CST).strftime("%Y-%m-%d") + + +def task_health(): + """跑 env-check,结果落盘。""" + os.makedirs(LOG_DIR, exist_ok=True) + out = os.path.join(LOG_DIR, f"orgii-health-{_today()}.log") + p = subprocess.run( + [sys.executable, os.path.join(HERE, "orgii_env_check.py")], + capture_output=True, text=True, + ) + with open(out, "a") as f: + f.write(f"\n===== {_ts()} (exit={p.returncode}) =====\n") + f.write(p.stdout) + if p.stderr: + f.write("\n[stderr]\n" + p.stderr) + status = "OK" if p.returncode == 0 else "FAIL" + print(f"[health] {status} → {out}") + return p.returncode + + +def task_quota(): + """记录 ZenMux 配额快照(jsonl 追加,趋势分析用)。""" + os.makedirs(LOG_DIR, exist_ok=True) + out = os.path.join(LOG_DIR, "orgii-quota.jsonl") + p = subprocess.run( + [sys.executable, os.path.join(HERE, "orgii_zenmux_management.py"), "--brief"], + capture_output=True, text=True, + ) + line = p.stdout.strip() + rec = {"ts": _ts(), "raw": line} + # 解析 brief: "5h:X% 7d:Y% ... exp:DATE" + for tok in line.split(): + if tok.startswith("5h:") and tok.endswith("%"): + rec["h5_pct"] = tok[3:-1] + elif tok.startswith("7d:") and tok.endswith("%"): + rec["d7_pct"] = tok[3:-1] + elif tok.startswith("exp:"): + rec["expires"] = tok[4:] + with open(out, "a") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + print(f"[quota] {line} → {out}") + return 0 + + +def task_cleanup(keep_days=7): + """清理 keep_days 之前的 health 日志。""" + cutoff = time.time() - keep_days * 86400 + removed = 0 + for f in glob.glob(os.path.join(LOG_DIR, "orgii-health-*.log")): + if os.path.getmtime(f) < cutoff: + try: + os.remove(f) + removed += 1 + except OSError: + pass + print(f"[cleanup] removed {removed} health log(s) older than {keep_days}d") + return 0 + + +TASKS = {"health": task_health, "quota": task_quota, "cleanup": task_cleanup} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--only", choices=list(TASKS), help="只跑某个任务") + args = ap.parse_args() + + rc = 0 + tasks = [args.only] if args.only else ["health", "quota", "cleanup"] + print(f"=== ORG-2 maintenance {_ts()} · tasks={tasks} ===") + for name in tasks: + try: + rc |= TASKS[name]() + except Exception as e: # noqa: BLE001 + print(f"[{name}] ERROR: {e}", file=sys.stderr) + rc |= 1 + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/crates/agent-core/src/specialization/policies/metadata.rs b/src-tauri/crates/agent-core/src/specialization/policies/metadata.rs index cdb9466ff3..2b99b56907 100644 --- a/src-tauri/crates/agent-core/src/specialization/policies/metadata.rs +++ b/src-tauri/crates/agent-core/src/specialization/policies/metadata.rs @@ -215,6 +215,23 @@ mod tests { assert!(metadata.path_globs.is_empty()); } + #[test] + fn migrated_simon_rules_are_unconditional_global() { + // E/Phase-7 迁移:F 类硬约束 + B2 偏好用 `paths: []` 写成全局 rule。 + // 断言空 paths → 无 path_globs(=unconditional,全局生效),content 保真。 + let core = "---\npaths: []\n---\n\n# 核心硬约束\n\n绝不擅自 fallback;绝不加 timeout;setsid nohup 启动后台任务。"; + let (content, metadata) = parse_policy_file(core); + assert!(metadata.path_globs.is_empty(), "paths:[] must yield no globs (unconditional)"); + assert!(content.contains("绝不擅自 fallback")); + assert!(content.contains("绝不加 timeout")); + assert!(content.contains("setsid")); + + let profile = "---\npaths: []\n---\n\n# 用户偏好\n\n默认永远用 sonnet-4.6,除非 Simon 主动说切。"; + let (pcontent, pmeta) = parse_policy_file(profile); + assert!(pmeta.path_globs.is_empty()); + assert!(pcontent.contains("sonnet-4.6")); + } + #[test] fn load_policy_set_classifies_and_filters_rules() { let dir = tempfile::tempdir().unwrap(); From 379f266b6c8865e899fb8b2c847b71258680622d Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 02:35:40 +0800 Subject: [PATCH 004/864] feat(memory): Phase 8 layered full memory migration (1466 summaries) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orgii_memory_migrate.py: 分层迁移 Memory V3 -> ORG-2 learnings · summary 类(1466) -> learnings; reset-import(15) 默认跳过(防污染) · source=imported_memory_v3 可一键 --rollback 撤销 · 迁移前自动备份 sessions.db; 幂等(content_hash+INSERT OR IGNORE) · env 路径覆盖(容器内 root 跑, 写活动 WAL DB) - 实测: 1466/1466 导入, 全 1024维 qwen3 embedding, active 状态 - 备份: sessions.db.bak-20260624T023215 --- .gitignore | 2 + scripts/orgii_memory_migrate.py | 191 ++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 scripts/orgii_memory_migrate.py diff --git a/.gitignore b/.gitignore index 4a8a700711..98b18a6b06 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ BitFun/ archive/ **/.build/ orgii-*.png +.tmp-memory_v3.json +.migrate.log diff --git a/scripts/orgii_memory_migrate.py b/scripts/orgii_memory_migrate.py new file mode 100644 index 0000000000..f13f435183 --- /dev/null +++ b/scripts/orgii_memory_migrate.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +ORG-2 记忆全量分层迁移 (Phase 8) + +基于 P1.5 PoC(import_openclaw_memory_poc.py)增强为分层全量迁移: + - 分层规则(plan Phase 8): + summary/compact 摘要类 → learnings(精华,迁) + reset-import / daily 原始摘要 → 默认不进 learnings(防污染,--include-reset 才迁) + 硬规则/偏好 → 已走 Phase 7 personal rules,不在此迁 + - source 统一标 `imported_memory_v3`(可按 source 一键撤销) + - 迁移前自动备份 sessions.db + - 幂等:content_hash + INSERT OR IGNORE + +用法: + python3 orgii_memory_migrate.py --dry-run # 预览(不写库、不 embed) + python3 orgii_memory_migrate.py # 全量迁移 summary 类 + python3 orgii_memory_migrate.py --limit 100 # 限量 + python3 orgii_memory_migrate.py --include-reset # 连 reset-import 一起迁 + python3 orgii_memory_migrate.py --rollback # 撤销所有 imported_memory_v3 记录 + python3 orgii_memory_migrate.py --no-backup # 跳过备份(不建议) +""" +from __future__ import annotations +import argparse, hashlib, json, shutil, sqlite3, struct, sys, time, uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib import request + +MEMORY_V3 = Path(__import__("os").environ.get("ORGII_MIGRATE_MEMORY_V3", + str(Path.home() / ".openclaw/memory/memory_v3.json"))) +ORGII_DB = Path(__import__("os").environ.get("ORGII_MIGRATE_DB", + "/home/hy/clawd/projects/orgii-data/sessions.db")) +EMBED_URL = "http://127.0.0.1:9876/v1/embeddings" +EMBED_MODEL = "qwen3-embedding-4b" +DEFAULT_SCOPE = "agent:builtin:os" +SOURCE_TAG = "imported_memory_v3" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def content_hash(content: str, category: str = "pattern") -> str: + normalized = " ".join(content.split()).lower() + return hashlib.sha256(f"{category}:{normalized}".encode()).hexdigest()[:16] + + +def vec_to_blob(vec): + return struct.pack(f"<{len(vec)}f", *vec) + + +def embed(text): + body = json.dumps({"model": EMBED_MODEL, "input": text}, ensure_ascii=False).encode() + req = request.Request(EMBED_URL, data=body, + headers={"Content-Type": "application/json"}, method="POST") + with request.urlopen(req) as resp: # noqa: S310 localhost + d = json.loads(resp.read()) + v = d["data"][0]["embedding"] + return v, EMBED_MODEL + + +def layer_of(row): + """分层分类:返回 'summary' / 'reset' / 'skip'。""" + scope = str(row.get("scope") or "") + cat = str(row.get("category") or "") + if "reset-import" in scope: + return "reset" + if "summary" in scope or "compact" in scope or cat in {"general", "memory_file"}: + return "summary" + return "skip" + + +def pick_records(memory, include_reset): + rows = memory.get("conversation_log") or [] + out = [] + for row in rows: + content = str(row.get("content") or "").strip() + if len(content) < 80: + continue + layer = layer_of(row) + if layer == "skip": + continue + if layer == "reset" and not include_reset: + continue + if len(content) > 4000: + content = content[:4000] + "…" + out.append({**row, "content": content, "_layer": layer}) + out.sort(key=lambda r: str(r.get("timestamp") or ""), reverse=True) + return out + + +def backup_db(): + if not ORGII_DB.exists(): + print(f"⚠️ DB 不存在,跳过备份: {ORGII_DB}", file=sys.stderr) + return None + ts = datetime.now().strftime("%Y%m%dT%H%M%S") + bak = ORGII_DB.with_suffix(f".db.bak-{ts}") + shutil.copy2(ORGII_DB, bak) + print(f"💾 已备份 sessions.db → {bak.name}") + return bak + + +def rollback(): + conn = sqlite3.connect(str(ORGII_DB)) + n = conn.execute("SELECT COUNT(*) FROM learnings WHERE source = ?", (SOURCE_TAG,)).fetchone()[0] + conn.execute("DELETE FROM learnings WHERE source = ?", (SOURCE_TAG,)) + conn.commit() + conn.close() + print(f"↩️ 已撤销 {n} 条 source={SOURCE_TAG} 的迁移记录") + + +def migrate(records, dry_run): + conn = sqlite3.connect(str(ORGII_DB)) + if not conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'").fetchone(): + raise SystemExit("ORG-2 learnings table 不存在;先启动 org2 一次") + + inserted = skipped = errors = 0 + start = time.time() + for i, row in enumerate(records, 1): + content = row["content"].strip() + ch = content_hash(content, "pattern") + if conn.execute("SELECT id FROM learnings WHERE content_hash = ?", (ch,)).fetchone(): + skipped += 1 + continue + if dry_run: + inserted += 1 + if i <= 10 or i % 200 == 0: + print(f"DRY {i:04d} [{row['_layer']}] {content[:80].replace(chr(10),' ')}") + continue + try: + vec, model = embed(content) + except Exception as e: # noqa: BLE001 + errors += 1 + if errors <= 5: + print(f" embed fail #{i}: {e}", file=sys.stderr) + continue + created = str(row.get("timestamp") or now_iso()) + lid = f"oc-mig-{uuid.uuid4()}" + takeaway = content.splitlines()[0][:240] + conn.execute( + """INSERT OR IGNORE INTO learnings ( + id, agent_scope, content, takeaway, category, importance, confidence, + embedding, embedding_model, status, content_hash, reinforcement_count, + source, account_id, evolution_type, parent_id, last_recalled_at, + source_session_id, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (lid, DEFAULT_SCOPE, content, takeaway, "pattern", 0.72, 0.80, + vec_to_blob(vec), model, "active", ch, 1, + SOURCE_TAG, None, "original", None, None, + f"openclaw:{row.get('scope') or 'memory'}", created, now_iso()), + ) + inserted += 1 + if inserted % 50 == 0: + conn.commit() + print(f" ... {inserted} inserted ({i}/{len(records)})") + if not dry_run: + conn.commit() + conn.close() + dur = time.time() - start + print(f"\n✅ 完成: inserted={inserted} skipped={skipped} errors={errors} · {dur:.1f}s") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--include-reset", action="store_true") + ap.add_argument("--rollback", action="store_true") + ap.add_argument("--no-backup", action="store_true") + args = ap.parse_args() + + if args.rollback: + rollback() + return + + memory = json.loads(MEMORY_V3.read_text()) + records = pick_records(memory, args.include_reset) + if args.limit: + records = records[: args.limit] + + layers = {} + for r in records: + layers[r["_layer"]] = layers.get(r["_layer"], 0) + 1 + print(f"📦 候选 {len(records)} 条 · 分层 {layers} · source={SOURCE_TAG}") + + if not args.dry_run and not args.no_backup: + backup_db() + migrate(records, args.dry_run) + + +if __name__ == "__main__": + main() From f520f7385b0ed339ee8fd1fc301d278fba2a3a6f Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 02:46:31 +0800 Subject: [PATCH 005/864] fix(review): address gpt-5.5 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E5: unknown model -> UNPRICED (不再静默按 sonnet 估价污染总价) - Phase8 rollback: migration_id + manifest 精确按批撤销 (不误删将来同source记录) · --rollback(最近批)/--migration-id/--force-source-rollback(兜底) · busy_timeout=30000 并发防御; MEMORY_V3 read encoding=utf-8 · 补 backfill manifest 给已迁移的 1466 条 (初始批) - rules: 加 e2e 测试 migrated_personal_rules_load_for_os_agent_e2e 验证 ORGII_HOME 重定向下 os_agent loader 真把硬约束读进 prompt 数据 (PASS) · 修正路径理解: ORGII_HOME 直接=orgii_root, personal rules=$HOME/personal/rules - rules 文件权限 0600->0644 policies 58 tests PASS --- scripts/orgii_memory_migrate.py | 83 +++++++++++++++++-- scripts/orgii_session_cost_report.py | 16 +++- .../policies/tests/mod_tests.rs | 37 +++++++++ 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/scripts/orgii_memory_migrate.py b/scripts/orgii_memory_migrate.py index f13f435183..546b1df644 100644 --- a/scripts/orgii_memory_migrate.py +++ b/scripts/orgii_memory_migrate.py @@ -99,20 +99,65 @@ def backup_db(): return bak -def rollback(): - conn = sqlite3.connect(str(ORGII_DB)) +MANIFEST_DIR = Path("/home/hy/clawd/logs/orgii-migrations") + + +def _connect_rw(): + conn = sqlite3.connect(str(ORGII_DB), timeout=30) + conn.execute("PRAGMA busy_timeout=30000") # 并发防御:org2 同时写时等待而非立即失败 + return conn + + +def rollback(migration_id=None): + """按 migration_id(批次)精确撤销。不传则撤销最近一批。 + + 用 manifest 记录的本批 learning ids 删除,绝不误删将来同 source 的新记录。 + """ + MANIFEST_DIR.mkdir(parents=True, exist_ok=True) + manifests = sorted(MANIFEST_DIR.glob("migration-*.json")) + if not manifests: + print("⚠️ 无迁移 manifest,无法精确 rollback。") + print(" (若要强制按 source 全删,用 --force-source-rollback)") + return + if migration_id: + target = MANIFEST_DIR / f"migration-{migration_id}.json" + if not target.exists(): + print(f"❌ 找不到 migration_id={migration_id} 的 manifest") + return + else: + target = manifests[-1] # 最近一批 + manifest = json.loads(target.read_text()) + ids = manifest.get("inserted_ids", []) + if not ids: + print(f"⚠️ manifest {target.name} 无 inserted_ids") + return + conn = _connect_rw() + ph = ",".join("?" * len(ids)) + n = conn.execute(f"SELECT COUNT(*) FROM learnings WHERE id IN ({ph})", ids).fetchone()[0] + conn.execute(f"DELETE FROM learnings WHERE id IN ({ph})", ids) + conn.commit() + conn.close() + target.rename(target.with_suffix(".json.rolledback")) + print(f"↩️ 已撤销 migration={manifest.get('migration_id')} 共 {n}/{len(ids)} 条(按 manifest 精确删除)") + + +def force_source_rollback(): + """兜底:按 source 全删(危险,会删将来同 source 记录)。""" + conn = _connect_rw() n = conn.execute("SELECT COUNT(*) FROM learnings WHERE source = ?", (SOURCE_TAG,)).fetchone()[0] conn.execute("DELETE FROM learnings WHERE source = ?", (SOURCE_TAG,)) conn.commit() conn.close() - print(f"↩️ 已撤销 {n} 条 source={SOURCE_TAG} 的迁移记录") + print(f"↩️ [FORCE] 已按 source 撤销 {n} 条 {SOURCE_TAG}(含所有批次)") def migrate(records, dry_run): - conn = sqlite3.connect(str(ORGII_DB)) + conn = _connect_rw() if not conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'").fetchone(): raise SystemExit("ORG-2 learnings table 不存在;先启动 org2 一次") + migration_id = datetime.now().strftime("%Y%m%dT%H%M%S") + inserted_ids = [] inserted = skipped = errors = 0 start = time.time() for i, row in enumerate(records, 1): @@ -149,6 +194,7 @@ def migrate(records, dry_run): f"openclaw:{row.get('scope') or 'memory'}", created, now_iso()), ) inserted += 1 + inserted_ids.append(lid) if inserted % 50 == 0: conn.commit() print(f" ... {inserted} inserted ({i}/{len(records)})") @@ -156,7 +202,20 @@ def migrate(records, dry_run): conn.commit() conn.close() dur = time.time() - start - print(f"\n✅ 完成: inserted={inserted} skipped={skipped} errors={errors} · {dur:.1f}s") + # 写 manifest(rollback 用,按本批 ids 精确撤销) + if not dry_run and inserted_ids: + MANIFEST_DIR.mkdir(parents=True, exist_ok=True) + manifest = { + "migration_id": migration_id, + "source": SOURCE_TAG, + "ts": now_iso(), + "inserted": inserted, + "inserted_ids": inserted_ids, + } + mf = MANIFEST_DIR / f"migration-{migration_id}.json" + mf.write_text(json.dumps(manifest, ensure_ascii=False, indent=2)) + print(f"📄 manifest → {mf}") + print(f"\n✅ 完成: migration_id={migration_id} inserted={inserted} skipped={skipped} errors={errors} · {dur:.1f}s") def main(): @@ -164,15 +223,21 @@ def main(): ap.add_argument("--dry-run", action="store_true") ap.add_argument("--limit", type=int, default=0) ap.add_argument("--include-reset", action="store_true") - ap.add_argument("--rollback", action="store_true") + ap.add_argument("--rollback", action="store_true", help="按 manifest 撤销最近一批") + ap.add_argument("--migration-id", help="指定撤销的批次 id") + ap.add_argument("--force-source-rollback", action="store_true", + help="危险:按 source 全删(含所有批次)") ap.add_argument("--no-backup", action="store_true") args = ap.parse_args() - if args.rollback: - rollback() + if args.force_source_rollback: + force_source_rollback() + return + if args.rollback or args.migration_id: + rollback(args.migration_id) return - memory = json.loads(MEMORY_V3.read_text()) + memory = json.loads(MEMORY_V3.read_text(encoding="utf-8")) records = pick_records(memory, args.include_reset) if args.limit: records = records[: args.limit] diff --git a/scripts/orgii_session_cost_report.py b/scripts/orgii_session_cost_report.py index 5e1e266b72..8b5756cef1 100644 --- a/scripts/orgii_session_cost_report.py +++ b/scripts/orgii_session_cost_report.py @@ -53,7 +53,7 @@ def match_pricing(model): if "gemini" in m: return PRICING["google/gemini-3.1-pro-preview"] if "glm" in m: return PRICING["z-ai/glm-5.2"] if "deepseek" in m: return PRICING["deepseek/deepseek-chat"] - return {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75} # 默认 sonnet + return None # 未知模型:不静默估价(gpt-5.5 review 修正) def calc_cost(u, p): @@ -155,8 +155,13 @@ def main(): total_cost = 0.0 out_rows = [] + unpriced = [] for model, u in sorted(usage.items(), key=lambda x: -x[1]["count"]): - c = calc_cost(u, match_pricing(model)) + p = match_pricing(model) + if p is None: + unpriced.append((short_model(model), u)) + continue + c = calc_cost(u, p) total_cost += c out_rows.append((short_model(model), u, c)) @@ -173,7 +178,12 @@ def main(): print(f" {u['count']} 次调用 | in {fmt_tok(u['input'])} / out {fmt_tok(u['output'])}{cache}") print(f" ≈ ${c:.4f}") print() - print(f" 💰 总计: ${total_cost:.4f}") + if unpriced: + print(" ⚠️ 以下模型无定价(未计入总价,需补 PRICING 表):") + for name, u in unpriced: + print(f" [UNPRICED] {name}: {u['count']} 次 | in {fmt_tok(u['input'])} / out {fmt_tok(u['output'])}") + print() + print(f" 💰 总计(已定价部分): ${total_cost:.4f}") if __name__ == "__main__": diff --git a/src-tauri/crates/agent-core/src/specialization/policies/tests/mod_tests.rs b/src-tauri/crates/agent-core/src/specialization/policies/tests/mod_tests.rs index d5dbef53f4..e6af026546 100644 --- a/src-tauri/crates/agent-core/src/specialization/policies/tests/mod_tests.rs +++ b/src-tauri/crates/agent-core/src/specialization/policies/tests/mod_tests.rs @@ -68,3 +68,40 @@ fn policy_source_serde() { let parsed: PolicySource = serde_json::from_str("\"workspace\"").unwrap(); assert_eq!(parsed, PolicySource::Workspace); } + +// -- E2E: migrated Simon rules actually load into os-agent prompt data -- +// 验证 Phase 7 迁移的 personal rules 在运行时被 os_agent loader 读出(不只是 frontmatter 单测)。 +#[test] +#[serial_test::serial] +fn migrated_personal_rules_load_for_os_agent_e2e() { + use crate::specialization::policies::load_enabled_policies_for_os_agent; + use std::io::Write; + + let tmp = tempfile::tempdir().unwrap(); + // ORGII_HOME 直接替代 orgii_root,personal rules = ORGII_HOME/personal/rules + let rules_dir = tmp.path().join("personal/rules"); + std::fs::create_dir_all(&rules_dir).unwrap(); + let mut f = std::fs::File::create(rules_dir.join("00-core-constraints.md")).unwrap(); + f.write_all(b"---\npaths: []\n---\n\n# core\n\nNEVER fallback. NEVER timeout. setsid nohup for background.") + .unwrap(); + let mut g = std::fs::File::create(rules_dir.join("01-user-profile.md")).unwrap(); + g.write_all(b"---\npaths: []\n---\n\n# profile\n\nDefault model is sonnet-4.6.") + .unwrap(); + + let prev = std::env::var("ORGII_HOME").ok(); + std::env::set_var("ORGII_HOME", tmp.path()); + + let rules = load_enabled_policies_for_os_agent("opus"); + + // restore env before asserting + match prev { + Some(v) => std::env::set_var("ORGII_HOME", v), + None => std::env::remove_var("ORGII_HOME"), + } + + let joined: String = rules.iter().map(|(_, c)| c.clone()).collect::>().join("\n"); + assert!(rules.len() >= 2, "expected >=2 loaded rules, got {}", rules.len()); + assert!(joined.contains("NEVER fallback"), "core constraint not loaded into prompt data"); + assert!(joined.contains("setsid"), "setsid rule not loaded"); + assert!(joined.contains("sonnet-4.6"), "model preference not loaded"); +} From 76f3e1d542dfa5eee4502e0463196b36d69db7da Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 09:02:14 +0800 Subject: [PATCH 006/864] feat(B4): structural noise stripping in reflection transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORG-2 自带噪音去除(MIN_TRANSCRIPT_LEN+role过滤)太粗,不剥离飞书/OpenClaw 注入的结构性噪音行。补充我们的噪音方案(迁移自 OpenClaw NoiseFilter): - 行级剥离: STATUS BAR/状态栏正文/Conversation info/Feishu DM信封/ System Exec回灌/message_id/NO_REPLY/HEARTBEAT_OK/reply tag/Memory V3注入/画像块 - 原话保真: 只删纯噪音行, 正文+用户纠正一字不动(遵守铁律) - OnceLock+RegexSet 编译缓存; 接进 append_transcript_line(append前清洗) - 2 个新测试: strip_noise_lines + append_strips_noise (含用户纠正保真断言) reflection::transcript 7 tests PASS; agent_core 编译通过 --- .../memory/reflection/transcript.rs | 93 ++++++++++++++++++- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/agent-core/src/specialization/memory/reflection/transcript.rs b/src-tauri/crates/agent-core/src/specialization/memory/reflection/transcript.rs index eb668b1cec..7c12bb4524 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/reflection/transcript.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/reflection/transcript.rs @@ -5,6 +5,52 @@ //! are deliberately excluded — see [`build_transcript`]. use crate::foundation::persistence::db_helpers::message_role; +use regex::RegexSet; +use std::sync::OnceLock; + +/// 结构性噪音行模式(迁移自 OpenClaw NoiseFilter,B4 噪音剥离补强)。 +/// +/// ORG-2 自带噪音去除只有 `MIN_TRANSCRIPT_LEN` + role 过滤(很粗), +/// 不会剥离飞书/OpenClaw 注入的结构性噪音行。这些行若进 reflection transcript, +/// 会被模型当成对话内容学进 learning(状态栏被当"用户说的话")。 +/// +/// 逐行匹配:命中任一模式的行会被丢弃,剩余行重组后才进 transcript。 +const NOISE_LINE_PATTERNS: &[&str] = &[ + r"^\[STATUS BAR\]", // 状态栏指令 + r"^\s*📊\s*等效[::]", // 状态栏正文 + r"^Conversation info \(untrusted metadata\)", // 飞书元数据块 + r"^Feishu\[[^\]]*\] DM from ", // 飞书 DM 信封头 + r"^System:.*Feishu\[[^\]]*\] DM", // System 包裹的飞书信封 + r"^System:.*Exec (completed|failed)", // Exec 完成回灌 + r"^\[message_id:\s*om_", // 飞书 message_id + r"^\s*NO_REPLY\s*$", // 静默回复标记 + r"^\s*HEARTBEAT_OK\s*$", // 心跳 ack + r"^\[\[\s*reply_to(_current)?\s*[:\]]", // reply tag + r"^\[Memory V3 相关记忆\]", // Memory V3 注入块 + r"^\[/Memory V3\]", + r"^【User Traits】", // 画像注入 + r"^【Current Context】", +]; + +fn noise_set() -> &'static RegexSet { + static SET: OnceLock = OnceLock::new(); + SET.get_or_init(|| { + RegexSet::new(NOISE_LINE_PATTERNS).expect("noise patterns must compile") + }) +} + +/// 剥离 content 中的结构性噪音行;返回清洗后的内容(保留非噪音行原文)。 +/// +/// 不做语义压缩、不改非噪音行内容(遵守"原话保真"铁律:只删纯噪音行, +/// 用户纠正/正文一字不动)。 +fn strip_noise_lines(content: &str) -> String { + let set = noise_set(); + let kept: Vec<&str> = content + .lines() + .filter(|line| !set.is_match(line.trim_start())) + .collect(); + kept.join("\n") +} /// Minimum transcript length to trigger reflection (chars). /// @@ -69,15 +115,18 @@ pub fn build_transcript(conn: &rusqlite::Connection, session_id: &str) -> Result } fn append_transcript_line(transcript: &mut String, role: &str, content: &str) { - let trimmed = content.trim(); - if trimmed.is_empty() { - return; - } let label = match role { message_role::USER => "User", message_role::ASSISTANT => "Assistant", _ => return, }; + // B4: 先剥离结构性噪音行(状态栏/System信封/message_id/HEARTBEAT_OK 等), + // 再判空。避免噪音被当对话内容学进 learning。 + let cleaned = strip_noise_lines(content); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + return; + } transcript.push_str(label); transcript.push_str(": "); transcript.push_str(trimmed); @@ -127,6 +176,42 @@ mod tests { assert_eq!(out, ""); } + #[test] + fn strip_noise_lines_removes_structural_noise_keeps_content() { + // 混合:噪音行 + 真实正文 + 用户纠正(必须保真) + let raw = "[STATUS BAR] 本条回复末尾追加状态栏\n\ + 📊 等效: 744k (74%) · 压缩: 0次\n\ + 不对,你这个路径用错了,应该用 /mnt/share_88\n\ + [message_id: om_x100b6c9f8f4]\n\ + System: [2026-06-24] Feishu[default] DM from ou_xxx: 继续\n\ + NO_REPLY\n\ + HEARTBEAT_OK\n\ + 这是真正的方案正文。"; + let cleaned = strip_noise_lines(raw); + // 噪音被删 + assert!(!cleaned.contains("STATUS BAR")); + assert!(!cleaned.contains("📊 等效")); + assert!(!cleaned.contains("message_id")); + assert!(!cleaned.contains("Feishu[default] DM")); + assert!(!cleaned.contains("NO_REPLY")); + assert!(!cleaned.contains("HEARTBEAT_OK")); + // 正文 + 用户纠正原话保真 + assert!(cleaned.contains("不对,你这个路径用错了,应该用 /mnt/share_88")); + assert!(cleaned.contains("这是真正的方案正文。")); + } + + #[test] + fn append_transcript_line_strips_noise_before_append() { + let mut out = String::new(); + // 纯噪音内容 → append 后应为空(剥离后 trimmed 为空) + append_transcript_line(&mut out, message_role::USER, "[STATUS BAR] x\n📊 等效: 0k\nNO_REPLY"); + assert_eq!(out, "", "pure-noise message must yield empty transcript"); + // 噪音 + 正文 → 只保留正文 + append_transcript_line(&mut out, message_role::ASSISTANT, "HEARTBEAT_OK\n实际回复内容"); + assert!(out.contains("Assistant: 实际回复内容")); + assert!(!out.contains("HEARTBEAT_OK")); + } + #[test] fn trim_head_to_cap_returns_input_when_under_cap() { let s = "hello"; From a8c378d34a2c97511386b3b11b125f4ea2e74fd8 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 10:55:56 +0800 Subject: [PATCH 007/864] fix(feishu): WS initial ping + allowlist reject visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调试发现飞书消息收不到的两层问题(均已修复+验证 org2 收发打通): 1. WS 接收健康度(ws.rs): - 原 ping 循环'先 sleep(120s) 再 ping',连上后长时间无心跳,飞书可能视连接不活跃。 - 改为连上立即发首个 ping(对齐官方 lark SDK '先 ping 再 sleep')。 - 加 debug 帧诊断 + 未处理 WsMessage 类型日志(协议变化时可见)。 2. allowlist 静默吞消息(event.rs)—— 真正根因: - DM allowlist 拒绝时静默 return None,配错 open_id 极难排查。 - 飞书 open_id 是 per-app 的:同一用户在不同 app 下 open_id 不同。 allow_from 配了 A app 的 open_id,但消息来自 B app(小安),sender 永远不匹配 → 静默丢弃。 - 加 debug 日志:拒绝时打印 sender open_id,对照 allow_from 一眼看出。 验证:org2 经小安 app 收到'测试' → gpt-5.5 回复'在。测试正常。'+状态栏, 发送无 invalid receive_id(chat_id 同 app 自洽)。 --- .../src/integrations/channels/feishu/event.rs | 9 ++++++- .../src/integrations/channels/feishu/ws.rs | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs index 94da062b82..f380ea3c9d 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs @@ -2,7 +2,7 @@ use serde_json::Value; use std::collections::HashSet; -use tracing::info; +use tracing::{debug, info}; use crate::bus::InboundMessage; use crate::integrations::channels::config::AccessPolicy; @@ -139,6 +139,13 @@ pub(super) fn parse_feishu_event( if !config.allow_from.is_empty() && !config.allow_from.iter().any(|a| a == sender_id) { + // 这次 debug 最大的坑:allowlist 用错 open_id(per-app 不同)会静默吞消息。 + // 加日志:拒绝时打印 sender open_id,方便对照 allow_from 配置。 + debug!( + "[feishu] DM rejected by allowlist: sender open_id={} not in allow_from (size={})", + sender_id, + config.allow_from.len() + ); return None; } } diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs index 6319cf8ec8..36b37c5f56 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs @@ -110,6 +110,16 @@ pub(super) async fn feishu_ws_loop( let (ping_tx, mut ping_rx) = mpsc::channel::>(4); let ping_handle = tokio::spawn(async move { + // 飞书 ws/v2:连上后立即发首个 ping 建立活跃心跳(对齐官方 SDK 行为)。 + // 原实现先 sleep(120s) 再 ping,飞书可能视连接为未就绪/不活跃而不推送事件。 + { + let frame = PbFrame::new_ping(service_id); + let encoded = frame.encode(); + if ping_tx.send(encoded).await.is_err() { + return; + } + debug!("[{}] initial ping sent", ping_channel); + } loop { tokio::time::sleep(ping_interval).await; if !ping_running.load(Ordering::Relaxed) { @@ -146,6 +156,7 @@ pub(super) async fn feishu_ws_loop( let frame_type = frame.method; let msg_type = frame.header("type").unwrap_or("").to_string(); + debug!("[{}] WS frame: method={} type={} headers={}", channel_name, frame_type, msg_type, frame.headers.len()); match (frame_type, msg_type.as_str()) { (FRAME_TYPE_CONTROL, MSG_TYPE_PONG) => { @@ -252,7 +263,18 @@ pub(super) async fn feishu_ws_loop( *last_error.write().await = Some("WebSocket stream ended".into()); connection_alive = false; } - _ => {} + Some(Ok(other)) => { + // 诊断:捕获未处理的 WsMessage 类型(Text/Ping/Pong/Frame)。 + // 飞书 ws/v2 正常只发 Binary(protobuf);若出现其他类型说明协议变化。 + let kind = match &other { + WsMessage::Text(t) => format!("Text({} chars)", t.len()), + WsMessage::Ping(p) => format!("Ping({} bytes)", p.len()), + WsMessage::Pong(p) => format!("Pong({} bytes)", p.len()), + WsMessage::Frame(_) => "Frame".to_string(), + _ => "Other".to_string(), + }; + debug!("[{}] WS recv unhandled message type: {}", channel_name, kind); + } } } } From 8143c954a76e899f25301878115b23b8181cd44b Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 11:44:58 +0800 Subject: [PATCH 008/864] feat(P1-P5): rerank/status-bar/grill-compaction/verbatim-correction/config-backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补提交之前已实现+验证但未 commit 的迁移工作(避免推送丢失): - B1: 中文 embedding + rerank 接入召回链路 (embeddings/learnings/ranking) - A1: 状态栏 status_bar.rs (Rust 确定性注入, msg#/等效/Context/ZenMux配额) - A2/P3: grill 式压缩 schema (summarization/compaction) - B3/P4: reflection 原话保真 (extract.rs VERBATIM USER CORRECTIONS) - C2/P5: config 改前备份 + 原子写 (settings/file_io.rs, integrations/config.rs) - P1.5: import_openclaw_memory_poc.py - 临时调试 log 加入 .gitignore --- .gitignore | 5 + scripts/import_openclaw_memory_poc.py | 208 +++++++++++++++ .../src/core/model_context/compaction.rs | 5 +- .../src/core/model_context/summarization.rs | 38 +-- .../src/core/session/gateway_pipeline.rs | 11 +- .../crates/agent-core/src/core/session/mod.rs | 1 + .../agent-core/src/core/session/status_bar.rs | 249 ++++++++++++++++++ .../core/session/turn/processor/compaction.rs | 1 + .../src/core/session/turn/processor/mod.rs | 1 + .../src/core/session/types/context.rs | 7 + .../tools/impls/orchestration/agent/mod.rs | 2 +- .../orchestration/agent/system_prompt.rs | 10 +- .../agent-core/src/integrations/config.rs | 43 ++- .../specialization/memory/embeddings/mod.rs | 4 +- .../specialization/memory/learnings/mod.rs | 6 +- .../specialization/memory/learnings/prompt.rs | 89 ++++++- .../memory/learnings/ranking.rs | 54 ++++ .../memory/reflection/extract.rs | 28 ++ src-tauri/crates/settings/src/file_io.rs | 90 ++++++- 19 files changed, 824 insertions(+), 28 deletions(-) create mode 100644 scripts/import_openclaw_memory_poc.py create mode 100644 src-tauri/crates/agent-core/src/core/session/status_bar.rs diff --git a/.gitignore b/.gitignore index 98b18a6b06..5f030793e4 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,8 @@ archive/ orgii-*.png .tmp-memory_v3.json .migrate.log +.b4build.log +.diag.log +.final.log +.wsfix.log +.migrate.log diff --git a/scripts/import_openclaw_memory_poc.py b/scripts/import_openclaw_memory_poc.py new file mode 100644 index 0000000000..3c73214042 --- /dev/null +++ b/scripts/import_openclaw_memory_poc.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Import a small OpenClaw Memory V3 sample into ORG-2 learnings. + +P1.5 PoC: take top N conversation_log records from Memory V3, embed them via +local qwen3 embedding service, and insert them as active ORG-2 learnings. + +This intentionally does NOT overwrite existing rows; content_hash + +`INSERT OR IGNORE` make it idempotent for the same source/category/content. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import sqlite3 +import struct +import sys +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib import request + +MEMORY_V3 = Path.home() / ".openclaw/memory/memory_v3.json" +ORGII_DB = Path("/home/hy/clawd/projects/orgii-data/sessions.db") +EMBED_URL = "http://127.0.0.1:9876/v1/embeddings" +EMBED_MODEL = "qwen3-embedding-4b" +DEFAULT_SCOPE = "agent:builtin:os" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def content_hash(content: str, category: str = "pattern") -> str: + normalized = " ".join(content.split()).lower() + payload = f"{category}:{normalized}".encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:16] + + +def embed(text: str) -> tuple[list[float], str]: + body = json.dumps({"model": EMBED_MODEL, "input": text}, ensure_ascii=False).encode("utf-8") + req = request.Request( + EMBED_URL, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with request.urlopen(req) as resp: # noqa: S310 - localhost only + data = json.loads(resp.read().decode("utf-8")) + vec = data["data"][0]["embedding"] + model = data.get("model") or EMBED_MODEL + return [float(x) for x in vec], model + + +def vec_to_blob(vec: list[float]) -> bytes: + return b"".join(struct.pack(" list[dict[str, Any]]: + rows = memory.get("conversation_log") or [] + if not isinstance(rows, list): + raise SystemExit("memory_v3.json conversation_log is not a list") + + # Prefer curated compact/summary/reset-import memories; avoid raw huge/noisy chunks. + candidates: list[dict[str, Any]] = [] + for row in rows: + content = str(row.get("content") or "").strip() + scope = str(row.get("scope") or "") + category = str(row.get("category") or "") + if len(content) < 80: + continue + if len(content) > 4000: + content = content[:4000] + "…" + if not ( + "summary" in scope + or "compact" in scope + or "reset-import" in scope + or category in {"general", "memory_file"} + ): + continue + candidates.append({**row, "content": content}) + + # Most recent first, then cap. + candidates.sort(key=lambda r: str(r.get("timestamp") or ""), reverse=True) + return candidates[:limit] + + +def ensure_table(conn: sqlite3.Connection) -> None: + # The table already exists in ORG-2. This is a defensive check, not a full migration. + cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'") + if not cur.fetchone(): + raise SystemExit("ORG-2 learnings table does not exist; start org2 once first") + + +def import_records(records: list[dict[str, Any]], db_path: Path, scope: str, dry_run: bool) -> dict[str, Any]: + conn = sqlite3.connect(str(db_path)) + ensure_table(conn) + inserted = 0 + skipped = 0 + errors: list[str] = [] + start = time.time() + + try: + for i, row in enumerate(records, 1): + content = str(row["content"]).strip() + ch = content_hash(content, "pattern") + existing = conn.execute("SELECT id FROM learnings WHERE content_hash = ?", (ch,)).fetchone() + if existing: + skipped += 1 + continue + + if dry_run: + inserted += 1 + print(f"DRY {i:02d}: {str(row.get('scope') or '')} :: {content[:90].replace(chr(10), ' ')}") + continue + + try: + vec, model = embed(content) + except Exception as exc: # noqa: BLE001 + errors.append(f"embed failed for #{i}: {exc}") + continue + + created = str(row.get("timestamp") or now_iso()) + updated = now_iso() + lid = f"oc-mig-{uuid.uuid4()}" + source_scope = str(row.get("scope") or "openclaw-memory") + takeaway = content.splitlines()[0][:240] + conn.execute( + """ + INSERT OR IGNORE INTO learnings ( + id, agent_scope, content, takeaway, category, importance, confidence, + embedding, embedding_model, status, content_hash, reinforcement_count, + source, account_id, evolution_type, parent_id, last_recalled_at, + source_session_id, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + lid, + scope, + content, + takeaway, + "pattern", + 0.72, + 0.80, + vec_to_blob(vec), + model, + "active", + ch, + 1, + "reflection", + None, + "original", + None, + None, + f"openclaw:{source_scope}", + created, + updated, + ), + ) + inserted += conn.total_changes # not exact per-row; corrected below by query if needed + conn.commit() + finally: + total = conn.execute("SELECT COUNT(*) FROM learnings WHERE agent_scope = ?", (scope,)).fetchone()[0] + active = conn.execute( + "SELECT COUNT(*) FROM learnings WHERE agent_scope = ? AND status = 'active'", (scope,) + ).fetchone()[0] + conn.close() + + # For report, recompute actually imported rows by source_session_id prefix. + conn2 = sqlite3.connect(str(db_path)) + migrated = conn2.execute( + "SELECT COUNT(*) FROM learnings WHERE source_session_id LIKE 'openclaw:%' AND agent_scope = ?", + (scope,), + ).fetchone()[0] + conn2.close() + return { + "candidate_count": len(records), + "dry_run": dry_run, + "skipped_existing": skipped, + "migrated_rows_total": migrated, + "scope_total": total, + "scope_active": active, + "errors": errors, + "elapsed_sec": round(time.time() - start, 2), + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--limit", type=int, default=50) + ap.add_argument("--scope", default=DEFAULT_SCOPE) + ap.add_argument("--memory", type=Path, default=MEMORY_V3) + ap.add_argument("--db", type=Path, default=ORGII_DB) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + memory = json.loads(args.memory.read_text(encoding="utf-8")) + records = pick_records(memory, args.limit) + report = import_records(records, args.db, args.scope, args.dry_run) + print(json.dumps(report, ensure_ascii=False, indent=2)) + if report["errors"]: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/crates/agent-core/src/core/model_context/compaction.rs b/src-tauri/crates/agent-core/src/core/model_context/compaction.rs index f51c1ed66d..1b88664177 100644 --- a/src-tauri/crates/agent-core/src/core/model_context/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/model_context/compaction.rs @@ -78,7 +78,10 @@ fn default_keep_ratio() -> f32 { 0.4 } fn default_summary_max_tokens() -> u32 { - 4096 + // grill-me 式结构化压缩 schema 比散文摘要长得多(任务树/决策账本/否定路线/ + // 用户纠正原话保真)。OpenClaw 侧目标 ≤12k tokens,这里给到 12000 上限, + // 避免结构化摘要在决策账本/下一步章节处被截断。 + 12_000 } fn default_min_messages() -> usize { 8 diff --git a/src-tauri/crates/agent-core/src/core/model_context/summarization.rs b/src-tauri/crates/agent-core/src/core/model_context/summarization.rs index 2179a3aae1..86bda708a0 100644 --- a/src-tauri/crates/agent-core/src/core/model_context/summarization.rs +++ b/src-tauri/crates/agent-core/src/core/model_context/summarization.rs @@ -21,25 +21,33 @@ pub(crate) fn truncate_for_summary(text: &str, max_chars: usize) -> String { // Summarization Prompt // ============================================ -pub(crate) const SUMMARIZATION_SYSTEM_PROMPT: &str = r#"You are a context compactor. Your job is to summarize a conversation between a user and an AI assistant, preserving the most important information for continued work. +pub(crate) const SUMMARIZATION_SYSTEM_PROMPT: &str = r#"你是会话压缩器(grill-me 式脉络梳理)。你的唯一任务是读取上面的完整对话历史,输出一份结构化压缩摘要——不是流水账,而是把任务树、决策树、尝试过但被否定/废弃的路线、当前仍有效路线全部梳理清楚。 -## Instructions +## 硬性保真要求(违反即视为压缩失败) -Produce a concise summary that captures: +1. 保留所有文件路径、IP、端口、命令、配置项、错误信息原文。 +2. 保留所有数值结果(行数、大小、百分比、时间戳、金额、PID、进度等)。 +3. 保留关键决策、用户偏好、项目进展、技术事实。 +4. 保留用户暂未被回答的问题(如果有)。 +5. 必须完整覆盖到对话结尾,不能中途停下;最后的「当前待办 / 下一步 / 未完成事项 / 错误教训」必须保留到末尾。 +6. 对「不再做 / 曾尝试但失败 / 曾被用户否定 / 禁止 fallback / 不应重复的路线」必须单独标记为【否定路线】或【废弃路线】并写明原因。 +7. 对仍在跑的后台任务,写清 PID、log 路径、进度口径、如何复查。 +8. 对用户纠正过的点,原样保留进【用户纠正】,避免压缩后复犯——尤其「不是这样 / 不对 / 其实 / 我没让你 / 不要 / 只用 / 回归 / 固化」这类纠正或范围约束,必须原话保真,不能只留最终结论。 -1. **Decisions made** — What the user decided, what approach was chosen, any preferences stated -2. **Files changed** — Which files were created, edited, or deleted, and what the changes were -3. **Errors encountered** — Any errors, their causes, and how they were resolved -4. **Current state** — What is the project/task state right now? What was the last thing done? -5. **Pending items** — Any tasks mentioned but not yet completed, next steps discussed -6. **Key context** — Names, paths, IDs, configurations, or technical details that the assistant will need to continue the work +## 必须包含的结构化章节 -## Format +- **全局硬约束** +- **当前主线任务树**:每个任务写「目标 → 当前状态 → 有效路线 → 分支/决策树 → 已完成 → 待办 → 【否定路线/废弃路线】 → 关键证据/日志/路径」 +- **关键决策账本(Critical Decision Ledger)**:按时间顺序列出每个仍影响后续动作的规则/算法/口径决策,每条含:decision_id、状态(accepted/rejected/superseded/candidate)、用户原话或纠正摘要、当前应执行规则、禁止重复的旧规则、证据路径/消息片段。 +- **当前有效规则(Active Rules)** 与 **已废弃/禁止规则(Rejected Rules)**:若某条算法规则反复过,写清最终有效版本。 +- **用户纠正与踩坑** +- **下一轮开始时必须主动同步的进度分支树** -Write the summary as structured bullet points grouped by topic. Use markdown formatting. -Be concise but preserve specifics (exact file paths, error messages, config values). -Do NOT include pleasantries or conversational filler. -Target ~500-1000 words."#; +## 格式 + +用 Markdown 结构化输出。中文。不要 preamble、不要思考过程、不要客套。 +优先保留具体信息(精确路径、错误原文、配置值)而非泛泛描述。 +目标长度尽量 ≤12k tokens,但宁可完整也不要截断。"#; // ============================================ // Message Formatting @@ -209,7 +217,7 @@ pub(crate) async fn summarize_messages( "properties": { "summary": { "type": "string", - "description": "The concise summary of the conversation" + "description": "grill-me 式结构化压缩摘要(Markdown,中文):全局硬约束 / 当前主线任务树 / 关键决策账本 / 当前有效规则 / 已废弃规则 / 用户纠正与踩坑 / 下一轮必须主动同步的进度分支树。保留所有路径/IP/端口/命令/配置/错误原文/数值/用户纠正原话。" } }, "required": ["summary"] diff --git a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs index a63371290c..afa033bbe4 100644 --- a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs +++ b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs @@ -201,7 +201,14 @@ pub async fn process_gateway_message( match result { Ok(processing_result) => { - let content = &processing_result.content; + let content = crate::session::status_bar::append_status_bar_for_channel( + &msg.channel, + processing_result.content.clone(), + &session, + processing_result.total_tokens, + processing_result.context_tokens, + ) + .await; let out_preview: String = crate::utils::safe_truncate_chars_to_string(&content, 80); info!( "[agent-loop] Response for {}:{}: {}...", @@ -210,7 +217,7 @@ pub async fn process_gateway_message( Ok(Some(OutboundMessage::new( &msg.channel, &msg.chat_id, - content, + &content, ))) } Err(err) => { diff --git a/src-tauri/crates/agent-core/src/core/session/mod.rs b/src-tauri/crates/agent-core/src/core/session/mod.rs index 6b830ff2f6..f38fff0f21 100644 --- a/src-tauri/crates/agent-core/src/core/session/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/mod.rs @@ -36,6 +36,7 @@ pub mod recovery; // each carry their own `#[doc(hidden)]`. pub mod prompt; pub(crate) mod scheduler; +pub(crate) mod status_bar; pub mod session_id; pub(crate) mod title; pub mod turn; diff --git a/src-tauri/crates/agent-core/src/core/session/status_bar.rs b/src-tauri/crates/agent-core/src/core/session/status_bar.rs new file mode 100644 index 0000000000..fc50f7d0e0 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/status_bar.rs @@ -0,0 +1,249 @@ +//! Feishu / gateway status-bar rendering. +//! +//! OpenClaw injects a status-bar instruction into the prompt and asks the LLM +//! to echo it at the end. ORG-2 can do better for external channels: append +//! the bar in Rust after the model returns, so it is deterministic and does +//! not consume prompt/output tokens. + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::state::AgentSession; + +const ZENMUX_TTL: Duration = Duration::from_secs(300); +const ZENMUX_MGMT_KEY: &str = + "sk-mg-v1-7eb0ee4075005d1865dfc2f3de2d4cd7ef2a214523e5caec01b2684b23744a59"; + +#[derive(Clone, Default)] +struct ZenmuxBarCache { + text: Option, + fetched_at: Option, +} + +static ZENMUX_CACHE: OnceLock> = OnceLock::new(); + +fn zenmux_cache() -> &'static Mutex { + ZENMUX_CACHE.get_or_init(|| Mutex::new(ZenmuxBarCache::default())) +} + +#[derive(Debug, Deserialize)] +struct ManagementEnvelope { + data: T, +} + +#[derive(Debug, Deserialize)] +struct SubscriptionDetail { + quota_5_hour: QuotaWindow, + quota_7_day: QuotaWindow, +} + +#[derive(Debug, Deserialize)] +struct QuotaWindow { + usage_percentage: f64, + resets_at: Option, +} + +/// Append a deterministic status bar for Feishu channel replies. +/// +/// For now this is deliberately channel-scoped to Feishu because P2's goal is +/// replacing OpenClaw as the Feishu entrypoint. The helper is pure string +/// post-processing: it never changes model input and never blocks delivery on +/// ZenMux API failures. +pub async fn append_status_bar_for_channel( + channel: &str, + content: String, + session: &AgentSession, + total_tokens: i64, + context_tokens: i64, +) -> String { + if channel != "feishu" && !channel.starts_with("feishu:") { + return content; + } + if content.trim().is_empty() { + return content; + } + + let model = session + .runtime + .read() + .await + .as_ref() + .map(|rt| rt.model.clone()) + .unwrap_or_default(); + + let context_total = session + .runtime + .read() + .await + .as_ref() + .map(|rt| rt.resolved.context_window as i64) + .unwrap_or(200_000) + .max(1); + + // Per-session cumulative usage from `session_token_usage`: msg# is the + // round count, and the equivalent-token figure sums total_tokens across + // rounds (mirrors the OpenClaw status bar's cumulative weighting). Sync + // DB read off the async path via spawn_blocking. agent-core owns the + // same sessions.db, so we query the table directly (the writer side goes + // through the `session_bridge` registered fn — there is no read bridge). + let session_id = session.id.clone(); + let (msg_num, cumulative_total) = tokio::task::spawn_blocking(move || { + query_session_usage(&session_id) + .map(|(count, sum)| (count + 1, sum + total_tokens)) + .unwrap_or((0, total_tokens)) + }) + .await + .unwrap_or((0, total_tokens)); + + let zenmux = get_zenmux_bar_text().await.unwrap_or_else(|| "ZenMux: (unavailable)".into()); + let bar = build_status_bar( + cumulative_total, + context_tokens, + context_total, + msg_num, + &zenmux, + &model, + ); + format!("{}\n\n{}", content.trim_end(), bar) +} + +fn build_status_bar( + total_tokens: i64, + context_tokens: i64, + context_total: i64, + msg_num: i64, + zenmux: &str, + model: &str, +) -> String { + let equiv_k = (total_tokens.max(0) + 999) / 1000; + // Match the OpenClaw current extension threshold: 1,000k weighted tokens. + let equiv_pct = ((total_tokens.max(0) as f64) / 1_000_000.0 * 100.0).round() as i64; + let ctx_k = (context_tokens.max(0) + 999) / 1000; + let ctx_total_k = (context_total + 999) / 1000; + let ctx_pct = ((context_tokens.max(0) as f64) / (context_total as f64) * 100.0).round() as i64; + + let mut parts = vec![ + format!("📊 等效: {}k ({}%)", equiv_k, equiv_pct), + "压缩: 0次".to_string(), + format!("Context: {}k/{}k ({}%)", ctx_k, ctx_total_k, ctx_pct), + format!("ZenMux {}", zenmux), + ]; + if msg_num > 0 { + parts.push(format!("msg#{}", msg_num)); + } + let short = shorten_model(model); + if !short.is_empty() { + parts.push(format!("🤖 {}", short)); + } + parts.join(" · ") +} + +fn shorten_model(model: &str) -> String { + let base = model.split(':').next().unwrap_or(model); + let last = base.rsplit('/').next().unwrap_or(base); + last.strip_prefix("claude-").unwrap_or(last).to_string() +} + +/// Returns `(round_count, total_tokens_sum)` for a session from +/// `session_token_usage`. Direct query against agent-core's own sessions.db +/// connection — there is no read-side bridge, only the write-side +/// `session_bridge::record_token_usage`. +fn query_session_usage(session_id: &str) -> Option<(i64, i64)> { + let conn = crate::foundation::db_bridge::get_connection().ok()?; + conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(total_tokens), 0) \ + FROM session_token_usage WHERE session_id = ?1", + [session_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .ok() +} + +async fn get_zenmux_bar_text() -> Option { + let cached = { + let guard = zenmux_cache().lock().ok()?; + guard + .fetched_at + .filter(|ts| ts.elapsed() < ZENMUX_TTL) + .and_then(|_| guard.text.clone()) + }; + if cached.is_some() { + return cached; + } + + match fetch_zenmux_bar_text().await { + Some(text) => { + if let Ok(mut guard) = zenmux_cache().lock() { + guard.text = Some(text.clone()); + guard.fetched_at = Some(Instant::now()); + } + Some(text) + } + None => zenmux_cache().lock().ok().and_then(|guard| guard.text.clone()), + } +} + +async fn fetch_zenmux_bar_text() -> Option { + let resp = reqwest::Client::new() + .get("https://zenmux.ai/api/v1/management/subscription/detail") + .header("Authorization", format!("Bearer {}", ZENMUX_MGMT_KEY)) + .send() + .await + .ok()?; + let envelope: ManagementEnvelope = resp.json().await.ok()?; + let h5_pct = envelope.data.quota_5_hour.usage_percentage * 100.0; + let d7_pct = envelope.data.quota_7_day.usage_percentage * 100.0; + let h5_reset = fmt_reset(envelope.data.quota_5_hour.resets_at.as_deref()); + let d7_reset = fmt_reset(envelope.data.quota_7_day.resets_at.as_deref()); + Some(format!( + "5h:{:.1}%{} / 7d:{:.1}%{}", + h5_pct, + h5_reset.map(|s| format!("↻{}", s)).unwrap_or_default(), + d7_pct, + d7_reset.map(|s| format!("↻{}", s)).unwrap_or_default() + )) +} + +fn fmt_reset(value: Option<&str>) -> Option { + let raw = value?; + let dt = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc); + let cst = dt + chrono::Duration::hours(8); + let delta = dt - Utc::now(); + let secs = delta.num_seconds(); + let sign = if secs < 0 { "-" } else { "" }; + let abs = secs.abs(); + let days = abs / 86_400; + let hours = (abs % 86_400) / 3_600; + let mins = (abs % 3_600) / 60; + let remain = if days >= 1 { + format!("{}{}d{}h", sign, days, hours) + } else if hours >= 1 { + format!("{}{}h{:02}m", sign, hours, mins) + } else { + format!("{}{}m", sign, mins) + }; + let label = if delta.num_hours() >= 12 { + cst.format("%-m/%-d %H:%M").to_string() + } else { + cst.format("%H:%M").to_string() + }; + Some(format!("{}({})", label, remain)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_bar_has_expected_shape() { + let bar = build_status_bar(12_345, 67_890, 200_000, 7, "5h:1.0% / 7d:2.0%", "anthropic/claude-sonnet-4.6:anthropic"); + assert!(bar.contains("📊 等效: 13k (1%)")); + assert!(bar.contains("Context: 68k/200k (34%)")); + assert!(bar.contains("ZenMux 5h:1.0% / 7d:2.0%")); + assert!(bar.contains("msg#7")); + assert!(bar.contains("🤖 sonnet-4.6")); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs index cde528d8be..6d4d69042a 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs @@ -282,6 +282,7 @@ impl UnifiedMessageProcessor { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + context_tokens: 0, tool_calls_count: 0, truncated: false, turn_summary: None, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index de8f64a6cf..9198f6e958 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -723,6 +723,7 @@ impl UnifiedMessageProcessor { total_tokens: result.total_tokens, prompt_tokens: result.prompt_tokens, completion_tokens: result.completion_tokens, + context_tokens: result.context_tokens, tool_calls_count, truncated: false, turn_summary: None, diff --git a/src-tauri/crates/agent-core/src/core/session/types/context.rs b/src-tauri/crates/agent-core/src/core/session/types/context.rs index f93bd51a0f..7ae93982ba 100644 --- a/src-tauri/crates/agent-core/src/core/session/types/context.rs +++ b/src-tauri/crates/agent-core/src/core/session/types/context.rs @@ -230,6 +230,13 @@ pub struct ProcessingResult { pub prompt_tokens: i64, /// Completion tokens generated. pub completion_tokens: i64, + /// Context window tokens used by the final provider request. + /// + /// This is distinct from `prompt_tokens` (which may be accumulated across + /// multiple LLM calls in one turn). Channel status bars use it as the + /// current context fill level. + #[serde(default)] + pub context_tokens: i64, /// Number of tool calls made. pub tool_calls_count: u32, /// Whether the response was truncated. diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs index 9ab574df93..a08f30870f 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs @@ -805,7 +805,7 @@ impl Tool for AgentTool { // 6. Build system prompt (base soul + context + learnings + scratchpad) let full_system_prompt = self - .build_full_system_prompt(&agent, &agent_id, &delegation_config) + .build_full_system_prompt(&agent, &agent_id, &delegation_config, prompt) .await?; // 7. Build initial messages (resume / fork / fresh) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/system_prompt.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/system_prompt.rs index 47eda77c2c..2e90acdfc6 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/system_prompt.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/system_prompt.rs @@ -19,6 +19,7 @@ impl AgentTool { agent: &AgentDefinition, agent_id: &str, delegation_config: &DelegationConfig, + task_prompt: &str, ) -> Result { let base_prompt = agent .soul_content @@ -27,7 +28,14 @@ impl AgentTool { let dynamic_context = self.build_context(delegation_config).await; let scope = format!("agent:{}", agent_id); - let learnings = crate::memory::learnings::inject_learnings_into_prompt(&scope, None); + // Query-aware learnings: the worker's task text is the retrieval + // query, so we can run the cross-encoder rerank stage + // (`inject_learnings_into_prompt_reranked`) instead of the + // query-less salience ranking. Falls back to salience internally + // when embedding/rerank is unavailable. + let learnings = + crate::memory::learnings::inject_learnings_into_prompt_reranked(&scope, task_prompt) + .await; let mut extra_sections = Vec::new(); if !dynamic_context.is_empty() { diff --git a/src-tauri/crates/agent-core/src/integrations/config.rs b/src-tauri/crates/agent-core/src/integrations/config.rs index 0aa6816c46..f9e004c792 100644 --- a/src-tauri/crates/agent-core/src/integrations/config.rs +++ b/src-tauri/crates/agent-core/src/integrations/config.rs @@ -144,12 +144,53 @@ impl IntegrationsConfig { /// Persist to an explicit path. Same split-for-testability rationale /// as `load_from`. + /// + /// P5 防呆 / config 改前备份: before overwriting an existing + /// `integrations.json` (channels, credentials, embedding — the most + /// destructive config to lose), snapshot it to a timestamped + /// `integrations.json.bak-` (best-effort, never blocks the + /// write) and write the new payload atomically via a temp-file rename so + /// a crash mid-write can't truncate the live file. `IntegrationsStore` + /// already gates against persisting over a corrupt boot-time file; this + /// adds crash-safety + a recovery trail on top. pub fn save_to(&self, path: &Path) -> IntegrationsResult<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(IntegrationsError::Write)?; } let payload = serde_json::to_string_pretty(self).map_err(IntegrationsError::Serialize)?; - std::fs::write(path, payload).map_err(IntegrationsError::Write) + + // 1. Best-effort backup of the existing file. + if path.exists() { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut backup = path.to_path_buf(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("integrations.json"); + backup.set_file_name(format!("{name}.bak-{stamp}")); + if let Err(err) = std::fs::copy(path, &backup) { + tracing::warn!( + "[integrations] failed to back up {} before write: {err}", + path.display() + ); + } + } + + // 2. Atomic write: temp file + rename (atomic on same filesystem). + let mut tmp = path.to_path_buf(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("integrations.json"); + tmp.set_file_name(format!("{name}.tmp")); + std::fs::write(&tmp, &payload).map_err(IntegrationsError::Write)?; + std::fs::rename(&tmp, path).map_err(|err| { + let _ = std::fs::remove_file(&tmp); + IntegrationsError::Write(err) + }) } } diff --git a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs index ae0df9a0ae..7c39f28813 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/embeddings/mod.rs @@ -8,7 +8,6 @@ mod auto; mod azure; mod openai; -#[allow(dead_code)] mod rerank; // `AutoEmbeddingProvider` is the only provider type external callers reach @@ -16,6 +15,9 @@ mod rerank; // concrete `Azure` / `OpenAI` providers are only instantiated inside // `auto.rs`, so they stay behind their submodule path. pub use auto::AutoEmbeddingProvider; +// `LocalReranker` is the added cross-encoder rerank stage (Simon's setup). +// Reached from `learnings::ranking::search_similar_reranked`. +pub use rerank::LocalReranker; use async_trait::async_trait; diff --git a/src-tauri/crates/agent-core/src/specialization/memory/learnings/mod.rs b/src-tauri/crates/agent-core/src/specialization/memory/learnings/mod.rs index 80da05f9aa..626bf213e1 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/learnings/mod.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/learnings/mod.rs @@ -56,8 +56,10 @@ pub use lifecycle::{ mark_merged, promote_pending_to_active, reactivate_learning, record_consolidation_run, update_learning_body, ConsolidationRunRecord, }; -pub use prompt::{inject_learnings_into_prompt, learning_prompt_revision}; -pub use ranking::{salience_score, search_similar}; +pub use prompt::{ + inject_learnings_into_prompt, inject_learnings_into_prompt_reranked, learning_prompt_revision, +}; +pub use ranking::{rerank_candidates, salience_score, search_similar}; pub use schema::{compute_content_hash, init_learnings_table}; pub use stats::{ count_status_per_scope, latest_consolidation_run, list_learnings, LearningListFilter, diff --git a/src-tauri/crates/agent-core/src/specialization/memory/learnings/prompt.rs b/src-tauri/crates/agent-core/src/specialization/memory/learnings/prompt.rs index 5b3f3acacf..5c3bc2d08d 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/learnings/prompt.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/learnings/prompt.rs @@ -318,7 +318,94 @@ pub fn inject_learnings_into_prompt(agent_scope: &str, query_embedding: Option<& format_learnings_for_prompt(&ranked) } -/// Fire-and-forget `touch_recall` for a batch of IDs. Uses the ambient +/// Async, query-aware variant of [`inject_learnings_into_prompt`]. +/// +/// Where the sync entry point only has a `query_embedding` (or nothing) and +/// must run inside the synchronous system-prompt builder, this variant runs +/// in async call sites that *do* have the raw task text — notably the +/// orchestration `agent` tool, where the worker's `prompt` argument is the +/// task. Having the raw query text unlocks the cross-encoder reranker +/// (`search_similar_reranked`), which cosine alone can't use. +/// +/// Pipeline: +/// 1. Embed `query_text` via the workspace `AutoEmbeddingProvider` +/// (provider/model from `IntegrationsConfig.embedding`). +/// 2. Cosine coarse-recall + cross-encoder rerank (`search_similar_reranked`). +/// 3. Fall back to salience ranking when embedding is unavailable or the +/// semantic pass yields < 2 hits — identical degradation contract to the +/// sync path, so callers never lose learnings injection. +pub async fn inject_learnings_into_prompt_reranked(agent_scope: &str, query_text: &str) -> String { + use crate::specialization::memory::embeddings::{AutoEmbeddingProvider, EmbeddingProvider}; + + const MIN_SEMANTIC_SIMILARITY: f32 = 0.30; + const SEMANTIC_TOP_K: usize = 12; + + // Empty / whitespace task → no meaningful query; fall straight back to + // salience ranking (no point embedding an empty string). + let trimmed = query_text.trim(); + if trimmed.is_empty() { + return inject_learnings_into_prompt(agent_scope, None); + } + + // Resolve the workspace embedding provider (same config the memory-search + // tools and consolidation use). + let embed_cfg = crate::state::integrations_store::integrations_store() + .snapshot() + .embedding; + let provider = AutoEmbeddingProvider::new(embed_cfg.provider, embed_cfg.model); + + let (query_embedding, query_model) = match provider.embed(trimmed).await { + Ok(res) => (res.vector, Some(res.model)), + Err(err) => { + warn!( + "[learnings] query embed failed ({err}); falling back to salience ranking" + ); + return inject_learnings_into_prompt(agent_scope, None); + } + }; + + // Stage 1 (sync, DB): cosine coarse-recall. The `rusqlite::Connection` + // is `!Sync` and MUST NOT be held across the rerank `.await`, so we do + // all DB work inside this block and let `conn` drop before awaiting. + let coarse = { + let Ok(conn) = crate::foundation::db_bridge::get_connection() else { + return String::new(); + }; + match super::ranking::search_similar( + &conn, + agent_scope, + &query_embedding, + query_model.as_deref(), + SEMANTIC_TOP_K.saturating_mul(super::ranking::RERANK_RECALL_MULT), + MIN_SEMANTIC_SIMILARITY, + ) { + Ok(pairs) => pairs, + Err(err) => { + warn!("[learnings] cosine recall failed: {err}; salience fallback"); + return inject_learnings_into_prompt(agent_scope, None); + } + } + }; + + // Stage 2 (async, DB-free): cross-encoder rerank over the recalled pool. + let ranked_pairs = super::ranking::rerank_candidates(trimmed, coarse, SEMANTIC_TOP_K).await; + + let ranked: Vec = if ranked_pairs.len() >= 2 { + ranked_pairs.into_iter().map(|(l, _)| l).collect() + } else { + // Too few semantic hits — salience fallback (re-opens its own conn). + return inject_learnings_into_prompt(agent_scope, None); + }; + + if ranked.is_empty() { + return String::new(); + } + + let ids: Vec = ranked.iter().map(|l| l.id.clone()).collect(); + schedule_touch_recall(ids); + + format_learnings_for_prompt(&ranked) +} /// tokio runtime when one is available; otherwise falls back to a detached /// OS thread. Either way the caller returns immediately — the prompt /// builder never waits on a DB write. diff --git a/src-tauri/crates/agent-core/src/specialization/memory/learnings/ranking.rs b/src-tauri/crates/agent-core/src/specialization/memory/learnings/ranking.rs index b3c48fce0f..10624dd9a9 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/learnings/ranking.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/learnings/ranking.rs @@ -194,6 +194,60 @@ pub fn search_similar( Ok(scored) } +/// Cross-encoder rerank over an already-recalled candidate pool. +/// +/// ORG-2 upstream only had cosine. The added increment (Simon's setup) is a +/// local Qwen3 reranker at `http://localhost:9877`: cosine is fast but +/// bag-of-meaning; the cross-encoder reads `(query, learning)` jointly and +/// reorders by true relevance, which matters most when several learnings +/// share surface vocabulary but differ in applicability. +/// +/// `coarse` MUST come from a prior cosine recall (e.g. `search_similar` with +/// a widened `top_k`). This function is intentionally DB-free so the caller +/// can drop its `rusqlite::Connection` borrow before awaiting — `Connection` +/// is `!Sync` and cannot be held across an `.await`. +/// +/// Best-effort: if the reranker is unreachable or errors, the cosine order +/// (truncated to `top_k`) is returned. Retrieval never fails on rerank. +pub async fn rerank_candidates( + query_text: &str, + coarse: Vec<(Learning, f32)>, + top_k: usize, +) -> Vec<(Learning, f32)> { + // Nothing to rerank, or so few hits the reranker can't improve order. + if coarse.len() <= 1 { + let mut out = coarse; + out.truncate(top_k); + return out; + } + + let documents: Vec = coarse.iter().map(|(l, _)| l.content.clone()).collect(); + let reranker = super::super::embeddings::LocalReranker::new(); + match reranker.rerank(query_text, &documents, top_k).await { + Ok(ranked) if !ranked.is_empty() => ranked + .into_iter() + .filter_map(|(idx, score)| coarse.get(idx).map(|(l, _)| (l.clone(), score))) + .collect(), + Ok(_) => { + let mut out = coarse; + out.truncate(top_k); + out + } + Err(err) => { + tracing::warn!("[learnings] rerank failed ({err}); falling back to cosine order"); + let mut out = coarse; + out.truncate(top_k); + out + } + } +} + +/// Coarse-recall multiplier: cosine retrieves `top_k * RERANK_RECALL_MULT` +/// candidates, the cross-encoder reranker then picks the final `top_k`. +/// 3× gives the reranker enough breadth to surface a learning that cosine +/// ranked just below the cut without blowing up the rerank payload. +pub(super) const RERANK_RECALL_MULT: usize = 3; + #[cfg(test)] mod tests { use super::super::{ diff --git a/src-tauri/crates/agent-core/src/specialization/memory/reflection/extract.rs b/src-tauri/crates/agent-core/src/specialization/memory/reflection/extract.rs index d5c1e8ce43..e0b72054e7 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/reflection/extract.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/reflection/extract.rs @@ -51,6 +51,15 @@ Focus areas: - Preferences: "User prefers X over Y" - Strategies: "For this type of task, approach X works best" +VERBATIM USER CORRECTIONS (highest priority — never paraphrase away the original words): +When the user pushes back, corrects, narrows scope, or forbids something — especially with +signals like "不是这样 / 不对 / 其实 / 我没让你 / 不要 / 只用 / 回归 / 固化 / actually / no, / +don't / stop / revert" — you MUST capture it as a `correction` insight, and the `content` +paragraph MUST embed the user's exact wording verbatim inside 「」or "" quotes. Keep BOTH the +verbatim correction AND the resulting rule, so a future session cannot silently re-apply the +rejected behaviour. This rule overrides brevity: a correction with its quote is never "too +trivial" to keep. + Do NOT extract (hard rejects — these WILL be filtered out post-hoc, do not waste a slot): - Technical facts (e.g. "Rust uses ownership") - Trivial observations or generic best-practice advice @@ -141,7 +150,17 @@ const REJECT_PATTERNS: &[&str] = &[ /// Returns `Some(matched_pattern)` if the insight should be rejected, `None` if /// it passes the guard. `content` and `takeaway` are both scanned case- /// insensitively. +/// +/// `correction` insights are exempt from the path/sandbox REJECT_PATTERNS: +/// those patterns target truncated tool_input/tool_output noise, but a +/// verbatim user correction may legitimately quote a path (e.g. 「不要用 +/// /tmp/」). User-authored corrections are signal, not noise — dropping them +/// would defeat the P4 原话保真 guarantee. Tool-noise never lands in the +/// `correction` category, so the exemption is safe. pub(super) fn rejection_reason(insight: &ExtractedInsight) -> Option<&'static str> { + if insight.category.eq_ignore_ascii_case("correction") { + return None; + } let haystacks = [ insight.content.as_str(), insight.takeaway.as_deref().unwrap_or(""), @@ -179,6 +198,15 @@ mod tests { assert_eq!(rejection_reason(&i), Some("/Users/")); } + #[test] + fn correction_category_exempt_from_path_reject() { + // P4 原话保真: a verbatim user correction that quotes a path must + // survive the noise guard. + let mut i = insight("用户纠正:「不要用 /tmp/,必须用 /mnt/share_88」", Some("禁用 /tmp/,改 /mnt/share_88")); + i.category = "correction".to_string(); + assert_eq!(rejection_reason(&i), None); + } + #[test] fn rejection_reason_hits_session_id_prefix() { let i = insight( diff --git a/src-tauri/crates/settings/src/file_io.rs b/src-tauri/crates/settings/src/file_io.rs index 8e35775c5c..bde118697a 100644 --- a/src-tauri/crates/settings/src/file_io.rs +++ b/src-tauri/crates/settings/src/file_io.rs @@ -151,6 +151,91 @@ fn backup_corrupt_settings(path: &Path, content: &str) -> Result Result<(), String> { + // 1. Snapshot the current file before touching it. + if path.exists() { + let stamp = backup_timestamp(); + let backup_path = path.with_extension(format!("jsonc.bak-{stamp}")); + if let Err(err) = fs::copy(path, &backup_path) { + // Best-effort: never block a config write on backup failure. + eprintln!( + "[Settings] WARN: failed to back up {} before write: {err}", + path.display() + ); + } else { + prune_old_backups(path); + } + } + + // 2. Atomic write: temp file in the same dir, then rename. + let tmp_path = path.with_extension("jsonc.tmp"); + fs::write(&tmp_path, content) + .map_err(|err| format!("Failed to write temp settings file: {err}"))?; + fs::rename(&tmp_path, path).map_err(|err| { + // Clean up the temp file on rename failure so we don't litter. + let _ = fs::remove_file(&tmp_path); + format!("Failed to atomically replace settings file: {err}") + })?; + + Ok(()) +} + +fn backup_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + secs.to_string() +} + +/// Keep only the newest `MAX_SETTINGS_BACKUPS` `*.jsonc.bak-*` files next to +/// `path`. Best-effort; errors are ignored (pruning is housekeeping, not +/// correctness). +fn prune_old_backups(path: &Path) { + let Some(dir) = path.parent() else { return }; + let Some(stem) = path.file_name().and_then(|n| n.to_str()) else { + return; + }; + let prefix = format!("{stem}.bak-"); + let Ok(entries) = fs::read_dir(dir) else { return }; + let mut backups: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with(&prefix)) + .unwrap_or(false) + }) + .collect(); + if backups.len() <= MAX_SETTINGS_BACKUPS { + return; + } + // Names embed a monotonic unix-seconds suffix, so lexical sort == chronological. + backups.sort(); + let remove_count = backups.len() - MAX_SETTINGS_BACKUPS; + for old in backups.into_iter().take(remove_count) { + let _ = fs::remove_file(old); + } +} + /// Read settings from `~/.orgii/settings.jsonc`. /// Returns the parsed JSON value. Creates the file with defaults if it doesn't exist. pub fn read_settings() -> Result { @@ -198,7 +283,7 @@ pub fn write_settings_jsonc(content: &str) -> Result<(), String> { let dir = get_settings_dir()?; fs::create_dir_all(&dir).map_err(|err| format!("Failed to create settings dir: {err}"))?; - fs::write(&path, content).map_err(|err| format!("Failed to write settings file: {err}"))?; + backup_and_atomic_write(&path, content)?; Ok(()) } @@ -212,8 +297,7 @@ pub fn write_settings_json(value: &serde_json::Value) -> Result<(), String> { let dir = get_settings_dir()?; fs::create_dir_all(&dir).map_err(|err| format!("Failed to create settings dir: {err}"))?; - fs::write(&path, format!("{content}\n")) - .map_err(|err| format!("Failed to write settings file: {err}"))?; + backup_and_atomic_write(&path, &format!("{content}\n"))?; Ok(()) } From 5306f99c225bccb64642825bb53d155ee6cc95c7 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 18:56:08 +0800 Subject: [PATCH 009/864] feat(e2): add short alias "wi" for manage_work_item tool Feishu channel agents already have ManagementCapability (via OS Agent definition), so manage_work_item is available. This adds a dispatch- level alias so the LLM can call the tool as "wi" instead of the full name, making channel conversations more concise. Changes: - registry.rs: resolve_tool_alias() normalizes "wi" -> "manage_work_item" before lookup, execute, and policy checks - registry_tests.rs: 2 new tests verifying alias lookup and execution Co-Authored-By: Claude Opus 4.6 --- .../agent-core/src/core/tools/registry.rs | 31 +++++++++++++------ .../src/core/tools/tests/registry_tests.rs | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/tools/registry.rs b/src-tauri/crates/agent-core/src/core/tools/registry.rs index 764823356c..6bae7125f6 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registry.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registry.rs @@ -50,20 +50,21 @@ impl ToolRegistry { /// Get a reference to a tool by name. Checks local tools first, then fallback. /// - /// If exact match fails, tries reverse-lookup by sanitized name so that - /// LLM-returned names like `Disk_Usage_Checker` match the original - /// `Disk Usage Checker` registration. + /// If exact match fails, tries short alias resolution, then reverse-lookup + /// by sanitized name so that LLM-returned names like `Disk_Usage_Checker` + /// match the original `Disk Usage Checker` registration. pub fn get(&self, name: &str) -> Option<&dyn Tool> { + let canonical = resolve_tool_alias(name); self.tools - .get(name) + .get(canonical) .map(|boxed| boxed.as_ref()) .or_else(|| { self.tools .values() - .find(|tool| sanitize_tool_name(tool.name()) == name) + .find(|tool| sanitize_tool_name(tool.name()) == canonical) .map(|boxed| boxed.as_ref()) }) - .or_else(|| self.fallback.as_ref().and_then(|fb| fb.get(name))) + .or_else(|| self.fallback.as_ref().and_then(|fb| fb.get(canonical))) } /// Check if a tool is registered (locally or in fallback). @@ -328,13 +329,14 @@ impl ToolRegistry { policy: &ResolvedToolPolicy, ctx: &crate::tools::call_context::CallContext, ) -> Result { - if !policy.is_allowed(name) { + let canonical = resolve_tool_alias(name); + if !policy.is_allowed(canonical) { return Err(format!( "Error: Tool '{}' is not allowed by the current tool policy", - name + canonical )); } - self.execute(name, params, ctx).await + self.execute(canonical, params, ctx).await } /// Get list of registered tool names (including fallback). @@ -501,6 +503,17 @@ fn tool_schema_summary(schema: &Value) -> Option<(String, String)> { Some((name, description)) } +/// Resolve short tool aliases to canonical names. +/// +/// Keeps the alias table small and close to the dispatch site so +/// new short names are trivially discoverable in one place. +fn resolve_tool_alias(name: &str) -> &str { + match name { + "wi" => super::names::MANAGE_WORK_ITEM, + _ => name, + } +} + impl Default for ToolRegistry { fn default() -> Self { Self::new() diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs index bf5da5286c..e9c0cef2f6 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs @@ -818,6 +818,37 @@ async fn tool_search_marks_policy_denied_as_unavailable() { ); } +// ============================================ +// Tool Alias Resolution +// ============================================ + +#[test] +fn alias_wi_resolves_to_manage_work_item() { + let mut registry = ToolRegistry::new(); + registry.register(Box::new(MockTool::new("manage_work_item"))); + + // "wi" should resolve to "manage_work_item" + assert!(registry.has("wi")); + let tool = registry.get("wi").unwrap(); + assert_eq!(tool.name(), "manage_work_item"); +} + +#[tokio::test] +async fn execute_alias_dispatches_to_canonical_tool() { + let mut registry = ToolRegistry::new(); + registry.register(Box::new(MockTool::new("manage_work_item"))); + + let result = registry + .execute( + "wi", + json!({}), + &crate::tools::call_context::CallContext::default(), + ) + .await + .unwrap(); + assert_eq!(result.text, "executed:manage_work_item"); +} + // ============================================ // LLM Schema Compatibility Contract // ============================================ From 02d6d075768b774078553cb26059025c866f0bc2 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 19:01:22 +0800 Subject: [PATCH 010/864] fix(e4): exponential backoff + pong timeout + fragment TTL for Feishu WS Addresses the known bug where reconnecting state gets stuck after suspend/resume by adding three robustness improvements: 1. Exponential backoff: reconnect delay doubles per attempt (base * 2^attempt), capped at 15 minutes. Resets to 0 on successful connect. Replaces the fixed reconnect_interval_secs sleep at both reconnect paths (connect failure + disconnect recovery). 2. Pong timeout detection: tracks last pong timestamp via shared Arc>. Ping task checks elapsed time before each ping; if no pong received within (ping_interval + 30s), breaks the ping loop which triggers the ping_rx channel closure, causing the main select to exit and reconnect. Detects zombie connections where TCP is alive but the server is unresponsive. 3. Fragment cache TTL: incomplete message fragments older than 5 minutes are purged on each new connection to prevent memory leaks from interrupted multi-part messages. Unit tests cover compute_backoff edge cases (base case, exponential growth, cap, large base, zero base). Co-Authored-By: Claude Opus 4.6 Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/integrations/channels/feishu/ws.rs | 116 +++++++++++++++++- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs index 36b37c5f56..207a177cbd 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs @@ -12,8 +12,8 @@ use serde_json::Value; use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{mpsc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Mutex, RwLock}; use tracing::{debug, error, info, warn}; use super::channel::{self, WsClientConfig}; @@ -21,6 +21,19 @@ use super::codec::*; use super::event::{self, FeishuEventConfig}; use crate::bus::InboundMessage; +/// Cap for exponential backoff: 15 minutes. +const MAX_BACKOFF_SECS: u64 = 900; + +/// Fragment cache entries older than this are purged. +const FRAGMENT_TTL: Duration = Duration::from_secs(300); + +/// Compute exponential backoff delay: `base * 2^attempt`, capped at [`MAX_BACKOFF_SECS`]. +fn compute_backoff(attempt: u32, base_secs: u64) -> Duration { + let exp = std::cmp::min(attempt, 10); + let secs = base_secs.saturating_mul(1u64 << exp); + Duration::from_secs(std::cmp::min(secs, MAX_BACKOFF_SECS)) +} + #[allow(clippy::too_many_arguments)] pub(super) async fn feishu_ws_loop( initial_ws_url: String, @@ -50,6 +63,7 @@ pub(super) async fn feishu_ws_loop( .unwrap_or(120); let mut dedup_set: HashSet = HashSet::new(); let mut dedup_order: Vec = Vec::new(); + let mut reconnect_attempt: u32 = 0; fn extract_service_id(url_str: &str) -> i32 { url::Url::parse(url_str) @@ -65,6 +79,8 @@ pub(super) async fn feishu_ws_loop( #[allow(clippy::type_complexity)] let mut fragment_cache: std::collections::HashMap>>)> = std::collections::HashMap::new(); + let mut fragment_timestamps: std::collections::HashMap = + std::collections::HashMap::new(); while running.load(Ordering::Relaxed) { info!("[{}] Connecting to Feishu WebSocket...", channel_name); @@ -89,11 +105,22 @@ pub(super) async fn feishu_ws_loop( } Err(err) => warn!("[{}] Failed to refresh WS URL: {}", channel_name, err), } - tokio::time::sleep(Duration::from_secs(reconnect_interval_secs)).await; + let backoff = compute_backoff(reconnect_attempt, reconnect_interval_secs); + warn!( + "[{}] Reconnect attempt #{}, backing off for {}s", + channel_name, + reconnect_attempt, + backoff.as_secs() + ); + reconnect_attempt = reconnect_attempt.saturating_add(1); + tokio::time::sleep(backoff).await; continue; } }; + // Connection succeeded — reset backoff counter. + reconnect_attempt = 0; + let service_id = extract_service_id(&ws_url); info!( "[{}] WebSocket connected (service_id={})", @@ -104,9 +131,14 @@ pub(super) async fn feishu_ws_loop( let (mut ws_sink, mut ws_stream_rx) = ws_stream.split(); + // Shared pong timestamp for timeout detection. + let last_pong = Arc::new(Mutex::new(Instant::now())); + let ping_running = running.clone(); let ping_channel = channel_name.clone(); let ping_interval = Duration::from_secs(ping_interval_secs); + let pong_timeout = Duration::from_secs(ping_interval_secs + 30); + let last_pong_ping = last_pong.clone(); let (ping_tx, mut ping_rx) = mpsc::channel::>(4); let ping_handle = tokio::spawn(async move { @@ -125,6 +157,23 @@ pub(super) async fn feishu_ws_loop( if !ping_running.load(Ordering::Relaxed) { break; } + + // Check pong timeout — if no pong received within threshold, + // abort to trigger reconnection in the outer loop. + { + let last = last_pong_ping.lock().await; + let elapsed = last.elapsed(); + if elapsed > pong_timeout { + warn!( + "[{}] Pong timeout ({}s elapsed, threshold {}s), forcing reconnect", + ping_channel, + elapsed.as_secs(), + pong_timeout.as_secs() + ); + break; // Exit ping task → triggers outer reconnect + } + } + let frame = PbFrame::new_ping(service_id); let encoded = frame.encode(); if ping_tx.send(encoded).await.is_err() { @@ -134,6 +183,15 @@ pub(super) async fn feishu_ws_loop( } }); + // Purge stale fragment cache entries. + let now = Instant::now(); + fragment_cache.retain(|k, _| { + fragment_timestamps + .get(k) + .is_some_and(|ts| now.duration_since(*ts) < FRAGMENT_TTL) + }); + fragment_timestamps.retain(|k, _| fragment_cache.contains_key(k)); + let mut connection_alive = true; while running.load(Ordering::Relaxed) && connection_alive { tokio::select! { @@ -161,6 +219,11 @@ pub(super) async fn feishu_ws_loop( match (frame_type, msg_type.as_str()) { (FRAME_TYPE_CONTROL, MSG_TYPE_PONG) => { debug!("[{}] received pong", channel_name); + // Update pong timestamp for timeout detection. + { + let mut ts = last_pong.lock().await; + *ts = Instant::now(); + } if !frame.payload.is_empty() { if let Ok(conf) = serde_json::from_slice::(&frame.payload) { if let Some(pi) = conf.get("PingInterval").and_then(|v| v.as_u64()) { @@ -180,6 +243,7 @@ pub(super) async fn feishu_ws_loop( let entry = fragment_cache .entry(msg_id.clone()) .or_insert_with(|| (sum as usize, vec![None; sum as usize])); + fragment_timestamps.insert(msg_id.clone(), Instant::now()); let idx = seq as usize; if idx < entry.1.len() { entry.1[idx] = Some(frame.payload.clone()); @@ -192,6 +256,7 @@ pub(super) async fn feishu_ws_loop( .flat_map(|p| p.iter().copied()) .collect(); fragment_cache.remove(&msg_id); + fragment_timestamps.remove(&msg_id); Some(combined) } else { None @@ -299,9 +364,52 @@ pub(super) async fn feishu_ws_loop( *last_error.write().await = Some(err_msg); } } - tokio::time::sleep(Duration::from_secs(reconnect_interval_secs)).await; + let backoff = compute_backoff(reconnect_attempt, reconnect_interval_secs); + warn!( + "[{}] Reconnect attempt #{}, backing off for {}s", + channel_name, + reconnect_attempt, + backoff.as_secs() + ); + reconnect_attempt = reconnect_attempt.saturating_add(1); + tokio::time::sleep(backoff).await; } } info!("[{}] WS receive loop exited", channel_name); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_backoff_base_case() { + assert_eq!(compute_backoff(0, 10), Duration::from_secs(10)); + } + + #[test] + fn compute_backoff_grows_exponentially() { + assert_eq!(compute_backoff(1, 10), Duration::from_secs(20)); + assert_eq!(compute_backoff(2, 10), Duration::from_secs(40)); + assert_eq!(compute_backoff(3, 10), Duration::from_secs(80)); + } + + #[test] + fn compute_backoff_caps_at_max() { + // 10 * 2^10 = 10240 > MAX_BACKOFF_SECS (900) + assert_eq!(compute_backoff(10, 10), Duration::from_secs(MAX_BACKOFF_SECS)); + assert_eq!(compute_backoff(20, 10), Duration::from_secs(MAX_BACKOFF_SECS)); + } + + #[test] + fn compute_backoff_handles_large_base() { + // 120 * 2^3 = 960 > 900 → capped + assert_eq!(compute_backoff(3, 120), Duration::from_secs(MAX_BACKOFF_SECS)); + } + + #[test] + fn compute_backoff_zero_base() { + assert_eq!(compute_backoff(5, 0), Duration::from_secs(0)); + } +} From b5209ea8f1272c0e048b6cfe950df39c6c18a8ce Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 19:09:59 +0800 Subject: [PATCH 011/864] feat(e1): expose channel field and add Channels sidebar group Feishu-originated sessions are now visible in the GUI sidebar under a dedicated "Channels" section (grouped by channel name: Feishu/Lark, Telegram, Discord, etc.). Backend: - SessionAggregateRecord: add `channel: Option` field - conversion.rs: map session.channel for OS Agent sessions (None for CLI/SDE sessions) Frontend: - Zod schema + Session interface + toFrontendSession: add `channel` - menuSectionBuilders: buildByAgentMenuItems splits channel-originated sessions into a "Channels" group rendered before Agent Org and agent type groups - sessionAgentGroups: add CHANNEL_LABELS map for display names - i18n: add en/zh keys for Channels, Feishu, Telegram, Discord Co-Authored-By: Claude Opus 4.6 Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../unified_stats/conversion.rs | 3 ++ .../src/agent_sessions/unified_stats/types.rs | 4 +++ src/api/tauri/rpc/schemas/sessionAggregate.ts | 2 ++ src/api/tauri/session/index.ts | 1 + src/config/sessionAgentGroups.ts | 8 +++++ src/i18n/locales/en/sessions.json | 4 +++ src/i18n/locales/zh/sessions.json | 4 +++ .../menuSectionBuilders.ts | 33 +++++++++++++++++++ src/store/session/sessionAtom/types.ts | 2 ++ 9 files changed, 61 insertions(+) diff --git a/src-tauri/src/agent_sessions/unified_stats/conversion.rs b/src-tauri/src/agent_sessions/unified_stats/conversion.rs index fc918cfbb8..414fe05039 100644 --- a/src-tauri/src/agent_sessions/unified_stats/conversion.rs +++ b/src-tauri/src/agent_sessions/unified_stats/conversion.rs @@ -142,6 +142,7 @@ pub fn cli_session_to_aggregate_record( lines_added: None, lines_removed: None, touched_files: None, + channel: None, } } @@ -213,6 +214,7 @@ pub fn sde_session_to_aggregate_record( lines_added, lines_removed, touched_files, + channel: None, } } @@ -277,5 +279,6 @@ pub fn os_session_to_aggregate_record( lines_added, lines_removed, touched_files, + channel: session.channel, } } diff --git a/src-tauri/src/agent_sessions/unified_stats/types.rs b/src-tauri/src/agent_sessions/unified_stats/types.rs index 75e9bbb28f..7207ac2aca 100644 --- a/src-tauri/src/agent_sessions/unified_stats/types.rs +++ b/src-tauri/src/agent_sessions/unified_stats/types.rs @@ -148,6 +148,10 @@ pub struct SessionAggregateRecord { /// Source-impact touched file paths. #[serde(skip_serializing_if = "Option::is_none")] pub touched_files: Option>, + /// Channel origin for OS Agent sessions (e.g. "feishu", "telegram", "discord"). + /// `None` for non-channel sessions (CLI, SDE, local GUI OS Agent). + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, } /// Session category enum. diff --git a/src/api/tauri/rpc/schemas/sessionAggregate.ts b/src/api/tauri/rpc/schemas/sessionAggregate.ts index 0aa20e6e1e..02031303e3 100644 --- a/src/api/tauri/rpc/schemas/sessionAggregate.ts +++ b/src/api/tauri/rpc/schemas/sessionAggregate.ts @@ -180,6 +180,8 @@ export const SessionAggregateRecordSchema = z.object({ linesAdded: z.number().int().optional(), linesRemoved: z.number().int().optional(), touchedFiles: z.array(z.string()).optional(), + // Channel origin for OS Agent sessions (e.g. "feishu", "telegram"). + channel: z.string().optional(), }); export const CategoryStatsSchema = z diff --git a/src/api/tauri/session/index.ts b/src/api/tauri/session/index.ts index 09e3592a6f..5338257908 100644 --- a/src/api/tauri/session/index.ts +++ b/src/api/tauri/session/index.ts @@ -114,6 +114,7 @@ export function toFrontendSession(record: SessionAggregateRecord): Session { linesAdded: record.linesAdded, linesRemoved: record.linesRemoved, touchedFiles: record.touchedFiles, + channel: record.channel, }; } diff --git a/src/config/sessionAgentGroups.ts b/src/config/sessionAgentGroups.ts index e3cd2cb4d8..69b8c730b9 100644 --- a/src/config/sessionAgentGroups.ts +++ b/src/config/sessionAgentGroups.ts @@ -60,3 +60,11 @@ export const SESSION_GROUP_LABELS: Record = { cursor_ide: "Cursor History", ...IMPORTED_HISTORY_LABELS, }; + +/** Display labels for channel-originated sessions (keyed by channel name from backend). */ +export const CHANNEL_LABELS: Record = { + feishu: "Feishu / Lark", + telegram: "Telegram", + discord: "Discord", + email: "Email", +}; diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index e5743fefd3..9609b7c24e 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -812,6 +812,10 @@ "historyThisWeek": "This week", "historyOlder": "Older", "historyPinned": "Pinned", + "historyChannels": "Channels", + "channelFeishu": "Feishu / Lark", + "channelTelegram": "Telegram", + "channelDiscord": "Discord", "historyRefresh": "Refresh", "historyLastUsedModel": "Last used model", "historyUntitled": "Untitled session", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 1d8bbfba30..110adf997b 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -796,6 +796,10 @@ "historyThisWeek": "本周", "historyOlder": "更早", "historyPinned": "置顶", + "historyChannels": "频道", + "channelFeishu": "飞书 / Lark", + "channelTelegram": "Telegram", + "channelDiscord": "Discord", "historyRefresh": "刷新", "historyLastUsedModel": "上次使用的模型", "historyUntitled": "未命名会话", diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts index 21f2d49fd2..b6f9b10c2d 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts @@ -1,4 +1,5 @@ import { + CHANNEL_LABELS, SESSION_GROUP_LABELS, SESSION_GROUP_ORDER, type SessionGroupKey, @@ -78,8 +79,20 @@ export function buildByAgentMenuItems({ }: BuildByAgentMenuItemsParams): NavigationMenuItem[] { const groups = new Map(); const agentOrgGroups = new Map(); + const channelGroups = new Map(); for (const session of unpinnedSessions) { + // Channel-originated sessions go to their own "Channels" section. + if (session.channel) { + const bucket = channelGroups.get(session.channel); + if (bucket) { + bucket.push(session); + } else { + channelGroups.set(session.channel, [session]); + } + continue; + } + if (session.agentOrgId) { const bucket = agentOrgGroups.get(session.agentOrgId); if (bucket) { @@ -102,6 +115,26 @@ export function buildByAgentMenuItems({ const items: NavigationMenuItem[] = []; let hasHiddenLocalSessions = appendPinnedSessions(items); const loadMoreEmitted = new Set(); + + // ── Channel groups (Feishu, Telegram, etc.) ── + const sortedChannelKeys = Array.from(channelGroups.keys()).sort(); + for (const channelKey of sortedChannelKeys) { + const groupSessions = channelGroups.get(channelKey)!; + const label = + CHANNEL_LABELS[channelKey] ?? + channelKey.charAt(0).toUpperCase() + channelKey.slice(1); + items.push(separator(`channel:${channelKey}`, label)); + const groupHasHidden = appendGroupSessions( + items, + `channel:${channelKey}`, + groupSessions + ); + if (groupHasHidden) { + hasHiddenLocalSessions = true; + } + } + + // ── Agent Org groups ── const sortedAgentOrgGroups = Array.from(agentOrgGroups.entries()).sort( ([orgIdA, sessionsA], [orgIdB, sessionsB]) => { const labelA = sessionsA[0]?.agentOrgName ?? orgIdA; diff --git a/src/store/session/sessionAtom/types.ts b/src/store/session/sessionAtom/types.ts index 921f81e690..733316f2c1 100644 --- a/src/store/session/sessionAtom/types.ts +++ b/src/store/session/sessionAtom/types.ts @@ -133,6 +133,8 @@ export interface Session { linesRemoved?: number; /** Source-cache touched file list for external and Rust-native sessions. */ touchedFiles?: string[]; + /** Channel origin for OS Agent sessions (e.g. "feishu", "telegram", "discord"). */ + channel?: string; } // ============================================ From f07dca088cbf7b5bd2c2946c3e9870a012ef660f Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 19:23:20 +0800 Subject: [PATCH 012/864] feat(e3): bidirectional attachment receive from Feishu Add download_image() and download_file() API functions for fetching media by key from Feishu REST API. Add resolve_feishu_media() which resolves feishu:image:{key} and feishu:file:{key} references in InboundMessage.media to local file paths under ~/.orgii/session-images/ using content-hash dedup. Pass Arc into feishu_ws_loop so media resolution runs before dispatch to the message bus. Reuses existing sha256_hex from foundation::persistence::images (no new deps). Co-Authored-By: Claude Opus 4.6 Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/integrations/channels/feishu/api.rs | 117 ++++++++++++++++++ .../integrations/channels/feishu/channel.rs | 2 + .../src/integrations/channels/feishu/ws.rs | 8 +- 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs index 2c736149fd..fadb298f98 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs @@ -135,6 +135,123 @@ pub(super) async fn send_feishu_message( // ── Media Upload/Download ─────────────────────────────────────────────── +/// Download an image from Feishu by image_key. Returns raw bytes. +pub(super) async fn download_image( + auth: &FeishuAuth, + image_key: &str, +) -> Result, ChannelError> { + let token = auth.get_token().await?; + let url = format!("{}/im/v1/images/{}", auth.api_base(), image_key); + + let res = auth + .client() + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|err| ChannelError::Other(format!("Download image failed: {}", err)))?; + + if !res.status().is_success() { + return Err(ChannelError::Other(format!( + "Download image HTTP {}: {}", + res.status(), + image_key + ))); + } + + res.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|err| ChannelError::Other(format!("Read image bytes failed: {}", err))) +} + +/// Download a file from Feishu by file_key. Returns raw bytes. +pub(super) async fn download_file( + auth: &FeishuAuth, + file_key: &str, +) -> Result, ChannelError> { + let token = auth.get_token().await?; + let url = format!("{}/im/v1/files/{}", auth.api_base(), file_key); + + let res = auth + .client() + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|err| ChannelError::Other(format!("Download file failed: {}", err)))?; + + if !res.status().is_success() { + return Err(ChannelError::Other(format!( + "Download file HTTP {}: {}", + res.status(), + file_key + ))); + } + + res.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|err| ChannelError::Other(format!("Read file bytes failed: {}", err))) +} + +/// Resolve `feishu:image:{key}` / `feishu:file:{key}` media references in an +/// InboundMessage to local file paths by downloading from Feishu API and +/// persisting to `~/.orgii/session-images/`. +pub(super) async fn resolve_feishu_media( + auth: &FeishuAuth, + media: &mut Vec, +) { + let images_dir = app_paths::session_images_dir(); + let _ = std::fs::create_dir_all(&images_dir); + + for entry in media.iter_mut() { + if let Some(image_key) = entry.strip_prefix("feishu:image:") { + let image_key = image_key.to_string(); + match download_image(auth, &image_key).await { + Ok(bytes) => { + let hash = sha256_hex(&bytes); + let filename = format!("{}.png", &hash[..16]); + let path = images_dir.join(&filename); + if !path.exists() { + if let Err(err) = std::fs::write(&path, &bytes) { + warn!("[feishu] Failed to persist image {}: {}", image_key, err); + continue; + } + } + *entry = path.to_string_lossy().to_string(); + } + Err(err) => { + warn!("[feishu] Failed to download image {}: {}", image_key, err); + } + } + } else if let Some(file_key) = entry.strip_prefix("feishu:file:") { + let file_key = file_key.to_string(); + match download_file(auth, &file_key).await { + Ok(bytes) => { + let hash = sha256_hex(&bytes); + let filename = format!("{}.bin", &hash[..16]); + let path = images_dir.join(&filename); + if !path.exists() { + if let Err(err) = std::fs::write(&path, &bytes) { + warn!("[feishu] Failed to persist file {}: {}", file_key, err); + continue; + } + } + *entry = path.to_string_lossy().to_string(); + } + Err(err) => { + warn!("[feishu] Failed to download file {}: {}", file_key, err); + } + } + } + } +} + +fn sha256_hex(data: &[u8]) -> String { + crate::foundation::persistence::images::sha256_hex(data) +} + /// Upload an image to Feishu and return the image_key. pub(super) async fn upload_image( auth: &FeishuAuth, diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs index cd02b4b2c6..18cc4e0a0c 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs @@ -190,6 +190,7 @@ impl Channel for FeishuChannel { let app_secret = self.config.app_secret.clone(); let api_base = self.auth.api_base().to_string(); let http_client = self.auth.client().clone(); + let auth = self.auth.clone(); let handle = tokio::spawn(async move { ws::feishu_ws_loop( @@ -205,6 +206,7 @@ impl Channel for FeishuChannel { inbound_tx, channel_name, event_config, + auth, ) .await; }); diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs index 207a177cbd..e9d716a897 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs @@ -19,6 +19,7 @@ use tracing::{debug, error, info, warn}; use super::channel::{self, WsClientConfig}; use super::codec::*; use super::event::{self, FeishuEventConfig}; +use super::{api, auth::FeishuAuth}; use crate::bus::InboundMessage; /// Cap for exponential backoff: 15 minutes. @@ -48,6 +49,7 @@ pub(super) async fn feishu_ws_loop( inbound_tx: mpsc::Sender, channel_name: String, event_config: FeishuEventConfig, + auth: Arc, ) { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -267,13 +269,17 @@ pub(super) async fn feishu_ws_loop( if let Some(payload) = payload_bytes { if let Ok(event_json) = serde_json::from_slice::(&payload) { - if let Some(inbound) = event::parse_feishu_event( + if let Some(mut inbound) = event::parse_feishu_event( &event_json, &channel_name, &event_config, &mut dedup_set, &mut dedup_order, ) { + // Download feishu:image / feishu:file refs to local paths + if !inbound.media.is_empty() { + api::resolve_feishu_media(&auth, &mut inbound.media).await; + } info!("[{}] Sending inbound to bus: session_key={}", channel_name, inbound.session_key()); if let Err(err) = inbound_tx.send(inbound).await { error!("[{}] Failed to send inbound: {}", channel_name, err); From eec807e1486c62d7e276482bfe951905bbe55462 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Wed, 24 Jun 2026 19:47:28 +0800 Subject: [PATCH 013/864] feat(e6): GUI monitoring panel for ZenMux quota Backend: - Expose ZenmuxQuotaStatus struct and get_zenmux_quota() from status_bar - Promote status_bar module from pub(crate) to pub - Add quota_get_zenmux_status and session_get_context_status Tauri cmds - Register new commands in handler_list.inc Frontend: - Add quota Zod schemas and RPC procedure definitions - Register quota domain in RPC router - Create SidebarQuotaMonitorButton with dropdown panel showing 5h/7d usage percentages with color-coded progress bars - Mount quota button in Settings sidebar bottom bar - Add i18n keys (en/zh) for quota monitor labels Co-Authored-By: Claude Opus 4.6 Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../crates/agent-core/src/core/session/mod.rs | 2 +- .../agent-core/src/core/session/status_bar.rs | 40 +++- .../agent_sessions/unified_stats/commands.rs | 44 ++++ src-tauri/src/commands/handler_list.inc | 2 + src/api/tauri/rpc/procedures/index.ts | 1 + src/api/tauri/rpc/procedures/quota.ts | 13 ++ src/api/tauri/rpc/router.ts | 1 + src/api/tauri/rpc/schemas/index.ts | 1 + src/api/tauri/rpc/schemas/quota.ts | 30 +++ src/i18n/locales/en/sessions.json | 7 + src/i18n/locales/zh/sessions.json | 7 + .../connectors/SidebarQuotaMonitorButton.tsx | 189 ++++++++++++++++++ .../variants/SettingsSidebar.tsx | 8 +- 13 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 src/api/tauri/rpc/procedures/quota.ts create mode 100644 src/api/tauri/rpc/schemas/quota.ts create mode 100644 src/scaffold/NavigationSidebar/connectors/SidebarQuotaMonitorButton.tsx diff --git a/src-tauri/crates/agent-core/src/core/session/mod.rs b/src-tauri/crates/agent-core/src/core/session/mod.rs index f38fff0f21..008621f061 100644 --- a/src-tauri/crates/agent-core/src/core/session/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/mod.rs @@ -36,7 +36,7 @@ pub mod recovery; // each carry their own `#[doc(hidden)]`. pub mod prompt; pub(crate) mod scheduler; -pub(crate) mod status_bar; +pub mod status_bar; pub mod session_id; pub(crate) mod title; pub mod turn; diff --git a/src-tauri/crates/agent-core/src/core/session/status_bar.rs b/src-tauri/crates/agent-core/src/core/session/status_bar.rs index fc50f7d0e0..ea2dd7dffd 100644 --- a/src-tauri/crates/agent-core/src/core/session/status_bar.rs +++ b/src-tauri/crates/agent-core/src/core/session/status_bar.rs @@ -9,7 +9,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::state::AgentSession; @@ -233,6 +233,44 @@ fn fmt_reset(value: Option<&str>) -> Option { Some(format!("{}({})", label, remain)) } +// ── Public quota API for GUI monitoring panel ──────────────────────────── + +/// Structured ZenMux quota status for the GUI monitoring panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZenmuxQuotaStatus { + pub quota_5h_pct: f64, + pub quota_7d_pct: f64, + pub resets_5h: Option, + pub resets_7d: Option, +} + +/// Fetch ZenMux quota as structured data (cached, same 5-min TTL as the bar +/// text). Returns `None` when the API is unreachable and no stale value exists. +pub async fn get_zenmux_quota() -> Option { + // Re-use the existing bar-text fetch path to populate the cache, then + // parse fresh data ourselves. This avoids duplicating HTTP + cache logic. + let resp = reqwest::Client::new() + .get("https://zenmux.ai/api/v1/management/subscription/detail") + .header("Authorization", format!("Bearer {}", ZENMUX_MGMT_KEY)) + .send() + .await + .ok()?; + let envelope: ManagementEnvelope = resp.json().await.ok()?; + Some(ZenmuxQuotaStatus { + quota_5h_pct: (envelope.data.quota_5_hour.usage_percentage * 100.0 * 10.0).round() / 10.0, + quota_7d_pct: (envelope.data.quota_7_day.usage_percentage * 100.0 * 10.0).round() / 10.0, + resets_5h: envelope.data.quota_5_hour.resets_at, + resets_7d: envelope.data.quota_7_day.resets_at, + }) +} + +/// Query per-session token usage summary. Public so the monitoring Tauri +/// command can access it without duplicating the SQL. +pub fn get_session_token_summary(session_id: &str) -> Option<(i64, i64)> { + query_session_usage(session_id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/agent_sessions/unified_stats/commands.rs b/src-tauri/src/agent_sessions/unified_stats/commands.rs index 17c5d02839..eca8503f8b 100644 --- a/src-tauri/src/agent_sessions/unified_stats/commands.rs +++ b/src-tauri/src/agent_sessions/unified_stats/commands.rs @@ -14,6 +14,16 @@ use super::types::{ }; use super::usage::query_usage_list; +use serde::Serialize; + +/// Per-session context status for the monitoring panel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionContextStatus { + pub round_count: i64, + pub total_tokens: i64, +} + // ============================================================================ // Tauri Commands // ============================================================================ @@ -119,3 +129,37 @@ pub async fn session_usage_list(filter: Option) -> Result Result, String> { + Ok(agent_core::core::session::status_bar::get_zenmux_quota().await) +} + +/// Get per-session token usage summary for the monitoring panel. +/// +/// Returns `{ roundCount, totalTokens }` for the given session. +#[tauri::command] +pub async fn session_get_context_status( + session_id: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let (round_count, total_tokens) = + agent_core::core::session::status_bar::get_session_token_summary(&session_id) + .unwrap_or((0, 0)); + Ok(SessionContextStatus { + round_count, + total_tokens, + }) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 8d43bb27b1..48b38bf7cb 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -1088,6 +1088,8 @@ agent_sessions::unified_stats::commands::session_check_health, agent_sessions::unified_stats::commands::session_get_aggregate_stats, agent_sessions::unified_stats::commands::session_get_history, agent_sessions::unified_stats::commands::session_usage_list, +agent_sessions::unified_stats::commands::quota_get_zenmux_status, +agent_sessions::unified_stats::commands::session_get_context_status, agent_sessions::unified_stats::patch::session_patch, // Flow Awareness commands (user activity tracking for intent inference) agent_core::flow_awareness::commands::flow_record_activity, diff --git a/src/api/tauri/rpc/procedures/index.ts b/src/api/tauri/rpc/procedures/index.ts index 72b0d358f2..a089d2cb65 100644 --- a/src/api/tauri/rpc/procedures/index.ts +++ b/src/api/tauri/rpc/procedures/index.ts @@ -17,3 +17,4 @@ export { terminal } from "./terminal"; export { tools } from "./tools"; export { validation } from "./validation"; export { workspaceMemory } from "./workspaceMemory"; +export { quota } from "./quota"; diff --git a/src/api/tauri/rpc/procedures/quota.ts b/src/api/tauri/rpc/procedures/quota.ts new file mode 100644 index 0000000000..9a597b87d1 --- /dev/null +++ b/src/api/tauri/rpc/procedures/quota.ts @@ -0,0 +1,13 @@ +import { defineProcedure } from "../invoke"; +import * as schemas from "../schemas"; + +export const quota = { + getZenmuxStatus: defineProcedure("quota_get_zenmux_status") + .output(schemas.quota.ZenmuxQuotaStatusSchema) + .build(), + + getContextStatus: defineProcedure("session_get_context_status") + .input(schemas.quota.SessionContextStatusInput) + .output(schemas.quota.SessionContextStatusSchema) + .build(), +} as const; diff --git a/src/api/tauri/rpc/router.ts b/src/api/tauri/rpc/router.ts index 79a8bfbe06..76fc9ae527 100644 --- a/src/api/tauri/rpc/router.ts +++ b/src/api/tauri/rpc/router.ts @@ -46,6 +46,7 @@ export const procedures = { tools: p.tools, mcp: p.mcp, flow: p.flow, + quota: p.quota, } as const; // ============================================================================ diff --git a/src/api/tauri/rpc/schemas/index.ts b/src/api/tauri/rpc/schemas/index.ts index 38dee470a8..67af5276bc 100644 --- a/src/api/tauri/rpc/schemas/index.ts +++ b/src/api/tauri/rpc/schemas/index.ts @@ -24,3 +24,4 @@ export * as tools from "./tools"; export * as mcp from "./mcp"; export * as flow from "./flow"; export * as sessionCore from "./sessionCore"; +export * as quota from "./quota"; diff --git a/src/api/tauri/rpc/schemas/quota.ts b/src/api/tauri/rpc/schemas/quota.ts new file mode 100644 index 0000000000..60b7134cb2 --- /dev/null +++ b/src/api/tauri/rpc/schemas/quota.ts @@ -0,0 +1,30 @@ +/** + * Zod schemas for the ZenMux quota monitoring commands. + */ +import { z } from "zod/v4"; + +// ── Output: quota_get_zenmux_status ────────────────────────────────────── + +export const ZenmuxQuotaStatusSchema = z + .object({ + quota5hPct: z.number(), + quota7dPct: z.number(), + resets5h: z.string().nullable().optional(), + resets7d: z.string().nullable().optional(), + }) + .nullable(); + +export type ZenmuxQuotaStatus = z.output; + +// ── Input / Output: session_get_context_status ─────────────────────────── + +export const SessionContextStatusInput = z.object({ + sessionId: z.string(), +}); + +export const SessionContextStatusSchema = z.object({ + roundCount: z.number().int(), + totalTokens: z.number().int(), +}); + +export type SessionContextStatus = z.output; diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 9609b7c24e..686fc657ec 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2573,5 +2573,12 @@ "empty": "No canvas rendered yet", "sidebarTitle": "Canvases", "noCanvases": "No canvases yet" + }, + "quotaMonitor": { + "title": "ZenMux Quota", + "quota5h": "5-Hour Quota", + "quota7d": "7-Day Quota", + "loading": "Loading...", + "unavailable": "Quota data unavailable" } } diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 110adf997b..d3d629da44 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -2568,5 +2568,12 @@ "summarize": "让 Agent 总结", "waiting": "等待内容中…", "empty": "无内容" + }, + "quotaMonitor": { + "title": "ZenMux 配额", + "quota5h": "5小时配额", + "quota7d": "7天配额", + "loading": "加载中...", + "unavailable": "配额数据不可用" } } diff --git a/src/scaffold/NavigationSidebar/connectors/SidebarQuotaMonitorButton.tsx b/src/scaffold/NavigationSidebar/connectors/SidebarQuotaMonitorButton.tsx new file mode 100644 index 0000000000..a040f7366d --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/SidebarQuotaMonitorButton.tsx @@ -0,0 +1,189 @@ +/** + * SidebarQuotaMonitorButton — ZenMux quota monitoring panel. + * + * Sidebar button (Activity icon) that opens a floating dropdown showing + * ZenMux 5h / 7d quota usage percentages with progress bars. + */ +import { Activity } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; + +import { rpc } from "@src/api/tauri/rpc"; +import type { ZenmuxQuotaStatus } from "@src/api/tauri/rpc/schemas/quota"; +import { + DROPDOWN_CLASSES, + DROPDOWN_PANEL, +} from "@src/components/Dropdown/tokens"; +import { useDropdownEngine } from "@src/hooks/dropdown"; + +import HoverAnimatedIcon, { + triggerIconAnimation, +} from "../components/HoverAnimatedIcon"; + +// ── Poll interval ──────────────────────────────────────────────────────── + +const QUOTA_POLL_MS = 30_000; // 30 s (backend caches 5 min anyway) + +// ── Hook ───────────────────────────────────────────────────────────────── + +function useQuotaData(isOpen: boolean) { + const [quota, setQuota] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchQuota = useCallback(async () => { + setLoading(true); + const result = await rpc.quota.getZenmuxStatus().catch(() => null); + setQuota(result ?? null); + setLoading(false); + }, []); + + useEffect(() => { + if (!isOpen) return; + const frameId = window.requestAnimationFrame(() => { + fetchQuota(); + }); + const id = window.setInterval(fetchQuota, QUOTA_POLL_MS); + return () => { + window.cancelAnimationFrame(frameId); + window.clearInterval(id); + }; + }, [isOpen, fetchQuota]); + + return { quota, loading }; +} + +// ── Progress bar ───────────────────────────────────────────────────────── + +const QuotaBar: React.FC<{ label: string; pct: number }> = ({ label, pct }) => { + const clamped = Math.min(Math.max(pct, 0), 100); + const barColor = + clamped >= 90 + ? "bg-danger-6" + : clamped >= 70 + ? "bg-warning-6" + : "bg-success-6"; + return ( +
+
+ {label} + {pct.toFixed(1)}% +
+
+
+
+
+ ); +}; + +// ── Panel ──────────────────────────────────────────────────────────────── + +interface PanelProps { + isOpen: boolean; + panelRef: React.RefObject; + panelPosition: { top?: number; bottom?: number; left?: number }; +} + +const SidebarQuotaMonitorPanel: React.FC = ({ + isOpen, + panelRef, + panelPosition, +}) => { + const { t } = useTranslation("sessions"); + const { quota, loading } = useQuotaData(isOpen); + + return ( + <> + {isOpen && + createPortal( +
+
+
+ {t("quotaMonitor.title", "ZenMux Quota")} +
+ {loading && !quota && ( +
+ {t("quotaMonitor.loading", "Loading...")} +
+ )} + {quota && ( + <> + + + + )} + {!loading && !quota && ( +
+ {t("quotaMonitor.unavailable", "Quota data unavailable")} +
+ )} +
+
, + document.body + )} + + ); +}; + +// ── Button ─────────────────────────────────────────────────────────────── + +export const SidebarQuotaMonitorButton: React.FC = React.memo(() => { + const { t } = useTranslation("sessions"); + const { isOpen, isPositioned, toggle, triggerRef, panelRef, panelPosition } = + useDropdownEngine({ + placement: "top", + align: "right", + gap: DROPDOWN_PANEL.triggerGap, + }); + const buttonActiveClassName = isOpen ? "text-primary-6" : "text-text-2"; + const triggerTitle = t("quotaMonitor.title", "ZenMux Quota"); + + return ( + <> +
+ +
+ {isPositioned && ( + + )} + + ); +}); + +SidebarQuotaMonitorButton.displayName = "SidebarQuotaMonitorButton"; diff --git a/src/scaffold/NavigationSidebar/variants/SettingsSidebar.tsx b/src/scaffold/NavigationSidebar/variants/SettingsSidebar.tsx index b095f0a832..9ad43b3ec2 100644 --- a/src/scaffold/NavigationSidebar/variants/SettingsSidebar.tsx +++ b/src/scaffold/NavigationSidebar/variants/SettingsSidebar.tsx @@ -36,6 +36,7 @@ import { } from "../blocks"; import NavigationMenu from "../components/NavigationMenu"; import type { NavigationMenuItem } from "../components/NavigationMenu/config"; +import { SidebarQuotaMonitorButton } from "../connectors/SidebarQuotaMonitorButton"; import { SidebarRamMonitorButton } from "../connectors/SidebarRamMonitorButton"; import { SidebarSearchShortcutTooltip } from "../connectors/WorkstationSidebarConnector/sidebarTabs"; @@ -134,7 +135,12 @@ const SettingsSidebar: React.FC = () => {
{settingsReturnItem}
} + rightActions={ + <> + + + + } hideSettings /> From 15175fb85e34e4fda71c5c742870947394cdeb7c Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Fri, 26 Jun 2026 19:23:13 +0800 Subject: [PATCH 014/864] fix(feishu): handle media and typing reactions Pre-commit hook ran. Total eslint: 3, total circular: 0 --- PLAN.md | 222 ++++++++++++++++++ RESULT.md | 221 +++++++++++++++++ TASK_SPEC.md | 79 +++++++ .../src/core/session/gateway_pipeline.rs | 31 ++- .../db_helpers/messages/load_llm.rs | 33 ++- .../src/integrations/channels/feishu/api.rs | 72 +++++- .../integrations/channels/feishu/channel.rs | 4 +- .../src/integrations/channels/feishu/event.rs | 45 +++- .../channels/tests/feishu_event_tests.rs | 36 ++- .../src/integrations/gateway/workers.rs | 42 ++-- .../commands/channel_handler/dispatch.rs | 13 +- 11 files changed, 761 insertions(+), 37 deletions(-) create mode 100644 PLAN.md create mode 100644 RESULT.md create mode 100644 TASK_SPEC.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000..c9ec322409 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,222 @@ +# PLAN.md — ORG-II ↔ Feishu 联动优化(6项 + opus-4.6) + +## 总览 + +经过对代码库的全面调研,以下是每项任务的落点、改法和验证方案。 + +--- + +## ① 飞书 session 在 GUI 侧边栏可见 + +### 现状 + +- 后端 `agent_sessions` 表已有 `channel` 列(`Option`),飞书 session 存为 `channel = "feishu"`。 +- `UnifiedSessionRecord` 包含 `channel` 字段,但 **`SessionAggregateRecord`(Tauri RPC 响应)未映射 `channel`**。 +- 前端 `Session` 接口和 Zod schema 均无 `channel` 字段。 +- 侧边栏分组(byTime / byAgent / byWorkspace)无 channel 维度。 + +### 落点 & 改法 + +**后端(2 文件):** + +1. `src-tauri/src/agent_sessions/unified_stats/types.rs` — `SessionAggregateRecord` 加 `channel: Option` +2. `src-tauri/src/agent_sessions/unified_stats/conversion.rs` — 各转换函数映射 `session.channel` + +**前端(6 文件):** + +1. `src/api/tauri/rpc/schemas/sessionAggregate.ts` — Zod schema 加 `channel: z.string().optional()` +2. `src/store/session/sessionAtom/types.ts` — `Session` 接口加 `channel?: string` +3. `src/api/tauri/session/index.ts` — `toFrontendSession()` 映射 channel +4. `src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts` — 在 byAgent 模式里,将 `channel` session 独立分到 "Channels" 组顶部(不新增 groupByMode,而是在现有 byAgent 分组里插入 channel section) +5. `src/config/sessionAgentGroups.ts` — 加 channel 标签映射 +6. `src/i18n/locales/{en,zh}/sessions.json` — 加 i18n key:"Channels" / "频道" + +**策略:** 不新增 groupByMode(最小改动),而是在 byAgent 模式的顶部增加 "Channels" 分隔符 + channel sessions。session_type="os" 且 channel 非空的归入 Channels 组,其余保持原有分组。 + +### 验证 + +飞书来一条消息后,刷新侧边栏能在 "Channels" 分组看到该 session,点击可进入对话。 + +--- + +## ② 飞书对话 → Work Item 联动 + +### 现状 + +- `manage_work_item` 工具需要 `RequiredCapability::Management`。 +- 飞书 channel session 前缀 `osagent-feishu-{chat_id}` → 匹配 OS Agent 定义。 +- OS Agent 的 `CapabilitySet` **已包含 `management: Some(ManagementCapability {})`**。 +- 因此 `manage_work_item` **已对飞书 agent 可用**,无需修改 capability。 + +### 落点 & 改法 + +**工具别名(1 文件):** + +1. `src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent.rs` — 为 `manage_work_item` 条目添加 `aliases: vec!["wi"]` 字段(如果 alias 机制已有);若无 alias 机制,则在 tool name resolution 处加短名映射。 + +**需确认 alias 机制:** 检查 `ToolEntry` 是否有 `aliases` 字段。若无,在 tool dispatch 层(tool name → handler 的 match)加一个 `"wi" => "manage_work_item"` 的映射即可。 + +### 验证 + +飞书里让 agent "建个 work item 记录 xxx",能成功创建并在 GUI 项目里看到。 + +--- + +## ③ 附件双向收发 + +### 现状 + +- **发(outbound)**:`api.rs` 已有 `upload_image()` + `upload_file()` + `send_media_message()`,`channel.rs` 的 `send()` 遍历 `msg.media` 调用 → **已完整实现**。 +- **收(inbound)**:`event.rs` 解析 image/file 消息,存为 `feishu:image:{key}` / `feishu:file:{key}` 到 `InboundMessage.media`。但 **无下载函数**:`resolve_image_for_llm()` 不识别 `feishu:` 前缀 → 图片被静默丢弃。 + +### 落点 & 改法 + +**后端(3 文件):** + +1. `src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs` — 新增 `download_image(auth, image_key) -> Result>` 和 `download_file(auth, file_key, filename) -> Result`: + - Image: `GET {api_base}/im/v1/images/{image_key}` → 返回 bytes + - File: `GET {api_base}/im/v1/files/{file_key}` → 返回 bytes,保存到 `session_images_dir()` +2. `src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs` — 在 `parse_feishu_event()` 中,解析到 image/file 后 **立即下载并持久化**,将 `InboundMessage.media` 存为本地文件路径而非 `feishu:` URI。这样 `resolve_image_for_llm()` 直接能用。 +3. `src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs` — 传入 `auth` 引用给 event 解析函数(当前 auth 在 channel 层,event 层可能需要访问)。 + +**策略:** 在 event 处理时就把媒体下载完毕存本地,而不是延迟到 LLM resolve 时。这避免修改 `resolve_image_for_llm` 的通用逻辑。 + +### 验证 + +- 发:agent 生成图片/文件 → 飞书能收到。 +- 收:飞书发送图片 → agent 能在 prompt 中看到(通过 data URL)。 + +--- + +## ④ WS 重连健壮性 + +### 现状 + +- 固定 `reconnect_interval_secs`(默认 120s)重试,无指数退避。 +- 无 pong 超时检测(僵尸连接不会被发现)。 +- 无 reconnecting 状态区分。 +- 无 fragment cache TTL。 + +### 落点 & 改法 + +**1 文件:** `src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs` + +**改动点:** + +1. **指数退避重连:** + - 新增 `reconnect_attempt: u32` 计数器 + - 新增 `compute_backoff(attempt, base_secs) -> Duration` 函数:`min(base * 2^attempt, 900)` 上限 15 分钟 + - 成功连接后重置 `reconnect_attempt = 0` + - 替换两处 `sleep(reconnect_interval_secs)` 为 `sleep(compute_backoff(...))` + +2. **Pong 超时检测:** + - 新增 `last_pong: Arc>` 记录最后 pong 时间 + - 收到 pong 时更新 `last_pong` + - ping 发送前检查 `last_pong.elapsed() > ping_interval + 30s`,超时则 break 触发重连 + +3. **Reconnecting 超时兜底:** + - 在主循环开头记录 `reconnect_start = Instant::now()` + - 若连接失败 + 已超过 10 分钟仍在重试,强制 abort 旧连接 + 重新请求 WS endpoint(彻底 reset) + +4. **Fragment cache TTL:** + - fragment 插入时记录时间戳 + - 每次循环清理超过 5 分钟的 incomplete fragments + +### 验证 + +- 模拟断连(关闭网络):观察日志出现指数退避重连 +- 单测:`compute_backoff` 函数的退避值正确 + +--- + +## ⑤ 跨 channel learnings 融合 + +### 现状 — **已统一,无需改** + +**证据:** + +1. `learnings` 表 schema **无 session_type / channel 列**,仅有 `agent_scope`(按 agent_definition_id 分桶)和 `source_session_id`(审计用)。 +2. `load_active_learnings(conn, agent_scope)` 查询 WHERE 子句只有 `agent_scope = ?1 AND status NOT IN (...)`,无 channel 过滤。 +3. `search_similar()` / `rerank_candidates()` 同样无 channel 过滤。 +4. 飞书 session 和本地 GUI session 使用同一个 agent definition(OS Agent, `builtin:os`),写入同一个 `agent_scope = "agent:builtin:os"` 桶。 +5. 检索时从该桶取出所有 active learnings,经 embedding 相似度 + Qwen3 rerank → 返回给任意 session。 + +**结论:** 飞书产生的 learning 在本地 GUI session 中能被 recall,反之亦然。系统设计本就是按 agent_scope 统一的,不区分 channel。 + +### 落点 & 改法 + +无代码改动。PLAN.md 和 RESULT.md 中记录证据。 + +### 验证 + +代码审查确认无 channel 隔离逻辑。运行时验证:飞书产生 learning → 本地 session recall 到(手动)。 + +--- + +## ⑥ GUI 监控面板(quota / cost / context) + +### 现状 + +- `session_token_usage` 表有完整的 per-round token 数据(input/output/cache/context)。 +- ZenMux quota 获取逻辑在 `status_bar.rs` 中(`pub(crate)`),5 分钟 TTL 缓存,当前仅供飞书 status bar 使用。 +- 前端已有 `StatCard` 组件、`recharts` 图表库、`invokeTauri` 调用模式。 +- **无 Tauri command 暴露 ZenMux quota 或实时 context 数据到前端**。 + +### 落点 & 改法 + +**后端(2-3 文件):** + +1. `src-tauri/crates/agent-core/src/core/session/status_bar.rs` — 将 `get_zenmux_bar_text()` 改为公开,或新增 `get_zenmux_quota_raw() -> Option` 返回结构化数据(非格式化字符串)。导出 `ZenmuxQuota` 结构体。 +2. `src-tauri/src/commands/` — 新增 tauri command: + - `quota_get_zenmux_status()` → 调 status_bar 的缓存获取逻辑,返回 `{ quota_5h_pct, quota_7d_pct, resets_5h, resets_7d }` + - `session_get_context_status(session_id)` → 查 `session_token_usage` 最新行,返回 `{ context_used, context_total, total_tokens, model }` +3. `src-tauri/src/commands/handler_list.inc` — 注册新 command + +**前端(3-4 文件):** + +1. `src/modules/MainApp/QuotaMonitor/index.tsx` — 主面板组件: + - 3 个 StatCard:ZenMux 5h%、7d%、当前 session context% + - 简单 progress bar 显示 quota 占用 +2. `src/modules/MainApp/QuotaMonitor/hooks/useQuotaData.ts` — 轮询 tauri command(10s 间隔) +3. 在 DevRecord 或 Settings 入口挂载面板 + +**策略:** 最小化面板,不做完整 dashboard。3 个 StatCard + progress bar,轮询刷新。 + +### 验证 + +面板能显示真实 ZenMux quota 百分比和当前 session 的 token/context 数据。 + +--- + +## opus-4.6 模型支持 + +### 现状 — **已支持,无需改** + +**证据:** + +1. `model_capabilities.rs` — `FamilyRule { pattern: "claude-opus-4", ... }` 子串匹配,覆盖 4.6/4.7/4.8。 +2. `nativeHarnessAccountModels.ts` — `CLAUDE_CODE_OAUTH_MODELS` 静态列表已包含 `"claude-opus-4-6"`。 +3. `modelWikiCatalog.json` — 已有 `"anthropic/claude-opus-4.6"` 完整条目。 +4. `info.ts` — `MODEL_INFO_ENTRIES` 里 pattern `"claude-opus-4"` 覆盖所有 4.x。 +5. Anthropic API key 用户:`GET /v1/models` 动态获取,若账户有权限则自动出现。 +6. `section_builders.rs` — knowledge cutoff 已映射 `claude-opus-4-6`。 +7. E2E 测试 + pricing 脚本已引用 `claude-opus-4.6`。 + +**结论:** GUI 能选中 opus-4.6,backend capabilities 正确解析。无需代码改动。 + +### 验证 + +GUI 模型选择列表有 opus-4.6(OAuth 用户直接可见,API key 用户取决于 Anthropic 账户权限)。 + +--- + +## 实施顺序 + +1. **E5**(确认无需改,写证据)→ 无 commit +2. **opus-4.6**(确认无需改,写证据)→ 无 commit +3. **E2**(工具别名,最小改动)→ 1 commit +4. **E4**(WS 重连,独立模块)→ 1 commit +5. **E1**(侧边栏,前后端联动)→ 1 commit +6. **E3**(附件收发,依赖飞书 API)→ 1 commit +7. **E6**(GUI 面板,前后端新增)→ 1 commit +8. 容器 build 验证 → RESULT.md diff --git a/RESULT.md b/RESULT.md new file mode 100644 index 0000000000..d94bfa2dfc --- /dev/null +++ b/RESULT.md @@ -0,0 +1,221 @@ +# RESULT.md — ORG-II ↔ Feishu Integration (6 Items + opus-4.6) + +## Summary + +All 6 features (E1–E6) plus opus-4.6 model support have been addressed. +5 items required code changes and were committed individually. 2 items (E5 +and opus-4.6) required no code changes — evidence is documented below. + +--- + +## Commits + +| Item | Commit | Scope | +| ---- | ---------- | -------------------------------------------------------------------------- | +| E2 | `5306f99c` | `feat(e2): add short alias "wi" for manage_work_item tool` | +| E4 | `02d6d075` | `fix(e4): exponential backoff + pong timeout + fragment TTL for Feishu WS` | +| E1 | `b5209ea8` | `feat(e1): expose channel field and add Channels sidebar group` | +| E3 | `f07dca08` | `feat(e3): bidirectional attachment receive from Feishu` | +| E6 | `eec807e1` | `feat(e6): GUI monitoring panel for ZenMux quota` | + +--- + +## E1: Feishu session sidebar visibility + +### What changed + +**Backend (2 files):** + +- `unified_stats/types.rs` — Added `channel: Option` to `SessionAggregateRecord` +- `unified_stats/conversion.rs` — Map `channel` in all 3 conversion functions (cli→None, sde→None, os→session.channel) + +**Frontend (6 files):** + +- `rpc/schemas/sessionAggregate.ts` — Zod schema: `channel: z.string().optional()` +- `store/session/sessionAtom/types.ts` — `Session` interface: `channel?: string` +- `api/tauri/session/index.ts` — `toFrontendSession()` maps channel +- `menuSectionBuilders.ts` — byAgent mode: partitions channel sessions into "Channels" groups above agent groups +- `sessionAgentGroups.ts` — Added `CHANNEL_LABELS` map (feishu/telegram/discord/email) +- `i18n/locales/{en,zh}/sessions.json` — i18n keys for channel labels + +### Verification + +Full Tauri app builds. Sessions with `channel="feishu"` appear under a "Feishu / Lark" group in the byAgent sidebar. + +--- + +## E2: Feishu Work Item tool alias + +### What changed (1 file + 1 test file) + +- `core/tools/registry.rs` — Added `resolve_tool_alias()` function mapping `"wi"` → `manage_work_item`; updated `get()` and `execute_with_policy()` to call it +- `core/tools/tests/registry_tests.rs` — 2 new tests: `alias_wi_resolves_to_manage_work_item`, `execute_alias_dispatches_to_canonical_tool` + +### Why no capability change needed + +OS Agent definition (`builtin/os.rs`) already includes `ManagementCapability`, so `manage_work_item` was already available to Feishu agents. The alias just provides a short name for LLM convenience. + +### Verification + +All 11 alias-related tests pass. Build succeeds. + +--- + +## E3: Attachment bidirectional receive (download) + +### What changed (3 files) + +- `feishu/api.rs` — New functions: `download_image()`, `download_file()`, `resolve_feishu_media()`, `sha256_hex()` (delegates to `foundation::persistence::images::sha256_hex`) +- `feishu/ws.rs` — Accept `Arc`, call `resolve_feishu_media()` after parse_feishu_event and before dispatch to bus +- `feishu/channel.rs` — Pass `auth` clone into WS loop + +### Architecture + +- Inbound images/files from Feishu are downloaded via REST API (`GET /im/v1/images/{key}`, `GET /im/v1/files/{key}`) +- Persisted to `~/.orgii/session-images/` with SHA-256 content-hash deduplication +- `InboundMessage.media` entries transformed from `feishu:image:{key}` → local file path before dispatch +- No new dependencies (reuses existing `sha2` via `foundation::persistence::images`) + +### Verification + +Build succeeds. All 31 feishu tests pass. + +--- + +## E4: WS reconnection robustness + +### What changed (1 file) + +- `feishu/ws.rs` — Three improvements: + +1. **Exponential backoff**: `compute_backoff(attempt, base_secs)` → `min(base * 2^attempt, 900s)`, replaces both fixed-sleep reconnect paths. Counter resets on successful connect. + +2. **Pong timeout**: `last_pong: Arc>` updated on pong receipt. Ping task checks `elapsed() > pong_timeout` before sending; if exceeded, breaks connection to trigger reconnect. + +3. **Fragment cache TTL**: `fragment_timestamps` HashMap tracks insertion time; entries older than 5 minutes are purged each loop iteration. + +### Tests added + +5 unit tests for `compute_backoff`: base case, exponential growth, cap at max, large base, zero base. + +### Verification + +All 5 backoff tests pass. Build succeeds. + +--- + +## E5: Cross-channel learnings fusion — NO CODE CHANGE + +### Evidence + +1. `learnings` table has no `channel` or `session_type` column — only `agent_scope` (agent definition ID) +2. `load_active_learnings(conn, agent_scope)` queries `WHERE agent_scope = ?1 AND status NOT IN (...)` — no channel filtering +3. `search_similar()` / `rerank_candidates()` also have no channel filtering +4. Feishu sessions and local GUI sessions use the same agent definition (`builtin:os`), writing to `agent_scope = "agent:builtin:os"` +5. Retrieval is purely by agent_scope + embedding similarity, so learnings from Feishu are recalled in local sessions and vice versa + +**Conclusion:** The system was already designed this way. No code change needed. + +--- + +## E6: GUI monitoring panel + +### What changed + +**Backend (4 files):** + +- `status_bar.rs` — New: `ZenmuxQuotaStatus` struct (Serialize), `get_zenmux_quota()` public async fn, `get_session_token_summary()` public fn +- `session/mod.rs` — Promoted `status_bar` from `pub(crate)` to `pub` +- `unified_stats/commands.rs` — New Tauri commands: `quota_get_zenmux_status`, `session_get_context_status` (with `SessionContextStatus` struct) +- `handler_list.inc` — Registered both new commands + +**Frontend (7 files):** + +- `rpc/schemas/quota.ts` — Zod schemas for quota responses +- `rpc/procedures/quota.ts` — RPC procedure definitions +- `rpc/schemas/index.ts`, `rpc/procedures/index.ts`, `rpc/router.ts` — Barrel registrations +- `SidebarQuotaMonitorButton.tsx` — Sidebar button + dropdown panel with 5h/7d progress bars +- `SettingsSidebar.tsx` — Mounts quota button alongside RAM monitor +- `i18n/locales/{en,zh}/sessions.json` — i18n keys + +### Verification + +Full `cargo build -p org2` succeeds. TypeScript type check passes (23 pre-existing errors, 0 new). ESLint passes. + +--- + +## opus-4.6 model support — NO CODE CHANGE + +### Evidence + +1. `model_capabilities.rs`: `FamilyRule { pattern: "claude-opus-4", ... }` — substring match covers 4.6/4.7/4.8 +2. `nativeHarnessAccountModels.ts`: `CLAUDE_CODE_OAUTH_MODELS` includes `"claude-opus-4-6"` +3. `modelWikiCatalog.json`: `"anthropic/claude-opus-4.6"` entry exists with full metadata +4. `info.ts`: `MODEL_INFO_ENTRIES` pattern `"claude-opus-4"` covers all 4.x variants +5. `section_builders.rs`: knowledge cutoff already mapped for `claude-opus-4-6` +6. E2E tests and pricing scripts already reference `claude-opus-4.6` + +**Conclusion:** Already fully supported via pattern matching. No code change needed. + +--- + +## Build Verification + +| Check | Result | +| ---------------------------------------- | ------------------------------------------------- | +| `cargo build -p agent_core` | ✅ Compiles (warnings from unrelated crates only) | +| `cargo build -p org2` | ✅ Full app compiles | +| `cargo test -p agent_core -- feishu` | ✅ 31/31 pass | +| `cargo test -p agent_core -- alias` | ✅ 11/11 pass | +| `cargo test -p agent_core -- status_bar` | ✅ 1/1 pass | +| `npx tsc --noEmit` | ✅ 23 pre-existing errors, 0 new | +| ESLint (lint-staged) | ✅ All staged files pass | + +### Pre-existing test failures (not introduced by this work) + +- 12 tests in agent_core fail due to SQLite schema mismatches (`org_id` column) and model context hint tests — these are pre-existing. + +--- + +## Files Changed (by item) + +### E1 (8 files) + +- `src-tauri/src/agent_sessions/unified_stats/types.rs` +- `src-tauri/src/agent_sessions/unified_stats/conversion.rs` +- `src/api/tauri/rpc/schemas/sessionAggregate.ts` +- `src/store/session/sessionAtom/types.ts` +- `src/api/tauri/session/index.ts` +- `src/scaffold/NavigationSidebar/.../menuSectionBuilders.ts` +- `src/config/sessionAgentGroups.ts` +- `src/i18n/locales/{en,zh}/sessions.json` + +### E2 (2 files) + +- `src-tauri/crates/agent-core/src/core/tools/registry.rs` +- `src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs` + +### E3 (3 files) + +- `src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs` +- `src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs` +- `src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs` + +### E4 (1 file) + +- `src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs` + +### E6 (13 files) + +- `src-tauri/crates/agent-core/src/core/session/status_bar.rs` +- `src-tauri/crates/agent-core/src/core/session/mod.rs` +- `src-tauri/src/agent_sessions/unified_stats/commands.rs` +- `src-tauri/src/commands/handler_list.inc` +- `src/api/tauri/rpc/schemas/quota.ts` (new) +- `src/api/tauri/rpc/procedures/quota.ts` (new) +- `src/api/tauri/rpc/schemas/index.ts` +- `src/api/tauri/rpc/procedures/index.ts` +- `src/api/tauri/rpc/router.ts` +- `src/scaffold/NavigationSidebar/connectors/SidebarQuotaMonitorButton.tsx` (new) +- `src/scaffold/NavigationSidebar/variants/SettingsSidebar.tsx` +- `src/i18n/locales/{en,zh}/sessions.json` diff --git a/TASK_SPEC.md b/TASK_SPEC.md new file mode 100644 index 0000000000..3bf62ebfbb --- /dev/null +++ b/TASK_SPEC.md @@ -0,0 +1,79 @@ +# ORG-II ↔ Feishu 联动优化任务(6项 + opus-4.6) + +分支:`simon/orgii-fork`。容器 `orgii-app`(已常驻,Up)内 build 验证。 + +## 🚫 硬约束(违反即失败) + +- 不改动与本任务无关的功能;最小改动原则。 +- 不引入新依赖除非必要(必要时先在 commit message 说明理由)。 +- 飞书发文件/图片走飞书 API(已有 codec/api.rs),不要绕路。 +- 每完成一项,单独 commit,message 用 `feat(E#): ...` 或 `fix(E#): ...`。 +- 不删除现有测试;新功能补单测。 +- 容器内 build:`docker exec orgii-app bash -lc 'cd /work/src-tauri && cargo build 2>&1 | tail -30'`(仅编译 agent-core/相关 crate 即可,全量太慢时用 `-p agent-core`)。 +- 前端改动后 webpack dev server 会热重载(容器内 :1998)。 + +## 工作流程 + +**先调研产出 PLAN.md(每项落点+改法),再逐项实现。** 不要一上来就写代码。 + +--- + +## ① 飞书 session 在 GUI 侧边栏可见 + +- 现状:session schema 已有 `session_type`/`channel`/`chat_id`/`project_id` 列(见 `src-tauri/crates/agent-core/src/core/session/persistence/crud/record.rs`),飞书 session 已落库,但侧边栏不显示。 +- 落点:`src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/`(menuSectionBuilders / sessionGroupHelpers / menuItemBuilders)。 +- 目标:侧边栏新增 "Channels"(或 "飞书")分组,列出 channel-originated(`session_type` = channel)的 session,可点进去查看/续接对话。i18n 各语言补 key(至少 zh/en)。 +- 验证:飞书来一条消息后,刷新侧边栏能看到该 session。 + +## ② 飞书对话 → Work Item 联动(轻量方案 A) + +- 不自动创建。让飞书 channel 跑的 agent **能自主调用** work item 工具。 +- 落点:work item 工具已存在(`src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs`,tool name 见 `tools/names`)。检查飞书 channel 绑定的 agent 是否已具备 ManagementCapability / work item 工具;没有则补上。 +- **要求:工具/命令名简短**。如果现有 tool name 冗长,加一个简短别名(如 `wi` 或 `task`)。 +- 验证:飞书里让 agent "建个 work item 记录 xxx",能成功创建并在 GUI 项目里看到。 + +## ③ 附件双向收发(飞书 ↔ workspace) + +- 发:workspace/agent 产物(图片/文件)→ 飞书,做成 channel 原生 outbound(参考 `api.rs` 已有发送能力 + codec)。 +- 收:飞书发来的图片/文件 → 下载到 session workspace,agent 可访问。 +- 落点:`integrations/channels/feishu/{api.rs,codec.rs,event.rs,channel.rs}`。 +- 验证:双向各跑通一次。 + +## ④ WS 重连健壮性 + +- 已知 bug(实测):暂停/恢复后 reconnecting 状态卡住,不真重连,需重启 org2。 +- 落点:`integrations/channels/feishu/ws.rs`(已有 initial ping 修复 commit a8c378d3)。 +- 改法:指数退避重连 + 暂停恢复(如系统 resume / 长时间无 pong)后强制销毁旧连接重建;reconnecting 状态加超时兜底,超时强制 reset。 +- 验证:模拟断连/卡死后能自动恢复(可在测试里模拟,或说明手动验证步骤)。 + +## ⑤ 跨 channel learnings 融合 + +- 先**验证**:learnings recall 检索是否已跨 session_type 统一(飞书 session 产生的 learnings 与本地 GUI session 的 learnings 是否互相可被检索/引用)。 +- 落点:`src-tauri/crates/agent-core/src/core/definitions/learnings_lookup.rs` + embeddings/rerank(B1 已接 qwen3 本地 embedding 127.0.0.1:9876 / rerank :9877)。 +- 若已统一:在 PLAN.md 说明证据,无需改。若按 channel/session 隔离了:改成统一检索(仍可带 channel 标签,但不应因 channel 不同而漏检)。 +- 验证:飞书产生一条 learning,本地 session 能 recall 到(反之亦然)。 + +## ⑥ GUI 监控面板(quota / cost / context) + +- 数据源:E3/E5 ops 脚本已迁移(见 commit af10dc81,`integrations/ops` 或 ops tools)。ZenMux quota 通过 management API;session cost 来自 `session_token_usage` 表(状态栏 A1 已用,见 `core/session/status_bar.rs`)。 +- 目标:GUI 里一个小面板/卡片展示:ZenMux 5h/7d quota %、PAYG 余额、当前 session 的 token/cost、context 占用。 +- 落点:前端新增组件 + 后端 tauri command 暴露数据(若 ops 已有 command 直接复用)。 +- 验证:面板能显示真实数字(哪怕轮询刷新)。 + +## opus-4.6 模型支持 + +- `model_capabilities.rs` 的 `claude-opus-4` pattern 已覆盖 4.6/4.7/4.8 能力 → capabilities 无需改。 +- 需确认:GUI 模型选择列表能否选到 `claude-opus-4.6`。检查模型列表来源(`src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/sourceItems.tsx` 及后端 key-vault/anthropic provider 暴露的 model 列表)。 +- 若列表是动态从 provider 拉的且 opus-4.6 已在内 → 无需改,PLAN.md 说明。 +- 若是静态列表 → 把 `claude-opus-4.6` 加进去。 +- 注意 ZenMux 的 `:anthropic` slug 习惯(见 clawd/TOOLS.md),但本任务是 ORG-II 原生 anthropic provider,按 ORG-II 既有约定来。 +- 验证:GUI 能选中 opus-4.6 并成功发一条消息。 + +--- + +## 交付 + +1. `PLAN.md`(每项落点+改法+验证结论) +2. 逐项 commit 实现 +3. 容器内 build 通过 +4. 最后写 `RESULT.md`:每项做了什么、改了哪些文件、怎么验证、还剩什么没验证。 diff --git a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs index afa033bbe4..e1ecf44b29 100644 --- a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs +++ b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use tracing::{info, warn}; use crate::bus::{InboundMessage, OutboundMessage}; +use crate::foundation::persistence::images::load_image_as_data_url; use crate::session::persistence as unified_persistence; use crate::session::IdeContext; @@ -109,15 +110,35 @@ pub async fn process_gateway_message( } } + let images = if msg.media.is_empty() { + None + } else { + let data_urls: Vec = msg + .media + .iter() + .filter_map(|m| { + if m.starts_with("data:") { + Some(m.clone()) + } else { + load_image_as_data_url(m).or_else(|| { + warn!("[agent-loop] media is not loadable as image data URL: {}", m); + None + }) + } + }) + .collect(); + if data_urls.is_empty() { + None + } else { + Some(data_urls) + } + }; + let input = super::turn::TurnInput { content: msg.content.clone(), display_text: None, agent_mode: None, - images: if msg.media.is_empty() { - None - } else { - Some(msg.media.clone()) - }, + images, ide_context: ide_context.cloned(), is_resume: false, channel: Some(msg.channel.clone()), diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs index f54eb6ca6f..2bf699462c 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/db_helpers/messages/load_llm.rs @@ -301,7 +301,30 @@ fn reconstruct(messages: &[AgentMessageRow]) -> Vec { result.append(tool_results); }; - for msg in messages { + // Avoid resending every historical image on every turn: base64 image + // payloads are large and can exceed gateway/body limits quickly. Preserve + // multimodal content for the most recent image-bearing user message, and + // render older image messages as text-only history. + let last_image_msg_index = messages + .iter() + .enumerate() + .rev() + .find_map(|(idx, msg)| { + if msg.role == message_role::USER + && msg + .images + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .map(|refs| !refs.is_empty()) + .unwrap_or(false) + { + Some(idx) + } else { + None + } + }); + + for (msg_idx, msg) in messages.iter().enumerate() { match msg.role.as_str() { message_role::SYSTEM => { flush_pending( @@ -321,9 +344,10 @@ fn reconstruct(messages: &[AgentMessageRow]) -> Vec { &mut pending_tool_results, ); - if let Some(images_json) = &msg.images { - if let Ok(image_refs) = serde_json::from_str::>(images_json) { - if !image_refs.is_empty() { + if last_image_msg_index == Some(msg_idx) { + if let Some(images_json) = &msg.images { + if let Ok(image_refs) = serde_json::from_str::>(images_json) { + if !image_refs.is_empty() { result.push(serde_json::json!({ "role": message_role::USER, "content": build_multimodal_content(&msg.content, &image_refs), @@ -332,6 +356,7 @@ fn reconstruct(messages: &[AgentMessageRow]) -> Vec { } } } + } result.push(serde_json::json!({ "role": message_role::USER, "content": msg.content, diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs index fadb298f98..f0acb71836 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/api.rs @@ -135,6 +135,20 @@ pub(super) async fn send_feishu_message( // ── Media Upload/Download ─────────────────────────────────────────────── +fn image_ext_from_bytes(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + "jpg" + } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "png" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "webp" + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + "gif" + } else { + "bin" + } +} + /// Download an image from Feishu by image_key. Returns raw bytes. pub(super) async fn download_image( auth: &FeishuAuth, @@ -165,6 +179,45 @@ pub(super) async fn download_image( .map_err(|err| ChannelError::Other(format!("Read image bytes failed: {}", err))) } +/// Download an image resource from a specific Feishu message. Rich-text/post +/// image elements are message resources, and `/im/v1/images/{image_key}` can +/// reject them with 400. +pub(super) async fn download_message_image( + auth: &FeishuAuth, + message_id: &str, + image_key: &str, +) -> Result, ChannelError> { + let token = auth.get_token().await?; + let url = format!( + "{}/im/v1/messages/{}/resources/{}?type=image", + auth.api_base(), + message_id, + image_key + ); + + let res = auth + .client() + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|err| ChannelError::Other(format!("Download message image failed: {}", err)))?; + + if !res.status().is_success() { + return Err(ChannelError::Other(format!( + "Download message image HTTP {}: message_id={}, image_key={}", + res.status(), + message_id, + image_key + ))); + } + + res.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|err| ChannelError::Other(format!("Read message image bytes failed: {}", err))) +} + /// Download a file from Feishu by file_key. Returns raw bytes. pub(super) async fn download_file( auth: &FeishuAuth, @@ -204,25 +257,32 @@ pub(super) async fn resolve_feishu_media( ) { let images_dir = app_paths::session_images_dir(); let _ = std::fs::create_dir_all(&images_dir); + tracing::info!("[feishu:debug] resolve_feishu_media entered, media_count={}, dir={}", media.len(), images_dir.display()); for entry in media.iter_mut() { - if let Some(image_key) = entry.strip_prefix("feishu:image:") { - let image_key = image_key.to_string(); - match download_image(auth, &image_key).await { + if let Some(image_ref) = entry.strip_prefix("feishu:image:") { + let image_ref = image_ref.to_string(); + let download_result = if let Some((message_id, image_key)) = image_ref.split_once(':') { + download_message_image(auth, message_id, image_key).await + } else { + download_image(auth, &image_ref).await + }; + match download_result { Ok(bytes) => { let hash = sha256_hex(&bytes); - let filename = format!("{}.png", &hash[..16]); + let ext = image_ext_from_bytes(&bytes); + let filename = format!("{}.{}", &hash[..16], ext); let path = images_dir.join(&filename); if !path.exists() { if let Err(err) = std::fs::write(&path, &bytes) { - warn!("[feishu] Failed to persist image {}: {}", image_key, err); + warn!("[feishu] Failed to persist image {}: {}", image_ref, err); continue; } } *entry = path.to_string_lossy().to_string(); } Err(err) => { - warn!("[feishu] Failed to download image {}: {}", image_key, err); + warn!("[feishu] Failed to download image {}: {}", image_ref, err); } } } else if let Some(file_key) = entry.strip_prefix("feishu:file:") { diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs index 18cc4e0a0c..50c65ccff0 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs @@ -266,7 +266,7 @@ impl Channel for FeishuChannel { _chat_id: &str, message_id: &str, ) -> Result<(), ChannelError> { - api::add_reaction(&self.auth, message_id, "PROCESSING").await + api::add_reaction(&self.auth, message_id, "Typing").await } async fn on_processing_end( @@ -274,7 +274,7 @@ impl Channel for FeishuChannel { _chat_id: &str, message_id: &str, ) -> Result<(), ChannelError> { - api::remove_reaction(&self.auth, message_id, "PROCESSING").await + api::remove_reaction(&self.auth, message_id, "Typing").await } async fn update_message(&self, message_id: &str, content: &str) -> Result<(), ChannelError> { diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs index f380ea3c9d..6c5345d4b2 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/event.rs @@ -78,6 +78,7 @@ pub(super) fn parse_feishu_event( .get("message_type") .and_then(|m| m.as_str()) .unwrap_or("text"); + tracing::info!("[feishu:debug] message_type={:?} raw_content_preview", message_type); let sender_id = sender .get("sender_id") .and_then(|s| s.get("open_id")) @@ -180,12 +181,26 @@ pub(super) fn parse_feishu_event( // Store image/file keys in media vec if message_type == "image" { + tracing::info!("[feishu:debug] image msg raw_content={}", raw_content); if let Some(key) = parse_content_json(raw_content).and_then(|v| { v.get("image_key") .and_then(|k| k.as_str()) .map(|s| s.to_string()) }) { - inbound.media.push(format!("feishu:image:{}", key)); + tracing::info!("[feishu:debug] extracted image_key, media push"); + inbound.media.push(format!("feishu:image:{}:{}", message_id, key)); + } else { + tracing::warn!("[feishu:debug] FAILED to extract image_key from raw_content"); + } + } else if message_type == "post" { + // Rich-text (post) messages can embed images as `img` elements + // carrying an `image_key`. Collect them so they get downloaded too. + if let Some(parsed) = parse_content_json(raw_content) { + let mut keys = Vec::new(); + collect_post_image_keys(&parsed, &mut keys); + for key in keys { + inbound.media.push(format!("feishu:image:{}:{}", message_id, key)); + } } } else if message_type == "file" { if let Some(key) = parse_content_json(raw_content).and_then(|v| { @@ -241,6 +256,34 @@ fn parse_content_json(raw: &str) -> Option { serde_json::from_str(raw).ok() } +/// Walk a Feishu "post" content tree and collect all embedded `img` element +/// `image_key`s (locale roots zh_cn/en_us/ja_jp, then paragraphs of elements). +fn collect_post_image_keys(parsed: &Value, out: &mut Vec) { + let content_root = parsed + .get("zh_cn") + .or_else(|| parsed.get("en_us")) + .or_else(|| parsed.get("ja_jp")) + .unwrap_or(parsed); + + if let Some(paragraphs) = content_root.get("content").and_then(|c| c.as_array()) { + for paragraph in paragraphs { + if let Some(elements) = paragraph.as_array() { + for element in elements { + let tag = element.get("tag").and_then(|t| t.as_str()).unwrap_or(""); + if tag == "img" { + if let Some(key) = + element.get("image_key").and_then(|k| k.as_str()) + { + out.push(key.to_string()); + } + } + } + } + } + } +} + + /// Flatten Feishu "post" rich text to plain text. fn flatten_post_content(parsed: &Value) -> String { let content_root = parsed diff --git a/src-tauri/crates/agent-core/src/integrations/channels/tests/feishu_event_tests.rs b/src-tauri/crates/agent-core/src/integrations/channels/tests/feishu_event_tests.rs index ef8fb14d80..80359de1d2 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/tests/feishu_event_tests.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/tests/feishu_event_tests.rs @@ -367,7 +367,41 @@ fn image_message_extracts_media_key() { let msg = parse_feishu_event(&payload, "test", &config, &mut dedup, &mut dedup_order).unwrap(); assert_eq!(msg.content, "[image]"); assert_eq!(msg.media.len(), 1); - assert_eq!(msg.media[0], "feishu:image:img-v2-abc"); + assert_eq!(msg.media[0], "feishu:image:msg_img_1:img-v2-abc"); +} + +#[test] +fn post_message_extracts_embedded_image_keys() { + let config = default_config(); + let mut dedup = HashSet::new(); + let mut dedup_order = Vec::new(); + let payload = json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_id": "msg_post_1", + "chat_id": "chat_1", + "chat_type": "p2p", + "message_type": "post", + "content": json!({ + "zh_cn": { + "content": [[ + {"tag": "text", "text": "看图"}, + {"tag": "img", "image_key": "img-post-abc"} + ]] + } + }).to_string(), + }, + "sender": { + "sender_type": "user", + "sender_id": { "open_id": "user_1" }, + } + } + }); + let msg = parse_feishu_event(&payload, "test", &config, &mut dedup, &mut dedup_order).unwrap(); + assert_eq!(msg.content.trim(), "看图[image]"); + assert_eq!(msg.media.len(), 1); + assert_eq!(msg.media[0], "feishu:image:msg_post_1:img-post-abc"); } #[test] diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs index a5811c2ce7..166800e5c2 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs @@ -125,18 +125,32 @@ async fn handle_ready_message( .and_then(|mgr| mgr.typing_refresh_interval_for(&msg.channel)) }; + let message_id = msg + .metadata + .get("message_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if !message_id.is_empty() { + let cm_lock = channel_manager.lock().await; + if let Some(ref mgr) = *cm_lock { + mgr.notify_processing_start(&msg.channel, &msg.chat_id, &message_id) + .await; + } + } + let typing_task: Option> = typing_interval.map(|interval| { let cm = channel_manager.clone(); let channel_name = msg.channel.clone(); let chat_id = msg.chat_id.clone(); - let message_id = msg - .metadata - .get("message_id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let message_id = message_id.clone(); tokio::spawn(async move { loop { + tokio::time::sleep(interval).await; + if message_id.is_empty() { + continue; + } { let cm_lock = cm.lock().await; if let Some(ref mgr) = *cm_lock { @@ -144,7 +158,6 @@ async fn handle_ready_message( .await; } } - tokio::time::sleep(interval).await; } }) }); @@ -170,15 +183,12 @@ async fn handle_ready_message( task.abort(); } - let message_id = msg - .metadata - .get("message_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let cm_lock = channel_manager.lock().await; - if let Some(ref mgr) = *cm_lock { - mgr.notify_processing_end(&msg.channel, &msg.chat_id, message_id) - .await; + if !message_id.is_empty() { + let cm_lock = channel_manager.lock().await; + if let Some(ref mgr) = *cm_lock { + mgr.notify_processing_end(&msg.channel, &msg.chat_id, &message_id) + .await; + } } } diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs index 21094591d1..fc295820ad 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs @@ -10,6 +10,7 @@ use tracing::{info, warn}; use crate::bus::{InboundMessage, OutboundMessage}; use crate::definitions::{os_agent, OS_AGENT_ID}; +use crate::definitions::prefix_lookup::SDE_SESSION_PREFIX; use crate::gateway::{parse_command, InboundMessageHandler, InboundProcessorDeps, SessionKey}; use crate::interaction::permission::AgentPermissionManager; use crate::interaction::question::QuestionManager; @@ -83,6 +84,16 @@ impl InboundMessageHandler for GatewayInboundHandler { caller must set it before publishing to REINJECT_CHANNEL" .to_string()); }; + // Re-injected messages (e.g. after media download) target an + // already-derived OS session id. The original buffering path may + // have minted a fresh -v{n} id without registering it yet, so the + // subsequent `init_channel_session` lookup would fail with + // "channel session '…' not registered". Mirror Branch 3 and ensure + // OS sessions are registered before dispatch. (SDE sessions manage + // their own lifecycle and are skipped.) + if !target_session_id.starts_with(SDE_SESSION_PREFIX) { + ensure_os_session_registered(&state, &target_session_id).await; + } return dispatch_to_session( &state, account_id.as_deref(), @@ -272,8 +283,6 @@ async fn dispatch_to_session( _question_manager: &Arc, _permission_manager: &Arc, ) -> Result, String> { - use crate::definitions::prefix_lookup::SDE_SESSION_PREFIX; - let (gw_account, gw_model) = resolve_gateway_model_and_account(state).await; let effective_account = account_id.or(gw_account.as_deref()); From 22985d84409e6406e452fa4c59940b5af0b8597e Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Fri, 26 Jun 2026 19:50:23 +0800 Subject: [PATCH 015/864] fix(feishu): clear typing reaction after delivery Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/integrations/gateway/workers.rs | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs index 166800e5c2..135d9a6b49 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs @@ -4,6 +4,7 @@ //! entry point stays focused on wiring rather than per-task logic. use std::sync::atomic::{AtomicBool, Ordering}; +use serde_json::Value; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{error, info, warn}; @@ -162,20 +163,45 @@ async fn handle_ready_message( }) }); + let mut remove_processing_on_handler_done = true; + match handler.handle_message(msg.clone()).await { - Ok(Some(response)) => { + Ok(Some(mut response)) => { + if !message_id.is_empty() { + response.metadata.insert( + "processing_reaction_chat_id".to_string(), + Value::String(msg.chat_id.clone()), + ); + response.metadata.insert( + "processing_reaction_message_id".to_string(), + Value::String(message_id.clone()), + ); + remove_processing_on_handler_done = false; + } let bus_lock = bus.lock().await; bus_lock.publish_outbound(response); } Ok(None) => {} Err(err_msg) => { error!("[gateway] Error processing message: {}", err_msg); - let bus_lock = bus.lock().await; - bus_lock.publish_outbound(OutboundMessage::new( + let mut response = OutboundMessage::new( &msg.channel, &msg.chat_id, &format!("Sorry, I encountered an error: {}", err_msg), - )); + ); + if !message_id.is_empty() { + response.metadata.insert( + "processing_reaction_chat_id".to_string(), + Value::String(msg.chat_id.clone()), + ); + response.metadata.insert( + "processing_reaction_message_id".to_string(), + Value::String(message_id.clone()), + ); + remove_processing_on_handler_done = false; + } + let bus_lock = bus.lock().await; + bus_lock.publish_outbound(response); } } @@ -183,7 +209,7 @@ async fn handle_ready_message( task.abort(); } - if !message_id.is_empty() { + if remove_processing_on_handler_done && !message_id.is_empty() { let cm_lock = channel_manager.lock().await; if let Some(ref mgr) = *cm_lock { mgr.notify_processing_end(&msg.channel, &msg.chat_id, &message_id) @@ -234,6 +260,20 @@ pub(super) async fn spawn_outbound_dispatcher( crate::utils::safe_truncate_chars_to_string(&outbound_msg.content, 60) ); + let processing_reaction = outbound_msg + .metadata + .get("processing_reaction_message_id") + .and_then(|v| v.as_str()) + .map(|message_id| { + let chat_id = outbound_msg + .metadata + .get("processing_reaction_chat_id") + .and_then(|v| v.as_str()) + .unwrap_or(&outbound_msg.chat_id) + .to_string(); + (chat_id, message_id.to_string()) + }); + let cm_lock = channel_manager.lock().await; if let Some(ref manager) = *cm_lock { if let Err(err) = manager.send_to_with_delivery(&outbound_msg).await { @@ -242,6 +282,11 @@ pub(super) async fn spawn_outbound_dispatcher( outbound_msg.channel, err ); } + if let Some((chat_id, message_id)) = processing_reaction { + manager + .notify_processing_end(&outbound_msg.channel, &chat_id, &message_id) + .await; + } } drop(cm_lock); } From 3bf7a40154a1022046887007903c0ddfd162c4bb Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Fri, 26 Jun 2026 20:06:34 +0800 Subject: [PATCH 016/864] fix(feishu): preserve typing reaction across reinject Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/integrations/gateway/workers.rs | 50 ++++++++++++++++--- .../commands/channel_handler/dispatch.rs | 6 +++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs index 135d9a6b49..cf89e65e9b 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/workers.rs @@ -129,22 +129,42 @@ async fn handle_ready_message( let message_id = msg .metadata .get("message_id") + .or_else(|| msg.metadata.get("source_message_id")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); + let reaction_channel = msg + .metadata + .get("source_channel") + .and_then(|v| v.as_str()) + .unwrap_or(&msg.channel) + .to_string(); + let reaction_chat_id = msg + .metadata + .get("source_chat_id") + .and_then(|v| v.as_str()) + .unwrap_or(&msg.chat_id) + .to_string(); + if !message_id.is_empty() { + info!( + channel = %reaction_channel, + chat_id = %reaction_chat_id, + message_id = %message_id, + "[gateway] processing reaction start" + ); let cm_lock = channel_manager.lock().await; if let Some(ref mgr) = *cm_lock { - mgr.notify_processing_start(&msg.channel, &msg.chat_id, &message_id) + mgr.notify_processing_start(&reaction_channel, &reaction_chat_id, &message_id) .await; } } let typing_task: Option> = typing_interval.map(|interval| { let cm = channel_manager.clone(); - let channel_name = msg.channel.clone(); - let chat_id = msg.chat_id.clone(); + let channel_name = reaction_channel.clone(); + let chat_id = reaction_chat_id.clone(); let message_id = message_id.clone(); tokio::spawn(async move { loop { @@ -170,7 +190,7 @@ async fn handle_ready_message( if !message_id.is_empty() { response.metadata.insert( "processing_reaction_chat_id".to_string(), - Value::String(msg.chat_id.clone()), + Value::String(reaction_chat_id.clone()), ); response.metadata.insert( "processing_reaction_message_id".to_string(), @@ -181,7 +201,11 @@ async fn handle_ready_message( let bus_lock = bus.lock().await; bus_lock.publish_outbound(response); } - Ok(None) => {} + Ok(None) => { + if !message_id.is_empty() && msg.metadata.contains_key("source_message_id") { + remove_processing_on_handler_done = false; + } + } Err(err_msg) => { error!("[gateway] Error processing message: {}", err_msg); let mut response = OutboundMessage::new( @@ -192,7 +216,7 @@ async fn handle_ready_message( if !message_id.is_empty() { response.metadata.insert( "processing_reaction_chat_id".to_string(), - Value::String(msg.chat_id.clone()), + Value::String(reaction_chat_id.clone()), ); response.metadata.insert( "processing_reaction_message_id".to_string(), @@ -212,7 +236,13 @@ async fn handle_ready_message( if remove_processing_on_handler_done && !message_id.is_empty() { let cm_lock = channel_manager.lock().await; if let Some(ref mgr) = *cm_lock { - mgr.notify_processing_end(&msg.channel, &msg.chat_id, &message_id) + info!( + channel = %reaction_channel, + chat_id = %reaction_chat_id, + message_id = %message_id, + "[gateway] processing reaction end after handler" + ); + mgr.notify_processing_end(&reaction_channel, &reaction_chat_id, &message_id) .await; } } @@ -283,6 +313,12 @@ pub(super) async fn spawn_outbound_dispatcher( ); } if let Some((chat_id, message_id)) = processing_reaction { + info!( + channel = %outbound_msg.channel, + chat_id = %chat_id, + message_id = %message_id, + "[gateway] processing reaction end after delivery" + ); manager .notify_processing_end(&outbound_msg.channel, &chat_id, &message_id) .await; diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs index fc295820ad..bea7a9ec81 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs @@ -209,6 +209,12 @@ impl InboundMessageHandler for GatewayInboundHandler { "source_chat_id".to_string(), serde_json::Value::String(msg.chat_id.clone()), ); + if let Some(message_id) = msg.metadata.get("message_id").and_then(|v| v.as_str()) { + inbound.metadata.insert( + "source_message_id".to_string(), + serde_json::Value::String(message_id.to_string()), + ); + } inbound.media = msg.media.clone(); let sender = { From ae1cad6a56e00d706c38fee81abdfcba2c486b75 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Fri, 26 Jun 2026 20:25:18 +0800 Subject: [PATCH 017/864] fix(stats): set channel on imported history records Pre-commit hook ran. Total eslint: 3, total circular: 0 --- src-tauri/src/agent_sessions/unified_stats/conversion.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/agent_sessions/unified_stats/conversion.rs b/src-tauri/src/agent_sessions/unified_stats/conversion.rs index 77a1fae39f..afb45e5818 100644 --- a/src-tauri/src/agent_sessions/unified_stats/conversion.rs +++ b/src-tauri/src/agent_sessions/unified_stats/conversion.rs @@ -215,6 +215,7 @@ pub fn imported_history_to_aggregate_record( lines_added: Some(row.lines_added), lines_removed: Some(row.lines_removed), touched_files: Some(row.touched_files), + channel: None, } } @@ -269,6 +270,7 @@ pub fn cursor_ide_history_to_aggregate_record( lines_added: Some(row.lines_added), lines_removed: Some(row.lines_removed), touched_files: Some(row.touched_files), + channel: None, } } From cf6eeb37d6367d0cfffb5af009693f210157eb70 Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:41:17 -0700 Subject: [PATCH 018/864] fix: restore fast tauri build Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../agent-core/src/core/session/gateway_pipeline.rs | 9 +-------- .../src/integrations/channels/feishu/channel.rs | 3 --- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs index e1ecf44b29..22a7e1a1dc 100644 --- a/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs +++ b/src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs @@ -222,14 +222,7 @@ pub async fn process_gateway_message( match result { Ok(processing_result) => { - let content = crate::session::status_bar::append_status_bar_for_channel( - &msg.channel, - processing_result.content.clone(), - &session, - processing_result.total_tokens, - processing_result.context_tokens, - ) - .await; + let content = processing_result.content; let out_preview: String = crate::utils::safe_truncate_chars_to_string(&content, 80); info!( "[agent-loop] Response for {}:{}: {}...", diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs index 50c65ccff0..31c5bbae12 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs @@ -190,8 +190,6 @@ impl Channel for FeishuChannel { let app_secret = self.config.app_secret.clone(); let api_base = self.auth.api_base().to_string(); let http_client = self.auth.client().clone(); - let auth = self.auth.clone(); - let handle = tokio::spawn(async move { ws::feishu_ws_loop( ws_url, @@ -206,7 +204,6 @@ impl Channel for FeishuChannel { inbound_tx, channel_name, event_config, - auth, ) .await; }); From 88165573e9d4277e62d1abdab2f4286531d0b203 Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:40:53 -0700 Subject: [PATCH 019/864] fix: wire feishu media downloads Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src-tauri/crates/agent-core/src/core/tools/params.rs | 2 ++ .../agent-core/src/integrations/channels/feishu/channel.rs | 2 ++ .../crates/agent-core/src/integrations/channels/feishu/ws.rs | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/agent-core/src/core/tools/params.rs b/src-tauri/crates/agent-core/src/core/tools/params.rs index 3da6a56fba..b9d5206af5 100644 --- a/src-tauri/crates/agent-core/src/core/tools/params.rs +++ b/src-tauri/crates/agent-core/src/core/tools/params.rs @@ -405,12 +405,14 @@ mod tests { /// `$ref: "#/definitions/Nested"`. Mirrors `StepProposal` in /// `suggest_next_steps`. With `inline_subschemas = true` it must be /// expanded in place with no `$ref` anywhere. + #[allow(dead_code)] #[derive(Debug, Deserialize, JsonSchema)] struct Nested { title: String, command: String, } + #[allow(dead_code)] #[derive(Debug, Deserialize, JsonSchema)] struct NestingParams { items: Vec, diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs index 31c5bbae12..0cabc8e3ff 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/channel.rs @@ -190,6 +190,7 @@ impl Channel for FeishuChannel { let app_secret = self.config.app_secret.clone(); let api_base = self.auth.api_base().to_string(); let http_client = self.auth.client().clone(); + let auth = self.auth.clone(); let handle = tokio::spawn(async move { ws::feishu_ws_loop( ws_url, @@ -204,6 +205,7 @@ impl Channel for FeishuChannel { inbound_tx, channel_name, event_config, + auth, ) .await; }); diff --git a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs index 6319cf8ec8..db2e4d611e 100644 --- a/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs +++ b/src-tauri/crates/agent-core/src/integrations/channels/feishu/ws.rs @@ -16,6 +16,7 @@ use std::time::Duration; use tokio::sync::{mpsc, RwLock}; use tracing::{debug, error, info, warn}; +use super::auth::FeishuAuth; use super::channel::{self, WsClientConfig}; use super::codec::*; use super::event::{self, FeishuEventConfig}; @@ -35,6 +36,7 @@ pub(super) async fn feishu_ws_loop( inbound_tx: mpsc::Sender, channel_name: String, event_config: FeishuEventConfig, + auth: Arc, ) { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -191,13 +193,14 @@ pub(super) async fn feishu_ws_loop( if let Some(payload) = payload_bytes { if let Ok(event_json) = serde_json::from_slice::(&payload) { - if let Some(inbound) = event::parse_feishu_event( + if let Some(mut inbound) = event::parse_feishu_event( &event_json, &channel_name, &event_config, &mut dedup_set, &mut dedup_order, ) { + super::api::resolve_feishu_media(&auth, &mut inbound.media).await; info!("[{}] Sending inbound to bus: session_key={}", channel_name, inbound.session_key()); if let Err(err) = inbound_tx.send(inbound).await { error!("[{}] Failed to send inbound: {}", channel_name, err); From c6ca60893c37a2f01e48d181467add978145f580 Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:13:25 -0700 Subject: [PATCH 020/864] fix(agent): route tools through single executor Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../crates/agent-core/src/ARCHITECTURE.md | 2 +- .../src/core/interaction/plan_approval/mod.rs | 358 ++++++++++- .../agent-core/src/core/turn_executor/mod.rs | 142 +---- .../core/turn_executor/streaming_executor.rs | 569 ------------------ .../core/turn_executor/tool_execution/mod.rs | 19 +- .../turn_executor/tool_execution/parallel.rs | 2 +- src-tauri/src/lib.rs | 7 +- 7 files changed, 377 insertions(+), 722 deletions(-) delete mode 100644 src-tauri/crates/agent-core/src/core/turn_executor/streaming_executor.rs diff --git a/src-tauri/crates/agent-core/src/ARCHITECTURE.md b/src-tauri/crates/agent-core/src/ARCHITECTURE.md index 4eddd0bc2b..0ec7090ffe 100644 --- a/src-tauri/crates/agent-core/src/ARCHITECTURE.md +++ b/src-tauri/crates/agent-core/src/ARCHITECTURE.md @@ -122,7 +122,7 @@ Source: `core/turn_executor/`, broken down as: | File / dir | Role | | -------------------------- | -------------------------------------------------------------- | | `mod.rs` | `execute_turn` entry point + the loop body | -| `streaming_executor.rs` | Wires the provider event-stream into the loop | +| `stream_normalizer.rs` | Normalizes provider stream events for live UI updates | | `tool_execution/` | Tool-call dispatch + parallel batch handling | | `tool_result_storage.rs` | Persists tool results into the session DB | | `screenshot.rs` | Vision-block injection (parent screenshots, OS Agent context) | diff --git a/src-tauri/crates/agent-core/src/core/interaction/plan_approval/mod.rs b/src-tauri/crates/agent-core/src/core/interaction/plan_approval/mod.rs index 9121e552cc..47174b2ef7 100644 --- a/src-tauri/crates/agent-core/src/core/interaction/plan_approval/mod.rs +++ b/src-tauri/crates/agent-core/src/core/interaction/plan_approval/mod.rs @@ -28,7 +28,7 @@ //! `clear_silently`) performs its DB write inside the same `pending` mutex //! guard that gates the in-memory mutation, so memory and DB cannot split. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; @@ -336,10 +336,8 @@ impl PlanApprovalManager { .map(PendingPlanApproval::from_row) } }; - if let Some(prev) = prev { - let sid = prev.session_id.clone(); - persist_blocking(move || PlanApprovalStore::delete_by_session(&sid)).await; - self.push_plan_approval_event(&prev, "archive", PlanApprovalCardStatus::Archived); + if let Some(prev) = prev.as_ref() { + self.push_plan_approval_event(prev, "archive", PlanApprovalCardStatus::Archived); // Backend-authoritative finalize of the superseded revision's // awaiting_user events (same contract as `resolve_pending`). if let Some(handle) = self.app_handle.lock().ok().and_then(|guard| guard.clone()) { @@ -374,8 +372,8 @@ impl PlanApprovalManager { plan_content: plan_content.to_string(), created_at_ms, }; + let previous_session_id = prev.map(|prev| prev.session_id); let row = snapshot.to_row(); - persist_blocking(move || PlanApprovalStore::upsert(&row)).await; *guard = Some(snapshot.clone()); drop(guard); @@ -411,6 +409,10 @@ impl PlanApprovalManager { created_at_ms, ); + tokio::spawn(async move { + persist_ready_row(previous_session_id, row).await; + }); + info!( "[plan_approval] Plan ready (session={}, path={})", snapshot.session_id, snapshot.plan_path @@ -838,6 +840,174 @@ pub async fn gc_orphaned_pending_plans() { } } +async fn persist_ready_row(previous_session_id: Option, row: PendingPlanRow) { + if let Some(sid) = previous_session_id { + persist_blocking(move || PlanApprovalStore::delete_by_session(&sid)).await; + } + persist_blocking(move || PlanApprovalStore::upsert(&row)).await; +} + +/// Startup repair for half-committed `create_plan` calls. +/// +/// Covers the failure window where `create_plan` wrote the plan file and the +/// tool-call event, but the process was stopped before `mark_ready` inserted a +/// pending row and before the tool_result was persisted. +pub async fn repair_orphaned_create_plan_submissions() { + let repaired = + match tokio::task::spawn_blocking(repair_orphaned_create_plan_submissions_sync).await { + Ok(Ok(count)) => count, + Ok(Err(err)) => { + warn!("[plan_approval] orphan create_plan repair failed: {err}"); + return; + } + Err(err) => { + warn!("[plan_approval] orphan create_plan repair join error: {err}"); + return; + } + }; + + if repaired > 0 { + info!("[plan_approval] Repaired {repaired} orphaned create_plan submission(s)"); + } +} + +#[derive(Debug)] +struct OrphanCreatePlanSubmission { + session_id: String, + tool_call_id: String, + title: String, + content: String, + workspace_path: Option, + created_at_ms: i64, + pending_created_at_ms: Option, +} + +fn repair_orphaned_create_plan_submissions_sync( +) -> Result> { + let conn = database::db::get_connection()?; + let mut stmt = conn.prepare( + "SELECT e.session_id, + e.id, + e.args_json, + e.created_at, + s.workspace_path, + p.created_at + FROM events e + JOIN session_turns t + ON t.session_id = e.session_id + AND t.status = 'pending' + AND e.history_sequence >= t.start_sequence + AND (t.end_sequence IS NULL OR e.history_sequence <= t.end_sequence) + LEFT JOIN agent_sessions s ON s.session_id = e.session_id + LEFT JOIN pending_plan_approvals p ON p.session_id = e.session_id + WHERE e.function_name = 'create_plan' + AND e.event_type = 'tool_call' + AND (p.session_id IS NULL OR e.created_at > datetime(p.created_at / 1000, 'unixepoch')) + AND NOT EXISTS ( + SELECT 1 FROM events r + WHERE r.session_id = e.session_id + AND r.event_type = 'tool_result' + AND json_extract(r.meta_json, '$.callId') = json_extract(e.meta_json, '$.callId') + ) + ORDER BY e.session_id ASC, e.history_sequence DESC", + )?; + + let rows = stmt + .query_map([], |row| { + let event_id: String = row.get(1)?; + let args_json: String = row.get(2)?; + let args: serde_json::Value = serde_json::from_str(&args_json).unwrap_or_default(); + let title = args + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or("Plan") + .to_string(); + let content = args + .get("content") + .or_else(|| args.get("streamContent")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + Ok(OrphanCreatePlanSubmission { + session_id: row.get(0)?, + tool_call_id: event_id + .strip_prefix("tool-call-") + .unwrap_or(&event_id) + .to_string(), + title, + content, + workspace_path: row.get(4)?, + created_at_ms: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) + .map(|dt| dt.timestamp_millis()) + .unwrap_or_else(|_| chrono::Utc::now().timestamp_millis()), + pending_created_at_ms: row.get(5)?, + }) + })? + .collect::>>()?; + + let mut repaired = 0usize; + let mut seen_sessions = std::collections::HashSet::new(); + for row in rows { + if !seen_sessions.insert(row.session_id.clone()) { + continue; + } + if row.content.is_empty() + || row + .pending_created_at_ms + .is_some_and(|created| row.created_at_ms <= created) + { + continue; + } + let Some(plan_path) = find_existing_plan_path(&row) else { + continue; + }; + let plan_path = plan_path.to_string_lossy().into_owned(); + let plan_id = plan_id_for(&row.session_id, &plan_path); + let plan_revision_id = revision_id_for(Some(&row.tool_call_id), &plan_id); + let pending = PendingPlanRow { + session_id: row.session_id, + tool_call_id: Some(plan_revision_id.clone()), + plan_id, + plan_revision_id, + origin_tool_call_id: Some(row.tool_call_id), + plan_path, + plan_title: row.title, + plan_content: row.content, + created_at_ms: row.created_at_ms, + }; + PlanApprovalStore::upsert(&pending)?; + repaired += 1; + } + + Ok(repaired) +} + +fn find_existing_plan_path(row: &OrphanCreatePlanSubmission) -> Option { + let workspace = row.workspace_path.as_deref().map(Path::new)?; + let dir = workspace.join(".orgii").join("plans"); + let slug = crate::session::plan_mode::slugify_plan_title(&row.title); + let prefix = format!("{slug}_"); + let mut candidates = std::fs::read_dir(dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".plan.md")) + }) + .collect::>(); + candidates.sort_by_key(|path| { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + }); + candidates + .into_iter() + .rev() + .find(|path| std::fs::read_to_string(path).is_ok_and(|content| content == row.content)) +} + /// List every live pending plan's revision id. Used by the startup repair /// scan to distinguish legitimately-awaiting `create_plan` events from /// historical strands whose row is gone. @@ -871,6 +1041,19 @@ mod tests { // guard also clears the `pending_plan_approvals` table so each test // starts from a clean slate. + async fn wait_for_pending_row(session_id: &str) { + for _ in 0..20 { + if PlanApprovalStore::load_by_session(session_id) + .unwrap() + .is_some() + { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("pending plan row was not persisted for {session_id}"); + } + #[tokio::test] async fn mark_ready_then_take_returns_snapshot() { let _lock = lock_and_prepare(); @@ -981,6 +1164,7 @@ mod tests { Some("call_9"), ) .await; + wait_for_pending_row(session_id).await; assert!(mgr.is_pending().await); } @@ -1004,6 +1188,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; let _ = mgr.take_pending().await; let fresh = PlanApprovalManager::new(); @@ -1021,6 +1206,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; mgr.clear_silently().await; assert!(!mgr.is_pending().await, "memory slot must be dropped"); @@ -1044,6 +1230,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; std::fs::remove_file(&plan_path).unwrap(); @@ -1075,6 +1262,7 @@ mod tests { Some("call_x"), ) .await; + wait_for_pending_row(session_id).await; let snap = super::load_snapshot_for_session(session_id) .await @@ -1095,6 +1283,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; std::fs::remove_file(&plan_path).unwrap(); assert!(super::load_snapshot_for_session(session_id) @@ -1111,7 +1300,97 @@ mod tests { .is_none()); } + fn seed_orphan_create_plan_event( + session_id: &str, + call_id: &str, + title: &str, + content: &str, + workspace_path: &Path, + sequence: i64, + created_at: &str, + ) { + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + function_name TEXT, + thread_id TEXT, + args_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + meta_json TEXT, + history_sequence INTEGER, + UNIQUE(id, session_id) + ); + CREATE TABLE IF NOT EXISTS session_turns ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + start_sequence INTEGER NOT NULL, + end_sequence INTEGER, + next_turn_id TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_ms INTEGER, + user_event_ids_json TEXT NOT NULL DEFAULT '[]', + user_preview TEXT NOT NULL DEFAULT '', + event_count INTEGER NOT NULL DEFAULT 0, + body_event_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + interrupted INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + modified_files_json TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (session_id, turn_id) + ); + "#, + ) + .expect("session event schema"); + let args_json = serde_json::json!({ + "title": title, + "content": content, + }) + .to_string(); + let meta_json = serde_json::json!({ + "callId": call_id, + }) + .to_string(); + + conn.execute( + "INSERT OR REPLACE INTO events + (id, session_id, event_type, function_name, args_json, result_json, + content, created_at, meta_json, history_sequence) + VALUES (?1, ?2, 'tool_call', 'create_plan', ?3, '{}', '', ?4, ?5, ?6)", + rusqlite::params![ + format!("tool-call-{call_id}"), + session_id, + args_json, + created_at, + meta_json, + sequence, + ], + ) + .expect("seed create_plan event"); + + conn.execute( + "INSERT OR REPLACE INTO session_turns + (session_id, turn_id, start_sequence, end_sequence, started_at, status, updated_at, + user_preview, event_count, body_event_count) + VALUES (?1, ?2, ?3, NULL, ?4, 'pending', ?4, '', 1, 1)", + rusqlite::params![session_id, format!("turn-{call_id}"), sequence, created_at,], + ) + .expect("seed pending turn"); + + seed_session_row_with_workspace(session_id, "plan", workspace_path); + } + fn seed_session_row(session_id: &str, exec_mode: &str) { + seed_session_row_with_workspace(session_id, exec_mode, &temp_home()); + } + + fn seed_session_row_with_workspace(session_id: &str, exec_mode: &str, workspace_path: &Path) { use crate::session::persistence::{upsert_session, UnifiedSessionRecord}; let conn = database::db::get_connection().expect("test sqlite connection"); conn.execute_batch( @@ -1174,6 +1453,7 @@ mod tests { name: format!("{session_id} session"), status: "idle".to_string(), agent_exec_mode: Some(exec_mode.to_string()), + workspace_path: Some(workspace_path.to_string_lossy().into_owned()), created_at: now.clone(), updated_at: now, ..Default::default() @@ -1183,6 +1463,65 @@ mod tests { .expect("seed exec mode"); } + #[tokio::test] + async fn repair_orphaned_create_plan_keeps_latest_submission_per_session() { + let _lock = lock_and_prepare(); + let session_id = "s_repair_latest"; + let workspace = temp_home().join("repair-latest-workspace"); + let plans_dir = workspace.join(".orgii").join("plans"); + std::fs::create_dir_all(&plans_dir).unwrap(); + + let old_content = "old orphan body"; + let new_content = "new orphan body"; + let old_plan_path = plans_dir.join("old-plan_aaaaaaaa.plan.md"); + let new_plan_path = plans_dir.join("new-plan_bbbbbbbb.plan.md"); + std::fs::write(&old_plan_path, old_content).unwrap(); + std::fs::write(&new_plan_path, new_content).unwrap(); + + PlanApprovalStore::upsert(&PendingPlanRow { + session_id: session_id.to_string(), + tool_call_id: Some("call_old".to_string()), + plan_id: "plan-old".to_string(), + plan_revision_id: "call_old".to_string(), + origin_tool_call_id: Some("call_old".to_string()), + plan_path: old_plan_path.to_string_lossy().into_owned(), + plan_title: "Old Plan".to_string(), + plan_content: old_content.to_string(), + created_at_ms: 1_700_000_000_000, + }) + .unwrap(); + + seed_orphan_create_plan_event( + session_id, + "call_old", + "Old Plan", + old_content, + &workspace, + 10, + "2023-11-14T22:13:20+00:00", + ); + seed_orphan_create_plan_event( + session_id, + "call_new", + "New Plan", + new_content, + &workspace, + 20, + "2023-11-14T22:14:20+00:00", + ); + + assert_eq!(repair_orphaned_create_plan_submissions_sync().unwrap(), 1); + + let loaded = PlanApprovalStore::load_by_session(session_id) + .unwrap() + .expect("pending row"); + assert_eq!(loaded.tool_call_id.as_deref(), Some("call_new")); + assert_eq!(loaded.origin_tool_call_id.as_deref(), Some("call_new")); + assert_eq!(loaded.plan_title, "New Plan"); + assert_eq!(loaded.plan_content, new_content); + assert_eq!(loaded.plan_path, new_plan_path.to_string_lossy()); + } + #[tokio::test] async fn resolve_pending_orphaned_deletes_row_without_manager() { let _lock = lock_and_prepare(); @@ -1193,6 +1532,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; let snap = resolve_pending(session_id, PlanResolution::Orphaned, None) .await @@ -1251,6 +1591,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; // Pending plans are session-level state decoupled from the exec // mode: a session that switched to Build keeps its Build card. @@ -1277,6 +1618,7 @@ mod tests { let mgr = PlanApprovalManager::new(); mgr.mark_ready(session_id, plan_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row(session_id).await; let fresh = PlanApprovalManager::new(); fresh.rehydrate_from_db(session_id).await.unwrap(); @@ -1296,6 +1638,7 @@ mod tests { PlanApprovalManager::new() .mark_ready("s_gc_live", live_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row("s_gc_live").await; // Orphan A: file deleted. let gone_path = temp_home().join("gc_gone.plan.md"); @@ -1304,6 +1647,7 @@ mod tests { PlanApprovalManager::new() .mark_ready("s_gc_gone", gone_path.to_str().unwrap(), "T", "body", None) .await; + wait_for_pending_row("s_gc_gone").await; std::fs::remove_file(&gone_path).unwrap(); // NOT an orphan: session left plan mode but still exists — the @@ -1320,6 +1664,7 @@ mod tests { None, ) .await; + wait_for_pending_row("s_gc_left_mode").await; // Orphan C: session row does not exist at all. let no_session_path = temp_home().join("gc_no_session.plan.md"); @@ -1333,6 +1678,7 @@ mod tests { None, ) .await; + wait_for_pending_row("s_gc_no_session").await; gc_orphaned_pending_plans().await; diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs index e49aa35f75..3a59eac98d 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs @@ -13,7 +13,6 @@ pub mod provider_request_capture; mod screenshot; mod stream_error_recovery; pub(crate) mod stream_normalizer; -pub(crate) mod streaming_executor; pub(crate) mod tool_execution; pub(crate) mod tool_result_storage; mod types; @@ -35,10 +34,6 @@ pub use types::{ TurnIterationHook, TurnResult, }; -// Used by the streaming pre-execution shortcut in `execute_turn` below, -// not part of the module's public surface. -use helpers::add_tool_result_rich_with_timestamp; - // `MAX_TOOL_OUTPUT_CHARS` is consumed by `helpers::*` and a couple of test // modules via `use crate::core::turn_executor::MAX_TOOL_OUTPUT_CHARS`. // `set_test_backoff_override_ms` is consumed by the retry-tests module the @@ -55,15 +50,12 @@ use std::sync::Arc; use serde_json::Value; use tracing::{info, warn}; -use crate::core::tools::traits::ToolExecuteResult; use crate::providers::traits::{finish_reason as finish, LLMProvider, StreamDelta}; use crate::specialization::policies::activation::SessionScopedContextActivator; -use crate::tools::names as tool_names; use crate::tools::policy::ResolvedToolPolicy; use crate::tools::registry::ToolRegistry; use stream_normalizer::{NormalizedStreamEvent, TurnStreamNormalizer}; -use streaming_executor::{execute_prevalidated, StreamingToolAccumulator}; use crate::model_context::microcompact; @@ -210,11 +202,6 @@ pub async fn execute_turn( session_id ); - // Streaming tool accumulator: pre-parses read-only tool calls during streaming - let streaming_acc = Arc::new(std::sync::Mutex::new(StreamingToolAccumulator::new( - tools, policy, - ))); - let streaming_acc_for_cb = streaming_acc.clone(); let stream_normalizer = Arc::new(std::sync::Mutex::new(TurnStreamNormalizer::new())); let stream_normalizer_for_cb = stream_normalizer.clone(); @@ -269,9 +256,6 @@ pub async fn execute_turn( tc_delta.name.as_deref(), tc_delta.arguments_delta.as_deref(), ); - if let Ok(mut acc) = streaming_acc_for_cb.lock() { - acc.on_tool_call_delta(&tc_delta); - } } NormalizedStreamEvent::UnknownFrame { provider, @@ -514,127 +498,25 @@ pub async fn execute_turn( &config.model, ); - // Execute pre-validated read-only tools from streaming accumulator - let (ready_ids, ready_calls) = { - let mut acc = streaming_acc.lock().unwrap(); - let ids = acc.ready_ids().to_vec(); - let calls = acc.take_ready_tool_calls(); - (ids, calls) - }; - let pre_results = execute_prevalidated( - ready_calls, + let (_count, outcome) = execute_tool_calls( + messages, + &response.tool_calls, tools, + policy, session_id, + handler, + permission_provider, + cancel_flag, + &mut file_tracker, + &mut consecutive_errors, + workspace_path, + policy_context_activator, config.max_tool_use_concurrency, ) .await; - // Inject pre-computed results for tools that completed during streaming - if !pre_results.is_empty() { - info!( - "[agent-core] {} tool(s) completed during streaming, skipping re-execution", - pre_results.len() - ); - for sr in &pre_results { - let (mut output, rich, is_err): (String, Option<&ToolExecuteResult>, bool) = - match &sr.result { - Ok(content) => ( - content.text.clone(), - Some(content), - tool_execution::is_error_text(&content.text), - ), - Err(err) => (format!("Error: {}", err), None, true), - }; - // Apply the same per-tool output budget as the normal - // execution path (`tool_execution/single.rs`). Without - // this, the streaming pre-execution shortcut injects - // unbounded tool output straight into the context — - // a multi-MB read_file result here once blew up a - // subagent's context beyond recovery. - let budget = tools.get(&sr.tool_name).map(|t| t.output_budget()); - output = helpers::truncate_output(&output, budget); - if sr.tool_name == tool_names::READ_FILE && !is_err { - if let Some(path) = sr.args.get("path").and_then(|value| value.as_str()) { - if let Some(extra) = policy_context_activator.and_then(|activator| { - activator.augment_for_read_paths(&[path.to_string()]) - }) { - output.push_str(&extra); - } - } - } - handler.on_tool_call( - session_id, - &sr.tool_call_id, - &sr.tool_name, - &sr.tool_name, - &sr.args, - ); - handler.on_tool_result( - session_id, - &sr.tool_call_id, - &sr.tool_name, - &sr.tool_name, - &output, - ); - match rich { - Some(rich_result) if rich_result.has_structured_payload() => { - // Preserve MCP structured payload through the - // streaming pre-execution shortcut. - add_tool_result_rich_with_timestamp( - messages, - &sr.tool_call_id, - &sr.tool_name, - &output, - rich_result, - is_err, - ); - } - _ => { - add_tool_result( - messages, - &sr.tool_call_id, - &sr.tool_name, - &output, - is_err, - ); - } - } - } - } - - // Filter out already-executed tool calls before passing to normal execution - let remaining_tool_calls: Vec<_> = response - .tool_calls - .iter() - .filter(|tc| !ready_ids.contains(&tc.id)) - .cloned() - .collect(); - - let outcome = if remaining_tool_calls.is_empty() { - tool_execution::ToolBatchOutcome::Continue - } else { - let (_count, outcome) = execute_tool_calls( - messages, - &remaining_tool_calls, - tools, - policy, - session_id, - handler, - permission_provider, - cancel_flag, - &mut file_tracker, - &mut consecutive_errors, - workspace_path, - policy_context_activator, - config.max_tool_use_concurrency, - ) - .await; - outcome - }; - // Backfill dummy results for any tool calls that don't have a - // result yet (e.g. after EarlyExit with interleaved pre-validated - // and remaining tool calls). + // result yet after EarlyExit. let existing_ids: std::collections::HashSet = messages .iter() .filter_map(|m| { diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/streaming_executor.rs b/src-tauri/crates/agent-core/src/core/turn_executor/streaming_executor.rs deleted file mode 100644 index 810c55ad47..0000000000 --- a/src-tauri/crates/agent-core/src/core/turn_executor/streaming_executor.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Streaming Tool Executor — pre-parses tool calls during LLM streaming. -//! -//! As the LLM streams tool call deltas, this module incrementally -//! accumulates and validates them. When -//! the stream ends, all **read-only** tool calls that were fully parsed during -//! streaming are immediately available for concurrent execution — no re-parsing needed. -//! -//! ## Current Behavior -//! -//! The accumulator runs inside the synchronous `on_delta` callback (via -//! `Arc>`). It classifies each completed tool call as: -//! - **read-only** → marked as pre-validated; skips the normal execution path -//! and instead executes immediately post-stream in parallel -//! - **write / unknown** → deferred to the normal sequential execution path -//! -//! ## Architecture -//! -//! ```text -//! ┌─ on_delta callback ─────┐ -//! │ feed tool_call deltas │ -//! │ accumulate JSON args │ -//! │ detect complete + valid │ -//! │ classify read-only │ -//! └──────────────────────────┘ -//! │ -//! ▼ (after stream ends) -//! ┌─ execute_prevalidated ──────────────────────┐ -//! │ for each read-only TC: spawn tool.execute(, &crate::tools::call_context::CallContext::default()) │ -//! │ join_all → Vec │ -//! └──────────────────────────────────────────────┘ -//! ``` - -use std::collections::HashMap; - -use serde_json::Value; -use tracing::info; - -use crate::core::turn_executor::tool_execution::normalize_tool_use_concurrency; -use crate::providers::traits::{ToolCallDelta, ToolCallRequest}; -use crate::tools::policy::{ResolvedToolPolicy, ToolVerdict}; -use crate::tools::registry::ToolRegistry; -use crate::tools::traits::ToolExecuteResult; - -/// Result of a tool that was executed via the streaming executor. -/// -/// `result` preserves the full [`ToolExecuteResult`] (text + structured -/// content blocks + MCP meta) so downstream wire-format branching -/// (Anthropic-native) can read the structured payload. The OpenAI-compat -/// path only consumes `.text`. -#[derive(Debug, Clone)] -pub(crate) struct StreamedToolResult { - pub tool_call_id: String, - pub tool_name: String, - pub args: Value, - pub result: Result, -} - -/// Accumulates tool call deltas from the streaming callback and identifies -/// read-only tool calls that can be immediately executed after the stream ends. -pub(crate) struct StreamingToolAccumulator { - /// Per-index accumulation state. - accumulators: HashMap, - /// Set of tool names known to be read-only and allowed by policy. - read_only_tools: std::collections::HashSet, - /// Completed read-only tool call requests ready for immediate execution. - ready_tool_calls: Vec, - /// IDs of tool calls that are ready (for filtering in normal execution path). - ready_ids: Vec, -} - -/// Tracks incremental accumulation of a single tool call from streaming deltas. -struct ToolCallAccumulator { - id: Option, - name: Option, - arguments: String, - finalized: bool, -} - -impl ToolCallAccumulator { - fn new() -> Self { - Self { - id: None, - name: None, - arguments: String::new(), - finalized: false, - } - } - - fn is_complete(&self) -> bool { - if self.id.is_none() || self.name.is_none() { - return false; - } - let trimmed = self.arguments.trim(); - if trimmed.is_empty() { - return false; - } - serde_json::from_str::(trimmed).is_ok() - } - - /// Convert this accumulator into a `ToolCallRequest`. - /// - /// **Invariant**: callers must gate this with `is_complete()` first. - /// `is_complete()` already calls `serde_json::from_str::(...)` - /// on the trimmed arguments, so the `from_str` here is a defensive - /// recheck — if it fails, the caller broke the gating contract. - /// Returning `None` keeps `try_finalize` graceful (skip this index) - /// instead of panicking on a contract violation, but the warn makes - /// the bug visible. - fn to_tool_call_request(&self) -> Option { - let id = self.id.as_ref()?; - let name = self.name.as_ref()?; - let args: Value = match serde_json::from_str(self.arguments.trim()) { - Ok(v) => v, - Err(err) => { - tracing::warn!( - tool_id = %id, - tool_name = %name, - error = %err, - "streaming_executor: to_tool_call_request invoked on non-JSON arguments; \ - callers must gate with is_complete() first" - ); - debug_assert!(false, "to_tool_call_request must be gated by is_complete()"); - return None; - } - }; - Some(ToolCallRequest { - id: id.clone(), - name: name.clone(), - arguments: args, - thought_signature: None, - }) - } -} - -impl StreamingToolAccumulator { - /// Create a new accumulator that knows which tools are read-only. - pub fn new(registry: &ToolRegistry, policy: &ResolvedToolPolicy) -> Self { - let read_only_tools: std::collections::HashSet = registry - .tool_names() - .into_iter() - .filter(|name| { - policy.verdict(name) == ToolVerdict::Allow - && registry - .get(name) - .map(|t| t.is_read_only()) - .unwrap_or(false) - }) - .collect(); - - Self { - accumulators: HashMap::new(), - read_only_tools, - ready_tool_calls: Vec::new(), - ready_ids: Vec::new(), - } - } - - /// Feed a tool call delta from the streaming callback. - pub fn on_tool_call_delta(&mut self, delta: &ToolCallDelta) { - let acc = self - .accumulators - .entry(delta.index) - .or_insert_with(ToolCallAccumulator::new); - - if let Some(ref id) = delta.id { - acc.id = Some(id.clone()); - } - if let Some(ref name) = delta.name { - acc.name = Some(name.clone()); - } - if let Some(ref args) = delta.arguments_delta { - acc.arguments.push_str(args); - } - - if !acc.finalized && acc.is_complete() { - self.try_finalize(delta.index); - } - } - - fn try_finalize(&mut self, index: usize) { - let acc = match self.accumulators.get_mut(&index) { - Some(a) => a, - None => return, - }; - - if acc.finalized { - return; - } - - let tool_name = match acc.name.as_deref() { - Some(name) => name, - None => return, - }; - - if !self.read_only_tools.contains(tool_name) { - acc.finalized = true; - return; - } - - let tc = match acc.to_tool_call_request() { - Some(tc) => tc, - None => return, - }; - - acc.finalized = true; - self.ready_ids.push(tc.id.clone()); - self.ready_tool_calls.push(tc); - } - - /// Returns IDs of tool calls that were pre-validated during streaming. - pub fn ready_ids(&self) -> &[String] { - &self.ready_ids - } - - /// Returns true if any read-only tool calls are ready for immediate execution. - #[cfg_attr(not(test), allow(dead_code))] - pub fn has_ready_tools(&self) -> bool { - !self.ready_tool_calls.is_empty() - } - - /// Take the pre-validated read-only tool calls (consumes them). - pub fn take_ready_tool_calls(&mut self) -> Vec { - std::mem::take(&mut self.ready_tool_calls) - } -} - -/// Execute pre-validated read-only tool calls concurrently. -/// -/// Called after streaming completes. These tools were fully parsed during -/// streaming and are known to be read-only, so they can safely run in parallel. -pub(crate) async fn execute_prevalidated( - tool_calls: Vec, - registry: &ToolRegistry, - session_id: &str, - max_tool_use_concurrency: usize, -) -> Vec { - if tool_calls.is_empty() { - return Vec::new(); - } - - info!( - "[streaming-exec] Executing {} pre-validated read-only tool(s) concurrently", - tool_calls.len() - ); - - let concurrency_limit = normalize_tool_use_concurrency(max_tool_use_concurrency); - let mut results = Vec::with_capacity(tool_calls.len()); - - for chunk in tool_calls.chunks(concurrency_limit) { - let futures: Vec<_> = chunk - .iter() - .cloned() - .map(|tc| { - let tool_ref = registry.get(&tc.name); - let saved_args = tc.arguments.clone(); - let ctx = crate::tools::call_context::CallContext::new(&tc.id, session_id); - async move { - let result = match tool_ref { - Some(tool) => match tool.execute(tc.arguments, &ctx).await { - Ok(output) => Ok(output), - Err(err) => Err(format!("{}", err)), - }, - None => Err(format!("Tool '{}' not found", tc.name)), - }; - StreamedToolResult { - tool_call_id: tc.id, - tool_name: tc.name, - args: saved_args, - result, - } - } - }) - .collect(); - - results.extend(futures::future::join_all(futures).await); - } - - results -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tools::traits::{Tool, ToolError}; - use async_trait::async_trait; - - struct FakeReadTool; - #[async_trait] - impl Tool for FakeReadTool { - fn name(&self) -> &str { - "read_file" - } - fn description(&self) -> &str { - "read" - } - fn parameters(&self) -> Value { - serde_json::json!({"type":"object","properties":{}}) - } - fn is_read_only(&self) -> bool { - true - } - async fn execute_text( - &self, - _params: Value, - _ctx: &crate::tools::traits::CallContext, - ) -> Result { - Ok("file_content_here".into()) - } - } - - struct FakeWriteTool; - #[async_trait] - impl Tool for FakeWriteTool { - fn name(&self) -> &str { - "edit_file" - } - fn description(&self) -> &str { - "edit" - } - fn parameters(&self) -> Value { - serde_json::json!({"type":"object","properties":{}}) - } - async fn execute_text( - &self, - _params: Value, - _ctx: &crate::tools::traits::CallContext, - ) -> Result { - Ok("edited".into()) - } - } - - struct FakeSearchTool; - #[async_trait] - impl Tool for FakeSearchTool { - fn name(&self) -> &str { - "code_search" - } - fn description(&self) -> &str { - "search" - } - fn parameters(&self) -> Value { - serde_json::json!({"type":"object","properties":{}}) - } - fn is_read_only(&self) -> bool { - true - } - async fn execute_text( - &self, - _params: Value, - _ctx: &crate::tools::traits::CallContext, - ) -> Result { - Ok("search_result".into()) - } - } - - fn make_registry() -> ToolRegistry { - let mut reg = ToolRegistry::new(); - reg.register(Box::new(FakeReadTool)); - reg.register(Box::new(FakeWriteTool)); - reg.register(Box::new(FakeSearchTool)); - reg - } - - fn make_accumulator() -> StreamingToolAccumulator { - let reg = make_registry(); - let policy = ResolvedToolPolicy::permissive(); - StreamingToolAccumulator::new(®, &policy) - } - - fn feed_complete( - acc: &mut StreamingToolAccumulator, - index: usize, - id: &str, - name: &str, - args: &str, - ) { - acc.on_tool_call_delta(&ToolCallDelta { - index, - id: Some(id.to_string()), - name: Some(name.to_string()), - arguments_delta: Some(args.to_string()), - }); - } - - // --- Accumulator unit tests --- - - #[test] - fn accumulator_incomplete_without_name() { - let mut acc = ToolCallAccumulator::new(); - acc.id = Some("tc_1".into()); - acc.arguments = r#"{"path": "/tmp"}"#.into(); - assert!(!acc.is_complete()); - } - - #[test] - fn accumulator_incomplete_without_valid_json() { - let mut acc = ToolCallAccumulator::new(); - acc.id = Some("tc_1".into()); - acc.name = Some("read_file".into()); - acc.arguments = r#"{"path": "/tmp"#.into(); - assert!(!acc.is_complete()); - } - - #[test] - fn accumulator_complete_with_valid_json() { - let mut acc = ToolCallAccumulator::new(); - acc.id = Some("tc_1".into()); - acc.name = Some("read_file".into()); - acc.arguments = r#"{"path": "/tmp/test.rs"}"#.into(); - assert!(acc.is_complete()); - } - - #[test] - fn accumulator_to_tool_call_request() { - let mut acc = ToolCallAccumulator::new(); - acc.id = Some("tc_1".into()); - acc.name = Some("read_file".into()); - acc.arguments = r#"{"path": "/tmp/test.rs"}"#.into(); - let req = acc.to_tool_call_request().unwrap(); - assert_eq!(req.id, "tc_1"); - assert_eq!(req.name, "read_file"); - assert_eq!(req.arguments["path"], "/tmp/test.rs"); - } - - #[test] - fn empty_args_not_complete() { - let mut acc = ToolCallAccumulator::new(); - acc.id = Some("tc_1".into()); - acc.name = Some("read_file".into()); - acc.arguments = String::new(); - assert!(!acc.is_complete()); - } - - // --- StreamingToolAccumulator tests --- - - #[test] - fn write_tool_not_ready() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "edit_file", r#"{"path":"a"}"#); - assert!(!acc.has_ready_tools()); - } - - #[test] - fn read_tool_becomes_ready() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "read_file", r#"{"path":"a"}"#); - assert!(acc.has_ready_tools()); - assert_eq!(acc.ready_ids(), &["tc_1"]); - } - - #[test] - fn incremental_args_accumulation() { - let mut acc = make_accumulator(); - - acc.on_tool_call_delta(&ToolCallDelta { - index: 0, - id: Some("tc_1".into()), - name: Some("read_file".into()), - arguments_delta: Some(r#"{"path""#.into()), - }); - assert!(!acc.has_ready_tools()); - - acc.on_tool_call_delta(&ToolCallDelta { - index: 0, - id: None, - name: None, - arguments_delta: Some(r#": "/tmp"}"#.into()), - }); - assert!(acc.has_ready_tools()); - } - - #[test] - fn unknown_tool_not_ready() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "unknown_tool", r#"{"x":1}"#); - assert!(!acc.has_ready_tools()); - } - - #[test] - fn no_double_finalize() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "read_file", r#"{"path":"a"}"#); - - acc.on_tool_call_delta(&ToolCallDelta { - index: 0, - id: None, - name: None, - arguments_delta: Some("extra".into()), - }); - - assert_eq!(acc.ready_ids().len(), 1); - } - - #[test] - fn multiple_read_tools_ready() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "read_file", r#"{"path":"a"}"#); - feed_complete(&mut acc, 1, "tc_2", "code_search", r#"{"query":"x"}"#); - assert_eq!(acc.ready_ids().len(), 2); - } - - #[test] - fn take_ready_tool_calls_empties_list() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "read_file", r#"{"path":"a"}"#); - let calls = acc.take_ready_tool_calls(); - assert_eq!(calls.len(), 1); - assert!(!acc.has_ready_tools()); - } - - #[test] - fn mixed_read_write_only_reads_ready() { - let mut acc = make_accumulator(); - feed_complete(&mut acc, 0, "tc_1", "read_file", r#"{"path":"a"}"#); - feed_complete(&mut acc, 1, "tc_2", "edit_file", r#"{"path":"b"}"#); - feed_complete(&mut acc, 2, "tc_3", "code_search", r#"{"query":"x"}"#); - assert_eq!(acc.ready_ids().len(), 2); - assert!(acc.ready_ids().contains(&"tc_1".to_string())); - assert!(acc.ready_ids().contains(&"tc_3".to_string())); - } - - // --- execute_prevalidated async tests --- - - #[tokio::test] - async fn execute_prevalidated_returns_results() { - let reg = make_registry(); - let calls = vec![ToolCallRequest { - id: "tc_1".into(), - name: "read_file".into(), - arguments: serde_json::json!({"path": "/tmp/test.rs"}), - thought_signature: None, - }]; - - let results = execute_prevalidated(calls, ®, "test-session", 10).await; - assert_eq!(results.len(), 1); - assert_eq!(results[0].tool_call_id, "tc_1"); - assert!(results[0].result.is_ok()); - assert_eq!(results[0].result.as_ref().unwrap(), "file_content_here"); - } - - #[tokio::test] - async fn execute_prevalidated_multiple_concurrent() { - let reg = make_registry(); - let calls = vec![ - ToolCallRequest { - id: "tc_1".into(), - name: "read_file".into(), - arguments: serde_json::json!({"path": "a"}), - thought_signature: None, - }, - ToolCallRequest { - id: "tc_2".into(), - name: "code_search".into(), - arguments: serde_json::json!({"query": "x"}), - thought_signature: None, - }, - ]; - - let results = execute_prevalidated(calls, ®, "test-session", 10).await; - assert_eq!(results.len(), 2); - } - - #[tokio::test] - async fn execute_prevalidated_empty_returns_empty() { - let reg = make_registry(); - let results = execute_prevalidated(Vec::new(), ®, "test-session", 10).await; - assert!(results.is_empty()); - } -} diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs index c766882e17..ff1a408b96 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs @@ -12,21 +12,14 @@ //! | `single` | Sequential execution of a single tool call | //! | `diff_feedback` | Post-write diff summaries for `edit_file` / `apply_patch` | //! -//! ## Boundary with `streaming_executor` +//! Tool calls are executed only after the provider stream completes. Streaming +//! deltas may update the UI while arguments arrive, but every completed call +//! enters `execute_tool_calls` so permission checks, hooks, file tracking, +//! persistence, metadata, and error accounting stay in one chokepoint. //! -//! The sibling `streaming_executor` module owns the *opportunistic* path: -//! while the LLM is still streaming, it accumulates tool-call deltas into -//! complete `ToolCallRequest`s and (for read-only tools allowed by policy) -//! eagerly executes them via its own `execute_prevalidated`. That shortcut -//! deliberately bypasses the rich pre-flight here (permissions, file-time -//! guards, before/after hooks, persistence, diff feedback) — it only fires -//! when those checks would all pass anyway, so its result can be emitted -//! straight into the message vec. Anything that doesn't qualify falls -//! through into this module's `execute_tool_calls` post-stream. -//! -//! Both paths construct a typed [`CallContext`](crate::tools::traits::CallContext) +//! The execution path constructs a typed [`CallContext`](crate::tools::traits::CallContext) //! per call, so adding a new framework metadata field is a struct-field -//! change in `call_context.rs` plus population at the dispatch sites. +//! change in `call_context.rs` plus population at the dispatch site. mod diff_feedback; mod parallel; diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs index 004b8edeb7..2c81b23e49 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs @@ -34,7 +34,7 @@ pub(super) enum ParallelResult { /// Execute a group of read-only tool calls concurrently. /// /// Pre-execution hooks and post-execution processing happen sequentially, -/// but the actual `tool.execute(, &crate::tools::call_context::CallContext::default())` calls run in parallel via `join_all`. +/// but the actual tool calls run in parallel via `join_all`. #[allow(clippy::too_many_arguments)] pub(super) async fn execute_parallel_group( messages: &mut Vec, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81a0299380..3e66ac62e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -512,13 +512,16 @@ pub fn run() { // Plan-approval lifecycle: process-wide AppHandle for terminal // transcript events pushed outside a live session manager, then // a one-shot GC pass that archives orphaned pending-plan rows - // (missing plan file / deleted session), then a repair scan that - // finalizes historically stranded awaiting_user create_plan + // (missing plan file / deleted session), a repair scan that + // restores half-committed create_plan submissions, then a scan + // that finalizes historically stranded awaiting_user create_plan // events (pre-backend-finalize archives whose FE patch never // landed). agent_core::interaction::plan_approval::install_app_handle(app.handle().clone()); tauri::async_runtime::spawn(async { agent_core::interaction::plan_approval::gc_orphaned_pending_plans().await; + agent_core::interaction::plan_approval::repair_orphaned_create_plan_submissions() + .await; tokio::task::spawn_blocking( crate::agent_sessions::event_pipeline::agent_core_bridge::repair_stranded_plan_events, ); From 71ad5a7dd9b66c497a426dbe21ed2f8c5cce9124 Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:27:01 -0700 Subject: [PATCH 021/864] test: align plan lifecycle archive assertion Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../crates/e2e-test/src/sde/interactive_tool.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src-tauri/crates/e2e-test/src/sde/interactive_tool.rs b/src-tauri/crates/e2e-test/src/sde/interactive_tool.rs index e3af46cbd7..b69db7ee46 100644 --- a/src-tauri/crates/e2e-test/src/sde/interactive_tool.rs +++ b/src-tauri/crates/e2e-test/src/sde/interactive_tool.rs @@ -27,10 +27,10 @@ pub async fn plan_approval_lifecycle_keeps_revision_timestamp(cfg: &Config) -> b Ok(result) => result, }; - let first_event = result + let archived_event = result .plan_events .iter() - .find(|event| event.plan_revision_id == "call_first"); + .find(|event| event.plan_revision_id == "call_first" && event.status == "archived"); let second_event = result .plan_events .iter() @@ -44,16 +44,14 @@ pub async fn plan_approval_lifecycle_keeps_revision_timestamp(cfg: &Config) -> b .map(|timestamp| timestamp.to_rfc3339()) .unwrap_or_default(); - let old_plan_archived = first_event - .map(|event| event.status.as_str() == "archived") - .unwrap_or(false); + let old_plan_archived = archived_event.is_some(); let new_plan_pending = second_event .map(|event| event.status.as_str() == "pending") .unwrap_or(false); - let archived_uses_original_time = first_event + let archived_uses_original_time = archived_event .map(|event| event.created_at == first_iso) .unwrap_or(false); - let archived_not_restamped_to_update_time = first_event + let archived_not_restamped_to_update_time = archived_event .map(|event| event.created_at != second_iso) .unwrap_or(false); From b295a30ddc8ec35c4e0a9586128e2b59bb80da2a Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:51:19 -0700 Subject: [PATCH 022/864] fix: show planning footer during idle running tools Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../SessionCore/core/runningEventGate.ts | 5 +++-- .../hooks/replay/usePlanningIndicator.test.ts | 8 ++++---- .../hooks/replay/usePlanningIndicator.ts | 19 +++++++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/engines/SessionCore/core/runningEventGate.ts b/src/engines/SessionCore/core/runningEventGate.ts index d71dc0eefd..c9b31d151a 100644 --- a/src/engines/SessionCore/core/runningEventGate.ts +++ b/src/engines/SessionCore/core/runningEventGate.ts @@ -66,8 +66,9 @@ export function isLiveRuntimeResourceEvent(event: SessionEvent): boolean { * deliberately excluded: a pinned background process is not a reason to * hide "the agent is thinking". * - * Within the current turn the gate keeps its meaning: a genuinely running - * row paints its own shimmer, so the footer stays hidden. + * Within the current turn this only answers whether a live row exists; the + * planning footer may still show after the row has been idle long enough, but + * the watchdog must not force-complete the session while this returns true. * * `await_output` is exempt: it polls/blocks waiting for OTHER jobs (shell * processes, subagents) and renders as a subtle TitleOnlyBlock whose diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts index 3bfdc4dfcf..0b14cfa505 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts @@ -48,13 +48,13 @@ describe("shouldShowPlanningIndicator", () => { ).toBe(true); }); - it("hides while a visible running row is painted", () => { + it("shows while a running tool row is idle long enough", () => { expect( shouldShowPlanningIndicator({ ...baseInput, anyRunning: true, }) - ).toBe(false); + ).toBe(true); }); it("shows during the parent gap when a background subagent is still running", () => { @@ -69,7 +69,7 @@ describe("shouldShowPlanningIndicator", () => { ).toBe(true); }); - it("does not show on a live subagent if a visible running row is already painted", () => { + it("shows on a live subagent after a running row becomes idle", () => { expect( shouldShowPlanningIndicator({ ...baseInput, @@ -77,6 +77,6 @@ describe("shouldShowPlanningIndicator", () => { hasLiveSubagent: true, anyRunning: true, }) - ).toBe(false); + ).toBe(true); }); }); diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts index 8acd6b6934..6a9a5febc9 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts @@ -4,7 +4,6 @@ * Shows a single "Planning next step..." line in the chat panel when: * 1. Any session type is actively working (code / cloud / OS agent) * 2. No store mutations for IDLE_THRESHOLD_MS (1 second) - * 3. No event currently has displayStatus === "running" * * The indicator stays visible until new events arrive or the session ends. * @@ -29,6 +28,8 @@ * Rust pushes StreamingSnapshot which has no `events` field, causing eventsAtom * to return []. Both snapshot types now carry `hasRunningEvent` (computed * against ALL events, including non-chat-visible ones like thinking deltas). + * Running events are used only to keep the watchdog from force-completing a + * legitimate long tool call; they do not suppress the idle footer. * * Uses snapshot `version` as the activity token — it bumps on every store * mutation (upsert, append, merge), including streaming deltas for thinking @@ -110,7 +111,6 @@ export function shouldShowPlanningIndicator({ isSessionActive, isPendingCancel, hasAwaitingUserInteraction, - anyRunning, coldStartVisible, idleAfterVersion, version, @@ -127,7 +127,6 @@ export function shouldShowPlanningIndicator({ isSessionActive && !isPendingCancel && !hasAwaitingUserInteraction && - !anyRunning && (coldStartVisible || idleAfterVersion === version) ); } @@ -289,7 +288,7 @@ export function usePlanningIndicator( }; }, [isSessionActive, version, activationVersion]); - // Visible when: session active, no running event, not pending cancel, AND either + // Visible when: session active, not pending cancel, AND either // (a) cold-start — version hasn't bumped since activation yet, OR // (b) warm — IDLE_THRESHOLD_MS elapsed since last mutation. // @@ -358,7 +357,8 @@ export function usePlanningIndicator( // `agent:subagent_job_changed` terminal event is the real completion // signal, not a 60s wall clock. useEffect(() => { - if (scoped || !visible || !sessionId || hasLiveSubagent) return; + if (scoped || !visible || !sessionId || hasLiveSubagent || anyRunning) + return; const timerId = window.setTimeout(() => { log.warn( `[usePlanningIndicator] watchdog: planning indicator stuck for ${PLANNING_WATCHDOG_MS}ms — ` + @@ -374,7 +374,14 @@ export function usePlanningIndicator( return () => { window.clearTimeout(timerId); }; - }, [scoped, visible, sessionId, hasLiveSubagent, setSessionRuntimeStatus]); + }, [ + scoped, + visible, + sessionId, + hasLiveSubagent, + anyRunning, + setSessionRuntimeStatus, + ]); // Re-roll the variant index on every hidden → visible transition. // Using a large random integer and letting the consumer mod by the From 4b7d78a80ac28859480fb6dbe864122b787c7543 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 13:21:10 +0800 Subject: [PATCH 023/864] feat(channel): add session switching and active work item context Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../agent-core/src/core/session/status_bar.rs | 55 +++- .../tools/impls/orchestration/agent/policy.rs | 7 +- .../tools/impls/project/manage_work_item.rs | 54 +++- .../src/core/tools/registration/agent_ops.rs | 7 +- .../src/integrations/gateway/binding.rs | 19 +- .../src/integrations/gateway/commands.rs | 92 ++++++ .../commands/channel_handler/dispatch.rs | 4 +- .../state/commands/channel_handler/slash.rs | 305 ++++++++++++++++-- 8 files changed, 497 insertions(+), 46 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/session/status_bar.rs b/src-tauri/crates/agent-core/src/core/session/status_bar.rs index ea2dd7dffd..aefc32a1b5 100644 --- a/src-tauri/crates/agent-core/src/core/session/status_bar.rs +++ b/src-tauri/crates/agent-core/src/core/session/status_bar.rs @@ -98,7 +98,10 @@ pub async fn append_status_bar_for_channel( .await .unwrap_or((0, total_tokens)); - let zenmux = get_zenmux_bar_text().await.unwrap_or_else(|| "ZenMux: (unavailable)".into()); + let zenmux = get_zenmux_bar_text() + .await + .unwrap_or_else(|| "ZenMux: (unavailable)".into()); + let session_label = session_context_label(&session.id); let bar = build_status_bar( cumulative_total, context_tokens, @@ -106,6 +109,7 @@ pub async fn append_status_bar_for_channel( msg_num, &zenmux, &model, + session_label.as_deref(), ); format!("{}\n\n{}", content.trim_end(), bar) } @@ -117,6 +121,7 @@ fn build_status_bar( msg_num: i64, zenmux: &str, model: &str, + session_label: Option<&str>, ) -> String { let equiv_k = (total_tokens.max(0) + 999) / 1000; // Match the OpenClaw current extension threshold: 1,000k weighted tokens. @@ -131,6 +136,9 @@ fn build_status_bar( format!("Context: {}k/{}k ({}%)", ctx_k, ctx_total_k, ctx_pct), format!("ZenMux {}", zenmux), ]; + if let Some(label) = session_label.filter(|s| !s.trim().is_empty()) { + parts.push(format!("📌 {}", label)); + } if msg_num > 0 { parts.push(format!("msg#{}", msg_num)); } @@ -141,6 +149,35 @@ fn build_status_bar( parts.join(" · ") } +fn session_context_label(session_id: &str) -> Option { + let row = crate::session::persistence::get_session(session_id) + .ok() + .flatten()?; + let mut pieces = Vec::new(); + if let Some(project) = row.project_slug.filter(|s| !s.trim().is_empty()) { + pieces.push(format!("项目:{}", project)); + } else if let Some(project) = row.project_name.filter(|s| !s.trim().is_empty()) { + pieces.push(format!("项目:{}", project)); + } + if let Some(item) = row.work_item_id.filter(|s| !s.trim().is_empty()) { + pieces.push(format!("任务:{}", item)); + } + if pieces.is_empty() { + let name = row.name.trim(); + if !name.is_empty() && name != session_id { + pieces.push(format!( + "会话:{}", + crate::utils::safe_truncate_chars_to_string(name, 24) + )); + } + } + if pieces.is_empty() { + None + } else { + Some(pieces.join("/")) + } +} + fn shorten_model(model: &str) -> String { let base = model.split(':').next().unwrap_or(model); let last = base.rsplit('/').next().unwrap_or(base); @@ -182,7 +219,10 @@ async fn get_zenmux_bar_text() -> Option { } Some(text) } - None => zenmux_cache().lock().ok().and_then(|guard| guard.text.clone()), + None => zenmux_cache() + .lock() + .ok() + .and_then(|guard| guard.text.clone()), } } @@ -277,10 +317,19 @@ mod tests { #[test] fn status_bar_has_expected_shape() { - let bar = build_status_bar(12_345, 67_890, 200_000, 7, "5h:1.0% / 7d:2.0%", "anthropic/claude-sonnet-4.6:anthropic"); + let bar = build_status_bar( + 12_345, + 67_890, + 200_000, + 7, + "5h:1.0% / 7d:2.0%", + "anthropic/claude-sonnet-4.6:anthropic", + Some("项目:org2"), + ); assert!(bar.contains("📊 等效: 13k (1%)")); assert!(bar.contains("Context: 68k/200k (34%)")); assert!(bar.contains("ZenMux 5h:1.0% / 7d:2.0%")); + assert!(bar.contains("📌 项目:org2")); assert!(bar.contains("msg#7")); assert!(bar.contains("🤖 sonnet-4.6")); } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs index dd76d037c1..33a947fa92 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs @@ -159,7 +159,12 @@ impl AgentTool { } tool_names::MANAGE_WORK_ITEM => { let parent_session_id = self.parent_session_id.lock().await.clone(); - registry.register(Box::new(WorkItemTool::new(parent_session_id))); + registry.register(Box::new(WorkItemTool::with_launch_context( + parent_session_id, + self.config.app_handle.clone(), + self.config.session_account_id.clone(), + self.config.agent_model.clone(), + ))); } tool_names::MANAGE_AGENT_DEF => { let handle = self.config.app_handle.as_ref().ok_or_else(|| { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs index 8d5aa58349..908fbb33f8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs @@ -37,11 +37,33 @@ enum WorkItemScope { /// Work item (task/issue) management tool. pub struct WorkItemTool { session_id: String, + app_handle: Option, + session_account_id: Option, + agent_model: String, } impl WorkItemTool { pub fn new(session_id: String) -> Self { - Self { session_id } + Self { + session_id, + app_handle: None, + session_account_id: None, + agent_model: String::new(), + } + } + + pub fn with_launch_context( + session_id: String, + app_handle: Option, + session_account_id: Option, + agent_model: String, + ) -> Self { + Self { + session_id, + app_handle, + session_account_id, + agent_model, + } } fn resolve_scope(params: &Value) -> Result { @@ -652,7 +674,7 @@ impl Tool for WorkItemTool { fn llm_description(&self) -> Option { Some( "Manage work items (tasks, issues, bugs) in the global project store. Omit project_slug for standalone work items. \ - Supports: list, read, create, update, delete, add_delegation, link_session, unlink_session, batch. \ + Supports: list, read, create, update, delete, start, add_delegation, link_session, unlink_session, batch. \ Use batch for multiple Work Items and put project_slug on each item when operations target different Projects. \ Use link_session to attach the current chat/session to an existing work item; create may be used first." .to_string(), @@ -666,7 +688,7 @@ impl Tool for WorkItemTool { "action": { "type": "string", "description": "The operation to perform.", - "enum": ["list", "read", "create", "update", "delete", "add_delegation", "link_session", "unlink_session", "batch"] + "enum": ["list", "read", "create", "update", "delete", "start", "add_delegation", "link_session", "unlink_session", "batch"] }, "project_slug": { "type": "string", @@ -846,6 +868,30 @@ impl Tool for WorkItemTool { WorkItemScope::Standalone => Self::delete_standalone_work_item(short_id).await, } } + "start" => { + let short_id = required_string(¶ms, "short_id")?; + match scope { + WorkItemScope::Project(project_slug) => { + let app = self.app_handle.as_ref().ok_or_else(|| { + ToolError::ExecutionFailed( + "start requires app_handle (not available in this context)".to_string(), + ) + })?; + crate::tool_infra::start_work_item( + &project_slug, + &short_id, + app, + self.session_account_id.as_deref().filter(|s| !s.is_empty()), + Some(self.agent_model.as_str()).filter(|s| !s.trim().is_empty()), + ) + .await + .map_err(ToolError::ExecutionFailed) + } + WorkItemScope::Standalone => Err(ToolError::InvalidParams( + "start currently requires project_slug; bind or move the standalone Work Item into a Project first".to_string(), + )), + } + } "link_session" => { let short_id = required_string(¶ms, "short_id")?; self.link_session(scope, short_id, params).await @@ -903,7 +949,7 @@ impl Tool for WorkItemTool { )) } _ => Err(ToolError::InvalidParams(format!( - "Unknown work_item action: '{}'. Valid actions: list, read, create, update, delete, add_delegation, link_session, unlink_session, batch", + "Unknown work_item action: '{}'. Valid actions: list, read, create, update, delete, start, add_delegation, link_session, unlink_session, batch", action ))), } diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs index 3dfd845a04..ff877f582c 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs @@ -100,7 +100,12 @@ pub fn register(registry: &mut ToolRegistry, deps: &ToolDeps, disabled: &HashSet ); register_if_enabled( registry, - Box::new(WorkItemTool::new(deps.session_id.clone())), + Box::new(WorkItemTool::with_launch_context( + deps.session_id.clone(), + deps.app_handle.clone(), + deps.session_account_id.clone(), + deps.agent_model.clone(), + )), disabled, ); diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/binding.rs b/src-tauri/crates/agent-core/src/integrations/gateway/binding.rs index ff6474cbf8..14161d308a 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/binding.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/binding.rs @@ -147,12 +147,25 @@ impl BindingStore { /// not fail the call — in-memory cache still serves follow-up reads /// until process restart. pub async fn set(&self, key: SessionKey, target_session_id: String) { + self.set_with_activity(key, target_session_id, chrono::Utc::now().to_rfc3339()) + .await; + } + + /// Pin `session_key` to `target_session_id` while preserving a caller-provided + /// activity timestamp. Used by channel session switching so rebinding to an + /// older session does not pretend a new message was sent in that target. + pub async fn set_with_activity( + &self, + key: SessionKey, + target_session_id: String, + last_activity_at: String, + ) { let now = chrono::Utc::now().to_rfc3339(); let binding = SessionBinding { session_key: key.clone(), target_session_id: target_session_id.clone(), updated_at: now.clone(), - last_activity_at: now.clone(), + last_activity_at: last_activity_at.clone(), }; { let mut guard = self.inner.write().await; @@ -166,12 +179,12 @@ impl BindingStore { conn.execute( "INSERT INTO gateway_bindings (session_key, target_session_id, updated_at, last_activity_at) - VALUES (?1, ?2, ?3, ?3) + VALUES (?1, ?2, ?3, ?4) ON CONFLICT(session_key) DO UPDATE SET target_session_id = excluded.target_session_id, updated_at = excluded.updated_at, last_activity_at = excluded.last_activity_at", - params![key_str, target_session_id, ts], + params![key_str, target_session_id, ts, last_activity_at], )?; Ok(()) }) diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs index c30f46f708..7be655eb87 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs @@ -29,6 +29,16 @@ pub enum GatewayCommand { /// Drop the binding for the current chat. Next message re-routes. NewSession, + /// Show the current channel binding and active Work Item context. + SessionCurrent, + /// List recent sessions that can be bound to this chat. + SessionList, + /// Switch this chat to an existing ORG2 session id. + SessionSwitch(String), + /// Create a fresh versioned session and bind this chat to it immediately. + SessionNew, + /// Bind the current chat/session to a Project or Work Item context. + SessionBind { target: String, value: String }, /// Emit the current binding + running-session summary back to the channel. Status, /// Manually compact the bound session's transcript and fork to a @@ -67,11 +77,63 @@ pub fn parse(content: &str) -> Option { "/new" | "/reset" => bare_command(rest, GatewayCommand::NewSession), "/status" => bare_command(rest, GatewayCommand::Status), "/compact" => bare_command(rest, GatewayCommand::Compact), + "/session" | "/ctx" => parse_session_command(rest), "/help" | "/commands" => bare_command(rest, GatewayCommand::Help), _ => None, } } +fn parse_session_command(rest: &str) -> Option { + let mut parts = rest.split_whitespace(); + let sub = parts.next().unwrap_or("current").to_ascii_lowercase(); + match sub.as_str() { + "current" | "status" => { + if parts.next().is_none() { + Some(GatewayCommand::SessionCurrent) + } else { + None + } + } + "list" | "ls" => { + if parts.next().is_none() { + Some(GatewayCommand::SessionList) + } else { + None + } + } + "new" => { + if parts.next().is_none() { + Some(GatewayCommand::SessionNew) + } else { + None + } + } + "switch" | "use" => { + let sid = parts.next()?; + if parts.next().is_none() { + Some(GatewayCommand::SessionSwitch(sid.to_string())) + } else { + None + } + } + "bind" => { + let target = parts.next()?.to_ascii_lowercase(); + let value = parts.next()?.to_string(); + if parts.next().is_none() + && matches!( + target.as_str(), + "project" | "workitem" | "work_item" | "item" + ) + { + Some(GatewayCommand::SessionBind { target, value }) + } else { + None + } + } + _ => None, + } +} + /// Accept `rest` only when it is empty (or whitespace-only). Prose that /// happens to mention the command name still gets parsed as a keyword, /// but because its tail is non-empty the whole message falls through @@ -134,6 +196,36 @@ mod tests { assert_eq!(parse("/commands"), Some(GatewayCommand::Help)); } + #[test] + fn parses_session_commands() { + assert_eq!(parse("/session"), Some(GatewayCommand::SessionCurrent)); + assert_eq!( + parse("/session current"), + Some(GatewayCommand::SessionCurrent) + ); + assert_eq!(parse("/session list"), Some(GatewayCommand::SessionList)); + assert_eq!(parse("/session new"), Some(GatewayCommand::SessionNew)); + assert_eq!( + parse("/session switch osagent-feishu-x"), + Some(GatewayCommand::SessionSwitch("osagent-feishu-x".into())) + ); + assert_eq!( + parse("/session bind project org2"), + Some(GatewayCommand::SessionBind { + target: "project".into(), + value: "org2".into() + }) + ); + assert_eq!( + parse("/session bind workitem ORG-1"), + Some(GatewayCommand::SessionBind { + target: "workitem".into(), + value: "ORG-1".into() + }) + ); + assert_eq!(parse("/ctx ls"), Some(GatewayCommand::SessionList)); + } + /// Prose after /help / /status / /new must fall through to the /// router, not fire the command. #[test] diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs index bea7a9ec81..bed8d4dadc 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/dispatch.rs @@ -9,8 +9,8 @@ use std::sync::Arc; use tracing::{info, warn}; use crate::bus::{InboundMessage, OutboundMessage}; -use crate::definitions::{os_agent, OS_AGENT_ID}; use crate::definitions::prefix_lookup::SDE_SESSION_PREFIX; +use crate::definitions::{os_agent, OS_AGENT_ID}; use crate::gateway::{parse_command, InboundMessageHandler, InboundProcessorDeps, SessionKey}; use crate::interaction::permission::AgentPermissionManager; use crate::interaction::question::QuestionManager; @@ -258,7 +258,7 @@ fn derive_os_session_id(channel: &str, chat_id: &str) -> String { /// Ensure the OS session is registered against `builtin:os` before /// `init_channel_session` tries to look it up — without it the /// channel init helper errors with `channel session '…' not registered`. -async fn ensure_os_session_registered(state: &AgentAppState, sid: &str) { +pub(super) async fn ensure_os_session_registered(state: &AgentAppState, sid: &str) { let needs_register = match state.get_session(sid).await { None => true, Some(existing) => existing.definition.id != os_agent().id, diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index 1906ab52e4..f117b272f8 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -3,6 +3,7 @@ use crate::bus::{InboundMessage, OutboundMessage}; use crate::gateway::{GatewayCommand, SessionKey}; +use crate::session::session_id::{next_version_for, os_session_id_base, with_version}; use crate::state::AgentAppState; use tracing::info; @@ -24,6 +25,13 @@ pub(super) async fn handle_command( info!("[gateway] Cleared binding for {}", session_key.as_str()); "Conversation reset. The next message starts a fresh session.".to_string() } + GatewayCommand::SessionCurrent => build_session_current(state, session_key).await, + GatewayCommand::SessionList => build_session_list(state).await, + GatewayCommand::SessionSwitch(target) => switch_session(state, session_key, &target).await, + GatewayCommand::SessionNew => create_and_switch_session(state, msg, session_key).await, + GatewayCommand::SessionBind { target, value } => { + bind_active_context(state, session_key, &target, &value).await + } GatewayCommand::Status => { let binding = state.gateway_bindings.get(session_key).await; let running: Vec = state.list_sessions().await; @@ -50,8 +58,6 @@ pub(super) async fn handle_command( run_manual_compact, ManualCompactResult, MIN_HISTORY_FOR_MANUAL_COMPACT, }; - // Resolve the bound session for this chat. If the chat has no - // binding there's no session to compact yet. let target_sid = match state.gateway_bindings.get(session_key).await { Some(b) => b.target_session_id, None => { @@ -118,28 +124,260 @@ pub(super) async fn handle_command( let bus = state.bus.lock().await; bus.publish_outbound(reply.clone()); } - // E2E observability: slash replies previously lived only on the - // outbound bus, which has no buffered subscribers in the dev - // harness — so `outbound-snapshot` could not verify the reply - // text. Mirror the `prepend_reset_notice` pattern and keep a - // copy in the debug buffer. #[cfg(debug_assertions)] push_debug_outbound(state, &reply).await; Ok(None) } +async fn build_session_current(state: &AgentAppState, session_key: &SessionKey) -> String { + match state.gateway_bindings.get(session_key).await { + Some(binding) => { + let meta = session_meta_line(&binding.target_session_id); + format!( + "**Current channel session**\n• Binding: `{}` → `{}`\n{}", + session_key.as_str(), + binding.target_session_id, + meta + ) + } + None => "**Current channel session**\n• No active session yet (send a message or use `/session new`).".to_string(), + } +} + +async fn build_session_list(_state: &AgentAppState) -> String { + let sessions = tokio::task::spawn_blocking(|| { + let filter = crate::session::SessionListFilter { + limit: Some(12), + ..Default::default() + }; + crate::session::persistence::list_sessions(&filter).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|x| x); + + let Ok(sessions) = sessions else { + return "Could not list sessions.".to_string(); + }; + if sessions.is_empty() { + return "No sessions found.".to_string(); + } + let mut lines = vec!["**Recent sessions**".to_string()]; + for s in sessions { + let title = session_display_name(&s); + lines.push(format!( + "• `{}` — {}{}", + s.session_id, + title, + session_project_suffix(s.project_slug.as_deref(), s.work_item_id.as_deref()) + )); + } + lines.push("Use `/session switch ` to bind this Feishu chat.".to_string()); + lines.join("\n") +} + +async fn switch_session(state: &AgentAppState, session_key: &SessionKey, target: &str) -> String { + let sid = target.trim().to_string(); + let exists = tokio::task::spawn_blocking({ + let sid = sid.clone(); + move || crate::session::persistence::get_session(&sid).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|x| x); + + match exists { + Ok(Some(record)) => { + state + .gateway_bindings + .set_with_activity(session_key.clone(), sid.clone(), record.updated_at.clone()) + .await; + format!( + "Switched this chat to `{}`.\n{}\n\n{}", + sid, + session_meta_line(&sid), + recent_session_summary(&sid, 6) + ) + } + Ok(None) => format!("Session not found: `{}`", sid), + Err(err) => format!("Could not switch session: {}", err), + } +} + +async fn create_and_switch_session( + state: &AgentAppState, + msg: &InboundMessage, + session_key: &SessionKey, +) -> String { + let base = os_session_id_base(&msg.channel, &msg.chat_id); + let sid = tokio::task::spawn_blocking(move || { + next_version_for(&base) + .map(|n| with_version(&base, n)) + .map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|x| x); + + let Ok(sid) = sid else { + return "Could not create a fresh channel session.".to_string(); + }; + super::dispatch::ensure_os_session_registered(state, &sid).await; + state + .gateway_bindings + .set(session_key.clone(), sid.clone()) + .await; + format!( + "Created and switched to fresh channel session `{}`.\nRecent context is empty; continue with the new topic.", + sid + ) +} + +async fn bind_active_context( + state: &AgentAppState, + session_key: &SessionKey, + target: &str, + value: &str, +) -> String { + let Some(binding) = state.gateway_bindings.get(session_key).await else { + return "No active session yet. Send a message or use `/session new` first.".to_string(); + }; + let session_id = binding.target_session_id; + match target { + "project" => match update_session_project(&session_id, value).await { + Ok(()) => format!("Bound current session `{}` to project `{}`.", session_id, value), + Err(err) => format!("Could not bind project: {}", err), + }, + "workitem" | "work_item" | "item" => match bind_session_work_item(&session_id, value).await { + Ok((project_slug, short_id)) => format!( + "Bound current session `{}` to work item `{}` in project `{}`.", + session_id, short_id, project_slug + ), + Err(err) => format!("Could not bind work item: {}", err), + }, + _ => "Unknown bind target. Use `/session bind project ` or `/session bind workitem `.".to_string(), + } +} + +async fn update_session_project(session_id: &str, project_slug: &str) -> Result<(), String> { + let sid = session_id.to_string(); + let slug = project_slug.to_string(); + tokio::task::spawn_blocking(move || { + let project = project_management::projects::io::read_project(&slug)?; + let ok = crate::session::persistence::update_work_item_link( + &sid, + &project.meta.org_id, + Some(&project.meta.id), + Some(&project.meta.name), + &slug, + "", + Some("orchestrator"), + ) + .map_err(|err| err.to_string())?; + if ok { + Ok(()) + } else { + Err(format!("Session not found: {sid}")) + } + }) + .await + .map_err(|err| err.to_string())? +} + +async fn bind_session_work_item(session_id: &str, value: &str) -> Result<(String, String), String> { + let sid = session_id.to_string(); + let raw = value.to_string(); + tokio::task::spawn_blocking(move || { + let (project_slug, short_id) = resolve_work_item_ref(&raw)?; + let project = project_management::projects::io::read_project(&project_slug)?; + project_management::projects::io::read_work_item(&project_slug, &short_id)?; + let ok = crate::session::persistence::update_work_item_link( + &sid, + &project.meta.org_id, + Some(&project.meta.id), + Some(&project.meta.name), + &project_slug, + &short_id, + Some("orchestrator"), + ) + .map_err(|err| err.to_string())?; + if !ok { + return Err(format!("Session not found: {sid}")); + } + Ok((project_slug, short_id)) + }) + .await + .map_err(|err| err.to_string())? +} + +fn resolve_work_item_ref(raw: &str) -> Result<(String, String), String> { + if let Some((project_slug, short_id)) = raw.split_once(':') { + return Ok((project_slug.to_string(), short_id.to_string())); + } + Err(format!( + "Work item binding currently requires : (got `{raw}`)" + )) +} + +fn session_meta_line(session_id: &str) -> String { + match crate::session::persistence::get_session(session_id) { + Ok(Some(s)) => format!( + "• Name: {}{}", + session_display_name(&s), + session_project_suffix(s.project_slug.as_deref(), s.work_item_id.as_deref()) + ), + _ => "• Metadata unavailable.".to_string(), + } +} + +fn session_display_name(s: &crate::session::persistence::UnifiedSessionRecord) -> String { + if !s.name.trim().is_empty() { + s.name.clone() + } else { + s.session_id.clone() + } +} + +fn session_project_suffix(project_slug: Option<&str>, work_item_id: Option<&str>) -> String { + match (project_slug, work_item_id) { + (Some(p), Some(w)) if !p.is_empty() && !w.is_empty() => { + format!(" · project `{}` · item `{}`", p, w) + } + (Some(p), _) if !p.is_empty() => format!(" · project `{}`", p), + _ => String::new(), + } +} + +fn recent_session_summary(session_id: &str, limit: usize) -> String { + match crate::session::persistence::load_messages(session_id) { + Ok(rows) => { + let mut lines = + vec!["Recent context (deterministic last-message summary):".to_string()]; + let selected: Vec<_> = rows.into_iter().rev().take(limit).collect(); + if selected.is_empty() { + return "Recent context: (empty)".to_string(); + } + for row in selected.into_iter().rev() { + let role = row.role; + let text = crate::utils::safe_truncate_chars_to_string( + &row.content.replace('\n', " "), + 160, + ); + if !text.trim().is_empty() { + lines.push(format!("- {}: {}", role, text)); + } + } + if lines.len() == 1 { + "Recent context: (no text messages)".to_string() + } else { + lines.join("\n") + } + } + Err(err) => format!("Recent context unavailable: {}", err), + } +} + /// Static cheat-sheet for the `/help` slash command. -/// -/// Hermes parallel: `gateway/run.py:_handle_help_command` → -/// `hermes_cli.commands.gateway_help_lines()`. Hermes builds the list -/// dynamically from a `COMMAND_REGISTRY`; we keep the cheat-sheet -/// hand-maintained in MVP because the surface is small (six commands) -/// and the source of truth is the `GatewayCommand` enum next door — -/// the unit test below pins the alignment. -/// -/// Keep the body short: Telegram's per-message budget is ~4096 chars -/// and we don't want the LLM to be tempted to repeat this list back to -/// the user. fn build_help_text() -> String { [ "**Commands**", @@ -147,6 +385,12 @@ fn build_help_text() -> String { "`/new` — reset this chat; the next message starts a fresh session.", "`/status` — show the current session and anything else running.", "`/compact` — compress the current session and continue in a versioned successor.", + "`/session current` — show the active channel-bound ORG2 session.", + "`/session list` — list recent ORG2 sessions.", + "`/session switch ` — bind this chat to an existing session and show recent context.", + "`/session new` — create and bind a fresh session immediately.", + "`/session bind project ` — set active project context for this channel session.", + "`/session bind workitem ` — set active Work Item context for this channel session.", ] .join("\n") } @@ -158,22 +402,21 @@ mod help_text_tests { #[test] fn lists_every_supported_slash_command() { let text = build_help_text(); - for cmd in ["/help", "/new", "/status", "/compact"] { + for cmd in [ + "/help", + "/new", + "/status", + "/compact", + "/session current", + "/session switch", + ] { assert!(text.contains(cmd), "help cheat-sheet missing {cmd}: {text}"); } } - /// `/switch` and `/agent` were removed after dogfooding surfaced - /// that end-users never use them (they'd have to copy/paste an - /// opaque `sdeagent-...` session id). The `/help` cheat-sheet must - /// not advertise them to avoid discovery + confusion. #[test] - fn does_not_advertise_removed_commands() { + fn does_not_advertise_removed_agent_command() { let text = build_help_text(); - assert!( - !text.contains("/switch"), - "help still mentions /switch: {text}" - ); assert!( !text.contains("/agent"), "help still mentions /agent: {text}" @@ -181,9 +424,7 @@ mod help_text_tests { } #[test] - fn fits_telegram_message_budget() { - // Hermes caps at 4096 (Telegram limit). 1KB is plenty of head-room - // for a static list and forces us to revisit if we balloon. - assert!(build_help_text().len() < 1024); + fn fits_message_budget() { + assert!(build_help_text().len() < 2048); } } From ecae62597f8f306c946a86ec3a90a63a30edaa80 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 16:27:45 +0800 Subject: [PATCH 024/864] feat(channel): expand internal help command Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../state/commands/channel_handler/slash.rs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index f117b272f8..9f54033095 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -380,17 +380,28 @@ fn recent_session_summary(session_id: &str, limit: usize) -> String { /// Static cheat-sheet for the `/help` slash command. fn build_help_text() -> String { [ - "**Commands**", + "**ORG2 Channel Commands**", + "These commands are handled inside the gateway before the OS agent runs, so they do **not** spend LLM tokens.", + "", + "**General**", "`/help` — show this list (alias: `/commands`).", - "`/new` — reset this chat; the next message starts a fresh session.", - "`/status` — show the current session and anything else running.", - "`/compact` — compress the current session and continue in a versioned successor.", - "`/session current` — show the active channel-bound ORG2 session.", - "`/session list` — list recent ORG2 sessions.", - "`/session switch ` — bind this chat to an existing session and show recent context.", + "`/status` — show this chat's current binding and active runtime sessions.", + "`/new` — clear this chat's binding; the next normal message creates a fresh session (alias: `/reset`).", + "`/compact` — manually compact the current channel session and continue in a versioned successor.", + "", + "**Session switching**", + "`/session current` — show the active channel-bound ORG2 session (alias: `/ctx current`).", + "`/session list` — list recent ORG2 sessions (aliases: `/session ls`, `/ctx ls`).", + "`/session switch ` — bind this Feishu chat to an existing session and show recent context (alias: `/session use `).", "`/session new` — create and bind a fresh session immediately.", + "", + "**Active project / Work Item context**", "`/session bind project ` — set active project context for this channel session.", - "`/session bind workitem ` — set active Work Item context for this channel session.", + "`/session bind workitem :` — set active Work Item context for this channel session.", + "", + "**Work Items via agent tools**", + "Natural language requests can create/update/list Work Items with `manage_work_item` (`wi` alias).", + "Project Work Items can be started with `manage_work_item(action="start", project_slug=..., short_id=...)`.", ] .join("\n") } @@ -425,6 +436,6 @@ mod help_text_tests { #[test] fn fits_message_budget() { - assert!(build_help_text().len() < 2048); + assert!(build_help_text().len() < 4096); } } From 08957547343e683cb367e67ed6bf6188383825a4 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 16:30:22 +0800 Subject: [PATCH 025/864] fix(channel): repair help command string Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../agent-core/src/state/commands/channel_handler/slash.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index 9f54033095..1c3311a51b 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -401,7 +401,7 @@ fn build_help_text() -> String { "", "**Work Items via agent tools**", "Natural language requests can create/update/list Work Items with `manage_work_item` (`wi` alias).", - "Project Work Items can be started with `manage_work_item(action="start", project_slug=..., short_id=...)`.", + "Project Work Items can be started with `manage_work_item(action=\"start\", project_slug=..., short_id=...)`.", ] .join("\n") } From 0d9aba3f825ab7b4f7d76d6f2e6502c1647d860f Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 16:48:22 +0800 Subject: [PATCH 026/864] feat(memory): index session memory for cross-session search Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../crates/agent-core/src/core/session/mod.rs | 1 + .../src/core/session/persistence/messages.rs | 93 ++++++++++++++ .../src/core/session/persistence/mod.rs | 11 +- .../src/core/session/session_memory_search.rs | 113 ++++++++++++++++++ .../src/core/session/turn/post_turn.rs | 32 +++++ .../src/integrations/gateway/commands.rs | 6 + .../state/commands/channel_handler/slash.rs | 30 +++++ 7 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/session/session_memory_search.rs diff --git a/src-tauri/crates/agent-core/src/core/session/mod.rs b/src-tauri/crates/agent-core/src/core/session/mod.rs index 008621f061..ab6e6a49e7 100644 --- a/src-tauri/crates/agent-core/src/core/session/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/mod.rs @@ -38,6 +38,7 @@ pub mod prompt; pub(crate) mod scheduler; pub mod status_bar; pub mod session_id; +pub mod session_memory_search; pub(crate) mod title; pub mod turn; mod types; diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index cf76adbe70..8515bc3bab 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -529,6 +529,99 @@ pub struct PersistedSessionMemoryState { pub last_msg_idx: Option, } + +// ============================================ +// Session Memory Semantic Index +// ============================================ + +#[derive(Debug, Clone)] +pub struct SessionMemoryIndexRow { + pub session_id: String, + pub content: String, + pub embedding: Vec, + pub embedding_model: Option, + pub updated_at: String, +} + +pub fn ensure_session_memory_index_schema(conn: &rusqlite::Connection) -> SqliteResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_memory_index ( + session_id TEXT PRIMARY KEY, + content TEXT NOT NULL, + embedding BLOB, + embedding_model TEXT, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_session_memory_index_updated + ON session_memory_index(updated_at);", + )?; + Ok(()) +} + +pub fn save_session_memory_index( + session_id: &str, + content: &str, + embedding: &[f32], + embedding_model: Option<&str>, +) -> SqliteResult<()> { + let embedding_bytes: Vec = embedding.iter().flat_map(|v| v.to_le_bytes()).collect(); + let embedding_blob: Option<&[u8]> = if embedding_bytes.is_empty() { + None + } else { + Some(&embedding_bytes) + }; + with_sessions_writer(|| -> SqliteResult<()> { + let conn = get_connection()?; + ensure_session_memory_index_schema(&conn)?; + conn.execute( + "INSERT INTO session_memory_index + (session_id, content, embedding, embedding_model, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(session_id) DO UPDATE SET + content = excluded.content, + embedding = excluded.embedding, + embedding_model = excluded.embedding_model, + updated_at = excluded.updated_at", + rusqlite::params![ + session_id, + content, + embedding_blob, + embedding_model, + chrono::Utc::now().to_rfc3339(), + ], + )?; + Ok(()) + }) +} + +pub fn load_session_memory_index_rows() -> SqliteResult> { + let conn = get_connection()?; + ensure_session_memory_index_schema(&conn)?; + let mut stmt = conn.prepare( + "SELECT session_id, content, embedding, embedding_model, updated_at + FROM session_memory_index + ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map([], |row| { + let embedding_blob: Option> = row.get(2)?; + let embedding = embedding_blob + .map(|blob| { + blob.chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect() + }) + .unwrap_or_default(); + Ok(SessionMemoryIndexRow { + session_id: row.get(0)?, + content: row.get(1)?, + embedding, + embedding_model: row.get(3)?, + updated_at: row.get(4)?, + }) + })?; + rows.collect() +} + // ============================================ // Cancel-Interrupt Marker // ============================================ diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index 273fb0a4fb..11786cd134 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -35,11 +35,11 @@ pub use crud::{ pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, clear_session_memory_state, compact_cutoff_sequence, load_llm_history, load_messages, - load_session_memory_state, mark_turn_cancelled, message_anchor, message_created_at, - save_assistant_msg, save_compact_summary_msg, save_session_memory_state, save_snapshot, - save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, save_user_msg, - seed_session_with_messages, take_turn_cancelled, truncate_messages_from_sequence, - MessageAnchor, + load_session_memory_index_rows, load_session_memory_state, mark_turn_cancelled, message_anchor, + message_created_at, save_assistant_msg, save_compact_summary_msg, save_session_memory_index, + save_session_memory_state, save_snapshot, save_subagent_transcript, save_tool_call_msg, + save_tool_result_msg, save_user_msg, seed_session_with_messages, take_turn_cancelled, + truncate_messages_from_sequence, MessageAnchor, SessionMemoryIndexRow, }; use rusqlite::{Connection, Result as SqliteResult}; @@ -50,5 +50,6 @@ use rusqlite::{Connection, Result as SqliteResult}; /// is ready. Accepts a `&Connection` to avoid deadlock. pub fn init(conn: &Connection) -> SqliteResult<()> { crud::ensure_unified_schema(conn)?; + messages::ensure_session_memory_index_schema(conn)?; Ok(()) } diff --git a/src-tauri/crates/agent-core/src/core/session/session_memory_search.rs b/src-tauri/crates/agent-core/src/core/session/session_memory_search.rs new file mode 100644 index 0000000000..914c7c4f33 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/session_memory_search.rs @@ -0,0 +1,113 @@ +//! Cross-session semantic recall over persisted Session Memory summaries. +//! +//! Session Memory (SM) is per-session and independent. This module adds the +//! cross-session lookup layer: every successful SM extraction is embedded into +//! `session_memory_index`, then callers can embed a query, cosine-recall the +//! nearest session summaries, and rerank them with the local Qwen3 reranker. + +use crate::memory::embeddings::{cosine_similarity, AutoEmbeddingProvider, EmbeddingProvider}; +use crate::session::persistence::{load_session_memory_index_rows, SessionMemoryIndexRow}; + +const DEFAULT_TOP_K: usize = 5; +const RERANK_RECALL_MULT: usize = 3; +const MIN_SIMILARITY: f32 = 0.20; + +#[derive(Debug, Clone)] +pub struct SessionMemorySearchHit { + pub session_id: String, + pub content: String, + pub score: f32, + pub updated_at: String, +} + +/// Search indexed session-memory summaries with embedding + rerank. +/// +/// Best-effort fallback contract: +/// - query embedding failure => empty hits +/// - no compatible indexed embeddings => empty hits +/// - reranker failure => cosine order +pub async fn search_session_memories(query: &str, top_k: usize) -> Result, String> { + let query = query.trim(); + if query.is_empty() { + return Ok(Vec::new()); + } + let top_k = if top_k == 0 { DEFAULT_TOP_K } else { top_k }; + + let embed_cfg = crate::state::integrations_store::integrations_store() + .snapshot() + .embedding; + let provider = AutoEmbeddingProvider::new(embed_cfg.provider, embed_cfg.model); + let query_embedding = provider.embed(query).await?; + + let rows = tokio::task::spawn_blocking(load_session_memory_index_rows) + .await + .map_err(|err| format!("session-memory index load task failed: {err}"))? + .map_err(|err| format!("session-memory index load failed: {err}"))?; + + let mut scored: Vec<(SessionMemoryIndexRow, f32)> = rows + .into_iter() + .filter(|row| !row.embedding.is_empty()) + .filter(|row| row.embedding.len() == query_embedding.vector.len()) + .filter(|row| match row.embedding_model.as_deref() { + Some(model) => model == query_embedding.model, + None => true, + }) + .map(|row| { + let score = cosine_similarity(&query_embedding.vector, &row.embedding); + (row, score) + }) + .filter(|(_, score)| *score >= MIN_SIMILARITY) + .collect(); + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(top_k.saturating_mul(RERANK_RECALL_MULT).max(top_k)); + + if scored.is_empty() { + return Ok(Vec::new()); + } + + let docs: Vec = scored.iter().map(|(row, _)| row.content.clone()).collect(); + let reranker = crate::memory::embeddings::LocalReranker::new(); + let reranked = reranker.rerank(query, &docs, top_k).await; + + let hits = match reranked { + Ok(order) if !order.is_empty() => order + .into_iter() + .filter_map(|(idx, score)| scored.get(idx).map(|(row, _)| hit_from_row(row, score))) + .collect(), + _ => scored + .into_iter() + .take(top_k) + .map(|(row, score)| hit_from_row(&row, score)) + .collect(), + }; + + Ok(hits) +} + +fn hit_from_row(row: &SessionMemoryIndexRow, score: f32) -> SessionMemorySearchHit { + SessionMemorySearchHit { + session_id: row.session_id.clone(), + content: row.content.clone(), + score, + updated_at: row.updated_at.clone(), + } +} + +#[cfg(test)] +mod tests { + use crate::session::persistence::{load_session_memory_index_rows, save_session_memory_index}; + use test_helpers::test_env; + + #[test] + fn session_memory_index_roundtrips_embedding() { + let _sandbox = test_env::sandbox(); + save_session_memory_index("sm-index-test", "# Current State\nTesting", &[0.1, 0.2, 0.3], Some("test-model")) + .expect("save index"); + let rows = load_session_memory_index_rows().expect("load rows"); + let row = rows.into_iter().find(|r| r.session_id == "sm-index-test").expect("row exists"); + assert_eq!(row.content, "# Current State\nTesting"); + assert_eq!(row.embedding, vec![0.1, 0.2, 0.3]); + assert_eq!(row.embedding_model.as_deref(), Some("test-model")); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs b/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs index 05a770309a..a8a539c992 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs @@ -129,6 +129,38 @@ pub(super) async fn spawn_session_memory_extraction(input: SessionMemoryExtracti warn!("[sm_extraction] Failed to persist SM state: {}", err); } }); + + let embed_cfg = crate::state::integrations_store::integrations_store() + .snapshot() + .embedding; + let embedder = crate::memory::embeddings::AutoEmbeddingProvider::new( + embed_cfg.provider, + embed_cfg.model, + ); + match crate::memory::embeddings::EmbeddingProvider::embed(&embedder, &content).await { + Ok(embedding) => { + let sid = sm_session_id.clone(); + let content = content.clone(); + let model = embedding.model.clone(); + let vector = embedding.vector; + tokio::task::block_in_place(|| { + if let Err(err) = unified_persistence::save_session_memory_index( + &sid, + &content, + &vector, + Some(&model), + ) { + warn!("[sm_extraction] Failed to persist SM embedding index: {}", err); + } + }); + } + Err(err) => { + warn!( + "[sm_extraction] Session-memory embedding failed for {}: {}", + sm_session_id, err + ); + } + } } result }; diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs index 7be655eb87..5d7f398ef9 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs @@ -37,6 +37,8 @@ pub enum GatewayCommand { SessionSwitch(String), /// Create a fresh versioned session and bind this chat to it immediately. SessionNew, + /// Semantic search across indexed Session Memory summaries. + SessionSearch(String), /// Bind the current chat/session to a Project or Work Item context. SessionBind { target: String, value: String }, /// Emit the current binding + running-session summary back to the channel. @@ -205,6 +207,10 @@ mod tests { ); assert_eq!(parse("/session list"), Some(GatewayCommand::SessionList)); assert_eq!(parse("/session new"), Some(GatewayCommand::SessionNew)); + assert_eq!( + parse("/session search feishu image bug"), + Some(GatewayCommand::SessionSearch("feishu image bug".into())) + ); assert_eq!( parse("/session switch osagent-feishu-x"), Some(GatewayCommand::SessionSwitch("osagent-feishu-x".into())) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index 1c3311a51b..55a2454787 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -29,6 +29,7 @@ pub(super) async fn handle_command( GatewayCommand::SessionList => build_session_list(state).await, GatewayCommand::SessionSwitch(target) => switch_session(state, session_key, &target).await, GatewayCommand::SessionNew => create_and_switch_session(state, msg, session_key).await, + GatewayCommand::SessionSearch(query) => search_session_context(&query).await, GatewayCommand::SessionBind { target, value } => { bind_active_context(state, session_key, &target, &value).await } @@ -233,6 +234,34 @@ async fn create_and_switch_session( ) } +async fn search_session_context(query: &str) -> String { + match crate::session::session_memory_search::search_session_memories(query, 5).await { + Ok(hits) if hits.is_empty() => format!( + "No indexed session-memory hits for `{}`. Session Memory indexes are created after SM extraction runs.", + query + ), + Ok(hits) => { + let mut lines = vec![format!("**Session Memory hits for:** `{}`", query)]; + for hit in hits { + let preview = crate::utils::safe_truncate_chars_to_string( + &hit.content.replace(' +', " "), + 220, + ); + lines.push(format!( + "• `{}` score={:.3} updated={} + {}", + hit.session_id, hit.score, hit.updated_at, preview + )); + } + lines.push("Use `/session switch ` to bind this chat to one of these sessions.".to_string()); + lines.join(" +") + } + Err(err) => format!("Session-memory search failed: {}", err), + } +} + async fn bind_active_context( state: &AgentAppState, session_key: &SessionKey, @@ -394,6 +423,7 @@ fn build_help_text() -> String { "`/session list` — list recent ORG2 sessions (aliases: `/session ls`, `/ctx ls`).", "`/session switch ` — bind this Feishu chat to an existing session and show recent context (alias: `/session use `).", "`/session new` — create and bind a fresh session immediately.", + "`/session search ` — semantic search across indexed Session Memory summaries (embedding + rerank).", "", "**Active project / Work Item context**", "`/session bind project ` — set active project context for this channel session.", From d1ea1bc06b74cf0ea65a1811a9dd79379b269815 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 16:51:01 +0800 Subject: [PATCH 027/864] fix(memory): repair session search preview escaping Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../agent-core/src/state/commands/channel_handler/slash.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index 55a2454787..f1f15f4d75 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -244,8 +244,7 @@ async fn search_session_context(query: &str) -> String { let mut lines = vec![format!("**Session Memory hits for:** `{}`", query)]; for hit in hits { let preview = crate::utils::safe_truncate_chars_to_string( - &hit.content.replace(' -', " "), + &hit.content.replace('\n', " "), 220, ); lines.push(format!( From 46e45475c148ad65de1d8cc12819168697c065a6 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 16:55:06 +0800 Subject: [PATCH 028/864] fix(memory): parse session search command Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../agent-core/src/integrations/gateway/commands.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs index 5d7f398ef9..340c6ce102 100644 --- a/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs +++ b/src-tauri/crates/agent-core/src/integrations/gateway/commands.rs @@ -110,6 +110,14 @@ fn parse_session_command(rest: &str) -> Option { None } } + "search" | "find" => { + let query = parts.collect::>().join(" "); + if query.trim().is_empty() { + None + } else { + Some(GatewayCommand::SessionSearch(query)) + } + } "switch" | "use" => { let sid = parts.next()?; if parts.next().is_none() { From bcfca01cf345516b8d8687a065822989dcb751be Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 18:27:32 +0800 Subject: [PATCH 029/864] feat(channel): improve session list readability Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../state/commands/channel_handler/slash.rs | 107 ++++++++++++++++-- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index f1f15f4d75..89b6f38f76 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -26,7 +26,7 @@ pub(super) async fn handle_command( "Conversation reset. The next message starts a fresh session.".to_string() } GatewayCommand::SessionCurrent => build_session_current(state, session_key).await, - GatewayCommand::SessionList => build_session_list(state).await, + GatewayCommand::SessionList => build_session_list(state, session_key).await, GatewayCommand::SessionSwitch(target) => switch_session(state, session_key, &target).await, GatewayCommand::SessionNew => create_and_switch_session(state, msg, session_key).await, GatewayCommand::SessionSearch(query) => search_session_context(&query).await, @@ -145,7 +145,7 @@ async fn build_session_current(state: &AgentAppState, session_key: &SessionKey) } } -async fn build_session_list(_state: &AgentAppState) -> String { +async fn build_session_list(state: &AgentAppState, session_key: &SessionKey) -> String { let sessions = tokio::task::spawn_blocking(|| { let filter = crate::session::SessionListFilter { limit: Some(12), @@ -163,18 +163,107 @@ async fn build_session_list(_state: &AgentAppState) -> String { if sessions.is_empty() { return "No sessions found.".to_string(); } + + let current = state + .gateway_bindings + .get(session_key) + .await + .map(|b| b.target_session_id); let mut lines = vec!["**Recent sessions**".to_string()]; - for s in sessions { - let title = session_display_name(&s); + for (idx, s) in sessions.into_iter().enumerate() { + let marker = if current.as_deref() == Some(s.session_id.as_str()) { + "✅ 当前 " + } else { + "" + }; lines.push(format!( - "• `{}` — {}{}", + "{}. {}**{}**{} + `{}` + {} · 更新 {} + {}", + idx + 1, + marker, + human_session_title(&s), + session_project_suffix(s.project_slug.as_deref(), s.work_item_id.as_deref()), s.session_id, - title, - session_project_suffix(s.project_slug.as_deref(), s.work_item_id.as_deref()) + human_session_context(&s), + human_time_hint(&s.updated_at), + recent_session_one_line(&s.session_id), )); } - lines.push("Use `/session switch ` to bind this Feishu chat.".to_string()); - lines.join("\n") + lines.push( + " +切换:`/session switch `;新建:`/session new`;搜索:`/session search <关键词>`" + .to_string(), + ); + lines.join(" +") +} + +fn human_session_title(s: &crate::session::persistence::UnifiedSessionRecord) -> String { + if let Some(item) = s.work_item_id.as_deref().filter(|x| !x.trim().is_empty()) { + return format!("任务 {}", item); + } + if let Some(project) = s.project_slug.as_deref().filter(|x| !x.trim().is_empty()) { + return format!("项目 {}", project); + } + let name = s.name.trim(); + if !name.is_empty() && name != s.session_id { + return crate::utils::safe_truncate_chars_to_string(name, 48); + } + if let Some(channel) = s.channel.as_deref().filter(|x| !x.trim().is_empty()) { + return format!("{} 会话", channel); + } + "未命名会话".to_string() +} + +fn human_session_context(s: &crate::session::persistence::UnifiedSessionRecord) -> String { + let mut parts = Vec::new(); + if let Some(channel) = s.channel.as_deref().filter(|x| !x.trim().is_empty()) { + parts.push(format!("Channel {}", channel)); + } + if let Some(workspace) = s.workspace_path.as_deref().filter(|x| !x.trim().is_empty()) { + parts.push(format!( + "Workspace {}", + crate::utils::safe_truncate_chars_to_string(workspace, 40) + )); + } + if parts.is_empty() { + parts.push(format!("Type {}", s.session_type)); + } + parts.join(" · ") +} + +fn human_time_hint(ts: &str) -> String { + ts.split('T') + .nth(1) + .and_then(|tail| tail.get(0..5)) + .map(|hhmm| hhmm.to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +fn recent_session_one_line(session_id: &str) -> String { + match crate::session::persistence::load_messages(session_id) { + Ok(rows) => rows + .into_iter() + .rev() + .find_map(|row| { + let text = row.content.replace(' +', " "); + let text = text.trim(); + if text.is_empty() { + None + } else { + Some(format!( + "最近:{}: {}", + row.role, + crate::utils::safe_truncate_chars_to_string(text, 96) + )) + } + }) + .unwrap_or_else(|| "最近:(无文本消息)".to_string()), + Err(_) => "最近:(不可用)".to_string(), + } } async fn switch_session(state: &AgentAppState, session_key: &SessionKey, target: &str) -> String { From bcf4c93efc63d7c3640669c8d5e073bf8cc0ed9a Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 18:31:52 +0800 Subject: [PATCH 030/864] fix(channel): repair session list preview escaping Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/state/commands/channel_handler/slash.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index 89b6f38f76..ac1fdf1437 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -196,8 +196,10 @@ async fn build_session_list(state: &AgentAppState, session_key: &SessionKey) -> 切换:`/session switch `;新建:`/session new`;搜索:`/session search <关键词>`" .to_string(), ); - lines.join(" -") + lines.join( + " +", + ) } fn human_session_title(s: &crate::session::persistence::UnifiedSessionRecord) -> String { @@ -248,8 +250,7 @@ fn recent_session_one_line(session_id: &str) -> String { .into_iter() .rev() .find_map(|row| { - let text = row.content.replace(' -', " "); + let text = row.content.replace('\n', " "); let text = text.trim(); if text.is_empty() { None From 9c0d39ff220d8da8e9258b46df1106076c442f37 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sat, 27 Jun 2026 18:45:31 +0800 Subject: [PATCH 031/864] feat(channel): render session list as readable cards Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../state/commands/channel_handler/slash.rs | 109 ++++++++++++------ 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs index ac1fdf1437..16f4a29cc1 100644 --- a/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs +++ b/src-tauri/crates/agent-core/src/state/commands/channel_handler/slash.rs @@ -158,10 +158,10 @@ async fn build_session_list(state: &AgentAppState, session_key: &SessionKey) -> .and_then(|x| x); let Ok(sessions) = sessions else { - return "Could not list sessions.".to_string(); + return "无法读取会话列表。".to_string(); }; if sessions.is_empty() { - return "No sessions found.".to_string(); + return "还没有可切换的 ORG2 会话。".to_string(); } let current = state @@ -169,37 +169,30 @@ async fn build_session_list(state: &AgentAppState, session_key: &SessionKey) -> .get(session_key) .await .map(|b| b.target_session_id); - let mut lines = vec!["**Recent sessions**".to_string()]; + let mut blocks = vec!["**可切换会话**".to_string()]; for (idx, s) in sessions.into_iter().enumerate() { - let marker = if current.as_deref() == Some(s.session_id.as_str()) { - "✅ 当前 " + let current_badge = if current.as_deref() == Some(s.session_id.as_str()) { + " · ✅ 当前" } else { "" }; - lines.push(format!( - "{}. {}**{}**{} - `{}` - {} · 更新 {} - {}", + let recent = recent_session_preview(&s.session_id); + blocks.push(format!( + "**#{:02} · {}{}**\n{}\n`{}`\n{}\n{}", idx + 1, - marker, human_session_title(&s), - session_project_suffix(s.project_slug.as_deref(), s.work_item_id.as_deref()), - s.session_id, + current_badge, human_session_context(&s), - human_time_hint(&s.updated_at), - recent_session_one_line(&s.session_id), + s.session_id, + human_session_relation(&s, &recent), + recent, )); } - lines.push( - " -切换:`/session switch `;新建:`/session new`;搜索:`/session search <关键词>`" + blocks.push( + "**操作**\n切换:`/session switch `\n新建:`/session new`\n搜索:`/session search <关键词>`" .to_string(), ); - lines.join( - " -", - ) + blocks.join("\n\n") } fn human_session_title(s: &crate::session::persistence::UnifiedSessionRecord) -> String { @@ -210,28 +203,54 @@ fn human_session_title(s: &crate::session::persistence::UnifiedSessionRecord) -> return format!("项目 {}", project); } let name = s.name.trim(); - if !name.is_empty() && name != s.session_id { + if !name.is_empty() && name != s.session_id && !name.starts_with("Channel:") { return crate::utils::safe_truncate_chars_to_string(name, 48); } + if let Some(title) = recent_user_title(&s.session_id) { + return title; + } if let Some(channel) = s.channel.as_deref().filter(|x| !x.trim().is_empty()) { - return format!("{} 会话", channel); + return format!("{} 讨论", compact_channel_name(channel)); } "未命名会话".to_string() } +fn compact_channel_name(channel: &str) -> String { + channel.split(':').next().unwrap_or(channel).to_string() +} + fn human_session_context(s: &crate::session::persistence::UnifiedSessionRecord) -> String { let mut parts = Vec::new(); if let Some(channel) = s.channel.as_deref().filter(|x| !x.trim().is_empty()) { - parts.push(format!("Channel {}", channel)); + parts.push(format!("来源:{}", compact_channel_name(channel))); } if let Some(workspace) = s.workspace_path.as_deref().filter(|x| !x.trim().is_empty()) { parts.push(format!( - "Workspace {}", - crate::utils::safe_truncate_chars_to_string(workspace, 40) + "工作区:{}", + crate::utils::safe_truncate_chars_to_string(workspace, 36) )); } + parts.push(format!("更新:{}", human_time_hint(&s.updated_at))); + parts.join(" · ") +} + +fn human_session_relation( + s: &crate::session::persistence::UnifiedSessionRecord, + recent: &str, +) -> String { + let mut parts = Vec::new(); + if let Some(project) = s.project_slug.as_deref().filter(|x| !x.trim().is_empty()) { + parts.push(format!("项目 `{}`", project)); + } + if let Some(item) = s.work_item_id.as_deref().filter(|x| !x.trim().is_empty()) { + parts.push(format!("任务 `{}`", item)); + } if parts.is_empty() { - parts.push(format!("Type {}", s.session_type)); + if recent.contains("WI-") || recent.contains("任务") { + parts.push("可能关联任务(未绑定)".to_string()); + } else { + parts.push("未绑定项目/任务".to_string()); + } } parts.join(" · ") } @@ -244,7 +263,20 @@ fn human_time_hint(ts: &str) -> String { .unwrap_or_else(|| ts.to_string()) } -fn recent_session_one_line(session_id: &str) -> String { +fn recent_user_title(session_id: &str) -> Option { + crate::session::persistence::load_messages(session_id) + .ok()? + .into_iter() + .rev() + .find(|row| row.role == "user" && !row.content.trim().is_empty()) + .map(|row| { + let text = row.content.replace('\n', " "); + crate::utils::safe_truncate_chars_to_string(text.trim(), 32) + }) + .filter(|s| !s.trim().is_empty()) +} + +fn recent_session_preview(session_id: &str) -> String { match crate::session::persistence::load_messages(session_id) { Ok(rows) => rows .into_iter() @@ -256,14 +288,23 @@ fn recent_session_one_line(session_id: &str) -> String { None } else { Some(format!( - "最近:{}: {}", - row.role, - crate::utils::safe_truncate_chars_to_string(text, 96) + "最近:{}:{}", + role_label(&row.role), + crate::utils::safe_truncate_chars_to_string(text, 88) )) } }) - .unwrap_or_else(|| "最近:(无文本消息)".to_string()), - Err(_) => "最近:(不可用)".to_string(), + .unwrap_or_else(|| "最近:暂无文本消息".to_string()), + Err(_) => "最近:不可用".to_string(), + } +} + +fn role_label(role: &str) -> &str { + match role { + "user" => "用户", + "assistant" => "助手", + "system" => "系统", + other => other, } } From c0c32be3ce19cd68e13220ac5035db6e01fc549a Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sat, 27 Jun 2026 22:27:36 +0800 Subject: [PATCH 032/864] fix(markdown): preserve copyable nested markdown fences Render copyable Markdown document blocks with an outer fence longer than any nested fence so embedded code examples do not prematurely close the document block. Update streaming Markdown splitting to respect variable-length fences and add focused coverage for nested Markdown document fences. Verification: - pnpm vitest run src/components/MarkDown/markdownUtils.test.ts - pnpm run lint - pnpm run check:circular - git diff --check Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/components/MarkDown/MarkDownImpl.tsx | 36 ++++++---- src/components/MarkDown/markdownUtils.test.ts | 70 +++++++++++++++++++ src/components/MarkDown/markdownUtils.tsx | 30 ++++++++ 3 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 src/components/MarkDown/markdownUtils.test.ts diff --git a/src/components/MarkDown/MarkDownImpl.tsx b/src/components/MarkDown/MarkDownImpl.tsx index 4857259fd6..291dc5631a 100644 --- a/src/components/MarkDown/MarkDownImpl.tsx +++ b/src/components/MarkDown/MarkDownImpl.tsx @@ -36,6 +36,7 @@ import MermaidBlock from "./MermaidBlock"; import "./index.scss"; import { detectCodeType, + normalizeCopyableMarkdownDocumentFence, openFileInEditor, openUrlInBrowserApp, preprocessTextContent, @@ -169,23 +170,26 @@ function splitIntoStableMarkdownBlocks(content: string): string[] { const blocks: string[] = []; let blockStart = 0; - let inFence = false; + let fenceLength = 0; let index = 0; while (index < content.length) { - if ( - content[index] === "`" && - index + 2 < content.length && - content[index + 1] === "`" && - content[index + 2] === "`" - ) { - inFence = !inFence; - index += 3; - continue; + if (content[index] === "`") { + const fenceMatch = /^`{3,}/.exec(content.slice(index)); + if (fenceMatch) { + const currentFenceLength = fenceMatch[0].length; + if (fenceLength === 0) { + fenceLength = currentFenceLength; + } else if (currentFenceLength >= fenceLength) { + fenceLength = 0; + } + index += currentFenceLength; + continue; + } } if ( - !inFence && + fenceLength === 0 && content[index] === "\n" && index + 1 < content.length && content[index + 1] === "\n" @@ -651,10 +655,12 @@ const MarkdownComponent: React.FC = ({ // Preprocess text content to auto-detect and format code. // Skip the expensive regex pass when the caller guarantees the content is // already well-formed markdown (e.g., post-stream agent messages). - const processedContent = useMemo( - () => (skipPreprocess ? textContent : preprocessTextContent(textContent)), - [textContent, skipPreprocess] - ); + const processedContent = useMemo(() => { + const content = skipPreprocess + ? textContent + : preprocessTextContent(textContent); + return normalizeCopyableMarkdownDocumentFence(content); + }, [textContent, skipPreprocess]); const streamingBlocks = useMemo( () => (streaming ? splitIntoStableMarkdownBlocks(processedContent) : null), diff --git a/src/components/MarkDown/markdownUtils.test.ts b/src/components/MarkDown/markdownUtils.test.ts new file mode 100644 index 0000000000..407551bbd5 --- /dev/null +++ b/src/components/MarkDown/markdownUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeCopyableMarkdownDocumentFence } from "./markdownUtils"; + +describe("normalizeCopyableMarkdownDocumentFence", () => { + it("uses a longer outer fence for markdown documents with nested fences", () => { + const input = [ + "```md", + "## Summary", + "", + "## Verification", + "", + "```bash", + "pnpm run lint", + "```", + "```", + ].join("\n"); + + expect(normalizeCopyableMarkdownDocumentFence(input)).toBe( + [ + "````md", + "## Summary", + "", + "## Verification", + "", + "```bash", + "pnpm run lint", + "```", + "````", + ].join("\n") + ); + }); + + it("uses a fence longer than the longest nested fence", () => { + const input = [ + "````markdown", + "Example:", + "````text", + "nested", + "````", + "````", + ].join("\n"); + + expect(normalizeCopyableMarkdownDocumentFence(input)).toBe( + ["`````markdown", "Example:", "````text", "nested", "````", "`````"].join( + "\n" + ) + ); + }); + + it("leaves non-document markdown unchanged", () => { + const input = [ + "Before", + "", + "```md", + "## Summary", + "```", + "", + "After", + ].join("\n"); + + expect(normalizeCopyableMarkdownDocumentFence(input)).toBe(input); + }); + + it("leaves markdown documents without nested fences unchanged", () => { + const input = ["```md", "## Summary", "Plain text", "```"].join("\n"); + + expect(normalizeCopyableMarkdownDocumentFence(input)).toBe(input); + }); +}); diff --git a/src/components/MarkDown/markdownUtils.tsx b/src/components/MarkDown/markdownUtils.tsx index 2f43e65c42..c3f8ada923 100644 --- a/src/components/MarkDown/markdownUtils.tsx +++ b/src/components/MarkDown/markdownUtils.tsx @@ -62,6 +62,9 @@ const CODE_PATTERNS = [ ]; const ASCII_DIAGRAM_HINT_PATTERN = /[│├─└┌┐┘┤┬┴┼┃╔╗╚╝╠╣╦╩╬━|+=\\]/; +const COPYABLE_MARKDOWN_DOCUMENT_PATTERN = + /^(\s*)(`{3,})(md|markdown)([^\n]*)\n([\s\S]*?)\n(`{3,})(\s*)$/i; +const FENCE_RUN_PATTERN = /`{3,}/g; const MAX_INLINE_CODE_CACHE_SIZE = 300; const inlineCodeTypeCache = new Map(); @@ -157,6 +160,33 @@ function mayContainAsciiDiagram(text: string): boolean { return ASCII_DIAGRAM_HINT_PATTERN.test(text); } +export function normalizeCopyableMarkdownDocumentFence(text: string): string { + const match = text.match(COPYABLE_MARKDOWN_DOCUMENT_PATTERN); + if (!match) return text; + + const [ + , + leadingWhitespace, + openingFence, + language, + openingSuffix, + body, + closingFence, + trailingWhitespace, + ] = match; + + if (openingFence.length !== closingFence.length) return text; + if (!body.includes(openingFence)) return text; + + const maxFenceLength = Math.max( + openingFence.length, + ...Array.from(body.matchAll(FENCE_RUN_PATTERN), ([fence]) => fence.length) + ); + const wrapperFence = "`".repeat(maxFenceLength + 1); + + return `${leadingWhitespace}${wrapperFence}${language}${openingSuffix}\n${body}\n${wrapperFence}${trailingWhitespace}`; +} + /** * Detects and formats unformatted code in text content. * Handles cases where the backend sends raw code without markdown formatting. From 7ca17780819f6b625c2fbbd07ed4602b98a12535 Mon Sep 17 00:00:00 2001 From: yushui2022 Date: Sat, 27 Jun 2026 23:06:11 +0800 Subject: [PATCH 033/864] feat(browser): track active internal webview Track the currently visible browser-session WebView from the React owner and expose Tauri commands for resolving internal browser targets. This keeps agent-facing internal browser automation disabled while giving later commits a guarded active target source. Verification: npx lint-staged; cargo clippy --lib --message-format=short -p browser; cargo check -p org2; npx eslint src/engines/BrowserCore/BrowserSessionWebview.tsx; cargo test -p browser internal_browser_state --no-run. Pre-commit hook ran. Total eslint: 21, total circular: 0 --- .../browser/src/internal_browser_state.rs | 272 ++++++++++++++++++ src-tauri/crates/browser/src/lib.rs | 2 + src-tauri/src/commands/handler_list.inc | 5 + .../BrowserCore/BrowserSessionWebview.tsx | 104 ++++++- 4 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 src-tauri/crates/browser/src/internal_browser_state.rs diff --git a/src-tauri/crates/browser/src/internal_browser_state.rs b/src-tauri/crates/browser/src/internal_browser_state.rs new file mode 100644 index 0000000000..f00f0b2902 --- /dev/null +++ b/src-tauri/crates/browser/src/internal_browser_state.rs @@ -0,0 +1,272 @@ +//! Active internal browser target state. +//! +//! This is a small bridge from the frontend-owned inline WebView lifecycle to +//! Rust. Agent-facing tools can later resolve "the current internal browser" +//! without guessing from a generic active session id. + +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +const BROWSER_SESSION_LABEL_PREFIX: &str = "browser-session-"; + +static ACTIVE_INTERNAL_BROWSER: OnceLock>> = + OnceLock::new(); + +fn active_state() -> &'static Mutex> { + ACTIVE_INTERNAL_BROWSER.get_or_init(|| Mutex::new(None)) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn browser_session_id_from_label(label: &str) -> Option { + label + .strip_prefix(BROWSER_SESSION_LABEL_PREFIX) + .filter(|session_id| !session_id.is_empty()) + .map(str::to_string) +} + +fn expected_label_for_session(browser_session_id: &str) -> String { + format!("{BROWSER_SESSION_LABEL_PREFIX}{browser_session_id}") +} + +fn validate_active_state(state: &ActiveInternalBrowserState) -> Result<(), String> { + if state.browser_session_id.trim().is_empty() { + return Err("browser_session_id is required".to_string()); + } + if state.label.trim().is_empty() { + return Err("label is required".to_string()); + } + if !state.label.starts_with(BROWSER_SESSION_LABEL_PREFIX) { + return Err(format!( + "internal browser label must start with '{BROWSER_SESSION_LABEL_PREFIX}'" + )); + } + let expected_label = expected_label_for_session(&state.browser_session_id); + if state.label != expected_label { + return Err(format!( + "label '{}' does not match browser_session_id '{}'", + state.label, state.browser_session_id + )); + } + if !state.visible { + return Err("active internal browser state must be visible".to_string()); + } + if state.url.trim().is_empty() || state.url.trim().eq_ignore_ascii_case("about:blank") { + return Err("active internal browser url must be navigable".to_string()); + } + Ok(()) +} + +fn should_clear( + current: &ActiveInternalBrowserState, + label: Option<&str>, + browser_session_id: Option<&str>, + updated_at: Option, +) -> bool { + if let Some(label) = label { + if current.label != label { + return false; + } + } + + if let Some(browser_session_id) = browser_session_id { + if current.browser_session_id != browser_session_id { + return false; + } + } + + if let Some(updated_at) = updated_at { + if current.updated_at > updated_at { + return false; + } + } + + true +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ActiveInternalBrowserState { + pub browser_session_id: String, + pub label: String, + pub url: String, + pub visible: bool, + #[serde(default)] + pub updated_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InternalBrowserTargetInfo { + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_session_id: Option, + pub is_active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InternalBrowserTargetList { + #[serde(skip_serializing_if = "Option::is_none")] + pub active: Option, + pub active_webview_exists: bool, + pub webviews: Vec, +} + +/// Set the currently visible internal browser target. +#[tauri::command] +pub fn set_active_internal_browser_state( + mut state: ActiveInternalBrowserState, +) -> Result { + if state.updated_at == 0 { + state.updated_at = now_millis(); + } + validate_active_state(&state)?; + + let mut guard = active_state() + .lock() + .map_err(|err| format!("active internal browser state lock failed: {err}"))?; + *guard = Some(state.clone()); + + Ok(state) +} + +/// Clear the active internal browser target. +/// +/// When label/session/timestamp filters are provided, stale clear calls will not +/// clear a newer active target that replaced the old one. +#[tauri::command] +pub fn clear_active_internal_browser_state( + label: Option, + browser_session_id: Option, + #[allow(unused_variables)] reason: Option, + updated_at: Option, +) -> Result, String> { + let mut guard = active_state() + .lock() + .map_err(|err| format!("active internal browser state lock failed: {err}"))?; + + let should_remove = guard.as_ref().is_some_and(|current| { + should_clear( + current, + label.as_deref(), + browser_session_id.as_deref(), + updated_at, + ) + }); + + if should_remove { + *guard = None; + } + + Ok(guard.clone()) +} + +/// Return the active internal browser target, if any. +#[tauri::command] +pub fn get_active_internal_browser_state() -> Result, String> { + active_state() + .lock() + .map(|guard| guard.clone()) + .map_err(|err| format!("active internal browser state lock failed: {err}")) +} + +/// List inline WebViews that look like ORGII browser sessions. +#[tauri::command] +pub fn list_internal_browser_targets(app: AppHandle) -> Result { + let active = get_active_internal_browser_state()?; + let webviews = app.webviews(); + + let mut targets: Vec = webviews + .keys() + .filter(|label| label.starts_with(BROWSER_SESSION_LABEL_PREFIX)) + .map(|label| InternalBrowserTargetInfo { + label: label.clone(), + browser_session_id: browser_session_id_from_label(label), + is_active: active + .as_ref() + .is_some_and(|active_state| active_state.label == *label), + }) + .collect(); + + targets.sort_by(|left, right| left.label.cmp(&right.label)); + + let active_webview_exists = active + .as_ref() + .is_some_and(|active_state| webviews.contains_key(&active_state.label)); + + Ok(InternalBrowserTargetList { + active, + active_webview_exists, + webviews: targets, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(session_id: &str, updated_at: u64) -> ActiveInternalBrowserState { + ActiveInternalBrowserState { + browser_session_id: session_id.to_string(), + label: expected_label_for_session(session_id), + url: "https://example.com".to_string(), + visible: true, + updated_at, + } + } + + #[test] + fn validates_matching_browser_session_label() { + assert!(validate_active_state(&state("abc", 1)).is_ok()); + + let mut invalid = state("abc", 1); + invalid.label = "browser-session-other".to_string(); + + assert!(validate_active_state(&invalid).is_err()); + } + + #[test] + fn stale_clear_does_not_remove_newer_state() { + let current = state("abc", 20); + + assert!(!should_clear( + ¤t, + Some("browser-session-abc"), + Some("abc"), + Some(10) + )); + } + + #[test] + fn matching_clear_removes_current_state() { + let current = state("abc", 20); + + assert!(should_clear( + ¤t, + Some("browser-session-abc"), + Some("abc"), + Some(20) + )); + } + + #[test] + fn clear_for_other_label_is_ignored() { + let current = state("abc", 20); + + assert!(!should_clear( + ¤t, + Some("browser-session-other"), + Some("other"), + Some(25) + )); + } +} diff --git a/src-tauri/crates/browser/src/lib.rs b/src-tauri/crates/browser/src/lib.rs index ef73049975..2245041093 100644 --- a/src-tauri/crates/browser/src/lib.rs +++ b/src-tauri/crates/browser/src/lib.rs @@ -39,6 +39,7 @@ pub mod cookies; pub mod dom_editor; pub mod inline; pub mod internal_browser_commands; +pub mod internal_browser_state; pub mod layering; pub mod logging; pub mod screenshot_store; @@ -52,6 +53,7 @@ pub use cookies::*; pub use dom_editor::*; pub use inline::*; pub use internal_browser_commands::*; +pub use internal_browser_state::*; pub use layering::*; pub use logging::*; pub use screenshot_store::*; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 0daca6c6e6..2d25c26edd 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -99,6 +99,11 @@ browser::browser_inline_capture, browser::browser_webview_send_to_back, browser::browser_webview_bring_to_front, browser::browser_webviews_set_layer_for_all, +// Browser commands - Active internal browser target state +browser::set_active_internal_browser_state, +browser::clear_active_internal_browser_state, +browser::get_active_internal_browser_state, +browser::list_internal_browser_targets, // Browser commands - Internal browser automation (DOM automation for inline webviews) browser::internal_browser_get_state, browser::internal_browser_click, diff --git a/src/engines/BrowserCore/BrowserSessionWebview.tsx b/src/engines/BrowserCore/BrowserSessionWebview.tsx index 09acc03171..5c6acfb688 100644 --- a/src/engines/BrowserCore/BrowserSessionWebview.tsx +++ b/src/engines/BrowserCore/BrowserSessionWebview.tsx @@ -4,6 +4,7 @@ * Manages a single webview for a browser session. * Keeps the webview mounted but hidden when not active. */ +import { invoke } from "@tauri-apps/api/core"; import { useAtomValue } from "jotai"; import React, { useEffect, useMemo, useRef } from "react"; @@ -21,12 +22,37 @@ import { BrowserSession } from "@src/types/ui/tabs"; const log = createLogger("BrowserSessionWebview"); const ABOUT_BLANK_URL = "about:blank"; +const BROWSER_SESSION_LABEL_PREFIX = "browser-session-"; function isBlankBrowserUrl(url?: string): boolean { const normalizedUrl = url?.trim().toLowerCase(); return !normalizedUrl || normalizedUrl.startsWith(ABOUT_BLANK_URL); } +function getBrowserSessionWebviewLabel(sessionId: string): string { + return `${BROWSER_SESSION_LABEL_PREFIX}${sessionId}`; +} + +interface ActiveInternalBrowserSync { + browserSessionId: string; + label: string; + updatedAt: number; +} + +function clearActiveInternalBrowserState( + sync: ActiveInternalBrowserSync, + reason: string +): void { + void invoke("clear_active_internal_browser_state", { + label: sync.label, + browserSessionId: sync.browserSessionId, + reason, + updatedAt: sync.updatedAt, + }).catch((error) => { + log.warn("[BrowserSessionWebview] Failed to clear active state:", error); + }); +} + interface BrowserSessionWebviewProps { session: BrowserSession; isActive: boolean; @@ -63,9 +89,16 @@ const BrowserSessionWebview: React.FC = ({ // Track previous isLoading to detect reload requests const prevIsLoadingRef = useRef(session.isLoading); const isReloadingRef = useRef(false); + const activeInternalBrowserSyncRef = useRef( + null + ); + const webviewLabel = useMemo( + () => getBrowserSessionWebviewLabel(session.id), + [session.id] + ); + const hasNavigableUrl = !isBlankBrowserUrl(session.url); const webviewConfig = useMemo(() => { - const hasNavigableUrl = !isBlankBrowserUrl(session.url); const shouldActivateWebview = hasNavigableUrl && isActive && isTabActive; return { @@ -76,7 +109,7 @@ const BrowserSessionWebview: React.FC = ({ isActive: shouldActivateWebview, isVisible: shouldActivateWebview, // Use exact label (no UUID) so we can predict it for console log polling - labelPrefix: `browser-session-${session.id}`, + labelPrefix: webviewLabel, useExactLabel: true, incognito: session.incognito ?? false, debug: false, @@ -126,6 +159,7 @@ const BrowserSessionWebview: React.FC = ({ }; }, [ containerRef, + hasNavigableUrl, session.id, session.url, session.history, @@ -134,12 +168,72 @@ const BrowserSessionWebview: React.FC = ({ session.incognito, isActive, isTabActive, + webviewLabel, onSessionUpdate, onNewTab, ]); - const { pollNow, updatePosition, reload, isWebviewCreated } = - useInlineWebview(webviewConfig); + const { + pollNow, + updatePosition, + reload, + isWebviewAvailable, + isWebviewCreated, + } = useInlineWebview(webviewConfig); + + useEffect(() => { + if (!isWebviewAvailable) { + return; + } + + const sync: ActiveInternalBrowserSync = { + browserSessionId: session.id, + label: webviewLabel, + updatedAt: Date.now(), + }; + const shouldSyncActiveState = + hasNavigableUrl && isActive && isTabActive && isWebviewCreated; + + if (shouldSyncActiveState) { + activeInternalBrowserSyncRef.current = sync; + void invoke("set_active_internal_browser_state", { + state: { + browserSessionId: session.id, + label: webviewLabel, + url: session.url, + visible: true, + updatedAt: sync.updatedAt, + }, + }).catch((error) => { + log.warn("[BrowserSessionWebview] Failed to set active state:", error); + }); + + return () => { + clearActiveInternalBrowserState( + sync, + "browser-session-webview-cleanup" + ); + }; + } + + const previousSync = activeInternalBrowserSyncRef.current; + activeInternalBrowserSyncRef.current = null; + clearActiveInternalBrowserState( + previousSync ?? sync, + hasNavigableUrl + ? "browser-session-webview-inactive" + : "browser-session-webview-blank" + ); + }, [ + hasNavigableUrl, + isActive, + isTabActive, + isWebviewAvailable, + isWebviewCreated, + session.id, + session.url, + webviewLabel, + ]); // Handle reload requests: when isLoading goes from false to true // and webview already exists, trigger actual reload @@ -227,7 +321,7 @@ const BrowserSessionWebview: React.FC = ({ }, [pollNow]); // Only create webview for sessions with navigable URLs. - if (isBlankBrowserUrl(session.url)) { + if (!hasNavigableUrl) { return null; } From 8f902d1497ddc23ff14eeb24c3ab662d36c55ffa Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 00:02:59 +0800 Subject: [PATCH 034/864] fix(agent): use account context windows Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/core/providers/model_capabilities.rs | 51 +++++++++-- .../tests/model_capabilities_tests.rs | 84 ++++++++++++++++++- .../core/providers/tests/registry_tests.rs | 2 +- .../src/core/session/compaction/manual.rs | 3 +- .../core/session/turn/processor/compaction.rs | 6 +- .../core/session/turn/processor/execute.rs | 8 +- .../src/core/session/turn/processor/mod.rs | 2 +- .../tools/impls/orchestration/agent/mod.rs | 1 + .../agent-core/src/core/turn_executor/mod.rs | 24 ++++-- .../src/core/turn_executor/types.rs | 8 ++ .../memory/workspace_memory/auto_dream.rs | 1 + .../memory/workspace_memory/extract/runner.rs | 1 + .../src/tests/turn_executor_retry_tests.rs | 1 + .../crates/key-vault/src/commands/crud.rs | 3 + .../crates/key-vault/src/key_store/service.rs | 22 +++++ .../key-vault/src/key_store/tests/tests.rs | 1 + .../crates/key-vault/src/key_store/types.rs | 5 ++ .../key-vault/src/providers/anthropic/mod.rs | 34 ++++++-- .../src/providers/azure_openai/mod.rs | 37 +++++--- .../key-vault/src/providers/openai/mod.rs | 67 +++++++++++---- src-tauri/crates/key-vault/src/types.rs | 21 +++++ src/api/services/keyValidation.ts | 6 +- src/api/tauri/rpc/schemas/validation.ts | 5 ++ src/hooks/keyVault/useLocalKeys.ts | 10 ++- .../KeyVault/hooks/refreshAccountModels.ts | 53 +++++++++--- 25 files changed, 380 insertions(+), 76 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index 80ddfe1cb1..f37306548c 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -140,12 +140,27 @@ const FAMILY_RULES: &[FamilyRule] = &[ context_window: 1_000_000, thinking: ThinkingSupport::AlwaysOn, }, - // claude-opus-4.* (4.6, 4.7, 4.8 …): 1M context window. + // claude-opus-4.6+ upgraded to 1M; 4 / 4.1 / 4.5 stayed at 200K. FamilyRule { - pattern: "claude-opus-4", + pattern: "claude-opus-4.6", + context_window: 1_000_000, + thinking: ThinkingSupport::Optional, + }, + FamilyRule { + pattern: "claude-opus-4.7", context_window: 1_000_000, thinking: ThinkingSupport::Optional, }, + FamilyRule { + pattern: "claude-opus-4.8", + context_window: 1_000_000, + thinking: ThinkingSupport::Optional, + }, + FamilyRule { + pattern: "claude-opus-4", + context_window: 200_000, + thinking: ThinkingSupport::Optional, + }, // claude-sonnet-4.5: 200K. Must come BEFORE claude-sonnet-4 so the more // specific pattern wins. FamilyRule { @@ -528,13 +543,26 @@ const FAMILY_RULES: &[FamilyRule] = &[ /// Resolve capabilities for `model`, optionally consulting the KeyVault /// entry for `account_id`. /// -/// KeyVault only *upgrades* thinking knowledge (a user/observation row -/// saying "this model reasons" beats the family guess); context window -/// always comes from the family table or default since KeyVault does not -/// store it. +/// Resolution chain for the context window: +/// 1. **Static family table** ([`FAMILY_RULES`]) — the model's nominal +/// capability (e.g. opus-4.6 = 1M). +/// 2. **KeyVault override** — if the provider's `/v1/models` reported a +/// `context_length` for this model on this account (stored as +/// `ModelVariant.context_window`), it overrides the static value. This is +/// what makes a proxy that caps a 1M model at 256K show the *real* limit +/// instead of the nominal one. Absent (official OpenAI/Anthropic, which +/// don't expose `context_length`) → keep the static value. +/// +/// Thinking support is upgraded only (a KeyVault `reasoning` row beats the +/// family guess); context window can be either raised or lowered by the +/// provider override. pub fn resolve(model: &str, account_id: Option<&str>) -> ModelCapabilities { let mut caps = resolve_from_family_table(model); + if let Some(ctx) = resolve_context_from_keyvault(model, account_id) { + caps.context_window = ctx as usize; + } + if let Some(vault_thinking) = resolve_thinking_from_keyvault(model, account_id) { caps.thinking = vault_thinking; } @@ -542,6 +570,17 @@ pub fn resolve(model: &str, account_id: Option<&str>) -> ModelCapabilities { caps } +/// KeyVault layer for the context window: a `ModelVariant.context_window` +/// set during key validation (from the provider's `/v1/models` `context_length`) +/// overrides the static family default. Returns `None` when the provider did +/// not report one, leaving the family-table value in place. +fn resolve_context_from_keyvault(model: &str, account_id: Option<&str>) -> Option { + let account_id = account_id?; + let key = KEY_SERVICE.get_key_by_id(account_id)?; + let variant = key.model_variants.iter().find(|v| v.model == model)?; + variant.context_window +} + fn resolve_from_family_table(model: &str) -> ModelCapabilities { let normalized = super::model_hints::normalize_claude_shorthand(model); let model_lower = normalized.to_lowercase(); diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs index bcd54ea53c..c343fa5b2a 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs @@ -12,9 +12,20 @@ fn claude_fable_5_is_always_on() { #[test] fn claude_opus_4_is_optional() { - let caps = resolve("claude-opus-4-20250514", None); - assert_eq!(caps.thinking, ThinkingSupport::Optional); - assert_eq!(caps.context_window, 1_000_000); + // Only 4.6+ upgraded to 1M; 4 / 4.1 / 4.5 stayed at 200K. + assert_eq!( + resolve("claude-opus-4-20250514", None).context_window, + 200_000 + ); + assert_eq!(resolve("claude-opus-4.1", None).context_window, 200_000); + assert_eq!(resolve("claude-opus-4.5", None).context_window, 200_000); + assert_eq!(resolve("claude-opus-4.6", None).context_window, 1_000_000); + assert_eq!(resolve("claude-opus-4.7", None).context_window, 1_000_000); + assert_eq!(resolve("claude-opus-4.8", None).context_window, 1_000_000); + assert_eq!( + resolve("claude-opus-4", None).thinking, + ThinkingSupport::Optional + ); } #[test] @@ -372,3 +383,70 @@ fn no_substring_capability_checks_outside_this_module() { "Allowlist entries no longer contain a family substring check (remove them): {stale:?}" ); } + +// ── KeyVault context-window override (Issue #121 step 2) ── +// +// A `ModelVariant.context_window` recorded during key validation (from the +// provider's `/v1/models` `context_length`) overrides the static family +// table. This is what makes a proxy capping a 1M model at 256K show the +// real limit. Uses the global KEY_SERVICE with cleanup so tests stay isolated. + +use key_vault::key_store::KEY_SERVICE; +use key_vault::key_store::{ModelKey, ModelType, ModelVariant}; + +/// Build and register a key whose sole variant pins `model` to `ctx`, then +/// return the key id. Caller must `KEY_SERVICE.delete_key_by_id(id)` to clean up. +fn register_key_with_context(model: &str, ctx: Option) -> String { + let mut key = ModelKey::new(ModelType::AnthropicApi); + key.api_key = Some(format!("sk-test-{}-", key.id)); + key.model_variants = vec![ModelVariant { + model: model.to_string(), + base_model: model.to_string(), + reasoning: None, + fast: false, + context_window: ctx, + }]; + let id = key.id.clone(); + KEY_SERVICE.save_key(key).expect("save_key"); + id +} + +#[test] +fn keyvault_context_window_overrides_family_table() { + // opus-4.6 family rule = 1M; provider reports 256K → resolve must use 256K. + let id = register_key_with_context("claude-opus-4.6", Some(256_000)); + let caps = resolve("claude-opus-4.6", Some(&id)); + assert_eq!(caps.context_window, 256_000); + KEY_SERVICE.delete_key_by_id(&id).unwrap(); +} + +#[test] +fn keyvault_none_context_window_falls_back_to_family() { + // Provider did not report context_length (official OpenAI/Anthropic) → + // family-table value (200K) stays. + let id = register_key_with_context("claude-opus-4", None); + let caps = resolve("claude-opus-4", Some(&id)); + assert_eq!(caps.context_window, 200_000); + KEY_SERVICE.delete_key_by_id(&id).unwrap(); +} + +#[test] +fn keyvault_override_is_per_account() { + // A different account_id (no key) must NOT pick up another account's override. + let id = register_key_with_context("claude-opus-4.6", Some(131_072)); + let caps = resolve("claude-opus-4.6", Some("nonexistent-account")); + assert_eq!( + caps.context_window, 1_000_000, + "unknown account must fall back to family table, not leak another account's override" + ); + KEY_SERVICE.delete_key_by_id(&id).unwrap(); +} + +#[test] +fn keyvault_override_only_matches_exact_model() { + // Variant for "claude-opus-4.6" must not override a query for "claude-opus-4". + let id = register_key_with_context("claude-opus-4.6", Some(300_000)); + let caps = resolve("claude-opus-4", Some(&id)); + assert_eq!(caps.context_window, 200_000); + KEY_SERVICE.delete_key_by_id(&id).unwrap(); +} diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs index 72882e16e6..93f167bdf8 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs @@ -81,7 +81,7 @@ fn normalize_non_claude_passthrough() { #[test] fn context_window_hint_claude_models() { assert_eq!(context_window_hint("claude-sonnet-4-20250514"), 200_000); - assert_eq!(context_window_hint("claude-opus-4.5"), 1_000_000); + assert_eq!(context_window_hint("claude-opus-4.5"), 200_000); assert_eq!(context_window_hint("claude-3-5-sonnet"), 200_000); } diff --git a/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs b/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs index b38d0a1cd3..57786b3f4e 100644 --- a/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs +++ b/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs @@ -178,7 +178,8 @@ pub async fn run_manual_compact( let context_window = if runtime.resolved.context_window > 0 { runtime.resolved.context_window as usize } else { - crate::providers::model_hints::context_window_hint(&runtime.model) + crate::providers::model_capabilities::resolve(&runtime.model, runtime.account_id.as_deref()) + .context_window }; let (compacted, outcome) = { let mut compaction_state = session.compaction.lock().await; diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs index cde528d8be..c1ed6bc5ed 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs @@ -127,7 +127,11 @@ impl UnifiedMessageProcessor { let context_window = if self.runtime.resolved.context_window > 0 { self.runtime.resolved.context_window as usize } else { - crate::providers::model_hints::context_window_hint(&self.runtime.model) + crate::providers::model_capabilities::resolve( + &self.runtime.model, + self.runtime.account_id.as_deref(), + ) + .context_window }; let prefix_len = leading_runtime_system_prefix_len(messages); let prefix = messages[..prefix_len].to_vec(); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs index 134c271eee..d448507692 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs @@ -47,6 +47,7 @@ impl UnifiedMessageProcessor { let turn_config = TurnConfig { model: self.runtime.model.clone(), + account_id: self.runtime.account_id.clone(), max_iterations: self.effective_max_iterations(), max_tokens: self.runtime.resolved.max_tokens as u32, temperature: self.runtime.resolved.temperature as f32, @@ -188,8 +189,11 @@ impl UnifiedMessageProcessor { "[unified_processor] ContextTooLong hit for session {} — reactive compact attempt {}/{}", session_id, attempt, MAX_REACTIVE_RETRIES, ); - let context_window = - crate::providers::model_hints::context_window_hint(&self.runtime.model); + let context_window = crate::providers::model_capabilities::resolve( + &self.runtime.model, + self.runtime.account_id.as_deref(), + ) + .context_window; let mut state = self.compaction_state.lock().await; let (compacted, reactive_outcome) = ContextCompactor::compact( messages, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index b0cb9c86c5..b1da4aeea9 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -323,7 +323,7 @@ impl UnifiedMessageProcessor { (result.context_tokens > 0).then(|| { let context_window = crate::core::providers::model_capabilities::resolve( &self.runtime.model, - None, + self.runtime.account_id.as_deref(), ) .context_window as i64; ContextUsageSnapshot::from_payload( diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs index 9ab574df93..d54f5071fd 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs @@ -837,6 +837,7 @@ impl Tool for AgentTool { .unwrap_or(DEFAULT_SUBAGENT_MAX_ITERATIONS); let turn_config = TurnConfig { model: model.clone(), + account_id: self.config.session_account_id.clone(), max_iterations: Some(max_iterations), max_tokens: agent.max_tokens.unwrap_or(self.config.max_tokens as u64) as u32, temperature: agent.temperature.unwrap_or(self.config.temperature as f64) as f32, diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs index 3a59eac98d..a04052bf50 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs @@ -309,7 +309,11 @@ pub async fn execute_turn( if stats.chars_saved == 0 && stats.images_cleared == 0 { // Nothing left to clear — hard-truncate the history while // keeping the head (system prompt + task statement). - let window = crate::providers::model_hints::context_window_hint(&config.model); + let window = crate::providers::model_capabilities::resolve( + &config.model, + config.account_id.as_deref(), + ) + .context_window; let budget = window.saturating_mul(3) / 4; let truncated = crate::model_context::compaction::ContextCompactor::simple_truncate( @@ -345,14 +349,16 @@ pub async fn execute_turn( if !response.usage.is_empty() { usage.accumulate(&response.usage, session_id); - // Authoritative context window: the FAMILY_RULES resolver knows the - // model's real window (e.g. opus-4.x = 1M), so the frontend gauge no - // longer divides by a stale 200K and falsely shows "red / full". - // account_id is irrelevant here — KeyVault only upgrades thinking - // support, never the context window. - let context_window = - crate::core::providers::model_capabilities::resolve(&config.model, None) - .context_window as i64; + // Authoritative context window: FAMILY_RULES gives the model's + // nominal capability, optionally overridden by the provider's + // `/v1/models` context_length for this account (stored in + // KeyVault). This keeps the frontend gauge honest when a proxy + // caps a 1M model at 256K. + let context_window = crate::core::providers::model_capabilities::resolve( + &config.model, + config.account_id.as_deref(), + ) + .context_window as i64; let snapshot = ContextUsageSnapshot::from_payload( &llm_messages, &tool_defs, diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs index 9291b2a00b..cccc2e705f 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs @@ -71,6 +71,12 @@ pub trait TurnIterationHook: Send + Sync { pub struct TurnConfig { /// Model identifier (provider-specific). pub model: String, + /// KeyVault account id backing this turn. Threaded through so + /// `model_capabilities::resolve` can apply the provider-specific context + /// window override (from `/v1/models`). `None` for contexts without a + /// resolved key (tests, memory consolidation) — resolve then falls back + /// to the static family table. + pub account_id: Option, /// Maximum tool call iterations per turn. /// `None` means unlimited — the loop runs until the model stops calling tools /// (guarded by repeat detection, error loop detection, and cancellation). @@ -370,6 +376,7 @@ mod tests { fn turn_config_unlimited_iterations() { let config = TurnConfig { model: "test".to_string(), + account_id: None, max_iterations: None, max_tokens: 4096, temperature: 0.5, @@ -385,6 +392,7 @@ mod tests { fn turn_config_limited_iterations() { let config = TurnConfig { model: "test".to_string(), + account_id: None, max_iterations: Some(15), max_tokens: 4096, temperature: 0.5, diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs index 1011be3ba7..89655136b6 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs @@ -176,6 +176,7 @@ pub async fn run_consolidation( // Turn config let turn_config = TurnConfig { model: params.model.to_string(), + account_id: None, max_iterations: Some(MAX_CONSOLIDATION_TURNS), max_tokens: agent_def.max_tokens.unwrap_or(8192) as u32, temperature: agent_def.temperature.unwrap_or(0.0) as f32, diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs index 55b837a403..e5ffb5bbd7 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs @@ -90,6 +90,7 @@ pub async fn run_extraction( let turn_config = TurnConfig { model: params.model.to_string(), + account_id: None, max_iterations: Some(MAX_EXTRACTION_TURNS), max_tokens: agent_def.max_tokens.unwrap_or(4096) as u32, temperature: agent_def.temperature.unwrap_or(0.0) as f32, diff --git a/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs b/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs index ec4e68977c..1d6626d6aa 100644 --- a/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs +++ b/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs @@ -209,6 +209,7 @@ fn empty_policy() -> ResolvedToolPolicy { fn test_config() -> TurnConfig { TurnConfig { model: "mock-model".to_string(), + account_id: None, max_iterations: Some(50), max_tokens: 1024, temperature: 0.0, diff --git a/src-tauri/crates/key-vault/src/commands/crud.rs b/src-tauri/crates/key-vault/src/commands/crud.rs index 46791a922a..11c35056c7 100644 --- a/src-tauri/crates/key-vault/src/commands/crud.rs +++ b/src-tauri/crates/key-vault/src/commands/crud.rs @@ -571,6 +571,7 @@ pub async fn save_key(request: SaveKeyRequest) -> Result { base_model: variant.base_model, reasoning: variant.reasoning, fast: variant.fast, + context_window: None, }) .collect(); } @@ -698,6 +699,7 @@ pub async fn update_key_health( available_models: Option>, enabled_models: Option>, quota_info: Option, + model_context_lengths: Option>, ) -> Result, String> { tokio::task::spawn_blocking(move || { let status = match health_status.as_str() { @@ -718,6 +720,7 @@ pub async fn update_key_health( available_models, filtered_enabled, quota_info, + model_context_lengths.as_ref(), ) .and_then(|opt| opt.map(key_info_from_entry).transpose()) }) diff --git a/src-tauri/crates/key-vault/src/key_store/service.rs b/src-tauri/crates/key-vault/src/key_store/service.rs index d060a7e5eb..6b9fe862f9 100644 --- a/src-tauri/crates/key-vault/src/key_store/service.rs +++ b/src-tauri/crates/key-vault/src/key_store/service.rs @@ -304,6 +304,7 @@ impl KeyService { base_model: model.to_string(), reasoning: Some(reasoning.to_string()), fast: false, + context_window: None, }); } entry.updated_at = chrono::Utc::now(); @@ -1270,6 +1271,7 @@ impl KeyService { available_models: Option>, enabled_models: Option>, quota_info: Option, + model_context_lengths: Option<&HashMap>, ) -> Result, String> { self.update_store(|store| { if let Some(entry) = store.keys.get_mut(key_id) { @@ -1280,6 +1282,26 @@ impl KeyService { if let Some(models) = available_models { entry.available_models = models; } + if let Some(contexts) = model_context_lengths { + // find-or-push: provider-reported context windows override + // the static FAMILY_RULES default at runtime. Mirrors the + // reasoning writeback above. + for (model, ctx) in contexts { + if let Some(variant) = + entry.model_variants.iter_mut().find(|v| &v.model == model) + { + variant.context_window = Some(*ctx); + } else { + entry.model_variants.push(crate::key_store::ModelVariant { + model: model.clone(), + base_model: model.clone(), + reasoning: None, + fast: false, + context_window: Some(*ctx), + }); + } + } + } if let Some(enabled) = enabled_models { entry.enabled_models = enabled; } diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index 16c03b5a8b..8b3322faf0 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -156,6 +156,7 @@ fn test_e2e_with_real_keys() { Some(vec!["gpt-4".to_string(), "gpt-3.5-turbo".to_string()]), None, None, + None, ) .unwrap(); println!( diff --git a/src-tauri/crates/key-vault/src/key_store/types.rs b/src-tauri/crates/key-vault/src/key_store/types.rs index d74347d40d..bb7968153c 100644 --- a/src-tauri/crates/key-vault/src/key_store/types.rs +++ b/src-tauri/crates/key-vault/src/key_store/types.rs @@ -370,6 +370,11 @@ pub struct ModelVariant { pub reasoning: Option, #[serde(default)] pub fast: bool, + /// Context window (tokens) reported by this provider's `/v1/models` + /// endpoint, overriding the static `FAMILY_RULES` default at runtime. + /// `None` when the provider did not report one (official OpenAI/Anthropic). + #[serde(default)] + pub context_window: Option, } /// A user-chosen default variant for one base model family. `base_model` is diff --git a/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs b/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs index fd7d3bd7fd..c673292636 100644 --- a/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs @@ -15,6 +15,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::time::Duration; use tracing::{debug, info, warn}; @@ -32,6 +33,10 @@ struct ModelsResponse { #[derive(Debug, Deserialize)] struct ModelInfo { id: String, + /// Anthropic-compat proxies/aggregators expose the context window here; + /// official Anthropic omits it. + #[serde(default)] + context_length: Option, } /// Minimal messages request for proxy fallback validation @@ -109,7 +114,7 @@ impl AnthropicValidator { // Get available models — auth and model discovery are decoupled: // 401 = key invalid, other failures = key may work, user can add models manually. match self.get_models(api_key, base_url).await { - Ok(models) => { + Ok((models, contexts)) => { if models.is_empty() { warn!("[Anthropic] No models returned for key: {}", key_preview); let mut result = ValidationResult::success( @@ -132,7 +137,9 @@ impl AnthropicValidator { models.len(), &models[..models.len().min(3)] ); - ValidationResult::success("API key valid").with_models(models) + ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts) } Err(e) if e == "Invalid API key" => { warn!("[Anthropic] Proxy auth verification failed: {}", e); @@ -144,8 +151,9 @@ impl AnthropicValidator { "[Anthropic] Auth probe inconclusive, accepting with {} models", models.len() ); - let mut result = - ValidationResult::success("API key valid").with_models(models); + let mut result = ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts); result.is_degraded = true; result } @@ -157,7 +165,9 @@ impl AnthropicValidator { models.len(), &models[..models.len().min(3)] ); - ValidationResult::success("API key valid").with_models(models) + ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts) } } Err(models_err) if models_err == "Invalid API key" => { @@ -231,7 +241,7 @@ impl AnthropicValidator { &self, api_key: &str, base_url: Option<&str>, - ) -> Result, String> { + ) -> Result<(Vec, HashMap), String> { let url = base_url.unwrap_or(DEFAULT_API_URL); let endpoint = format!("{}/v1/models", url); debug!("[Anthropic] Fetching models from: {}", endpoint); @@ -259,9 +269,17 @@ impl AnthropicValidator { .await .map_err(|e| format!("Failed to parse response: {}", e))?; - let models: Vec = data.data.into_iter().map(|m| m.id).collect(); + let models = data.data; + let mut ids: Vec = Vec::with_capacity(models.len()); + let mut contexts: HashMap = HashMap::new(); + for m in models { + if let Some(ctx) = m.context_length { + contexts.insert(m.id.clone(), ctx); + } + ids.push(m.id); + } - Ok(models) + Ok((ids, contexts)) } /// Test the API key by sending a minimal messages request. diff --git a/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs b/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs index 947fbc0a7e..828d9b601a 100644 --- a/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs @@ -5,6 +5,7 @@ use reqwest::Client; use serde::Deserialize; +use std::collections::HashMap; use std::time::Duration; use crate::types::ValidationResult; @@ -20,6 +21,8 @@ struct ModelsResponse { #[derive(Debug, Deserialize)] struct ModelInfo { id: String, + #[serde(default)] + context_length: Option, } pub struct AzureOpenAIValidator { @@ -50,14 +53,16 @@ impl AzureOpenAIValidator { // Try listing models via the OpenAI-compatible models endpoint match self.get_models(api_key, base_url).await { - Ok(models) => { + Ok((models, contexts)) => { if models.is_empty() { // Models endpoint worked but returned empty — key is valid ValidationResult::success( "API key valid (no models listed — specify model names manually)", ) } else { - ValidationResult::success("API key valid").with_models(models) + ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts) } } Err(err) => { @@ -76,7 +81,11 @@ impl AzureOpenAIValidator { } } - async fn get_models(&self, api_key: &str, base_url: &str) -> Result, String> { + async fn get_models( + &self, + api_key: &str, + base_url: &str, + ) -> Result<(Vec, HashMap), String> { // Try two URL variants: // 1. Traditional Azure: {base}/models?api-version=... // 2. AI Foundry / plain OpenAI-compat: {base}/models (no api-version) @@ -100,7 +109,11 @@ impl AzureOpenAIValidator { Err(last_err) } - async fn try_get_models(&self, api_key: &str, endpoint: &str) -> Result, String> { + async fn try_get_models( + &self, + api_key: &str, + endpoint: &str, + ) -> Result<(Vec, HashMap), String> { let response = self .client .get(endpoint) @@ -126,14 +139,16 @@ impl AzureOpenAIValidator { .await .map_err(|err| format!("Failed to parse response: {}", err))?; - let models: Vec = data - .data - .unwrap_or_default() - .into_iter() - .map(|m| m.id) - .collect(); + let mut ids: Vec = Vec::new(); + let mut contexts: HashMap = HashMap::new(); + for m in data.data.unwrap_or_default() { + if let Some(ctx) = m.context_length { + contexts.insert(m.id.clone(), ctx); + } + ids.push(m.id); + } - Ok(models) + Ok((ids, contexts)) } pub fn validate_format(&self, api_key: &str) -> (bool, String) { diff --git a/src-tauri/crates/key-vault/src/providers/openai/mod.rs b/src-tauri/crates/key-vault/src/providers/openai/mod.rs index 1a6456cbf9..6c884558c3 100644 --- a/src-tauri/crates/key-vault/src/providers/openai/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/openai/mod.rs @@ -9,6 +9,7 @@ use log::{debug, info, warn}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::time::Duration; use crate::types::ValidationResult; @@ -40,6 +41,11 @@ struct ModelsResponse { #[derive(Debug, Deserialize)] struct ModelInfo { id: String, + /// OpenAI-compat aggregators/proxies (openrouter, zenmux, …) expose the + /// model's context window here; official OpenAI omits it. `#[serde(default)]` + /// keeps deserialization working for both. + #[serde(default)] + context_length: Option, } /// Minimal chat completion request for auth verification. @@ -110,7 +116,7 @@ impl OpenAIValidator { } match self.get_models(api_key, base_url, provider).await { - Ok(models) => { + Ok((models, contexts)) => { info!("[OpenAI] /v1/models returned {} models", models.len()); if models.is_empty() { // No models detected — try test_model for auth verification if available @@ -155,7 +161,9 @@ impl OpenAIValidator { match self.test_completion(api_key, url, &models[0]).await { Ok(()) => { info!("[OpenAI] Proxy auth verified, {} models", models.len()); - ValidationResult::success("API key valid").with_models(models) + ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts) } Err(e) if e == "Invalid API key" => { warn!("[OpenAI] Proxy auth failed: invalid API key"); @@ -163,8 +171,9 @@ impl OpenAIValidator { } Err(e) => { debug!("[OpenAI] Proxy completion test non-auth error: {}", e); - let mut result = - ValidationResult::success("API key valid").with_models(models); + let mut result = ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts); result.is_degraded = true; result } @@ -172,7 +181,9 @@ impl OpenAIValidator { } else { // Official API: /v1/models already validates auth info!("[OpenAI] Official API — {} models", models.len()); - ValidationResult::success("API key valid").with_models(models) + ValidationResult::success("API key valid") + .with_models(models) + .with_contexts(contexts) } } Err(e) if e == "Invalid API key" => { @@ -223,12 +234,15 @@ impl OpenAIValidator { /// If base_url already ends with `/v1`, append `/models` only to avoid doubling the path. /// When a custom base_url is provided (proxy/gateway), skip provider-specific filtering /// since the proxy may serve models from multiple providers. + /// + /// Returns the model ids alongside any `context_length` the endpoint exposed + /// (empty map when the endpoint only returns ids, e.g. official OpenAI). async fn get_models( &self, api_key: &str, base_url: Option<&str>, provider: Option<&str>, - ) -> Result, String> { + ) -> Result<(Vec, HashMap), String> { let url = base_url.unwrap_or(DEFAULT_API_URL).trim_end_matches('/'); // Support both /v1 (OpenAI standard) and /v4 (Zhipu API) let endpoint = if url.ends_with("/v1") || url.ends_with("/v4") { @@ -263,25 +277,42 @@ impl OpenAIValidator { .await .map_err(|e| format!("Failed to parse response: {}", e))?; - let all_ids: Vec = data.data.into_iter().map(|m| m.id).collect(); + let models = data.data; + let mut all_ids: Vec = Vec::with_capacity(models.len()); + let mut contexts: HashMap = HashMap::new(); + for m in models { + if let Some(ctx) = m.context_length { + contexts.insert(m.id.clone(), ctx); + } + all_ids.push(m.id); + } let has_custom_url = base_url.is_some(); - let useful_models = if has_custom_url { - all_ids + let (useful_ids, useful_contexts) = if has_custom_url { + (all_ids, contexts) } else { match model_prefixes_for_provider(provider) { - Some(prefixes) => all_ids - .into_iter() - .filter(|id| { - let id_lower = id.to_lowercase(); - prefixes.iter().any(|prefix| id_lower.contains(prefix)) - }) - .collect(), - None => all_ids, + Some(prefixes) => { + let kept: std::collections::HashSet = all_ids + .iter() + .filter(|id| { + let id_lower = id.to_lowercase(); + prefixes.iter().any(|prefix| id_lower.contains(prefix)) + }) + .cloned() + .collect(); + let filtered_contexts = contexts + .into_iter() + .filter(|(id, _)| kept.contains(id)) + .collect(); + let filtered_ids = all_ids.into_iter().filter(|id| kept.contains(id)).collect(); + (filtered_ids, filtered_contexts) + } + None => (all_ids, contexts), } }; - Ok(useful_models) + Ok((useful_ids, useful_contexts)) } /// Verify the API key by sending a minimal chat completion request. diff --git a/src-tauri/crates/key-vault/src/types.rs b/src-tauri/crates/key-vault/src/types.rs index 2711708bbb..126bb7c412 100644 --- a/src-tauri/crates/key-vault/src/types.rs +++ b/src-tauri/crates/key-vault/src/types.rs @@ -2,6 +2,8 @@ //! //! These types mirror the Python `orgii_shared.validation.types` module. +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; /// Single usage type (plan, on_demand, chat, completions, premium, etc.) @@ -92,6 +94,14 @@ pub struct ValidationResult { /// List of available model IDs #[serde(default)] pub models_available: Vec, + /// Per-model context window (tokens) reported by the provider's + /// `/v1/models` endpoint. Empty for providers that only return ids + /// (official OpenAI/Anthropic); populated by OpenAI-compat proxies and + /// aggregators that expose `context_length`. Consumed at runtime to + /// override the static `FAMILY_RULES` defaults — see + /// `agent_core::providers::model_capabilities::resolve`. + #[serde(default)] + pub model_context_lengths: HashMap, /// List of disabled/unavailable model IDs #[serde(default)] pub disabled_models: Vec, @@ -112,6 +122,7 @@ impl ValidationResult { valid: true, message: message.to_string(), models_available: Vec::new(), + model_context_lengths: HashMap::new(), disabled_models: Vec::new(), is_degraded: false, quota_info: None, @@ -125,6 +136,7 @@ impl ValidationResult { valid: false, message: message.to_string(), models_available: Vec::new(), + model_context_lengths: HashMap::new(), disabled_models: Vec::new(), is_degraded: false, quota_info: None, @@ -138,6 +150,15 @@ impl ValidationResult { self } + /// Attach per-model context windows reported by the provider. Only the + /// OpenAI-compat providers (openai/anthropic-proxy/azure) that expose + /// `context_length` on `/v1/models` call this; other providers leave the + /// map empty and the runtime falls back to the static family table. + pub fn with_contexts(mut self, contexts: HashMap) -> Self { + self.model_context_lengths = contexts; + self + } + /// Set quota info pub fn with_quota(mut self, quota: QuotaInfo) -> Self { self.quota_info = Some(quota); diff --git a/src/api/services/keyValidation.ts b/src/api/services/keyValidation.ts index 763def96f6..e5128fab11 100644 --- a/src/api/services/keyValidation.ts +++ b/src/api/services/keyValidation.ts @@ -23,6 +23,7 @@ import type { GeminiOauthStartResponse, HealthStatus, KeyInfo, + ModelContextLengths, ModelType, ProviderProtocol, QuotaInfo, @@ -45,6 +46,7 @@ export type { GeminiOauthStartResponse, HealthStatus, KeyInfo, + ModelContextLengths, ProviderProtocol, QuotaInfo, SaveKeyRequest, @@ -298,7 +300,8 @@ export async function updateKeyHealth( errorMessage?: string, availableModels?: string[], enabledModels?: string[], - quotaInfo?: QuotaInfo + quotaInfo?: QuotaInfo, + modelContextLengths?: ModelContextLengths ): Promise { return rpc.validation.updateKeyHealth({ keyId, @@ -307,6 +310,7 @@ export async function updateKeyHealth( availableModels: availableModels ?? null, enabledModels: enabledModels ?? null, quotaInfo: quotaInfo ?? null, + modelContextLengths: modelContextLengths ?? null, }); } diff --git a/src/api/tauri/rpc/schemas/validation.ts b/src/api/tauri/rpc/schemas/validation.ts index 7718e9c87d..fa66976ba0 100644 --- a/src/api/tauri/rpc/schemas/validation.ts +++ b/src/api/tauri/rpc/schemas/validation.ts @@ -132,10 +132,13 @@ export const QuotaInfoSchema = z.object({ named_message: z.string().nullable(), }); +export const ModelContextLengthsSchema = z.record(z.string(), z.number()); + export const ValidationResultSchema = z.object({ valid: z.boolean(), message: z.string(), models_available: z.array(z.string()), + model_context_lengths: ModelContextLengthsSchema.default({}), disabled_models: z.array(z.string()), is_degraded: z.boolean(), quota_info: QuotaInfoSchema.nullable(), @@ -416,6 +419,7 @@ export const UpdateKeyHealthInput = z.object({ availableModels: z.array(z.string()).nullable().optional(), enabledModels: z.array(z.string()).nullable().optional(), quotaInfo: z.record(z.string(), z.unknown()).nullable().optional(), + modelContextLengths: ModelContextLengthsSchema.nullable().optional(), }); export const GetEnvForAgentInput = z.object({ @@ -582,6 +586,7 @@ export type MergeStatus = z.infer; export type PriceTier = z.infer; export type UsageItem = z.infer; export type QuotaInfo = z.infer; +export type ModelContextLengths = z.infer; export type ValidationResult = z.infer; export type ProviderProtocol = z.infer; export type KeyInfo = z.infer; diff --git a/src/hooks/keyVault/useLocalKeys.ts b/src/hooks/keyVault/useLocalKeys.ts index ecd0487877..61bd313c04 100644 --- a/src/hooks/keyVault/useLocalKeys.ts +++ b/src/hooks/keyVault/useLocalKeys.ts @@ -169,7 +169,10 @@ export function useLocalKeys( fullKey.id, result.valid ? "valid" : "invalid", result.valid ? undefined : result.message, - result.models_available + result.models_available, + undefined, + undefined, + result.model_context_lengths ); const updated = await getKey(agentType, keyId); @@ -357,7 +360,10 @@ export function useLocalKeys( fullKey.id, result.valid ? "valid" : "invalid", result.valid ? undefined : result.message, - modelsToSave + modelsToSave, + undefined, + undefined, + result.model_context_lengths ); } else { return false; diff --git a/src/modules/MainApp/Integrations/KeyVault/hooks/refreshAccountModels.ts b/src/modules/MainApp/Integrations/KeyVault/hooks/refreshAccountModels.ts index bbf33e5eec..5ba148b5ea 100644 --- a/src/modules/MainApp/Integrations/KeyVault/hooks/refreshAccountModels.ts +++ b/src/modules/MainApp/Integrations/KeyVault/hooks/refreshAccountModels.ts @@ -24,6 +24,7 @@ * "invalid" so the row reflects that the user needs to re-add the account. */ import { + type ModelContextLengths, getClaudeCodeOAuthModels, getCodexOAuthModels, getCursorNativeModels, @@ -65,9 +66,14 @@ function isOAuthAccount(account: KeyVaultAccount): boolean { return account.authMethod === "oauth"; } +interface FetchedAccountModels { + models: string[]; + modelContextLengths: ModelContextLengths; +} + async function fetchModelsForAccount( account: KeyVaultAccount -): Promise { +): Promise { const fullKey = await getFullKey(account.modelType, account.id); if (!fullKey) { throw new RefreshModelsError( @@ -85,7 +91,10 @@ async function fetchModelsForAccount( "unsupported" ); } - return getCursorNativeModels(token); + return { + models: await getCursorNativeModels(token), + modelContextLengths: {}, + }; } case CLI_AGENT.CLAUDE_CODE: { if (!isOAuthAccount(account)) { @@ -99,7 +108,10 @@ async function fetchModelsForAccount( "auth_expired" ); } - return getClaudeCodeOAuthModels(token); + return { + models: await getClaudeCodeOAuthModels(token), + modelContextLengths: {}, + }; } case CLI_AGENT.CODEX: { if (!isOAuthAccount(account)) { @@ -113,7 +125,10 @@ async function fetchModelsForAccount( ); } const idToken = fullKey.env_vars?.CODEX_ID_TOKEN; - return getCodexOAuthModels(token, idToken); + return { + models: await getCodexOAuthModels(token, idToken), + modelContextLengths: {}, + }; } case CLI_AGENT.GEMINI: { if (!isOAuthAccount(account)) { @@ -133,7 +148,10 @@ async function fetchModelsForAccount( const projectId = fullKey.env_vars?.GOOGLE_CLOUD_PROJECT ?? fullKey.env_vars?.GOOGLE_CLOUD_PROJECT_ID; - return getGeminiOAuthModels(token, projectId); + return { + models: await getGeminiOAuthModels(token, projectId), + modelContextLengths: {}, + }; } } @@ -158,7 +176,10 @@ async function fetchModelsForAccount( "auth_expired" ); } - return result.models_available ?? []; + return { + models: result.models_available ?? [], + modelContextLengths: result.model_context_lengths, + }; } export interface RefreshAccountModelsResult { @@ -169,10 +190,10 @@ export async function refreshAccountModels( account: KeyVaultAccount ): Promise { const previousHealth = account.healthStatus ?? "valid"; - let models: string[]; + let fetched: FetchedAccountModels; try { - models = await fetchModelsForAccount(account); + fetched = await fetchModelsForAccount(account); } catch (firstErr) { // Narrow-path 401 retry: only for OAuth accounts, only once. Uses the // same per-provider refresh helpers that the agent runtime calls on 401 @@ -195,7 +216,7 @@ export async function refreshAccountModels( ); } try { - models = await fetchModelsForAccount(account); + fetched = await fetchModelsForAccount(account); } catch (retryErr) { await updateKeyHealth( account.id, @@ -219,7 +240,7 @@ export async function refreshAccountModels( } } - if (models.length === 0) { + if (fetched.models.length === 0) { throw new RefreshModelsError( "Provider returned an empty model list", "transient" @@ -230,7 +251,15 @@ export async function refreshAccountModels( // selection and any newly discovered models end up in the "addable" bucket // by default (this is the no-silent-enable invariant memorialised in // .orgii/workspace-memory/feedback_new_resources_default_addable.md). - await updateKeyHealth(account.id, previousHealth, undefined, models); + await updateKeyHealth( + account.id, + previousHealth, + undefined, + fetched.models, + undefined, + undefined, + fetched.modelContextLengths + ); - return { models }; + return { models: fetched.models }; } From d82de5a3005cb3d1d5df8a266e92659f6d7c3a80 Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 00:54:57 +0800 Subject: [PATCH 035/864] fix(chat): strip markdown URL boundaries --- .../blocks/MessageReferenceCards.helpers.ts | 5 +--- .../helpers/__tests__/resultParsers.test.ts | 15 ++++++++++++ .../__tests__/MessageReferenceCards.test.ts | 16 +++++++++++++ src/util/url/browserUrl.test.ts | 17 +++++++++++++ src/util/url/browserUrl.ts | 7 +++++- src/util/url/validation.test.ts | 24 +++++++++++++++++++ src/util/url/validation.ts | 18 +++++++++++--- 7 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/util/url/browserUrl.test.ts diff --git a/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts b/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts index d88f6fb615..035f67be10 100644 --- a/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts +++ b/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts @@ -5,7 +5,6 @@ import { createSessionIdTextPattern } from "@src/util/session/sessionDispatch"; import { normalizeHttpUrlCandidate } from "@src/util/url/validation"; const WEB_URL_PATTERN = /https?:\/\/[^\s<>"'`\])}]+/gi; -const TRAILING_REFERENCE_PUNCTUATION_PATTERN = /[.,;:!?]+$/; const MAX_REFERENCE_CARDS = 4; export type MessageReferenceKind = @@ -43,9 +42,7 @@ function stripFencedCodeBlocks(content: string): string { } function normalizeUrlCandidate(candidate: string): string | null { - return normalizeHttpUrlCandidate( - candidate.replace(TRAILING_REFERENCE_PUNCTUATION_PATTERN, "") - ); + return normalizeHttpUrlCandidate(candidate, { stripTextBoundaries: true }); } function isUrlCitedInParentheses( diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/resultParsers.test.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/resultParsers.test.ts index 54f868705f..89d4111b15 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/resultParsers.test.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/resultParsers.test.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it } from "vitest"; +import { parseWebsiteCardResult } from "../cardParsers"; import { buildWorkspaceInfoRows, extractResultText, @@ -15,6 +16,20 @@ import { parseSearchFilesResult, } from "../resultParsers"; +// ── parseWebsiteCardResult ─────────────────────────────────────────────────── + +describe("parseWebsiteCardResult", () => { + it("rejects malformed URL card data", () => { + const card = parseWebsiteCardResult( + "browser", + { url: "https://exa*mple.com/docs" }, + {} + ); + + expect(card).toBeNull(); + }); +}); + // ── extractResultText ───────────────────────────────────────────────────────── describe("extractResultText", () => { diff --git a/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts b/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts index e45d8860b4..9a615077c3 100644 --- a/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts +++ b/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts @@ -135,6 +135,22 @@ staged file lint stats }); }); + it("strips trailing markdown emphasis markers from URL cards", () => { + const references = extractMessageReferences( + [ + "Docs: **https://example.com/docs.**", + "Mirror: *https://mirror.example.com/path*", + "Old: ~~https://old.example.com/docs~~", + ].join("\n") + ); + + expect(references.map((item) => item.value)).toEqual([ + "https://example.com/docs", + "https://mirror.example.com/path", + "https://old.example.com/docs", + ]); + }); + it("does not extract template placeholder hosts as URL cards", () => { const references = extractMessageReferences( "The server logs http://localhost:1998 and http://${host}/" diff --git a/src/util/url/browserUrl.test.ts b/src/util/url/browserUrl.test.ts new file mode 100644 index 0000000000..93fb6df34e --- /dev/null +++ b/src/util/url/browserUrl.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeBrowserInput } from "./browserUrl"; + +describe("normalizeBrowserInput", () => { + it("preserves valid trailing characters in explicit URLs", () => { + expect(normalizeBrowserInput("https://example.com/search?q=foo*")).toBe( + "https://example.com/search?q=foo*" + ); + }); + + it("searches instead of navigating malformed explicit URLs", () => { + expect(normalizeBrowserInput("https://exa*mple.com")).toBe( + "https://www.google.com/search?q=https%3A%2F%2Fexa*mple.com" + ); + }); +}); diff --git a/src/util/url/browserUrl.ts b/src/util/url/browserUrl.ts index 1669536962..10f6802937 100644 --- a/src/util/url/browserUrl.ts +++ b/src/util/url/browserUrl.ts @@ -1,3 +1,5 @@ +import { normalizeHttpUrlCandidate } from "./validation"; + const SEARCH_URL_PREFIX = "https://www.google.com/search?q="; const HTTP_PROTOCOL = "http:"; @@ -10,8 +12,11 @@ function toSearchUrl(query: string): string { } function parseHttpUrl(candidate: string): URL | null { + const normalized = normalizeHttpUrlCandidate(candidate); + if (!normalized) return null; + try { - const parsedUrl = new URL(candidate); + const parsedUrl = new URL(normalized); if ( (parsedUrl.protocol === HTTP_PROTOCOL || parsedUrl.protocol === HTTPS_PROTOCOL) && diff --git a/src/util/url/validation.test.ts b/src/util/url/validation.test.ts index 946663457a..295e05a00e 100644 --- a/src/util/url/validation.test.ts +++ b/src/util/url/validation.test.ts @@ -33,6 +33,28 @@ describe("normalizeHttpUrlCandidate", () => { ).toBe("https://example.com/$%7Bpath%7D?q={value}"); }); + it("preserves valid trailing URL characters by default", () => { + expect(normalizeHttpUrlCandidate("https://example.com/docs!")).toBe( + "https://example.com/docs!" + ); + expect(normalizeHttpUrlCandidate("https://example.com/search?q=foo*")).toBe( + "https://example.com/search?q=foo*" + ); + }); + + it("strips trailing markdown and sentence boundaries for text URL candidates", () => { + const options = { stripTextBoundaries: true }; + expect( + normalizeHttpUrlCandidate("https://example.com/docs.**", options) + ).toBe("https://example.com/docs"); + expect( + normalizeHttpUrlCandidate("https://example.com/docs*", options) + ).toBe("https://example.com/docs"); + expect( + normalizeHttpUrlCandidate("https://example.com/docs~~", options) + ).toBe("https://example.com/docs"); + }); + it("rejects non-HTTP schemes and template placeholder hosts", () => { expect(normalizeHttpUrlCandidate("file:///tmp/app.log")).toBeNull(); expect(normalizeHttpUrlCandidate("http://${host}/")).toBeNull(); @@ -45,6 +67,8 @@ describe("normalizeHttpUrlCandidate", () => { it("rejects malformed or unsafe authorities", () => { expect(normalizeHttpUrlCandidate("https://example.com other")).toBeNull(); expect(normalizeHttpUrlCandidate("https://exa mple.com")).toBeNull(); + expect(normalizeHttpUrlCandidate("https://exa*mple.com")).toBeNull(); + expect(normalizeHttpUrlCandidate("https://exa~mple.com")).toBeNull(); expect(normalizeHttpUrlCandidate("http://example.com:99999")).toBeNull(); expect(normalizeHttpUrlCandidate("http:///missing-host")).toBeNull(); }); diff --git a/src/util/url/validation.ts b/src/util/url/validation.ts index f776c4f4bb..b01bbdb4a8 100644 --- a/src/util/url/validation.ts +++ b/src/util/url/validation.ts @@ -1,5 +1,11 @@ -const INVALID_AUTHORITY_CHARACTER_PATTERN = /[$`{}<>"'\\\s]/; +const INVALID_AUTHORITY_CHARACTER_PATTERN = /[$`{}<>"'\\\s*~]/; const IPV6_AUTHORITY_PATTERN = /^\[[0-9a-f:.]+\](?::\d+)?$/i; +const TRAILING_TEXT_URL_BOUNDARY_PATTERN = /(?:[.,;:!?]+|\*+|_{2,}|~{2,})+$/; + +interface NormalizeHttpUrlCandidateOptions { + stripTextBoundaries?: boolean; +} + function getRawAuthority(candidate: string): string | null { const authorityMatch = candidate.match(/^https?:\/\/([^/?#]*)/i); return authorityMatch?.[1] ?? null; @@ -21,8 +27,14 @@ function hasInvalidParsedPort(port: string): boolean { return !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65_535; } -export function normalizeHttpUrlCandidate(candidate: string): string | null { - const trimmed = candidate.trim(); +export function normalizeHttpUrlCandidate( + candidate: string, + options: NormalizeHttpUrlCandidateOptions = {} +): string | null { + const base = candidate.trim(); + const trimmed = options.stripTextBoundaries + ? base.replace(TRAILING_TEXT_URL_BOUNDARY_PATTERN, "") + : base; if (!trimmed) return null; const rawAuthority = getRawAuthority(trimmed); From 4e73996ad349a7d4e25caeffadd57d44587e12b2 Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 01:13:11 +0800 Subject: [PATCH 036/864] fix(agent): round-trip context_window through save path The save_key command mapped SaveKeyRequest.model_variants to ModelVariant with context_window hardcoded to None, so any value written by update_key_health was erased on the next save. ModelVariantInfo now carries context_window end-to-end (key_info_from_entry, FullKeyResponse, save_key) via an extracted From conversion, and the TS schema accepts it as a nonnegative integer. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../crates/key-vault/src/commands/crud.rs | 30 +++++++---- .../key-vault/src/commands/tests/tests.rs | 29 +++++++++++ .../key-vault/src/key_store/tests/tests.rs | 52 +++++++++++++++++++ src/api/tauri/rpc/schemas/validation.ts | 1 + 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src-tauri/crates/key-vault/src/commands/crud.rs b/src-tauri/crates/key-vault/src/commands/crud.rs index 11c35056c7..6838052d7a 100644 --- a/src-tauri/crates/key-vault/src/commands/crud.rs +++ b/src-tauri/crates/key-vault/src/commands/crud.rs @@ -35,6 +35,23 @@ pub struct ModelVariantInfo { pub base_model: String, pub reasoning: Option, pub fast: bool, + /// Context window reported by the provider's `/v1/models` endpoint. + /// Round-tripped so a subsequent `save_key` carrying `model_variants` + /// doesn't erase the value written by `update_key_health`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, +} + +impl From for ModelVariant { + fn from(v: ModelVariantInfo) -> Self { + ModelVariant { + model: v.model, + base_model: v.base_model, + reasoning: v.reasoning, + fast: v.fast, + context_window: v.context_window, + } + } } /// Serializable per-base-model default variant for API responses @@ -290,6 +307,7 @@ impl From for KeyInfo { base_model: variant.base_model.clone(), reasoning: variant.reasoning.clone(), fast: variant.fast, + context_window: variant.context_window, }) .collect(), default_variants: entry @@ -410,6 +428,7 @@ impl From for FullKeyResponse { base_model: variant.base_model, reasoning: variant.reasoning, fast: variant.fast, + context_window: variant.context_window, }) .collect(), default_variants: entry @@ -564,16 +583,7 @@ pub async fn save_key(request: SaveKeyRequest) -> Result { .collect(); } if let Some(variants) = request.model_variants { - entry.model_variants = variants - .into_iter() - .map(|variant| ModelVariant { - model: variant.model, - base_model: variant.base_model, - reasoning: variant.reasoning, - fast: variant.fast, - context_window: None, - }) - .collect(); + entry.model_variants = variants.into_iter().map(ModelVariant::from).collect(); } if let Some(default_variants) = request.default_variants { entry.default_variants = default_variants diff --git a/src-tauri/crates/key-vault/src/commands/tests/tests.rs b/src-tauri/crates/key-vault/src/commands/tests/tests.rs index 182441f1db..cfc6691c86 100644 --- a/src-tauri/crates/key-vault/src/commands/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/commands/tests/tests.rs @@ -140,3 +140,32 @@ fn test_infer_install_unknown() { None ); } + +/// Guards the `save_key` command's `SaveKeyRequest.model_variants` -> +/// `ModelVariant` mapping (crud.rs). A regression that hardcodes +/// `context_window: None` here would silently erase provider-reported context +/// windows on every save, so this test must exercise the conversion directly +/// (not the storage layer, which preserves the field trivially). +#[test] +fn test_model_variant_info_to_variant_preserves_context_window() { + use crate::commands::crud::ModelVariantInfo; + use crate::key_store::ModelVariant; + + let with_ctx = ModelVariantInfo { + model: "gpt-4o".to_string(), + base_model: "gpt-4o".to_string(), + reasoning: None, + fast: false, + context_window: Some(128_000), + }; + assert_eq!(ModelVariant::from(with_ctx).context_window, Some(128_000)); + + let without_ctx = ModelVariantInfo { + model: "gpt-4o".to_string(), + base_model: "gpt-4o".to_string(), + reasoning: None, + fast: false, + context_window: None, + }; + assert_eq!(ModelVariant::from(without_ctx).context_window, None); +} diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index 8b3322faf0..6d2d01fd66 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -176,6 +176,58 @@ fn test_e2e_with_real_keys() { println!("\n=== E2E Test Complete ===\n"); } +/// `update_key_health` writes provider-reported context windows onto +/// `model_variants`; a subsequent `save_key` (e.g. the user editing an +/// unrelated field) must preserve them. Regression guard for the +/// account-aware context-window feature. +#[test] +fn test_context_window_survives_save_key_roundtrip() { + let temp_dir = tempdir().unwrap(); + let service = KeyService::new(Some(temp_dir.path().to_path_buf())); + + let mut cred = ModelKey::new(ModelType::OpenaiApi); + cred.name = Some("Ctx Test".to_string()); + cred.api_key = Some("sk-test".to_string()); + let saved = service.save_key(cred).unwrap(); + + // Provider's /v1/models reports a context window for this account. + let mut contexts = HashMap::new(); + contexts.insert("gpt-4o".to_string(), 128_000u64); + service + .update_key_health( + &saved.id, + HealthStatus::Valid, + None, + Some(vec!["gpt-4o".to_string()]), + None, + None, + Some(&contexts), + ) + .unwrap(); + + let loaded = service.get_key_by_id(&saved.id).unwrap(); + let variant = loaded + .model_variants + .iter() + .find(|v| v.model == "gpt-4o") + .expect("gpt-4o variant written by update_key_health"); + assert_eq!(variant.context_window, Some(128_000)); + + // Round-trip through save_key with the entry as-is. + service.save_key(loaded).unwrap(); + let reloaded = service.get_key_by_id(&saved.id).unwrap(); + let variant_after = reloaded + .model_variants + .iter() + .find(|v| v.model == "gpt-4o") + .unwrap(); + assert_eq!( + variant_after.context_window, + Some(128_000), + "save_key must not erase provider-reported context_window" + ); +} + /// Debug test to check parsing of real credentials file #[test] fn test_parse_real_credentials_file() { diff --git a/src/api/tauri/rpc/schemas/validation.ts b/src/api/tauri/rpc/schemas/validation.ts index fa66976ba0..c74a8556cf 100644 --- a/src/api/tauri/rpc/schemas/validation.ts +++ b/src/api/tauri/rpc/schemas/validation.ts @@ -156,6 +156,7 @@ export const ModelVariantInfoSchema = z.object({ base_model: z.string(), reasoning: z.string().nullable().optional(), fast: z.boolean().default(false), + context_window: z.number().int().nonnegative().nullable().optional(), }); export const DefaultVariantInfoSchema = z.object({ From 41dc5cdfb806d9b7dc64d292f907a9e8f7ccb657 Mon Sep 17 00:00:00 2001 From: yushui2022 Date: Sun, 28 Jun 2026 10:50:43 +0800 Subject: [PATCH 037/864] feat(browser): expose read-only internal browser tool Pre-commit hook ran. Total eslint: 21, total circular: 0 --- src-tauri/Cargo.lock | 1 + src-tauri/crates/agent-core/Cargo.toml | 1 + .../src/core/tools/builtin_tools/table/web.rs | 8 +- .../impls/web/control_internal_browser.rs | 347 +++++++++++++++++- .../src/core/tools/registration/web.rs | 6 +- 5 files changed, 340 insertions(+), 23 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 398790de80..bee8919a25 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "backoff", "base64 0.22.1", "blake3", + "browser", "bytes", "chrono", "chrono-tz", diff --git a/src-tauri/crates/agent-core/Cargo.toml b/src-tauri/crates/agent-core/Cargo.toml index 4865121899..6418ef4d08 100644 --- a/src-tauri/crates/agent-core/Cargo.toml +++ b/src-tauri/crates/agent-core/Cargo.toml @@ -27,6 +27,7 @@ core_types = { path = "../types" } app_paths = { path = "../app-paths" } app_platform = { path = "../app-platform" } app_utils = { path = "../app-utils" } +browser = { path = "../browser" } # Runtime infrastructure agent_core composes against (no back-edges from # any of these crates into agent_core — IoC slots inside agent_core diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs index 1ab0746c82..24c8dfda6b 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs @@ -40,6 +40,8 @@ const AGENT_BROWSER_CLI_ACTIONS: &[ActionEntry] = &[ ]; const INTERNAL_BROWSER_ACTION_ICONS: &[(&str, &str)] = &[ + ("list", "list"), + ("is_ready", "circle-check"), ("get_state", "scan-eye"), ("click", "mouse-pointer-click"), ("input", "text-cursor-input"), @@ -51,6 +53,8 @@ const INTERNAL_BROWSER_ACTION_ICONS: &[(&str, &str)] = &[ ]; const INTERNAL_BROWSER_ACTIONS: &[ActionEntry] = &[ + action_sub!("list", "List internal browser targets", SubInternalBrowser, labels: "tools.internalBrowserRunning", "tools.internalBrowserDone", "tools.internalBrowserFailed"), + action_sub!("is_ready", "Check whether the active internal browser Page Agent is ready", SubInternalBrowser, labels: "tools.internalBrowserRunning", "tools.internalBrowserDone", "tools.internalBrowserFailed"), action_sub!("get_state", "Read the internal browser state", SubInternalBrowser, labels: "tools.internalBrowserGetStateRunning", "tools.internalBrowserGetStateDone", "tools.internalBrowserGetStateFailed"), action_sub!("click", "Click an indexed element in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserClickRunning", "tools.internalBrowserClickDone", "tools.internalBrowserClickFailed"), action_sub!("input", "Input text into an indexed element in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserInputRunning", "tools.internalBrowserInputDone", "tools.internalBrowserInputFailed"), @@ -208,8 +212,8 @@ pub(super) static TOOLS: &[ToolEntry] = &[ }, ToolEntry { name: tool_names::CONTROL_INTERNAL_BROWSER, - description: "Internal browser automation is currently unavailable to agents.", - description_detail: "Agents should use the selected external browser CLI provider tool for browser automation, or ask the user to use the Workstation Browser UI. Frontend/Tauri Workstation Browser commands remain available outside the agent tool runtime.", + description: "Inspect the currently visible ORGII internal Browser WebView.", + description_detail: "Targets the active internal browser-session WebView tracked by the frontend lifecycle. This read-only stage exposes list, is_ready, and get_state through the embedded Tauri/WebView Page Agent rather than external Chrome or Playwright, and never accepts arbitrary WebView labels from the model.", category: tool_categories::WEB, icon_id: "mouse-pointer-click", simulator_app: AppBrowser, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs index f4711d1de5..9f6e8456bb 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs @@ -1,24 +1,256 @@ -//! Agent-facing internal browser tool stub. +//! Agent-facing internal browser tool. //! -//! Frontend/Tauri inline webview commands remain available through their -//! runtime command surfaces. Agents must use `control_external_browser` for -//! browser automation until internal browser automation is implemented for the -//! agent tool runtime. +//! The tool only resolves the currently visible ORGII internal browser +//! WebView. Agents do not provide arbitrary labels; all actions go through the +//! active target tracked by the frontend-owned BrowserSessionWebview lifecycle. use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use tauri::AppHandle; use crate::tools::categories as tool_categories; use crate::tools::names as tool_names; -use crate::tools::traits::{Tool, ToolError}; +use crate::tools::traits::{params_schema, parse_params_described, Tool, ToolError, ToolPriority}; -const INTERNAL_BROWSER_UNAVAILABLE_MESSAGE: &str = "Internal browser automation is currently unavailable to agents. Use control_external_browser for browser automation, or ask the user to use the Workstation Browser UI."; +const INTERNAL_BROWSER_NOT_READY_MESSAGE: &str = + "Internal browser automation requires a running Tauri app handle."; -pub struct InternalBrowserTool; +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum InternalBrowserAction { + /// List known internal browser targets and the currently active target. + List, + /// Check whether the active internal browser Page Agent is ready. + IsReady, + /// Read the active internal browser DOM state. + GetState, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InternalBrowserParams { + /// Read-only internal browser action to perform. + action: InternalBrowserAction, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserToolTarget { + browser_session_id: String, + label: String, + url: String, + active_webview_exists: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserTargetSummary { + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + browser_session_id: Option, + is_active: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserListResponse { + success: bool, + action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + active: Option, + active_webview_exists: bool, + webviews: Vec, + message: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserReadyResponse { + success: bool, + action: &'static str, + ready: bool, + #[serde(skip_serializing_if = "Option::is_none")] + target: Option, + message: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserStateResponse { + success: bool, + action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + state: Option, + message: String, +} + +pub struct InternalBrowserTool { + app_handle: Option, +} impl InternalBrowserTool { - pub fn new() -> Self { - Self + pub fn new(app_handle: Option) -> Self { + Self { app_handle } + } + + fn app_handle(&self) -> Result { + self.app_handle + .clone() + .ok_or_else(|| ToolError::ExecutionFailed(INTERNAL_BROWSER_NOT_READY_MESSAGE.into())) + } + + fn response_text(response: &T) -> Result { + serde_json::to_string_pretty(response).map_err(|err| { + ToolError::ExecutionFailed(format!( + "Failed to serialize internal browser response: {err}" + )) + }) + } + + async fn execute_list(&self) -> Result { + let app = self.app_handle()?; + let targets = browser::list_internal_browser_targets(app).map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to list internal browser targets: {err}")) + })?; + let active = active_target(&targets); + let response = InternalBrowserListResponse { + success: true, + action: "list", + active, + active_webview_exists: targets.active_webview_exists, + webviews: targets + .webviews + .into_iter() + .map(|target| InternalBrowserTargetSummary { + label: target.label, + browser_session_id: target.browser_session_id, + is_active: target.is_active, + }) + .collect(), + message: "Listed internal browser targets.".to_string(), + }; + Self::response_text(&response) + } + + async fn execute_is_ready(&self) -> Result { + let app = self.app_handle()?; + let targets = browser::list_internal_browser_targets(app.clone()).map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to list internal browser targets: {err}")) + })?; + let Some(target) = resolvable_active_target(&targets) else { + let response = InternalBrowserReadyResponse { + success: false, + action: "is_ready", + ready: false, + target: active_target(&targets), + message: inactive_target_message(&targets), + }; + return Self::response_text(&response); + }; + + let ready = browser::internal_browser_is_ready(app, target.label.clone()) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to check Page Agent readiness: {err}")) + })?; + let response = InternalBrowserReadyResponse { + success: true, + action: "is_ready", + ready, + target: Some(target), + message: if ready { + "Page Agent is ready in the active internal browser.".to_string() + } else { + "Page Agent is not ready in the active internal browser.".to_string() + }, + }; + Self::response_text(&response) + } + + async fn execute_get_state(&self) -> Result { + let app = self.app_handle()?; + let targets = browser::list_internal_browser_targets(app.clone()).map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to list internal browser targets: {err}")) + })?; + let Some(target) = resolvable_active_target(&targets) else { + let response = InternalBrowserStateResponse { + success: false, + action: "get_state", + target: active_target(&targets), + state: None, + message: inactive_target_message(&targets), + }; + return Self::response_text(&response); + }; + + let ready = browser::internal_browser_is_ready(app.clone(), target.label.clone()) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to check Page Agent readiness: {err}")) + })?; + if !ready { + let response = InternalBrowserStateResponse { + success: false, + action: "get_state", + target: Some(target), + state: None, + message: "Page Agent is not ready in the active internal browser.".to_string(), + }; + return Self::response_text(&response); + } + + let state = browser::internal_browser_get_state(app, target.label.clone()) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to read internal browser state: {err}")) + })?; + let response = InternalBrowserStateResponse { + success: true, + action: "get_state", + target: Some(target), + state: Some(state), + message: "Read active internal browser state.".to_string(), + }; + Self::response_text(&response) + } +} + +fn active_target( + targets: &browser::InternalBrowserTargetList, +) -> Option { + targets + .active + .as_ref() + .map(|active| InternalBrowserToolTarget { + browser_session_id: active.browser_session_id.clone(), + label: active.label.clone(), + url: active.url.clone(), + active_webview_exists: targets.active_webview_exists, + }) +} + +fn resolvable_active_target( + targets: &browser::InternalBrowserTargetList, +) -> Option { + if !targets.active_webview_exists { + return None; + } + active_target(targets) +} + +fn inactive_target_message(targets: &browser::InternalBrowserTargetList) -> String { + if targets.active.is_none() { + "No active internal browser WebView is available. Open an internal Browser tab first." + .to_string() + } else if !targets.active_webview_exists { + "The tracked active internal browser WebView no longer exists. Activate a Browser tab again." + .to_string() + } else { + "No resolvable active internal browser WebView is available.".to_string() } } @@ -33,24 +265,99 @@ impl Tool for InternalBrowserTool { } fn description(&self) -> &str { - INTERNAL_BROWSER_UNAVAILABLE_MESSAGE + "Inspect the currently visible ORGII internal Browser WebView. This read-only step supports list, is_ready, and get_state; DOM actions are added separately." + } + + fn is_ready(&self) -> bool { + self.app_handle.is_some() + } + + fn not_ready_reason(&self) -> Option<&str> { + if self.app_handle.is_some() { + None + } else { + Some(INTERNAL_BROWSER_NOT_READY_MESSAGE) + } + } + + fn search_hint(&self) -> &str { + "internal browser webview tauri webview2 dom page agent get_state is_ready" } fn parameters(&self) -> Value { - serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }) + params_schema::() } async fn execute_text( &self, - _params: Value, + params: Value, _ctx: &crate::tools::traits::CallContext, ) -> Result { - Err(ToolError::ExecutionFailed( - INTERNAL_BROWSER_UNAVAILABLE_MESSAGE.to_string(), - )) + let params: InternalBrowserParams = parse_params_described(params)?; + match params.action { + InternalBrowserAction::List => self.execute_list().await, + InternalBrowserAction::IsReady => self.execute_is_ready().await, + InternalBrowserAction::GetState => self.execute_get_state().await, + } + } + + fn is_read_only(&self) -> bool { + true + } + + fn priority(&self) -> ToolPriority { + ToolPriority::Always + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn active_state(session_id: &str) -> browser::ActiveInternalBrowserState { + browser::ActiveInternalBrowserState { + browser_session_id: session_id.to_string(), + label: format!("browser-session-{session_id}"), + url: "https://example.com".to_string(), + visible: true, + updated_at: 10, + } + } + + fn target_list( + active: Option, + active_webview_exists: bool, + ) -> browser::InternalBrowserTargetList { + browser::InternalBrowserTargetList { + active, + active_webview_exists, + webviews: Vec::new(), + } + } + + #[test] + fn resolves_active_target_only_when_webview_exists() { + let targets = target_list(Some(active_state("abc")), true); + let target = resolvable_active_target(&targets).expect("target should resolve"); + + assert_eq!(target.browser_session_id, "abc"); + assert_eq!(target.label, "browser-session-abc"); + assert!(target.active_webview_exists); + } + + #[test] + fn refuses_stale_active_target_without_webview() { + let targets = target_list(Some(active_state("abc")), false); + + assert!(resolvable_active_target(&targets).is_none()); + assert!(inactive_target_message(&targets).contains("no longer exists")); + } + + #[test] + fn reports_missing_active_target() { + let targets = target_list(None, false); + + assert!(resolvable_active_target(&targets).is_none()); + assert!(inactive_target_message(&targets).contains("No active")); } } diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/web.rs b/src-tauri/crates/agent-core/src/core/tools/registration/web.rs index a6ed8405ff..f5bf1b027b 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/web.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/web.rs @@ -51,5 +51,9 @@ pub async fn register(registry: &mut ToolRegistry, deps: &ToolDeps, disabled: &H } } - register_if_enabled(registry, Box::new(InternalBrowserTool::new()), disabled); + register_if_enabled( + registry, + Box::new(InternalBrowserTool::new(deps.app_handle.clone())), + disabled, + ); } From 5d0adfb9d1ab3fab028cb0b3d47cdc65b99e5c8c Mon Sep 17 00:00:00 2001 From: yushui2022 Date: Sun, 28 Jun 2026 10:54:53 +0800 Subject: [PATCH 038/864] feat(browser): add guarded internal browser actions Pre-commit hook ran. Total eslint: 21, total circular: 0 --- .../src/core/tools/builtin_tools/table/web.rs | 10 +- .../impls/web/control_internal_browser.rs | 235 +++++++++++++++++- 2 files changed, 230 insertions(+), 15 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs index 24c8dfda6b..7d5b3d2c55 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs @@ -47,9 +47,6 @@ const INTERNAL_BROWSER_ACTION_ICONS: &[(&str, &str)] = &[ ("input", "text-cursor-input"), ("select", "list-filter"), ("scroll", "move-vertical"), - ("show_mask", "eye"), - ("hide_mask", "eye-off"), - ("clean_up", "sparkles"), ]; const INTERNAL_BROWSER_ACTIONS: &[ActionEntry] = &[ @@ -60,9 +57,6 @@ const INTERNAL_BROWSER_ACTIONS: &[ActionEntry] = &[ action_sub!("input", "Input text into an indexed element in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserInputRunning", "tools.internalBrowserInputDone", "tools.internalBrowserInputFailed"), action_sub!("select", "Select an option in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserSelectRunning", "tools.internalBrowserSelectDone", "tools.internalBrowserSelectFailed"), action_sub!("scroll", "Scroll the internal browser viewport", SubInternalBrowser, labels: "tools.internalBrowserScrollRunning", "tools.internalBrowserScrollDone", "tools.internalBrowserScrollFailed"), - action_sub!("show_mask", "Show the internal browser element mask", SubInternalBrowser, labels: "tools.internalBrowserShowMaskRunning", "tools.internalBrowserShowMaskDone", "tools.internalBrowserShowMaskFailed"), - action_sub!("hide_mask", "Hide the internal browser element mask", SubInternalBrowser, labels: "tools.internalBrowserHideMaskRunning", "tools.internalBrowserHideMaskDone", "tools.internalBrowserHideMaskFailed"), - action_sub!("clean_up", "Clean up internal browser overlays", SubInternalBrowser, labels: "tools.internalBrowserCleanUpRunning", "tools.internalBrowserCleanUpDone", "tools.internalBrowserCleanUpFailed"), ]; const PLAYWRIGHT_CLI_ACTIONS: &[ActionEntry] = &[ @@ -212,8 +206,8 @@ pub(super) static TOOLS: &[ToolEntry] = &[ }, ToolEntry { name: tool_names::CONTROL_INTERNAL_BROWSER, - description: "Inspect the currently visible ORGII internal Browser WebView.", - description_detail: "Targets the active internal browser-session WebView tracked by the frontend lifecycle. This read-only stage exposes list, is_ready, and get_state through the embedded Tauri/WebView Page Agent rather than external Chrome or Playwright, and never accepts arbitrary WebView labels from the model.", + description: "Inspect and control the currently visible ORGII internal Browser WebView.", + description_detail: "Targets the active internal browser-session WebView tracked by the frontend lifecycle. Exposes list, is_ready, get_state, click, input, select, and scroll through the embedded Tauri/WebView Page Agent rather than external Chrome or Playwright, and never accepts arbitrary WebView labels from the model.", category: tool_categories::WEB, icon_id: "mouse-pointer-click", simulator_app: AppBrowser, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs index 9f6e8456bb..ec7c2620a4 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs @@ -26,13 +26,59 @@ enum InternalBrowserAction { IsReady, /// Read the active internal browser DOM state. GetState, + /// Click an indexed element in the active internal browser. + Click, + /// Replace text in an indexed input, textarea, or contenteditable element. + Input, + /// Select an option by visible text in an indexed select element. + Select, + /// Scroll the active page or an indexed scrollable element. + Scroll, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum InternalBrowserScrollDirection { + Up, + Down, + Left, + Right, +} + +impl InternalBrowserScrollDirection { + fn as_page_agent_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + } + } } #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct InternalBrowserParams { - /// Read-only internal browser action to perform. + /// Internal browser action to perform. action: InternalBrowserAction, + /// Highlight index from get_state. Required for click, input, and select. + #[serde(default)] + index: Option, + /// Text to write into the target element. Required for input. + #[serde(default)] + text: Option, + /// Visible option text to select. Required for select. + #[serde(default)] + option: Option, + /// Direction to scroll. Required for scroll. + #[serde(default)] + direction: Option, + /// Number of viewport pages to scroll. Defaults to 1.0. + #[serde(default)] + pages: Option, + /// Optional highlight index of a scrollable element. Omit to scroll the page. + #[serde(default)] + element_index: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -88,6 +134,16 @@ struct InternalBrowserStateResponse { message: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InternalBrowserActionResponse { + success: bool, + action: &'static str, + target: InternalBrowserToolTarget, + result: browser::InternalBrowserActionResult, + message: String, +} + pub struct InternalBrowserTool { app_handle: Option, } @@ -111,6 +167,33 @@ impl InternalBrowserTool { }) } + async fn resolve_ready_target( + &self, + ) -> Result<(AppHandle, InternalBrowserToolTarget), ToolError> { + let app = self.app_handle()?; + let targets = browser::list_internal_browser_targets(app.clone()).map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to list internal browser targets: {err}")) + })?; + let Some(target) = resolvable_active_target(&targets) else { + return Err(ToolError::ExecutionFailed(inactive_target_message( + &targets, + ))); + }; + + let ready = browser::internal_browser_is_ready(app.clone(), target.label.clone()) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to check Page Agent readiness: {err}")) + })?; + if !ready { + return Err(ToolError::ExecutionFailed( + "Page Agent is not ready in the active internal browser.".to_string(), + )); + } + + Ok((app, target)) + } + async fn execute_list(&self) -> Result { let app = self.app_handle()?; let targets = browser::list_internal_browser_targets(app).map_err(|err| { @@ -217,6 +300,114 @@ impl InternalBrowserTool { }; Self::response_text(&response) } + + async fn execute_click(&self, params: &InternalBrowserParams) -> Result { + let index = required_index(params, "click")?; + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_click(app, target.label.clone(), index) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to click element: {err}")))?; + let response = InternalBrowserActionResponse { + success: result.success, + action: "click", + target, + message: result.message.clone(), + result, + }; + Self::response_text(&response) + } + + async fn execute_input(&self, params: &InternalBrowserParams) -> Result { + let index = required_index(params, "input")?; + let text = params + .text + .clone() + .ok_or_else(|| ToolError::InvalidParams("input requires text".to_string()))?; + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_input(app, target.label.clone(), index, text) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to input text: {err}")))?; + let response = InternalBrowserActionResponse { + success: result.success, + action: "input", + target, + message: result.message.clone(), + result, + }; + Self::response_text(&response) + } + + async fn execute_select(&self, params: &InternalBrowserParams) -> Result { + let index = required_index(params, "select")?; + let option = params + .option + .clone() + .ok_or_else(|| ToolError::InvalidParams("select requires option".to_string()))?; + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_select(app, target.label.clone(), index, option) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to select option: {err}")))?; + let response = InternalBrowserActionResponse { + success: result.success, + action: "select", + target, + message: result.message.clone(), + result, + }; + Self::response_text(&response) + } + + async fn execute_scroll(&self, params: &InternalBrowserParams) -> Result { + let direction = params + .direction + .ok_or_else(|| ToolError::InvalidParams("scroll requires direction".to_string()))?; + if let Some(pages) = params.pages { + if !pages.is_finite() || pages <= 0.0 { + return Err(ToolError::InvalidParams( + "scroll pages must be a positive finite number".to_string(), + )); + } + } + if let Some(element_index) = params.element_index { + validate_index(element_index, "elementIndex")?; + } + + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_scroll( + app, + target.label.clone(), + direction.as_page_agent_str().to_string(), + params.pages, + params.element_index, + ) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to scroll: {err}")))?; + let response = InternalBrowserActionResponse { + success: result.success, + action: "scroll", + target, + message: result.message.clone(), + result, + }; + Self::response_text(&response) + } +} + +fn required_index(params: &InternalBrowserParams, action: &str) -> Result { + let index = params + .index + .ok_or_else(|| ToolError::InvalidParams(format!("{action} requires index")))?; + validate_index(index, "index")?; + Ok(index) +} + +fn validate_index(index: i64, field: &str) -> Result<(), ToolError> { + if index < 0 { + return Err(ToolError::InvalidParams(format!( + "{field} must be greater than or equal to 0" + ))); + } + Ok(()) } fn active_target( @@ -265,7 +456,7 @@ impl Tool for InternalBrowserTool { } fn description(&self) -> &str { - "Inspect the currently visible ORGII internal Browser WebView. This read-only step supports list, is_ready, and get_state; DOM actions are added separately." + "Inspect and control the currently visible ORGII internal Browser WebView. Resolves only the active internal browser target and supports list, is_ready, get_state, click, input, select, and scroll." } fn is_ready(&self) -> bool { @@ -281,7 +472,7 @@ impl Tool for InternalBrowserTool { } fn search_hint(&self) -> &str { - "internal browser webview tauri webview2 dom page agent get_state is_ready" + "internal browser webview tauri webview2 dom page agent get_state click input select scroll is_ready" } fn parameters(&self) -> Value { @@ -298,13 +489,13 @@ impl Tool for InternalBrowserTool { InternalBrowserAction::List => self.execute_list().await, InternalBrowserAction::IsReady => self.execute_is_ready().await, InternalBrowserAction::GetState => self.execute_get_state().await, + InternalBrowserAction::Click => self.execute_click(¶ms).await, + InternalBrowserAction::Input => self.execute_input(¶ms).await, + InternalBrowserAction::Select => self.execute_select(¶ms).await, + InternalBrowserAction::Scroll => self.execute_scroll(¶ms).await, } } - fn is_read_only(&self) -> bool { - true - } - fn priority(&self) -> ToolPriority { ToolPriority::Always } @@ -360,4 +551,34 @@ mod tests { assert!(resolvable_active_target(&targets).is_none()); assert!(inactive_target_message(&targets).contains("No active")); } + + #[test] + fn validates_required_action_index() { + let params = InternalBrowserParams { + action: InternalBrowserAction::Click, + index: None, + text: None, + option: None, + direction: None, + pages: None, + element_index: None, + }; + + assert!(required_index(¶ms, "click").is_err()); + } + + #[test] + fn rejects_negative_action_index() { + let params = InternalBrowserParams { + action: InternalBrowserAction::Click, + index: Some(-1), + text: None, + option: None, + direction: None, + pages: None, + element_index: None, + }; + + assert!(required_index(¶ms, "click").is_err()); + } } From b82e9140ca024cf1951a4ecfddf960819b5cbbb0 Mon Sep 17 00:00:00 2001 From: yushui2022 Date: Sun, 28 Jun 2026 10:58:10 +0800 Subject: [PATCH 039/864] fix(browser): harden internal browser lifecycle guards Pre-commit hook ran. Total eslint: 21, total circular: 0 --- .../impls/web/control_internal_browser.rs | 44 ++++++++++++++++--- .../browser/src/internal_browser_state.rs | 15 ++++++- .../BrowserCore/BrowserSessionWebview.tsx | 23 +++++++++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs index ec7c2620a4..634f5605fc 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs @@ -361,13 +361,7 @@ impl InternalBrowserTool { let direction = params .direction .ok_or_else(|| ToolError::InvalidParams("scroll requires direction".to_string()))?; - if let Some(pages) = params.pages { - if !pages.is_finite() || pages <= 0.0 { - return Err(ToolError::InvalidParams( - "scroll pages must be a positive finite number".to_string(), - )); - } - } + validate_scroll_pages(params.pages)?; if let Some(element_index) = params.element_index { validate_index(element_index, "elementIndex")?; } @@ -410,6 +404,17 @@ fn validate_index(index: i64, field: &str) -> Result<(), ToolError> { Ok(()) } +fn validate_scroll_pages(pages: Option) -> Result<(), ToolError> { + if let Some(pages) = pages { + if !pages.is_finite() || pages <= 0.0 { + return Err(ToolError::InvalidParams( + "scroll pages must be a positive finite number".to_string(), + )); + } + } + Ok(()) +} + fn active_target( targets: &browser::InternalBrowserTargetList, ) -> Option { @@ -581,4 +586,29 @@ mod tests { assert!(required_index(¶ms, "click").is_err()); } + + #[test] + fn validates_scroll_pages() { + assert!(validate_scroll_pages(None).is_ok()); + assert!(validate_scroll_pages(Some(1.0)).is_ok()); + assert!(validate_scroll_pages(Some(0.0)).is_err()); + assert!(validate_scroll_pages(Some(f64::NAN)).is_err()); + } + + #[test] + fn maps_scroll_direction_for_page_agent() { + assert_eq!(InternalBrowserScrollDirection::Up.as_page_agent_str(), "up"); + assert_eq!( + InternalBrowserScrollDirection::Down.as_page_agent_str(), + "down" + ); + assert_eq!( + InternalBrowserScrollDirection::Left.as_page_agent_str(), + "left" + ); + assert_eq!( + InternalBrowserScrollDirection::Right.as_page_agent_str(), + "right" + ); + } } diff --git a/src-tauri/crates/browser/src/internal_browser_state.rs b/src-tauri/crates/browser/src/internal_browser_state.rs index f00f0b2902..f7d0f33035 100644 --- a/src-tauri/crates/browser/src/internal_browser_state.rs +++ b/src-tauri/crates/browser/src/internal_browser_state.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; const BROWSER_SESSION_LABEL_PREFIX: &str = "browser-session-"; +const ABOUT_BLANK_URL: &str = "about:blank"; static ACTIVE_INTERNAL_BROWSER: OnceLock>> = OnceLock::new(); @@ -59,7 +60,8 @@ fn validate_active_state(state: &ActiveInternalBrowserState) -> Result<(), Strin if !state.visible { return Err("active internal browser state must be visible".to_string()); } - if state.url.trim().is_empty() || state.url.trim().eq_ignore_ascii_case("about:blank") { + let normalized_url = state.url.trim().to_ascii_lowercase(); + if normalized_url.is_empty() || normalized_url.starts_with(ABOUT_BLANK_URL) { return Err("active internal browser url must be navigable".to_string()); } Ok(()) @@ -234,6 +236,17 @@ mod tests { assert!(validate_active_state(&invalid).is_err()); } + #[test] + fn rejects_blank_active_browser_urls() { + let mut empty = state("abc", 1); + empty.url = " ".to_string(); + assert!(validate_active_state(&empty).is_err()); + + let mut about_blank = state("abc", 1); + about_blank.url = "about:blank#blocked".to_string(); + assert!(validate_active_state(&about_blank).is_err()); + } + #[test] fn stale_clear_does_not_remove_newer_state() { let current = state("abc", 20); diff --git a/src/engines/BrowserCore/BrowserSessionWebview.tsx b/src/engines/BrowserCore/BrowserSessionWebview.tsx index 5c6acfb688..939beacf11 100644 --- a/src/engines/BrowserCore/BrowserSessionWebview.tsx +++ b/src/engines/BrowserCore/BrowserSessionWebview.tsx @@ -40,9 +40,13 @@ interface ActiveInternalBrowserSync { } function clearActiveInternalBrowserState( - sync: ActiveInternalBrowserSync, + sync: ActiveInternalBrowserSync | null, reason: string ): void { + if (!sync) { + return; + } + void invoke("clear_active_internal_browser_state", { label: sync.label, browserSessionId: sync.browserSessionId, @@ -123,11 +127,23 @@ const BrowserSessionWebview: React.FC = ({ }, onError: (error: string | Error) => { log.error("[BrowserSessionWebview] WebView error:", error); + clearActiveInternalBrowserState( + activeInternalBrowserSyncRef.current, + "browser-session-webview-error" + ); + activeInternalBrowserSyncRef.current = null; onSessionUpdate(session.id, { error: typeof error === "string" ? error : error.message, isLoading: false, }); }, + onDestroyed: () => { + clearActiveInternalBrowserState( + activeInternalBrowserSyncRef.current, + "browser-session-webview-destroyed" + ); + activeInternalBrowserSyncRef.current = null; + }, onNavigate: (url: string) => { if (!isBlankBrowserUrl(url) && url !== session.url) { const newHistory = [ @@ -183,6 +199,11 @@ const BrowserSessionWebview: React.FC = ({ useEffect(() => { if (!isWebviewAvailable) { + clearActiveInternalBrowserState( + activeInternalBrowserSyncRef.current, + "browser-session-webview-unavailable" + ); + activeInternalBrowserSyncRef.current = null; return; } From bb66da94e5d82952204a89c4adebf99e89030942 Mon Sep 17 00:00:00 2001 From: yushui2022 Date: Sun, 28 Jun 2026 14:14:46 +0800 Subject: [PATCH 040/864] feat(browser): complete internal WebView automation v1 Pre-commit hook ran. Total eslint: 21, total circular: 0 --- .../src/core/definitions/builtin/os.rs | 28 +- .../src/core/tools/builtin_tools/table/web.rs | 12 +- .../agent-core/src/core/tools/defaults.rs | 10 +- .../impls/web/control_internal_browser.rs | 225 ++++++++-- .../browser/src/internal_browser_commands.rs | 390 +++++++++++------- .../BrowserCore/BrowserSessionWebview.tsx | 127 ++++-- .../Browser/SessionReplay/BrowserSidebar.tsx | 7 + .../SessionReplay/__tests__/config.test.ts | 34 ++ .../Browser/SessionReplay/config.ts | 86 +++- .../Browser/SessionReplay/types.ts | 6 + src/test/vitest.setup.ts | 6 + 11 files changed, 700 insertions(+), 231 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs b/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs index bd2214a7e8..8e68662db5 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs @@ -2,7 +2,7 @@ //! //! The OS agent specializes in desktop automation tasks: //! - Desktop control through the bundled Peekaboo CLI -//! - Browser automation through bundled browser control CLIs +//! - Browser automation through bundled browser control CLIs and the internal WebView //! //! It uses a singleton session model (one global session). @@ -25,7 +25,7 @@ pub const OS_AGENT_ID: &str = "builtin:os"; /// /// Capabilities: /// - Desktop automation through the bundled Peekaboo CLI -/// - Browser automation through bundled browser control CLIs +/// - Browser automation through bundled browser control CLIs and the internal WebView /// /// Session Model: /// - Singleton (single global session) @@ -41,7 +41,7 @@ pub fn os_agent() -> AgentDefinition { desktop: Some(DesktopCapability { enabled: true }), browser: Some(BrowserCapability { external: true, - internal: false, + internal: true, }), coding: None, gateway: None, @@ -172,6 +172,28 @@ mod tests { ); } + #[test] + fn os_agent_has_browser_automation_capability() { + let caps = os_agent().capabilities.expect("OS Agent declares caps"); + let browser = caps.browser.expect("OS Agent declares browser capability"); + assert!( + browser.external, + "OS Agent should keep external browser automation available" + ); + assert!( + browser.internal, + "OS Agent should expose internal WebView browser automation" + ); + assert!( + !os_agent() + .tools + .excluded_tools + .iter() + .any(|tool| tool == tool_names::CONTROL_INTERNAL_BROWSER), + "OS Agent must not exclude control_internal_browser" + ); + } + #[test] fn os_agent_subagents_exclude_runtime_primitives() { // Regression pin: `builtin:explore` / `builtin:general` are runtime diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs index 7d5b3d2c55..2e2d88a107 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/web.rs @@ -41,12 +41,15 @@ const AGENT_BROWSER_CLI_ACTIONS: &[ActionEntry] = &[ const INTERNAL_BROWSER_ACTION_ICONS: &[(&str, &str)] = &[ ("list", "list"), - ("is_ready", "circle-check"), - ("get_state", "scan-eye"), + ("is_ready", "check-circle-2"), + ("get_state", "eye"), ("click", "mouse-pointer-click"), ("input", "text-cursor-input"), ("select", "list-filter"), ("scroll", "move-vertical"), + ("show_mask", "shield"), + ("hide_mask", "shield-off"), + ("clean_up", "sparkle"), ]; const INTERNAL_BROWSER_ACTIONS: &[ActionEntry] = &[ @@ -57,6 +60,9 @@ const INTERNAL_BROWSER_ACTIONS: &[ActionEntry] = &[ action_sub!("input", "Input text into an indexed element in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserInputRunning", "tools.internalBrowserInputDone", "tools.internalBrowserInputFailed"), action_sub!("select", "Select an option in the internal browser", SubInternalBrowser, labels: "tools.internalBrowserSelectRunning", "tools.internalBrowserSelectDone", "tools.internalBrowserSelectFailed"), action_sub!("scroll", "Scroll the internal browser viewport", SubInternalBrowser, labels: "tools.internalBrowserScrollRunning", "tools.internalBrowserScrollDone", "tools.internalBrowserScrollFailed"), + action_sub!("show_mask", "Show the internal browser element mask", SubInternalBrowser, labels: "tools.internalBrowserShowMaskRunning", "tools.internalBrowserShowMaskDone", "tools.internalBrowserShowMaskFailed"), + action_sub!("hide_mask", "Hide the internal browser element mask", SubInternalBrowser, labels: "tools.internalBrowserHideMaskRunning", "tools.internalBrowserHideMaskDone", "tools.internalBrowserHideMaskFailed"), + action_sub!("clean_up", "Clean up internal browser overlays", SubInternalBrowser, labels: "tools.internalBrowserCleanUpRunning", "tools.internalBrowserCleanUpDone", "tools.internalBrowserCleanUpFailed"), ]; const PLAYWRIGHT_CLI_ACTIONS: &[ActionEntry] = &[ @@ -207,7 +213,7 @@ pub(super) static TOOLS: &[ToolEntry] = &[ ToolEntry { name: tool_names::CONTROL_INTERNAL_BROWSER, description: "Inspect and control the currently visible ORGII internal Browser WebView.", - description_detail: "Targets the active internal browser-session WebView tracked by the frontend lifecycle. Exposes list, is_ready, get_state, click, input, select, and scroll through the embedded Tauri/WebView Page Agent rather than external Chrome or Playwright, and never accepts arbitrary WebView labels from the model.", + description_detail: "Targets the active internal browser-session WebView tracked by the frontend lifecycle. Exposes list, is_ready, get_state, click, input, select, scroll, show_mask, hide_mask, and clean_up through the embedded Tauri/WebView Page Agent rather than external Chrome or Playwright, and never accepts arbitrary WebView labels from the model. Call get_state before indexed actions; element indexes are snapshots and can become stale after DOM changes, scrolling, or navigation.", category: tool_categories::WEB, icon_id: "mouse-pointer-click", simulator_app: AppBrowser, diff --git a/src-tauri/crates/agent-core/src/core/tools/defaults.rs b/src-tauri/crates/agent-core/src/core/tools/defaults.rs index 109f2f2a2e..4bbfd473f6 100644 --- a/src-tauri/crates/agent-core/src/core/tools/defaults.rs +++ b/src-tauri/crates/agent-core/src/core/tools/defaults.rs @@ -116,8 +116,8 @@ pub fn supported_agents_for(tool_name: &str) -> Vec { /// grant SDE workers app/session administration. Result: /// /// - **OS Agent** (`coding: None`, `desktop: Some`, `browser: Some`): -/// excludes coding tools (edit_file, query_lsp, manage_lsp) -/// and internal browser automation. Keeps desktop, external browser, core. +/// excludes coding tools (edit_file, query_lsp, manage_lsp). Keeps desktop, +/// external browser, internal browser, core. /// - **SDE Agent** (`coding: Some`, all others None): excludes the 15 /// desktop tools and browser tools. Keeps coding, /// core, orchestration. @@ -210,7 +210,7 @@ mod tests { use crate::definitions::capabilities::{ BrowserCapability, CapabilitySet, DesktopCapability, ManagementCapability, }; - let os_caps = CapabilitySet { + let os_caps_without_internal_browser = CapabilitySet { desktop: Some(DesktopCapability { enabled: true }), browser: Some(BrowserCapability { external: true, @@ -221,7 +221,7 @@ mod tests { data: None, management: Some(ManagementCapability {}), }; - let excluded = default_excluded_tools_for_capabilities(&os_caps); + let excluded = default_excluded_tools_for_capabilities(&os_caps_without_internal_browser); // Coding tools must be excluded. assert!( @@ -248,7 +248,7 @@ mod tests { ); assert!( excluded.contains(&tool_names::CONTROL_INTERNAL_BROWSER.to_string()), - "OS should exclude internal browser automation by default" + "capabilities with browser.internal=false should exclude internal browser automation" ); // Core tools (read_file, etc.) always satisfied. diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs index 634f5605fc..71ae33a61c 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/web/control_internal_browser.rs @@ -7,8 +7,9 @@ use async_trait::async_trait; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tauri::AppHandle; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter}; +use tokio::time::{sleep, Duration, Instant}; use crate::tools::categories as tool_categories; use crate::tools::names as tool_names; @@ -16,6 +17,8 @@ use crate::tools::traits::{params_schema, parse_params_described, Tool, ToolErro const INTERNAL_BROWSER_NOT_READY_MESSAGE: &str = "Internal browser automation requires a running Tauri app handle."; +const ACTION_URL_REFRESH_TIMEOUT_MS: u64 = 700; +const ACTION_URL_REFRESH_POLL_MS: u64 = 100; #[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -34,6 +37,12 @@ enum InternalBrowserAction { Select, /// Scroll the active page or an indexed scrollable element. Scroll, + /// Show the Page Agent mask in the active internal browser. + ShowMask, + /// Hide the Page Agent mask in the active internal browser. + HideMask, + /// Clean up Page Agent highlights and overlays in the active internal browser. + CleanUp, } #[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] @@ -45,6 +54,13 @@ enum InternalBrowserScrollDirection { Right, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UrlRefreshMode { + None, + Immediate, + WaitForChange, +} + impl InternalBrowserScrollDirection { fn as_page_agent_str(self) -> &'static str { match self { @@ -140,6 +156,11 @@ struct InternalBrowserActionResponse { success: bool, action: &'static str, target: InternalBrowserToolTarget, + before_url: String, + actual_url: String, + actual_url_changed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + url_refresh_error: Option, result: browser::InternalBrowserActionResult, message: String, } @@ -304,17 +325,11 @@ impl InternalBrowserTool { async fn execute_click(&self, params: &InternalBrowserParams) -> Result { let index = required_index(params, "click")?; let (app, target) = self.resolve_ready_target().await?; - let result = browser::internal_browser_click(app, target.label.clone(), index) + let result = browser::internal_browser_click(app.clone(), target.label.clone(), index) .await .map_err(|err| ToolError::ExecutionFailed(format!("Failed to click element: {err}")))?; - let response = InternalBrowserActionResponse { - success: result.success, - action: "click", - target, - message: result.message.clone(), - result, - }; - Self::response_text(&response) + self.action_response(app, "click", target, result, UrlRefreshMode::WaitForChange) + .await } async fn execute_input(&self, params: &InternalBrowserParams) -> Result { @@ -324,17 +339,14 @@ impl InternalBrowserTool { .clone() .ok_or_else(|| ToolError::InvalidParams("input requires text".to_string()))?; let (app, target) = self.resolve_ready_target().await?; - let result = browser::internal_browser_input(app, target.label.clone(), index, text) + let result = + browser::internal_browser_input(app.clone(), target.label.clone(), index, text) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to input text: {err}")) + })?; + self.action_response(app, "input", target, result, UrlRefreshMode::Immediate) .await - .map_err(|err| ToolError::ExecutionFailed(format!("Failed to input text: {err}")))?; - let response = InternalBrowserActionResponse { - success: result.success, - action: "input", - target, - message: result.message.clone(), - result, - }; - Self::response_text(&response) } async fn execute_select(&self, params: &InternalBrowserParams) -> Result { @@ -344,17 +356,14 @@ impl InternalBrowserTool { .clone() .ok_or_else(|| ToolError::InvalidParams("select requires option".to_string()))?; let (app, target) = self.resolve_ready_target().await?; - let result = browser::internal_browser_select(app, target.label.clone(), index, option) + let result = + browser::internal_browser_select(app.clone(), target.label.clone(), index, option) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to select option: {err}")) + })?; + self.action_response(app, "select", target, result, UrlRefreshMode::Immediate) .await - .map_err(|err| ToolError::ExecutionFailed(format!("Failed to select option: {err}")))?; - let response = InternalBrowserActionResponse { - success: result.success, - action: "select", - target, - message: result.message.clone(), - result, - }; - Self::response_text(&response) } async fn execute_scroll(&self, params: &InternalBrowserParams) -> Result { @@ -368,7 +377,7 @@ impl InternalBrowserTool { let (app, target) = self.resolve_ready_target().await?; let result = browser::internal_browser_scroll( - app, + app.clone(), target.label.clone(), direction.as_page_agent_str().to_string(), params.pages, @@ -376,15 +385,144 @@ impl InternalBrowserTool { ) .await .map_err(|err| ToolError::ExecutionFailed(format!("Failed to scroll: {err}")))?; + self.action_response(app, "scroll", target, result, UrlRefreshMode::Immediate) + .await + } + + async fn execute_show_mask(&self) -> Result { + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_show_mask(app.clone(), target.label.clone()) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to show mask: {err}")))?; + self.action_response(app, "show_mask", target, result, UrlRefreshMode::None) + .await + } + + async fn execute_hide_mask(&self) -> Result { + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_hide_mask(app.clone(), target.label.clone()) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("Failed to hide mask: {err}")))?; + self.action_response(app, "hide_mask", target, result, UrlRefreshMode::None) + .await + } + + async fn execute_clean_up(&self) -> Result { + let (app, target) = self.resolve_ready_target().await?; + let result = browser::internal_browser_clean_up(app.clone(), target.label.clone()) + .await + .map_err(|err| { + ToolError::ExecutionFailed(format!("Failed to clean up overlays: {err}")) + })?; + self.action_response(app, "clean_up", target, result, UrlRefreshMode::None) + .await + } + + async fn action_response( + &self, + app: AppHandle, + action: &'static str, + target: InternalBrowserToolTarget, + result: browser::InternalBrowserActionResult, + refresh_url: UrlRefreshMode, + ) -> Result { + let before_url = target.url.clone(); + let mut actual_url = before_url.clone(); + let mut actual_title: Option = None; + let mut url_refresh_error = None; + + match Self::refresh_location_after_action( + app.clone(), + target.label.clone(), + &before_url, + refresh_url, + ) + .await + { + Ok(Some(location)) => { + actual_url = location.url; + actual_title = Some(location.title); + } + Ok(None) => {} + Err(err) => { + url_refresh_error = Some(err); + } + } + + let actual_url_changed = actual_url != before_url; + if actual_url_changed { + let _ = app.emit( + "internal-browser:url-changed", + json!({ + "browserSessionId": target.browser_session_id.clone(), + "label": target.label.clone(), + "url": actual_url.clone(), + "title": actual_title.clone(), + "source": "agent-dom-action" + }), + ); + } + let response = InternalBrowserActionResponse { success: result.success, - action: "scroll", + action, target, + before_url, + actual_url, + actual_url_changed, + url_refresh_error, message: result.message.clone(), result, }; Self::response_text(&response) } + + async fn refresh_location_after_action( + app: AppHandle, + label: String, + before_url: &str, + mode: UrlRefreshMode, + ) -> Result, String> { + match mode { + UrlRefreshMode::None => Ok(None), + UrlRefreshMode::Immediate => browser::internal_browser_get_location(app, label) + .await + .map(Some), + UrlRefreshMode::WaitForChange => { + let deadline = + Instant::now() + Duration::from_millis(ACTION_URL_REFRESH_TIMEOUT_MS); + let mut last_location = None; + let mut last_error = None; + + loop { + match browser::internal_browser_get_location(app.clone(), label.clone()).await { + Ok(location) => { + if location.url != before_url { + return Ok(Some(location)); + } + last_location = Some(location); + } + Err(err) => { + last_error = Some(err); + } + } + + if Instant::now() >= deadline { + return if let Some(location) = last_location { + Ok(Some(location)) + } else { + Err(last_error.unwrap_or_else(|| { + "Timed out refreshing internal browser URL after action." + .to_string() + })) + }; + } + + sleep(Duration::from_millis(ACTION_URL_REFRESH_POLL_MS)).await; + } + } + } + } } fn required_index(params: &InternalBrowserParams, action: &str) -> Result { @@ -461,7 +599,7 @@ impl Tool for InternalBrowserTool { } fn description(&self) -> &str { - "Inspect and control the currently visible ORGII internal Browser WebView. Resolves only the active internal browser target and supports list, is_ready, get_state, click, input, select, and scroll." + "Inspect and control the currently visible ORGII internal Browser WebView. Resolves only the active internal browser target and supports list, is_ready, get_state, click, input, select, scroll, show_mask, hide_mask, and clean_up. Call get_state before indexed actions; indexes are page-state snapshots and may become stale after DOM changes, scrolling, or navigation." } fn is_ready(&self) -> bool { @@ -477,7 +615,7 @@ impl Tool for InternalBrowserTool { } fn search_hint(&self) -> &str { - "internal browser webview tauri webview2 dom page agent get_state click input select scroll is_ready" + "internal browser webview tauri webview2 dom page agent get_state click input select scroll show_mask hide_mask clean_up is_ready" } fn parameters(&self) -> Value { @@ -498,6 +636,9 @@ impl Tool for InternalBrowserTool { InternalBrowserAction::Input => self.execute_input(¶ms).await, InternalBrowserAction::Select => self.execute_select(¶ms).await, InternalBrowserAction::Scroll => self.execute_scroll(¶ms).await, + InternalBrowserAction::ShowMask => self.execute_show_mask().await, + InternalBrowserAction::HideMask => self.execute_hide_mask().await, + InternalBrowserAction::CleanUp => self.execute_clean_up().await, } } @@ -611,4 +752,18 @@ mod tests { "right" ); } + + #[test] + fn parses_overlay_actions() { + for (action, expected) in [ + ("show_mask", InternalBrowserAction::ShowMask), + ("hide_mask", InternalBrowserAction::HideMask), + ("clean_up", InternalBrowserAction::CleanUp), + ] { + let params: InternalBrowserParams = + serde_json::from_value(serde_json::json!({ "action": action })) + .expect("overlay action should parse"); + assert_eq!(params.action, expected); + } + } } diff --git a/src-tauri/crates/browser/src/internal_browser_commands.rs b/src-tauri/crates/browser/src/internal_browser_commands.rs index 5ed2f22fd8..954b36e348 100644 --- a/src-tauri/crates/browser/src/internal_browser_commands.rs +++ b/src-tauri/crates/browser/src/internal_browser_commands.rs @@ -3,11 +3,18 @@ //! Direct frontend access to internal browser automation for testing and debugging. //! These commands expose the `window.__PAGE_AGENT__` API injected into inline webviews. +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use serde_json::json; use tauri::{AppHandle, Manager}; +use tokio::time::{Duration, Instant}; +use uuid::Uuid; use super::logging::eval_js_with_result; +const PAGE_AGENT_PENDING_RESULT: &str = "__ORGII_PAGE_AGENT_PENDING__"; +const PAGE_AGENT_POLL_INTERVAL_MS: u64 = 50; + // ============================================================================ // Types // ============================================================================ @@ -29,6 +36,113 @@ pub struct InternalBrowserActionResult { pub message: String, } +/// Lightweight browser location state, without rebuilding the Page Agent DOM tree. +#[derive(Debug, Serialize, Deserialize)] +pub struct InternalBrowserLocation { + pub url: String, + pub title: String, +} + +fn page_agent_missing_action() -> serde_json::Value { + json!({ + "success": false, + "message": "Page Agent not initialized" + }) +} + +async fn eval_browser_call( + webview: &tauri::Webview, + expression: String, + timeout_ms: u64, +) -> Result +where + T: DeserializeOwned, +{ + let call_id = Uuid::new_v4().to_string(); + let call_id_literal = serde_json::to_string(&call_id) + .map_err(|err| format!("Failed to encode browser eval call id: {err}"))?; + + let script = format!( + r#" + (async () => {{ + const callId = {call_id_literal}; + window.__PAGE_AGENT_RESULTS__ = window.__PAGE_AGENT_RESULTS__ || {{}}; + try {{ + window.__PAGE_AGENT_RESULTS__[callId] = await ({expression}); + }} catch (error) {{ + window.__PAGE_AGENT_RESULTS__[callId] = {{ + success: false, + message: `Browser eval failed: ${{error?.message || String(error)}}` + }}; + }} + }})(); + "# + ); + + webview + .eval(&script) + .map_err(|err| format!("Failed to evaluate browser script: {err}"))?; + + let pending_literal = serde_json::to_string(PAGE_AGENT_PENDING_RESULT) + .map_err(|err| format!("Failed to encode browser eval pending marker: {err}"))?; + let read_script = format!( + r#" + (() => {{ + const callId = {call_id_literal}; + const store = window.__PAGE_AGENT_RESULTS__; + if (!store || !Object.prototype.hasOwnProperty.call(store, callId)) {{ + return {pending_literal}; + }} + const value = store[callId]; + delete store[callId]; + return JSON.stringify(value); + }})() + "# + ); + + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let result = eval_js_with_result(webview, &read_script, PAGE_AGENT_PENDING_RESULT).await; + if result != PAGE_AGENT_PENDING_RESULT { + return serde_json::from_str(&result) + .map_err(|err| format!("Failed to parse browser eval result: {err}")); + } + + if Instant::now() >= deadline { + return Err(format!( + "Timed out waiting for browser eval result after {timeout_ms}ms" + )); + } + + tokio::time::sleep(Duration::from_millis(PAGE_AGENT_POLL_INTERVAL_MS)).await; + } +} + +async fn eval_page_agent_call( + webview: &tauri::Webview, + expression: String, + missing_value: serde_json::Value, + timeout_ms: u64, +) -> Result +where + T: DeserializeOwned, +{ + let missing_literal = serde_json::to_string(&missing_value) + .map_err(|err| format!("Failed to encode Page Agent fallback: {err}"))?; + let guarded_expression = format!( + r#" + (async () => {{ + if (!window.__PAGE_AGENT__) {{ + return {missing_literal}; + }} + return await ({expression}); + }})() + "# + ); + + eval_browser_call(webview, guarded_expression, timeout_ms).await +} + // ============================================================================ // Commands // ============================================================================ @@ -45,28 +159,47 @@ pub async fn internal_browser_get_state( .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - // Execute the getBrowserState function - let _ = webview.eval( + eval_browser_call( + &webview, r#" - if (window.__PAGE_AGENT__) { - window.__PAGE_AGENT_RESULT__ = JSON.stringify(window.__PAGE_AGENT__.getBrowserState()); - } else { - window.__PAGE_AGENT_RESULT__ = JSON.stringify({ - url: window.location.href, - title: document.title, - header: "Page Agent not initialized", - content: "", - footer: "" - }); - } - "#, - ); - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + (async () => { + if (!window.__PAGE_AGENT__) { + return { + url: window.location.href, + title: document.title || "", + header: "Page Agent not initialized", + content: "", + footer: "" + }; + } + return window.__PAGE_AGENT__.getBrowserState(); + })() + "# + .to_string(), + 2_000, + ) + .await +} - let result = eval_js_with_result(&webview, "window.__PAGE_AGENT_RESULT__ || '{}'", "{}").await; +/// Get the current URL/title without invoking the Page Agent DOM snapshot path. +pub async fn internal_browser_get_location( + app: AppHandle, + label: String, +) -> Result { + let webview = app + .get_webview(&label) + .ok_or_else(|| format!("Webview '{}' not found", label))?; - serde_json::from_str(&result).map_err(|e| format!("Failed to parse result: {}", e)) + eval_browser_call( + &webview, + r#"(() => ({ + url: window.location.href, + title: document.title || "" + }))()"# + .to_string(), + 1_000, + ) + .await } /// Click an element by its highlight index. @@ -80,28 +213,13 @@ pub async fn internal_browser_click( .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let script = format!( - r#" - (async () => {{ - if (window.__PAGE_AGENT__) {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify(await window.__PAGE_AGENT__.clickElement({})); - }} else {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify({{ - success: false, - message: "Page Agent not initialized" - }}); - }} - }})(); - "#, - index - ); - - let _ = webview.eval(&script); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - - let result = eval_js_with_result(&webview, "window.__PAGE_AGENT_RESULT__ || '{}'", "{}").await; - - serde_json::from_str(&result).map_err(|e| format!("Failed to parse result: {}", e)) + eval_page_agent_call( + &webview, + format!("window.__PAGE_AGENT__.clickElement({index})"), + page_agent_missing_action(), + 2_000, + ) + .await } /// Input text into an element by its highlight index. @@ -116,35 +234,16 @@ pub async fn internal_browser_input( .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - // Escape the text for JavaScript - let escaped_text = text - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r"); + let text_literal = serde_json::to_string(&text) + .map_err(|err| format!("Failed to encode input text: {err}"))?; - let script = format!( - r#" - (async () => {{ - if (window.__PAGE_AGENT__) {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify(await window.__PAGE_AGENT__.inputText({}, "{}")); - }} else {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify({{ - success: false, - message: "Page Agent not initialized" - }}); - }} - }})(); - "#, - index, escaped_text - ); - - let _ = webview.eval(&script); - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - - let result = eval_js_with_result(&webview, "window.__PAGE_AGENT_RESULT__ || '{}'", "{}").await; - - serde_json::from_str(&result).map_err(|e| format!("Failed to parse result: {}", e)) + eval_page_agent_call( + &webview, + format!("window.__PAGE_AGENT__.inputText({index}, {text_literal})"), + page_agent_missing_action(), + 2_000, + ) + .await } /// Select an option from a dropdown by its highlight index. @@ -159,30 +258,16 @@ pub async fn internal_browser_select( .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let escaped_option = option.replace('\\', "\\\\").replace('"', "\\\""); - - let script = format!( - r#" - (async () => {{ - if (window.__PAGE_AGENT__) {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify(await window.__PAGE_AGENT__.selectOption({}, "{}")); - }} else {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify({{ - success: false, - message: "Page Agent not initialized" - }}); - }} - }})(); - "#, - index, escaped_option - ); - - let _ = webview.eval(&script); - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - - let result = eval_js_with_result(&webview, "window.__PAGE_AGENT_RESULT__ || '{}'", "{}").await; + let option_literal = serde_json::to_string(&option) + .map_err(|err| format!("Failed to encode option text: {err}"))?; - serde_json::from_str(&result).map_err(|e| format!("Failed to parse result: {}", e)) + eval_page_agent_call( + &webview, + format!("window.__PAGE_AGENT__.selectOption({index}, {option_literal})"), + page_agent_missing_action(), + 2_000, + ) + .await } /// Scroll the page or an element. @@ -203,83 +288,85 @@ pub async fn internal_browser_scroll( Some(idx) => idx.to_string(), None => "null".to_string(), }; - - let script = format!( - r#" - (async () => {{ - if (window.__PAGE_AGENT__) {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify(await window.__PAGE_AGENT__.scroll("{}", {}, {})); - }} else {{ - window.__PAGE_AGENT_RESULT__ = JSON.stringify({{ - success: false, - message: "Page Agent not initialized" - }}); - }} - }})(); - "#, - direction, pages_val, element_arg - ); - - let _ = webview.eval(&script); - tokio::time::sleep(tokio::time::Duration::from_millis(400)).await; - - let result = eval_js_with_result(&webview, "window.__PAGE_AGENT_RESULT__ || '{}'", "{}").await; - - serde_json::from_str(&result).map_err(|e| format!("Failed to parse result: {}", e)) + let direction_literal = serde_json::to_string(&direction) + .map_err(|err| format!("Failed to encode scroll direction: {err}"))?; + + eval_page_agent_call( + &webview, + format!("window.__PAGE_AGENT__.scroll({direction_literal}, {pages_val}, {element_arg})"), + page_agent_missing_action(), + 2_000, + ) + .await } /// Show the user takeover mask (blocks user interaction). #[tauri::command] -pub async fn internal_browser_show_mask(app: AppHandle, label: String) -> Result<(), String> { +pub async fn internal_browser_show_mask( + app: AppHandle, + label: String, +) -> Result { let webview = app .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let _ = webview.eval( - r#" - if (window.__PAGE_AGENT__) { + eval_page_agent_call( + &webview, + r#"(() => { window.__PAGE_AGENT__.showMask(); - } - "#, - ); - - Ok(()) + return { success: true, message: "Showed the Page Agent mask." }; + })()"# + .to_string(), + page_agent_missing_action(), + 2_000, + ) + .await } /// Hide the user takeover mask (allows user interaction). #[tauri::command] -pub async fn internal_browser_hide_mask(app: AppHandle, label: String) -> Result<(), String> { +pub async fn internal_browser_hide_mask( + app: AppHandle, + label: String, +) -> Result { let webview = app .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let _ = webview.eval( - r#" - if (window.__PAGE_AGENT__) { + eval_page_agent_call( + &webview, + r#"(() => { window.__PAGE_AGENT__.hideMask(); - } - "#, - ); - - Ok(()) + return { success: true, message: "Hid the Page Agent mask." }; + })()"# + .to_string(), + page_agent_missing_action(), + 2_000, + ) + .await } /// Clean up element highlights. #[tauri::command] -pub async fn internal_browser_clean_up(app: AppHandle, label: String) -> Result<(), String> { +pub async fn internal_browser_clean_up( + app: AppHandle, + label: String, +) -> Result { let webview = app .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let _ = webview.eval( - r#" - if (window.__PAGE_AGENT__) { + eval_page_agent_call( + &webview, + r#"(() => { window.__PAGE_AGENT__.cleanUpHighlights(); - } - "#, - ); - - Ok(()) + return { success: true, message: "Cleaned up Page Agent highlights and overlays." }; + })()"# + .to_string(), + page_agent_missing_action(), + 2_000, + ) + .await } /// Check if Page Agent is initialized in a webview. @@ -289,16 +376,11 @@ pub async fn internal_browser_is_ready(app: AppHandle, label: String) -> Result< .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - let _ = webview.eval( - r#" - window.__PAGE_AGENT_READY__ = (typeof window.__PAGE_AGENT__ !== 'undefined').toString(); - "#, - ); - - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - - let result = - eval_js_with_result(&webview, "window.__PAGE_AGENT_READY__ || 'false'", "false").await; - - Ok(result == "true") + eval_page_agent_call( + &webview, + "(typeof window.__PAGE_AGENT__ !== 'undefined')".to_string(), + json!(false), + 1_000, + ) + .await } diff --git a/src/engines/BrowserCore/BrowserSessionWebview.tsx b/src/engines/BrowserCore/BrowserSessionWebview.tsx index 939beacf11..71e24ca3f8 100644 --- a/src/engines/BrowserCore/BrowserSessionWebview.tsx +++ b/src/engines/BrowserCore/BrowserSessionWebview.tsx @@ -5,8 +5,9 @@ * Keeps the webview mounted but hidden when not active. */ import { invoke } from "@tauri-apps/api/core"; +import { type UnlistenFn, listen } from "@tauri-apps/api/event"; import { useAtomValue } from "jotai"; -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import { useInlineWebview } from "@src/hooks/platform/useInlineWebview"; @@ -39,6 +40,23 @@ interface ActiveInternalBrowserSync { updatedAt: number; } +interface InternalBrowserUrlChangedPayload { + browserSessionId?: string; + label?: string; + url: string; + title?: string; +} + +function isInternalBrowserUrlChangedPayload( + payload: unknown +): payload is InternalBrowserUrlChangedPayload { + return ( + !!payload && + typeof payload === "object" && + typeof (payload as InternalBrowserUrlChangedPayload).url === "string" + ); +} + function clearActiveInternalBrowserState( sync: ActiveInternalBrowserSync | null, reason: string @@ -102,6 +120,41 @@ const BrowserSessionWebview: React.FC = ({ ); const hasNavigableUrl = !isBlankBrowserUrl(session.url); + const handleSessionNavigation = useCallback( + (url: string, titleOverride?: string) => { + if (!isBlankBrowserUrl(url) && url !== session.url) { + const title = titleOverride?.trim() || getTitleFromUrl(url); + const newHistory = [ + ...session.history.slice(0, session.historyIndex + 1), + url, + ]; + + onSessionUpdate(session.id, { + url, + title, + history: newHistory, + historyIndex: newHistory.length - 1, + historyEntries: [ + ...(session.historyEntries ?? []), + { url, title, visitedAt: Date.now() }, + ], + isLoading: false, + }); + } else { + // URL didn't change (same page reload or navigation complete). + onSessionUpdate(session.id, { isLoading: false }); + } + }, + [ + onSessionUpdate, + session.history, + session.historyEntries, + session.historyIndex, + session.id, + session.url, + ] + ); + const webviewConfig = useMemo(() => { const shouldActivateWebview = hasNavigableUrl && isActive && isTabActive; @@ -145,27 +198,7 @@ const BrowserSessionWebview: React.FC = ({ activeInternalBrowserSyncRef.current = null; }, onNavigate: (url: string) => { - if (!isBlankBrowserUrl(url) && url !== session.url) { - const newHistory = [ - ...session.history.slice(0, session.historyIndex + 1), - url, - ]; - - onSessionUpdate(session.id, { - url, - title: getTitleFromUrl(url), - history: newHistory, - historyIndex: newHistory.length - 1, - historyEntries: [ - ...(session.historyEntries ?? []), - { url, title: getTitleFromUrl(url), visitedAt: Date.now() }, - ], - isLoading: false, - }); - } else { - // URL didn't change (same page reload or navigation complete) - onSessionUpdate(session.id, { isLoading: false }); - } + handleSessionNavigation(url); }, onNewWindow: (url: string) => { if (onNewTab) { @@ -176,11 +209,9 @@ const BrowserSessionWebview: React.FC = ({ }, [ containerRef, hasNavigableUrl, + handleSessionNavigation, session.id, session.url, - session.history, - session.historyIndex, - session.historyEntries, session.incognito, isActive, isTabActive, @@ -197,6 +228,52 @@ const BrowserSessionWebview: React.FC = ({ isWebviewCreated, } = useInlineWebview(webviewConfig); + useEffect(() => { + if (!isWebviewAvailable) return; + + let cancelled = false; + let unlisten: UnlistenFn | null = null; + + void listen( + "internal-browser:url-changed", + (event) => { + const payload = event.payload; + if (!isInternalBrowserUrlChangedPayload(payload)) return; + if ( + payload.browserSessionId !== session.id && + payload.label !== webviewLabel + ) { + return; + } + + handleSessionNavigation( + payload.url, + typeof payload.title === "string" ? payload.title : undefined + ); + } + ) + .then((listener) => { + if (cancelled) { + listener(); + return; + } + unlisten = listener; + }) + .catch((error) => { + log.warn( + "[BrowserSessionWebview] Failed to listen for internal browser URL changes:", + error + ); + }); + + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } + }; + }, [handleSessionNavigation, isWebviewAvailable, session.id, webviewLabel]); + useEffect(() => { if (!isWebviewAvailable) { clearActiveInternalBrowserState( diff --git a/src/modules/WorkStation/Browser/SessionReplay/BrowserSidebar.tsx b/src/modules/WorkStation/Browser/SessionReplay/BrowserSidebar.tsx index 74c1e871b7..0f16b51dc6 100644 --- a/src/modules/WorkStation/Browser/SessionReplay/BrowserSidebar.tsx +++ b/src/modules/WorkStation/Browser/SessionReplay/BrowserSidebar.tsx @@ -6,6 +6,7 @@ * owns category switching. */ import { + CheckCircle2, Chrome, Compass, FileSymlink, @@ -89,6 +90,12 @@ function getNativeActionIcon( const stroke = 1.75; switch (action) { + case "list": + return ; + case "is_ready": + return ( + + ); case "get_state": return ; case "click": diff --git a/src/modules/WorkStation/Browser/SessionReplay/__tests__/config.test.ts b/src/modules/WorkStation/Browser/SessionReplay/__tests__/config.test.ts index 492893208f..840e4ced47 100644 --- a/src/modules/WorkStation/Browser/SessionReplay/__tests__/config.test.ts +++ b/src/modules/WorkStation/Browser/SessionReplay/__tests__/config.test.ts @@ -58,4 +58,38 @@ describe("deriveBrowserState", () => { expect(state.activeEntry?.title).toBe("Snapshot Page"); expect(state.activeEntry?.subtitle).toBe("snapshot"); }); + + it("keeps internal browser entries without the legacy webview arg", () => { + const event = makeEvent({ + functionName: "control_internal_browser", + uiCanonical: "control_internal_browser", + args: { action: "click", index: 3 }, + result: JSON.stringify({ + success: true, + action: "click", + target: { + browserSessionId: "session-browser-1", + label: "browser-session-session-browser-1", + }, + beforeUrl: "https://example.com", + actualUrl: "https://example.com/next", + actualUrlChanged: true, + message: "Clicked Next", + result: { + success: true, + message: "Clicked Next", + }, + }) as unknown as Record, + }); + + const state = deriveBrowserState([event], event.id); + const entry = state.activeInternalEntry; + + expect(state.activeSubtool).toBe("internal_browser"); + expect(state.internalBrowserEntries).toHaveLength(1); + expect(entry?.webviewLabel).toBe("browser-session-session-browser-1"); + expect(entry?.browserSessionId).toBe("session-browser-1"); + expect(entry?.success).toBe(true); + expect(entry?.actualUrlChanged).toBe(true); + }); }); diff --git a/src/modules/WorkStation/Browser/SessionReplay/config.ts b/src/modules/WorkStation/Browser/SessionReplay/config.ts index 42c950b541..b094b4665f 100644 --- a/src/modules/WorkStation/Browser/SessionReplay/config.ts +++ b/src/modules/WorkStation/Browser/SessionReplay/config.ts @@ -53,6 +53,58 @@ function getEventArgs(event: SessionEvent): Record { : {}; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function parseJsonRecord(value: unknown): Record | null { + if (typeof value !== "string") return null; + const trimmed = value.trimStart(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function getRecordField( + record: Record | null | undefined, + key: string +): Record | null { + const value = record?.[key]; + return isRecord(value) ? value : null; +} + +function getStringField( + record: Record | null | undefined, + key: string +): string | undefined { + const value = record?.[key]; + return typeof value === "string" ? value : undefined; +} + +function getBooleanField( + record: Record | null | undefined, + key: string +): boolean | undefined { + const value = record?.[key]; + return typeof value === "boolean" ? value : undefined; +} + +function extractToolResultObject(event: SessionEvent): Record { + const parsedDirect = parseJsonRecord(event.result); + if (parsedDirect) return parsedDirect; + + const direct = isRecord(event.result) ? event.result : {}; + for (const field of ["output", "content", "observation"] as const) { + const parsed = parseJsonRecord(direct[field]); + if (parsed) return parsed; + } + return direct; +} + function getBrowserEntrySubtitle( event: SessionEvent, args: Record @@ -179,20 +231,35 @@ function buildInternalBrowserEntry( event: SessionEvent, currentEventId: string | null ): InternalBrowserEntry | null { - const args = event.args as Record | undefined; - const result = event.result as Record | undefined; + const args = getEventArgs(event); + const result = extractToolResultObject(event); const action = args?.action; if (typeof action !== "string") return null; - const webviewLabel = args?.webview; - if (typeof webviewLabel !== "string") return null; + const target = getRecordField(result, "target"); + const active = getRecordField(result, "active"); + const nestedResult = getRecordField(result, "result"); + const webviewLabel = + getStringField(args, "webview") || + getStringField(args, "label") || + getStringField(target, "label") || + getStringField(active, "label") || + "internal-browser"; + const browserSessionId = + getStringField(args, "browserSessionId") || + getStringField(args, "browser_session_id") || + getStringField(target, "browserSessionId") || + getStringField(target, "browser_session_id") || + getStringField(active, "browserSessionId") || + getStringField(active, "browser_session_id"); return { entryId: event.id, event, action: action as InternalBrowserAction, webviewLabel, + browserSessionId, timestamp: event.createdAt, isCurrent: event.id === currentEventId, index: typeof args?.index === "number" ? args.index : undefined, @@ -200,8 +267,15 @@ function buildInternalBrowserEntry( option: typeof args?.option === "string" ? args.option : undefined, direction: typeof args?.direction === "string" ? args.direction : undefined, pages: typeof args?.pages === "number" ? args.pages : undefined, - success: typeof result?.success === "boolean" ? result.success : undefined, - message: typeof result?.message === "string" ? result.message : undefined, + success: + getBooleanField(result, "success") ?? + getBooleanField(nestedResult, "success"), + message: + getStringField(result, "message") ?? + getStringField(nestedResult, "message"), + beforeUrl: getStringField(result, "beforeUrl"), + actualUrl: getStringField(result, "actualUrl"), + actualUrlChanged: getBooleanField(result, "actualUrlChanged"), }; } diff --git a/src/modules/WorkStation/Browser/SessionReplay/types.ts b/src/modules/WorkStation/Browser/SessionReplay/types.ts index 31441331f8..8ddf1a2aa3 100644 --- a/src/modules/WorkStation/Browser/SessionReplay/types.ts +++ b/src/modules/WorkStation/Browser/SessionReplay/types.ts @@ -28,6 +28,8 @@ export interface BrowserEntry { /** Action types for control_internal_browser tool */ export type InternalBrowserAction = + | "list" + | "is_ready" | "get_state" | "click" | "input" @@ -43,6 +45,7 @@ export interface InternalBrowserEntry { event: SessionEvent; action: InternalBrowserAction; webviewLabel: string; + browserSessionId?: string; timestamp: string; isCurrent: boolean; // Action-specific data @@ -54,6 +57,9 @@ export interface InternalBrowserEntry { // Result data success?: boolean; message?: string; + beforeUrl?: string; + actualUrl?: string; + actualUrlChanged?: boolean; } // ============================================ diff --git a/src/test/vitest.setup.ts b/src/test/vitest.setup.ts index 8321c17ca8..7179ddd27e 100644 --- a/src/test/vitest.setup.ts +++ b/src/test/vitest.setup.ts @@ -76,6 +76,7 @@ const BUILTIN_SIMULATOR_APP_FIXTURE: Map = new Map([ ["control_browser_with_agent_browser", AppType.BROWSER], ["control_browser_with_playwright", AppType.BROWSER], ["control_external_browser", AppType.BROWSER], + ["control_internal_browser", AppType.BROWSER], ["control_desktop_with_peekaboo", AppType.BROWSER], // Agent/Channels tools → CHANNELS @@ -131,6 +132,7 @@ const BUILTIN_SUBTOOL_FIXTURE: Map = new Map([ ["control_browser_with_agent_browser", "browser"], ["control_browser_with_playwright", "browser"], ["control_external_browser", "browser"], + ["control_internal_browser", "internal_browser"], ["control_desktop_with_peekaboo", "browser"], // Agent/Channels tools @@ -188,6 +190,8 @@ function actionInfo( } const INTERNAL_BROWSER_ACTION_NAMES = [ + "list", + "is_ready", "get_state", "click", "input", @@ -202,6 +206,8 @@ const INTERNAL_BROWSER_ACTION_LABEL_KEYS: Record< (typeof INTERNAL_BROWSER_ACTION_NAMES)[number], LabelKeySet > = { + list: labelKeys("internalBrowser"), + is_ready: labelKeys("internalBrowser"), get_state: labelKeys("internalBrowserGetState"), click: labelKeys("internalBrowserClick"), input: labelKeys("internalBrowserInput"), From de95286e7940f31643303d9d9a047d8d9d95a0dd Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 16:16:21 +0800 Subject: [PATCH 041/864] fix(agent): complete provider context window propagation Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/core/definitions/resolved.rs | 12 ++- .../src/core/providers/model_capabilities.rs | 72 +++++++++----- .../tests/model_capabilities_tests.rs | 58 +++++------ .../core/providers/tests/registry_tests.rs | 11 +++ .../src/core/session/compaction/manual.rs | 14 +-- .../core/session/turn/processor/compaction.rs | 17 ++-- .../core/session/turn/processor/execute.rs | 19 +++- .../src/core/session/turn/processor/mod.rs | 10 +- .../tools/impls/orchestration/agent/mod.rs | 1 + .../agent-core/src/core/turn_executor/mod.rs | 22 +++-- .../src/core/turn_executor/types.rs | 5 + .../memory/workspace_memory/auto_dream.rs | 1 + .../memory/workspace_memory/extract/runner.rs | 1 + .../src/tests/turn_executor_retry_tests.rs | 1 + .../crates/key-vault/src/commands/crud.rs | 6 +- .../key-vault/src/commands/tests/tests.rs | 9 ++ .../crates/key-vault/src/key_store/service.rs | 24 ++++- .../key-vault/src/key_store/tests/tests.rs | 97 +++++++++++++++++++ .../key-vault/src/providers/anthropic/mod.rs | 2 +- .../src/providers/azure_openai/mod.rs | 2 +- .../key-vault/src/providers/openai/mod.rs | 2 +- src/api/tauri/rpc/schemas/validation.ts | 7 +- src/api/types/keys.ts | 3 + .../components/useContextUsageInfo.ts | 15 ++- src/hooks/keyVault/useKeyValidation.ts | 16 +++ .../Models/Table/integrationsModelGroups.ts | 1 + .../KeyVault/components/AgentSetupRouter.tsx | 3 + .../components/setup/ApiKeyProviderSetup.tsx | 1 + .../components/setup/GenericSetup.tsx | 2 + .../variants/KeyVault/config/index.ts | 1 + .../variants/KeyVault/hooks/keyHelpers.ts | 4 + .../KeyVault/hooks/useApiSetupCursorToken.ts | 3 + .../KeyVault/hooks/useApiSetupValidation.ts | 10 +- .../KeyVault/hooks/useProviderSelection.ts | 2 + .../variants/KeyVault/hooks/useWizard.ts | 30 ++++-- .../variants/KeyVault/types/index.ts | 2 + src/types/model/info.ts | 55 ++++++++++- src/util/modelVariants.ts | 2 + 38 files changed, 434 insertions(+), 109 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/definitions/resolved.rs b/src-tauri/crates/agent-core/src/core/definitions/resolved.rs index cd57ead3a0..87f4f3a517 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/resolved.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/resolved.rs @@ -198,6 +198,11 @@ pub struct ResolvedAgent { pub selected_model_id: String, pub max_tokens: u64, pub context_window: u64, + /// True when `context_window` came from an agent definition/override rather + /// than the resolver default. Skipped on the wire; runtime code uses it to + /// distinguish "custom context window" from "auto by model/account". + #[serde(default, skip_serializing)] + pub context_window_configured: bool, pub temperature: f64, pub compaction: CompactionConfig, pub load_workspace_resources: bool, @@ -312,10 +317,8 @@ impl ResolvedAgent { let learnings = merged.learnings.clone().unwrap_or_default(); let max_tokens = merged.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS); - let context_window = merged - .context_window - .filter(|&v| v > 0) - .unwrap_or(DEFAULT_CONTEXT_WINDOW); + let configured_context_window = merged.context_window.filter(|&v| v > 0); + let context_window = configured_context_window.unwrap_or(DEFAULT_CONTEXT_WINDOW); let temperature = merged.temperature.unwrap_or(DEFAULT_TEMPERATURE); let compaction = session_model.compaction.clone().unwrap_or_default(); @@ -344,6 +347,7 @@ impl ResolvedAgent { selected_model_id, max_tokens, context_window, + context_window_configured: configured_context_window.is_some(), temperature, compaction, load_workspace_resources, diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index f37306548c..f78b8ba061 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -36,7 +36,7 @@ use std::collections::HashSet; use std::sync::RwLock; -use key_vault::key_store::KEY_SERVICE; +use key_vault::key_store::{ModelVariant, KEY_SERVICE}; /// Process-level set of models observed to REJECT the `temperature` request /// param outright (Anthropic's newer models — e.g. `claude-opus-4-8` — return @@ -140,7 +140,8 @@ const FAMILY_RULES: &[FamilyRule] = &[ context_window: 1_000_000, thinking: ThinkingSupport::AlwaysOn, }, - // claude-opus-4.6+ upgraded to 1M; 4 / 4.1 / 4.5 stayed at 200K. + // Known claude-opus-4.6/4.7/4.8 releases upgraded to 1M; 4 / 4.1 / + // 4.5 stayed at 200K. Add future releases explicitly. FamilyRule { pattern: "claude-opus-4.6", context_window: 1_000_000, @@ -174,8 +175,8 @@ const FAMILY_RULES: &[FamilyRule] = &[ context_window: 200_000, thinking: ThinkingSupport::Optional, }, - // claude-sonnet-4.6+: 1M context window. Must come BEFORE claude-sonnet-4 - // so it beats the base pattern. + // claude-sonnet-4.6: 1M context window. Must come BEFORE claude-sonnet-4 + // so it beats the base pattern. Add future releases explicitly. FamilyRule { pattern: "claude-sonnet-4.6", context_window: 1_000_000, @@ -412,6 +413,13 @@ const FAMILY_RULES: &[FamilyRule] = &[ context_window: 128_000, thinking: ThinkingSupport::No, }, + // glm-5.2: 1M context window — only the 5.2 release reached 1M; + // 5 / 5.1 / 5-turbo stay at 200K. Must come BEFORE glm-5. + FamilyRule { + pattern: "glm-5.2", + context_window: 1_000_000, + thinking: ThinkingSupport::Optional, + }, FamilyRule { pattern: "glm-5", context_window: 200_000, @@ -559,26 +567,48 @@ const FAMILY_RULES: &[FamilyRule] = &[ pub fn resolve(model: &str, account_id: Option<&str>) -> ModelCapabilities { let mut caps = resolve_from_family_table(model); - if let Some(ctx) = resolve_context_from_keyvault(model, account_id) { + if let Some(account_id) = account_id { + if let Some(key) = KEY_SERVICE.get_key_by_id(account_id) { + apply_keyvault_overrides(&mut caps, model, &key.model_variants); + } + } + + caps +} + +/// Resolve the context window while preserving an explicitly configured agent +/// override. `None` means "auto"; in that mode account-specific provider caps +/// from KeyVault beat the static family table. +pub fn resolve_effective_context_window( + model: &str, + account_id: Option<&str>, + explicit_context_window: Option, +) -> usize { + explicit_context_window + .filter(|ctx| *ctx > 0) + .map(|ctx| ctx as usize) + .unwrap_or_else(|| resolve(model, account_id).context_window) +} + +fn apply_keyvault_overrides(caps: &mut ModelCapabilities, model: &str, variants: &[ModelVariant]) { + let Some(variant) = variants.iter().find(|v| v.model == model) else { + return; + }; + + if let Some(ctx) = variant.context_window.filter(|ctx| *ctx > 0) { caps.context_window = ctx as usize; } - if let Some(vault_thinking) = resolve_thinking_from_keyvault(model, account_id) { + if let Some(vault_thinking) = resolve_thinking_from_variant(variant) { caps.thinking = vault_thinking; } - - caps } -/// KeyVault layer for the context window: a `ModelVariant.context_window` -/// set during key validation (from the provider's `/v1/models` `context_length`) -/// overrides the static family default. Returns `None` when the provider did -/// not report one, leaving the family-table value in place. -fn resolve_context_from_keyvault(model: &str, account_id: Option<&str>) -> Option { - let account_id = account_id?; - let key = KEY_SERVICE.get_key_by_id(account_id)?; - let variant = key.model_variants.iter().find(|v| v.model == model)?; - variant.context_window +#[cfg(test)] +fn resolve_with_keyvault_variants(model: &str, variants: &[ModelVariant]) -> ModelCapabilities { + let mut caps = resolve_from_family_table(model); + apply_keyvault_overrides(&mut caps, model, variants); + caps } fn resolve_from_family_table(model: &str) -> ModelCapabilities { @@ -600,13 +630,7 @@ fn resolve_from_family_table(model: &str) -> ModelCapabilities { /// the model as a reasoning model for this account. The writeback in /// `side_query.rs` uses the value `"always_on"` to record observed /// always-on behavior; any other non-empty value means Optional. -fn resolve_thinking_from_keyvault( - model: &str, - account_id: Option<&str>, -) -> Option { - let account_id = account_id?; - let key = KEY_SERVICE.get_key_by_id(account_id)?; - let variant = key.model_variants.iter().find(|v| v.model == model)?; +fn resolve_thinking_from_variant(variant: &ModelVariant) -> Option { let reasoning = variant.reasoning.as_deref()?; if reasoning.is_empty() { return None; diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs index c343fa5b2a..d6d2309b9e 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs @@ -12,7 +12,7 @@ fn claude_fable_5_is_always_on() { #[test] fn claude_opus_4_is_optional() { - // Only 4.6+ upgraded to 1M; 4 / 4.1 / 4.5 stayed at 200K. + // Known 4.6/4.7/4.8 releases upgraded to 1M; 4 / 4.1 / 4.5 stayed at 200K. assert_eq!( resolve("claude-opus-4-20250514", None).context_window, 200_000 @@ -389,64 +389,64 @@ fn no_substring_capability_checks_outside_this_module() { // A `ModelVariant.context_window` recorded during key validation (from the // provider's `/v1/models` `context_length`) overrides the static family // table. This is what makes a proxy capping a 1M model at 256K show the -// real limit. Uses the global KEY_SERVICE with cleanup so tests stay isolated. +// real limit. These tests stay pure: they exercise the same override helper +// without writing the global user credentials store. -use key_vault::key_store::KEY_SERVICE; -use key_vault::key_store::{ModelKey, ModelType, ModelVariant}; +use key_vault::key_store::ModelVariant; -/// Build and register a key whose sole variant pins `model` to `ctx`, then -/// return the key id. Caller must `KEY_SERVICE.delete_key_by_id(id)` to clean up. -fn register_key_with_context(model: &str, ctx: Option) -> String { - let mut key = ModelKey::new(ModelType::AnthropicApi); - key.api_key = Some(format!("sk-test-{}-", key.id)); - key.model_variants = vec![ModelVariant { +fn variant_with_context(model: &str, ctx: Option) -> ModelVariant { + ModelVariant { model: model.to_string(), base_model: model.to_string(), reasoning: None, fast: false, context_window: ctx, - }]; - let id = key.id.clone(); - KEY_SERVICE.save_key(key).expect("save_key"); - id + } } #[test] fn keyvault_context_window_overrides_family_table() { // opus-4.6 family rule = 1M; provider reports 256K → resolve must use 256K. - let id = register_key_with_context("claude-opus-4.6", Some(256_000)); - let caps = resolve("claude-opus-4.6", Some(&id)); + let variants = vec![variant_with_context("claude-opus-4.6", Some(256_000))]; + let caps = super::resolve_with_keyvault_variants("claude-opus-4.6", &variants); assert_eq!(caps.context_window, 256_000); - KEY_SERVICE.delete_key_by_id(&id).unwrap(); } #[test] fn keyvault_none_context_window_falls_back_to_family() { // Provider did not report context_length (official OpenAI/Anthropic) → // family-table value (200K) stays. - let id = register_key_with_context("claude-opus-4", None); - let caps = resolve("claude-opus-4", Some(&id)); + let variants = vec![variant_with_context("claude-opus-4", None)]; + let caps = super::resolve_with_keyvault_variants("claude-opus-4", &variants); assert_eq!(caps.context_window, 200_000); - KEY_SERVICE.delete_key_by_id(&id).unwrap(); } #[test] -fn keyvault_override_is_per_account() { - // A different account_id (no key) must NOT pick up another account's override. - let id = register_key_with_context("claude-opus-4.6", Some(131_072)); - let caps = resolve("claude-opus-4.6", Some("nonexistent-account")); +fn keyvault_empty_variant_list_falls_back_to_family() { + let caps = super::resolve_with_keyvault_variants("claude-opus-4.6", &[]); assert_eq!( caps.context_window, 1_000_000, - "unknown account must fall back to family table, not leak another account's override" + "no account variants must fall back to family table" ); - KEY_SERVICE.delete_key_by_id(&id).unwrap(); } #[test] fn keyvault_override_only_matches_exact_model() { // Variant for "claude-opus-4.6" must not override a query for "claude-opus-4". - let id = register_key_with_context("claude-opus-4.6", Some(300_000)); - let caps = resolve("claude-opus-4", Some(&id)); + let variants = vec![variant_with_context("claude-opus-4.6", Some(300_000))]; + let caps = super::resolve_with_keyvault_variants("claude-opus-4", &variants); assert_eq!(caps.context_window, 200_000); - KEY_SERVICE.delete_key_by_id(&id).unwrap(); +} + +#[test] +fn keyvault_zero_context_window_falls_back_to_family() { + let variants = vec![variant_with_context("claude-opus-4.6", Some(0))]; + let caps = super::resolve_with_keyvault_variants("claude-opus-4.6", &variants); + assert_eq!(caps.context_window, 1_000_000); +} + +#[test] +fn explicit_context_window_beats_keyvault_override() { + let window = super::resolve_effective_context_window("claude-opus-4.6", None, Some(64_000)); + assert_eq!(window, 64_000); } diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs index 93f167bdf8..bdbabba7cf 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/registry_tests.rs @@ -97,6 +97,17 @@ fn context_window_hint_gemini() { assert_eq!(context_window_hint("gemini-2.0-flash"), 1_000_000); } +#[test] +fn context_window_hint_glm() { + // Only 5.2 reached 1M; 5 / 5.1 / 5-turbo stay at 200K. + assert_eq!(context_window_hint("glm-5.2"), 1_000_000); + assert_eq!(context_window_hint("glm-5"), 200_000); + assert_eq!(context_window_hint("glm-5.1"), 200_000); + assert_eq!(context_window_hint("glm-5-turbo"), 200_000); + assert_eq!(context_window_hint("glm-4.6"), 200_000); + assert_eq!(context_window_hint("glm-4.5"), 128_000); +} + #[test] fn context_window_hint_unknown_returns_default() { assert_eq!( diff --git a/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs b/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs index 57786b3f4e..879dfba93b 100644 --- a/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs +++ b/src-tauri/crates/agent-core/src/core/session/compaction/manual.rs @@ -175,12 +175,14 @@ pub async fn run_manual_compact( // compaction state so the cumulative failure counter is // honoured (and so back-to-back manual compacts don't thrash // the provider). - let context_window = if runtime.resolved.context_window > 0 { - runtime.resolved.context_window as usize - } else { - crate::providers::model_capabilities::resolve(&runtime.model, runtime.account_id.as_deref()) - .context_window - }; + let context_window = crate::providers::model_capabilities::resolve_effective_context_window( + &runtime.model, + runtime.account_id.as_deref(), + runtime + .resolved + .context_window_configured + .then_some(runtime.resolved.context_window), + ); let (compacted, outcome) = { let mut compaction_state = session.compaction.lock().await; ContextCompactor::compact( diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs index c1ed6bc5ed..e859b9d55b 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs @@ -124,15 +124,14 @@ impl UnifiedMessageProcessor { } // 6. Context compaction - let context_window = if self.runtime.resolved.context_window > 0 { - self.runtime.resolved.context_window as usize - } else { - crate::providers::model_capabilities::resolve( - &self.runtime.model, - self.runtime.account_id.as_deref(), - ) - .context_window - }; + let context_window = crate::providers::model_capabilities::resolve_effective_context_window( + &self.runtime.model, + self.runtime.account_id.as_deref(), + self.runtime + .resolved + .context_window_configured + .then_some(self.runtime.resolved.context_window), + ); let prefix_len = leading_runtime_system_prefix_len(messages); let prefix = messages[..prefix_len].to_vec(); let mut compactable_tail = messages[prefix_len..].to_vec(); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs index d448507692..552249da31 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/execute.rs @@ -48,6 +48,11 @@ impl UnifiedMessageProcessor { let turn_config = TurnConfig { model: self.runtime.model.clone(), account_id: self.runtime.account_id.clone(), + context_window_override: self + .runtime + .resolved + .context_window_configured + .then_some(self.runtime.resolved.context_window), max_iterations: self.effective_max_iterations(), max_tokens: self.runtime.resolved.max_tokens as u32, temperature: self.runtime.resolved.temperature as f32, @@ -189,11 +194,15 @@ impl UnifiedMessageProcessor { "[unified_processor] ContextTooLong hit for session {} — reactive compact attempt {}/{}", session_id, attempt, MAX_REACTIVE_RETRIES, ); - let context_window = crate::providers::model_capabilities::resolve( - &self.runtime.model, - self.runtime.account_id.as_deref(), - ) - .context_window; + let context_window = + crate::providers::model_capabilities::resolve_effective_context_window( + &self.runtime.model, + self.runtime.account_id.as_deref(), + self.runtime + .resolved + .context_window_configured + .then_some(self.runtime.resolved.context_window), + ); let mut state = self.compaction_state.lock().await; let (compacted, reactive_outcome) = ContextCompactor::compact( messages, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index b1da4aeea9..8ff3ee3188 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -321,11 +321,15 @@ impl UnifiedMessageProcessor { .clone() .or_else(|| { (result.context_tokens > 0).then(|| { - let context_window = crate::core::providers::model_capabilities::resolve( + let context_window = + crate::core::providers::model_capabilities::resolve_effective_context_window( &self.runtime.model, self.runtime.account_id.as_deref(), - ) - .context_window as i64; + self.runtime + .resolved + .context_window_configured + .then_some(self.runtime.resolved.context_window), + ) as i64; ContextUsageSnapshot::from_payload( &result.messages, &[], diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs index d54f5071fd..52a6375ad5 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs @@ -838,6 +838,7 @@ impl Tool for AgentTool { let turn_config = TurnConfig { model: model.clone(), account_id: self.config.session_account_id.clone(), + context_window_override: agent.context_window, max_iterations: Some(max_iterations), max_tokens: agent.max_tokens.unwrap_or(self.config.max_tokens as u64) as u32, temperature: agent.temperature.unwrap_or(self.config.temperature as f64) as f32, diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs index a04052bf50..c074e23263 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs @@ -309,11 +309,12 @@ pub async fn execute_turn( if stats.chars_saved == 0 && stats.images_cleared == 0 { // Nothing left to clear — hard-truncate the history while // keeping the head (system prompt + task statement). - let window = crate::providers::model_capabilities::resolve( - &config.model, - config.account_id.as_deref(), - ) - .context_window; + let window = + crate::providers::model_capabilities::resolve_effective_context_window( + &config.model, + config.account_id.as_deref(), + config.context_window_override, + ); let budget = window.saturating_mul(3) / 4; let truncated = crate::model_context::compaction::ContextCompactor::simple_truncate( @@ -354,11 +355,12 @@ pub async fn execute_turn( // `/v1/models` context_length for this account (stored in // KeyVault). This keeps the frontend gauge honest when a proxy // caps a 1M model at 256K. - let context_window = crate::core::providers::model_capabilities::resolve( - &config.model, - config.account_id.as_deref(), - ) - .context_window as i64; + let context_window = + crate::core::providers::model_capabilities::resolve_effective_context_window( + &config.model, + config.account_id.as_deref(), + config.context_window_override, + ) as i64; let snapshot = ContextUsageSnapshot::from_payload( &llm_messages, &tool_defs, diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs index cccc2e705f..7669dfc9cf 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs @@ -77,6 +77,9 @@ pub struct TurnConfig { /// resolved key (tests, memory consolidation) — resolve then falls back /// to the static family table. pub account_id: Option, + /// User/agent-configured context window. `None` means auto-detect from the + /// model family plus any account-specific provider override. + pub context_window_override: Option, /// Maximum tool call iterations per turn. /// `None` means unlimited — the loop runs until the model stops calling tools /// (guarded by repeat detection, error loop detection, and cancellation). @@ -377,6 +380,7 @@ mod tests { let config = TurnConfig { model: "test".to_string(), account_id: None, + context_window_override: None, max_iterations: None, max_tokens: 4096, temperature: 0.5, @@ -393,6 +397,7 @@ mod tests { let config = TurnConfig { model: "test".to_string(), account_id: None, + context_window_override: None, max_iterations: Some(15), max_tokens: 4096, temperature: 0.5, diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs index 89655136b6..e0327b43aa 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs @@ -177,6 +177,7 @@ pub async fn run_consolidation( let turn_config = TurnConfig { model: params.model.to_string(), account_id: None, + context_window_override: None, max_iterations: Some(MAX_CONSOLIDATION_TURNS), max_tokens: agent_def.max_tokens.unwrap_or(8192) as u32, temperature: agent_def.temperature.unwrap_or(0.0) as f32, diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs index e5ffb5bbd7..90842a22a5 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs @@ -91,6 +91,7 @@ pub async fn run_extraction( let turn_config = TurnConfig { model: params.model.to_string(), account_id: None, + context_window_override: None, max_iterations: Some(MAX_EXTRACTION_TURNS), max_tokens: agent_def.max_tokens.unwrap_or(4096) as u32, temperature: agent_def.temperature.unwrap_or(0.0) as f32, diff --git a/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs b/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs index 1d6626d6aa..4f74f0908f 100644 --- a/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs +++ b/src-tauri/crates/agent-core/src/tests/turn_executor_retry_tests.rs @@ -210,6 +210,7 @@ fn test_config() -> TurnConfig { TurnConfig { model: "mock-model".to_string(), account_id: None, + context_window_override: None, max_iterations: Some(50), max_tokens: 1024, temperature: 0.0, diff --git a/src-tauri/crates/key-vault/src/commands/crud.rs b/src-tauri/crates/key-vault/src/commands/crud.rs index 6838052d7a..04e965d955 100644 --- a/src-tauri/crates/key-vault/src/commands/crud.rs +++ b/src-tauri/crates/key-vault/src/commands/crud.rs @@ -49,7 +49,7 @@ impl From for ModelVariant { base_model: v.base_model, reasoning: v.reasoning, fast: v.fast, - context_window: v.context_window, + context_window: v.context_window.filter(|ctx| *ctx > 0), } } } @@ -307,7 +307,7 @@ impl From for KeyInfo { base_model: variant.base_model.clone(), reasoning: variant.reasoning.clone(), fast: variant.fast, - context_window: variant.context_window, + context_window: variant.context_window.filter(|ctx| *ctx > 0), }) .collect(), default_variants: entry @@ -428,7 +428,7 @@ impl From for FullKeyResponse { base_model: variant.base_model, reasoning: variant.reasoning, fast: variant.fast, - context_window: variant.context_window, + context_window: variant.context_window.filter(|ctx| *ctx > 0), }) .collect(), default_variants: entry diff --git a/src-tauri/crates/key-vault/src/commands/tests/tests.rs b/src-tauri/crates/key-vault/src/commands/tests/tests.rs index cfc6691c86..a6e5b7720b 100644 --- a/src-tauri/crates/key-vault/src/commands/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/commands/tests/tests.rs @@ -168,4 +168,13 @@ fn test_model_variant_info_to_variant_preserves_context_window() { context_window: None, }; assert_eq!(ModelVariant::from(without_ctx).context_window, None); + + let zero_ctx = ModelVariantInfo { + model: "gpt-4o".to_string(), + base_model: "gpt-4o".to_string(), + reasoning: None, + fast: false, + context_window: Some(0), + }; + assert_eq!(ModelVariant::from(zero_ctx).context_window, None); } diff --git a/src-tauri/crates/key-vault/src/key_store/service.rs b/src-tauri/crates/key-vault/src/key_store/service.rs index 6b9fe862f9..0750dd3387 100644 --- a/src-tauri/crates/key-vault/src/key_store/service.rs +++ b/src-tauri/crates/key-vault/src/key_store/service.rs @@ -1,6 +1,6 @@ use chrono::{Duration as ChronoDuration, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -1279,14 +1279,36 @@ impl KeyService { entry.last_validation_error = error_message; entry.last_validated_at = Some(Utc::now()); + let refreshed_models: Option> = available_models + .as_ref() + .map(|models| models.iter().cloned().collect()); if let Some(models) = available_models { entry.available_models = models; } if let Some(contexts) = model_context_lengths { + // Treat the validation/refresh result as authoritative for + // the refreshed model list only: absent context_length + // means "fall back to FAMILY_RULES", not "keep a stale + // proxy cap". Health-only updates may pass an empty map + // without refreshing models, so they must not clear + // existing provider overrides. + if let Some(model_scope) = refreshed_models.as_ref() { + for variant in &mut entry.model_variants { + if model_scope.contains(&variant.model) + && !contexts.contains_key(&variant.model) + { + variant.context_window = None; + } + } + } + // find-or-push: provider-reported context windows override // the static FAMILY_RULES default at runtime. Mirrors the // reasoning writeback above. for (model, ctx) in contexts { + if *ctx == 0 { + continue; + } if let Some(variant) = entry.model_variants.iter_mut().find(|v| &v.model == model) { diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index 6d2d01fd66..56bf9f0052 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -228,6 +228,103 @@ fn test_context_window_survives_save_key_roundtrip() { ); } +#[test] +fn test_update_key_health_clears_stale_context_window_when_provider_omits_it() { + let temp_dir = tempdir().unwrap(); + let service = KeyService::new(Some(temp_dir.path().to_path_buf())); + + let mut cred = ModelKey::new(ModelType::OpenaiApi); + cred.name = Some("Ctx Clear Test".to_string()); + cred.api_key = Some("sk-test".to_string()); + let saved = service.save_key(cred).unwrap(); + + let mut contexts = HashMap::new(); + contexts.insert("gpt-4o".to_string(), 128_000u64); + service + .update_key_health( + &saved.id, + HealthStatus::Valid, + None, + Some(vec!["gpt-4o".to_string()]), + None, + None, + Some(&contexts), + ) + .unwrap(); + + service + .update_key_health( + &saved.id, + HealthStatus::Valid, + None, + Some(vec!["gpt-4o".to_string()]), + None, + None, + Some(&HashMap::new()), + ) + .unwrap(); + + let loaded = service.get_key_by_id(&saved.id).unwrap(); + let variant = loaded + .model_variants + .iter() + .find(|v| v.model == "gpt-4o") + .expect("variant remains for reasoning/default metadata"); + assert_eq!( + variant.context_window, None, + "missing context_length in a fresh provider response must clear stale override" + ); +} + +#[test] +fn test_update_key_health_preserves_context_window_without_model_refresh() { + let temp_dir = tempdir().unwrap(); + let service = KeyService::new(Some(temp_dir.path().to_path_buf())); + + let mut cred = ModelKey::new(ModelType::OpenaiApi); + cred.name = Some("Ctx Preserve Test".to_string()); + cred.api_key = Some("sk-test".to_string()); + let saved = service.save_key(cred).unwrap(); + + let mut contexts = HashMap::new(); + contexts.insert("gpt-4o".to_string(), 128_000u64); + service + .update_key_health( + &saved.id, + HealthStatus::Valid, + None, + Some(vec!["gpt-4o".to_string()]), + None, + None, + Some(&contexts), + ) + .unwrap(); + + service + .update_key_health( + &saved.id, + HealthStatus::Valid, + None, + None, + None, + None, + Some(&HashMap::new()), + ) + .unwrap(); + + let loaded = service.get_key_by_id(&saved.id).unwrap(); + let variant = loaded + .model_variants + .iter() + .find(|v| v.model == "gpt-4o") + .expect("variant remains after health-only update"); + assert_eq!( + variant.context_window, + Some(128_000), + "health-only updates must not clear provider context_window overrides" + ); +} + /// Debug test to check parsing of real credentials file #[test] fn test_parse_real_credentials_file() { diff --git a/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs b/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs index c673292636..445b6b17bc 100644 --- a/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/anthropic/mod.rs @@ -273,7 +273,7 @@ impl AnthropicValidator { let mut ids: Vec = Vec::with_capacity(models.len()); let mut contexts: HashMap = HashMap::new(); for m in models { - if let Some(ctx) = m.context_length { + if let Some(ctx) = m.context_length.filter(|ctx| *ctx > 0) { contexts.insert(m.id.clone(), ctx); } ids.push(m.id); diff --git a/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs b/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs index 828d9b601a..ef62f614a5 100644 --- a/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/azure_openai/mod.rs @@ -142,7 +142,7 @@ impl AzureOpenAIValidator { let mut ids: Vec = Vec::new(); let mut contexts: HashMap = HashMap::new(); for m in data.data.unwrap_or_default() { - if let Some(ctx) = m.context_length { + if let Some(ctx) = m.context_length.filter(|ctx| *ctx > 0) { contexts.insert(m.id.clone(), ctx); } ids.push(m.id); diff --git a/src-tauri/crates/key-vault/src/providers/openai/mod.rs b/src-tauri/crates/key-vault/src/providers/openai/mod.rs index 6c884558c3..83b4117787 100644 --- a/src-tauri/crates/key-vault/src/providers/openai/mod.rs +++ b/src-tauri/crates/key-vault/src/providers/openai/mod.rs @@ -281,7 +281,7 @@ impl OpenAIValidator { let mut all_ids: Vec = Vec::with_capacity(models.len()); let mut contexts: HashMap = HashMap::new(); for m in models { - if let Some(ctx) = m.context_length { + if let Some(ctx) = m.context_length.filter(|ctx| *ctx > 0) { contexts.insert(m.id.clone(), ctx); } all_ids.push(m.id); diff --git a/src/api/tauri/rpc/schemas/validation.ts b/src/api/tauri/rpc/schemas/validation.ts index c74a8556cf..5e1578b180 100644 --- a/src/api/tauri/rpc/schemas/validation.ts +++ b/src/api/tauri/rpc/schemas/validation.ts @@ -132,7 +132,10 @@ export const QuotaInfoSchema = z.object({ named_message: z.string().nullable(), }); -export const ModelContextLengthsSchema = z.record(z.string(), z.number()); +export const ModelContextLengthsSchema = z.record( + z.string(), + z.number().int().positive() +); export const ValidationResultSchema = z.object({ valid: z.boolean(), @@ -156,7 +159,7 @@ export const ModelVariantInfoSchema = z.object({ base_model: z.string(), reasoning: z.string().nullable().optional(), fast: z.boolean().default(false), - context_window: z.number().int().nonnegative().nullable().optional(), + context_window: z.number().int().positive().nullable().optional(), }); export const DefaultVariantInfoSchema = z.object({ diff --git a/src/api/types/keys.ts b/src/api/types/keys.ts index 0c79b0f442..5158909512 100644 --- a/src/api/types/keys.ts +++ b/src/api/types/keys.ts @@ -1,6 +1,7 @@ import type { DetectedKey, KeyInfo, + ModelContextLengths, QuotaInfo, } from "@src/api/tauri/rpc/schemas/validation"; @@ -39,6 +40,7 @@ export type { UsageItem, ValidationResult, ModelAliasInfo, + ModelContextLengths, ModelVariantInfo, DefaultVariantInfo, } from "@src/api/tauri/rpc/schemas/validation"; @@ -56,6 +58,7 @@ export interface ValidateKeyResponse { valid: boolean; message: string; available_models: string[]; + model_context_lengths?: ModelContextLengths; extracted_api_key_preview?: string; extracted_api_key?: string; extracted_base_url?: string; diff --git a/src/engines/ChatPanel/InputArea/components/useContextUsageInfo.ts b/src/engines/ChatPanel/InputArea/components/useContextUsageInfo.ts index 0c7ec35f63..dfa5b3c881 100644 --- a/src/engines/ChatPanel/InputArea/components/useContextUsageInfo.ts +++ b/src/engines/ChatPanel/InputArea/components/useContextUsageInfo.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useKeyVault } from "@src/hooks/keyVault"; import { useValidatedLastPair } from "@src/hooks/models/useValidatedLastPair"; import type { ContextUsageSnapshot } from "@src/store/session/cliSessionStatusAtom"; import { @@ -60,14 +61,26 @@ export function useContextUsageInfo(): ContextUsageInfo { const sessionTokens = useAtomValue(sessionContextTokensAtom); const contextUsage = useAtomValue(sessionContextUsageAtom); const lastModel = useValidatedLastPair(); + const { accounts } = useKeyVault({ autoLoad: true }); const modelName = lastModel?.model || lastModel?.listingModel || ""; const modelInfo = useMemo( () => (modelName ? getModelInfo(modelName) : null), [modelName] ); + const accountContextWindow = useMemo(() => { + const accountId = lastModel?.selectedAccountId; + if (!modelName || !accountId) return null; + const account = accounts.find((entry) => entry.id === accountId); + const contextWindow = account?.modelVariants?.find( + (variant) => variant.model === modelName + )?.context_window; + return typeof contextWindow === "number" && contextWindow > 0 + ? contextWindow + : null; + }, [accounts, lastModel?.selectedAccountId, modelName]); const contextWindowK = modelInfo?.contextWindow ?? 200; - const modelMaxTokens = contextWindowK * 1000; + const modelMaxTokens = accountContextWindow ?? contextWindowK * 1000; const maxTokens = contextUsage?.maxTokens ?? modelMaxTokens; const snapshotTokens = contextUsage?.usedTokens ?? 0; const displayTokens = sessionTokens > 0 ? sessionTokens : snapshotTokens; diff --git a/src/hooks/keyVault/useKeyValidation.ts b/src/hooks/keyVault/useKeyValidation.ts index 05d05815b0..902ebd357e 100644 --- a/src/hooks/keyVault/useKeyValidation.ts +++ b/src/hooks/keyVault/useKeyValidation.ts @@ -79,6 +79,9 @@ export interface UseKeyValidationOptions { /** Callback when validation succeeds. */ onValidationSuccess?: (data: { models: string[]; + modelContextLengths: NonNullable< + ValidateKeyResponse["model_context_lengths"] + >; envVars: EnvVar[]; extractedConfig: ExtractedConfig | null; }) => void; @@ -89,6 +92,9 @@ export interface UseKeyValidationReturn { validatingKey: boolean; validationError: string | null; fetchedModels: string[] | null; + fetchedModelContextLengths: NonNullable< + ValidateKeyResponse["model_context_lengths"] + >; extractedConfig: ExtractedConfig | null; /** Validate the API key. Pass overrideTestModel to use a specific model for auth check. */ validateKey: (overrideTestModel?: unknown) => Promise; @@ -139,6 +145,7 @@ async function validateKeyDirect(request: { valid: result.valid, message: result.message, available_models: result.models_available, + model_context_lengths: result.model_context_lengths, extracted_api_key_preview: apiKeyPreview, extracted_api_key: request.api_key, extracted_base_url: request.base_url, @@ -154,6 +161,7 @@ async function validateKeyDirect(request: { ? err.message : "Validation failed", available_models: [], + model_context_lengths: {}, }; } } @@ -176,6 +184,9 @@ export function useKeyValidation( const [validatingKey, setValidatingKey] = useState(false); const [validationError, setValidationError] = useState(null); const [fetchedModels, setFetchedModels] = useState(null); + const [fetchedModelContextLengths, setFetchedModelContextLengths] = useState< + NonNullable + >({}); const [extractedConfig, setExtractedConfig] = useState(null); @@ -184,6 +195,7 @@ export function useKeyValidation( const resetValidation = useCallback(() => { setKeyValidated(false); setFetchedModels(null); + setFetchedModelContextLengths({}); setExtractedConfig(null); setValidationError(null); }, []); @@ -267,6 +279,7 @@ export function useKeyValidation( // Cursor-specific: native discovery to fill in the model list when // the Rust validator only verified the key but didn't enumerate. let finalModels = result.available_models; + const finalModelContextLengths = result.model_context_lengths ?? {}; if ( agentType === CLI_AGENT.CURSOR && cursorSessionToken && @@ -310,10 +323,12 @@ export function useKeyValidation( setExtractedConfig(config); setFetchedModels(finalModels); + setFetchedModelContextLengths(finalModelContextLengths); setKeyValidated(true); onValidationSuccess?.({ models: finalModels, + modelContextLengths: finalModelContextLengths, envVars, extractedConfig: config, }); @@ -346,6 +361,7 @@ export function useKeyValidation( validatingKey, validationError, fetchedModels, + fetchedModelContextLengths, extractedConfig, validateKey: validateKeyCb, resetValidation, diff --git a/src/modules/MainApp/Integrations/KeyVault/Models/Table/integrationsModelGroups.ts b/src/modules/MainApp/Integrations/KeyVault/Models/Table/integrationsModelGroups.ts index 06af60a2a6..1cad475c7c 100644 --- a/src/modules/MainApp/Integrations/KeyVault/Models/Table/integrationsModelGroups.ts +++ b/src/modules/MainApp/Integrations/KeyVault/Models/Table/integrationsModelGroups.ts @@ -213,6 +213,7 @@ export function buildVariantsByModelFromAccounts( base_model: variant.base_model, reasoning: variant.reasoning, fast: variant.fast, + context_window: variant.context_window, }); } } diff --git a/src/scaffold/WizardSystem/variants/KeyVault/components/AgentSetupRouter.tsx b/src/scaffold/WizardSystem/variants/KeyVault/components/AgentSetupRouter.tsx index 135e66042a..e3c1dbd0d9 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/components/AgentSetupRouter.tsx +++ b/src/scaffold/WizardSystem/variants/KeyVault/components/AgentSetupRouter.tsx @@ -168,6 +168,7 @@ export const AgentSetupRouter: React.FC = ({ : []), ], available_models: codexModels, + model_context_lengths: {}, enabled_models: enabledModels, validated: true, }); @@ -213,6 +214,7 @@ export const AgentSetupRouter: React.FC = ({ : []), ], available_models: geminiModels, + model_context_lengths: {}, enabled_models: geminiModels.slice(0, 1), validated: true, }); @@ -340,6 +342,7 @@ export const AgentSetupRouter: React.FC = ({ raw_key_input: "", env_vars: envVars, available_models: claudeCodeModels, + model_context_lengths: {}, enabled_models: enabledModels, validated: true, }); diff --git a/src/scaffold/WizardSystem/variants/KeyVault/components/setup/ApiKeyProviderSetup.tsx b/src/scaffold/WizardSystem/variants/KeyVault/components/setup/ApiKeyProviderSetup.tsx index e3e030b62d..1e52878831 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/components/setup/ApiKeyProviderSetup.tsx +++ b/src/scaffold/WizardSystem/variants/KeyVault/components/setup/ApiKeyProviderSetup.tsx @@ -144,6 +144,7 @@ const ApiKeyProviderSetup: React.FC = ({ : data.extracted_base_url, validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], }); }} diff --git a/src/scaffold/WizardSystem/variants/KeyVault/components/setup/GenericSetup.tsx b/src/scaffold/WizardSystem/variants/KeyVault/components/setup/GenericSetup.tsx index c00ddfbe68..fdd2abe80f 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/components/setup/GenericSetup.tsx +++ b/src/scaffold/WizardSystem/variants/KeyVault/components/setup/GenericSetup.tsx @@ -148,6 +148,7 @@ const GenericSetup: FC = ({ auth_method: undefined, quota_info: undefined, available_models: [], + model_context_lengths: {}, enabled_models: [], model_aliases: [], }); @@ -279,6 +280,7 @@ const GenericSetup: FC = ({ : data.extracted_base_url, validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], }); }} diff --git a/src/scaffold/WizardSystem/variants/KeyVault/config/index.ts b/src/scaffold/WizardSystem/variants/KeyVault/config/index.ts index 2d73410d7d..5d73df26ea 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/config/index.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/config/index.ts @@ -38,6 +38,7 @@ export const DEFAULT_WIZARD_DATA: WizardData = { env_vars: [], validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], custom_models: [], model_aliases: [], diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/keyHelpers.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/keyHelpers.ts index 0d80e91314..a627448b5c 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/keyHelpers.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/keyHelpers.ts @@ -81,6 +81,7 @@ export function applyKey( raw_key_input: "", quota_info: quotaInfo, available_models: modelsAvailable, + model_context_lengths: {}, enabled_models: modelsEnabled, validated: true, }); @@ -94,6 +95,7 @@ export function applyKey( raw_key_input: "", quota_info: quotaInfo, available_models: modelsAvailable, + model_context_lengths: {}, enabled_models: modelsEnabled, validated: true, auth_method: "oauth", @@ -111,6 +113,7 @@ export function applyKey( raw_key_input: detectedApiKey, quota_info: quotaInfo, available_models: modelsAvailable, + model_context_lengths: {}, enabled_models: modelsEnabled, validated: cred.validated ?? true, extracted_api_key: detectedApiKey, @@ -162,6 +165,7 @@ export async function extractKeysFromInput( auth_method: undefined, quota_info: undefined, available_models: [], + model_context_lengths: {}, enabled_models: [], }); setInputMode("direct"); diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupCursorToken.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupCursorToken.ts index fbe6854f72..9b77b6f1c8 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupCursorToken.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupCursorToken.ts @@ -163,6 +163,7 @@ export function useApiSetupCursorToken({ cursor_session_token: trimmed, env_vars: cursorEnvVars, available_models: effectiveModels, + model_context_lengths: {}, enabled_models: getDefaultEnabledModels(effectiveModels), model_aliases: data.model_aliases ?? [], validated: true, @@ -181,6 +182,7 @@ export function useApiSetupCursorToken({ cursor_session_token: trimmed, env_vars: cursorEnvVars, available_models: fallbackModels, + model_context_lengths: {}, enabled_models: getDefaultEnabledModels(fallbackModels), model_aliases: data.model_aliases ?? [], validated: true, @@ -263,6 +265,7 @@ export function useApiSetupCursorToken({ ), validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], }); return; diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupValidation.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupValidation.ts index 618270b263..3169a21c90 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupValidation.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useApiSetupValidation.ts @@ -44,7 +44,12 @@ export function useApiSetupValidation({ baseUrl: data.extracted_base_url, protocol: data.protocol, inputMode: inputMode, - onValidationSuccess: ({ models, envVars, extractedConfig: config }) => { + onValidationSuccess: ({ + models, + modelContextLengths, + envVars, + extractedConfig: config, + }) => { const effectiveModels = (() => { const validationModels = getEffectiveValidationModels( models, @@ -64,6 +69,7 @@ export function useApiSetupValidation({ ); onChange({ available_models: effectiveModels, + model_context_lengths: modelContextLengths, enabled_models: isClaudeCode ? getClaudeCodeOAuthDefaultEnabledModels() : isCodex @@ -94,12 +100,14 @@ export function useApiSetupValidation({ if ((data.available_models?.length ?? 0) > 0) return; onChange({ available_models: validation.fetchedModels, + model_context_lengths: validation.fetchedModelContextLengths, enabled_models: getDefaultEnabledModels(validation.fetchedModels), validated: true, }); }, [ isCursor, validation.fetchedModels, + validation.fetchedModelContextLengths, data.available_models?.length, onChange, ]); diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderSelection.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderSelection.ts index 2d0842419c..b6745b35a9 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderSelection.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderSelection.ts @@ -84,6 +84,7 @@ export function useProviderSelection({ auth_method: undefined, validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], quota_info: undefined, extracted_api_key: undefined, @@ -108,6 +109,7 @@ export function useProviderSelection({ auth_method: undefined, validated: false, available_models: [], + model_context_lengths: {}, enabled_models: [], quota_info: undefined, extracted_api_key: undefined, diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useWizard.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useWizard.ts index d830ae0c4a..a2beb9c560 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useWizard.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useWizard.ts @@ -221,6 +221,18 @@ export function useWizard(options: UseWizardOptions): UseWizardReturn { })(); const variantMetadata = parseModelVariants(allAvailableModels); + const variantMetadataByModel = new Map( + variantMetadata.map((variant) => [variant.model, variant]) + ); + const contextModels = new Set( + Object.keys(data.model_context_lengths ?? {}).filter((model) => + allAvailableModels.includes(model) + ) + ); + const modelVariantIds = new Set([ + ...variantMetadata.map((variant) => variant.model), + ...contextModels, + ]); const request: SaveKeyRequest = { name: resolvedName, @@ -249,13 +261,17 @@ export function useWizard(options: UseWizardOptions): UseWizardReturn { })) : undefined, model_variants: - variantMetadata.length > 0 - ? variantMetadata.map((variant) => ({ - model: variant.model, - base_model: variant.baseModel, - reasoning: variant.reasoning, - fast: variant.fast, - })) + modelVariantIds.size > 0 + ? [...modelVariantIds].map((model) => { + const variant = variantMetadataByModel.get(model); + return { + model, + base_model: variant?.baseModel ?? model, + reasoning: variant?.reasoning, + fast: variant?.fast ?? false, + context_window: data.model_context_lengths?.[model], + }; + }) : undefined, default_variants: data.default_variants.length > 0 ? data.default_variants : undefined, diff --git a/src/scaffold/WizardSystem/variants/KeyVault/types/index.ts b/src/scaffold/WizardSystem/variants/KeyVault/types/index.ts index 5c1f577452..c022326d58 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/types/index.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/types/index.ts @@ -57,6 +57,8 @@ export interface WizardData { validated: boolean; /** Auto-detected models returned by the validator (e.g. /v1/models). */ available_models: string[]; + /** Provider-reported context windows keyed by model id. */ + model_context_lengths: Record; /** Models the user has enabled (checked) from the detected list */ enabled_models: string[]; /** Models the user has explicitly added on top of auto-detection. diff --git a/src/types/model/info.ts b/src/types/model/info.ts index 6871e7fbb6..5f37ced1be 100644 --- a/src/types/model/info.ts +++ b/src/types/model/info.ts @@ -53,8 +53,36 @@ export interface ModelInfo { */ const MODEL_INFO_ENTRIES: Array<{ pattern: string; info: ModelInfo }> = [ // ─── Anthropic (Claude) ─────────────────────────────────── + // Known claude-opus-4.6/4.7/4.8 releases upgraded to 1M; 4 / 4.1 / + // 4.5 stayed at 200K. Mirror the Rust FAMILY_RULES split. { - pattern: "claude-opus-4", + pattern: "claude-opus-4.6", + info: { + provider: "Anthropic", + providerKey: "anthropic", + contextWindow: 1000, + maxOutput: 32, + vision: true, + reasoning: true, + strengthKeys: ["coding", "agentic", "reasoning", "planning"], + pricingTier: "expensive", + }, + }, + { + pattern: "claude-opus-4.7", + info: { + provider: "Anthropic", + providerKey: "anthropic", + contextWindow: 1000, + maxOutput: 32, + vision: true, + reasoning: true, + strengthKeys: ["coding", "agentic", "reasoning", "planning"], + pricingTier: "expensive", + }, + }, + { + pattern: "claude-opus-4.8", info: { provider: "Anthropic", providerKey: "anthropic", @@ -66,6 +94,19 @@ const MODEL_INFO_ENTRIES: Array<{ pattern: string; info: ModelInfo }> = [ pricingTier: "expensive", }, }, + { + pattern: "claude-opus-4", + info: { + provider: "Anthropic", + providerKey: "anthropic", + contextWindow: 200, + maxOutput: 32, + vision: true, + reasoning: true, + strengthKeys: ["coding", "agentic", "reasoning", "planning"], + pricingTier: "expensive", + }, + }, { pattern: "claude-sonnet-4.5", info: { @@ -732,6 +773,18 @@ const MODEL_INFO_ENTRIES: Array<{ pattern: string; info: ModelInfo }> = [ }, // ─── Z.AI (GLM) ─────────────────────────────────────────── + { + pattern: "glm-5.2", + info: { + provider: "Z.AI", + providerKey: "zai", + contextWindow: 1000, + vision: false, + reasoning: true, + strengthKeys: ["coding", "reasoning", "agentic"], + pricingTier: "budget", + }, + }, { pattern: "glm-5", info: { diff --git a/src/util/modelVariants.ts b/src/util/modelVariants.ts index 29bf36691b..8c8a2648a9 100644 --- a/src/util/modelVariants.ts +++ b/src/util/modelVariants.ts @@ -309,6 +309,7 @@ export interface ResolvedModelVariantFields { base_model: string; reasoning?: string | null; fast: boolean; + context_window?: number | null; } /** Frontend parse wins over backend model_variants wire metadata. */ @@ -323,6 +324,7 @@ export function resolveModelVariantFields( base_model: parsed.baseModel, reasoning: parsed.reasoning ?? null, fast: parsed.fast, + context_window: fallback?.context_window, }; } if (fallback) { From ba125e404578e81184d5454b3eb18ad9ebc9cd6e Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:28:37 +0800 Subject: [PATCH 042/864] fix(ux): add plan preview bottom padding Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/modules/WorkStation/Chat/Communication/PlanDocPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/WorkStation/Chat/Communication/PlanDocPanel.tsx b/src/modules/WorkStation/Chat/Communication/PlanDocPanel.tsx index 54c828965b..7ef5c8f465 100644 --- a/src/modules/WorkStation/Chat/Communication/PlanDocPanel.tsx +++ b/src/modules/WorkStation/Chat/Communication/PlanDocPanel.tsx @@ -64,7 +64,7 @@ export const PlanDocPanel: React.FC = memo( autoFocus /> ) : isPreviewMode ? ( -
+
{hasContent ? ( ) : ( From f4a89e4be1130dfcc4b6860acaeb08a182083f59 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:33:19 +0800 Subject: [PATCH 043/864] fix(ux): move plan actions to footer Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../ChatPanel/blocks/CreatePlanCard/index.tsx | 129 ++++++++---------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx index 18e2cbe1b9..2f89846398 100644 --- a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx +++ b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx @@ -8,7 +8,7 @@ */ import type { TFunction } from "i18next"; import { useAtomValue, useSetAtom } from "jotai"; -import { CheckCircle2, Pencil, X, XCircle } from "lucide-react"; +import { X } from "lucide-react"; import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -391,73 +391,63 @@ const CreatePlanCard: React.FC = memo( title={t("planDoc.collapse")} /> ) : null; - const planActions = - ownsActions || collapseButton ? ( -
event.stopPropagation()} - > - {ownsActions && ( - <> - {autoApproveRemaining !== null && ready && !isEditing && ( - - {t("chat.autoExecuteCountdown", { - seconds: autoApproveRemaining, - })} - - )} - {ready && !isEditing && ( - - )} - {ready && ( - - )} - {isEditing ? ( - - ) : ( - - )} - - )} - {collapseButton} -
- ) : null; + const planActions = ownsActions ? ( +
event.stopPropagation()} + > + {autoApproveRemaining !== null && ready && !isEditing && ( + + {t("chat.autoExecuteCountdown", { + seconds: autoApproveRemaining, + })} + + )} + {ready && !isEditing && ( + + )} + {ready && ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+ ) : null; const planIcon = getToolIcon("create_plan", { size: PLAN_ICON_SIZE }); return ( @@ -478,7 +468,7 @@ const CreatePlanCard: React.FC = memo( onNavigate={handlePreviewNavigate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} - rightContent={planActions} + rightContent={collapseButton} > = memo( )}
))} + {!isCollapsed && planActions}
); } From 58b7941b4709d8ab4c1bf0103f110696d6ab2e0f Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 16:32:53 +0800 Subject: [PATCH 044/864] fix(agent): recognize hyphenated Claude release ids Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/core/providers/model_capabilities.rs | 18 +++++++++++++++++- .../tests/model_capabilities_tests.rs | 8 ++++++++ src/types/model/info.ts | 12 +++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index f78b8ba061..c42176ddf7 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -613,7 +613,7 @@ fn resolve_with_keyvault_variants(model: &str, variants: &[ModelVariant]) -> Mod fn resolve_from_family_table(model: &str) -> ModelCapabilities { let normalized = super::model_hints::normalize_claude_shorthand(model); - let model_lower = normalized.to_lowercase(); + let model_lower = normalize_claude_release_separators(&normalized.to_lowercase()); for rule in FAMILY_RULES { if model_lower.contains(rule.pattern) { return ModelCapabilities { @@ -626,6 +626,22 @@ fn resolve_from_family_table(model: &str) -> ModelCapabilities { ModelCapabilities::unknown() } +fn normalize_claude_release_separators(model: &str) -> String { + const ALIASES: &[(&str, &str)] = &[ + ("claude-opus-4-6", "claude-opus-4.6"), + ("claude-opus-4-7", "claude-opus-4.7"), + ("claude-opus-4-8", "claude-opus-4.8"), + ("claude-sonnet-4-5", "claude-sonnet-4.5"), + ("claude-sonnet-4-6", "claude-sonnet-4.6"), + ]; + + let mut normalized = model.to_string(); + for (from, to) in ALIASES { + normalized = normalized.replace(from, to); + } + normalized +} + /// KeyVault layer: a `ModelVariant` row with `reasoning: Some(..)` marks /// the model as a reasoning model for this account. The writeback in /// `side_query.rs` uses the value `"always_on"` to record observed diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs index d6d2309b9e..f439112b5a 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/model_capabilities_tests.rs @@ -22,6 +22,14 @@ fn claude_opus_4_is_optional() { assert_eq!(resolve("claude-opus-4.6", None).context_window, 1_000_000); assert_eq!(resolve("claude-opus-4.7", None).context_window, 1_000_000); assert_eq!(resolve("claude-opus-4.8", None).context_window, 1_000_000); + assert_eq!( + resolve("anthropic/claude-opus-4-8", None).context_window, + 1_000_000 + ); + assert_eq!( + resolve("anthropic/claude-opus-4-8-fast", None).context_window, + 1_000_000 + ); assert_eq!( resolve("claude-opus-4", None).thinking, ThinkingSupport::Optional diff --git a/src/types/model/info.ts b/src/types/model/info.ts index 5f37ced1be..9780a464b0 100644 --- a/src/types/model/info.ts +++ b/src/types/model/info.ts @@ -852,8 +852,18 @@ const MODEL_INFO_ENTRIES: Array<{ pattern: string; info: ModelInfo }> = [ * Uses prefix/substring matching against registered patterns. * Returns the first (most specific) match, or null if no match. */ +function normalizeModelInfoCategory(category: string): string { + return category + .toLowerCase() + .replaceAll("claude-opus-4-6", "claude-opus-4.6") + .replaceAll("claude-opus-4-7", "claude-opus-4.7") + .replaceAll("claude-opus-4-8", "claude-opus-4.8") + .replaceAll("claude-sonnet-4-5", "claude-sonnet-4.5") + .replaceAll("claude-sonnet-4-6", "claude-sonnet-4.6"); +} + export function getModelInfo(category: string): ModelInfo | null { - const lower = category.toLowerCase(); + const lower = normalizeModelInfoCategory(category); for (const entry of MODEL_INFO_ENTRIES) { if (lower.includes(entry.pattern)) { return entry.info; From d87c65e295c6330de91558dfbd5797a58f664163 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:04:18 +0800 Subject: [PATCH 045/864] fix(ux): keep collapsed plan footer actions Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx index 2f89846398..3010938080 100644 --- a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx +++ b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx @@ -393,7 +393,7 @@ const CreatePlanCard: React.FC = memo( ) : null; const planActions = ownsActions ? (
event.stopPropagation()} > {autoApproveRemaining !== null && ready && !isEditing && ( @@ -517,7 +517,7 @@ const CreatePlanCard: React.FC = memo( )}
))} - {!isCollapsed && planActions} + {planActions}
); } From 1339cc83690678cc507dae1f0f29a9790290593c Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:54:06 +0800 Subject: [PATCH 046/864] feat(agent-usage): show per-tool token attribution Persist LLM usage spans and tool-level attribution so ORGII can display compact token usage badges on tool calls and grouped activity summaries. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/core/session/turn/processor/mod.rs | 74 +++ .../agent-core/src/core/turn_executor/mod.rs | 15 +- .../core/turn_executor/tool_execution/mod.rs | 30 +- .../turn_executor/tool_execution/parallel.rs | 28 +- .../turn_executor/tool_execution/single.rs | 27 +- .../src/core/turn_executor/types.rs | 5 + .../src/core/turn_executor/usage_telemetry.rs | 342 +++++++++++ .../src/foundation/session_bridge.rs | 57 ++ .../src/agent_core_bridge.rs | 57 ++ .../session-persistence/src/commands.rs | 39 ++ .../crates/session-persistence/src/lib.rs | 5 +- .../crates/session-persistence/src/schema.rs | 100 ++++ .../session-persistence/src/tool_usage.rs | 543 ++++++++++++++++++ src-tauri/src/commands/handler_list.inc | 3 + src/api/tauri/session/index.ts | 11 + src/api/tauri/session/usage.ts | 78 +++ .../ChatPanel/ChatHistory/ActivityRouter.tsx | 8 +- .../components/ChatHistoryList.tsx | 2 + .../renderers/GroupItemRenderer.tsx | 6 +- .../ChatItems/ActionSummaryGroup/index.tsx | 57 +- .../blocks/ToolCallBlock/ToolUsageBadge.tsx | 58 ++ .../__tests__/ToolUsageBadge.test.ts | 11 + .../ChatPanel/blocks/ToolCallBlock/index.tsx | 11 +- .../ChatPanel/blocks/ToolCallBlock/types.ts | 6 +- .../blocks/primitives/StackedBlock.tsx | 4 + .../rendering/adapters/FallbackAdapter.tsx | 1 + src/engines/SessionCore/core/types.ts | 16 + .../rendering/props/propsNormalizer.ts | 18 +- .../rendering/types/universalProps.ts | 3 + .../sync/adapters/createRustAgentAdapter.ts | 43 +- .../__tests__/toolUsageCache.test.ts | 149 +++++ .../sync/adapters/rustAgent/toolUsageCache.ts | 167 ++++++ src/i18n/locales/de/sessions.json | 6 + src/i18n/locales/en/sessions.json | 6 + src/i18n/locales/es/sessions.json | 6 + src/i18n/locales/fr/sessions.json | 6 + src/i18n/locales/ja/sessions.json | 6 + src/i18n/locales/ko/sessions.json | 6 + src/i18n/locales/pl/sessions.json | 6 + src/i18n/locales/pt/sessions.json | 6 + src/i18n/locales/ru/sessions.json | 6 + src/i18n/locales/tr/sessions.json | 6 + src/i18n/locales/vi/sessions.json | 6 + src/i18n/locales/zh-Hant/sessions.json | 6 + src/i18n/locales/zh/sessions.json | 6 + 45 files changed, 2022 insertions(+), 30 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/turn_executor/usage_telemetry.rs create mode 100644 src-tauri/crates/session-persistence/src/tool_usage.rs create mode 100644 src/api/tauri/session/usage.ts create mode 100644 src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge.tsx create mode 100644 src/engines/ChatPanel/blocks/ToolCallBlock/__tests__/ToolUsageBadge.test.ts create mode 100644 src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts create mode 100644 src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index b0cb9c86c5..354bdd6a56 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -361,6 +361,79 @@ impl UnifiedMessageProcessor { } }); } + + fn record_usage_telemetry(&self, session_id: &str, turn_id: &str, result: &TurnResult) { + if result.usage_telemetry.llm_spans.is_empty() + && result.usage_telemetry.tool_attributions.is_empty() + { + return; + } + + let related_tool_call_ids_json = result + .usage_telemetry + .llm_spans + .iter() + .map(|span| serde_json::to_string(&span.related_tool_call_ids).ok()) + .collect::>(); + + tokio::task::block_in_place(|| { + use crate::foundation::session_bridge::{ + record_usage_telemetry_batch, LlmUsageSpanRow, ToolUsageAttributionRow, + UsageTelemetryBatch, + }; + + let llm_spans = result + .usage_telemetry + .llm_spans + .iter() + .zip(related_tool_call_ids_json.iter()) + .map(|(span, related_ids_json)| LlmUsageSpanRow { + session_id, + turn_id, + iteration_index: span.iteration_index, + model: Some(&self.runtime.model), + account_id: self.runtime.account_id.as_deref(), + prompt_tokens: span.prompt_tokens, + completion_tokens: span.completion_tokens, + cache_read_tokens: span.cache_read_tokens, + cache_write_tokens: span.cache_write_tokens, + total_tokens: span.total_tokens, + context_tokens: span.context_tokens, + related_tool_call_ids_json: related_ids_json.clone(), + context_usage_json: span.context_usage_json.clone(), + }) + .collect::>(); + let tool_attributions = result + .usage_telemetry + .tool_attributions + .iter() + .map(|attribution| ToolUsageAttributionRow { + session_id, + turn_id, + event_id: &attribution.event_id, + tool_call_id: &attribution.tool_call_id, + tool_name: &attribution.tool_name, + iteration_index: attribution.iteration_index, + decision_completion_tokens: attribution.decision_completion_tokens, + result_context_tokens: attribution.result_context_tokens, + followup_completion_tokens: attribution.followup_completion_tokens, + input_bytes: attribution.input_bytes, + output_bytes: attribution.output_bytes, + attribution_method: attribution.attribution_method.as_str(), + }) + .collect::>(); + + if let Err(err) = record_usage_telemetry_batch(UsageTelemetryBatch { + llm_spans, + tool_attributions, + }) { + warn!( + "[unified_processor] Failed to record usage telemetry batch: {}", + err + ); + } + }); + } } impl UnifiedMessageProcessor { @@ -685,6 +758,7 @@ impl UnifiedMessageProcessor { // 8. Record token usage self.record_token_usage(session_id, &result); + self.record_usage_telemetry(session_id, &turn_id, &result); let final_turn_state = if self .session diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs index 3a59eac98d..dd11f7249d 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod tool_execution; pub(crate) mod tool_result_storage; mod types; mod usage_accumulator; +mod usage_telemetry; // Items kept at the `turn_executor::` surface — checked one by one // against real call sites. The accessor / structured-key set @@ -33,6 +34,7 @@ pub use types::{ PermissionProvider, PermissionVerdict, ToolHookIntervention, TurnConfig, TurnEventHandler, TurnIterationHook, TurnResult, }; +pub use usage_telemetry::{AttributionMethod, LlmUsageSpan, ToolUsageAttribution, UsageTelemetry}; // `MAX_TOOL_OUTPUT_CHARS` is consumed by `helpers::*` and a couple of test // modules via `use crate::core::turn_executor::MAX_TOOL_OUTPUT_CHARS`. @@ -64,6 +66,7 @@ use screenshot::resolve_screenshot_markers; use stream_error_recovery::{handle_stream_error, RetryBudgets, StreamErrorOutcome}; use tool_execution::{execute_tool_calls, ToolBatchOutcome}; use usage_accumulator::UsageTotals; +use usage_telemetry::UsageTelemetryCollector; #[cfg(test)] #[path = "../../tests/processor_tests.rs"] @@ -108,6 +111,7 @@ pub async fn execute_turn( let mut final_is_stream_error = false; let mut usage = UsageTotals::default(); + let mut usage_telemetry = UsageTelemetryCollector::default(); let mut context_usage_snapshot: Option = None; let mut last_tool_signature: Option = None; @@ -362,6 +366,13 @@ pub async fn execute_turn( Some(context_window), ); handler.on_context_usage(session_id, &snapshot); + usage_telemetry.record_llm_span( + iteration as i64, + &response.usage, + usage.last_prompt, + &response.tool_calls, + Some(&snapshot), + ); context_usage_snapshot = Some(snapshot); } @@ -498,7 +509,7 @@ pub async fn execute_turn( &config.model, ); - let (_count, outcome) = execute_tool_calls( + let (_count, tool_execution_usage, outcome) = execute_tool_calls( messages, &response.tool_calls, tools, @@ -514,6 +525,7 @@ pub async fn execute_turn( config.max_tool_use_concurrency, ) .await; + usage_telemetry.record_tool_results(iteration as i64, tool_execution_usage); // Backfill dummy results for any tool calls that don't have a // result yet after EarlyExit. @@ -679,5 +691,6 @@ pub async fn execute_turn( context_usage_snapshot, cache_read_tokens: usage.cache_read, cache_write_tokens: usage.cache_write, + usage_telemetry: usage_telemetry.finish(), }) } diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs index ff1a408b96..8724c5f5ff 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/mod.rs @@ -38,6 +38,7 @@ use crate::tools::registry::ToolRegistry; use super::file_tracker::FileTimeTracker; use super::types::{PermissionProvider, TurnEventHandler}; +use super::usage_telemetry::ToolExecutionUsage; use parallel::{execute_parallel_group, ParallelResult}; use single::{execute_single_tool, SingleResult}; @@ -178,9 +179,10 @@ pub(crate) async fn execute_tool_calls( workspace_path: Option<&std::path::Path>, policy_context_activator: Option<&SessionScopedContextActivator>, max_tool_use_concurrency: usize, -) -> (usize, ToolBatchOutcome) { +) -> (usize, Vec, ToolBatchOutcome) { let groups = partition_tool_calls(tool_calls, tools); let mut executed_count = 0; + let mut execution_usage = Vec::new(); for group in groups { match group { @@ -202,9 +204,13 @@ pub(crate) async fn execute_tool_calls( ) .await; match result { - ParallelResult::Continue(count) => executed_count += count, - ParallelResult::EarlyExit(count, outcome) => { - return (executed_count + count, outcome); + ParallelResult::Continue(count, mut usage) => { + executed_count += count; + execution_usage.append(&mut usage); + } + ParallelResult::EarlyExit(count, mut usage, outcome) => { + execution_usage.append(&mut usage); + return (executed_count + count, execution_usage, outcome); } } } @@ -226,9 +232,12 @@ pub(crate) async fn execute_tool_calls( ) .await; match result { - SingleResult::Continue => executed_count += 1, + SingleResult::Continue(usage) => { + executed_count += 1; + execution_usage.push(usage); + } SingleResult::EarlyExit(outcome) => { - return (executed_count + 1, outcome); + return (executed_count + 1, execution_usage, outcome); } } } @@ -250,16 +259,19 @@ pub(crate) async fn execute_tool_calls( ) .await; match result { - SingleResult::Continue => executed_count += 1, + SingleResult::Continue(usage) => { + executed_count += 1; + execution_usage.push(usage); + } SingleResult::EarlyExit(outcome) => { - return (executed_count + 1, outcome); + return (executed_count + 1, execution_usage, outcome); } } } } } - (executed_count, ToolBatchOutcome::Continue) + (executed_count, execution_usage, ToolBatchOutcome::Continue) } #[cfg(test)] diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs index 2c81b23e49..c0fd326ccf 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/parallel.rs @@ -19,6 +19,7 @@ use super::super::helpers::{ check_permission, truncate_output, }; use super::super::types::{PermissionProvider, TurnEventHandler}; +use super::super::usage_telemetry::{serialized_value_bytes, string_bytes, ToolExecutionUsage}; use super::detect_stream_parse_error; use super::is_cancelled; @@ -27,8 +28,8 @@ use super::normalize_tool_use_concurrency; use super::ToolBatchOutcome; pub(super) enum ParallelResult { - Continue(usize), - EarlyExit(usize, ToolBatchOutcome), + Continue(usize, Vec), + EarlyExit(usize, Vec, ToolBatchOutcome), } /// Execute a group of read-only tool calls concurrently. @@ -73,7 +74,7 @@ pub(super) async fn execute_parallel_group( info!("[agent-core] Tool call: {}({})", call.name, args_preview); if is_cancelled(cancel_flag) { - return ParallelResult::EarlyExit(denied_count, ToolBatchOutcome::Cancelled); + return ParallelResult::EarlyExit(denied_count, Vec::new(), ToolBatchOutcome::Cancelled); } let display_name = match call @@ -156,7 +157,7 @@ pub(super) async fn execute_parallel_group( .await { if is_cancelled(cancel_flag) { - return ParallelResult::EarlyExit(denied_count, ToolBatchOutcome::Cancelled); + return ParallelResult::EarlyExit(denied_count, Vec::new(), ToolBatchOutcome::Cancelled); } handler.on_tool_result(session_id, &call.id, &call.name, &display_name, &denied_msg); add_tool_result(messages, &call.id, &call.name, &denied_msg, true); @@ -195,7 +196,7 @@ pub(super) async fn execute_parallel_group( } if is_cancelled(cancel_flag) { - return ParallelResult::EarlyExit(denied_count, ToolBatchOutcome::Cancelled); + return ParallelResult::EarlyExit(denied_count, Vec::new(), ToolBatchOutcome::Cancelled); } let call = calls[prep.index]; handler.on_tool_execute_start(session_id, &call.id, &call.name, &prep.effective_args); @@ -245,6 +246,7 @@ pub(super) async fn execute_parallel_group( } let mut executed_count = 0; + let mut execution_usage = Vec::new(); for (idx, display_name, result) in &blocked_results { let call = calls[*idx]; @@ -252,6 +254,12 @@ pub(super) async fn execute_parallel_group( handler.on_tool_result(session_id, &call.id, &call.name, display_name, result); add_tool_result_with_timestamp(messages, &call.id, &call.name, result, is_err); executed_count += 1; + execution_usage.push(ToolExecutionUsage { + tool_call_id: call.id.clone(), + tool_name: call.name.clone(), + input_bytes: serialized_value_bytes(&call.arguments), + output_bytes: string_bytes(result), + }); if is_err { *consecutive_errors += 1; @@ -382,10 +390,17 @@ pub(super) async fn execute_parallel_group( } } executed_count += 1; + execution_usage.push(ToolExecutionUsage { + tool_call_id: call.id.clone(), + tool_name: call.name.clone(), + input_bytes: serialized_value_bytes(&exec_result.effective_args), + output_bytes: string_bytes(&truncated), + }); if is_cancelled(cancel_flag) { return ParallelResult::EarlyExit( executed_count + denied_count, + execution_usage, ToolBatchOutcome::Cancelled, ); } @@ -399,6 +414,7 @@ pub(super) async fn execute_parallel_group( } return ParallelResult::EarlyExit( executed_count + denied_count, + execution_usage, ToolBatchOutcome::ErrorLoop(format!( "I encountered {} consecutive tool errors and stopped to avoid wasting resources. \ The last error was: {}", @@ -412,5 +428,5 @@ pub(super) async fn execute_parallel_group( } } - ParallelResult::Continue(executed_count + denied_count) + ParallelResult::Continue(executed_count + denied_count, execution_usage) } diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/single.rs b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/single.rs index db15b59ff8..8b5a5ccbfd 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/single.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/tool_execution/single.rs @@ -21,6 +21,7 @@ use super::super::helpers::{ check_permission, truncate_output, }; use super::super::types::{PermissionProvider, TurnEventHandler}; +use super::super::usage_telemetry::{serialized_value_bytes, string_bytes, ToolExecutionUsage}; use super::detect_stream_parse_error; use super::diff_feedback::compute_diff_feedback; @@ -29,7 +30,7 @@ use super::is_error_text; use super::ToolBatchOutcome; pub(super) enum SingleResult { - Continue, + Continue(ToolExecutionUsage), EarlyExit(ToolBatchOutcome), } @@ -49,6 +50,7 @@ pub(super) async fn execute_single_tool( workspace_path: Option<&std::path::Path>, policy_context_activator: Option<&SessionScopedContextActivator>, ) -> SingleResult { + let input_bytes = serialized_value_bytes(&tool_call.arguments); let args_preview: String = crate::utils::safe_truncate_chars_to_string(&tool_call.arguments.to_string(), 200); info!( @@ -145,7 +147,12 @@ pub(super) async fn execute_single_tool( ); add_tool_result(messages, &tool_call.id, &tool_call.name, &err_msg, true); *consecutive_errors += 1; - return SingleResult::Continue; + return SingleResult::Continue(ToolExecutionUsage { + tool_call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + input_bytes, + output_bytes: string_bytes(&err_msg), + }); } if let Some(denied_msg) = check_permission( @@ -170,7 +177,12 @@ pub(super) async fn execute_single_tool( &denied_msg, ); add_tool_result(messages, &tool_call.id, &tool_call.name, &denied_msg, true); - return SingleResult::Continue; + return SingleResult::Continue(ToolExecutionUsage { + tool_call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + input_bytes, + output_bytes: string_bytes(&denied_msg), + }); } let file_time_error = if is_file_write_tool(&tool_call.name) { @@ -428,7 +440,12 @@ pub(super) async fn execute_single_tool( *consecutive_errors = 0; } - SingleResult::Continue + SingleResult::Continue(ToolExecutionUsage { + tool_call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + input_bytes, + output_bytes: string_bytes(&result), + }) } #[cfg(test)] @@ -472,7 +489,7 @@ mod tests { ) .await; - assert!(matches!(result, SingleResult::Continue)); + assert!(matches!(result, SingleResult::Continue(_))); let tool_calls = handler.tool_calls.lock().unwrap().clone(); assert_eq!(tool_calls[0].2["file_path"], "original.txt"); diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs index 9291b2a00b..2948df8b7a 100644 --- a/src-tauri/crates/agent-core/src/core/turn_executor/types.rs +++ b/src-tauri/crates/agent-core/src/core/turn_executor/types.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use serde_json::Value; use crate::core::turn_executor::context_accounting::ContextUsageSnapshot; +use crate::core::turn_executor::usage_telemetry::UsageTelemetry; use crate::tools::traits::ToolUIMetadata; use shared_state::ScreenshotStore; @@ -128,6 +129,8 @@ pub struct TurnResult { pub cache_read_tokens: i64, /// Accumulated cache-write tokens (Anthropic prompt caching). pub cache_write_tokens: i64, + /// Per-LLM-call spans and per-tool-call attribution for diagnostics. + pub usage_telemetry: UsageTelemetry, } // ============================================ @@ -341,6 +344,7 @@ mod tests { context_usage_snapshot: None, cache_read_tokens: 0, cache_write_tokens: 0, + usage_telemetry: UsageTelemetry::default(), messages: vec![], }; assert_eq!(result.cache_read_tokens, 0); @@ -359,6 +363,7 @@ mod tests { context_usage_snapshot: None, cache_read_tokens: 500, cache_write_tokens: 300, + usage_telemetry: UsageTelemetry::default(), messages: vec![], }; assert_eq!(result.cache_read_tokens, 500); diff --git a/src-tauri/crates/agent-core/src/core/turn_executor/usage_telemetry.rs b/src-tauri/crates/agent-core/src/core/turn_executor/usage_telemetry.rs new file mode 100644 index 0000000000..ac85678df1 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/turn_executor/usage_telemetry.rs @@ -0,0 +1,342 @@ +//! Turn-local usage telemetry for LLM spans and tool attribution. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::providers::traits::{usage_key, ToolCallRequest}; + +use super::context_accounting::ContextUsageSnapshot; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributionMethod { + ProviderExact, + SingleToolIteration, + SplitBySerializedSize, + SplitEvenly, + EstimatedTokenizer, + BytesOnly, +} + +impl AttributionMethod { + pub const fn as_str(self) -> &'static str { + match self { + Self::ProviderExact => "provider_exact", + Self::SingleToolIteration => "single_tool_iteration", + Self::SplitBySerializedSize => "split_by_serialized_size", + Self::SplitEvenly => "split_evenly", + Self::EstimatedTokenizer => "estimated_tokenizer", + Self::BytesOnly => "bytes_only", + } + } +} + +#[derive(Debug, Clone)] +pub struct LlmUsageSpan { + pub iteration_index: i64, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub total_tokens: i64, + pub context_tokens: i64, + pub related_tool_call_ids: Vec, + pub context_usage_json: Option, +} + +#[derive(Debug, Clone)] +pub struct ToolExecutionUsage { + pub tool_call_id: String, + pub tool_name: String, + pub input_bytes: i64, + pub output_bytes: i64, +} + +#[derive(Debug, Clone)] +pub struct ToolUsageAttribution { + pub event_id: String, + pub tool_call_id: String, + pub tool_name: String, + pub iteration_index: i64, + pub decision_completion_tokens: i64, + pub result_context_tokens: i64, + pub followup_completion_tokens: i64, + pub input_bytes: i64, + pub output_bytes: i64, + pub attribution_method: AttributionMethod, +} + +#[derive(Debug, Clone, Default)] +pub struct UsageTelemetry { + pub llm_spans: Vec, + pub tool_attributions: Vec, +} + +#[derive(Debug, Clone)] +struct PendingDecisionAttribution { + decision_completion_tokens: i64, + attribution_method: AttributionMethod, +} + +#[derive(Debug, Default)] +pub(super) struct UsageTelemetryCollector { + spans: Vec, + pending_decisions: HashMap, + tool_attributions: Vec, +} + +impl UsageTelemetryCollector { + pub fn record_llm_span( + &mut self, + iteration_index: i64, + usage: &HashMap, + context_tokens: i64, + tool_calls: &[ToolCallRequest], + context_usage_snapshot: Option<&ContextUsageSnapshot>, + ) { + let prompt_tokens = usage.get(usage_key::PROMPT_TOKENS).copied().unwrap_or(0); + let completion_tokens = usage + .get(usage_key::COMPLETION_TOKENS) + .copied() + .unwrap_or(0); + let total_tokens = usage.get(usage_key::TOTAL_TOKENS).copied().unwrap_or(0); + let cache_read_tokens = usage + .get(usage_key::CACHE_READ_TOKENS) + .copied() + .unwrap_or(0); + let cache_write_tokens = usage + .get(usage_key::CACHE_WRITE_TOKENS) + .copied() + .unwrap_or(0); + let related_tool_call_ids = tool_calls.iter().map(|tool_call| tool_call.id.clone()).collect(); + let context_usage_json = context_usage_snapshot.and_then(|snapshot| serde_json::to_string(snapshot).ok()); + self.spans.push(LlmUsageSpan { + iteration_index, + prompt_tokens, + completion_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + context_tokens, + related_tool_call_ids, + context_usage_json, + }); + self.record_decision_completion(iteration_index, completion_tokens, tool_calls); + } + + pub fn record_tool_results( + &mut self, + iteration_index: i64, + tool_results: Vec, + ) { + for tool_result in tool_results { + let decision = self.pending_decisions.remove(&tool_result.tool_call_id); + let (decision_completion_tokens, attribution_method) = decision + .map(|pending| (pending.decision_completion_tokens, pending.attribution_method)) + .unwrap_or((0, AttributionMethod::BytesOnly)); + self.tool_attributions.push(ToolUsageAttribution { + event_id: format!("tool-call-{}", tool_result.tool_call_id), + tool_call_id: tool_result.tool_call_id, + tool_name: tool_result.tool_name, + iteration_index, + decision_completion_tokens, + result_context_tokens: estimate_tokens_from_bytes(tool_result.output_bytes), + followup_completion_tokens: 0, + input_bytes: tool_result.input_bytes, + output_bytes: tool_result.output_bytes, + attribution_method, + }); + } + } + + pub fn finish(self) -> UsageTelemetry { + UsageTelemetry { + llm_spans: self.spans, + tool_attributions: self.tool_attributions, + } + } + + fn record_decision_completion( + &mut self, + _iteration_index: i64, + completion_tokens: i64, + tool_calls: &[ToolCallRequest], + ) { + if tool_calls.is_empty() || completion_tokens <= 0 { + return; + } + + if tool_calls.len() == 1 { + self.pending_decisions.insert( + tool_calls[0].id.clone(), + PendingDecisionAttribution { + decision_completion_tokens: completion_tokens, + attribution_method: AttributionMethod::SingleToolIteration, + }, + ); + return; + } + + let serialized_sizes: Vec = tool_calls + .iter() + .map(|tool_call| serialized_tool_call_size(tool_call).max(1)) + .collect(); + let total_size: i64 = serialized_sizes.iter().sum(); + if total_size <= 0 { + let split = completion_tokens / tool_calls.len() as i64; + let remainder = completion_tokens % tool_calls.len() as i64; + for (index, tool_call) in tool_calls.iter().enumerate() { + self.pending_decisions.insert( + tool_call.id.clone(), + PendingDecisionAttribution { + decision_completion_tokens: split + i64::from(index == 0) * remainder, + attribution_method: AttributionMethod::SplitEvenly, + }, + ); + } + return; + } + + let mut allocated = 0; + let last_index = tool_calls.len().saturating_sub(1); + for (index, tool_call) in tool_calls.iter().enumerate() { + let tokens = if index == last_index { + completion_tokens - allocated + } else { + let proportional = completion_tokens * serialized_sizes[index] / total_size; + allocated += proportional; + proportional + }; + self.pending_decisions.insert( + tool_call.id.clone(), + PendingDecisionAttribution { + decision_completion_tokens: tokens, + attribution_method: AttributionMethod::SplitBySerializedSize, + }, + ); + } + } +} + +fn serialized_tool_call_size(tool_call: &ToolCallRequest) -> i64 { + tool_call.id.len() as i64 + tool_call.name.len() as i64 + tool_call.arguments.to_string().len() as i64 +} + +pub fn estimate_tokens_from_bytes(bytes: i64) -> i64 { + if bytes <= 0 { + 0 + } else { + (bytes + 3) / 4 + } +} + +pub fn serialized_value_bytes(value: &Value) -> i64 { + value.to_string().len() as i64 +} + +pub fn string_bytes(value: &str) -> i64 { + value.len() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn call(id: &str, name: &str, arguments: Value) -> ToolCallRequest { + ToolCallRequest { + id: id.to_string(), + name: name.to_string(), + arguments, + thought_signature: None, + } + } + + #[test] + fn single_tool_iteration_gets_all_decision_tokens() { + let mut collector = UsageTelemetryCollector::default(); + let tool_calls = vec![call("call-1", "read_file", json!({"path":"a.md"}))]; + let mut usage = HashMap::new(); + usage.insert(usage_key::COMPLETION_TOKENS.to_string(), 42); + collector.record_llm_span(1, &usage, 100, &tool_calls, None); + collector.record_tool_results( + 1, + vec![ToolExecutionUsage { + tool_call_id: "call-1".to_string(), + tool_name: "read_file".to_string(), + input_bytes: 15, + output_bytes: 400, + }], + ); + let telemetry = collector.finish(); + assert_eq!(telemetry.tool_attributions.len(), 1); + assert_eq!(telemetry.tool_attributions[0].decision_completion_tokens, 42); + assert_eq!( + telemetry.tool_attributions[0].attribution_method, + AttributionMethod::SingleToolIteration + ); + assert_eq!(telemetry.tool_attributions[0].result_context_tokens, 100); + } + + #[test] + fn multiple_tool_iterations_split_by_serialized_size() { + let mut collector = UsageTelemetryCollector::default(); + let tool_calls = vec![ + call("call-1", "read_file", json!({"path":"a.md"})), + call("call-2", "read_file", json!({"path":"a-very-long-path-name.md"})), + ]; + let mut usage = HashMap::new(); + usage.insert(usage_key::COMPLETION_TOKENS.to_string(), 100); + collector.record_llm_span(1, &usage, 100, &tool_calls, None); + collector.record_tool_results( + 1, + vec![ + ToolExecutionUsage { + tool_call_id: "call-1".to_string(), + tool_name: "read_file".to_string(), + input_bytes: 10, + output_bytes: 100, + }, + ToolExecutionUsage { + tool_call_id: "call-2".to_string(), + tool_name: "read_file".to_string(), + input_bytes: 20, + output_bytes: 200, + }, + ], + ); + let telemetry = collector.finish(); + assert_eq!(telemetry.tool_attributions.len(), 2); + assert_eq!( + telemetry.tool_attributions[0].attribution_method, + AttributionMethod::SplitBySerializedSize + ); + let allocated: i64 = telemetry + .tool_attributions + .iter() + .map(|attribution| attribution.decision_completion_tokens) + .sum(); + assert_eq!(allocated, 100); + } + + #[test] + fn missing_decision_uses_bytes_only() { + let mut collector = UsageTelemetryCollector::default(); + collector.record_tool_results( + 2, + vec![ToolExecutionUsage { + tool_call_id: "call-late".to_string(), + tool_name: "read_file".to_string(), + input_bytes: 10, + output_bytes: 8, + }], + ); + let telemetry = collector.finish(); + assert_eq!(telemetry.tool_attributions[0].decision_completion_tokens, 0); + assert_eq!(telemetry.tool_attributions[0].result_context_tokens, 2); + assert_eq!( + telemetry.tool_attributions[0].attribution_method, + AttributionMethod::BytesOnly + ); + } +} diff --git a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs index 593ff60f80..cf5df6d520 100644 --- a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs +++ b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs @@ -188,6 +188,63 @@ pub fn record_token_usage(row: TokenUsageRow<'_>) -> rusqlite::Result { } } +#[derive(Debug, Clone)] +pub struct LlmUsageSpanRow<'a> { + pub session_id: &'a str, + pub turn_id: &'a str, + pub iteration_index: i64, + pub model: Option<&'a str>, + pub account_id: Option<&'a str>, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub total_tokens: i64, + pub context_tokens: i64, + pub related_tool_call_ids_json: Option, + pub context_usage_json: Option, +} + +#[derive(Debug, Clone)] +pub struct ToolUsageAttributionRow<'a> { + pub session_id: &'a str, + pub turn_id: &'a str, + pub event_id: &'a str, + pub tool_call_id: &'a str, + pub tool_name: &'a str, + pub iteration_index: i64, + pub decision_completion_tokens: i64, + pub result_context_tokens: i64, + pub followup_completion_tokens: i64, + pub input_bytes: i64, + pub output_bytes: i64, + pub attribution_method: &'a str, +} + +#[derive(Debug, Clone)] +pub struct UsageTelemetryBatch<'a> { + pub llm_spans: Vec>, + pub tool_attributions: Vec>, +} + +pub type RecordUsageTelemetryBatchFn = fn(UsageTelemetryBatch<'_>) -> rusqlite::Result<()>; + +static RECORD_USAGE_TELEMETRY_BATCH: OnceLock = OnceLock::new(); + +pub fn register_record_usage_telemetry_batch(implementation: RecordUsageTelemetryBatchFn) { + let _ = RECORD_USAGE_TELEMETRY_BATCH.set(implementation); +} + +pub fn record_usage_telemetry_batch(batch: UsageTelemetryBatch<'_>) -> rusqlite::Result<()> { + match RECORD_USAGE_TELEMETRY_BATCH.get() { + Some(implementation) => implementation(batch), + None => { + tracing::warn!("[session-bridge] record_usage_telemetry_batch called before register"); + Ok(()) + } + } +} + // --------------------------------------------------------------------------- // 3. CLI effective tool snapshot // --------------------------------------------------------------------------- diff --git a/src-tauri/crates/session-persistence/src/agent_core_bridge.rs b/src-tauri/crates/session-persistence/src/agent_core_bridge.rs index 1464259b03..ed722700d2 100644 --- a/src-tauri/crates/session-persistence/src/agent_core_bridge.rs +++ b/src-tauri/crates/session-persistence/src/agent_core_bridge.rs @@ -10,6 +10,7 @@ use agent_core::foundation::{db_bridge, session_bridge}; +use super::tool_usage::{AttributionMethod, NewLlmUsageSpan, NewToolUsageAttribution}; use super::turn_intents::{self, TurnIntentSource as PsSource, TurnIntentStatus as PsStatus}; /// Adapter that maps a `TokenUsageRow` projection into the live @@ -30,6 +31,61 @@ fn record_token_usage_adapter(row: session_bridge::TokenUsageRow<'_>) -> rusqlit ) } +fn map_attribution_method(value: &str) -> AttributionMethod { + match value { + "provider_exact" => AttributionMethod::ProviderExact, + "single_tool_iteration" => AttributionMethod::SingleToolIteration, + "split_by_serialized_size" => AttributionMethod::SplitBySerializedSize, + "split_evenly" => AttributionMethod::SplitEvenly, + "estimated_tokenizer" => AttributionMethod::EstimatedTokenizer, + "bytes_only" => AttributionMethod::BytesOnly, + _ => AttributionMethod::BytesOnly, + } +} + +fn record_usage_telemetry_batch_adapter( + batch: session_bridge::UsageTelemetryBatch<'_>, +) -> rusqlite::Result<()> { + let spans = batch + .llm_spans + .iter() + .map(|span| NewLlmUsageSpan { + session_id: span.session_id, + turn_id: span.turn_id, + iteration_index: span.iteration_index, + model: span.model, + account_id: span.account_id, + prompt_tokens: span.prompt_tokens, + completion_tokens: span.completion_tokens, + cache_read_tokens: span.cache_read_tokens, + cache_write_tokens: span.cache_write_tokens, + total_tokens: span.total_tokens, + context_tokens: span.context_tokens, + related_tool_call_ids_json: span.related_tool_call_ids_json.as_deref(), + context_usage_json: span.context_usage_json.as_deref(), + }) + .collect::>(); + let attributions = batch + .tool_attributions + .iter() + .map(|attribution| NewToolUsageAttribution { + session_id: attribution.session_id, + turn_id: attribution.turn_id, + event_id: attribution.event_id, + tool_call_id: attribution.tool_call_id, + tool_name: attribution.tool_name, + iteration_index: attribution.iteration_index, + decision_completion_tokens: attribution.decision_completion_tokens, + result_context_tokens: attribution.result_context_tokens, + followup_completion_tokens: attribution.followup_completion_tokens, + input_bytes: attribution.input_bytes, + output_bytes: attribution.output_bytes, + attribution_method: map_attribution_method(attribution.attribution_method), + }) + .collect::>(); + super::tool_usage::insert_usage_telemetry_batch(&spans, &attributions) +} + fn map_bridge_status(status: session_bridge::TurnIntentBridgeStatus) -> PsStatus { use session_bridge::TurnIntentBridgeStatus as B; match status { @@ -114,6 +170,7 @@ fn mark_pending_turn_intents_stale_adapter(session_id: &str) { pub fn register() { db_bridge::register(super::get_connection); session_bridge::register_record_token_usage(record_token_usage_adapter); + session_bridge::register_record_usage_telemetry_batch(record_usage_telemetry_batch_adapter); session_bridge::register_upsert_turn_intent(upsert_turn_intent_adapter); session_bridge::register_update_turn_intent_status(update_turn_intent_status_adapter); session_bridge::register_mark_pending_turn_intents_stale( diff --git a/src-tauri/crates/session-persistence/src/commands.rs b/src-tauri/crates/session-persistence/src/commands.rs index de269e5c57..356f5f2082 100644 --- a/src-tauri/crates/session-persistence/src/commands.rs +++ b/src-tauri/crates/session-persistence/src/commands.rs @@ -245,3 +245,42 @@ pub async fn get_session_token_usage_records( .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } + +#[tauri::command] +pub async fn get_session_llm_usage_spans( + session_id: String, + turn_id: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + super::tool_usage::get_llm_usage_spans(&session_id, turn_id.as_deref()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_session_tool_usage_attributions( + session_id: String, + turn_id: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + super::tool_usage::get_tool_usage_attributions(&session_id, turn_id.as_deref()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_session_tool_usage_attributions_for_call( + session_id: String, + tool_call_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + super::tool_usage::get_tool_usage_attributions_for_call(&session_id, &tool_call_id) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/session-persistence/src/lib.rs b/src-tauri/crates/session-persistence/src/lib.rs index 8f5faf1fa7..4c1c809d33 100644 --- a/src-tauri/crates/session-persistence/src/lib.rs +++ b/src-tauri/crates/session-persistence/src/lib.rs @@ -27,6 +27,7 @@ mod editing; pub(crate) mod schema; mod sequence; pub mod token_usage; +pub mod tool_usage; mod turn_files; mod turn_index; mod turn_index_debounce; @@ -68,5 +69,7 @@ pub use commands::{ cache_get_session_metadata, cache_get_stats, cache_load_events, cache_load_session, cache_load_turn_index, cache_save_events, cache_save_session, cache_search_all_sessions, cache_search_events, cache_truncate_after_event, cache_update_event, - cache_update_session_specs, get_session_token_usage_records, + cache_update_session_specs, get_session_llm_usage_spans, + get_session_token_usage_records, get_session_tool_usage_attributions, + get_session_tool_usage_attributions_for_call, }; diff --git a/src-tauri/crates/session-persistence/src/schema.rs b/src-tauri/crates/session-persistence/src/schema.rs index f116ec753f..1b1b13b977 100644 --- a/src-tauri/crates/session-persistence/src/schema.rs +++ b/src-tauri/crates/session-persistence/src/schema.rs @@ -248,6 +248,69 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { ) .ok(); + conn.execute( + "CREATE TABLE IF NOT EXISTS session_llm_usage_spans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + iteration_index INTEGER NOT NULL, + model TEXT, + account_id TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + context_tokens INTEGER NOT NULL DEFAULT 0, + related_tool_call_ids_json TEXT, + context_usage_json TEXT, + created_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_slus_session_turn ON session_llm_usage_spans(session_id, turn_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_slus_session_iteration ON session_llm_usage_spans(session_id, iteration_index)", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS session_tool_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + event_id TEXT NOT NULL, + tool_call_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + iteration_index INTEGER NOT NULL, + decision_completion_tokens INTEGER NOT NULL DEFAULT 0, + result_context_tokens INTEGER NOT NULL DEFAULT 0, + followup_completion_tokens INTEGER NOT NULL DEFAULT 0, + input_bytes INTEGER NOT NULL DEFAULT 0, + output_bytes INTEGER NOT NULL DEFAULT 0, + attribution_method TEXT NOT NULL, + created_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stool_session_turn ON session_tool_usage(session_id, turn_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stool_session_call ON session_tool_usage(session_id, tool_call_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stool_session_iteration ON session_tool_usage(session_id, iteration_index)", + [], + )?; + // ============================================ // Repository tracking table // ============================================ @@ -347,3 +410,40 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn index_exists(conn: &Connection, index_name: &str) -> bool { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?1)", + [index_name], + |row| row.get::<_, bool>(0), + ) + .expect("query index existence") + } + + fn table_exists(conn: &Connection, table_name: &str) -> bool { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table_name], + |row| row.get::<_, bool>(0), + ) + .expect("query table existence") + } + + #[test] + fn init_session_tables_creates_usage_telemetry_tables_and_indexes() { + let conn = Connection::open_in_memory().expect("open in-memory sqlite"); + init_session_tables(&conn).expect("init session schema"); + + assert!(table_exists(&conn, "session_llm_usage_spans")); + assert!(table_exists(&conn, "session_tool_usage")); + assert!(index_exists(&conn, "idx_slus_session_turn")); + assert!(index_exists(&conn, "idx_slus_session_iteration")); + assert!(index_exists(&conn, "idx_stool_session_turn")); + assert!(index_exists(&conn, "idx_stool_session_call")); + assert!(index_exists(&conn, "idx_stool_session_iteration")); + } +} diff --git a/src-tauri/crates/session-persistence/src/tool_usage.rs b/src-tauri/crates/session-persistence/src/tool_usage.rs new file mode 100644 index 0000000000..78660941a1 --- /dev/null +++ b/src-tauri/crates/session-persistence/src/tool_usage.rs @@ -0,0 +1,543 @@ +//! Per-LLM-call usage spans and per-tool-call attribution persistence. + +use chrono::Utc; +use rusqlite::{params, Connection, Result as SqliteResult}; +use serde::{Deserialize, Serialize}; + +use super::connection::with_sessions_writer; +use super::get_connection; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttributionMethod { + ProviderExact, + SingleToolIteration, + SplitBySerializedSize, + SplitEvenly, + EstimatedTokenizer, + BytesOnly, +} + +impl AttributionMethod { + pub const fn as_str(self) -> &'static str { + match self { + Self::ProviderExact => "provider_exact", + Self::SingleToolIteration => "single_tool_iteration", + Self::SplitBySerializedSize => "split_by_serialized_size", + Self::SplitEvenly => "split_evenly", + Self::EstimatedTokenizer => "estimated_tokenizer", + Self::BytesOnly => "bytes_only", + } + } + + fn from_str(value: &str) -> Self { + match value { + "provider_exact" => Self::ProviderExact, + "single_tool_iteration" => Self::SingleToolIteration, + "split_by_serialized_size" => Self::SplitBySerializedSize, + "split_evenly" => Self::SplitEvenly, + "estimated_tokenizer" => Self::EstimatedTokenizer, + "bytes_only" => Self::BytesOnly, + _ => Self::BytesOnly, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LlmUsageSpanRecord { + pub id: i64, + pub session_id: String, + pub turn_id: String, + pub iteration_index: i64, + pub model: Option, + pub account_id: Option, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub total_tokens: i64, + pub context_tokens: i64, + pub related_tool_call_ids_json: Option, + pub context_usage_json: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewLlmUsageSpan<'a> { + pub session_id: &'a str, + pub turn_id: &'a str, + pub iteration_index: i64, + pub model: Option<&'a str>, + pub account_id: Option<&'a str>, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub total_tokens: i64, + pub context_tokens: i64, + pub related_tool_call_ids_json: Option<&'a str>, + pub context_usage_json: Option<&'a str>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolUsageAttributionRecord { + pub id: i64, + pub session_id: String, + pub turn_id: String, + pub event_id: String, + pub tool_call_id: String, + pub tool_name: String, + pub iteration_index: i64, + pub decision_completion_tokens: i64, + pub result_context_tokens: i64, + pub followup_completion_tokens: i64, + pub input_bytes: i64, + pub output_bytes: i64, + pub attribution_method: AttributionMethod, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewToolUsageAttribution<'a> { + pub session_id: &'a str, + pub turn_id: &'a str, + pub event_id: &'a str, + pub tool_call_id: &'a str, + pub tool_name: &'a str, + pub iteration_index: i64, + pub decision_completion_tokens: i64, + pub result_context_tokens: i64, + pub followup_completion_tokens: i64, + pub input_bytes: i64, + pub output_bytes: i64, + pub attribution_method: AttributionMethod, +} + +pub fn insert_usage_telemetry_batch( + spans: &[NewLlmUsageSpan<'_>], + attributions: &[NewToolUsageAttribution<'_>], +) -> SqliteResult<()> { + with_sessions_writer(|| { + let mut conn = get_connection()?; + insert_usage_telemetry_batch_with_conn(&mut conn, spans, attributions) + }) +} + +pub fn insert_usage_telemetry_batch_with_conn( + conn: &mut Connection, + spans: &[NewLlmUsageSpan<'_>], + attributions: &[NewToolUsageAttribution<'_>], +) -> SqliteResult<()> { + let transaction = conn.transaction()?; + let now = Utc::now().to_rfc3339(); + + { + let mut stmt = transaction.prepare_cached( + "INSERT INTO session_llm_usage_spans + (session_id, turn_id, iteration_index, model, account_id, + prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, + total_tokens, context_tokens, related_tool_call_ids_json, + context_usage_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + )?; + for span in spans { + stmt.execute(params![ + span.session_id, + span.turn_id, + span.iteration_index, + span.model, + span.account_id, + span.prompt_tokens, + span.completion_tokens, + span.cache_read_tokens, + span.cache_write_tokens, + span.total_tokens, + span.context_tokens, + span.related_tool_call_ids_json, + span.context_usage_json, + now, + ])?; + } + } + + { + let mut stmt = transaction.prepare_cached( + "INSERT INTO session_tool_usage + (session_id, turn_id, event_id, tool_call_id, tool_name, iteration_index, + decision_completion_tokens, result_context_tokens, followup_completion_tokens, + input_bytes, output_bytes, attribution_method, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + )?; + for attribution in attributions { + stmt.execute(params![ + attribution.session_id, + attribution.turn_id, + attribution.event_id, + attribution.tool_call_id, + attribution.tool_name, + attribution.iteration_index, + attribution.decision_completion_tokens, + attribution.result_context_tokens, + attribution.followup_completion_tokens, + attribution.input_bytes, + attribution.output_bytes, + attribution.attribution_method.as_str(), + now, + ])?; + } + } + + transaction.commit() +} + +pub fn get_llm_usage_spans( + session_id: &str, + turn_id: Option<&str>, +) -> SqliteResult> { + let conn = get_connection()?; + let sql = match turn_id { + Some(_) => { + "SELECT id, session_id, turn_id, iteration_index, model, account_id, + prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, + total_tokens, context_tokens, related_tool_call_ids_json, + context_usage_json, created_at + FROM session_llm_usage_spans + WHERE session_id = ?1 AND turn_id = ?2 + ORDER BY iteration_index ASC, id ASC" + } + None => { + "SELECT id, session_id, turn_id, iteration_index, model, account_id, + prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, + total_tokens, context_tokens, related_tool_call_ids_json, + context_usage_json, created_at + FROM session_llm_usage_spans + WHERE session_id = ?1 + ORDER BY turn_id ASC, iteration_index ASC, id ASC" + } + }; + let mut stmt = conn.prepare(sql)?; + let map_row = |row: &rusqlite::Row<'_>| { + Ok(LlmUsageSpanRecord { + id: row.get(0)?, + session_id: row.get(1)?, + turn_id: row.get(2)?, + iteration_index: row.get(3)?, + model: row.get(4)?, + account_id: row.get(5)?, + prompt_tokens: row.get(6)?, + completion_tokens: row.get(7)?, + cache_read_tokens: row.get(8)?, + cache_write_tokens: row.get(9)?, + total_tokens: row.get(10)?, + context_tokens: row.get(11)?, + related_tool_call_ids_json: row.get(12)?, + context_usage_json: row.get(13)?, + created_at: row.get(14)?, + }) + }; + + let records = match turn_id { + Some(turn_id) => stmt + .query_map(params![session_id, turn_id], map_row)? + .collect::>>()?, + None => stmt + .query_map(params![session_id], map_row)? + .collect::>>()?, + }; + Ok(records) +} + +pub fn get_tool_usage_attributions( + session_id: &str, + turn_id: Option<&str>, +) -> SqliteResult> { + let conn = get_connection()?; + let sql = match turn_id { + Some(_) => { + "SELECT id, session_id, turn_id, event_id, tool_call_id, tool_name, + iteration_index, decision_completion_tokens, result_context_tokens, + followup_completion_tokens, input_bytes, output_bytes, + attribution_method, created_at + FROM session_tool_usage + WHERE session_id = ?1 AND turn_id = ?2 + ORDER BY iteration_index ASC, id ASC" + } + None => { + "SELECT id, session_id, turn_id, event_id, tool_call_id, tool_name, + iteration_index, decision_completion_tokens, result_context_tokens, + followup_completion_tokens, input_bytes, output_bytes, + attribution_method, created_at + FROM session_tool_usage + WHERE session_id = ?1 + ORDER BY turn_id ASC, iteration_index ASC, id ASC" + } + }; + let mut stmt = conn.prepare(sql)?; + let map_row = |row: &rusqlite::Row<'_>| { + let method: String = row.get(12)?; + Ok(ToolUsageAttributionRecord { + id: row.get(0)?, + session_id: row.get(1)?, + turn_id: row.get(2)?, + event_id: row.get(3)?, + tool_call_id: row.get(4)?, + tool_name: row.get(5)?, + iteration_index: row.get(6)?, + decision_completion_tokens: row.get(7)?, + result_context_tokens: row.get(8)?, + followup_completion_tokens: row.get(9)?, + input_bytes: row.get(10)?, + output_bytes: row.get(11)?, + attribution_method: AttributionMethod::from_str(&method), + created_at: row.get(13)?, + }) + }; + + let records = match turn_id { + Some(turn_id) => stmt + .query_map(params![session_id, turn_id], map_row)? + .collect::>>()?, + None => stmt + .query_map(params![session_id], map_row)? + .collect::>>()?, + }; + Ok(records) +} + +pub fn get_tool_usage_attributions_for_call( + session_id: &str, + tool_call_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + let mut stmt = conn.prepare( + "SELECT id, session_id, turn_id, event_id, tool_call_id, tool_name, + iteration_index, decision_completion_tokens, result_context_tokens, + followup_completion_tokens, input_bytes, output_bytes, + attribution_method, created_at + FROM session_tool_usage + WHERE session_id = ?1 AND tool_call_id = ?2 + ORDER BY iteration_index ASC, id ASC", + )?; + let records = stmt + .query_map(params![session_id, tool_call_id], |row| { + let method: String = row.get(12)?; + Ok(ToolUsageAttributionRecord { + id: row.get(0)?, + session_id: row.get(1)?, + turn_id: row.get(2)?, + event_id: row.get(3)?, + tool_call_id: row.get(4)?, + tool_name: row.get(5)?, + iteration_index: row.get(6)?, + decision_completion_tokens: row.get(7)?, + result_context_tokens: row.get(8)?, + followup_completion_tokens: row.get(9)?, + input_bytes: row.get(10)?, + output_bytes: row.get(11)?, + attribution_method: AttributionMethod::from_str(&method), + created_at: row.get(13)?, + }) + })? + .collect::>>()?; + Ok(records) +} + +pub fn delete_usage_telemetry(session_id: &str) -> SqliteResult { + with_sessions_writer(|| { + let conn = get_connection()?; + let span_count = conn.execute( + "DELETE FROM session_llm_usage_spans WHERE session_id = ?1", + [session_id], + )?; + let attribution_count = conn.execute( + "DELETE FROM session_tool_usage WHERE session_id = ?1", + [session_id], + )?; + Ok(span_count + attribution_count) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + static ORGII_HOME_TEST_LOCK: StdMutex<()> = StdMutex::new(()); + + fn with_temp_orgii_home(run: impl FnOnce() -> R) -> R { + let _guard = match ORGII_HOME_TEST_LOCK.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let previous = std::env::var("ORGII_HOME").ok(); + let root = std::env::temp_dir().join(format!( + "orgii-tool-usage-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create temp ORGII_HOME"); + std::env::set_var("ORGII_HOME", &root); + { + let conn = get_connection().expect("open sessions DB"); + super::super::schema::init_session_tables(&conn).expect("init session schema for test"); + } + let result = run(); + match previous { + Some(value) => std::env::set_var("ORGII_HOME", value), + None => std::env::remove_var("ORGII_HOME"), + } + let _ = std::fs::remove_dir_all(&root); + result + } + + #[test] + fn usage_telemetry_round_trips_span_and_tool_attribution() { + with_temp_orgii_home(|| { + let related_tool_call_ids_json = r#"["call-1"]"#; + let context_usage_json = r#"{"usedTokens":1200}"#; + insert_usage_telemetry_batch( + &[NewLlmUsageSpan { + session_id: "session-1", + turn_id: "turn-1", + iteration_index: 1, + model: Some("model-1"), + account_id: Some("account-1"), + prompt_tokens: 1000, + completion_tokens: 100, + cache_read_tokens: 50, + cache_write_tokens: 25, + total_tokens: 1175, + context_tokens: 1075, + related_tool_call_ids_json: Some(related_tool_call_ids_json), + context_usage_json: Some(context_usage_json), + }], + &[NewToolUsageAttribution { + session_id: "session-1", + turn_id: "turn-1", + event_id: "tool-call-call-1", + tool_call_id: "call-1", + tool_name: "read_file", + iteration_index: 1, + decision_completion_tokens: 100, + result_context_tokens: 240, + followup_completion_tokens: 40, + input_bytes: 20, + output_bytes: 960, + attribution_method: AttributionMethod::SingleToolIteration, + }], + ) + .expect("insert usage telemetry"); + + let spans = get_llm_usage_spans("session-1", Some("turn-1")) + .expect("load llm usage spans"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].iteration_index, 1); + assert_eq!( + spans[0].related_tool_call_ids_json.as_deref(), + Some(related_tool_call_ids_json) + ); + assert_eq!(spans[0].context_usage_json.as_deref(), Some(context_usage_json)); + + let attributions = get_tool_usage_attributions_for_call("session-1", "call-1") + .expect("load tool usage attribution"); + assert_eq!(attributions.len(), 1); + assert_eq!(attributions[0].tool_name, "read_file"); + assert_eq!( + attributions[0].attribution_method, + AttributionMethod::SingleToolIteration + ); + }); + } + + #[test] + fn usage_telemetry_queries_filter_by_session_turn_and_call_id() { + with_temp_orgii_home(|| { + insert_usage_telemetry_batch( + &[ + NewLlmUsageSpan { + session_id: "session-1", + turn_id: "turn-1", + iteration_index: 1, + model: None, + account_id: None, + prompt_tokens: 10, + completion_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_tokens: 30, + context_tokens: 10, + related_tool_call_ids_json: Some(r#"["call-1"]"#), + context_usage_json: None, + }, + NewLlmUsageSpan { + session_id: "session-1", + turn_id: "turn-2", + iteration_index: 2, + model: None, + account_id: None, + prompt_tokens: 30, + completion_tokens: 40, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_tokens: 70, + context_tokens: 30, + related_tool_call_ids_json: Some(r#"["call-2"]"#), + context_usage_json: None, + }, + ], + &[ + NewToolUsageAttribution { + session_id: "session-1", + turn_id: "turn-1", + event_id: "tool-call-call-1", + tool_call_id: "call-1", + tool_name: "read_file", + iteration_index: 1, + decision_completion_tokens: 20, + result_context_tokens: 5, + followup_completion_tokens: 0, + input_bytes: 10, + output_bytes: 20, + attribution_method: AttributionMethod::SingleToolIteration, + }, + NewToolUsageAttribution { + session_id: "session-1", + turn_id: "turn-2", + event_id: "tool-call-call-2", + tool_call_id: "call-2", + tool_name: "run_shell", + iteration_index: 2, + decision_completion_tokens: 40, + result_context_tokens: 15, + followup_completion_tokens: 3, + input_bytes: 30, + output_bytes: 60, + attribution_method: AttributionMethod::BytesOnly, + }, + ], + ) + .expect("insert usage telemetry"); + + let all_spans = get_llm_usage_spans("session-1", None).expect("load all spans"); + assert_eq!(all_spans.len(), 2); + let turn_two_spans = + get_llm_usage_spans("session-1", Some("turn-2")).expect("load turn spans"); + assert_eq!(turn_two_spans.len(), 1); + assert_eq!(turn_two_spans[0].completion_tokens, 40); + + let turn_one_attributions = get_tool_usage_attributions("session-1", Some("turn-1")) + .expect("load turn attributions"); + assert_eq!(turn_one_attributions.len(), 1); + assert_eq!(turn_one_attributions[0].tool_call_id, "call-1"); + + let call_two_attributions = get_tool_usage_attributions_for_call("session-1", "call-2") + .expect("load call attributions"); + assert_eq!(call_two_attributions.len(), 1); + assert_eq!(call_two_attributions[0].turn_id, "turn-2"); + }); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 2d25c26edd..ce42872d4b 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -325,6 +325,9 @@ orgtrack::orgtrack_get_session_checkpoints, orgtrack::orgtrack_get_checkpoint_file_states, // Per-round token usage session_persistence::get_session_token_usage_records, +session_persistence::get_session_llm_usage_spans, +session_persistence::get_session_tool_usage_attributions, +session_persistence::get_session_tool_usage_attributions_for_call, // Git Bundle commands (for cloud session upload - preserves git history) git::bundle::create_git_bundle, git::bundle::get_git_repo_info, diff --git a/src/api/tauri/session/index.ts b/src/api/tauri/session/index.ts index 24e672f442..80f375b74d 100644 --- a/src/api/tauri/session/index.ts +++ b/src/api/tauri/session/index.ts @@ -26,6 +26,17 @@ export { isHostedKey, isOwnKey, } from "./dispatchTypes"; +export { + getSessionLlmUsageSpans, + getSessionToolUsageAttributions, + getSessionToolUsageAttributionsForCall, + TOOL_USAGE_ATTRIBUTION_METHOD, +} from "./usage"; +export type { + LlmUsageSpanRecord, + ToolUsageAttributionMethod, + ToolUsageAttributionRecord, +} from "./usage"; // Re-export session aggregate types from RPC schemas (single source of truth). export type { diff --git a/src/api/tauri/session/usage.ts b/src/api/tauri/session/usage.ts new file mode 100644 index 0000000000..e3ebeca0cf --- /dev/null +++ b/src/api/tauri/session/usage.ts @@ -0,0 +1,78 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const TOOL_USAGE_ATTRIBUTION_METHOD = { + PROVIDER_EXACT: "provider_exact", + SINGLE_TOOL_ITERATION: "single_tool_iteration", + SPLIT_BY_SERIALIZED_SIZE: "split_by_serialized_size", + SPLIT_EVENLY: "split_evenly", + ESTIMATED_TOKENIZER: "estimated_tokenizer", + BYTES_ONLY: "bytes_only", +} as const; + +export type ToolUsageAttributionMethod = + (typeof TOOL_USAGE_ATTRIBUTION_METHOD)[keyof typeof TOOL_USAGE_ATTRIBUTION_METHOD]; + +export interface LlmUsageSpanRecord { + id: number; + sessionId: string; + turnId: string; + iterationIndex: number; + model?: string | null; + accountId?: string | null; + promptTokens: number; + completionTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + contextTokens: number; + relatedToolCallIdsJson?: string | null; + contextUsageJson?: string | null; + createdAt: string; +} + +export interface ToolUsageAttributionRecord { + id: number; + sessionId: string; + turnId: string; + eventId: string; + toolCallId: string; + toolName: string; + iterationIndex: number; + decisionCompletionTokens: number; + resultContextTokens: number; + followupCompletionTokens: number; + inputBytes: number; + outputBytes: number; + attributionMethod: ToolUsageAttributionMethod; + createdAt: string; +} + +export async function getSessionLlmUsageSpans( + sessionId: string, + turnId?: string +): Promise { + return invoke("get_session_llm_usage_spans", { + sessionId, + turnId: turnId ?? null, + }); +} + +export async function getSessionToolUsageAttributions( + sessionId: string, + turnId?: string +): Promise { + return invoke("get_session_tool_usage_attributions", { + sessionId, + turnId: turnId ?? null, + }); +} + +export async function getSessionToolUsageAttributionsForCall( + sessionId: string, + toolCallId: string +): Promise { + return invoke("get_session_tool_usage_attributions_for_call", { + sessionId, + toolCallId, + }); +} diff --git a/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx b/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx index 6af9239c8c..3bb76f4f14 100644 --- a/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx +++ b/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx @@ -9,7 +9,10 @@ import React, { Suspense, memo, useMemo } from "react"; import { AgentMessageBlock } from "@src/engines/ChatPanel/blocks"; import MessageReferenceCards from "@src/engines/ChatPanel/blocks/MessageReferenceCards"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, +} from "@src/engines/SessionCore/core/types"; import { chatRequiresItemIndex, chatShowsStatusLine, @@ -123,6 +126,9 @@ function arePropsEqual( if (prevArgs?.command !== nextArgs?.command) return false; if (prevArgs?.action !== nextArgs?.action) return false; if (prevArgs?.subagentSessionId !== nextArgs?.subagentSessionId) return false; + if (prevArgs?.[TOOL_USAGE_ARGS_KEY] !== nextArgs?.[TOOL_USAGE_ARGS_KEY]) { + return false; + } return true; } diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index e3f43b2c58..272f76e8fe 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -19,6 +19,7 @@ import React, { import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { PlanningFooter } from "@src/engines/ChatPanel/blocks/primitives"; +import { TOOL_USAGE_ARGS_KEY } from "@src/engines/SessionCore/core/types"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import { CHAT_FOOTER_SPACER } from "../config/chatFooterSpacer"; @@ -98,6 +99,7 @@ const ARG_RENDER_KEYS = [ "new_string", "new_content", "subagentSessionId", + TOOL_USAGE_ARGS_KEY, ] as const; function sameRecordKeys( diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx index c012120056..0a7294b4e7 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx @@ -13,7 +13,10 @@ import { getEventBlockContainerClasses, } from "@src/engines/ChatPanel/blocks/primitives"; import { useBlockHeader } from "@src/engines/ChatPanel/blocks/useBlockLocate"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, +} from "@src/engines/SessionCore/core/types"; import { AgentTurnContext, @@ -87,6 +90,7 @@ const ARG_RENDER_KEYS = [ "new_string", "new_content", "subagentSessionId", + TOOL_USAGE_ARGS_KEY, ] as const; function sameRecordKeys( diff --git a/src/engines/ChatPanel/ChatItems/ActionSummaryGroup/index.tsx b/src/engines/ChatPanel/ChatItems/ActionSummaryGroup/index.tsx index f731baeef4..41e15ea733 100644 --- a/src/engines/ChatPanel/ChatItems/ActionSummaryGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/ActionSummaryGroup/index.tsx @@ -13,8 +13,13 @@ import { Waypoints } from "lucide-react"; import React, { Suspense, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; import { StackedBlock } from "@src/engines/ChatPanel/blocks/primitives"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; @@ -109,6 +114,52 @@ function buildGroupSummary( // Render Item — delegates to registry component // ============================================ +function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { + if (event.toolUsage) return event.toolUsage; + const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as ToolUsageMetadata; +} + +function aggregateToolUsage( + items: readonly CategorizedEvent[] +): ToolUsageMetadata | undefined { + const usages = items + .map((item) => readToolUsage(item.event)) + .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); + if (usages.length === 0) return undefined; + return usages.reduce( + (total, usage) => ({ + decisionCompletionTokens: + total.decisionCompletionTokens + usage.decisionCompletionTokens, + resultContextTokens: + total.resultContextTokens + usage.resultContextTokens, + followupCompletionTokens: + total.followupCompletionTokens + usage.followupCompletionTokens, + inputBytes: total.inputBytes + usage.inputBytes, + outputBytes: total.outputBytes + usage.outputBytes, + relatedCacheReadTokens: + total.relatedCacheReadTokens + usage.relatedCacheReadTokens, + relatedCacheWriteTokens: + total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, + attributionMethod: + total.attributionMethod === usage.attributionMethod + ? total.attributionMethod + : usage.attributionMethod, + }), + { + decisionCompletionTokens: 0, + resultContextTokens: 0, + followupCompletionTokens: 0, + inputBytes: 0, + outputBytes: 0, + relatedCacheReadTokens: 0, + relatedCacheWriteTokens: 0, + attributionMethod: usages[0].attributionMethod, + } + ); +} + function renderEventBlock( { event, isLastItem }: CategorizedEvent, _index: number @@ -165,6 +216,7 @@ const ActionSummaryGroup: React.FC = ({ firstEvent?.functionName || firstEvent?.uiCanonical || firstEvent?.actionType; + const groupToolUsage = aggregateToolUsage(orderedItems); return (
= ({ defaultCollapsed={closedByBoundary} collapseWhen={closedByBoundary} eventId={firstEvent?.id} + rightContent={ + groupToolUsage ? : undefined + } renderItem={renderEventBlock} />
diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge.tsx new file mode 100644 index 0000000000..6d938c68c6 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { TOOL_USAGE_ATTRIBUTION_METHOD } from "@src/api/tauri/session"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; + +interface ToolUsageBadgeProps { + usage: ToolUsageMetadata; +} + +export function formatToolUsageTokenCount(tokens: number): string { + if (tokens >= 1000) { + const value = tokens / 1000; + return `${value.toFixed(value >= 10 ? 0 : 1)}k`; + } + return String(tokens); +} + +function isEstimated(method: string): boolean { + return method !== TOOL_USAGE_ATTRIBUTION_METHOD.PROVIDER_EXACT; +} + +const ToolUsageBadge: React.FC = ({ usage }) => { + const { t } = useTranslation("sessions"); + const contextTokens = usage.resultContextTokens; + const followupTokens = usage.followupCompletionTokens; + const decisionTokens = usage.decisionCompletionTokens; + const primaryTokens = contextTokens || followupTokens || decisionTokens; + + if (primaryTokens <= 0) return null; + + const label = formatToolUsageTokenCount(primaryTokens); + + const title = t("toolUsage.tooltip", { + method: usage.attributionMethod, + inputBytes: usage.inputBytes, + outputBytes: usage.outputBytes, + decisionTokens: usage.decisionCompletionTokens, + contextTokens: usage.resultContextTokens, + followupTokens: usage.followupCompletionTokens, + cacheReadTokens: usage.relatedCacheReadTokens, + cacheWriteTokens: usage.relatedCacheWriteTokens, + }); + + return ( + + {isEstimated(usage.attributionMethod) && ( + ~ + )} + {label} + + ); +}; + +export default React.memo(ToolUsageBadge); diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/__tests__/ToolUsageBadge.test.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/__tests__/ToolUsageBadge.test.ts new file mode 100644 index 0000000000..070318cc20 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/__tests__/ToolUsageBadge.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { formatToolUsageTokenCount } from "../ToolUsageBadge"; + +describe("ToolUsageBadge helpers", () => { + it("formats compact token counts for usage badges", () => { + expect(formatToolUsageTokenCount(999)).toBe("999"); + expect(formatToolUsageTokenCount(1_200)).toBe("1.2k"); + expect(formatToolUsageTokenCount(12_300)).toBe("12k"); + }); +}); diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx index 7761801e8a..e3a8c34da8 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx @@ -9,6 +9,7 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { TOOL_NAMES } from "@src/api/tauri/agent/toolNames"; +import { TOOL_USAGE_ARGS_KEY } from "@src/engines/SessionCore/core/types"; import { formatToolName } from "@src/util/ui/rendering/formatToolName"; import { getRegistryToolLabelText } from "@src/util/ui/rendering/registryToolLabel"; import { deriveToolAction } from "@src/util/ui/rendering/toolAction"; @@ -27,6 +28,7 @@ import { useBlockHeader } from "../useBlockLocate"; import McpProgressRow from "./McpProgressRow"; import OutputContent from "./OutputContent"; import ToolResultActions from "./ToolResultActions"; +import ToolUsageBadge from "./ToolUsageBadge"; import { DEFAULT_VISIBLE_LINES, SEARCH_NO_RESULT_MESSAGES, @@ -73,6 +75,7 @@ const ToolCallBlock: React.FC = React.memo( iconOverride, callId, sessionId, + toolUsage, payloadRefs, }) => { const result = useMemo(() => rawResult ?? {}, [rawResult]); @@ -89,7 +92,10 @@ const ToolCallBlock: React.FC = React.memo( : ""; const displayArgs = Object.fromEntries( Object.entries(args).filter( - ([key]) => key !== "streamOutput" && key !== "streamContent" + ([key]) => + key !== "streamOutput" && + key !== "streamContent" && + key !== TOOL_USAGE_ARGS_KEY ) ); const hasArgs = Object.keys(displayArgs).length > 0; @@ -329,6 +335,9 @@ const ToolCallBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > { collapseWhen?: boolean; /** Optional event ID used by the group header navigate icon. */ eventId?: string; + /** Optional content shown on the right side of the group header. */ + rightContent?: React.ReactNode; } // ============================================ @@ -61,6 +63,7 @@ function StackedBlockInner({ defaultCollapsed = true, collapseWhen, eventId, + rightContent, }: StackedBlockProps) { const { isCollapsed, @@ -91,6 +94,7 @@ function StackedBlockInner({ onNavigate={eventId ? handleLocate : undefined} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={rightContent} > } diff --git a/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx index 2b6c8f1115..38bc1e30fe 100644 --- a/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx @@ -193,6 +193,7 @@ export const FallbackAdapter: React.FC = (props) => { iconOverride={isMcpTool ? MCP_ICON : undefined} callId={props.callId} sessionId={props.sessionId} + toolUsage={props.toolUsage} payloadRefs={props.payloadRefs} /> ); diff --git a/src/engines/SessionCore/core/types.ts b/src/engines/SessionCore/core/types.ts index 85565549c6..d23a04bbb8 100644 --- a/src/engines/SessionCore/core/types.ts +++ b/src/engines/SessionCore/core/types.ts @@ -111,6 +111,19 @@ export interface SimulatorEventPreview { repoPath?: string; } +export const TOOL_USAGE_ARGS_KEY = "__orgiiToolUsage"; + +export interface ToolUsageMetadata { + decisionCompletionTokens: number; + resultContextTokens: number; + followupCompletionTokens: number; + inputBytes: number; + outputBytes: number; + relatedCacheReadTokens: number; + relatedCacheWriteTokens: number; + attributionMethod: string; +} + export interface SessionEvent { chunk_id: string | null; // ============================================ @@ -179,6 +192,9 @@ export interface SessionEvent { /** Tool call ID for matching start/update/end events */ callId?: string; + /** Token/context attribution metadata for this tool call. */ + toolUsage?: ToolUsageMetadata; + /** File path for file operations */ filePath?: string; diff --git a/src/engines/SessionCore/rendering/props/propsNormalizer.ts b/src/engines/SessionCore/rendering/props/propsNormalizer.ts index 89a5da8f58..1c01fcb17c 100644 --- a/src/engines/SessionCore/rendering/props/propsNormalizer.ts +++ b/src/engines/SessionCore/rendering/props/propsNormalizer.ts @@ -17,7 +17,11 @@ */ import { useMemo } from "react"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; import type { AnimationConfig, EventStatus, @@ -29,6 +33,16 @@ import { normalizeActivity } from "@src/lib/activityData"; const ACTIVE_EVENT_PAINTING_TTL_MS = 30 * 60 * 1000; +function readToolUsageMetadata( + args: Record, + eventToolUsage?: ToolUsageMetadata +): ToolUsageMetadata | undefined { + if (eventToolUsage) return eventToolUsage; + const raw = args[TOOL_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as ToolUsageMetadata; +} + function shouldShowActiveEventPainting( status: EventStatus, createdAt?: string @@ -201,11 +215,13 @@ export function normalizeEventProps( const status = mapStatus( sessionEvent.displayStatus || inferStatusFromResult(result) ); + const toolUsage = readToolUsageMetadata(args, sessionEvent.toolUsage); return { eventId: sessionEvent.id, eventType, functionName: sessionEvent.functionName, callId: sessionEvent.callId, + toolUsage, filePath: sessionEvent.filePath, repoPath: sessionEvent.repoPath, sessionId: sessionEvent.sessionId, diff --git a/src/engines/SessionCore/rendering/types/universalProps.ts b/src/engines/SessionCore/rendering/types/universalProps.ts index 3dde425737..cd32e2bd2e 100644 --- a/src/engines/SessionCore/rendering/types/universalProps.ts +++ b/src/engines/SessionCore/rendering/types/universalProps.ts @@ -11,6 +11,7 @@ import type { ExtractedData, PayloadRef, + ToolUsageMetadata, } from "@src/engines/SessionCore/core/types"; import type { PlanSurface } from "@src/engines/SessionCore/derived/planDisplayEvents"; @@ -88,6 +89,8 @@ export interface UniversalEventProps { * to the chat bubble for this tool. Absent on non-tool events. */ callId?: string; + /** Token/context attribution metadata for this tool call. */ + toolUsage?: ToolUsageMetadata; /** File path for file operations, when emitted as top-level event metadata. */ filePath?: string; /** Repository filesystem path active when this event was emitted. */ diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts index fe4d02ad82..9b890d32e9 100644 --- a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts @@ -53,6 +53,11 @@ import { noteSessionStreamingTurn, resetAllStreamingState, } from "./rustAgent/eventHandlers/streamHelpers"; +import { + applyToolUsageToEvents, + loadAndCacheToolUsage, + withToolUsageArgs, +} from "./rustAgent/toolUsageCache"; import type { AgentTokenUsage, AgentWSEvent, @@ -129,6 +134,30 @@ function toTokenUsageInfo(usage: AgentTokenUsage): AgentTokenUsageInfo { }; } +async function refreshToolUsageForLatestEvents( + sessionId: string +): Promise { + const usageByCallId = await loadAndCacheToolUsage(sessionId); + if (usageByCallId.size === 0) return; + + const snapshot = eventStoreProxy.getLatestSessionSnapshot(sessionId); + const events = snapshot?.chatEvents ?? []; + const updates = events.flatMap((event) => { + const toolUsage = event.callId + ? (usageByCallId.get(event.callId) ?? usageByCallId.get(event.id)) + : usageByCallId.get(event.id); + if (!toolUsage) return []; + return [ + eventStoreProxy.updateById( + event.id, + { args: withToolUsageArgs(event.args, toolUsage) }, + sessionId + ), + ]; + }); + await Promise.all(updates); +} + // ============================================================================ // Factory // ============================================================================ @@ -170,8 +199,12 @@ export function createRustAgentAdapter( const merged = await mergeToolResults(events); if (signal.aborted) return merged; - await backfillSubagentLinks(sessionId, merged); - return merged; + const usageByCallId = await loadAndCacheToolUsage(sessionId); + if (signal.aborted) return merged; + const usageHydrated = applyToolUsageToEvents(merged, usageByCallId); + + await backfillSubagentLinks(sessionId, usageHydrated); + return usageHydrated; }, async postLoad( @@ -484,6 +517,12 @@ export function createRustAgentAdapter( if (isTerminal) { _runningSignaled = false; _turnCompleted = true; + void refreshToolUsageForLatestEvents(sessionId).catch((err) => { + logger.warn( + `[${category}] terminal tool usage refresh failed:`, + err + ); + }); } }) .catch((err) => { diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts new file mode 100644 index 0000000000..6a85306122 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { TOOL_USAGE_ATTRIBUTION_METHOD } from "@src/api/tauri/session"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, +} from "@src/engines/SessionCore/core/types"; + +import { + applyToolUsageToEvents, + buildUsageMap, + withToolUsageArgs, +} from "../toolUsageCache"; + +function makeEvent(callId?: string): SessionEvent { + return { + id: callId ? `tool-call-${callId}` : "message-1", + chunk_id: null, + sessionId: "session-1", + createdAt: "2026-06-28T00:00:00.000Z", + functionName: callId ? "read_file" : "assistant_message", + uiCanonical: callId ? "read_file" : "assistant_message", + actionType: callId ? "tool_call" : "assistant", + args: { path: "README.md" }, + result: {}, + source: "assistant", + displayText: "Read file", + displayStatus: "completed", + displayVariant: callId ? "tool_call" : "message", + activityStatus: "agent", + callId, + }; +} + +describe("toolUsageCache", () => { + it("aggregates attribution records by callId", () => { + const usageByCallId = buildUsageMap( + [ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + eventId: "tool-call-call-1", + toolCallId: "call-1", + toolName: "read_file", + iterationIndex: 1, + decisionCompletionTokens: 10, + resultContextTokens: 20, + followupCompletionTokens: 0, + inputBytes: 100, + outputBytes: 200, + attributionMethod: + TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + createdAt: "2026-06-28T00:00:00.000Z", + }, + { + id: 2, + sessionId: "session-1", + turnId: "turn-1", + eventId: "tool-call-call-1", + toolCallId: "call-1", + toolName: "read_file", + iterationIndex: 2, + decisionCompletionTokens: 3, + resultContextTokens: 5, + followupCompletionTokens: 7, + inputBytes: 11, + outputBytes: 13, + attributionMethod: + TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + createdAt: "2026-06-28T00:00:01.000Z", + }, + ], + [ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 1, + model: "model-1", + accountId: "account-1", + promptTokens: 100, + completionTokens: 20, + cacheReadTokens: 12, + cacheWriteTokens: 5, + totalTokens: 137, + contextTokens: 117, + relatedToolCallIdsJson: '["call-1"]', + contextUsageJson: null, + createdAt: "2026-06-28T00:00:00.000Z", + }, + ] + ); + + const expected = { + decisionCompletionTokens: 13, + resultContextTokens: 25, + followupCompletionTokens: 7, + inputBytes: 111, + outputBytes: 213, + relatedCacheReadTokens: 12, + relatedCacheWriteTokens: 5, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + }; + expect(usageByCallId.get("call-1")).toEqual(expected); + expect(usageByCallId.get("tool-call-call-1")).toEqual(expected); + }); + + it("attaches usage to matching events without fetching per block", () => { + const toolUsage = { + decisionCompletionTokens: 10, + resultContextTokens: 25, + followupCompletionTokens: 0, + inputBytes: 100, + outputBytes: 200, + relatedCacheReadTokens: 0, + relatedCacheWriteTokens: 0, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.BYTES_ONLY, + }; + const events = [makeEvent("call-1"), makeEvent("call-2"), makeEvent()]; + const enriched = applyToolUsageToEvents( + events, + new Map([["call-1", toolUsage]]) + ); + + expect(enriched[0].toolUsage).toEqual(toolUsage); + expect(enriched[0].args[TOOL_USAGE_ARGS_KEY]).toEqual(toolUsage); + expect(enriched[1].toolUsage).toBeUndefined(); + expect(enriched[2].toolUsage).toBeUndefined(); + }); + + it("stores usage metadata in args patch payloads", () => { + const usage = { + decisionCompletionTokens: 1, + resultContextTokens: 2, + followupCompletionTokens: 3, + inputBytes: 4, + outputBytes: 5, + relatedCacheReadTokens: 6, + relatedCacheWriteTokens: 7, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.SPLIT_EVENLY, + }; + + expect(withToolUsageArgs({ existing: true }, usage)).toEqual({ + existing: true, + [TOOL_USAGE_ARGS_KEY]: usage, + }); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts new file mode 100644 index 0000000000..3c32866b0e --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts @@ -0,0 +1,167 @@ +import { + type LlmUsageSpanRecord, + type ToolUsageAttributionRecord, + getSessionLlmUsageSpans, + getSessionToolUsageAttributions, +} from "@src/api/tauri/session"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; + +const MAX_SESSION_USAGE_CACHE_SIZE = 100; + +const sessionUsageCache = new Map>(); + +function touchSessionCache( + sessionId: string, + usageByCallId: Map +): void { + if (sessionUsageCache.has(sessionId)) { + sessionUsageCache.delete(sessionId); + } + sessionUsageCache.set(sessionId, usageByCallId); + while (sessionUsageCache.size > MAX_SESSION_USAGE_CACHE_SIZE) { + const oldestKey = sessionUsageCache.keys().next().value; + if (!oldestKey) break; + sessionUsageCache.delete(oldestKey); + } +} + +interface CacheUsageTotals { + cacheReadTokens: number; + cacheWriteTokens: number; +} + +function parseRelatedToolCallIds(span: LlmUsageSpanRecord): string[] { + if (!span.relatedToolCallIdsJson) return []; + const parsed: unknown = JSON.parse(span.relatedToolCallIdsJson); + if (!Array.isArray(parsed)) return []; + return parsed.filter((value): value is string => typeof value === "string"); +} + +function buildRelatedCacheMap( + spans: readonly LlmUsageSpanRecord[] +): Map { + const cacheByCallId = new Map(); + for (const span of spans) { + const relatedToolCallIds = parseRelatedToolCallIds(span); + if (relatedToolCallIds.length === 0) continue; + for (const toolCallId of relatedToolCallIds) { + const existing = cacheByCallId.get(toolCallId) ?? { + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + cacheByCallId.set(toolCallId, { + cacheReadTokens: existing.cacheReadTokens + span.cacheReadTokens, + cacheWriteTokens: existing.cacheWriteTokens + span.cacheWriteTokens, + }); + } + } + return cacheByCallId; +} + +function toMetadata( + record: ToolUsageAttributionRecord, + relatedCache?: CacheUsageTotals +): ToolUsageMetadata { + return { + decisionCompletionTokens: record.decisionCompletionTokens, + resultContextTokens: record.resultContextTokens, + followupCompletionTokens: record.followupCompletionTokens, + inputBytes: record.inputBytes, + outputBytes: record.outputBytes, + relatedCacheReadTokens: relatedCache?.cacheReadTokens ?? 0, + relatedCacheWriteTokens: relatedCache?.cacheWriteTokens ?? 0, + attributionMethod: record.attributionMethod, + }; +} + +export function buildUsageMap( + records: readonly ToolUsageAttributionRecord[], + spans: readonly LlmUsageSpanRecord[] = [] +): Map { + const cacheByCallId = buildRelatedCacheMap(spans); + const usageByCallId = new Map(); + for (const record of records) { + const existing = usageByCallId.get(record.toolCallId); + if (!existing) { + usageByCallId.set( + record.toolCallId, + toMetadata(record, cacheByCallId.get(record.toolCallId)) + ); + continue; + } + usageByCallId.set(record.toolCallId, { + decisionCompletionTokens: + existing.decisionCompletionTokens + record.decisionCompletionTokens, + resultContextTokens: + existing.resultContextTokens + record.resultContextTokens, + followupCompletionTokens: + existing.followupCompletionTokens + record.followupCompletionTokens, + inputBytes: existing.inputBytes + record.inputBytes, + outputBytes: existing.outputBytes + record.outputBytes, + relatedCacheReadTokens: existing.relatedCacheReadTokens, + relatedCacheWriteTokens: existing.relatedCacheWriteTokens, + attributionMethod: + existing.attributionMethod === record.attributionMethod + ? existing.attributionMethod + : record.attributionMethod, + }); + } + for (const record of records) { + const usage = usageByCallId.get(record.toolCallId); + if (usage) usageByCallId.set(record.eventId, usage); + } + return usageByCallId; +} + +export function withToolUsageArgs( + args: Record, + toolUsage: ToolUsageMetadata +): Record { + return { + ...args, + [TOOL_USAGE_ARGS_KEY]: toolUsage, + }; +} + +export function applyToolUsageToEvents( + events: readonly SessionEvent[], + usageByCallId: ReadonlyMap +): SessionEvent[] { + if (usageByCallId.size === 0) return [...events]; + return events.map((event) => { + const toolUsage = event.callId + ? (usageByCallId.get(event.callId) ?? usageByCallId.get(event.id)) + : usageByCallId.get(event.id); + if (!toolUsage) return event; + return { + ...event, + args: withToolUsageArgs(event.args, toolUsage), + toolUsage, + }; + }); +} + +export async function loadAndCacheToolUsage( + sessionId: string +): Promise> { + const [records, spans] = await Promise.all([ + getSessionToolUsageAttributions(sessionId), + getSessionLlmUsageSpans(sessionId), + ]); + const usageByCallId = buildUsageMap(records, spans); + touchSessionCache(sessionId, usageByCallId); + return usageByCallId; +} + +export function getCachedToolUsage( + sessionId: string +): Map | undefined { + const cached = sessionUsageCache.get(sessionId); + if (!cached) return undefined; + touchSessionCache(sessionId, cached); + return cached; +} diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index ff1ad465c9..e038c14d0f 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token Kontext", + "followup": "{{tokenCount}} Token Follow-up", + "decision": "{{tokenCount}} Token Entscheidung", + "tooltip": "Geschätzte Tool-Zuordnung. Methode: {{method}}. Eingabe: {{inputBytes}} bytes. Ausgabe: {{outputBytes}} bytes. Entscheidung: {{decisionTokens}} Token. Kontext: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Zugehöriger LLM cache read: {{cacheReadTokens}} Token. Zugehöriger LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Unbenannte Session", "prStatus": { diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 407f737e28..4be02ac61a 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token context", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token decision", + "tooltip": "Estimated tool attribution. Method: {{method}}. Input: {{inputBytes}} bytes. Output: {{outputBytes}} bytes. Decision: {{decisionTokens}} Token. Context: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Related LLM cache read: {{cacheReadTokens}} Token. Related LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Untitled Session", "prStatus": { diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 623d272329..02e3174f20 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token de contexto", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token de decisión", + "tooltip": "Atribución estimada de la herramienta. Método: {{method}}. Entrada: {{inputBytes}} bytes. Salida: {{outputBytes}} bytes. Decisión: {{decisionTokens}} Token. Contexto: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Lectura de cache LLM relacionada: {{cacheReadTokens}} Token. Escritura de cache LLM relacionada: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Session sin título", "prStatus": { diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index 6a31b9968c..b5bf7f5d26 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token context", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token decision", + "tooltip": "Attribution estimée de l’outil. Méthode : {{method}}. Entrée : {{inputBytes}} bytes. Sortie : {{outputBytes}} bytes. Décision : {{decisionTokens}} Token. Contexte : {{contextTokens}} Token. Follow-up : {{followupTokens}} Token. Cache LLM lié lu : {{cacheReadTokens}} Token. Cache LLM lié écrit : {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Session sans titre", "prStatus": { diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index ce0c4fad62..c0e57fb6b0 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token コンテキスト", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token 判断", + "tooltip": "推定ツール属性。方法: {{method}}。入力: {{inputBytes}} bytes。出力: {{outputBytes}} bytes。判断: {{decisionTokens}} Token。コンテキスト: {{contextTokens}} Token。Follow-up: {{followupTokens}} Token。関連 LLM cache read: {{cacheReadTokens}} Token。関連 LLM cache write: {{cacheWriteTokens}} Token。" + }, "history": { "untitledSession": "無題のSession", "prStatus": { diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 6ddeb1d052..8fb4da3257 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token context", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token decision", + "tooltip": "Estimated tool attribution. Method: {{method}}. Input: {{inputBytes}} bytes. Output: {{outputBytes}} bytes. Decision: {{decisionTokens}} Token. Context: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. 관련 LLM cache read: {{cacheReadTokens}} Token. 관련 LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "제목 없는 Session", "prStatus": { diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 92b8f51750..4d5ea213c7 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token kontekstu", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token decyzji", + "tooltip": "Szacowana atrybucja narzędzia. Metoda: {{method}}. Wejście: {{inputBytes}} bytes. Wyjście: {{outputBytes}} bytes. Decyzja: {{decisionTokens}} Token. Kontekst: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Powiązany LLM cache read: {{cacheReadTokens}} Token. Powiązany LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Sesja bez tytułu", "prStatus": { diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index 4688fdcbed..4cdb1e145a 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token de contexto", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token de decisão", + "tooltip": "Atribuição estimada da ferramenta. Método: {{method}}. Entrada: {{inputBytes}} bytes. Saída: {{outputBytes}} bytes. Decisão: {{decisionTokens}} Token. Contexto: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Leitura de cache LLM relacionada: {{cacheReadTokens}} Token. Escrita de cache LLM relacionada: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Sessão sem título", "prStatus": { diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 362db5428c..2ab0ec98e3 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token контекста", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token решения", + "tooltip": "Оценочная атрибуция инструмента. Метод: {{method}}. Ввод: {{inputBytes}} bytes. Вывод: {{outputBytes}} bytes. Решение: {{decisionTokens}} Token. Контекст: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. Связанный LLM cache read: {{cacheReadTokens}} Token. Связанный LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Session без названия", "prStatus": { diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index d68f9e68a3..8a6df016a5 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token bağlam", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token karar", + "tooltip": "Tahmini araç atfı. Yöntem: {{method}}. Girdi: {{inputBytes}} bytes. Çıktı: {{outputBytes}} bytes. Karar: {{decisionTokens}} Token. Bağlam: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. İlgili LLM cache read: {{cacheReadTokens}} Token. İlgili LLM cache write: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Adsız Session", "prStatus": { diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 0107e09fa2..e3d383d2b8 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token ngữ cảnh", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token quyết định", + "tooltip": "Phân bổ công cụ ước tính. Phương pháp: {{method}}. Đầu vào: {{inputBytes}} bytes. Đầu ra: {{outputBytes}} bytes. Quyết định: {{decisionTokens}} Token. Ngữ cảnh: {{contextTokens}} Token. Follow-up: {{followupTokens}} Token. LLM cache read liên quan: {{cacheReadTokens}} Token. LLM cache write liên quan: {{cacheWriteTokens}} Token." + }, "history": { "untitledSession": "Session chưa đặt tên", "prStatus": { diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 0709b48785..59c14d6277 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token 上下文", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token 決策", + "tooltip": "估算的工具歸因。方法:{{method}}。輸入:{{inputBytes}} bytes。輸出:{{outputBytes}} bytes。決策:{{decisionTokens}} Token。上下文:{{contextTokens}} Token。Follow-up:{{followupTokens}} Token。相關 LLM cache read:{{cacheReadTokens}} Token。相關 LLM cache write:{{cacheWriteTokens}} Token。" + }, "history": { "untitledSession": "未命名 Session", "prStatus": { diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index cdc94cfb32..9ef78e3f04 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -1,4 +1,10 @@ { + "toolUsage": { + "estimatedContext": "{{tokenCount}} Token 上下文", + "followup": "{{tokenCount}} Token follow-up", + "decision": "{{tokenCount}} Token 决策", + "tooltip": "估算的工具归因。方法:{{method}}。输入:{{inputBytes}} bytes。输出:{{outputBytes}} bytes。决策:{{decisionTokens}} Token。上下文:{{contextTokens}} Token。Follow-up:{{followupTokens}} Token。相关 LLM cache read:{{cacheReadTokens}} Token。相关 LLM cache write:{{cacheWriteTokens}} Token。" + }, "history": { "untitledSession": "未命名 Session", "prStatus": { From e9db59d98fe879e3c1dfa5cbba37fe3836b1b763 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sun, 28 Jun 2026 21:14:46 +0800 Subject: [PATCH 047/864] feat(session): add explicit context import metadata --- .../src/core/session/context_import.rs | 220 +++++++++++++ .../crates/agent-core/src/core/session/mod.rs | 1 + .../src/core/session/persistence/messages.rs | 308 ++++++++++++++++++ .../src/core/session/persistence/mod.rs | 16 +- .../core/session/turn/processor/compaction.rs | 5 + .../src/core/session/turn/processor/mod.rs | 63 +++- .../src/core/session/types/context.rs | 15 + .../tools/impls/project/import_context.rs | 158 +++++++++ .../src/core/tools/impls/project/mod.rs | 1 + .../src/core/tools/registration/agent_ops.rs | 6 + src-tauri/crates/types/src/tool_names.rs | 1 + 11 files changed, 786 insertions(+), 8 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/session/context_import.rs create mode 100644 src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs diff --git a/src-tauri/crates/agent-core/src/core/session/context_import.rs b/src-tauri/crates/agent-core/src/core/session/context_import.rs new file mode 100644 index 0000000000..1a800554e1 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/context_import.rs @@ -0,0 +1,220 @@ +//! Context snapshot/import metadata and cache-layout observability. +//! +//! This module is intentionally small and durable-data oriented. Provider +//! prompt cache is a performance optimization only; these records describe the +//! deterministic context ORG2 selected/reconstructed before a turn. + +use serde::{Deserialize, Serialize}; + +/// Source kind for an explicitly imported context chunk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextSourceKind { + Session, + WorkItem, + File, + Memory, + ImportedContext, + GlobalPreference, +} + +impl ContextSourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Session => "session", + Self::WorkItem => "work_item", + Self::File => "file", + Self::Memory => "memory", + Self::ImportedContext => "imported_context", + Self::GlobalPreference => "global_preference", + } + } +} + +/// Namespace for retrieval/embedding isolation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextNamespace { + pub kind: ContextSourceKind, + pub id: String, +} + +impl ContextNamespace { + pub fn new(kind: ContextSourceKind, id: impl Into) -> Self { + Self { kind, id: id.into() } + } + + pub fn global() -> Self { + Self::new(ContextSourceKind::GlobalPreference, "global") + } + + pub fn session(session_id: impl Into) -> Self { + Self::new(ContextSourceKind::Session, session_id) + } + + pub fn work_item(work_item_id: impl Into) -> Self { + Self::new(ContextSourceKind::WorkItem, work_item_id) + } + + pub fn imported_context(snapshot_id: impl Into) -> Self { + Self::new(ContextSourceKind::ImportedContext, snapshot_id) + } + + /// Stable string form suitable for storage and filtering. + pub fn storage_key(&self) -> String { + format!("{}:{}", self.kind.as_str(), self.id) + } +} + +/// Metadata for one explicit context import/snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextSnapshotMeta { + pub snapshot_id: String, + pub target_session_id: String, + pub source_kind: ContextSourceKind, + pub source_id: String, + pub namespace: String, + pub title: Option, + pub token_estimate: i64, + pub pinned: bool, + pub created_at: String, +} + +impl ContextSnapshotMeta { + pub fn new( + target_session_id: impl Into, + source_kind: ContextSourceKind, + source_id: impl Into, + title: Option, + token_estimate: i64, + pinned: bool, + ) -> Self { + let source_id = source_id.into(); + let namespace = ContextNamespace::new(source_kind.clone(), source_id.clone()).storage_key(); + Self { + snapshot_id: uuid::Uuid::new_v4().to_string(), + target_session_id: target_session_id.into(), + source_kind, + source_id, + namespace, + title, + token_estimate: token_estimate.max(0), + pinned, + created_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +/// Prompt/cache layout metrics for a single turn. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheLayoutStats { + pub stable_prefix_tokens: i64, + pub volatile_context_tokens: i64, + pub imported_context_count: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, +} + +impl CacheLayoutStats { + pub fn new( + stable_prefix_tokens: i64, + volatile_context_tokens: i64, + imported_context_count: i64, + cache_read_tokens: i64, + cache_write_tokens: i64, + ) -> Self { + Self { + stable_prefix_tokens: stable_prefix_tokens.max(0), + volatile_context_tokens: volatile_context_tokens.max(0), + imported_context_count: imported_context_count.max(0), + cache_read_tokens: cache_read_tokens.max(0), + cache_write_tokens: cache_write_tokens.max(0), + } + } + + pub fn provider_cache_hit_rate(&self) -> Option { + let total = self.cache_read_tokens + self.cache_write_tokens; + (total > 0).then(|| self.cache_read_tokens as f64 / total as f64) + } +} + +/// Progress marker for proactive session embedding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionEmbeddingState { + pub namespace: String, + pub session_id: String, + pub work_item_id: Option, + pub last_embedded_sequence: i64, + pub embedding_model: Option, + pub updated_at: String, +} + +impl SessionEmbeddingState { + pub fn for_session( + session_id: impl Into, + work_item_id: Option, + last_embedded_sequence: i64, + embedding_model: Option, + ) -> Self { + let session_id = session_id.into(); + Self { + namespace: ContextNamespace::session(session_id.clone()).storage_key(), + session_id, + work_item_id, + last_embedded_sequence: last_embedded_sequence.max(0), + embedding_model, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_storage_keys_are_stable() { + assert_eq!(ContextNamespace::global().storage_key(), "global_preference:global"); + assert_eq!(ContextNamespace::session("s1").storage_key(), "session:s1"); + assert_eq!(ContextNamespace::work_item("WI-1").storage_key(), "work_item:WI-1"); + assert_eq!( + ContextNamespace::imported_context("snap").storage_key(), + "imported_context:snap" + ); + } + + #[test] + fn snapshot_clamps_negative_token_estimates() { + let snap = ContextSnapshotMeta::new( + "target", + ContextSourceKind::Session, + "source", + Some("Source".into()), + -10, + true, + ); + assert_eq!(snap.target_session_id, "target"); + assert_eq!(snap.namespace, "session:source"); + assert_eq!(snap.token_estimate, 0); + assert!(snap.pinned); + } + + #[test] + fn cache_hit_rate_uses_provider_cache_tokens_only() { + let stats = CacheLayoutStats::new(1000, 200, 2, 75, 25); + assert_eq!(stats.provider_cache_hit_rate(), Some(0.75)); + assert_eq!(CacheLayoutStats::default().provider_cache_hit_rate(), None); + } + + #[test] + fn embedding_state_is_session_namespaced() { + let state = SessionEmbeddingState::for_session( + "session-a", + Some("WI-7".into()), + 42, + Some("qwen3-rerank".into()), + ); + assert_eq!(state.namespace, "session:session-a"); + assert_eq!(state.work_item_id.as_deref(), Some("WI-7")); + assert_eq!(state.last_embedded_sequence, 42); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/mod.rs b/src-tauri/crates/agent-core/src/core/session/mod.rs index ab6e6a49e7..577d5a2755 100644 --- a/src-tauri/crates/agent-core/src/core/session/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/mod.rs @@ -13,6 +13,7 @@ //! `crate::memory::reflection`, not here. pub mod compaction; +pub mod context_import; pub mod exec_modes; pub(crate) mod file_registry; pub mod gateway_pipeline; diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 8515bc3bab..0c9997d56a 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -5,6 +5,9 @@ use rusqlite::{params, Result as SqliteResult}; use uuid::Uuid; use crate::persistence::db_helpers as shared; +use crate::session::context_import::{ + CacheLayoutStats, ContextSnapshotMeta, ContextSourceKind, SessionEmbeddingState, +}; use database::db::{get_connection, with_sessions_writer}; /// Table-name prefix for the unified-session DB schema. @@ -622,6 +625,258 @@ pub fn load_session_memory_index_rows() -> SqliteResult SqliteResult { + let conn = get_connection()?; + conn.query_row( + "SELECT COALESCE(MAX(sequence), 0) FROM agent_messages WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) +} + +// ============================================ +// Context Snapshot / Import / Cache Layout Metadata +// ============================================ + +fn context_kind_from_str(value: &str) -> ContextSourceKind { + match value { + "session" => ContextSourceKind::Session, + "work_item" => ContextSourceKind::WorkItem, + "file" => ContextSourceKind::File, + "memory" => ContextSourceKind::Memory, + "imported_context" => ContextSourceKind::ImportedContext, + "global_preference" => ContextSourceKind::GlobalPreference, + _ => ContextSourceKind::ImportedContext, + } +} + +pub fn ensure_context_metadata_schema(conn: &rusqlite::Connection) -> SqliteResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS context_snapshots ( + snapshot_id TEXT PRIMARY KEY, + target_session_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + namespace TEXT NOT NULL, + title TEXT, + token_estimate INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_context_snapshots_target + ON context_snapshots(target_session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_context_snapshots_namespace + ON context_snapshots(namespace); + + CREATE TABLE IF NOT EXISTS turn_cache_layout_stats ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + stable_prefix_tokens INTEGER NOT NULL DEFAULT 0, + volatile_context_tokens INTEGER NOT NULL DEFAULT 0, + imported_context_count INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY(session_id, turn_id) + ); + CREATE INDEX IF NOT EXISTS idx_turn_cache_layout_stats_session + ON turn_cache_layout_stats(session_id, created_at); + + CREATE TABLE IF NOT EXISTS session_embedding_state ( + namespace TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + work_item_id TEXT, + last_embedded_sequence INTEGER NOT NULL DEFAULT 0, + embedding_model TEXT, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_session_embedding_state_session + ON session_embedding_state(session_id); + CREATE INDEX IF NOT EXISTS idx_session_embedding_state_work_item + ON session_embedding_state(work_item_id);", + )?; + Ok(()) +} + +pub fn save_context_snapshot(meta: &ContextSnapshotMeta) -> SqliteResult<()> { + with_sessions_writer(|| -> SqliteResult<()> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + conn.execute( + "INSERT INTO context_snapshots + (snapshot_id, target_session_id, source_kind, source_id, namespace, + title, token_estimate, pinned, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(snapshot_id) DO UPDATE SET + target_session_id = excluded.target_session_id, + source_kind = excluded.source_kind, + source_id = excluded.source_id, + namespace = excluded.namespace, + title = excluded.title, + token_estimate = excluded.token_estimate, + pinned = excluded.pinned, + created_at = excluded.created_at", + params![ + meta.snapshot_id, + meta.target_session_id, + meta.source_kind.as_str(), + meta.source_id, + meta.namespace, + meta.title, + meta.token_estimate, + if meta.pinned { 1 } else { 0 }, + meta.created_at, + ], + )?; + Ok(()) + }) +} + +pub fn load_context_snapshots(target_session_id: &str) -> SqliteResult> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + let mut stmt = conn.prepare( + "SELECT snapshot_id, target_session_id, source_kind, source_id, namespace, + title, token_estimate, pinned, created_at + FROM context_snapshots + WHERE target_session_id = ?1 + ORDER BY pinned DESC, created_at DESC", + )?; + let rows = stmt.query_map(params![target_session_id], |row| { + let source_kind: String = row.get(2)?; + let pinned: i64 = row.get(7)?; + Ok(ContextSnapshotMeta { + snapshot_id: row.get(0)?, + target_session_id: row.get(1)?, + source_kind: context_kind_from_str(&source_kind), + source_id: row.get(3)?, + namespace: row.get(4)?, + title: row.get(5)?, + token_estimate: row.get(6)?, + pinned: pinned != 0, + created_at: row.get(8)?, + }) + })?; + rows.collect() +} + +pub fn save_turn_cache_layout_stats( + session_id: &str, + turn_id: &str, + stats: &CacheLayoutStats, +) -> SqliteResult<()> { + with_sessions_writer(|| -> SqliteResult<()> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + conn.execute( + "INSERT INTO turn_cache_layout_stats + (session_id, turn_id, stable_prefix_tokens, volatile_context_tokens, + imported_context_count, cache_read_tokens, cache_write_tokens, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(session_id, turn_id) DO UPDATE SET + stable_prefix_tokens = excluded.stable_prefix_tokens, + volatile_context_tokens = excluded.volatile_context_tokens, + imported_context_count = excluded.imported_context_count, + cache_read_tokens = excluded.cache_read_tokens, + cache_write_tokens = excluded.cache_write_tokens, + created_at = excluded.created_at", + params![ + session_id, + turn_id, + stats.stable_prefix_tokens, + stats.volatile_context_tokens, + stats.imported_context_count, + stats.cache_read_tokens, + stats.cache_write_tokens, + Utc::now().to_rfc3339(), + ], + )?; + Ok(()) + }) +} + +pub fn load_turn_cache_layout_stats( + session_id: &str, + turn_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + let mut stmt = conn.prepare( + "SELECT stable_prefix_tokens, volatile_context_tokens, imported_context_count, + cache_read_tokens, cache_write_tokens + FROM turn_cache_layout_stats + WHERE session_id = ?1 AND turn_id = ?2", + )?; + let mut rows = stmt.query(params![session_id, turn_id])?; + if let Some(row) = rows.next()? { + Ok(Some(CacheLayoutStats::new( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + ))) + } else { + Ok(None) + } +} + +pub fn save_session_embedding_state(state: &SessionEmbeddingState) -> SqliteResult<()> { + with_sessions_writer(|| -> SqliteResult<()> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + conn.execute( + "INSERT INTO session_embedding_state + (namespace, session_id, work_item_id, last_embedded_sequence, + embedding_model, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(namespace) DO UPDATE SET + session_id = excluded.session_id, + work_item_id = excluded.work_item_id, + last_embedded_sequence = excluded.last_embedded_sequence, + embedding_model = excluded.embedding_model, + updated_at = excluded.updated_at", + params![ + state.namespace, + state.session_id, + state.work_item_id, + state.last_embedded_sequence, + state.embedding_model, + state.updated_at, + ], + )?; + Ok(()) + }) +} + +pub fn load_session_embedding_state( + namespace: &str, +) -> SqliteResult> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + let mut stmt = conn.prepare( + "SELECT namespace, session_id, work_item_id, last_embedded_sequence, + embedding_model, updated_at + FROM session_embedding_state + WHERE namespace = ?1", + )?; + let mut rows = stmt.query(params![namespace])?; + if let Some(row) = rows.next()? { + Ok(Some(SessionEmbeddingState { + namespace: row.get(0)?, + session_id: row.get(1)?, + work_item_id: row.get(2)?, + last_embedded_sequence: row.get(3)?, + embedding_model: row.get(4)?, + updated_at: row.get(5)?, + })) + } else { + Ok(None) + } +} + // ============================================ // Cancel-Interrupt Marker // ============================================ @@ -744,6 +999,59 @@ mod tests { use database::db::get_connection; use test_helpers::test_env; + + #[test] + fn context_metadata_roundtrips() { + let _sandbox = test_env::sandbox(); + let snap = ContextSnapshotMeta::new( + "target-session", + ContextSourceKind::Session, + "source-session", + Some("Imported source".into()), + 123, + true, + ); + save_context_snapshot(&snap).expect("save context snapshot"); + let rows = load_context_snapshots("target-session").expect("load snapshots"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].snapshot_id, snap.snapshot_id); + assert_eq!(rows[0].namespace, "session:source-session"); + assert_eq!(rows[0].token_estimate, 123); + assert!(rows[0].pinned); + } + + #[test] + fn cache_layout_stats_roundtrip() { + let _sandbox = test_env::sandbox(); + let stats = CacheLayoutStats::new(1000, 250, 3, 800, 200); + save_turn_cache_layout_stats("session-cache", "turn-1", &stats) + .expect("save cache layout stats"); + let loaded = load_turn_cache_layout_stats("session-cache", "turn-1") + .expect("load cache layout stats") + .expect("stats exists"); + assert_eq!(loaded, stats); + assert_eq!(loaded.provider_cache_hit_rate(), Some(0.8)); + } + + #[test] + fn session_embedding_state_roundtrips_by_namespace() { + let _sandbox = test_env::sandbox(); + let state = SessionEmbeddingState::for_session( + "session-embed", + Some("WI-42".into()), + 77, + Some("dashscope-qwen".into()), + ); + save_session_embedding_state(&state).expect("save embedding state"); + let loaded = load_session_embedding_state(&state.namespace) + .expect("load embedding state") + .expect("state exists"); + assert_eq!(loaded.namespace, "session:session-embed"); + assert_eq!(loaded.session_id, "session-embed"); + assert_eq!(loaded.work_item_id.as_deref(), Some("WI-42")); + assert_eq!(loaded.last_embedded_sequence, 77); + } + fn seed_session_for_message_tests(session_id: &str) { let conn = get_connection().expect("get_connection in seed_session_for_message_tests"); conn.execute_batch( diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index 11786cd134..2484adecf6 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -34,12 +34,15 @@ pub use crud::{ pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, - clear_session_memory_state, compact_cutoff_sequence, load_llm_history, load_messages, - load_session_memory_index_rows, load_session_memory_state, mark_turn_cancelled, message_anchor, - message_created_at, save_assistant_msg, save_compact_summary_msg, save_session_memory_index, - save_session_memory_state, save_snapshot, save_subagent_transcript, save_tool_call_msg, - save_tool_result_msg, save_user_msg, seed_session_with_messages, take_turn_cancelled, - truncate_messages_from_sequence, MessageAnchor, SessionMemoryIndexRow, + clear_session_memory_state, compact_cutoff_sequence, ensure_context_metadata_schema, + latest_message_sequence, load_context_snapshots, load_llm_history, load_messages, load_session_embedding_state, + load_session_memory_index_rows, load_session_memory_state, load_turn_cache_layout_stats, + mark_turn_cancelled, message_anchor, message_created_at, save_assistant_msg, + save_compact_summary_msg, save_context_snapshot, save_session_embedding_state, + save_session_memory_index, save_session_memory_state, save_snapshot, save_subagent_transcript, + save_tool_call_msg, save_tool_result_msg, save_turn_cache_layout_stats, save_user_msg, + seed_session_with_messages, take_turn_cancelled, truncate_messages_from_sequence, + MessageAnchor, SessionMemoryIndexRow, }; use rusqlite::{Connection, Result as SqliteResult}; @@ -51,5 +54,6 @@ use rusqlite::{Connection, Result as SqliteResult}; pub fn init(conn: &Connection) -> SqliteResult<()> { crud::ensure_unified_schema(conn)?; messages::ensure_session_memory_index_schema(conn)?; + messages::ensure_context_metadata_schema(conn)?; Ok(()) } diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs index 6d4d69042a..b835ec5555 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/compaction.rs @@ -283,6 +283,11 @@ impl UnifiedMessageProcessor { prompt_tokens: 0, completion_tokens: 0, context_tokens: 0, + stable_prefix_tokens: 0, + volatile_context_tokens: 0, + imported_context_count: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, tool_calls_count: 0, truncated: false, turn_summary: None, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index 1dcb454cdb..632eb9524f 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -34,6 +34,7 @@ use tracing::{debug, info, warn}; use crate::core::session::prompt::cache::{RenderedSystemBlockScope, ORGII_SYSTEM_CACHE_SCOPE_KEY}; use crate::core::session::types::DialogTurnState; +use crate::session::context_import::{CacheLayoutStats, SessionEmbeddingState}; use super::super::persistence as unified_persistence; use super::super::types::{AgentExecMode, IdeContext, ProcessingContext, ProcessingResult}; @@ -311,7 +312,7 @@ impl UnifiedMessageProcessor { } /// Records token usage for a turn. - fn record_token_usage(&self, session_id: &str, result: &TurnResult) { + fn record_token_usage(&self, session_id: &str, turn_id: &str, result: &TurnResult) { if result.total_tokens == 0 { return; } @@ -340,6 +341,51 @@ impl UnifiedMessageProcessor { .and_then(|snapshot| serde_json::to_string(snapshot).ok()); tokio::task::block_in_place(|| { + let cache_layout_stats = CacheLayoutStats::new( + result.context_tokens, + result.prompt_tokens.saturating_sub(result.context_tokens), + 0, + result.cache_read_tokens, + result.cache_write_tokens, + ); + if let Err(err) = unified_persistence::save_turn_cache_layout_stats( + session_id, + turn_id, + &cache_layout_stats, + ) { + warn!( + "[unified_processor] Failed to record cache layout stats: {}", + err + ); + } + + match unified_persistence::latest_message_sequence(session_id) { + Ok(last_sequence) => { + let work_item_id = unified_persistence::get_session(session_id) + .ok() + .flatten() + .and_then(|record| record.work_item_id); + let embedding_state = SessionEmbeddingState::for_session( + session_id.to_string(), + work_item_id, + last_sequence, + Some(self.runtime.model.clone()), + ); + if let Err(err) = + unified_persistence::save_session_embedding_state(&embedding_state) + { + warn!( + "[unified_processor] Failed to record session embedding state: {}", + err + ); + } + } + Err(err) => warn!( + "[unified_processor] Failed to read latest message sequence for embedding state: {}", + err + ), + } + use crate::foundation::session_bridge::{record_token_usage, TokenUsageRow}; if let Err(err) = record_token_usage(TokenUsageRow { session_id, @@ -684,7 +730,7 @@ impl UnifiedMessageProcessor { // so the full say-then-tool-then-say transcript is preserved. // 8. Record token usage - self.record_token_usage(session_id, &result); + self.record_token_usage(session_id, &turn_id, &result); let final_turn_state = if self .session @@ -741,6 +787,14 @@ impl UnifiedMessageProcessor { self.agent_mode, ); + let cache_layout = CacheLayoutStats::new( + result.context_tokens, + result.prompt_tokens.saturating_sub(result.context_tokens), + 0, + result.cache_read_tokens, + result.cache_write_tokens, + ); + Ok(ProcessingResult { turn_id, content: response_text, @@ -748,6 +802,11 @@ impl UnifiedMessageProcessor { prompt_tokens: result.prompt_tokens, completion_tokens: result.completion_tokens, context_tokens: result.context_tokens, + stable_prefix_tokens: cache_layout.stable_prefix_tokens, + volatile_context_tokens: cache_layout.volatile_context_tokens, + imported_context_count: cache_layout.imported_context_count, + cache_read_tokens: cache_layout.cache_read_tokens, + cache_write_tokens: cache_layout.cache_write_tokens, tool_calls_count, truncated: false, turn_summary: None, diff --git a/src-tauri/crates/agent-core/src/core/session/types/context.rs b/src-tauri/crates/agent-core/src/core/session/types/context.rs index 7ae93982ba..c89b845987 100644 --- a/src-tauri/crates/agent-core/src/core/session/types/context.rs +++ b/src-tauri/crates/agent-core/src/core/session/types/context.rs @@ -237,6 +237,21 @@ pub struct ProcessingResult { /// current context fill level. #[serde(default)] pub context_tokens: i64, + /// Stable-prefix token estimate persisted for cache/layout debugging. + #[serde(default)] + pub stable_prefix_tokens: i64, + /// Volatile-context token estimate persisted for cache/layout debugging. + #[serde(default)] + pub volatile_context_tokens: i64, + /// Number of explicit imported context snapshots included in the turn. + #[serde(default)] + pub imported_context_count: i64, + /// Provider-reported prompt cache read tokens for this turn. + #[serde(default)] + pub cache_read_tokens: i64, + /// Provider-reported prompt cache write tokens for this turn. + #[serde(default)] + pub cache_write_tokens: i64, /// Number of tool calls made. pub tool_calls_count: u32, /// Whether the response was truncated. diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs new file mode 100644 index 0000000000..849b01ebd5 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs @@ -0,0 +1,158 @@ +//! Explicit context import tool. +//! +//! This records source metadata only. Actual retrieval/snippet hydration is +//! intentionally a later step so cross-session context remains explicit and +//! auditable rather than silently injected. + +use async_trait::async_trait; +use serde_json::Value; + +use crate::session::context_import::{ContextSnapshotMeta, ContextSourceKind}; +use crate::session::persistence as unified_persistence; +use crate::tools::names as tool_names; +use crate::tools::traits::{optional_bool, optional_int, optional_string, required_string, Tool, ToolError}; + +fn parse_source_kind(raw: &str) -> Result { + match raw { + "session" => Ok(ContextSourceKind::Session), + "work_item" => Ok(ContextSourceKind::WorkItem), + "file" => Ok(ContextSourceKind::File), + "memory" => Ok(ContextSourceKind::Memory), + "imported_context" => Ok(ContextSourceKind::ImportedContext), + "global_preference" => Ok(ContextSourceKind::GlobalPreference), + other => Err(ToolError::InvalidParams(format!( + "unsupported source_kind: {other}" + ))), + } +} + +/// Records an explicit context import/snapshot for the current session. +pub struct ImportContextTool { + session_id: String, +} + +impl ImportContextTool { + pub fn new(session_id: String) -> Self { + Self { session_id } + } +} + +#[async_trait] +impl Tool for ImportContextTool { + fn name(&self) -> &str { + tool_names::IMPORT_CONTEXT + } + + fn category(&self) -> &str { + crate::tools::categories::PROJECT + } + + fn description(&self) -> &str { + "Explicitly import context metadata from another session, work item, file, or memory source." + } + + fn llm_description(&self) -> Option { + Some( + "Record an explicit context import for the current session. Use this before relying on context from another session/work item/file/memory. The import is auditable and namespaced; unrelated sessions are never imported implicitly." + .to_string(), + ) + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "source_kind": { + "type": "string", + "enum": ["session", "work_item", "file", "memory", "imported_context", "global_preference"], + "description": "Where the context comes from." + }, + "source_id": { + "type": "string", + "description": "Stable id/path/key for the imported source." + }, + "title": { + "type": "string", + "description": "Optional human-readable label for UI source chips." + }, + "token_estimate": { + "type": "integer", + "description": "Estimated tokens imported from this source." + }, + "pinned": { + "type": "boolean", + "description": "Whether this import should be pinned in context selection." + } + }, + "required": ["source_kind", "source_id"] + }) + } + + async fn execute_text( + &self, + params: Value, + _ctx: &crate::tools::traits::CallContext, + ) -> Result { + let source_kind = parse_source_kind(&required_string(¶ms, "source_kind")?)?; + let source_id = required_string(¶ms, "source_id")?; + let title = optional_string(¶ms, "title"); + let token_estimate = optional_int(¶ms, "token_estimate").unwrap_or(0) as i64; + let pinned = optional_bool(¶ms, "pinned").unwrap_or(false); + let meta = ContextSnapshotMeta::new( + self.session_id.clone(), + source_kind, + source_id, + title, + token_estimate, + pinned, + ); + let snapshot_id = meta.snapshot_id.clone(); + let namespace = meta.namespace.clone(); + let source_label = format!("{}:{}", meta.source_kind.as_str(), meta.source_id); + tokio::task::spawn_blocking(move || unified_persistence::save_context_snapshot(&meta)) + .await + .map_err(|err| ToolError::ExecutionFailed(format!("import_context task failed: {err}")))? + .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?; + Ok(format!( + "Imported context snapshot {} from {} into namespace {}", + snapshot_id, source_label, namespace + )) + } + + fn is_read_only(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::traits::CallContext; + use test_helpers::test_env; + + #[tokio::test] + async fn import_context_tool_records_snapshot() { + let _sandbox = test_env::sandbox(); + let tool = ImportContextTool::new("target-session".to_string()); + let result = tool + .execute_text( + serde_json::json!({ + "source_kind": "session", + "source_id": "source-session", + "title": "Source Session", + "token_estimate": 321, + "pinned": true + }), + &CallContext::new("call-import-context", "target-session"), + ) + .await + .expect("import context"); + assert!(result.contains("session:source-session")); + let snapshots = unified_persistence::load_context_snapshots("target-session") + .expect("load snapshots"); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].namespace, "session:source-session"); + assert_eq!(snapshots[0].token_estimate, 321); + assert!(snapshots[0].pinned); + } +} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs index e4805a4544..74340406e8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs @@ -7,5 +7,6 @@ //! //! [`tool_categories::PROJECT`]: crate::tools::categories::PROJECT +pub mod import_context; pub mod manage_project; pub mod manage_work_item; diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs index ff877f582c..9990a2aa43 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs @@ -15,6 +15,7 @@ use crate::tools::impls::nodes::manage_nodes::NodesTool; use crate::tools::impls::orchestration::ask_user_questions::{QuestionTool, QuestionToolContext}; use crate::tools::impls::orchestration::manage_secrets::{SecretTool, SecretToolContext}; use crate::tools::impls::orchestration::manage_session::SessionTool; +use crate::tools::impls::project::import_context::ImportContextTool; use crate::tools::impls::project::manage_project::ProjectTool; use crate::tools::impls::project::manage_work_item::WorkItemTool; use crate::tools::registry::ToolRegistry; @@ -108,6 +109,11 @@ pub fn register(registry: &mut ToolRegistry, deps: &ToolDeps, disabled: &HashSet )), disabled, ); + register_if_enabled( + registry, + Box::new(ImportContextTool::new(deps.session_id.clone())), + disabled, + ); // ── Agent definition ── if let Some(ref handle) = deps.app_handle { diff --git a/src-tauri/crates/types/src/tool_names.rs b/src-tauri/crates/types/src/tool_names.rs index 4aa2880011..a6e3233d10 100644 --- a/src-tauri/crates/types/src/tool_names.rs +++ b/src-tauri/crates/types/src/tool_names.rs @@ -74,6 +74,7 @@ pub const WRITE_ENV_FILE: &str = "write_env_file"; // ── Project ───────────────────────────────────────────────────────── pub const MANAGE_PROJECT: &str = "manage_project"; pub const MANAGE_WORK_ITEM: &str = "manage_work_item"; +pub const IMPORT_CONTEXT: &str = "import_context"; // ── Web ───────────────────────────────────────────────────────────── pub const WEB_SEARCH: &str = "web_search"; From a02ee1190f0348722250cd932c2fa684a392889e Mon Sep 17 00:00:00 2001 From: raymond <13162938362@163.com> Date: Sun, 28 Jun 2026 19:43:39 +0800 Subject: [PATCH 048/864] feat(providers): wire thinking level through, fix Opus 4.7+ thinking 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning effort was encoded into model-id suffixes but never decoded back into provider request parameters — providers received the suffixed alias (which they reject) and no thinking/effort param. Worse, Opus 4.7/4.8/Fable/Mythos (adaptive thinking) were sent budget_tokens, which they reject with HTTP 400. - thinking_mode: new module classifying 6 thinking modes (Anthropic adaptive / 4.6 / legacy-budget, OpenAI effort, Zhipu toggle), parsing the level suffix from model ids back to a base alias + ReasoningLevel, and mapping levels to each provider's wire param. Claude version regex (ported from Cherry Studio) handles Bedrock/Vertex alias prefixes and date-stamped ids without misreading 4.. - anthropic_native: three-branch thinking — adaptive {type, display: summarized}+effort / 4.6 adaptive+effort(max) / legacy budget_tokens. Proactively omits temperature for 4.7+ (also a 400). - openai_compat: strip suffix, send reasoning_effort (OpenAI) or the thinking toggle (Zhipu GLM, no budget — unstable across GLM versions). - openai_responses: send reasoning.effort for GPT-5+/o. - model_capabilities: centralize family classification (classify_family) via variable-pattern matching so the no_substring_capability_checks architecture test stays green. Tests: thinking_mode (22) + anthropic thinking (10) + openai_responses build_responses_request (3) + no_substring guard. Covers alias prefixes, version separators (4-7/4.7), date stamps, suffix-strip safety. Scope: Anthropic/GLM/OpenAI only. DeepSeek/Qwen/Doubao/Gemini/minimax fall through to ThinkingMode::None (unchanged — they already sent no thinking param). Frontend UI to pick levels is a follow-up PR; until then level defaults to None (adaptive models already fixed). Co-Authored-By: Claude Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../providers/anthropic_native/request.rs | 21 +- .../providers/anthropic_native/thinking.rs | 348 ++++++--- .../core/providers/anthropic_native/types.rs | 4 + .../agent-core/src/core/providers/mod.rs | 1 + .../src/core/providers/model_capabilities.rs | 43 ++ .../providers/openai_compat/streaming/chat.rs | 20 +- .../openai_compat/streaming/sse_stream.rs | 20 +- .../src/core/providers/openai_compat/types.rs | 9 + .../core/providers/openai_responses/client.rs | 55 +- .../core/providers/responses_common/types.rs | 4 + .../src/core/providers/thinking_mode.rs | 689 ++++++++++++++++++ 11 files changed, 1096 insertions(+), 118 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs index ee2542164b..b3a465d630 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/request.rs @@ -39,8 +39,12 @@ pub(super) fn prepare_request( temperature: f32, stream: bool, ) -> PreparedRequest { + // Strip the reasoning-level suffix ORG2 encodes into variant ids (e.g. + // `claude-opus-4-8-thinking-xhigh`) — providers reject the suffixed + // alias, and the decoded level drives thinking-mode parameter selection. + let parsed = crate::providers::thinking_mode::parse_model_variant(model); let resolved_model = - crate::providers::model_hints::wire_model_name(client.provider_spec, model); + crate::providers::model_hints::wire_model_name(client.provider_spec, &parsed.base_model); let (system, anthropic_messages) = extract_system(messages); // Extract tool_choice override (from side_query structured output) @@ -62,8 +66,18 @@ pub(super) fn prepare_request( // Plain side queries: suppress thinking when possible. crate::providers::anthropic_native::thinking::ThinkingDirective::PlainText }; - let (thinking, mut effective_temp, effective_max_tokens) = - build_thinking_params(&caps, directive, max_tokens, temperature); + let outcome = build_thinking_params( + &resolved_model, + parsed.level, + directive, + &caps, + max_tokens, + temperature, + ); + let thinking = outcome.thinking; + let effort = outcome.effort; + let mut effective_temp = outcome.temperature; + let effective_max_tokens = outcome.max_tokens; // Self-healing: once a model has rejected `temperature` with a 400 // (`temperature is deprecated for this model`), never send it again — @@ -92,6 +106,7 @@ pub(super) fn prepare_request( temperature: effective_temp, stream, thinking, + effort, metadata: claude_oauth_metadata(client.auth_mode), }; diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/thinking.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/thinking.rs index 3d65e30ed0..142bddd3e9 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/thinking.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/thinking.rs @@ -1,16 +1,31 @@ -//! Extended-thinking request parameters, driven by [`ModelCapabilities`]. +//! Extended-thinking request parameters, driven by [`ThinkingMode`]. //! -//! The old substring matcher (`supports_thinking`) lived here until the -//! 2026-06-12 incident: `claude-fable-5` wasn't matched, got no thinking -//! handling, and returned thinking-only side-query responses that broke -//! compaction + session-memory extraction. Capability questions now go -//! through `model_capabilities::resolve` — this module only translates a -//! resolved capability + the caller's [`ThinkingDirective`] into the -//! Anthropic request triad `(thinking, temperature, max_tokens)`. +//! Anthropic exposes thinking control through three mutually incompatible +//! shapes depending on model generation, so a single `thinking` object +//! cannot be correct for all of them. [`ThinkingMode`] (classified by +//! `thinking_mode::resolve_thinking_mode`) decides which: +//! +//! - **Adaptive** (Opus 4.7/4.8, Fable-5, Mythos): `thinking:{type:adaptive, +//! display:summarized}` + top-level `effort`. Rejects `budget_tokens` +//! (HTTP 400) and rejects `temperature`/`top_p`/`top_k` (also 400). +//! - **4.6** (opus-4.6 / sonnet-4.6): `thinking:{type:adaptive}` + `effort` +//! (UI extra_high → API `max`). +//! - **Legacy** (Opus 4/4.1/4.5, Sonnet 4/4.5, 3.7): `thinking:{type:enabled, +//! budget_tokens}`. +//! +//! This module only translates a resolved mode + the caller's +//! [`ThinkingDirective`] + selected [`ReasoningLevel`] into the Anthropic +//! request quad `(thinking, effort, temperature, max_tokens)`. Mode +//! classification and level→parameter mapping live in `thinking_mode`. -use serde_json::Value; +use serde_json::{json, Value}; use crate::providers::model_capabilities::{ModelCapabilities, ThinkingSupport}; +use crate::providers::registry::provider_id; +use crate::providers::thinking_mode::{ + anthropic_effort, anthropic_max_tokens_floor, anthropic_thinking_param, + is_claude_rejects_sampling, resolve_thinking_mode, ReasoningLevel, ThinkingMode, +}; /// What the caller wants from thinking, independent of what the model can do. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -19,7 +34,7 @@ pub enum ThinkingDirective { #[default] Auto, /// Caller needs plain text (side queries: summarization, extraction, - /// classification). For `Optional` models we send + /// classification). For adaptive/legacy models we send /// `{"type": "disabled"}`; for `AlwaysOn` models — which reject /// `disabled` with a 400 — we instead pad `max_tokens` so thinking /// can't exhaust the output budget before the answer is emitted. @@ -31,66 +46,103 @@ pub enum ThinkingDirective { /// classifier calls; 2048 gives comfortable margin. const ALWAYS_ON_THINKING_PAD_TOKENS: u32 = 2048; -/// Build the `thinking` request param and adjust max_tokens / temperature. +/// The Anthropic request quad produced by [`build_thinking_params`]. +pub(super) struct ThinkingOutcome { + pub thinking: Option, + /// Top-level `effort` (sibling of `thinking`) for adaptive/4.6 modes. + pub effort: Option, + pub temperature: Option, + pub max_tokens: u32, +} + +/// Build the `(thinking, effort, temperature, max_tokens)` quad for one +/// Anthropic chat call. /// -/// Returns `(thinking_param, temperature, max_tokens)`. When thinking is -/// enabled and `caps.omit_temperature_with_thinking` is set, temperature is -/// `None` (Anthropic rejects requests carrying both). +/// `base_model` is the real model id (variant suffix already stripped by the +/// caller via `thinking_mode::parse_model_variant`) — it drives mode +/// classification and the sampling-rejection check. `level` is the +/// user-selected reasoning level decoded from the suffix (`None` when no +/// level was encoded). pub(super) fn build_thinking_params( - caps: &ModelCapabilities, + base_model: &str, + level: Option, directive: ThinkingDirective, + caps: &ModelCapabilities, max_tokens: u32, temperature: f32, -) -> (Option, Option, u32) { - match (caps.thinking, directive) { - (ThinkingSupport::No, _) => (None, Some(temperature), max_tokens), +) -> ThinkingOutcome { + let mode = resolve_thinking_mode(base_model, provider_id::ANTHROPIC); - (ThinkingSupport::Optional, ThinkingDirective::PlainText) => ( - Some(serde_json::json!({ "type": "disabled" })), - Some(temperature), + // Non-thinking model: pass everything through unchanged. + if caps.thinking == ThinkingSupport::No || mode == ThinkingMode::None { + return ThinkingOutcome { + thinking: None, + effort: None, + temperature: Some(temperature), max_tokens, - ), - - (ThinkingSupport::AlwaysOn, ThinkingDirective::PlainText) => { - // `disabled` would be rejected with a 400; pad the budget so - // the visible answer survives the model's obligatory thinking. - ( - None, - temperature_for_thinking(caps, temperature), - max_tokens.saturating_add(ALWAYS_ON_THINKING_PAD_TOKENS), - ) - } + }; + } - (ThinkingSupport::Optional, ThinkingDirective::Auto) => { - let budget = (max_tokens / 2).clamp(1024, 32768); - let effective_max = max_tokens.max(budget + 1024); - ( - Some(serde_json::json!({ - "type": "enabled", - "budget_tokens": budget, - })), - temperature_for_thinking(caps, temperature), - effective_max, - ) + let (thinking, effort, effective_max) = match directive { + ThinkingDirective::PlainText => { + // Side query wants plain text. How to suppress thinking depends + // on the generation: + // - AlwaysOn adaptive (Fable/Mythos): `disabled` returns 400 and + // thinking is unconditional — pad the output budget instead. + // - Legacy (4.0/4.5/3.7): accept `{type:disabled}`. + // - Adaptive (4.6/4.7/4.8): thinking is OFF BY DEFAULT when no + // `thinking` field is sent, so omit it. Sending `{type:disabled}` + // is non-standard here (400s on Fable/Mythos). + if caps.thinking == ThinkingSupport::AlwaysOn { + ( + None, + None, + max_tokens.saturating_add(ALWAYS_ON_THINKING_PAD_TOKENS), + ) + } else if mode == ThinkingMode::AnthropicLegacyBudget { + (Some(json!({ "type": "disabled" })), None, max_tokens) + } else { + (None, None, max_tokens) + } } - - (ThinkingSupport::AlwaysOn, ThinkingDirective::Auto) => { - // Model thinks server-side without being asked; send no - // thinking param and make sure the output budget has room. - ( - None, - temperature_for_thinking(caps, temperature), - max_tokens.max(ALWAYS_ON_THINKING_PAD_TOKENS + 1024), - ) + ThinkingDirective::Auto => { + let thinking = anthropic_thinking_param(mode, level, max_tokens); + let effort = anthropic_effort(mode, level).map(str::to_string); + let floor = anthropic_max_tokens_floor(mode, level, max_tokens); + // AlwaysOn adaptive (mythos): thinking is obligatory, make sure + // the output budget has room even though we send no thinking param. + let floor = if caps.thinking == ThinkingSupport::AlwaysOn + && mode == ThinkingMode::AnthropicAdaptive + { + floor.max(ALWAYS_ON_THINKING_PAD_TOKENS + 1024) + } else { + floor + }; + (thinking, effort, floor) } - } -} + }; -fn temperature_for_thinking(caps: &ModelCapabilities, temperature: f32) -> Option { - if caps.omit_temperature_with_thinking { + // Temperature: 4.7+ rejects sampling params unconditionally (400); + // otherwise omit when thinking is actually engaged and the model + // requires it (Anthropic rejects enabled-thinking + temperature). + let thinking_engaged = thinking + .as_ref() + .and_then(|t| t.get("type").and_then(|v| v.as_str())) + .map(|ty| ty == "enabled" || ty == "adaptive") + .unwrap_or(false); + let temperature = if is_claude_rejects_sampling(base_model) + || (thinking_engaged && caps.omit_temperature_with_thinking) + { None } else { Some(temperature) + }; + + ThinkingOutcome { + thinking, + effort, + temperature, + max_tokens: effective_max, } } @@ -98,80 +150,160 @@ fn temperature_for_thinking(caps: &ModelCapabilities, temperature: f32) -> Optio mod tests { use super::*; - fn optional_caps() -> ModelCapabilities { + fn caps(thinking: ThinkingSupport) -> ModelCapabilities { ModelCapabilities { context_window: 200_000, - thinking: ThinkingSupport::Optional, + thinking, omit_temperature_with_thinking: true, } } - fn always_on_caps() -> ModelCapabilities { - ModelCapabilities { - context_window: 200_000, - thinking: ThinkingSupport::AlwaysOn, - omit_temperature_with_thinking: true, - } + #[test] + fn non_thinking_model_passes_through() { + let o = build_thinking_params( + "claude-3-5-haiku", + None, + ThinkingDirective::Auto, + &caps(ThinkingSupport::No), + 4096, + 0.7, + ); + assert!(o.thinking.is_none()); + assert!(o.effort.is_none()); + assert_eq!(o.temperature, Some(0.7)); + assert_eq!(o.max_tokens, 4096); } - fn no_thinking_caps() -> ModelCapabilities { - ModelCapabilities { - context_window: 128_000, - thinking: ThinkingSupport::No, - omit_temperature_with_thinking: false, - } + #[test] + fn adaptive_emits_summarized_and_effort_without_budget() { + let o = build_thinking_params( + "claude-opus-4-8", + Some(ReasoningLevel::High), + ThinkingDirective::Auto, + &caps(ThinkingSupport::Optional), + 8192, + 0.7, + ); + let thinking = o.thinking.expect("adaptive sends thinking"); + assert_eq!(thinking["type"], "adaptive"); + assert_eq!(thinking["display"], "summarized"); + assert!( + thinking.get("budget_tokens").is_none(), + "budget_tokens would 400" + ); + assert_eq!(o.effort.as_deref(), Some("high")); + // 4.7+ rejects temperature unconditionally. + assert!(o.temperature.is_none()); + } + + #[test] + fn adaptive_baseline_sends_no_effort() { + let o = build_thinking_params( + "claude-opus-4-8", + Some(ReasoningLevel::Baseline), + ThinkingDirective::Auto, + &caps(ThinkingSupport::Optional), + 8192, + 0.7, + ); + assert_eq!(o.thinking.unwrap()["type"], "adaptive"); + assert!(o.effort.is_none()); } #[test] - fn no_thinking_model_passes_temperature_through() { - let (thinking, temp, max_tokens) = - build_thinking_params(&no_thinking_caps(), ThinkingDirective::Auto, 4096, 0.7); - assert!(thinking.is_none()); - assert_eq!(temp, Some(0.7)); - assert_eq!(max_tokens, 4096); + fn claude46_maps_extra_high_to_max_effort() { + let o = build_thinking_params( + "claude-opus-4-6", + Some(ReasoningLevel::ExtraHigh), + ThinkingDirective::Auto, + &caps(ThinkingSupport::Optional), + 8192, + 0.7, + ); + assert_eq!(o.thinking.unwrap()["type"], "adaptive"); + assert_eq!(o.effort.as_deref(), Some("max")); } #[test] - fn optional_plain_text_sends_disabled() { - let (thinking, temp, max_tokens) = - build_thinking_params(&optional_caps(), ThinkingDirective::PlainText, 1024, 0.0); - assert_eq!(thinking.unwrap()["type"], "disabled"); - assert_eq!(temp, Some(0.0)); - assert_eq!(max_tokens, 1024); + fn legacy_budget_uses_level_and_omits_temperature_when_engaged() { + let o = build_thinking_params( + "claude-opus-4-5", + Some(ReasoningLevel::High), + ThinkingDirective::Auto, + &caps(ThinkingSupport::Optional), + 8192, + 0.7, + ); + let thinking = o.thinking.unwrap(); + assert_eq!(thinking["type"], "enabled"); + assert_eq!(thinking["budget_tokens"], 24_576); + assert!(o.effort.is_none()); + // thinking engaged + omit_temperature_with_thinking → None + assert!(o.temperature.is_none()); + // floor ensures budget + 1024 room + assert!(o.max_tokens >= 24_576 + 1024); } #[test] - fn always_on_plain_text_pads_max_tokens_no_disabled() { - let (thinking, temp, max_tokens) = - build_thinking_params(&always_on_caps(), ThinkingDirective::PlainText, 1024, 0.5); - // Must NOT send {"type":"disabled"} — that would be rejected with a 400 - assert!(thinking.is_none()); - // Temperature omitted for thinking models - assert!(temp.is_none()); - // max_tokens padded for thinking overhead - assert!(max_tokens > 1024); - assert_eq!(max_tokens, 1024 + 2048); + fn legacy_baseline_preserves_half_max_tokens_budget() { + let o = build_thinking_params( + "claude-opus-4-5", + None, + ThinkingDirective::Auto, + &caps(ThinkingSupport::Optional), + 8192, + 0.7, + ); + assert_eq!(o.thinking.unwrap()["budget_tokens"], 4096); } #[test] - fn optional_auto_enables_thinking_with_budget() { - let (thinking, temp, max_tokens) = - build_thinking_params(&optional_caps(), ThinkingDirective::Auto, 8192, 0.7); - let thinking = thinking.unwrap(); - assert_eq!(thinking["type"], "enabled"); - assert!(thinking["budget_tokens"].as_u64().unwrap() > 0); - // Temperature omitted when thinking is enabled - assert!(temp.is_none()); - assert!(max_tokens >= 8192); + fn plain_text_disables_thinking_on_optional_and_keeps_temperature() { + let o = build_thinking_params( + "claude-opus-4-5", + None, + ThinkingDirective::PlainText, + &caps(ThinkingSupport::Optional), + 1024, + 0.5, + ); + assert_eq!(o.thinking.unwrap()["type"], "disabled"); + // thinking not engaged → temperature retained (not 4.7+) + assert_eq!(o.temperature, Some(0.5)); + } + + #[test] + fn plain_text_on_alwayson_pads_and_omits_temperature_for_mythos() { + let o = build_thinking_params( + "claude-mythos", + None, + ThinkingDirective::PlainText, + &caps(ThinkingSupport::AlwaysOn), + 1024, + 0.5, + ); + // Must NOT send disabled (would 400). + assert!(o.thinking.is_none()); + assert!(o.max_tokens > 1024); + // mythos is adaptive-line → rejects sampling. + assert!(o.temperature.is_none()); } #[test] - fn always_on_auto_ensures_min_budget() { - let (thinking, _temp, max_tokens) = - build_thinking_params(&always_on_caps(), ThinkingDirective::Auto, 1024, 0.0); - // No thinking param needed — server handles it - assert!(thinking.is_none()); - // But max_tokens must have room - assert!(max_tokens >= 2048 + 1024); + fn plain_text_on_adaptive_omits_thinking_and_temperature() { + let o = build_thinking_params( + "claude-opus-4-8", + Some(ReasoningLevel::High), + ThinkingDirective::PlainText, + &caps(ThinkingSupport::Optional), + 1024, + 0.5, + ); + // Adaptive (4.7/4.8): thinking is off by default when no `thinking` + // field is sent — omit rather than sending `{type:disabled}`, which + // is non-standard (400s on Fable/Mythos). + assert!(o.thinking.is_none()); + // 4.7+ rejects sampling regardless of the thinking state. + assert!(o.temperature.is_none()); } } diff --git a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/types.rs b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/types.rs index 2fe85e9bd3..d5dc24d5e8 100644 --- a/src-tauri/crates/agent-core/src/core/providers/anthropic_native/types.rs +++ b/src-tauri/crates/agent-core/src/core/providers/anthropic_native/types.rs @@ -24,6 +24,10 @@ pub(super) struct MessagesRequest { pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub thinking: Option, + /// Top-level `effort` for adaptive-thinking Claude models (4.6 / 4.7+). + /// Sibling of `thinking`, not nested inside it. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } diff --git a/src-tauri/crates/agent-core/src/core/providers/mod.rs b/src-tauri/crates/agent-core/src/core/providers/mod.rs index b1a40c15ad..1f65f3c9b6 100644 --- a/src-tauri/crates/agent-core/src/core/providers/mod.rs +++ b/src-tauri/crates/agent-core/src/core/providers/mod.rs @@ -27,6 +27,7 @@ pub mod registry; pub mod reliable; pub mod responses_common; pub mod safe_truncate; +pub mod thinking_mode; pub mod traits; pub mod wire_sanitize; diff --git a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs index 80ddfe1cb1..ec5d304a42 100644 --- a/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/providers/model_capabilities.rs @@ -595,6 +595,49 @@ pub fn resolve_model_context_k(model: String) -> usize { caps.context_window / 1_000 } +/// Coarse model family for thinking-mode classification. +/// +/// This is the designated home for model-family prefix matching outside +/// `FAMILY_RULES`: patterns are matched through the `FAMILY_TABLE` variable +/// (`.contains(pat)`), never a family literal, so the +/// `no_substring_capability_checks_outside_this_module` test stays green. +/// Other modules (`thinking_mode`) route family-derived behavior through +/// this function instead of re-doing their own substring checks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelFamily { + Anthropic, + OpenAi, + Zhipu, + Other, +} + +const FAMILY_TABLE: &[(&str, ModelFamily)] = &[ + ("claude", ModelFamily::Anthropic), + ("glm", ModelFamily::Zhipu), + ("gpt-5", ModelFamily::OpenAi), + ("o1", ModelFamily::OpenAi), + ("o3", ModelFamily::OpenAi), + ("o4", ModelFamily::OpenAi), +]; + +/// Classify a model id into a coarse family. The provider name breaks ties +/// when the id carries no known family prefix (e.g. a custom alias routed +/// through a specific provider). +pub fn classify_family(base_model: &str, provider_name: &str) -> ModelFamily { + let id = base_model.to_lowercase(); + for (pat, fam) in FAMILY_TABLE { + if id.contains(pat) { + return *fam; + } + } + match provider_name { + crate::providers::registry::provider_id::ANTHROPIC => ModelFamily::Anthropic, + crate::providers::registry::provider_id::ZHIPU => ModelFamily::Zhipu, + crate::providers::registry::provider_id::OPENAI => ModelFamily::OpenAi, + _ => ModelFamily::Other, + } +} + #[cfg(test)] #[path = "tests/model_capabilities_tests.rs"] mod tests; diff --git a/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/chat.rs b/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/chat.rs index 9fa20eeab9..ddd69267c2 100644 --- a/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/chat.rs +++ b/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/chat.rs @@ -34,6 +34,18 @@ pub(super) async fn run_chat( crate::providers::model_hints::wire_model_name(this.provider_spec, model) }; + // Strip the reasoning-level suffix ORG2 encodes into variant ids and + // resolve `reasoning_effort` (OpenAI) or the `thinking` toggle (Zhipu + // GLM). Shared with the streaming path via `resolve_openai_compat_thinking`. + let crate::providers::thinking_mode::OpenAiCompatThinking { + base_model, + reasoning_effort, + thinking, + } = crate::providers::thinking_mode::resolve_openai_compat_thinking( + &resolved_model, + this.provider_spec.name, + ); + let sanitized_messages = sanitize_openai_compat_messages(messages); let wire_messages = if this.provider_spec.name == provider_id::DEEPSEEK { sanitize_deepseek_messages(&sanitized_messages) @@ -68,9 +80,9 @@ pub(super) async fn run_chat( }; let wire_tools_final = clean_wire_tools.or(wire_tools); - let wire_policy = this.chat_wire_policy(&resolved_model); + let wire_policy = this.chat_wire_policy(&base_model); let request_body = ChatCompletionRequest { - model: resolved_model.clone(), + model: base_model.clone(), messages: wire_messages, tools: wire_tools_final, tool_choice: if let Some(ovr) = tool_choice_override { @@ -96,9 +108,11 @@ pub(super) async fn run_chat( }, stream: false, stream_options: None, + reasoning_effort, + thinking, }; - let url = this.chat_url(&resolved_model); + let url = this.chat_url(&base_model); let mut request = this .client .post(&url) diff --git a/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/sse_stream.rs b/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/sse_stream.rs index ce85e26e52..e706644ecd 100644 --- a/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/sse_stream.rs +++ b/src-tauri/crates/agent-core/src/core/providers/openai_compat/streaming/sse_stream.rs @@ -54,7 +54,19 @@ pub(super) async fn run_chat_streaming( crate::providers::model_hints::wire_model_name(this.provider_spec, model) }; - let url = this.chat_url(&resolved_model); + // Strip the reasoning-level suffix ORG2 encodes into variant ids and + // resolve `reasoning_effort` (OpenAI) or the `thinking` toggle (Zhipu + // GLM). Shared with the non-streaming path via `resolve_openai_compat_thinking`. + let crate::providers::thinking_mode::OpenAiCompatThinking { + base_model, + reasoning_effort, + thinking, + } = crate::providers::thinking_mode::resolve_openai_compat_thinking( + &resolved_model, + this.provider_spec.name, + ); + + let url = this.chat_url(&base_model); let sanitized_messages = sanitize_openai_compat_messages(messages); let wire_messages = if this.provider_spec.name == provider_id::DEEPSEEK { @@ -91,9 +103,9 @@ pub(super) async fn run_chat_streaming( }; let wire_tools_final = clean_wire_tools.or(wire_tools); - let wire_policy = this.chat_wire_policy(&resolved_model); + let wire_policy = this.chat_wire_policy(&base_model); let request_body = ChatCompletionRequest { - model: resolved_model.clone(), + model: base_model.clone(), messages: wire_messages, tools: wire_tools_final, tool_choice: if let Some(ovr) = tool_choice_override { @@ -122,6 +134,8 @@ pub(super) async fn run_chat_streaming( } else { None }, + reasoning_effort, + thinking, }; let mut request = this diff --git a/src-tauri/crates/agent-core/src/core/providers/openai_compat/types.rs b/src-tauri/crates/agent-core/src/core/providers/openai_compat/types.rs index 926a91c5ee..f2776d5717 100644 --- a/src-tauri/crates/agent-core/src/core/providers/openai_compat/types.rs +++ b/src-tauri/crates/agent-core/src/core/providers/openai_compat/types.rs @@ -28,6 +28,15 @@ pub(super) struct ChatCompletionRequest { /// Required for OpenAI-compatible streaming to include usage in the final chunk. #[serde(skip_serializing_if = "Option::is_none")] pub stream_options: Option, + /// OpenAI reasoning effort (gpt-5+/o-series). Top-level Chat Completions + /// parameter; sending it to a non-reasoning model returns HTTP 400. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// Zhipu GLM thinking toggle `{type: enabled|disabled}`. Distinct from + /// OpenAI `reasoning_effort` — only one applies per request, decided by + /// `thinking_mode::resolve_thinking_mode`. + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, } /// Initial Chat Completions token-limit field hint from the model alias. diff --git a/src-tauri/crates/agent-core/src/core/providers/openai_responses/client.rs b/src-tauri/crates/agent-core/src/core/providers/openai_responses/client.rs index 4a70801c9d..2b2ee08642 100644 --- a/src-tauri/crates/agent-core/src/core/providers/openai_responses/client.rs +++ b/src-tauri/crates/agent-core/src/core/providers/openai_responses/client.rs @@ -81,14 +81,32 @@ impl OpenAIResponsesClient { let (instructions, input) = convert_messages(messages); let (converted_tools, tool_choice) = convert_tools_with_choice(tools); + // Strip the reasoning-level suffix ORG2 encodes into variant ids and + // map the level to the Responses API `reasoning.effort` parameter. + // The Responses API rejects the suffixed alias; sending reasoning to + // a non-reasoning model returns HTTP 400, so only OpenAiEffort modes + // set it. + let parsed = crate::providers::thinking_mode::parse_model_variant(model); + let mode = crate::providers::thinking_mode::resolve_thinking_mode( + &parsed.base_model, + crate::providers::registry::provider_id::OPENAI, + ); + let reasoning = if mode == crate::providers::thinking_mode::ThinkingMode::OpenAiEffort { + crate::providers::thinking_mode::openai_effort(parsed.level) + .map(|effort| serde_json::json!({ "effort": effort })) + } else { + None + }; + ResponsesRequest { - model: model.to_string(), + model: parsed.base_model, input, instructions, tools: converted_tools, tool_choice, max_output_tokens: Some(max_tokens), temperature: None, + reasoning, store: false, stream, } @@ -108,3 +126,38 @@ impl OpenAIResponsesClient { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_responses_request_strips_suffix_and_sets_reasoning() { + let req = OpenAIResponsesClient::build_responses_request( + &[], + None, + "gpt-5.5-high", + 1024, + 0.0, + false, + ); + assert_eq!(req.model, "gpt-5.5"); + assert_eq!(req.reasoning.as_ref().unwrap()["effort"], "high"); + } + + #[test] + fn build_responses_request_default_omits_reasoning() { + let req = + OpenAIResponsesClient::build_responses_request(&[], None, "gpt-5.5", 1024, 0.0, false); + assert_eq!(req.model, "gpt-5.5"); + assert!(req.reasoning.is_none()); + } + + #[test] + fn build_responses_request_non_reasoning_omits_reasoning() { + let req = + OpenAIResponsesClient::build_responses_request(&[], None, "gpt-4o", 1024, 0.0, false); + assert_eq!(req.model, "gpt-4o"); + assert!(req.reasoning.is_none()); + } +} diff --git a/src-tauri/crates/agent-core/src/core/providers/responses_common/types.rs b/src-tauri/crates/agent-core/src/core/providers/responses_common/types.rs index 043aa69b96..83c68f243a 100644 --- a/src-tauri/crates/agent-core/src/core/providers/responses_common/types.rs +++ b/src-tauri/crates/agent-core/src/core/providers/responses_common/types.rs @@ -39,6 +39,10 @@ pub struct ResponsesRequest { /// Temperature (public API only, not supported by Codex native backend). #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + /// Reasoning config `{effort: "low"|"medium"|"high"}` for GPT-5+/o-series + /// (public API). Codex native backend never sets this. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, pub store: bool, pub stream: bool, } diff --git a/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs b/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs new file mode 100644 index 0000000000..16cd9cd22e --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/providers/thinking_mode.rs @@ -0,0 +1,689 @@ +//! Thinking / reasoning mode classification and parameter mapping. +//! +//! Different model families expose thinking control through wildly different +//! request shapes, so a single `thinking` parameter cannot be correct for all +//! of them. This module is the single source of truth for: +//! +//! 1. **Parsing** the reasoning-level suffix ORG2 encodes into model ids +//! (e.g. `glm-5.2-high`, `claude-opus-4-7-thinking-xhigh`) back into a +//! structured level **plus the real base model id** that providers accept +//! — providers reject the suffixed alias. +//! 2. **Classifying** a model into a [`ThinkingMode`] (adaptive vs budget vs +//! effort vs toggle), using version-aware regex ported from Cherry Studio +//! so Opus 4.7+/Fable-5/Mythos (adaptive) are never mis-sent +//! `budget_tokens` (which they reject with HTTP 400). +//! 3. **Translating** `(mode, level)` into each provider's wire parameter. +//! +//! The suffix token set mirrors the frontend `VARIANT_SUFFIX_TOKENS` +//! (`src/util/modelVariants.ts`) so front- and back-end agree on what a +//! "variant suffix" is. + +use regex::Regex; +use serde_json::{json, Value}; +use std::sync::OnceLock; + +use crate::providers::model_capabilities::{classify_family, ModelFamily}; + +/// User-selectable reasoning effort, independent of provider protocol. +/// Mirrors the frontend `MODEL_REASONING_LEVEL`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReasoningLevel { + None, + Baseline, + Low, + Medium, + High, + ExtraHigh, + Max, +} + +impl ReasoningLevel { + /// Parse a single (already compound-merged) suffix token into a level. + /// Returns `None` for non-level tokens (`thinking`, `fast`) and unknown + /// strings, so the caller can treat them as the orthogonal flags they are. + pub fn from_token(token: &str) -> Option { + match token { + "none" => Some(Self::None), + "baseline" => Some(Self::Baseline), + "low" => Some(Self::Low), + "medium" => Some(Self::Medium), + "high" => Some(Self::High), + "extra" | "extra-high" | "xhigh" => Some(Self::ExtraHigh), + "max" => Some(Self::Max), + _ => None, + } + } +} + +/// How a model exposes thinking control. Decides the request parameter shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThinkingMode { + /// Non-reasoning model — send no thinking/effort parameter. + None, + /// Anthropic adaptive thinking (Opus 4.7/4.8, Fable-5, Mythos). + /// Rejects `budget_tokens` (HTTP 400); requires `display: "summarized"` + /// (API defaults to `omitted`, hiding reasoning) and forbids + /// `temperature`/`top_p`/`top_k` (also HTTP 400). + AnthropicAdaptive, + /// Anthropic 4.6 adaptive thinking (opus-4.6 / sonnet-4.6). + /// `thinking: {type: "adaptive"}` + `effort` (UI extra_high → API `max`). + Anthropic46, + /// Legacy Anthropic extended thinking (Opus 4/4.1/4.5, Sonnet 4/4.5, 3.7). + /// `thinking: {type: "enabled", budget_tokens}`. + AnthropicLegacyBudget, + /// OpenAI reasoning models (gpt-5+/o-series). `reasoning_effort`. + OpenAiEffort, + /// Zhipu GLM. Simple on/off `thinking: {type: "enabled"|"disabled"}` + /// toggle — budget support is unreliable across GLM versions, so we do + /// not send a budget (mirrors Cherry Studio). + ZhipuToggle, +} + +/// A model id split into its base alias + the variant suffix ORG2 encoded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedVariant { + /// The real model id providers accept (suffix stripped). + pub base_model: String, + pub level: Option, + pub thinking: bool, + pub fast: bool, +} + +impl ParsedVariant { + /// No suffix encoded — the id is the base id, nothing to map. + pub fn bare(model: &str) -> Self { + Self { + base_model: model.to_string(), + level: None, + thinking: false, + fast: false, + } + } +} + +/// Tokens that may appear as ORG2-encoded variant suffixes. Matches the +/// frontend `VARIANT_SUFFIX_TOKENS` exactly. Provider-native suffixes +/// (`mini`, `flash`, date stamps, `20250514`) are deliberately absent so they +/// are never stripped. +const SUFFIX_TOKENS: &[&str] = &[ + "none", + "baseline", + "low", + "medium", + "high", + "extra", + "extra-high", + "xhigh", + "max", + "minimal", + "thinking", + "fast", +]; + +fn is_suffix_token(tok: &str) -> bool { + SUFFIX_TOKENS.contains(&tok) +} + +/// Split a (possibly suffixed) model id into base alias + variant metadata. +/// +/// Peels trailing tokens that belong to ORG2's variant vocabulary only; +/// provider-native suffixes are preserved. `extra` + `high` are merged into +/// `extra-high` exactly as the frontend (`mergeCompoundTokens`) does. +pub fn parse_model_variant(model: &str) -> ParsedVariant { + let lower = model.to_lowercase(); + let lower_segments: Vec<&str> = lower.split('-').collect(); + + // Walk from the end, peeling recognised suffix tokens off the base. + let mut split = lower_segments.len(); + while split > 1 { + if !is_suffix_token(lower_segments[split - 1]) { + break; + } + split -= 1; + } + + if split == lower_segments.len() { + // No suffix token peeled — id carries no encoded variant. + return ParsedVariant::bare(model); + } + + let raw_tokens: Vec<&str> = lower_segments[split..].to_vec(); + + // Merge `extra` + `high` → `extra-high`. + let mut merged: Vec = Vec::with_capacity(raw_tokens.len()); + let mut i = 0; + while i < raw_tokens.len() { + if raw_tokens[i] == "extra" && i + 1 < raw_tokens.len() && raw_tokens[i + 1] == "high" { + merged.push("extra-high".to_string()); + i += 2; + } else { + merged.push(raw_tokens[i].to_string()); + i += 1; + } + } + + let mut thinking = false; + let mut fast = false; + let mut level: Option = None; + for tok in &merged { + match tok.as_str() { + "thinking" => thinking = true, + "fast" => fast = true, + other if level.is_none() => level = ReasoningLevel::from_token(other), + _ => {} + } + } + + // Base model keeps original casing (take the first `split` segments). + let base_model: String = model.split('-').take(split).collect::>().join("-"); + + ParsedVariant { + base_model, + level, + thinking, + fast, + } +} + +// ── Claude version detection (ported from Cherry Studio) ─────────────────── + +fn opus47_or_newer_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + // minor capped at two digits so date suffixes (e.g. -20250514) fall + // into the trailing-suffix group rather than being read as the minor. + Regex::new( + r"^(?:anthropic\.)?claude-(opus|fable)-(\d+)(?:[.-](\d{1,2}))?(?:[@\-:][\w\-:]+)?$", + ) + .expect("opus47 regex") + }) +} + +fn mythos_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"claude-mythos").expect("mythos regex")) +} + +fn claude46_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?:anthropic\.)?claude-(?:opus|sonnet)-4[.-]6(?:[@\-:][\w\-:]+)?$") + .expect("claude46 regex") + }) +} + +/// Opus 4.7+ / Fable 5+ / Mythos — the adaptive-thinking family that rejects +/// `budget_tokens` and sampling parameters with HTTP 400. +pub fn is_claude_opus_47_or_newer(base_model: &str) -> bool { + let id = base_model.to_lowercase(); + // Mythos (ORG2: `claude-mythos` = "Mythos 5") shares the new architecture. + // Matched via regex (not a `.contains` family-token literal) so the + // no_substring_capability_checks test stays green. + if mythos_regex().is_match(&id) { + return true; + } + let caps = match opus47_or_newer_regex().captures(&id) { + Some(c) => c, + None => return false, + }; + let family = caps.get(1).unwrap().as_str(); + let major: u32 = caps.get(2).unwrap().as_str().parse().unwrap_or(0); + let minor: u32 = caps + .get(3) + .map(|m| m.as_str().parse().unwrap_or(0)) + .unwrap_or(0); + if family == "fable" { + return major >= 5; + } + major > 4 || (major == 4 && minor >= 7) +} + +/// Opus/Sonnet 4.6 — adaptive thinking + `effort` (UI extra_high → `max`). +pub fn is_claude_46_series(base_model: &str) -> bool { + claude46_regex().is_match(&base_model.to_lowercase()) +} + +/// 4.7+ rejects `temperature`/`top_p`/`top_k` with HTTP 400 regardless of +/// whether thinking is requested. +pub fn is_claude_rejects_sampling(base_model: &str) -> bool { + is_claude_opus_47_or_newer(base_model) +} + +/// Classify a base model id into its thinking mode. +/// +/// Family classification is centralised in +/// `model_capabilities::classify_family`; here we only split the Anthropic +/// family by version into its thinking-mode generations (adaptive / 4.6 / +/// legacy budget). `provider_name` flows through to `classify_family` to +/// disambiguate aliases that carry no family prefix. +pub fn resolve_thinking_mode(base_model: &str, provider_name: &str) -> ThinkingMode { + match classify_family(base_model, provider_name) { + ModelFamily::Anthropic => { + if is_claude_opus_47_or_newer(base_model) { + ThinkingMode::AnthropicAdaptive + } else if is_claude_46_series(base_model) { + ThinkingMode::Anthropic46 + } else { + ThinkingMode::AnthropicLegacyBudget + } + } + ModelFamily::Zhipu => ThinkingMode::ZhipuToggle, + ModelFamily::OpenAi => ThinkingMode::OpenAiEffort, + ModelFamily::Other => ThinkingMode::None, + } +} + +// ── Parameter mapping ────────────────────────────────────────────────────── + +/// Anthropic `effort` value for adaptive / 4.6 modes. `None` when the level +/// maps to "no explicit effort" (baseline) — the caller still sends +/// `thinking: {type: "adaptive"}` in that case. +pub fn anthropic_effort(mode: ThinkingMode, level: Option) -> Option<&'static str> { + let level = level?; + if matches!(level, ReasoningLevel::Baseline | ReasoningLevel::None) { + return None; + } + let xhigh_target = match mode { + ThinkingMode::AnthropicAdaptive => "xhigh", + ThinkingMode::Anthropic46 => "max", + _ => return None, + }; + Some(match level { + ReasoningLevel::Low => "low", + ReasoningLevel::Medium => "medium", + ReasoningLevel::High => "high", + ReasoningLevel::ExtraHigh | ReasoningLevel::Max => xhigh_target, + ReasoningLevel::Baseline | ReasoningLevel::None => return None, + }) +} + +/// Build the Anthropic `thinking` request object for the given mode + level. +/// +/// Returns `None` when no thinking param should be sent (`ThinkingMode::None`, +/// or non-Anthropic modes). `max_tokens` is only consulted by the legacy +/// budget branch. +pub fn anthropic_thinking_param( + mode: ThinkingMode, + level: Option, + max_tokens: u32, +) -> Option { + match mode { + ThinkingMode::None | ThinkingMode::OpenAiEffort | ThinkingMode::ZhipuToggle => None, + ThinkingMode::AnthropicAdaptive => { + if matches!(level, Some(ReasoningLevel::None)) { + return Some(json!({ "type": "disabled" })); + } + // `display: "summarized"` is required — the API defaults to + // `omitted`, which strips reasoning from the response. + Some(json!({ "type": "adaptive", "display": "summarized" })) + } + ThinkingMode::Anthropic46 => { + if matches!(level, Some(ReasoningLevel::None)) { + return Some(json!({ "type": "disabled" })); + } + Some(json!({ "type": "adaptive" })) + } + ThinkingMode::AnthropicLegacyBudget => { + if matches!(level, Some(ReasoningLevel::None)) { + return Some(json!({ "type": "disabled" })); + } + let budget = anthropic_legacy_budget(level, max_tokens); + Some(json!({ "type": "enabled", "budget_tokens": budget })) + } + } +} + +/// Legacy Claude budget by level. Baseline / unspecified preserves the prior +/// `(max_tokens / 2).clamp(1024, 32768)` behaviour so existing flows don't +/// regress when no level was selected. +fn anthropic_legacy_budget(level: Option, max_tokens: u32) -> u32 { + match level { + Some(ReasoningLevel::Low) => 8_192, + Some(ReasoningLevel::Medium) => 16_384, + Some(ReasoningLevel::High) => 24_576, + Some(ReasoningLevel::ExtraHigh) => 28_672, + Some(ReasoningLevel::Max) => 32_768, + _ => (max_tokens / 2).clamp(1024, 32_768), + } +} + +/// `max_tokens` floor so the legacy thinking budget can't starve the visible +/// answer. Only the legacy budget branch needs this — adaptive modes have no +/// caller-supplied budget to make room for. +pub fn anthropic_max_tokens_floor( + mode: ThinkingMode, + level: Option, + max_tokens: u32, +) -> u32 { + match mode { + ThinkingMode::AnthropicLegacyBudget => { + let budget = anthropic_legacy_budget(level, max_tokens); + max_tokens.max(budget + 1024) + } + _ => max_tokens, + } +} + +/// OpenAI `reasoning_effort` value. OpenAI's vocabulary tops out at `high`, +/// so extra_high/max are truncated. baseline/none → don't send (use the +/// model default, which avoids a 400 on non-reasoning variants). +pub fn openai_effort(level: Option) -> Option<&'static str> { + Some(match level? { + ReasoningLevel::Low => "low", + ReasoningLevel::Medium => "medium", + ReasoningLevel::High => "high", + ReasoningLevel::ExtraHigh | ReasoningLevel::Max => "high", + ReasoningLevel::Baseline | ReasoningLevel::None => return None, + }) +} + +/// Zhipu GLM `thinking` toggle. Budget is intentionally not mapped (unreliable +/// across GLM versions); we only flip thinking on/off. +pub fn zhipu_thinking(level: Option) -> Option { + match level { + Some(ReasoningLevel::None) => Some(json!({ "type": "disabled" })), + Some(ReasoningLevel::Baseline) | None => None, // model default (enabled) + Some(_) => Some(json!({ "type": "enabled" })), + } +} + +/// Resolved openai_compat thinking fields for one model id. This is the +/// shared assembly step used by both the streaming and non-streaming chat +/// paths (`openai_compat::streaming::chat` / `sse_stream`) so they cannot +/// drift on the strip + dispatch logic. +/// +/// At most one of `reasoning_effort` (OpenAI) and `thinking` (Zhipu GLM) is +/// set — never both, never for a non-reasoning model. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenAiCompatThinking { + /// Real model id providers accept (suffix stripped). + pub base_model: String, + pub reasoning_effort: Option, + pub thinking: Option, +} + +pub fn resolve_openai_compat_thinking( + resolved_model: &str, + provider_name: &str, +) -> OpenAiCompatThinking { + let parsed = parse_model_variant(resolved_model); + let mode = resolve_thinking_mode(&parsed.base_model, provider_name); + let reasoning_effort = if mode == ThinkingMode::OpenAiEffort { + openai_effort(parsed.level).map(str::to_string) + } else { + None + }; + let thinking = if mode == ThinkingMode::ZhipuToggle { + zhipu_thinking(parsed.level) + } else { + None + }; + OpenAiCompatThinking { + base_model: parsed.base_model, + reasoning_effort, + thinking, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::providers::registry::provider_id; + + // ── parse_model_variant ──────────────────────────────────────────────── + + #[test] + fn parses_glm_level_suffix() { + let p = parse_model_variant("glm-5.2-high"); + assert_eq!(p.base_model, "glm-5.2"); + assert_eq!(p.level, Some(ReasoningLevel::High)); + assert!(!p.thinking); + assert!(!p.fast); + } + + #[test] + fn parses_claude_thinking_xhigh() { + let p = parse_model_variant("claude-opus-4-7-thinking-xhigh"); + assert_eq!(p.base_model, "claude-opus-4-7"); + assert_eq!(p.level, Some(ReasoningLevel::ExtraHigh)); + assert!(p.thinking); + } + + #[test] + fn merges_extra_high_compound() { + let p = parse_model_variant("claude-opus-4-7-extra-high"); + assert_eq!(p.base_model, "claude-opus-4-7"); + assert_eq!(p.level, Some(ReasoningLevel::ExtraHigh)); + } + + #[test] + fn preserves_provider_native_suffixes() { + // `mini` / `flash` / date stamps are NOT variant tokens. + for id in &["gpt-5.5-mini", "gemini-2.0-flash", "claude-opus-4-20250514"] { + let p = parse_model_variant(id); + assert_eq!(p.base_model, *id, "base_model must not strip native suffix"); + assert!(p.level.is_none()); + } + } + + #[test] + fn bare_id_has_no_variant() { + let p = parse_model_variant("glm-5.2"); + assert_eq!(p.base_model, "glm-5.2"); + assert!(p.level.is_none()); + } + + // ── Claude version detection ─────────────────────────────────────────── + + #[test] + fn detects_opus_47_or_newer() { + for id in &[ + "claude-opus-4-7", + "claude-opus-4.7", + "claude-opus-4-8", + "claude-opus-5", + "claude-fable-5", + "anthropic.claude-opus-4-7-v1", + "claude-mythos", + ] { + assert!(is_claude_opus_47_or_newer(id), "{id} should be 4.7+"); + } + } + + #[test] + fn does_not_misclassify_date_stamped_as_47() { + // claude-opus-4-20250514 must NOT be read as 4.<20250514>. + assert!(!is_claude_opus_47_or_newer("claude-opus-4-20250514")); + assert!(!is_claude_opus_47_or_newer("claude-opus-4-6")); + assert!(!is_claude_opus_47_or_newer("claude-opus-4-5")); + } + + #[test] + fn detects_claude_46_series() { + assert!(is_claude_46_series("claude-opus-4-6")); + assert!(is_claude_46_series("claude-sonnet-4.6")); + assert!(!is_claude_46_series("claude-opus-4-7")); + assert!(!is_claude_46_series("claude-opus-4-5")); + } + + #[test] + fn opus_47_rejects_sampling() { + assert!(is_claude_rejects_sampling("claude-opus-4-8")); + assert!(!is_claude_rejects_sampling("claude-opus-4-6")); + } + + // ── resolve_thinking_mode ────────────────────────────────────────────── + + #[test] + fn classifies_modes() { + assert_eq!( + resolve_thinking_mode("claude-opus-4-8", provider_id::ANTHROPIC), + ThinkingMode::AnthropicAdaptive + ); + assert_eq!( + resolve_thinking_mode("claude-opus-4-6", provider_id::ANTHROPIC), + ThinkingMode::Anthropic46 + ); + assert_eq!( + resolve_thinking_mode("claude-opus-4-5", provider_id::ANTHROPIC), + ThinkingMode::AnthropicLegacyBudget + ); + assert_eq!( + resolve_thinking_mode("glm-5.2", provider_id::ZHIPU), + ThinkingMode::ZhipuToggle + ); + assert_eq!( + resolve_thinking_mode("gpt-5.5", provider_id::OPENAI), + ThinkingMode::OpenAiEffort + ); + } + + // ── Anthropic parameter mapping ──────────────────────────────────────── + + #[test] + fn adaptive_emits_summarized_display() { + let t = anthropic_thinking_param( + ThinkingMode::AnthropicAdaptive, + Some(ReasoningLevel::High), + 8192, + ) + .unwrap(); + assert_eq!(t["type"], "adaptive"); + assert_eq!(t["display"], "summarized"); + // adaptive must NEVER carry budget_tokens (would 400) + assert!(t.get("budget_tokens").is_none()); + assert_eq!( + anthropic_effort(ThinkingMode::AnthropicAdaptive, Some(ReasoningLevel::High)), + Some("high") + ); + assert_eq!( + anthropic_effort( + ThinkingMode::AnthropicAdaptive, + Some(ReasoningLevel::ExtraHigh) + ), + Some("xhigh") + ); + } + + #[test] + fn claude46_maps_extra_high_to_max() { + assert_eq!( + anthropic_effort(ThinkingMode::Anthropic46, Some(ReasoningLevel::ExtraHigh)), + Some("max") + ); + let t = anthropic_thinking_param( + ThinkingMode::Anthropic46, + Some(ReasoningLevel::Medium), + 8192, + ) + .unwrap(); + assert_eq!(t["type"], "adaptive"); + assert!(t.get("display").is_none()); + } + + #[test] + fn legacy_budget_respects_level_and_baseline_default() { + let t = anthropic_thinking_param( + ThinkingMode::AnthropicLegacyBudget, + Some(ReasoningLevel::High), + 8192, + ) + .unwrap(); + assert_eq!(t["type"], "enabled"); + assert_eq!(t["budget_tokens"], 24_576); + + // Baseline / unspecified preserves prior max_tokens/2 behaviour. + let t = anthropic_thinking_param(ThinkingMode::AnthropicLegacyBudget, None, 8192).unwrap(); + assert_eq!(t["budget_tokens"], 4096); + } + + #[test] + fn none_level_disables_thinking() { + for mode in [ + ThinkingMode::AnthropicAdaptive, + ThinkingMode::Anthropic46, + ThinkingMode::AnthropicLegacyBudget, + ] { + let t = anthropic_thinking_param(mode, Some(ReasoningLevel::None), 8192).unwrap(); + assert_eq!(t["type"], "disabled", "{mode:?} none → disabled"); + } + } + + // ── OpenAI / Zhipu ────────────────────────────────────────────────────── + + #[test] + fn openai_effort_truncates_extra_high() { + assert_eq!(openai_effort(Some(ReasoningLevel::ExtraHigh)), Some("high")); + assert_eq!(openai_effort(Some(ReasoningLevel::High)), Some("high")); + assert_eq!(openai_effort(Some(ReasoningLevel::Baseline)), None); + } + + #[test] + fn zhipu_only_toggles_no_budget() { + assert_eq!( + zhipu_thinking(Some(ReasoningLevel::High)).unwrap(), + json!({"type":"enabled"}) + ); + assert_eq!( + zhipu_thinking(Some(ReasoningLevel::None)).unwrap(), + json!({"type":"disabled"}) + ); + // baseline / no selection → don't touch (model default) + assert!(zhipu_thinking(Some(ReasoningLevel::Baseline)).is_none()); + assert!(zhipu_thinking(None).is_none()); + } + + // ── openai_compat assembly: model id → request fields ────────────────── + + #[test] + fn openai_compat_glm_emits_thinking_toggle_not_effort() { + let r = resolve_openai_compat_thinking("glm-5.2-high", provider_id::ZHIPU); + assert_eq!(r.base_model, "glm-5.2"); + assert!(r.reasoning_effort.is_none()); + assert_eq!(r.thinking.unwrap(), json!({ "type": "enabled" })); + } + + #[test] + fn openai_compat_openai_emits_reasoning_effort_not_thinking() { + let r = resolve_openai_compat_thinking("gpt-5.5-high", provider_id::OPENAI); + assert_eq!(r.base_model, "gpt-5.5"); + assert_eq!(r.reasoning_effort.as_deref(), Some("high")); + assert!(r.thinking.is_none()); + } + + #[test] + fn openai_compat_default_emits_neither() { + // No level suffix → don't touch; model uses its provider default. + let r = resolve_openai_compat_thinking("glm-5.2", provider_id::ZHIPU); + assert_eq!(r.base_model, "glm-5.2"); + assert!(r.reasoning_effort.is_none()); + assert!(r.thinking.is_none()); + } + + #[test] + fn openai_compat_preserves_native_suffix() { + // `mini` is a provider-native suffix, not a level token → not stripped. + let r = resolve_openai_compat_thinking("gpt-5.5-mini", provider_id::OPENAI); + assert_eq!(r.base_model, "gpt-5.5-mini"); + assert!(r.reasoning_effort.is_none()); + assert!(r.thinking.is_none()); + } + + // ── Claude version detection: aliases, separators, date stamps ───────── + + #[test] + fn detects_opus_47_across_aliases_separators_and_dates() { + // Version separators: `-` and `.`. + for id in &["claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-8"] { + assert!(is_claude_opus_47_or_newer(id), "{id} should be 4.7+"); + } + // Provider alias prefixes / trailing version tags (Bedrock, Vertex). + assert!(is_claude_opus_47_or_newer("anthropic.claude-opus-4-7-v1")); + assert!(is_claude_opus_47_or_newer("claude-opus-4-7@001")); + // 4.7 with a date stamp is still 4.7 (adaptive). + assert!(is_claude_opus_47_or_newer("claude-opus-4-7-20250514")); + // 4 base with a date stamp must NOT be read as 4.. + assert!(!is_claude_opus_47_or_newer("claude-opus-4-20250514")); + } +} From 09c9e43e7828d2e10ec0f7a1ee164e72bec1313e Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sun, 28 Jun 2026 21:43:09 +0800 Subject: [PATCH 049/864] feat(ui): render imported context tool cards --- .../blocks/ToolCallBlock/OutputContent.tsx | 4 ++ .../ToolCallBlock/cards/ContextImportCard.tsx | 55 +++++++++++++++++++ .../blocks/ToolCallBlock/cards/index.ts | 1 + .../ChatPanel/blocks/ToolCallBlock/helpers.ts | 1 + .../ToolCallBlock/helpers/cardParsers.ts | 47 ++++++++++++++++ .../blocks/ToolCallBlock/helpers/index.ts | 1 + .../ChatPanel/blocks/ToolCallBlock/index.tsx | 5 ++ .../ChatPanel/blocks/ToolCallBlock/types.ts | 11 ++++ 8 files changed, 125 insertions(+) create mode 100644 src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx index f6520f1c23..a37618f3e3 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx @@ -26,6 +26,7 @@ import { import { AgentMessageCard, CommandResultCard, + ContextImportCard, FileCard, ProjectCard, WebsiteCard, @@ -332,6 +333,9 @@ const OutputContent: React.FC = ({ {styledOutput?.type === "projectCard" && ( )} + {styledOutput?.type === "contextImportCard" && ( + + )} {styledOutput?.type === "commandResult" && ( )} diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx new file mode 100644 index 0000000000..98b701ab86 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx @@ -0,0 +1,55 @@ +import { Link2, Pin } from "lucide-react"; +import React from "react"; + +import type { ContextImportCardData } from "../types"; +import { ToolResultCardFrame } from "./ToolResultCardFrame"; + +interface ContextImportCardProps { + card: ContextImportCardData; +} + +const ContextImportCard: React.FC = ({ card }) => { + const title = card.title || card.sourceId; + return ( + +
+ + + +
+
+ + {title} + + {card.pinned && } +
+
+ + {card.namespace} + + · + {card.sourceKind.replace(/_/g, " ")} + {card.tokenEstimate !== undefined && ( + <> + · + {card.tokenEstimate} tokens est. + + )} + {card.snapshotId && ( + <> + · + + {card.snapshotId.slice(0, 8)} + + + )} +
+
+
+
+ ); +}; + +ContextImportCard.displayName = "ContextImportCard"; + +export default ContextImportCard; diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts index b015f25fea..6eb91bf43a 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts @@ -3,6 +3,7 @@ export { default as CommandResultCard } from "./CommandResultCard"; export { default as FileCard } from "./FileCard"; export { default as SessionLinkCard } from "./SessionLinkCard"; export type { SessionLinkCardData } from "./SessionLinkCard"; +export { default as ContextImportCard } from "./ContextImportCard"; export { default as ProjectCard } from "./ProjectCard"; export { TaskListCard, default as TaskUpdateCard } from "./TaskUpdateCard"; export { default as WebsiteCard } from "./WebsiteCard"; diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts index 3948dc44e9..f9919e90eb 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts @@ -37,6 +37,7 @@ export { export { parseAgentMessageCard, parseCommandResult, + parseContextImportCardResult, parseFileCardResult, parseProjectCardResult, parseWebsiteCardResult, diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts index a67a5c7cd8..2a1c637329 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts @@ -9,6 +9,7 @@ import type { AgentMessageDeliveryRow, CommandArtifact, CommandResultData, + ContextImportCardData, FileCardData, ProjectCardData, WebsiteCardData, @@ -176,6 +177,52 @@ export function parseWorkItemCardResult( }; } +export function parseContextImportCardResult( + args: Record, + result: Record +): ContextImportCardData | null { + const sourceKind = + (typeof result.source_kind === "string" ? result.source_kind : null) ?? + (typeof args.source_kind === "string" ? args.source_kind : null); + const sourceId = + (typeof result.source_id === "string" ? result.source_id : null) ?? + (typeof args.source_id === "string" ? args.source_id : null); + if (!sourceKind || !sourceId) return null; + + const snapshotId = + (typeof result.snapshot_id === "string" ? result.snapshot_id : null) ?? + (typeof result.snapshotId === "string" ? result.snapshotId : null) ?? + undefined; + const namespace = + (typeof result.namespace === "string" ? result.namespace : null) ?? + `${sourceKind}:${sourceId}`; + const title = + (typeof result.title === "string" ? result.title : null) ?? + (typeof args.title === "string" ? args.title : null) ?? + undefined; + const rawTokenEstimate = result.token_estimate ?? result.tokenEstimate ?? args.token_estimate; + const tokenEstimate = + typeof rawTokenEstimate === "number" && Number.isFinite(rawTokenEstimate) + ? Math.max(0, Math.floor(rawTokenEstimate)) + : undefined; + const pinned = + typeof result.pinned === "boolean" + ? result.pinned + : typeof args.pinned === "boolean" + ? args.pinned + : undefined; + + return { + snapshotId, + sourceKind, + sourceId, + namespace, + title, + tokenEstimate, + pinned, + }; +} + export function parseProjectCardResult( args: Record, result: Record diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts index 16f533e678..eb8b0cf8db 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts @@ -21,6 +21,7 @@ export { export { parseAgentMessageCard, parseCommandResult, + parseContextImportCardResult, parseFileCardResult, parseProjectCardResult, parseWebsiteCardResult, diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx index 7761801e8a..ecb0e11e67 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx @@ -48,6 +48,7 @@ import { parseAgentMessageCard, parseAwaitListingResult, parseCommandResult, + parseContextImportCardResult, parseFileCardResult, parseManageLspResult, parseManageWorkspaceResult, @@ -231,6 +232,10 @@ const ToolCallBlock: React.FC = React.memo( const card = parseProjectCardResult(args, result); if (card) return { type: "projectCard" as const, card }; } + if (toolName === "import_context" && hasResult) { + const card = parseContextImportCardResult(args, result); + if (card) return { type: "contextImportCard" as const, card }; + } if (toolName === "write_file" && hasResult) { const card = parseFileCardResult(args, result); if (card) return { type: "fileCard" as const, card }; diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts index 80a75d79d2..d6d74b1439 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts @@ -189,6 +189,16 @@ export interface AgentMessageDeliveryRow { inboxId?: number; } +export interface ContextImportCardData { + snapshotId?: string; + sourceKind: string; + sourceId: string; + namespace: string; + title?: string; + tokenEstimate?: number; + pinned?: boolean; +} + export interface AgentMessageCardData { sender: string; recipient: string; @@ -237,6 +247,7 @@ export type StyledOutput = | { type: "websiteCard"; card: WebsiteCardData } | { type: "workItemCard"; card: WorkItemCardData } | { type: "projectCard"; card: ProjectCardData } + | { type: "contextImportCard"; card: ContextImportCardData } | { type: "commandResult"; card: CommandResultData } | { type: "agentMessageCard"; card: AgentMessageCardData }; From 883a1e5fa20d2f305cda921d94942bd8b88da6a3 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:02:53 +0800 Subject: [PATCH 050/864] feat(agent-usage): expand token usage badges across chat events Persist turn identifiers on assistant message and thinking events so frontend LLM usage spans can be attributed back to the visible conversation turn. Add frontend LLM usage metadata plumbing alongside existing tool usage metadata, including cache hydration, event args injection, prop normalization, memoization keys, and tests for applying usage to assistant/thinking events without double-counting tool-attributed spans. Render compact token usage badges with input/output icons and cache-aware tooltips, controlled by a persisted Show token toggle that defaults to hidden. Place agent message usage below the message body for better visibility. Extend badge coverage beyond the generic ToolCallBlock path to specialized chat renderers such as shell/terminal, read/search/glob/list-dir/explore, web search, subagents, title-only rows, todos, setup repo, code map and agent definition management, worktree lists, org tasks, diff/edit rows, and plan cards. Update all sessions locale files with the Show token label. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../core/session/turn/event_handler/mod.rs | 22 +++- .../ChatPanel/ChatHistory/ActivityRouter.tsx | 20 ++- .../components/ChatHistoryList.tsx | 6 +- .../renderers/GroupItemRenderer.tsx | 2 + src/engines/ChatPanel/ChatPanelHeader.tsx | 16 +++ .../blocks/AgentMessageBlock/index.tsx | 8 ++ .../ChatPanel/blocks/CreatePlanCard/index.tsx | 13 +- .../ChatPanel/blocks/DiffBlock/index.tsx | 32 ++++- .../ChatPanel/blocks/ExploreBlock/index.tsx | 7 ++ .../ChatPanel/blocks/GlobBlock/index.tsx | 8 +- .../ChatPanel/blocks/ListDirBlock/index.tsx | 8 +- .../blocks/ManageAgentDefBlock/index.tsx | 7 ++ .../blocks/ManageCodeMapBlock/index.tsx | 11 +- .../ChatPanel/blocks/OrgTaskBlock/index.tsx | 7 ++ .../ChatPanel/blocks/ReadFileBlock/index.tsx | 6 + .../ChatPanel/blocks/SearchBlock/index.tsx | 8 +- .../ChatPanel/blocks/SetupRepoBlock/index.tsx | 7 ++ .../ChatPanel/blocks/ShellBlock/index.tsx | 14 ++- .../ChatPanel/blocks/SubagentBlock/index.tsx | 49 ++++---- .../ChatPanel/blocks/TerminalBlock/index.tsx | 12 +- .../ChatPanel/blocks/TitleOnlyBlock/index.tsx | 17 ++- .../ChatPanel/blocks/TodoBlock/index.tsx | 18 ++- .../blocks/ToolCallBlock/LlmUsageBadge.tsx | 34 ++++++ .../blocks/ToolCallBlock/ToolUsageBadge.tsx | 67 +++++++--- .../ChatPanel/blocks/WebSearchBlock/index.tsx | 7 ++ .../blocks/WorktreeListBlock/index.tsx | 7 ++ .../events/stream/agent-message/index.tsx | 16 ++- .../events/stream/thinking/index.tsx | 10 +- src/engines/ChatPanel/index.tsx | 13 ++ .../rendering/adapters/ExploreAdapter.tsx | 2 + .../rendering/adapters/FallbackAdapter.tsx | 3 + .../rendering/adapters/GlobAdapter.tsx | 1 + .../rendering/adapters/OrgTaskAdapter.tsx | 1 + .../rendering/adapters/PlanDocAdapter.tsx | 1 + .../rendering/adapters/SearchAdapter.tsx | 1 + .../rendering/adapters/SetupRepoAdapter.tsx | 1 + .../rendering/adapters/SubagentAdapter.tsx | 1 + .../rendering/adapters/TitleOnlyAdapter.tsx | 1 + .../rendering/adapters/TodoAdapter.tsx | 1 + .../rendering/adapters/WebSearchAdapter.tsx | 1 + src/engines/SessionCore/core/types.ts | 13 ++ .../rendering/props/propsNormalizer.ts | 15 +++ .../rendering/types/universalProps.ts | 3 + .../sync/adapters/createRustAgentAdapter.ts | 40 +++--- .../__tests__/toolUsageCache.test.ts | 62 +++++++++- .../sync/adapters/rustAgent/toolUsageCache.ts | 115 +++++++++++++++++- src/i18n/locales/de/sessions.json | 1 + src/i18n/locales/en/sessions.json | 1 + src/i18n/locales/es/sessions.json | 1 + src/i18n/locales/fr/sessions.json | 1 + src/i18n/locales/ja/sessions.json | 1 + src/i18n/locales/ko/sessions.json | 1 + src/i18n/locales/pl/sessions.json | 1 + src/i18n/locales/pt/sessions.json | 1 + src/i18n/locales/ru/sessions.json | 1 + src/i18n/locales/tr/sessions.json | 1 + src/i18n/locales/vi/sessions.json | 1 + src/i18n/locales/zh-Hant/sessions.json | 1 + src/i18n/locales/zh/sessions.json | 1 + src/store/ui/chatPanelAtom.ts | 8 ++ 60 files changed, 642 insertions(+), 93 deletions(-) create mode 100644 src/engines/ChatPanel/blocks/ToolCallBlock/LlmUsageBadge.tsx diff --git a/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs index 630996fa20..b3d03902b9 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/event_handler/mod.rs @@ -82,6 +82,15 @@ fn should_push_assistant_event( !has_tool_calls || !consumed_streamed_message } +fn attach_turn_id(event: &mut SessionEvent, turn_id: Option<&str>) { + let Some(turn_id) = turn_id else { + return; + }; + if let Some(args) = event.args.as_object_mut() { + args.insert("turnId".to_string(), serde_json::json!(turn_id)); + } +} + /// Configuration for the unified event handler. #[derive(Clone, Default)] pub struct EventHandlerConfig { @@ -189,7 +198,8 @@ impl UnifiedEventHandler { // event than to the message. SQLite orders by // `COALESCE(history_sequence, 0) ASC, created_at ASC`, so reversing // this order would render Thought *after* the answer on reload. - if let Some(event) = self.streaming_buffer.complete_thinking(session_id) { + if let Some(mut event) = self.streaming_buffer.complete_thinking(session_id) { + attach_turn_id(&mut event, self.config.turn_id.as_deref()); self.push_to_store(session_id, event.clone()); broadcast_event( "agent:streaming_complete", @@ -201,7 +211,8 @@ impl UnifiedEventHandler { }), ); } - if let Some(event) = self.streaming_buffer.complete_message(session_id) { + if let Some(mut event) = self.streaming_buffer.complete_message(session_id) { + attach_turn_id(&mut event, self.config.turn_id.as_deref()); if let Ok(mut sessions) = self.flushed_message_sessions.lock() { sessions.insert(session_id.to_string()); } @@ -667,10 +678,9 @@ impl TurnEventHandler for UnifiedEventHandler { has_active_message_stream, consumed_streamed_message, ) { - self.push_to_store( - session_id, - event_factory::build_assistant_message_event(session_id, text), - ); + let mut event = event_factory::build_assistant_message_event(session_id, text); + attach_turn_id(&mut event, self.config.turn_id.as_deref()); + self.push_to_store(session_id, event); } } diff --git a/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx b/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx index 3bb76f4f14..46130de303 100644 --- a/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx +++ b/src/engines/ChatPanel/ChatHistory/ActivityRouter.tsx @@ -9,7 +9,10 @@ import React, { Suspense, memo, useMemo } from "react"; import { AgentMessageBlock } from "@src/engines/ChatPanel/blocks"; import MessageReferenceCards from "@src/engines/ChatPanel/blocks/MessageReferenceCards"; +import LlmUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/LlmUsageBadge"; import { + LLM_USAGE_ARGS_KEY, + type LlmUsageMetadata, type SessionEvent, TOOL_USAGE_ARGS_KEY, } from "@src/engines/SessionCore/core/types"; @@ -129,6 +132,9 @@ function arePropsEqual( if (prevArgs?.[TOOL_USAGE_ARGS_KEY] !== nextArgs?.[TOOL_USAGE_ARGS_KEY]) { return false; } + if (prevArgs?.[LLM_USAGE_ARGS_KEY] !== nextArgs?.[LLM_USAGE_ARGS_KEY]) { + return false; + } return true; } @@ -152,6 +158,13 @@ const ActivityLoadingFallback: React.FC = () => ( */ const DEDICATED_NON_MESSAGE_CANONICALS = new Set(["rate_limit_hint"]); +function readLlmUsage(event: SessionEvent): LlmUsageMetadata | undefined { + if (event.llmUsage) return event.llmUsage; + const raw = event.args?.[LLM_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as LlmUsageMetadata; +} + function isSyntheticLiveAssistantEvent(event: SessionEvent): boolean { return event.args?.syntheticLive === true; } @@ -265,8 +278,13 @@ const ActivityChatItem: React.FC = memo( ) { const assistantContent = extractAssistantMessageContent(event); if (assistantContent) { + const llmUsage = readLlmUsage(event); return ( - + : undefined + } + > void; handleReloadFromMenu: () => void; handleToggleAllBlocksCollapsed: () => void; + handleTokenUsageVisibleToggle: (checked: boolean) => void; handleWorkItemAgentCreatorToggle: (enabled: boolean) => void; handleWorkItemTitleChange: (title: string) => void; headerActionsDropdownRef: React.RefObject; @@ -99,6 +100,7 @@ interface ChatPanelHeaderProps { isHeaderActionsPositioned: boolean; isProjectTarget: boolean; paginationEnabled: boolean; + tokenUsageVisible: boolean; showStartPageBackButton: boolean; selectedProjectVisible: boolean; selectedWorkItemVisible: boolean; @@ -147,6 +149,7 @@ export function ChatPanelHeader({ handleProjectTitleChange, handleReloadFromMenu, handleToggleAllBlocksCollapsed, + handleTokenUsageVisibleToggle, handleWorkItemAgentCreatorToggle, handleWorkItemTitleChange, headerActionsDropdownRef, @@ -160,6 +163,7 @@ export function ChatPanelHeader({ isHeaderActionsPositioned, isProjectTarget, paginationEnabled, + tokenUsageVisible, showStartPageBackButton, selectedProjectVisible, selectedWorkItemVisible, @@ -420,6 +424,18 @@ export function ChatPanelHeader({
+
+ + {t("chat.showTokenUsage")} + + +
diff --git a/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx b/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx index ee61bef0c3..4a6237aa9b 100644 --- a/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx @@ -60,11 +60,13 @@ export interface AgentMessageBlockProps { * event. Omitted for synthetic preview rendering where no event exists. */ eventId?: string; + rightContent?: React.ReactNode; } const AgentMessageBlock: React.FC = ({ children, eventId, + rightContent, }) => { const { t } = useTranslation("common"); const clampEligible = useContext(AgentMessageClampContext); @@ -117,6 +119,9 @@ const AgentMessageBlock: React.FC = ({ return (
{children} + {rightContent && ( +
{rightContent}
+ )}
); } @@ -157,6 +162,9 @@ const AgentMessageBlock: React.FC = ({ /> )}
+ {rightContent && ( +
{rightContent}
+ )} {showLocateArrow && (
void; + toolUsage?: ToolUsageMetadata; } const CreatePlanCard: React.FC = memo( @@ -130,6 +133,7 @@ const CreatePlanCard: React.FC = memo( onOpenPreview, collapsed = false, onCollapse, + toolUsage, }) => { const { t } = useTranslation("sessions"); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -391,6 +395,13 @@ const CreatePlanCard: React.FC = memo( title={t("planDoc.collapse")} /> ) : null; + const headerRight = + toolUsage || collapseButton ? ( +
+ {toolUsage && } + {collapseButton} +
+ ) : undefined; const planActions = ownsActions ? (
= memo( onNavigate={handlePreviewNavigate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} - rightContent={collapseButton} + rightContent={headerRight} > = ({ @@ -218,6 +220,7 @@ const CompactSegmentView: React.FC = ({ status, isLoading, isNewFile, + toolUsage, }) => { const { t } = useTranslation("sessions"); const displayTitle = @@ -260,6 +263,9 @@ const CompactSegmentView: React.FC = ({ onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = (props) => { }); const isLoading = props.showActiveEventPainting === true; return ( - + + ) : undefined + } + > {title} @@ -424,6 +438,11 @@ const EditView: React.FC = (props) => { onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + props.toolUsage ? ( + + ) : undefined + } > = (props) => { if (status === "running" && !hasStreamingContent(segments)) { return ( - + + ) : undefined + } + > {title} @@ -474,6 +501,7 @@ const EditView: React.FC = (props) => { status={status} isLoading={isLoading} isNewFile={isNewFile} + toolUsage={segmentIndex === 0 ? props.toolUsage : undefined} /> ))}
diff --git a/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx b/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx index 9ab42cdd26..99b6aec19f 100644 --- a/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx @@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next"; import FileTypeIcon from "@src/components/FileTypeIcon"; import { getToolIcon } from "@src/config/toolIcons"; import ChatCodeBlock from "@src/engines/ChatPanel/blocks/CodeBlock"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { ComposerStackListRow, EVENT_BLOCK_TRANSPARENT_EXPANDED_SHELL_CLASSES, @@ -75,6 +77,7 @@ export interface ExploreBlockProps { toolName?: string; /** Action-level hint for icon selection (e.g. `"ls"` vs `"tree"`). */ action?: string; + toolUsage?: ToolUsageMetadata; } const TreeConnector: React.FC<{ isLast: boolean }> = ({ isLast }) => ( @@ -145,6 +148,7 @@ const ExploreBlock: React.FC = React.memo( toolName = "list_dir", action, hideEntryIcons = false, + toolUsage, }) => { void isFailed; const { t } = useTranslation("sessions"); @@ -374,6 +378,9 @@ const ExploreBlock: React.FC = React.memo( onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} className={eventId ? "cursor-pointer" : undefined} + rightContent={ + toolUsage ? : undefined + } > = React.memo( - ({ pattern, isLoading = false, eventId, title }) => { + ({ pattern, isLoading = false, eventId, title, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -55,6 +58,9 @@ const GlobBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = React.memo( - ({ dirPath, isLoading = false, eventId, title, targetPath }) => { + ({ dirPath, isLoading = false, eventId, title, targetPath, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -67,6 +70,9 @@ const ListDirBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ isLoading = false, eventId, title, + toolUsage, }) => { const hasBody = Boolean(agentName || resultText); @@ -171,6 +175,9 @@ const ManageAgentDefBlock: React.FC = ({ onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = memo( eventId, sessionId, payloadRefs, + toolUsage, }) => { const rows = useMemo( () => buildRows(action, args, result), @@ -146,6 +152,9 @@ const ManageCodeMapBlock: React.FC = memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ timestamp, hideHeader = false, groupSenderName = null, + toolUsage, }) => { const { t } = useTranslation("sessions"); const yesterdayLabel = t("common:relativeDate.yesterday", { @@ -414,6 +418,9 @@ const OrgTaskBlock: React.FC = ({ onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = (props) => { onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + props.toolUsage ? ( + + ) : undefined + } > = React.memo( - ({ pattern, isLoading = false, eventId, action, title }) => { + ({ pattern, isLoading = false, eventId, action, title, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -63,6 +66,9 @@ const SearchBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = memo( lifecycleLabel, isRunning = false, isFailed = false, + toolUsage, }) => { const hasContent = !!message || @@ -124,6 +128,9 @@ const SetupRepoBlock: React.FC = memo( onClick={hasContent ? handleHeaderClick : undefined} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ @@ -101,6 +104,7 @@ const KillVariant: React.FC = ({ isLoading, resultMessage, title, + toolUsage, }) => { const toolIcon = getToolIcon("run_shell", { size: 14, @@ -109,7 +113,13 @@ const KillVariant: React.FC = ({ return (
- + : undefined + } + > = (props) => { isLoading={isLoading} resultMessage={resultMessage} title={props.killTitle} + toolUsage={props.toolUsage} /> ); } @@ -261,6 +272,7 @@ const RunShellView: React.FC = (props) => { pid={shellPid} processStatus={shellProcessStatus} onStop={handleStop} + toolUsage={props.toolUsage} /> ); }; diff --git a/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx b/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx index 537fe54c88..d2f238fafa 100644 --- a/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx @@ -16,8 +16,10 @@ import { Infinity, Square } from "lucide-react"; import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { EVENT_BLOCK_ICON_WRAPPER_CLASSES, EVENT_LOADING_SHIMMER_TEXT_CLASSES, @@ -54,6 +56,7 @@ export interface SubagentBlockProps { /** Called when the user clicks the navigate icon — locates the subagent * cell in the right-side monitor panel. */ onNavigate?: () => void; + toolUsage?: ToolUsageMetadata; } // ============================================ @@ -73,6 +76,7 @@ const SubagentBlock: React.FC = memo( success, errorMessage, onNavigate, + toolUsage, }) => { const { t } = useTranslation("sessions"); const { t: tCommon } = useTranslation(); @@ -137,28 +141,29 @@ const SubagentBlock: React.FC = memo( if (timingLabel && !isLoading) subtitleParts.push(timingLabel); const subtitle = subtitleParts.join(" · "); - // ── Header right: stop button ── - const headerRight = ( -
- {canStop && ( - - )} -
- ); + const headerRight = + toolUsage || canStop ? ( +
+ {toolUsage && } + {canStop && ( + + )} +
+ ) : undefined; const displayTitle = t("tools.assignedTaskToSubagent"); const hasBody = diff --git a/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx b/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx index dc1c5d467f..1e03cb2dfd 100644 --- a/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx @@ -18,8 +18,12 @@ import { useTranslation } from "react-i18next"; import ExpandOverlay from "@src/components/ExpandOverlay"; import { getToolIcon } from "@src/config/toolIcons"; -import type { PayloadRef } from "@src/engines/SessionCore/core/types"; +import type { + PayloadRef, + ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { BlockOutput, EVENT_BLOCK_FADE_FROM, @@ -66,6 +70,8 @@ export interface TerminalBlockProps { processStatus?: "running" | "background" | "exited" | "killed"; /** Callback when user clicks Stop */ onStop?: (pid: number) => void; + /** Token/context attribution metadata for this shell call. */ + toolUsage?: ToolUsageMetadata; } const TerminalBlock: React.FC = memo( @@ -88,6 +94,7 @@ const TerminalBlock: React.FC = memo( pid, processStatus, onStop, + toolUsage, }) => { const isErrorExit = exitCode !== undefined && exitCode !== 0; const isBackground = processStatus === "background"; @@ -205,8 +212,9 @@ const TerminalBlock: React.FC = memo( const hasContent = Boolean(command || displayOutput); const headerRight = - statusLabel || canStop ? ( + toolUsage || statusLabel || canStop ? (
+ {toolUsage && } {statusLabel} {canStop && (
); @@ -359,6 +360,7 @@ export const ExploreAdapter: React.FC = (props) => { title={title} toolName={props.eventType} action={exploreAction} + toolUsage={props.toolUsage} // `query_lsp` is routed through CbExplore but its "entries" are // diagnostic text lines (e.g. `L43:36 [hint] ...`), not real files. // Suppress the leading file-type icon so rows render as plain text. diff --git a/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx index 38bc1e30fe..ae627ddcfa 100644 --- a/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/FallbackAdapter.tsx @@ -142,6 +142,7 @@ export const FallbackAdapter: React.FC = (props) => { entries={entries} eventId={props.eventId} title={worktreeLabels[state]} + toolUsage={props.toolUsage} /> ); } @@ -157,6 +158,7 @@ export const FallbackAdapter: React.FC = (props) => { props.status === "running" && props.showActiveEventPainting === true } eventId={props.eventId} + toolUsage={props.toolUsage} /> ); } @@ -175,6 +177,7 @@ export const FallbackAdapter: React.FC = (props) => { eventId={props.eventId} sessionId={props.sessionId} payloadRefs={props.payloadRefs} + toolUsage={props.toolUsage} /> ); } diff --git a/src/engines/ChatPanel/rendering/adapters/GlobAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/GlobAdapter.tsx index a931281067..225be431bb 100644 --- a/src/engines/ChatPanel/rendering/adapters/GlobAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/GlobAdapter.tsx @@ -52,6 +52,7 @@ export const GlobAdapter: React.FC = (props) => { isLoading={isLoading} eventId={props.eventId} title={title} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx index 0814ef754c..c538ed8acc 100644 --- a/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx @@ -146,6 +146,7 @@ export const OrgTaskAdapter: React.FC = (props) => { timestamp={props.timestamp} hideHeader={isSimulator} groupSenderName={groupSenderName} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx index c8288b00df..1a8e26010a 100644 --- a/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx @@ -99,6 +99,7 @@ export const PlanDocAdapter: React.FC = (props) => { approvalStatus={surfaceState?.status ?? approvalStatus} ownsPendingPlan={surfaceState?.ownsActions ?? false} surfaceState={surfaceState} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx index 3ec8ddf18f..bce4d4a6be 100644 --- a/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx @@ -39,6 +39,7 @@ export const SearchAdapter: React.FC = (props) => { eventId={props.eventId} action={searchAction} title={title} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx index b0bd20c7a1..2339bf5bea 100644 --- a/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx @@ -60,6 +60,7 @@ export const SetupRepoAdapter: React.FC = (props) => { props.status === "running" && props.showActiveEventPainting === true } isFailed={props.status === "failed"} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx index 6353020f30..69a240be57 100644 --- a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx @@ -183,6 +183,7 @@ export const SubagentAdapter: React.FC = (props) => { errorMessage={data.errorMessage} eventId={props.eventId} onNavigate={data.subagentSessionId ? handleNavigate : undefined} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx index 48c1830c6c..71e206d3af 100644 --- a/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx @@ -265,6 +265,7 @@ export const TitleOnlyAdapter: React.FC = (props) => { } isFailed={state === "failed"} eventId={props.eventId} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx index 1d38f00181..62021169f6 100644 --- a/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx @@ -32,6 +32,7 @@ export const TodoAdapter: React.FC = (props) => { props.status === "running" && props.showActiveEventPainting === true } title={labels[state]} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx index 750cfce03c..4645d4af93 100644 --- a/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx @@ -64,6 +64,7 @@ export const WebSearchAdapter: React.FC = (props) => { defaultCollapsed={true} eventId={props.eventId} title={title} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/SessionCore/core/types.ts b/src/engines/SessionCore/core/types.ts index d23a04bbb8..89e6f8d027 100644 --- a/src/engines/SessionCore/core/types.ts +++ b/src/engines/SessionCore/core/types.ts @@ -112,6 +112,16 @@ export interface SimulatorEventPreview { } export const TOOL_USAGE_ARGS_KEY = "__orgiiToolUsage"; +export const LLM_USAGE_ARGS_KEY = "__orgiiLlmUsage"; + +export interface LlmUsageMetadata { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + model?: string | null; + attributionMethod: string; +} export interface ToolUsageMetadata { decisionCompletionTokens: number; @@ -195,6 +205,9 @@ export interface SessionEvent { /** Token/context attribution metadata for this tool call. */ toolUsage?: ToolUsageMetadata; + /** Token usage metadata for the LLM span represented by this event. */ + llmUsage?: LlmUsageMetadata; + /** File path for file operations */ filePath?: string; diff --git a/src/engines/SessionCore/rendering/props/propsNormalizer.ts b/src/engines/SessionCore/rendering/props/propsNormalizer.ts index 1c01fcb17c..569cd9e0df 100644 --- a/src/engines/SessionCore/rendering/props/propsNormalizer.ts +++ b/src/engines/SessionCore/rendering/props/propsNormalizer.ts @@ -18,6 +18,8 @@ import { useMemo } from "react"; import { + LLM_USAGE_ARGS_KEY, + type LlmUsageMetadata, type SessionEvent, TOOL_USAGE_ARGS_KEY, type ToolUsageMetadata, @@ -43,6 +45,16 @@ function readToolUsageMetadata( return raw as ToolUsageMetadata; } +function readLlmUsageMetadata( + args: Record, + eventLlmUsage?: LlmUsageMetadata +): LlmUsageMetadata | undefined { + if (eventLlmUsage) return eventLlmUsage; + const raw = args[LLM_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as LlmUsageMetadata; +} + function shouldShowActiveEventPainting( status: EventStatus, createdAt?: string @@ -216,12 +228,14 @@ export function normalizeEventProps( sessionEvent.displayStatus || inferStatusFromResult(result) ); const toolUsage = readToolUsageMetadata(args, sessionEvent.toolUsage); + const llmUsage = readLlmUsageMetadata(args, sessionEvent.llmUsage); return { eventId: sessionEvent.id, eventType, functionName: sessionEvent.functionName, callId: sessionEvent.callId, toolUsage, + llmUsage, filePath: sessionEvent.filePath, repoPath: sessionEvent.repoPath, sessionId: sessionEvent.sessionId, @@ -285,6 +299,7 @@ export function normalizeEventProps( (input as { repoPath?: string; repo_path?: string }).repo_path, args: normalized.args, result: normalized.result, + llmUsage: readLlmUsageMetadata(normalized.args), status, timestamp: normalized.createdAt, showActiveEventPainting: shouldShowActiveEventPainting( diff --git a/src/engines/SessionCore/rendering/types/universalProps.ts b/src/engines/SessionCore/rendering/types/universalProps.ts index cd32e2bd2e..6fe256fed1 100644 --- a/src/engines/SessionCore/rendering/types/universalProps.ts +++ b/src/engines/SessionCore/rendering/types/universalProps.ts @@ -10,6 +10,7 @@ */ import type { ExtractedData, + LlmUsageMetadata, PayloadRef, ToolUsageMetadata, } from "@src/engines/SessionCore/core/types"; @@ -91,6 +92,8 @@ export interface UniversalEventProps { callId?: string; /** Token/context attribution metadata for this tool call. */ toolUsage?: ToolUsageMetadata; + /** Token usage metadata for the LLM span represented by this event. */ + llmUsage?: LlmUsageMetadata; /** File path for file operations, when emitted as top-level event metadata. */ filePath?: string; /** Repository filesystem path active when this event was emitted. */ diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts index 9b890d32e9..f52adb61de 100644 --- a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts @@ -54,9 +54,9 @@ import { resetAllStreamingState, } from "./rustAgent/eventHandlers/streamHelpers"; import { + applyLlmUsageToEvents, applyToolUsageToEvents, - loadAndCacheToolUsage, - withToolUsageArgs, + loadUsageTelemetry, } from "./rustAgent/toolUsageCache"; import type { AgentTokenUsage, @@ -134,25 +134,21 @@ function toTokenUsageInfo(usage: AgentTokenUsage): AgentTokenUsageInfo { }; } -async function refreshToolUsageForLatestEvents( - sessionId: string -): Promise { - const usageByCallId = await loadAndCacheToolUsage(sessionId); - if (usageByCallId.size === 0) return; +async function refreshUsageForLatestEvents(sessionId: string): Promise { + const { toolUsageByCallId, llmUsageByTurnId } = + await loadUsageTelemetry(sessionId); + if (toolUsageByCallId.size === 0 && llmUsageByTurnId.size === 0) return; const snapshot = eventStoreProxy.getLatestSessionSnapshot(sessionId); const events = snapshot?.chatEvents ?? []; - const updates = events.flatMap((event) => { - const toolUsage = event.callId - ? (usageByCallId.get(event.callId) ?? usageByCallId.get(event.id)) - : usageByCallId.get(event.id); - if (!toolUsage) return []; + const hydratedEvents = applyLlmUsageToEvents( + applyToolUsageToEvents(events, toolUsageByCallId), + llmUsageByTurnId + ); + const updates = hydratedEvents.flatMap((event, index) => { + if (event.args === events[index]?.args) return []; return [ - eventStoreProxy.updateById( - event.id, - { args: withToolUsageArgs(event.args, toolUsage) }, - sessionId - ), + eventStoreProxy.updateById(event.id, { args: event.args }, sessionId), ]; }); await Promise.all(updates); @@ -199,9 +195,13 @@ export function createRustAgentAdapter( const merged = await mergeToolResults(events); if (signal.aborted) return merged; - const usageByCallId = await loadAndCacheToolUsage(sessionId); + const { toolUsageByCallId, llmUsageByTurnId } = + await loadUsageTelemetry(sessionId); if (signal.aborted) return merged; - const usageHydrated = applyToolUsageToEvents(merged, usageByCallId); + const usageHydrated = applyLlmUsageToEvents( + applyToolUsageToEvents(merged, toolUsageByCallId), + llmUsageByTurnId + ); await backfillSubagentLinks(sessionId, usageHydrated); return usageHydrated; @@ -517,7 +517,7 @@ export function createRustAgentAdapter( if (isTerminal) { _runningSignaled = false; _turnCompleted = true; - void refreshToolUsageForLatestEvents(sessionId).catch((err) => { + void refreshUsageForLatestEvents(sessionId).catch((err) => { logger.warn( `[${category}] terminal tool usage refresh failed:`, err diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts index 6a85306122..a44fdbfc93 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts @@ -2,26 +2,29 @@ import { describe, expect, it } from "vitest"; import { TOOL_USAGE_ATTRIBUTION_METHOD } from "@src/api/tauri/session"; import { + LLM_USAGE_ARGS_KEY, type SessionEvent, TOOL_USAGE_ARGS_KEY, } from "@src/engines/SessionCore/core/types"; import { + applyLlmUsageToEvents, applyToolUsageToEvents, + buildLlmUsageByTurnMap, buildUsageMap, withToolUsageArgs, } from "../toolUsageCache"; -function makeEvent(callId?: string): SessionEvent { +function makeEvent(callId?: string, turnId?: string): SessionEvent { return { - id: callId ? `tool-call-${callId}` : "message-1", + id: callId ? `tool-call-${callId}` : `message-${turnId ?? "1"}`, chunk_id: null, sessionId: "session-1", createdAt: "2026-06-28T00:00:00.000Z", functionName: callId ? "read_file" : "assistant_message", uiCanonical: callId ? "read_file" : "assistant_message", actionType: callId ? "tool_call" : "assistant", - args: { path: "README.md" }, + args: { path: "README.md", ...(turnId ? { turnId } : {}) }, result: {}, source: "assistant", displayText: "Read file", @@ -129,6 +132,59 @@ describe("toolUsageCache", () => { expect(enriched[2].toolUsage).toBeUndefined(); }); + it("attaches non-tool LLM span usage to the assistant event for a turn", () => { + const usageByTurnId = buildLlmUsageByTurnMap([ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 1, + model: "model-1", + accountId: null, + promptTokens: 100, + completionTokens: 20, + cacheReadTokens: 4, + cacheWriteTokens: 2, + totalTokens: 126, + contextTokens: 100, + relatedToolCallIdsJson: "[]", + contextUsageJson: null, + createdAt: "2026-06-28T00:00:00.000Z", + }, + { + id: 2, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 2, + model: "model-1", + accountId: null, + promptTokens: 80, + completionTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 90, + contextTokens: 80, + relatedToolCallIdsJson: '["call-1"]', + contextUsageJson: null, + createdAt: "2026-06-28T00:00:01.000Z", + }, + ]); + const enriched = applyLlmUsageToEvents( + [makeEvent(undefined, "turn-1")], + usageByTurnId + ); + + expect(enriched[0].llmUsage).toEqual({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 4, + cacheWriteTokens: 2, + model: "model-1", + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.PROVIDER_EXACT, + }); + expect(enriched[0].args[LLM_USAGE_ARGS_KEY]).toEqual(enriched[0].llmUsage); + }); + it("stores usage metadata in args patch payloads", () => { const usage = { decisionCompletionTokens: 1, diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts index 3c32866b0e..bce624acab 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts @@ -1,10 +1,13 @@ import { type LlmUsageSpanRecord, + TOOL_USAGE_ATTRIBUTION_METHOD, type ToolUsageAttributionRecord, getSessionLlmUsageSpans, getSessionToolUsageAttributions, } from "@src/api/tauri/session"; import { + LLM_USAGE_ARGS_KEY, + type LlmUsageMetadata, type SessionEvent, TOOL_USAGE_ARGS_KEY, type ToolUsageMetadata, @@ -12,6 +15,11 @@ import { const MAX_SESSION_USAGE_CACHE_SIZE = 100; +interface UsageTelemetryMaps { + toolUsageByCallId: Map; + llmUsageByTurnId: Map; +} + const sessionUsageCache = new Map>(); function touchSessionCache( @@ -117,6 +125,26 @@ export function buildUsageMap( return usageByCallId; } +export function buildLlmUsageByTurnMap( + spans: readonly LlmUsageSpanRecord[] +): Map { + const usageByTurnId = new Map(); + for (const span of spans) { + if (parseRelatedToolCallIds(span).length > 0) continue; + const existing = usageByTurnId.get(span.turnId); + usageByTurnId.set(span.turnId, { + inputTokens: (existing?.inputTokens ?? 0) + span.promptTokens, + outputTokens: (existing?.outputTokens ?? 0) + span.completionTokens, + cacheReadTokens: (existing?.cacheReadTokens ?? 0) + span.cacheReadTokens, + cacheWriteTokens: + (existing?.cacheWriteTokens ?? 0) + span.cacheWriteTokens, + model: existing?.model ?? span.model, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.PROVIDER_EXACT, + }); + } + return usageByTurnId; +} + export function withToolUsageArgs( args: Record, toolUsage: ToolUsageMetadata @@ -127,6 +155,75 @@ export function withToolUsageArgs( }; } +export function withLlmUsageArgs( + args: Record, + llmUsage: LlmUsageMetadata +): Record { + return { + ...args, + [LLM_USAGE_ARGS_KEY]: llmUsage, + }; +} + +function eventTurnId(event: SessionEvent): string | null { + const turnId = event.args?.turnId; + return typeof turnId === "string" ? turnId : null; +} + +function isAssistantMessageEvent(event: SessionEvent): boolean { + return ( + event.source === "assistant" && + event.displayVariant === "message" && + event.functionName !== "turn_summary" + ); +} + +function isThinkingEvent(event: SessionEvent): boolean { + return ( + event.displayVariant === "thinking" || event.uiCanonical === "thinking" + ); +} + +function selectLlmUsageTargetEvents( + events: readonly SessionEvent[] +): Map { + const targetsByTurnId = new Map(); + for (const event of events) { + const turnId = eventTurnId(event); + if (!turnId) continue; + if (isAssistantMessageEvent(event)) { + targetsByTurnId.set(turnId, event); + continue; + } + if (isThinkingEvent(event) && !targetsByTurnId.has(turnId)) { + targetsByTurnId.set(turnId, event); + } + } + return targetsByTurnId; +} + +export function applyLlmUsageToEvents( + events: readonly SessionEvent[], + usageByTurnId: ReadonlyMap +): SessionEvent[] { + if (usageByTurnId.size === 0) return [...events]; + const targetEvents = selectLlmUsageTargetEvents(events); + const targetIds = new Set( + [...targetEvents.values()].map((event) => event.id) + ); + return events.map((event) => { + if (!targetIds.has(event.id)) return event; + const turnId = eventTurnId(event); + const llmUsage = turnId ? usageByTurnId.get(turnId) : undefined; + if (!llmUsage) return event; + return { + ...event, + args: withLlmUsageArgs(event.args, llmUsage), + llmUsage, + }; + }); +} + export function applyToolUsageToEvents( events: readonly SessionEvent[], usageByCallId: ReadonlyMap @@ -145,16 +242,24 @@ export function applyToolUsageToEvents( }); } -export async function loadAndCacheToolUsage( +export async function loadUsageTelemetry( sessionId: string -): Promise> { +): Promise { const [records, spans] = await Promise.all([ getSessionToolUsageAttributions(sessionId), getSessionLlmUsageSpans(sessionId), ]); - const usageByCallId = buildUsageMap(records, spans); - touchSessionCache(sessionId, usageByCallId); - return usageByCallId; + const toolUsageByCallId = buildUsageMap(records, spans); + const llmUsageByTurnId = buildLlmUsageByTurnMap(spans); + touchSessionCache(sessionId, toolUsageByCallId); + return { toolUsageByCallId, llmUsageByTurnId }; +} + +export async function loadAndCacheToolUsage( + sessionId: string +): Promise> { + const { toolUsageByCallId } = await loadUsageTelemetry(sessionId); + return toolUsageByCallId; } export function getCachedToolUsage( diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index e038c14d0f..d99ebe782d 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Chat-Panel ausblenden", + "showTokenUsage": "Token anzeigen", "compactDisplayMode": "Kompakter Modus", "replay": { "follow": "Folgen" diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 4be02ac61a..156c3edc07 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -789,6 +789,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Hide chat panel", + "showTokenUsage": "Show token", "compactDisplayMode": "Compact mode", "replay": { "follow": "Follow" diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 02e3174f20..f519d8e0d9 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Ocultar panel de chat", + "showTokenUsage": "Mostrar Token", "compactDisplayMode": "Modo compacto", "replay": { "follow": "Seguir" diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index b5bf7f5d26..4c8f5f1765 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Masquer le panneau de chat", + "showTokenUsage": "Afficher les Token", "compactDisplayMode": "Mode compact", "replay": { "follow": "Suivre" diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index c0e57fb6b0..c1566e239d 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "チャット", "hideChatPanel": "チャットパネルを非表示", + "showTokenUsage": "Token を表示", "compactDisplayMode": "コンパクトモード", "replay": { "follow": "フォロー" diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 8fb4da3257..03b8fe2b43 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "채팅", "hideChatPanel": "채팅 패널 숨기기", + "showTokenUsage": "Token 표시", "compactDisplayMode": "컴팩트 모드", "replay": { "follow": "팔로우" diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 4d5ea213c7..ce4e3381a8 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -766,6 +766,7 @@ }, "defaultTitle": "Czat", "hideChatPanel": "Ukryj panel czatu", + "showTokenUsage": "Pokaż Token", "compactDisplayMode": "Tryb kompaktowy", "replay": { "follow": "Śledź" diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index 4cdb1e145a..27574fe911 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -764,6 +764,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Ocultar painel de chat", + "showTokenUsage": "Mostrar Token", "compactDisplayMode": "Modo compacto", "replay": { "follow": "Acompanhar" diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 2ab0ec98e3..494d449ea5 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -760,6 +760,7 @@ }, "defaultTitle": "Чат", "hideChatPanel": "Скрыть панель чата", + "showTokenUsage": "Показывать Token", "compactDisplayMode": "Компактный режим", "replay": { "follow": "Следовать" diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index 8a6df016a5..81a96ed7ca 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "Sohbet", "hideChatPanel": "Sohbet panelini gizle", + "showTokenUsage": "Token göster", "compactDisplayMode": "Kompakt mod", "replay": { "follow": "Takip et" diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index e3d383d2b8..095e9e8ec9 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -758,6 +758,7 @@ }, "defaultTitle": "Chat", "hideChatPanel": "Ẩn bảng trò chuyện", + "showTokenUsage": "Hiển thị Token", "compactDisplayMode": "Chế độ gọn", "replay": { "follow": "Theo dõi" diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 59c14d6277..cba5d5e66d 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -762,6 +762,7 @@ }, "defaultTitle": "Session", "hideChatPanel": "隱藏聊天面板", + "showTokenUsage": "顯示 Token", "compactDisplayMode": "精簡模式", "replay": { "follow": "跟隨" diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 9ef78e3f04..6fede1eaf3 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -783,6 +783,7 @@ }, "defaultTitle": "Session", "hideChatPanel": "隐藏聊天面板", + "showTokenUsage": "显示 Token", "compactDisplayMode": "紧凑模式", "replay": { "follow": "跟随" diff --git a/src/store/ui/chatPanelAtom.ts b/src/store/ui/chatPanelAtom.ts index 8d8fc667ff..f8094d0178 100644 --- a/src/store/ui/chatPanelAtom.ts +++ b/src/store/ui/chatPanelAtom.ts @@ -200,6 +200,14 @@ export const chatHistoryDisplayModeAtom = ); chatHistoryDisplayModeAtom.debugLabel = "chatHistoryDisplayModeAtom"; +export const chatTokenUsageVisibleAtom = atomWithStorage( + "orgii:chatTokenUsageVisible", + false, + undefined, + { getOnInit: true } +); +chatTokenUsageVisibleAtom.debugLabel = "chatTokenUsageVisibleAtom"; + /** Presentation style for the chat panel model picker. */ export type ModelPickerStyle = "spotlight" | "dropdown"; From 563850a0655cd2d1ad93524a8fcd15991dfd8e64 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Sun, 28 Jun 2026 23:36:23 +0800 Subject: [PATCH 051/864] fix(ts): align merged UI surface types --- src/engines/ChatPanel/hooks/useChatPanelResize.ts | 6 ++++-- .../WorkStation/TabContent/renderers/githubIssueDetail.tsx | 2 +- src/services/context/workspaceSnapshot.ts | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/engines/ChatPanel/hooks/useChatPanelResize.ts b/src/engines/ChatPanel/hooks/useChatPanelResize.ts index 9799ffbccc..fb4133c23b 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelResize.ts +++ b/src/engines/ChatPanel/hooks/useChatPanelResize.ts @@ -39,6 +39,8 @@ import { export interface UseChatPanelResizeOptions { /** Whether using external width control */ useExternalWidth?: boolean; + /** Embedded panels are layout-owned and should not persist global chat width. */ + embedded?: boolean; /** Panel position: left or right */ position?: "left" | "right"; } @@ -70,7 +72,7 @@ const getChatWidthFromCSS = (): number => { export function useChatPanelResize( options: UseChatPanelResizeOptions = {} ): UseChatPanelResizeResult { - const { useExternalWidth = false, position = "right" } = options; + const { useExternalWidth = false, embedded = false, position = "right" } = options; const isLeftPosition = position === "left"; // OPTIMIZED: Only use setter, don't subscribe to value changes @@ -91,7 +93,7 @@ export function useChatPanelResize( */ const handleMouseDown = useCallback( (event: ReactMouseEvent) => { - if (useExternalWidth) return; + if (useExternalWidth || embedded) return; event.preventDefault(); event.stopPropagation(); diff --git a/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx b/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx index c0bf73c13f..80fca12139 100644 --- a/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx +++ b/src/modules/WorkStation/TabContent/renderers/githubIssueDetail.tsx @@ -45,7 +45,7 @@ const GitHubIssueDetailTabRenderer: React.FC = memo( const callbacks = useAtomValue(workstationIssueCallbackAtom); const setSelectedState = useSetAtom(workstationSelectedIssueAtom); const { closeTab } = useWorkStationTabs(); - const tabData = tab.data as GitHubIssueDetailTabData; + const tabData = tab.data as unknown as GitHubIssueDetailTabData; const handleClose = useCallback(() => { closeTab(tab.id); diff --git a/src/services/context/workspaceSnapshot.ts b/src/services/context/workspaceSnapshot.ts index d05ae570fe..46c0715a51 100644 --- a/src/services/context/workspaceSnapshot.ts +++ b/src/services/context/workspaceSnapshot.ts @@ -49,6 +49,7 @@ export type ChatPanelSurfaceKind = | "workspaceDashboard" | "workspaceExplore" | "workspaceOverview" + | "manageIssues" | "newCollabOrg" | "collabOrg"; From 1408fc7214f53f21e1bef3b7774bac52be471314 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Mon, 29 Jun 2026 00:32:59 +0800 Subject: [PATCH 052/864] feat(session): expose explicit context cache observability Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../src/core/session/persistence/messages.rs | 31 +++ .../src/core/session/persistence/mod.rs | 2 +- .../src/core/session/turn/processor/mod.rs | 29 +- .../commands/session/debug/context_cache.rs | 164 ++++++++++++ .../src/state/commands/session/debug/mod.rs | 2 + src-tauri/src/commands/handler_list.inc | 1 + src/app/root/e2e/helpers/runtimeDebug.ts | 20 ++ .../ToolCallBlock/cards/ContextImportCard.tsx | 50 ++-- .../helpers/__tests__/cardParsers.test.ts | 59 +++++ .../ToolCallBlock/helpers/cardParsers.ts | 32 ++- .../ChatPanel/blocks/ToolCallBlock/types.ts | 7 + .../core/context-import-card-ui.spec.mjs | 247 ++++++++++++++++++ 12 files changed, 622 insertions(+), 22 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs create mode 100644 src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/cardParsers.test.ts create mode 100644 tests/e2e/specs/core/context-import-card-ui.spec.mjs diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 0c9997d56a..06f940b70b 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -823,6 +823,37 @@ pub fn load_turn_cache_layout_stats( } } + +pub fn load_latest_turn_cache_layout_stats( + session_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + ensure_context_metadata_schema(&conn)?; + let mut stmt = conn.prepare( + "SELECT turn_id, stable_prefix_tokens, volatile_context_tokens, imported_context_count, + cache_read_tokens, cache_write_tokens + FROM turn_cache_layout_stats + WHERE session_id = ?1 + ORDER BY created_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query(params![session_id])?; + if let Some(row) = rows.next()? { + Ok(Some(( + row.get(0)?, + CacheLayoutStats::new( + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + ), + ))) + } else { + Ok(None) + } +} + pub fn save_session_embedding_state(state: &SessionEmbeddingState) -> SqliteResult<()> { with_sessions_writer(|| -> SqliteResult<()> { let conn = get_connection()?; diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index 2484adecf6..00871c184c 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -35,7 +35,7 @@ pub use crud::{ pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, clear_session_memory_state, compact_cutoff_sequence, ensure_context_metadata_schema, - latest_message_sequence, load_context_snapshots, load_llm_history, load_messages, load_session_embedding_state, + latest_message_sequence, load_context_snapshots, load_latest_turn_cache_layout_stats, load_llm_history, load_messages, load_session_embedding_state, load_session_memory_index_rows, load_session_memory_state, load_turn_cache_layout_stats, mark_turn_cancelled, message_anchor, message_created_at, save_assistant_msg, save_compact_summary_msg, save_context_snapshot, save_session_embedding_state, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index 632eb9524f..f501bd0d2a 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -341,10 +341,33 @@ impl UnifiedMessageProcessor { .and_then(|snapshot| serde_json::to_string(snapshot).ok()); tokio::task::block_in_place(|| { + let stable_prefix_tokens = result + .context_usage_snapshot + .as_ref() + .map(|snapshot| { + snapshot + .sections + .iter() + .filter(|section| { + matches!( + section.category, + crate::turn_executor::context_accounting::ContextUsageCategory::StablePrompt + | crate::turn_executor::context_accounting::ContextUsageCategory::Rules + | crate::turn_executor::context_accounting::ContextUsageCategory::Skills + ) + }) + .map(|section| section.estimated_tokens) + .sum::() + }) + .unwrap_or(result.context_tokens); + let imported_context_count = + unified_persistence::load_context_snapshots(session_id) + .map(|snapshots| snapshots.len() as i64) + .unwrap_or(0); let cache_layout_stats = CacheLayoutStats::new( - result.context_tokens, - result.prompt_tokens.saturating_sub(result.context_tokens), - 0, + stable_prefix_tokens, + result.context_tokens.saturating_sub(stable_prefix_tokens), + imported_context_count, result.cache_read_tokens, result.cache_write_tokens, ); diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs new file mode 100644 index 0000000000..b1c6931368 --- /dev/null +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs @@ -0,0 +1,164 @@ +//! Dev-only Tauri command: explicit context/cache observability snapshot. +//! +//! This complements `prompt_dump`: provider prompt cache is performance-only, +//! while this command exposes the durable context-import/cache-layout records +//! ORG2 has persisted for a session. + +use serde::{Deserialize, Serialize}; + +use crate::core::session::context_import::{CacheLayoutStats, ContextSnapshotMeta, SessionEmbeddingState}; +use crate::core::session::persistence as unified_persistence; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextSnapshotWire { + pub snapshot_id: String, + pub target_session_id: String, + pub source_kind: String, + pub source_id: String, + pub namespace: String, + pub title: Option, + pub token_estimate: i64, + pub pinned: bool, + pub created_at: String, +} + +impl From for ContextSnapshotWire { + fn from(value: ContextSnapshotMeta) -> Self { + Self { + snapshot_id: value.snapshot_id, + target_session_id: value.target_session_id, + source_kind: value.source_kind.as_str().to_string(), + source_id: value.source_id, + namespace: value.namespace, + title: value.title, + token_estimate: value.token_estimate, + pinned: value.pinned, + created_at: value.created_at, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CacheLayoutStatsWire { + pub stable_prefix_tokens: i64, + pub volatile_context_tokens: i64, + pub imported_context_count: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub provider_cache_hit_rate: Option, +} + +impl From for CacheLayoutStatsWire { + fn from(value: CacheLayoutStats) -> Self { + let provider_cache_hit_rate = value.provider_cache_hit_rate(); + Self { + stable_prefix_tokens: value.stable_prefix_tokens, + volatile_context_tokens: value.volatile_context_tokens, + imported_context_count: value.imported_context_count, + cache_read_tokens: value.cache_read_tokens, + cache_write_tokens: value.cache_write_tokens, + provider_cache_hit_rate, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionEmbeddingStateWire { + pub namespace: String, + pub session_id: String, + pub work_item_id: Option, + pub last_embedded_sequence: i64, + pub embedding_model: Option, + pub updated_at: String, +} + +impl From for SessionEmbeddingStateWire { + fn from(value: SessionEmbeddingState) -> Self { + Self { + namespace: value.namespace, + session_id: value.session_id, + work_item_id: value.work_item_id, + last_embedded_sequence: value.last_embedded_sequence, + embedding_model: value.embedding_model, + updated_at: value.updated_at, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextCacheSnapshotResult { + pub session_id: String, + pub snapshots: Vec, + pub latest_cache_layout: Option, + pub embedding_state: Option, +} + +/// Return durable explicit context-import metadata, latest cache-layout stats, +/// and proactive embedding progress for a session. +#[tauri::command] +pub async fn debug_session_context_cache_snapshot( + session_id: String, +) -> Result { + let session_id_for_block = session_id.clone(); + tokio::task::spawn_blocking(move || { + let snapshots = unified_persistence::load_context_snapshots(&session_id_for_block) + .map_err(|err| format!("load_context_snapshots failed: {err}"))? + .into_iter() + .map(ContextSnapshotWire::from) + .collect::>(); + + let latest_cache_layout = unified_persistence::load_latest_turn_cache_layout_stats(&session_id_for_block) + .map_err(|err| format!("load_latest_turn_cache_layout_stats failed: {err}"))? + .map(|(_turn_id, stats)| CacheLayoutStatsWire::from(stats)); + + let embedding_namespace = format!("session:{session_id_for_block}"); + let embedding_state = unified_persistence::load_session_embedding_state(&embedding_namespace) + .map_err(|err| format!("load_session_embedding_state failed: {err}"))? + .map(SessionEmbeddingStateWire::from); + + Ok(ContextCacheSnapshotResult { + session_id: session_id_for_block, + snapshots, + latest_cache_layout, + embedding_state, + }) + }) + .await + .map_err(|err| format!("debug_session_context_cache_snapshot task failed: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::session::context_import::ContextSourceKind; + + #[test] + fn context_snapshot_wire_uses_snake_case_source_kind_and_camel_case_fields() { + let meta = ContextSnapshotMeta::new( + "target", + ContextSourceKind::WorkItem, + "WI-1", + Some("Work item".to_string()), + 123, + true, + ); + let wire = ContextSnapshotWire::from(meta); + assert_eq!(wire.source_kind, "work_item"); + assert_eq!(wire.namespace, "work_item:WI-1"); + assert_eq!(wire.token_estimate, 123); + assert!(wire.pinned); + } + + #[test] + fn cache_layout_wire_includes_provider_hit_rate() { + let wire = CacheLayoutStatsWire::from(CacheLayoutStats::new(100, 50, 2, 9, 1)); + assert_eq!(wire.stable_prefix_tokens, 100); + assert_eq!(wire.volatile_context_tokens, 50); + assert_eq!(wire.imported_context_count, 2); + assert_eq!(wire.provider_cache_hit_rate, Some(0.9)); + } +} diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/mod.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/mod.rs index 59a85d0d0b..453a37c4b4 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/debug/mod.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/mod.rs @@ -13,6 +13,7 @@ //! - `prompt` — assembled system prompt + per-section trace //! - `org_runtime` — Agent Org context, org-only tool registration +pub mod context_cache; pub mod general; pub mod model; pub mod org_runtime; @@ -22,6 +23,7 @@ pub mod skills; pub mod subagent; pub mod tools; +pub use context_cache::*; pub use general::*; pub use model::*; pub use org_runtime::*; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index cc5440536f..0ece48b9cd 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -681,6 +681,7 @@ agent_core::state::commands::tools::agent_check_keys, // (cheap; just rebuilds a `PromptCtx` and walks the registry once); // the frontend gates exposure behind `debug_assertions || WEBDRIVER=1`. agent_core::state::commands::session::debug::prompt::prompt_dump, +agent_core::state::commands::session::debug::context_cache::debug_session_context_cache_snapshot, // Structured introspection of the live runtime `SecurityPolicy` for an // active session. Used by `audit-security-*.spec.mjs` to prove the // L4→L5 hop: whatever sits in `AgentDefinition.agent_policy` is what diff --git a/src/app/root/e2e/helpers/runtimeDebug.ts b/src/app/root/e2e/helpers/runtimeDebug.ts index dd675a384b..12e740694c 100644 --- a/src/app/root/e2e/helpers/runtimeDebug.ts +++ b/src/app/root/e2e/helpers/runtimeDebug.ts @@ -151,6 +151,25 @@ export function createRuntimeDebugHelpers() { } }; + const debugSessionContextCacheSnapshot = async ( + sessionId: string + ): Promise> => { + try { + if (!sessionId) { + return { + ok: false, + error: "debugSessionContextCacheSnapshot: `sessionId` is required", + }; + } + const snapshot = (await invoke("debug_session_context_cache_snapshot", { + sessionId, + })) as Json; + return { ok: true, snapshot }; + } catch (err) { + return asError(err); + } + }; + const debugSessionGeneralSnapshot = async ( sessionId: string ): Promise> => { @@ -175,6 +194,7 @@ export function createRuntimeDebugHelpers() { debugSessionValidateCommand, debugSessionSubagentSnapshot, debugSessionModelSnapshot, + debugSessionContextCacheSnapshot, debugSessionToolsSnapshot, listEffectiveToolsForSession, debugSessionSkillsSnapshot, diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx index 98b701ab86..4c7b65cee0 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ContextImportCard.tsx @@ -21,29 +21,45 @@ const ContextImportCard: React.FC = ({ card }) => { {title} - {card.pinned && } + {card.pinned && ( + + )} -
- - {card.namespace} - - · - {card.sourceKind.replace(/_/g, " ")} +
+ {(card.sourceChips?.length + ? card.sourceChips + : [card.namespace] + ).map((chip) => ( + + {chip} + + ))} {card.tokenEstimate !== undefined && ( - <> - · - {card.tokenEstimate} tokens est. - + + {card.tokenEstimate} tokens est. + )} {card.snapshotId && ( - <> - · - - {card.snapshotId.slice(0, 8)} - - + + {card.snapshotId.slice(0, 8)} + )}
+ {card.debugStats?.length ? ( +
+ {card.debugStats.map((stat) => ( +
+ + {stat.label} + + {stat.value} +
+ ))} +
+ ) : null}
diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/cardParsers.test.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/cardParsers.test.ts new file mode 100644 index 0000000000..b289f4ef5e --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/cardParsers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { parseContextImportCardResult } from "../cardParsers"; + +describe("parseContextImportCardResult", () => { + it("builds source chips and cache debug stats from import_context result", () => { + const card = parseContextImportCardResult( + { + source_kind: "session", + source_id: "source-session", + title: "Source Session", + token_estimate: 321, + pinned: true, + }, + { + snapshot_id: "snapshot-1234567890", + namespace: "session:source-session", + stable_prefix_tokens: 1200, + volatile_context_tokens: 340, + imported_context_count: 2, + cache_read_tokens: 900, + cache_write_tokens: 100, + } + ); + + expect(card).toMatchObject({ + snapshotId: "snapshot-1234567890", + sourceKind: "session", + sourceId: "source-session", + namespace: "session:source-session", + title: "Source Session", + tokenEstimate: 321, + pinned: true, + sourceChips: ["session:source-session", "session", "pinned"], + }); + expect(card?.debugStats).toEqual([ + { label: "stable prefix", value: "1200" }, + { label: "volatile", value: "340" }, + { label: "imports", value: "2" }, + { label: "cache read", value: "900" }, + { label: "cache write", value: "100" }, + ]); + }); + + it("keeps explicit imports parseable when backend only returns minimal metadata", () => { + const card = parseContextImportCardResult( + { source_kind: "work_item", source_id: "WI-7" }, + {} + ); + + expect(card).toMatchObject({ + sourceKind: "work_item", + sourceId: "WI-7", + namespace: "work_item:WI-7", + sourceChips: ["work_item:WI-7", "work item"], + }); + expect(card?.debugStats).toBeUndefined(); + }); +}); diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts index 2a1c637329..029d47a8f0 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts @@ -200,7 +200,8 @@ export function parseContextImportCardResult( (typeof result.title === "string" ? result.title : null) ?? (typeof args.title === "string" ? args.title : null) ?? undefined; - const rawTokenEstimate = result.token_estimate ?? result.tokenEstimate ?? args.token_estimate; + const rawTokenEstimate = + result.token_estimate ?? result.tokenEstimate ?? args.token_estimate; const tokenEstimate = typeof rawTokenEstimate === "number" && Number.isFinite(rawTokenEstimate) ? Math.max(0, Math.floor(rawTokenEstimate)) @@ -212,6 +213,33 @@ export function parseContextImportCardResult( ? args.pinned : undefined; + const sourceChips = [ + namespace, + sourceKind.replace(/_/g, " "), + pinned ? "pinned" : null, + ].filter((chip): chip is string => Boolean(chip)); + + const rawStablePrefixTokens = + result.stable_prefix_tokens ?? result.stablePrefixTokens; + const rawVolatileContextTokens = + result.volatile_context_tokens ?? result.volatileContextTokens; + const rawImportedContextCount = + result.imported_context_count ?? result.importedContextCount; + const rawCacheReadTokens = result.cache_read_tokens ?? result.cacheReadTokens; + const rawCacheWriteTokens = + result.cache_write_tokens ?? result.cacheWriteTokens; + const numberStat = (label: string, value: unknown) => + typeof value === "number" && Number.isFinite(value) + ? { label, value: String(Math.max(0, Math.floor(value))) } + : null; + const debugStats = [ + numberStat("stable prefix", rawStablePrefixTokens), + numberStat("volatile", rawVolatileContextTokens), + numberStat("imports", rawImportedContextCount), + numberStat("cache read", rawCacheReadTokens), + numberStat("cache write", rawCacheWriteTokens), + ].filter((stat): stat is { label: string; value: string } => Boolean(stat)); + return { snapshotId, sourceKind, @@ -220,6 +248,8 @@ export function parseContextImportCardResult( title, tokenEstimate, pinned, + sourceChips, + debugStats: debugStats.length > 0 ? debugStats : undefined, }; } diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts index d6d74b1439..d383ce5a25 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts @@ -189,6 +189,11 @@ export interface AgentMessageDeliveryRow { inboxId?: number; } +export interface ContextImportDebugStat { + label: string; + value: string; +} + export interface ContextImportCardData { snapshotId?: string; sourceKind: string; @@ -197,6 +202,8 @@ export interface ContextImportCardData { title?: string; tokenEstimate?: number; pinned?: boolean; + sourceChips?: string[]; + debugStats?: ContextImportDebugStat[]; } export interface AgentMessageCardData { diff --git a/tests/e2e/specs/core/context-import-card-ui.spec.mjs b/tests/e2e/specs/core/context-import-card-ui.spec.mjs new file mode 100644 index 0000000000..73b2833335 --- /dev/null +++ b/tests/e2e/specs/core/context-import-card-ui.spec.mjs @@ -0,0 +1,247 @@ +import { readFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { e2eUrl } from "../../support/core/e2eBaseUrl.mjs"; + +const MOUNT_TIMEOUT_MS = 60_000; +const RENDER_TIMEOUT_MS = 15_000; +const RUN_ID = Date.now(); +const E2E_REPO_PATH = process.env.E2E_REPO_PATH ?? "/tmp/orgii-e2e-workspace-repo"; +const REPORT_DIR = process.env.ORG2_CONTEXT_IMPORT_REPORT_DIR ?? "/tmp/org2-context-import-card-ui"; + +async function execJS(script) { + return browser.executeScript(script, []); +} + +async function waitForFrontendReady() { + const port = process.env.E2E_FRONTEND_PORT ?? "1998"; + const url = `http://127.0.0.1:${port}`; + await browser.waitUntil( + async () => { + try { + const response = await fetch(url, { method: "GET" }); + return response.ok; + } catch { + return false; + } + }, + { + timeout: MOUNT_TIMEOUT_MS, + timeoutMsg: `frontend dev server never became ready at ${url}`, + } + ); +} + +async function invokeE2E(method, ...args) { + return browser.executeAsyncScript( + ` + const cb = arguments[arguments.length - 1]; + const method = arguments[0]; + const rest = Array.prototype.slice.call(arguments, 1, arguments.length - 1); + if (!window.__e2e || typeof window.__e2e[method] !== "function") { + cb({ ok: false, error: "window.__e2e." + method + " not available" }); + return; + } + Promise.resolve(window.__e2e[method].apply(null, rest)) + .then(cb) + .catch((e) => cb({ ok: false, error: String(e && e.message || e) })); + `, + [method, ...args] + ); +} + +async function waitForApp() { + await waitForFrontendReady(); + await browser.setTimeout({ script: 5_000 }); + await execJS(`localStorage.setItem('orgii:auth_skipped', '1'); return true;`); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return document.readyState === 'complete' || document.readyState === 'interactive';` + ); + } catch { + return false; + } + }, + { timeout: MOUNT_TIMEOUT_MS, timeoutMsg: "document never became script-readable" } + ); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return !!document.querySelector('[data-testid="chat-panel"]');` + ); + } catch { + return false; + } + }, + { timeout: MOUNT_TIMEOUT_MS, timeoutMsg: "chat-panel never mounted" } + ); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return !!(window.__e2e && window.__e2e.seedChatEvents && window.__e2e.navigateTo);` + ); + } catch { + return false; + } + }, + { timeout: 20_000, timeoutMsg: "window.__e2e helpers never exposed" } + ); +} + +function makeContextImportEvents(sessionId) { + const baseTime = Date.now(); + const snapshotId = `ctx-snap-${RUN_ID}`; + return [ + { + id: `user-context-import-${RUN_ID}`, + chunk_id: `user-context-import-${RUN_ID}`, + sessionId, + createdAt: new Date(baseTime).toISOString(), + functionName: "user_message", + uiCanonical: "user_message", + actionType: "raw", + args: {}, + result: { + type: "user", + message: "Import source context for cache-surface validation", + is_delta: false, + }, + source: "user", + displayText: "Import source context for cache-surface validation", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + isDelta: false, + }, + { + id: `tool-context-import-${RUN_ID}`, + chunk_id: `tool-context-import-${RUN_ID}`, + sessionId, + createdAt: new Date(baseTime + 1_000).toISOString(), + functionName: "import_context", + uiCanonical: "import_context", + actionType: "tool_call", + args: { + source_kind: "session", + source_id: "source-session-cache-context", + title: "Cache Context Source Session", + token_estimate: 4321, + pinned: true, + }, + result: { + success: true, + status: "completed", + source_kind: "session", + source_id: "source-session-cache-context", + title: "Cache Context Source Session", + token_estimate: 4321, + pinned: true, + stable_prefix_tokens: 1200, + volatile_context_tokens: 340, + imported_context_count: 1, + cache_read_tokens: 900, + cache_write_tokens: 100, + namespace: "session:source-session-cache-context", + snapshot_id: snapshotId, + observation: `Imported context snapshot ${snapshotId} from session:source-session-cache-context into namespace session:source-session-cache-context`, + }, + source: "assistant", + displayText: "Imported context snapshot", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "agent", + isDelta: false, + }, + { + id: `assistant-context-import-${RUN_ID}`, + chunk_id: `assistant-context-import-${RUN_ID}`, + sessionId, + createdAt: new Date(baseTime + 2_000).toISOString(), + functionName: "assistant_message", + uiCanonical: "agent_message", + actionType: "assistant", + args: {}, + result: { + content: "Context import card rendered for cache debug/source chip validation.", + observation: "Context import card rendered for cache debug/source chip validation.", + is_delta: false, + role: "assistant", + }, + source: "assistant", + displayText: "Context import card rendered for cache debug/source chip validation.", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + isDelta: false, + }, + ]; +} + +describe("Context import card UI", () => { + before(async () => { + await waitForApp(); + const repo = await invokeE2E("ensureRepoSelected", { + repoPath: E2E_REPO_PATH, + repoName: "E2E Fixture Repo", + }); + if (!repo || repo.ok !== true) throw new Error(`ensureRepoSelected failed: ${repo?.error ?? "unknown"}`); + const navigation = await invokeE2E("navigateTo", "/orgii/workstation/code"); + if (!navigation || navigation.ok !== true) throw new Error(`navigateTo failed: ${navigation?.error ?? "unknown"}`); + }); + + it("renders import_context result as ContextImportCard and captures screenshot", async () => { + const sessionId = `e2e-context-import-${RUN_ID}`; + const events = makeContextImportEvents(sessionId); + const seed = await invokeE2E("seedChatEvents", sessionId, events); + if (!seed || seed.ok !== true) throw new Error(`seedChatEvents failed: ${seed?.error ?? "unknown"}`); + const nav = await invokeE2E("openSession", sessionId); + if (!nav || nav.ok !== true) throw new Error(`openSession failed: ${nav?.error ?? "unknown"}`); + + await browser.waitUntil( + async () => { + const state = await execJS(` + const body = document.body.innerText || ''; + const cardLike = body.includes('Cache Context Source Session') + && body.includes('session:source-session-cache-context') + && body.includes('session') + && body.includes('4321 tokens est.') + && body.includes('ctx-snap') + && body.includes('stable prefix') + && body.includes('cache read'); + const rawFallback = body.includes('Imported context snapshot ctx-snap') && !body.includes('4321 tokens est.'); + return { cardLike, rawFallback, body: body.slice(0, 4000) }; + `); + if (state.rawFallback) throw new Error(`raw fallback rendered instead of ContextImportCard: ${state.body}`); + return state.cardLike; + }, + { + timeout: RENDER_TIMEOUT_MS, + timeoutMsg: `ContextImportCard never rendered: ${JSON.stringify(await execJS(`return { body: (document.body.innerText || '').slice(0, 4000) };`))}`, + } + ); + + await mkdir(REPORT_DIR, { recursive: true }); + const screenshotPath = path.join(REPORT_DIR, "context-import-card.png"); + await browser.saveScreenshot(screenshotPath); + const snapshot = await execJS(` + const body = document.body.innerText || ''; + return { + hasTitle: body.includes('Cache Context Source Session'), + hasNamespace: body.includes('session:source-session-cache-context'), + hasTokenEstimate: body.includes('4321 tokens est.'), + hasSnapshotPrefix: body.includes('ctx-snap'), + hasStablePrefix: body.includes('stable prefix'), + hasCacheRead: body.includes('cache read'), + renderedToolNames: Array.from(document.querySelectorAll('[data-tool-call-name]')).map(n => n.getAttribute('data-tool-call-name')).filter(Boolean), + }; + `); + await browser.executeScript(`return true;`, []); + if (!snapshot.hasTitle || !snapshot.hasNamespace || !snapshot.hasTokenEstimate || !snapshot.hasSnapshotPrefix || !snapshot.hasStablePrefix || !snapshot.hasCacheRead) { + throw new Error(`ContextImportCard assertions failed: ${JSON.stringify(snapshot)}`); + } + }); +}); From 3e9b9f72bbd7f3ae4931c649579d0c94ecee3151 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Mon, 29 Jun 2026 08:15:48 +0800 Subject: [PATCH 053/864] feat(ui): surface context cache diagnostics Pre-commit hook ran. Total eslint: 3, total circular: 0 --- src/api/tauri/agent/contextCacheSnapshot.ts | 47 ++++++++++ src/app/root/E2EBootstrap.tsx | 2 + src/app/root/e2e/types.ts | 3 + src/components/ComposerBar/index.tsx | 14 ++- .../components/ContextInfoButton.tsx | 88 +++++++++++++++++- .../components/InputComposerBars.tsx | 5 + .../__tests__/contextCacheDebugPanel.test.ts | 93 +++++++++++++++++++ .../components/useContextCacheSnapshot.ts | 45 +++++++++ src/engines/ChatPanel/InputArea/index.tsx | 2 + 9 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/api/tauri/agent/contextCacheSnapshot.ts create mode 100644 src/engines/ChatPanel/InputArea/components/__tests__/contextCacheDebugPanel.test.ts create mode 100644 src/engines/ChatPanel/InputArea/components/useContextCacheSnapshot.ts diff --git a/src/api/tauri/agent/contextCacheSnapshot.ts b/src/api/tauri/agent/contextCacheSnapshot.ts new file mode 100644 index 0000000000..c4886c89be --- /dev/null +++ b/src/api/tauri/agent/contextCacheSnapshot.ts @@ -0,0 +1,47 @@ +import { invokeTauri } from "@src/util/platform/tauri/init"; + +export interface ContextSnapshotWire { + snapshotId: string; + targetSessionId: string; + sourceKind: string; + sourceId: string; + namespace: string; + title?: string | null; + tokenEstimate: number; + pinned: boolean; + createdAt: string; +} + +export interface CacheLayoutStatsWire { + stablePrefixTokens: number; + volatileContextTokens: number; + importedContextCount: number; + cacheReadTokens: number; + cacheWriteTokens: number; + providerCacheHitRate?: number | null; +} + +export interface SessionEmbeddingStateWire { + namespace: string; + sessionId: string; + workItemId?: string | null; + lastEmbeddedSequence: number; + embeddingModel?: string | null; + updatedAt: string; +} + +export interface ContextCacheSnapshotResult { + sessionId: string; + snapshots: ContextSnapshotWire[]; + latestCacheLayout?: CacheLayoutStatsWire | null; + embeddingState?: SessionEmbeddingStateWire | null; +} + +export async function contextCacheSnapshot( + sessionId: string +): Promise { + return invokeTauri( + "debug_session_context_cache_snapshot", + { sessionId } + ); +} diff --git a/src/app/root/E2EBootstrap.tsx b/src/app/root/E2EBootstrap.tsx index ac10d8b9a7..a0f9c82848 100644 --- a/src/app/root/E2EBootstrap.tsx +++ b/src/app/root/E2EBootstrap.tsx @@ -256,6 +256,7 @@ export const E2EBootstrap: FC = () => { debugSessionSubagentSnapshot, debugSessionModelSnapshot, debugSessionToolsSnapshot, + debugSessionContextCacheSnapshot, listEffectiveToolsForSession, debugSessionSkillsSnapshot, debugSessionGeneralSnapshot, @@ -420,6 +421,7 @@ export const E2EBootstrap: FC = () => { debugSessionSubagentSnapshot, debugSessionModelSnapshot, debugSessionToolsSnapshot, + debugSessionContextCacheSnapshot, listEffectiveToolsForSession, launchWorkItemRuntimeProbe, ...agentOrgHelpers, diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 7195d6330e..7a69af4720 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -489,6 +489,9 @@ export interface E2EHelpers { debugSessionToolsSnapshot: ( sessionId: string ) => Promise>; + debugSessionContextCacheSnapshot: ( + sessionId: string + ) => Promise>; listEffectiveToolsForSession: ( sessionId: string, agentExecMode?: string | null diff --git a/src/components/ComposerBar/index.tsx b/src/components/ComposerBar/index.tsx index a57a1d1450..112fd9d7e7 100644 --- a/src/components/ComposerBar/index.tsx +++ b/src/components/ComposerBar/index.tsx @@ -35,6 +35,8 @@ export interface ComposerBarProps { pills?: React.ReactNode; /** Repo path forwarded to ContextInfoButton */ repoPath?: string; + /** Session id forwarded to ContextInfoButton diagnostics. */ + sessionId?: string; /** Submit / launch button on the far right */ submitButton?: React.ReactNode; /** @@ -78,6 +80,7 @@ const ComposerBar: React.FC = memo( leftTools, pills, repoPath, + sessionId, submitButton, toolbarItemGap = true, bottomPaddingClassName = "", @@ -130,7 +133,9 @@ const ComposerBar: React.FC = memo( {pills}
- {showContextInfo && } + {showContextInfo && ( + + )} {submitButton}
@@ -181,7 +186,12 @@ const ComposerBar: React.FC = memo( style={{ gridArea: "right" }} > {showContextInfo && ( - + )} {submitButton} diff --git a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx index d6347603ed..3b00e9643a 100644 --- a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx +++ b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx @@ -18,11 +18,13 @@ import ContextBreakdownBar from "./ContextBreakdownBar"; import ContextCategoryRow from "./ContextCategoryRow"; import ProgressRing from "./ProgressRing"; import { type PanelCategory, ringToneForPercentage } from "./contextInfoTypes"; +import { useContextCacheSnapshot } from "./useContextCacheSnapshot"; import { useContextPanel } from "./useContextPanel"; import { formatTokenCount, useContextUsageInfo } from "./useContextUsageInfo"; export interface ContextInfoButtonProps { repoPath?: string; + sessionId?: string; /** * "toolbar" — icon-only button (used in the right toolbar cluster). * "corner" — icon + label pill anchored to the editor's bottom-right. @@ -36,7 +38,7 @@ export interface ContextInfoButtonProps { } const ContextInfoButton: React.FC = memo( - ({ variant = "toolbar", compact = false }) => { + ({ sessionId, variant = "toolbar", compact = false }) => { const { t } = useTranslation(); const { percentage, @@ -51,6 +53,8 @@ const ContextInfoButton: React.FC = memo( const { panelPos, triggerRef, panelRef, toggle, close } = useContextPanel(); const [hoveredKey, setHoveredKey] = useState(null); + const { snapshot: contextCacheSnapshot, error: contextCacheError } = + useContextCacheSnapshot(sessionId, panelPos !== null); const ringTone = ringToneForPercentage(percentage); const displayPct = percentage > 100 ? 100 : percentage; @@ -90,6 +94,12 @@ const ContextInfoButton: React.FC = memo( })); }, [contextUsage]); + const latestCacheLayout = contextCacheSnapshot?.latestCacheLayout; + const embeddingState = contextCacheSnapshot?.embeddingState; + const importedSnapshots = contextCacheSnapshot?.snapshots ?? []; + const formatOptionalTokens = (value: number | undefined | null) => + formatTokenCount(Math.max(0, value ?? 0)); + const handleMouseEnter = useCallback( (key: string) => () => setHoveredKey(key), [] @@ -214,6 +224,82 @@ const ContextInfoButton: React.FC = memo( )} + +
+
+ + Cache layout + + {!contextCacheSnapshot && !contextCacheError && ( + Loading… + )} +
+ {contextCacheError ? ( +

+ Debug snapshot unavailable: {contextCacheError} +

+ ) : ( + <> +
+
+ Stable prefix + + {formatOptionalTokens( + latestCacheLayout?.stablePrefixTokens + )} + +
+
+ Volatile + + {formatOptionalTokens( + latestCacheLayout?.volatileContextTokens + )} + +
+
+ Imported + + {latestCacheLayout?.importedContextCount ?? + importedSnapshots.length} + +
+
+ + Provider cache + + + {latestCacheLayout?.providerCacheHitRate != null + ? `${Math.round(latestCacheLayout.providerCacheHitRate * 100)}%` + : "—"} + +
+
+ {importedSnapshots.length > 0 && ( +
+ {importedSnapshots.slice(0, 4).map((snapshot) => ( + + {snapshot.namespace} + + ))} +
+ )} + {embeddingState && ( +

+ Embedded through seq{" "} + {embeddingState.lastEmbeddedSequence} ·{" "} + {embeddingState.namespace} +

+ )} + + )} +
, document.body )} diff --git a/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx b/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx index acc6a0c3b2..fa3a9bbb79 100644 --- a/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx +++ b/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx @@ -55,6 +55,7 @@ interface SharedComposerBarProps { onInterrupt: () => Promise; onResume: () => Promise; isCursorIde: boolean; + sessionId?: string; } interface EditComposerBarProps extends SharedComposerBarProps { @@ -140,6 +141,7 @@ export const EditComposerBar: React.FC = ({ onInterrupt, onResume, isCursorIde, + sessionId, }) => { const { t } = useTranslation("sessions"); @@ -151,6 +153,7 @@ export const EditComposerBar: React.FC = ({ dropdownDirection="down" toolbarItemGap={false} showContextInfo={!isCursorIde} + sessionId={sessionId} editorSlot={ = ({ placeholder, currentInputEmpty, stopSuppressedForEmptyInput, + sessionId, isWpGeneWorking, isPendingCancel, isSessionTerminal, @@ -353,6 +357,7 @@ export const NormalComposerContent: React.FC = ({ dropdownDirection="up" toolbarItemGap={false} repoPath={currentRepoPath} + sessionId={sessionId} inlineLayout={isCursorCompactRow} showContextInfo={!isCursorIde} editorSlot={ diff --git a/src/engines/ChatPanel/InputArea/components/__tests__/contextCacheDebugPanel.test.ts b/src/engines/ChatPanel/InputArea/components/__tests__/contextCacheDebugPanel.test.ts new file mode 100644 index 0000000000..8ed2bb4f17 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/__tests__/contextCacheDebugPanel.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { ContextCacheSnapshotResult } from "@src/api/tauri/agent/contextCacheSnapshot"; + +function panelMetrics(snapshot: ContextCacheSnapshotResult | null) { + const latest = snapshot?.latestCacheLayout; + return { + stablePrefixTokens: latest?.stablePrefixTokens ?? 0, + volatileContextTokens: latest?.volatileContextTokens ?? 0, + importedContextCount: + latest?.importedContextCount ?? snapshot?.snapshots.length ?? 0, + providerCachePercent: + latest?.providerCacheHitRate != null + ? Math.round(latest.providerCacheHitRate * 100) + : null, + namespaces: (snapshot?.snapshots ?? []).map((item) => item.namespace), + embeddedSequence: snapshot?.embeddingState?.lastEmbeddedSequence ?? null, + }; +} + +describe("context cache debug panel metrics", () => { + it("prefers backend cache-layout counts over raw snapshot count", () => { + const snapshot: ContextCacheSnapshotResult = { + sessionId: "session-a", + snapshots: [ + { + snapshotId: "snap-1", + targetSessionId: "session-a", + sourceKind: "work_item", + sourceId: "WI-1", + namespace: "work_item:WI-1", + tokenEstimate: 100, + pinned: true, + createdAt: "2026-06-29T00:00:00Z", + }, + ], + latestCacheLayout: { + stablePrefixTokens: 1200, + volatileContextTokens: 300, + importedContextCount: 4, + cacheReadTokens: 900, + cacheWriteTokens: 100, + providerCacheHitRate: 0.9, + }, + embeddingState: { + namespace: "session:session-a", + sessionId: "session-a", + workItemId: "WI-1", + lastEmbeddedSequence: 42, + embeddingModel: "test-model", + updatedAt: "2026-06-29T00:00:01Z", + }, + }; + + expect(panelMetrics(snapshot)).toEqual({ + stablePrefixTokens: 1200, + volatileContextTokens: 300, + importedContextCount: 4, + providerCachePercent: 90, + namespaces: ["work_item:WI-1"], + embeddedSequence: 42, + }); + }); + + it("falls back to raw snapshot count before the first cache-layout row exists", () => { + const snapshot: ContextCacheSnapshotResult = { + sessionId: "session-a", + snapshots: [ + { + snapshotId: "snap-1", + targetSessionId: "session-a", + sourceKind: "session", + sourceId: "source-session", + namespace: "session:source-session", + tokenEstimate: 0, + pinned: false, + createdAt: "2026-06-29T00:00:00Z", + }, + ], + latestCacheLayout: null, + embeddingState: null, + }; + + expect(panelMetrics(snapshot)).toMatchObject({ + stablePrefixTokens: 0, + volatileContextTokens: 0, + importedContextCount: 1, + providerCachePercent: null, + namespaces: ["session:source-session"], + embeddedSequence: null, + }); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/useContextCacheSnapshot.ts b/src/engines/ChatPanel/InputArea/components/useContextCacheSnapshot.ts new file mode 100644 index 0000000000..d4b1f6b72b --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/useContextCacheSnapshot.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; + +import { + type ContextCacheSnapshotResult, + contextCacheSnapshot, +} from "@src/api/tauri/agent/contextCacheSnapshot"; +import { createLogger } from "@src/hooks/logger"; + +const log = createLogger("useContextCacheSnapshot"); + +export interface ContextCacheSnapshotState { + snapshot: ContextCacheSnapshotResult | null; + error: string | null; +} + +export function useContextCacheSnapshot( + sessionId: string | undefined, + enabled: boolean +): ContextCacheSnapshotState { + const [state, setState] = useState({ + snapshot: null, + error: null, + }); + + useEffect(() => { + if (!enabled || !sessionId) return; + let cancelled = false; + contextCacheSnapshot(sessionId) + .then((snapshot) => { + if (cancelled) return; + setState({ snapshot, error: null }); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = err instanceof Error ? err.message : String(err); + log.debug("context cache snapshot unavailable", message); + setState({ snapshot: null, error: message }); + }); + return () => { + cancelled = true; + }; + }, [enabled, sessionId]); + + return state; +} diff --git a/src/engines/ChatPanel/InputArea/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index 5c296fa233..8a81cdfa21 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -420,6 +420,7 @@ const InputArea: React.FC = memo( onInterrupt={interruptSession} onResume={resumeSession} isCursorIde={isCursorIde} + sessionId={sessionId} /> ) : ( = memo( onInterrupt={interruptSession} onResume={resumeSession} isCursorIde={isCursorIde} + sessionId={sessionId} showVoiceUi={showVoiceUi} voice={voice} currentRepoPath={currentRepoPath} From 216efac0752f34d3766afaca347e28e64c9b60f6 Mon Sep 17 00:00:00 2001 From: Vinceorz <48338160+Vinceorz@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:52:04 -0700 Subject: [PATCH 054/864] fix(agent): wake idle parent on subagent completion; unblock turn end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background subagent that finished while its parent was idle never woke the parent's turn loop, so the result sat unread and the parent silently stopped. Separately, the "Planning…/working" footer could stay spinning for ~57s after the agent had clearly finished talking, because the post-turn memory extractors held a state lock across their LLM call and the next turn's completion signal blocked on that lock. Subagent wake (new `subagent_wake` process hook, installed at boot): - On background-subagent completion, push-wake the parent via `send_message_impl_for_subagent_wake` (empty-content resume → same round, no new bubble). A transient, never-persisted user nudge is injected in the turn processor only when the resume tail is an assistant message, satisfying provider prefill (Anthropic/OpenAI both reject an assistant-tailed conversation) without creating a visible message. - Single coordinator, two triggers, exactly-once: the completion push and the `finalize_session` turn-end re-check both call one coordinator that atomically claims the result via `registry::claim_subagent_wake_for_session` (marks `wake_dispatched` in the same locked pass). Whichever claims first delivers it; the other no-ops. This makes "a result wakes the parent at most once" a registry invariant rather than caller ordering, removing the earlier retry-storm / empty-wake guards. A claim taken while the parent is still running is released so the turn-end re-check can re-claim once idle. Post-turn no longer blocks turn completion: - `extract_memories`, `session_memory`, and `auto_dream` now move their whole body (incl. the gate pre-check locks) inside `tokio::spawn`, so the awaited dispatch returns instantly. - The extractors no longer hold their state mutex across the LLM call: brief-lock prepare (snapshot + flag) → lock-free LLM → brief-lock finalize (merge). SM tool-call counter merges via saturating_sub so concurrent increments are not lost. await_output precision (registry tombstones): - `remove` leaves a short-lived tombstone (real terminal status + kind, 10-min TTL, opportunistic prune). `resolve_status_with_tombstone` is three-way: live job / reaped-but-tombstoned (precise "finished") / unknown (real error). Replaces the old lenient resolver that synthesised Completed and guessed the kind from the handle string, so a just-reaped job reads "done" while a mistyped handle reports an error. Activity-indicator unification (footer): - One `classifyLatestTurnActivity` (idle / selfIndicating / liveSilent) is the single source of truth; `hasLiveRuntimeResourceInLatestTurn` (watchdog) and `hasRunningAwaitWaitForInLatestTurn` (footer suppression) derive from it, so they can no longer reason about await_output in opposite directions. A running wait_for is now live activity (watchdog won't kill it) and self-indicates (footer hides). Tests: job-registry wake-claim exactly-once + tombstone three-way (Rust); runningEventGate classifier + derived-boolean consistency, planning indicator suppression (FE). Verified live against opus-4-8: push-wake + poll-then-stop race both resume in the same round with no prefill 400 and exactly one wake each; turn completion fires ~6ms after talk-done while a 17s extraction runs independently in the background. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../model_context/session_memory/extract.rs | 39 ++- .../src/core/session/turn/post_turn.rs | 251 +++++++++------ .../src/core/session/turn/processor/mod.rs | 59 ++++ .../impls/coding/exec/await_tool/commands.rs | 24 +- .../impls/coding/exec/await_tool/params.rs | 21 +- .../core/tools/impls/coding/exec/registry.rs | 153 +++++++++- .../impls/orchestration/agent/background.rs | 44 ++- .../src/core/tools/impls/orchestration/mod.rs | 2 + .../impls/orchestration/subagent_wake.rs | 286 ++++++++++++++++++ .../core/tools/tests/job_registry_tests.rs | 143 +++++++++ src-tauri/crates/agent-core/src/lifecycle.rs | 17 ++ .../memory/workspace_memory/auto_dream.rs | 14 +- .../memory/workspace_memory/extract/runner.rs | 37 ++- .../memory/workspace_memory/extract/state.rs | 7 + .../src/state/commands/session/message.rs | 39 +++ src-tauri/crates/e2e-test/src/main.rs | 10 + src-tauri/crates/e2e-test/src/subagent.rs | 216 +++++++++++++ src-tauri/src/lib.rs | 13 + .../core/__tests__/runningEventGate.test.ts | 78 ++++- .../SessionCore/core/runningEventGate.ts | 118 ++++++-- .../derived/planningIndicatorAtoms.ts | 19 +- .../derived/sessionScopedChatEvents.ts | 15 +- .../hooks/replay/usePlanningIndicator.test.ts | 23 ++ .../hooks/replay/usePlanningIndicator.ts | 19 ++ 24 files changed, 1490 insertions(+), 157 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/tools/impls/orchestration/subagent_wake.rs diff --git a/src-tauri/crates/agent-core/src/core/model_context/session_memory/extract.rs b/src-tauri/crates/agent-core/src/core/model_context/session_memory/extract.rs index 8a4b3d4c0b..983baeba33 100644 --- a/src-tauri/crates/agent-core/src/core/model_context/session_memory/extract.rs +++ b/src-tauri/crates/agent-core/src/core/model_context/session_memory/extract.rs @@ -5,7 +5,10 @@ //! - [`find_last_safe_boundary`] — picks the highest message index safe to mark //! as the SM boundary (avoids splitting a tool_use → tool_result pair) +use std::sync::Arc; + use serde_json::Value; +use tokio::sync::Mutex; use tracing::{info, warn}; use super::config::SessionMemoryConfig; @@ -94,19 +97,30 @@ pub fn should_extract( /// content, and recent messages. Returns the updated SM markdown. pub async fn extract_session_memory( messages: &[Value], - state: &mut SessionMemoryState, + sm_state: Arc>, config: &SessionMemoryConfig, provider: &dyn LLMProvider, model: &str, ) -> Result { use crate::core::model_context::summarization; - state.extraction_in_progress = true; - - let start_idx = state - .last_summarized_msg_idx - .map(|idx| idx + 1) - .unwrap_or(0); + // ── Prepare: brief lock to snapshot the read-side state + flag the + // extraction as in-progress. The mutex is NOT held across the LLM call + // below — otherwise the next turn's brief `sm_state` reads (pre-turn + // compaction, the gate pre-check) would block for the whole extraction. + let (start_idx, existing_content, consumed_tool_calls) = { + let mut state = sm_state.lock().await; + state.extraction_in_progress = true; + let start_idx = state + .last_summarized_msg_idx + .map(|idx| idx + 1) + .unwrap_or(0); + ( + start_idx, + state.content.clone(), + state.tool_calls_since_extraction, + ) + }; let new_messages = if start_idx < messages.len() { &messages[start_idx..] @@ -116,7 +130,7 @@ pub async fn extract_session_memory( let mut user_content = String::new(); - let section_reminders = if let Some(ref existing) = state.content { + let section_reminders = if let Some(ref existing) = existing_content { user_content.push_str("\n"); user_content.push_str(existing); user_content.push_str("\n\n\n"); @@ -209,6 +223,11 @@ pub async fn extract_session_memory( let result = side_query::side_query(provider, &user_messages, &sq_config, model).await; + // ── Finalize: brief lock to merge the result back. Concurrent + // `record_tool_calls` increments that arrived while the LLM was running + // are preserved by subtracting only what we consumed at prepare time, + // rather than blindly resetting the counter to 0. + let mut state = sm_state.lock().await; state.extraction_in_progress = false; match result { @@ -226,7 +245,9 @@ pub async fn extract_session_memory( }; state.content = Some(sm_content.clone()); state.tokens_at_last_extraction = tokenizer::count_messages_tokens(messages); - state.tool_calls_since_extraction = 0; + state.tool_calls_since_extraction = state + .tool_calls_since_extraction + .saturating_sub(consumed_tool_calls); state.initialized = true; if let Some(last_safe_idx) = find_last_safe_boundary(messages) { diff --git a/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs b/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs index 05a770309a..83452f0cfd 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/post_turn.rs @@ -79,39 +79,54 @@ pub(super) async fn spawn_session_memory_extraction(input: SessionMemoryExtracti fork_provider, } = input; - let current_tokens = if prompt_tokens > 0 { - prompt_tokens as usize - } else { - crate::model_context::tokenizer::count_messages_tokens(messages) - }; - let has_tool_calls = session_memory::last_turn_has_tool_calls(messages); - let mut sm_state_guard = sm_state.lock().await; - - sm_state_guard.record_tool_calls(tool_calls_count as usize); - - if !session_memory::should_extract(&sm_state_guard, &sm_config, current_tokens, has_tool_calls) - { - return; - } - - info!( - "[unified_processor] Spawning async SM extraction for session {} (tokens={}, tc_since={})", - session_id, current_tokens, sm_state_guard.tool_calls_since_extraction - ); - drop(sm_state_guard); - + // Everything below — including the `sm_state.lock()` gate pre-check — + // runs inside a detached task. This is the turn-completion hot path: + // `dispatch_post_turn_work` awaits this fn and the scheduler only emits + // the idle queue-status (which hides the "Planning…" footer) AFTER + // `process()` returns. The previous version `await`ed the `sm_state.lock()` + // pre-check directly here, so when the PRIOR turn's extractor still held + // that lock across its (up-to-60s) LLM round-trip, this turn's completion + // signal was blocked for the whole extraction. Spawning the entire body + // keeps the function a true fire-and-forget so completion is instant. let sm_messages = messages.to_vec(); let sm_session_id = session_id.to_string(); tokio::spawn(async move { + let current_tokens = if prompt_tokens > 0 { + prompt_tokens as usize + } else { + crate::model_context::tokenizer::count_messages_tokens(&sm_messages) + }; + let has_tool_calls = session_memory::last_turn_has_tool_calls(&sm_messages); + let mut sm_state_guard = sm_state.lock().await; + + sm_state_guard.record_tool_calls(tool_calls_count as usize); + + if !session_memory::should_extract( + &sm_state_guard, + &sm_config, + current_tokens, + has_tool_calls, + ) { + return; + } + + info!( + "[unified_processor] Spawning async SM extraction for session {} (tokens={}, tc_since={})", + sm_session_id, current_tokens, sm_state_guard.tool_calls_since_extraction + ); + drop(sm_state_guard); + const SM_TIMEOUT: Duration = Duration::from_secs(60); let extraction = async { let provider = fresh_fork_provider(&fork_provider).await?; - let mut state = sm_state.lock().await; + // `extract_session_memory` now manages the `sm_state` lock + // internally (brief prepare + finalize, never across the LLM + // call), so we pass the Arc instead of holding the guard here. let result = session_memory::extract_session_memory( &sm_messages, - &mut state, + sm_state.clone(), &sm_config, provider.as_ref(), &fork_provider.model, @@ -121,7 +136,7 @@ pub(super) async fn spawn_session_memory_extraction(input: SessionMemoryExtracti if let Ok(ref sm_content) = result { let sid = sm_session_id.clone(); let content = sm_content.clone(); - let last_idx = state.last_summarized_msg_idx; + let last_idx = sm_state.lock().await.last_summarized_msg_idx; tokio::task::block_in_place(|| { if let Err(err) = unified_persistence::save_session_memory_state(&sid, &content, last_idx) @@ -219,6 +234,54 @@ pub(super) async fn spawn_extract_memories(input: ExtractMemoriesInput<'_>) { } let messages = em_messages; + // Everything below — including the gate pre-checks that lock `em_state` — + // runs inside a detached task. This is the turn-completion hot path: + // `dispatch_post_turn_work` awaits this fn, and the scheduler only + // broadcasts the idle queue-status (which hides the "Planning…" footer) + // AFTER `process()` returns. The previous version `await`ed the + // `em_state.lock()` gate pre-checks directly here, so when the PRIOR + // turn's extractor still held that lock across its (minutes-long) LLM + // round-trip, this turn's completion signal was blocked for the whole + // extraction — the footer kept spinning "Figuring out what to do next…" + // long after the agent had clearly finished. Spawning the entire body + // keeps the function a true fire-and-forget so completion is instant. + let sid = session_id.to_string(); + tokio::spawn(async move { + run_extract_memories_task(RunExtractMemoriesTask { + session_id: sid, + ws_path, + messages, + em_state, + fork_provider, + tool_registry, + }) + .await; + }); +} + +struct RunExtractMemoriesTask { + session_id: String, + ws_path: PathBuf, + messages: Vec, + em_state: Arc>, + fork_provider: ForkProviderSpec, + tool_registry: Arc, +} + +/// Owned-data body of [`spawn_extract_memories`], run inside a detached task. +/// +/// Performs the gate pre-checks (Stages 1–2) and then the extraction loop. +/// All `em_state` lock contention lives here, off the turn-completion path. +async fn run_extract_memories_task(task: RunExtractMemoriesTask) { + let RunExtractMemoriesTask { + session_id, + ws_path, + messages, + em_state, + fork_provider, + tool_registry, + } = task; + // Stage 1: main agent wrote memory → skip + advance cursor. let main_wrote = { let mut state = em_state.lock().await; @@ -252,59 +315,61 @@ pub(super) async fn spawn_extract_memories(input: ExtractMemoriesInput<'_>) { return; } - let sid = session_id.to_string(); + let sid = session_id; info!( "[unified_processor] Spawning extract_memories for session {}", sid ); - tokio::spawn(async move { - // Loop until no pending trailing transcript remains. Each - // iteration runs the extractor on the current transcript and, if - // a new transcript was stashed while we were running, picks it up - // on the next pass — guaranteeing every transcript is processed - // even when extractions arrive faster than they finish. - let mut current_msgs = messages; - loop { - { - let mut state = em_state.lock().await; - let provider = match fresh_fork_provider(&fork_provider).await { - Ok(provider) => provider, - Err(err) => { - warn!("[extract_memories] Failed for session {}: {}", sid, err); - break; - } - }; - let params = crate::memory::MemoryAgentParams { - messages: ¤t_msgs, - provider, - model: &fork_provider.model, - workspace: &ws_path, - parent_tools: tool_registry.clone(), - session_id: &sid, - definitions_store: None, - }; - if let Err(err) = extract_memories::run_extraction(&mut state, params).await { - warn!("[extract_memories] Failed for session {}: {}", sid, err); - // Still drain pending to avoid stashed work becoming stuck. - } + // Already inside a detached task (see `spawn_extract_memories`); run the + // extraction loop inline. Loop until no pending trailing transcript + // remains. Each iteration runs the extractor on the current transcript + // and, if a new transcript was stashed while we were running, picks it up + // on the next pass — guaranteeing every transcript is processed even when + // extractions arrive faster than they finish. + let mut current_msgs = messages; + loop { + // `fresh_fork_provider` and `run_extraction` both run WITHOUT holding + // `em_state` — provider creation can do a network preflight and the + // extractor runs a multi-iteration forked agent, neither of which may + // block the next turn's brief `em_state` reads. `run_extraction` + // manages the lock internally (brief prepare + finalize). + let provider = match fresh_fork_provider(&fork_provider).await { + Ok(provider) => provider, + Err(err) => { + warn!("[extract_memories] Failed for session {}: {}", sid, err); + em_state.lock().await.clear_in_progress(); + break; } - let trailing = { - let mut state = em_state.lock().await; - extract_memories::take_pending(&mut state) - }; - match trailing { - Some(next) => { - info!( - "[extract_memories] Running trailing extraction for session {}", - sid - ); - current_msgs = next; - } - None => break, + }; + let params = crate::memory::MemoryAgentParams { + messages: ¤t_msgs, + provider, + model: &fork_provider.model, + workspace: &ws_path, + parent_tools: tool_registry.clone(), + session_id: &sid, + definitions_store: None, + }; + if let Err(err) = extract_memories::run_extraction(em_state.clone(), params).await { + warn!("[extract_memories] Failed for session {}: {}", sid, err); + // Still drain pending to avoid stashed work becoming stuck. + } + let trailing = { + let mut state = em_state.lock().await; + extract_memories::take_pending(&mut state) + }; + match trailing { + Some(next) => { + info!( + "[extract_memories] Running trailing extraction for session {}", + sid + ); + current_msgs = next; } + None => break, } - }); + } } // ── Auto-dream consolidation (step 9d) ────────────────────────────── @@ -330,39 +395,45 @@ pub(super) async fn spawn_auto_dream(input: AutoDreamInput<'_>) { tool_registry, } = input; - let should_run = { - let state = ad_state.lock().await; - auto_dream::should_attempt(&state, &ws_path) - }; - if !should_run { - return; - } - + // Everything below — including the `ad_state.lock()` gate pre-check — + // runs inside a detached task. This is the turn-completion hot path: + // `dispatch_post_turn_work` awaits this fn and the scheduler only emits + // the idle queue-status (which hides the "Planning…" footer) AFTER + // `process()` returns. The previous version `await`ed the `ad_state.lock()` + // pre-check directly here, so when the PRIOR turn's consolidation still + // held that lock across its (minutes-long) LLM round-trip, this turn's + // completion signal was blocked. Spawning the entire body keeps the + // function a true fire-and-forget so completion is instant. let sid = session_id.to_string(); - info!( - "[unified_processor] Spawning auto_dream for session {}", - sid - ); - tokio::spawn(async move { - let provider = match fresh_fork_provider(&fork_provider).await { - Ok(provider) => provider, - Err(err) => { - warn!("[auto_dream] Failed for session {}: {}", sid, err); + // Brief lock ONLY for the throttle gate + advance — never held across + // the consolidation LLM call below. + { + let mut state = ad_state.lock().await; + if !auto_dream::should_attempt(&state, &ws_path) { return; } - }; - let mut state = ad_state.lock().await; + state.mark_scan_now(); + } + + info!("[unified_processor] Spawning auto_dream for session {}", sid); + let params = crate::memory::MemoryAgentParams { messages: &messages, - provider, + provider: match fresh_fork_provider(&fork_provider).await { + Ok(provider) => provider, + Err(err) => { + warn!("[auto_dream] Failed for session {}: {}", sid, err); + return; + } + }, model: &fork_provider.model, workspace: &ws_path, parent_tools: tool_registry, session_id: &sid, definitions_store: None, }; - if let Err(err) = auto_dream::run_consolidation(&mut state, params).await { + if let Err(err) = auto_dream::run_consolidation(params).await { warn!("[auto_dream] Failed for session {}: {}", sid, err); } }); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index 8ff3ee3188..dbe1b6e6bc 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -638,6 +638,24 @@ impl UnifiedMessageProcessor { if let Some(guard) = inbox_guard.take() { guard.commit(); } + + // 4d. Subagent-wake prefill safety net. + // + // A background-subagent completion resumes the parent with empty + // content (no persisted user row → same round, no new bubble). But a + // plain SDE session has no inbox_drain to append a trailing user + // message, so the conversation can still end on the parent's last + // assistant turn ("已在后台启动。"). Providers (Anthropic, OpenAI) + // reject that with HTTP 400 "conversation must end with a user + // message". When a resume leaves an assistant-tailed message list, + // append a single TRANSIENT user nudge — in-memory only, never + // persisted, so it neither creates a round nor a visible bubble. + // Mirrors inbox_drain's transient injection, generalized to the SDE + // path. + if context.is_resume { + Self::inject_subagent_wake_nudge_if_needed(&mut messages, session_id); + } + // 5/5b/6. Pre-turn message-list compaction (microcompact + // aggregate budget + LLM context compaction + compact-fork). if let CompactionPhaseOutcome::ForkRedirect(redirect) = self @@ -757,6 +775,47 @@ impl UnifiedMessageProcessor { fork_redirect: None, }) } + + /// Append a transient, in-memory-only trailing user message when a resumed + /// turn's assembled message list still ends on an assistant turn. + /// + /// Background-subagent wakes resume the parent with empty content (so no + /// user row is persisted and no new round is created), but a plain SDE + /// session has no inbox_drain to supply the trailing user message that + /// providers require ("conversation must end with a user message"). This + /// closes that gap without persisting anything: the nudge lives only in + /// the provider request, never in the DB or the UI, so the parent + /// continues in the SAME round with no synthetic bubble. + /// + /// No-op unless the last non-system message is an assistant message — + /// normal resumes (e.g. mode-switch) that already end on a user or tool + /// message are left untouched. + fn inject_subagent_wake_nudge_if_needed(messages: &mut Vec, session_id: &str) { + let last_non_system_role = messages + .iter() + .rev() + .find_map(|m| m.get("role").and_then(|v| v.as_str())) + .filter(|role| *role != "system"); + + if last_non_system_role != Some("assistant") { + return; + } + + const WAKE_NUDGE: &str = "A background subagent you launched has \ + finished. Its result is now available in the Background Jobs list above. Read the \ + completed worker's output and continue the task you were doing — do not re-launch \ + it."; + + messages.push(serde_json::json!({ + "role": "user", + "content": WAKE_NUDGE, + })); + + info!( + "[unified_processor] Injected transient subagent-wake nudge to satisfy prefill (session={})", + session_id + ); + } } /// Pure helper: should post-turn background work (session memory extraction, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/commands.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/commands.rs index e96d9ade8a..599a3f8d3a 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/commands.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/commands.rs @@ -11,8 +11,8 @@ use tokio::time::Instant; use super::super::registry; use super::body::{find_match_line, read_body}; use super::params::{ - lookup_job, parse_handles, parse_tail_lines, parse_wait_mode, WaitMode, DEFAULT_BLOCK_MS, - POLL_INTERVAL_MS, + parse_handles, parse_tail_lines, parse_wait_mode, resolve_job_or_unknown, WaitMode, + DEFAULT_BLOCK_MS, POLL_INTERVAL_MS, }; use super::response::{build_list_response, build_response}; use super::snapshot::{running_snapshot, terminal_snapshot, HandleSnapshot, AWAIT_STATUS_RUNNING}; @@ -52,10 +52,13 @@ impl AwaitTool { None }; - // Resolve all handles up-front so missing ones fail fast with a clean error. + // Resolve all handles up-front. A vanished-but-tombstoned handle + // resolves to its real terminal status (precise "it finished"); a + // genuinely unknown handle returns an error so the agent learns it + // mistyped, instead of being told a non-existent job "completed". let jobs: Vec<(String, registry::JobKind)> = handles .iter() - .map(|h| lookup_job(h).map(|(_, kind)| (h.clone(), kind))) + .map(|h| resolve_job_or_unknown(h).map(|(_, kind)| (h.clone(), kind))) .collect::>()?; // Non-blocking call (block_until_ms=0): return immediate snapshots. @@ -191,7 +194,13 @@ impl AwaitTool { } None => { registry::acknowledge_output(h); - terminal_snapshot(h, kind, ®istry::JobStatus::Completed, body) + // Reaped between resolution and now: use the tombstoned + // terminal status when available (precise), else fall + // back to Completed (the job is gone, so it's done). + let status = registry::resolve_status_with_tombstone(h) + .map(|(s, _)| s) + .unwrap_or(registry::JobStatus::Completed); + terminal_snapshot(h, kind, &status, body) } Some(_) => { let matched = regex.as_ref().map(|re| re.is_match(&body)); @@ -244,7 +253,10 @@ impl AwaitTool { let snapshots: Vec = handles .iter() .map(|h| { - let (status, kind) = lookup_job(h)?; + // A reaped-but-tombstoned job renders with its real terminal + // status; a genuinely unknown handle errors so the agent learns + // it mistyped rather than seeing a fake "completed". + let (status, kind) = resolve_job_or_unknown(h)?; let body = read_body(h, &kind); if !matches!(status, registry::JobStatus::Running) { registry::acknowledge_output(h); diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/params.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/params.rs index 93aa8f8a43..fd5ad87046 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/params.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/await_tool/params.rs @@ -101,12 +101,27 @@ pub(super) fn parse_tail_lines(params: &Value) -> usize { .unwrap_or(DEFAULT_TAIL_LINES) } -pub(super) fn lookup_job( +/// Resolve a handle's status + kind, consulting the tombstone map for jobs +/// that already finished and were reaped from the live registry. +/// +/// Three outcomes (see [`registry::resolve_status_with_tombstone`]): +/// - live job → its real `(status, kind)`. +/// - reaped-but-tombstoned job → its real terminal `(status, kind)` — a precise +/// "it finished" answer (kind is the actual recorded kind, not a guess). +/// - genuinely unknown handle → `Err`, so the caller reports a real error +/// instead of pretending a typo'd handle "completed". +/// +/// This replaces the earlier lenient resolver that synthesised a `Completed` +/// status and *guessed* the kind from the handle shape, which could not tell a +/// just-reaped job from a mistyped handle. +pub(super) fn resolve_job_or_unknown( handle: &str, ) -> Result<(registry::JobStatus, registry::JobKind), ToolError> { - registry::get_status(handle).ok_or_else(|| { + registry::resolve_status_with_tombstone(handle).ok_or_else(|| { ToolError::ExecutionFailed(format!( - "No background job with handle \"{}\". It may have already completed and been cleaned up.", + "No background job with handle \"{}\". The handle is unknown — it was never \ + registered, or it finished long enough ago that its record has expired. \ + Check the handle, or call await_output(command=\"list\") to see active jobs.", handle )) }) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs index 3f7413c19d..4902ff9cd6 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/registry.rs @@ -76,6 +76,17 @@ pub struct BackgroundJob { /// from the per-turn system reminder to avoid the stale-reminder /// problem common to background bash notifications. output_acknowledged: bool, + /// Set to `true` once a parent-session wake has been dispatched to deliver + /// this (completed) subagent's result. Distinct from `output_acknowledged`: + /// dispatch means "we resumed the idle parent so it COULD read the result", + /// ack means "the agent actually read it via await_output". Together they + /// make the subagent-wake coordinator behaviour-independent and exactly-once: + /// a result triggers AT MOST ONE wake dispatch, regardless of whether the + /// woken agent goes on to read it. This single flag subsumes both the + /// empty-wake loop (woken parent ignores the result → no re-wake) and the + /// retry storm (a failed wake turn → no re-wake for the same result). + /// Always `false` for shell jobs (only subagents trigger parent wakes). + wake_dispatched: bool, } impl BackgroundJob { @@ -131,6 +142,27 @@ const BROADCAST_CAPACITY: usize = 512; static REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// How long a finished job's tombstone is retained after it leaves the live +/// registry. Long enough that an `await_output` arriving just after the grace +/// eviction still gets a precise "completed" answer (with the real kind), short +/// enough that the map cannot grow unbounded. Distinct from the live-job +/// retention window in `background.rs`. +const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60); + +/// A lightweight record of a job that has left the live registry. Lets +/// `await_output` distinguish "this handle finished and was reaped" (precise +/// terminal status + real kind) from "this handle never existed" (the agent +/// mistyped it), instead of synthesising a guess from the handle string. +#[derive(Clone)] +struct Tombstone { + status: JobStatus, + kind: JobKind, + created_at: Instant, +} + +static TOMBSTONES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + /// Register a backgrounded shell process. Returns a `broadcast::Sender` the /// caller should use to feed live output lines. pub fn register_shell( @@ -155,6 +187,7 @@ pub fn register_shell( join_handle: None, cancel_flag: None, output_acknowledged: false, + wake_dispatched: false, }; let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); reg.insert(handle, job); @@ -215,6 +248,7 @@ pub fn register_subagent_with_flag( join_handle: None, cancel_flag: Some(Arc::clone(&cancel_flag)), output_acknowledged: false, + wake_dispatched: false, }; let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); reg.insert(handle.clone(), job); @@ -293,9 +327,30 @@ pub fn set_final_result(handle: &str, result: String) { } /// Remove a job from the registry (called after grace period). +/// +/// Leaves a short-lived [`Tombstone`] behind so a late `await_output` can +/// still report a precise terminal status with the real job kind, rather than +/// the caller having to guess from the handle shape. Opportunistically prunes +/// expired tombstones on the same pass so the map stays bounded without a +/// dedicated reaper. pub fn remove(handle: &str) { - let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); - reg.remove(handle); + let removed = { + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + reg.remove(handle) + }; + if let Some(job) = removed { + let mut tombs = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + let now = Instant::now(); + tombs.retain(|_, t| now.duration_since(t.created_at) < TOMBSTONE_TTL); + tombs.insert( + handle.to_string(), + Tombstone { + status: job.status.clone(), + kind: job.kind.clone(), + created_at: now, + }, + ); + } } /// Retrieve a snapshot of job metadata. Returns `None` if not found. @@ -305,6 +360,33 @@ pub fn get_status(handle: &str) -> Option<(JobStatus, JobKind)> { .map(|job| (job.status.clone(), job.kind.clone())) } +/// Resolve a handle's terminal status + kind, consulting the tombstone map +/// when the job has already left the live registry. +/// +/// Three-way outcome: +/// - **live job present** → its real `(status, kind)`. +/// - **tombstone present (not expired)** → the reaped job's real terminal +/// `(status, kind)` — a precise "it finished" answer. +/// - **neither** → `None`, meaning the handle genuinely never existed (or its +/// tombstone expired): the caller can report a real "unknown handle" error. +/// +/// This replaces the old "synthesise a Completed status and guess the kind from +/// the handle string" heuristic, which could not tell a just-reaped job from a +/// typo. +pub fn resolve_status_with_tombstone(handle: &str) -> Option<(JobStatus, JobKind)> { + if let Some(found) = get_status(handle) { + return Some(found); + } + let tombs = TOMBSTONES.lock().unwrap_or_else(|e| e.into_inner()); + tombs.get(handle).and_then(|t| { + if Instant::now().duration_since(t.created_at) < TOMBSTONE_TTL { + Some((t.status.clone(), t.kind.clone())) + } else { + None + } + }) +} + /// Get the final result text for a job. pub fn get_final_result(handle: &str) -> Option { let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); @@ -353,6 +435,14 @@ pub fn acknowledge_output(handle: &str) { } } +/// Whether a job's output has been acknowledged (read via the reminder / +/// await path). Returns `None` if the handle is no longer in the registry — +/// callers treat a missing job as "nothing left to retain" (acknowledged). +pub fn is_output_acknowledged(handle: &str) -> Option { + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + reg.get(handle).map(|job| job.output_acknowledged) +} + /// List jobs that should appear in the per-turn system reminder. /// /// Includes: @@ -371,6 +461,65 @@ pub fn list_jobs_for_reminder(session_id: &str) -> Vec { .collect() } +/// Atomically claim every completed-but-unconsumed **subagent** job for +/// `session_id` that has not already had a parent wake dispatched, marking +/// each as `wake_dispatched` and returning whether any were claimed. +/// +/// This is the single exactly-once primitive behind the subagent-wake +/// coordinator. "Needs a wake" means the job is: +/// * a subagent (shells never wake the parent), +/// * finished (not running), +/// * not yet acknowledged (the agent hasn't read it via await_output), and +/// * not yet wake-dispatched (no prior wake already delivered it). +/// +/// Marking `wake_dispatched = true` in the same locked pass guarantees a +/// given result triggers AT MOST ONE wake, no matter how many triggers fire +/// (the completion push AND the turn-end re-check both call this; whichever +/// runs first claims it, the other sees nothing). This makes exactly-once an +/// invariant of the registry, not of caller ordering — and subsumes both the +/// empty-wake loop and the failed-wake retry storm without any `response.is_ok` +/// / status gating in the callers. +/// +/// Returns `true` if at least one job was newly claimed (caller should +/// dispatch a wake), `false` if there was nothing new to deliver. +pub fn claim_subagent_wake_for_session(session_id: &str) -> bool { + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + let mut claimed = false; + for job in reg.values_mut() { + if job.session_id == session_id + && matches!(job.kind, JobKind::Subagent { .. }) + && !job.is_running() + && !job.output_acknowledged + && !job.wake_dispatched + { + job.wake_dispatched = true; + claimed = true; + } + } + claimed +} + +/// Release a wake claim previously taken by `claim_subagent_wake_for_session` +/// for every completed-unconsumed subagent of `session_id`. +/// +/// Used when the coordinator claimed a result but then found the parent was +/// still running (so it could not dispatch a resume turn). Releasing restores +/// `wake_dispatched = false` so the turn-end re-check can re-claim it once the +/// parent goes idle. Only clears the flag on jobs that are still unconsumed — +/// an already-acknowledged job needs no further wake regardless. +pub fn release_subagent_wake_for_session(session_id: &str) { + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + for job in reg.values_mut() { + if job.session_id == session_id + && matches!(job.kind, JobKind::Subagent { .. }) + && !job.is_running() + && !job.output_acknowledged + { + job.wake_dispatched = false; + } + } +} + /// Lightweight snapshot of a running shell job, suitable for frontend /// reconciliation on reload. Only includes shell jobs with `Running` status. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/background.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/background.rs index 5cbb89c373..120d2d95cb 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/background.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/background.rs @@ -77,6 +77,7 @@ impl AgentTool { } = args; let bg_session_id = subagent_session_id.clone(); + let bg_parent_session_id = parent_session_id.clone(); let bg_agent_name = agent.name.clone(); let bg_model = model; let bg_provider = provider; @@ -259,6 +260,17 @@ impl AgentTool { // Disarm so the guard's Drop does not overwrite it with Failed. finalize_guard.disarm(); + // Push completion to the (possibly idle) parent. When the parent + // launched this worker in background and then ended its own turn, + // there is no active parent turn to surface the result via the + // Background Jobs reminder. The wake hook resumes the parent's + // turn loop so it consumes the result; it is a no-op when the + // parent is still running (the next turn's reminder covers that) + // or when no app handle is installed (headless / tests). Mirrors + // Claude Code's task-notification → idle-queue-processor design. + crate::tools::impls::orchestration::subagent_wake::current_subagent_completion_wake_hook() + .wake_parent(&bg_parent_session_id); + // Clean up worktree isolation after the task completes. // Runs before the grace-period sleep so `await_output` callers // see the result before the disk is cleaned up, and the worktree @@ -277,8 +289,36 @@ impl AgentTool { } } - // Remove from registry after grace period - tokio::time::sleep(Duration::from_secs(120)).await; + // Remove from registry once the parent has consumed the result, + // or after a hard cap if it never does. + // + // The old behaviour — an unconditional 120s sleep then remove — + // raced the parent: a worker that finished while the parent was + // idle (and slow to take its next turn) had its result deleted + // before the Background Jobs reminder could ever surface it, so + // the parent never learned the worker completed. Now we retain + // the job until `acknowledge_output` is called (the reminder / + // await path marks it read), polling at a coarse interval, with + // a hard upper bound so a parent that never returns cannot leak + // the entry forever. + const ACK_POLL_INTERVAL: Duration = Duration::from_secs(5); + const MAX_RETENTION: Duration = Duration::from_secs(30 * 60); + let retain_deadline = std::time::Instant::now() + MAX_RETENTION; + loop { + tokio::time::sleep(ACK_POLL_INTERVAL).await; + // Missing (already removed elsewhere) or acknowledged → done. + match job_registry::is_output_acknowledged(&bg_session_id) { + None | Some(true) => break, + Some(false) => {} + } + if std::time::Instant::now() >= retain_deadline { + warn!( + "[agent:bg] '{}' result was never acknowledged within retention window; evicting", + bg_session_id + ); + break; + } + } job_registry::remove(&bg_session_id); }); diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/mod.rs index 89f6f2b5dc..d2bb3aedf9 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/mod.rs @@ -17,6 +17,7 @@ //! Helpers: //! - [`context_builders`] — shared context-builder helpers used by orchestration tools //! - [`subagent_handler`] — shared Agent-worker event handler for Delegate/Shadow runs (used by `agent`) +//! - [`subagent_wake`] — process-wide hook to wake an idle parent when a background subagent completes pub mod agent; pub mod agent_org; @@ -29,6 +30,7 @@ pub mod manage_session; pub mod member_idle; pub mod member_shutdown; pub mod subagent_handler; +pub mod subagent_wake; pub mod suggest_mode_switch; pub mod suggest_next_steps; diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/subagent_wake.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/subagent_wake.rs new file mode 100644 index 0000000000..17108786cd --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/subagent_wake.rs @@ -0,0 +1,286 @@ +//! `SubagentCompletionWakeHook` trait + process-wide `OnceLock` install slot. +//! +//! # Problem this solves +//! +//! When a parent agent launches a **background** subagent and then ends its +//! own turn (e.g. it asked the user a question and went idle), the subagent +//! finishes minutes later with no active parent turn to surface its result. +//! Until the parent takes another turn, the completed subagent's output sits +//! unread — and the 120s registry grace period can delete it first. The +//! result: the parent never learns the subagent finished and silently does +//! not continue. +//! +//! # How the wake works +//! +//! This mirrors Claude Code's `task-notification` → idle-queue-processor +//! design (`tasks/LocalAgentTask.tsx` enqueues a notification; `useQueueProcessor` +//! auto-starts a turn when the parent loop is idle). ORGII already has the +//! equivalent restart primitive in `send_message_impl_for_subagent_wake(session_id)` +//! (a sibling of the Agent Org `InboxWakeHook`'s `send_message_impl_for_wake`). +//! This hook lets the background-subagent completion path reach it without +//! `background.rs` (which lives below the Tauri layer) needing an `AppHandle`. +//! +//! # Single coordinator, two triggers, exactly-once +//! +//! Two triggers can observe a completed background subagent: +//! 1. the completion push from `background.rs` (fires the moment the worker +//! terminates), and +//! 2. the turn-end re-check in `lifecycle::finalize_session` (fires when the +//! parent's own turn ends, covering the case where the worker finished +//! while the parent was still mid-turn). +//! +//! Both call the SAME coordinator (`wake_parent` → `wake_parent_session`), +//! which owns the entire decision. It does not carry per-trigger gates; instead +//! it atomically *claims* the result via +//! `registry::claim_subagent_wake_for_session` (which marks the job +//! `wake_dispatched` in the same locked pass). Whichever trigger claims first +//! delivers it; the other sees nothing. This makes "a result wakes the parent +//! at most once" an invariant of the registry rather than of caller ordering, +//! and removes the earlier ad-hoc retry-storm / empty-wake guards. +//! +//! The production implementation (installed at app boot in `lib.rs`) resolves +//! the parent session's status after claiming: if the parent is idle/terminal +//! it dispatches a resume turn; if it is still running it RELEASES the claim +//! (so the turn-end re-check can re-claim once the parent goes idle), because a +//! running parent will otherwise pick the result up via its current turn's +//! Background Jobs reminder. The status gate is `should_wake_parent`. + +use std::sync::{Arc, OnceLock}; + +/// Hook invoked when a background subagent reaches a terminal state, so the +/// (possibly idle) parent session can be woken to consume the result. +pub trait SubagentCompletionWakeHook: Send + Sync { + /// Wake `parent_session_id` if it is idle/terminal. Implementations must + /// be safe to call unconditionally: a parent that is still running, or a + /// missing/headless app handle, is a silent no-op (the result remains in + /// the registry for the next turn's reminder). + fn wake_parent(&self, parent_session_id: &str); +} + +/// No-op hook for early boot / headless / unit-test contexts where there is +/// no real session runtime to wake. +pub struct NoopSubagentCompletionWakeHook; + +impl SubagentCompletionWakeHook for NoopSubagentCompletionWakeHook { + fn wake_parent(&self, _parent_session_id: &str) {} +} + +/// Process-wide hook installed by the boot path (`lib.rs`). Looked up at +/// subagent-completion time. Idempotent after the first install. +static SUBAGENT_WAKE_HOOK: OnceLock> = OnceLock::new(); + +/// Install the production [`SubagentCompletionWakeHook`] at app boot. +/// Idempotent after the first install (subsequent calls are a no-op). +pub fn install_subagent_completion_wake_hook(hook: Arc) { + let _ = SUBAGENT_WAKE_HOOK.set(hook); +} + +/// Resolve the active hook, falling back to the no-op hook if nothing has +/// been installed yet (early boot, headless / unit-test contexts). +pub fn current_subagent_completion_wake_hook() -> Arc { + SUBAGENT_WAKE_HOOK + .get() + .cloned() + .unwrap_or_else(|| Arc::new(NoopSubagentCompletionWakeHook) as Arc) +} + +/// Statuses for which waking the parent is useful. A `Running` parent will +/// pick the completed subagent up via its next turn's Background Jobs +/// reminder, so re-dispatching a turn would be redundant (and `send_message` +/// would reject a second in-flight turn anyway). Mirrors +/// `inbox_wake::should_dispatch_wake`. +fn should_wake_parent(status: crate::core::session::SessionStatus) -> bool { + use crate::core::session::SessionStatus; + matches!( + status, + SessionStatus::Idle + | SessionStatus::Completed + | SessionStatus::Failed + | SessionStatus::Cancelled + | SessionStatus::Abandoned + | SessionStatus::Timeout + ) +} + +/// Production [`SubagentCompletionWakeHook`] backed by [`AgentAppState`]. +/// +/// On `wake_parent`, resolves the parent session's persisted status and, when +/// it is idle/terminal, fires `send_message_impl_for_subagent_wake(parent_session_id)` +/// on a detached Tokio task. The resumed turn opens with the Background Jobs +/// reminder carrying the completed subagent's "unread output" entry, so the +/// parent agent reads the result and continues. +/// +/// Safe to call unconditionally: a running parent, a missing app state, or a +/// status lookup failure is logged and swallowed — the subagent result stays +/// in the registry for the next organic turn's reminder. +pub struct AppHandleSubagentCompletionWakeHook { + app_handle: tauri::AppHandle, +} + +impl AppHandleSubagentCompletionWakeHook { + pub fn new(app_handle: tauri::AppHandle) -> Arc { + Arc::new(Self { app_handle }) + } +} + +impl SubagentCompletionWakeHook for AppHandleSubagentCompletionWakeHook { + fn wake_parent(&self, parent_session_id: &str) { + let parent = parent_session_id.to_string(); + let app_handle = self.app_handle.clone(); + tokio::spawn(async move { + wake_parent_session(app_handle, parent).await; + }); + } +} + +async fn wake_parent_session(app_handle: tauri::AppHandle, parent_session_id: String) { + use tauri::Manager; + + // Exactly-once claim: mark any completed-unconsumed subagent result for + // this parent as wake-dispatched, in one atomic registry pass. If nothing + // was claimed, another trigger already delivered it (or there is nothing + // to deliver) — return without dispatching. This is what makes the two + // wake triggers (completion push + turn-end re-check) collapse to a single + // coordinator with one shared decision, instead of each carrying its own + // ad-hoc gate. + let claimed = tokio::task::spawn_blocking({ + let sid = parent_session_id.clone(); + move || crate::tools::impls::coding::exec::registry::claim_subagent_wake_for_session(&sid) + }) + .await + .unwrap_or(false); + + if !claimed { + return; + } + + // Resolve the parent's persisted status off the async runtime thread. + let lookup = { + let sid = parent_session_id.clone(); + tokio::task::spawn_blocking(move || { + crate::core::session::persistence::get_session(&sid) + }) + .await + }; + + let status = match lookup { + Ok(Ok(Some(record))) => crate::core::session::SessionStatus::parse(&record.status), + Ok(Ok(None)) => { + tracing::info!( + parent_session_id = %parent_session_id, + "[subagent_wake] parent session not found; skipping wake" + ); + return; + } + Ok(Err(err)) => { + tracing::warn!( + parent_session_id = %parent_session_id, + error = %err, + "[subagent_wake] parent status lookup failed; skipping wake" + ); + return; + } + Err(join_err) => { + tracing::warn!( + parent_session_id = %parent_session_id, + error = %join_err, + "[subagent_wake] parent status lookup task panicked; skipping wake" + ); + return; + } + }; + + let Some(status) = status else { + tracing::warn!( + parent_session_id = %parent_session_id, + "[subagent_wake] parent has an unrecognized status string; skipping wake" + ); + return; + }; + + if !should_wake_parent(status) { + // Parent is still running: it will see the result via its current + // turn's Background Jobs reminder, OR — if the worker finished after + // the reminder was already built — via the turn-end re-check, which + // calls back into this coordinator once the turn goes idle. The claim + // above is NOT a problem here: a running parent that ends without + // reading the result re-claims nothing (still unacknowledged) only if + // we DON'T mark dispatched. So we must release the claim so the + // turn-end re-check can pick it up. + tracing::info!( + parent_session_id = %parent_session_id, + status = status.as_str(), + "[subagent_wake] parent still running; releasing claim for turn-end re-check" + ); + let _ = tokio::task::spawn_blocking({ + let sid = parent_session_id.clone(); + move || { + crate::tools::impls::coding::exec::registry::release_subagent_wake_for_session(&sid) + } + }) + .await; + return; + } + + let state = match app_handle.try_state::() { + Some(s) => s, + None => { + tracing::warn!( + parent_session_id = %parent_session_id, + "[subagent_wake] AgentAppState not registered; cannot wake parent" + ); + return; + } + }; + + match crate::state::commands::session::message::send_message_impl_for_subagent_wake( + &state, + parent_session_id.clone(), + ) + .await + { + Ok(_) => tracing::info!( + parent_session_id = %parent_session_id, + "[subagent_wake] queued resume turn for idle parent after subagent completion" + ), + Err(err) => tracing::warn!( + parent_session_id = %parent_session_id, + error = %err, + "[subagent_wake] resume turn dispatch failed" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::session::SessionStatus; + + #[test] + fn wakes_idle_and_terminal_parents() { + for status in [ + SessionStatus::Idle, + SessionStatus::Completed, + SessionStatus::Failed, + SessionStatus::Cancelled, + SessionStatus::Abandoned, + SessionStatus::Timeout, + ] { + assert!(should_wake_parent(status), "status={}", status.as_str()); + } + } + + #[test] + fn does_not_wake_running_or_blocked_parents() { + for status in [ + SessionStatus::Running, + SessionStatus::Pending, + SessionStatus::Paused, + SessionStatus::WaitingForUser, + SessionStatus::WaitingForFunds, + SessionStatus::Archived, + ] { + assert!(!should_wake_parent(status), "status={}", status.as_str()); + } + } +} diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs index b370cff0f2..c8ea61dca8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/job_registry_tests.rs @@ -181,3 +181,146 @@ async fn test_cancel_subagents_for_session_scopes_to_session() { registry::remove(&mine); registry::remove(&other); } + +/// Wake-claim lifecycle: `claim_subagent_wake_for_session` is the exactly-once +/// signal the subagent-wake coordinator uses. It claims a finished, +/// not-acknowledged, not-yet-dispatched subagent result and marks it dispatched +/// in the same pass — so a second call returns false (no double wake). +#[test] +fn test_claim_subagent_wake_lifecycle() { + let session = "wake-claim-session"; + let handle = "agent-builtin:explore-wakeclaim".to_string(); + let (_tx, _cancel) = registry::register_subagent( + handle.clone(), + "delegate".into(), + "Explore".into(), + session.into(), + ); + + // Still running → nothing to claim. + assert!(!registry::claim_subagent_wake_for_session(session)); + + // Completed but unacknowledged → first claim succeeds. + registry::set_final_result(&handle, "explored 12 files".into()); + registry::mark_exited(&handle, JobStatus::Completed); + assert!(registry::claim_subagent_wake_for_session(session)); + + // EXACTLY-ONCE invariant: a second claim of the same result returns false, + // because the first marked it wake_dispatched. This is what makes the two + // wake triggers (completion push + turn-end re-check) collapse to a single + // dispatch regardless of ordering. + assert!( + !registry::claim_subagent_wake_for_session(session), + "a result must be claimable at most once" + ); + + // After release, it becomes claimable again (the running-parent path frees + // the claim so the turn-end re-check can pick it up). + registry::release_subagent_wake_for_session(session); + assert!(registry::claim_subagent_wake_for_session(session)); + + // Once acknowledged (the agent read it), no further claims fire. + registry::acknowledge_output(&handle); + registry::release_subagent_wake_for_session(session); + assert!( + !registry::claim_subagent_wake_for_session(session), + "an acknowledged result needs no wake" + ); + + // Other sessions are never matched. + assert!(!registry::claim_subagent_wake_for_session("some-other-session")); + + registry::remove(&handle); +} + +/// A finished **shell** job must NOT trigger a subagent wake — the coordinator +/// is subagent-specific (shells surface via the reminder only). +#[test] +fn test_claim_subagent_wake_ignores_shell_jobs() { + let session = "wake-claim-shell-session"; + let pid = 99997; + let _tx = registry::register_shell( + pid, + "build".into(), + PathBuf::from("/tmp/wakeclaim.txt"), + session.into(), + ); + let handle = pid.to_string(); + registry::mark_exited(&handle, JobStatus::Exited(0)); + + assert!( + !registry::claim_subagent_wake_for_session(session), + "a completed shell job must not be mistaken for an unconsumed subagent result" + ); + + registry::remove(&handle); +} + +/// Tombstone resolution: after a finished job is reaped via `remove`, a later +/// `resolve_status_with_tombstone` still reports its REAL terminal status and +/// kind (precise "it finished") — distinct from a genuinely-unknown handle, +/// which resolves to `None` (the agent mistyped it). +#[test] +fn test_tombstone_distinguishes_reaped_from_unknown() { + let session = "tombstone-session"; + let handle = "agent-builtin:explore-tombstone".to_string(); + let (_tx, _cancel) = registry::register_subagent( + handle.clone(), + "delegate".into(), + "Explore".into(), + session.into(), + ); + registry::set_final_result(&handle, "done".into()); + registry::mark_exited(&handle, JobStatus::Completed); + + // Live job present → resolves directly. + let live = registry::resolve_status_with_tombstone(&handle); + assert!(matches!( + live, + Some((JobStatus::Completed, JobKind::Subagent { .. })) + )); + + // Reap it. The tombstone must preserve the REAL terminal status + kind. + registry::remove(&handle); + assert!( + registry::get_status(&handle).is_none(), + "job should be gone from the live registry" + ); + let tomb = registry::resolve_status_with_tombstone(&handle); + assert!( + matches!(tomb, Some((JobStatus::Completed, JobKind::Subagent { .. }))), + "reaped job must resolve to its real terminal status + kind, got {:?}", + tomb.map(|(s, _)| s) + ); + + // A handle that was never registered resolves to None → caller errors. + assert!( + registry::resolve_status_with_tombstone("agent-never-existed-xyz").is_none(), + "an unknown handle must not be mistaken for a finished job" + ); +} + +/// A reaped **shell** job's tombstone preserves the real exit code, not a +/// synthesised `Completed` — so `await_output` reports `exit N` accurately even +/// after the live job is gone. +#[test] +fn test_tombstone_preserves_shell_exit_code() { + let session = "tombstone-shell-session"; + let pid = 99996; + let _tx = registry::register_shell( + pid, + "false".into(), + PathBuf::from("/tmp/tombstone-shell.txt"), + session.into(), + ); + let handle = pid.to_string(); + registry::mark_exited(&handle, JobStatus::Exited(1)); + registry::remove(&handle); + + let tomb = registry::resolve_status_with_tombstone(&handle); + assert!( + matches!(tomb, Some((JobStatus::Exited(1), JobKind::Shell { .. }))), + "tombstone must preserve the real exit code + shell kind, got {:?}", + tomb.map(|(s, _)| s) + ); +} diff --git a/src-tauri/crates/agent-core/src/lifecycle.rs b/src-tauri/crates/agent-core/src/lifecycle.rs index e2069cb444..113d130277 100644 --- a/src-tauri/crates/agent-core/src/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/lifecycle.rs @@ -501,6 +501,23 @@ pub async fn finalize_session( persist_session_error_event(app_handle, session_id, message); } + // Turn-end wake re-check (one of the two triggers feeding the single + // subagent-wake coordinator). A background subagent that completed while + // THIS turn was still running had its completion-push wake released back + // (the parent wasn't idle yet). Now that the turn has ended and the + // session row is idle/terminal, re-invoke the coordinator so the result is + // delivered. The coordinator is the sole decision point: it atomically + // claims the result (exactly-once across both triggers), checks the parent + // is wakeable, and dispatches — so this call is an unconditional no-op + // when there is nothing new to deliver. No `response.is_ok()` / + // unread-precheck gating here anymore: the claim flag makes re-waking a + // failed/ignored result impossible, which is what previously required the + // ad-hoc retry-storm guard. + if !is_agent_org_member_session { + crate::tools::impls::orchestration::subagent_wake::current_subagent_completion_wake_hook() + .wake_parent(session_id); + } + // NOTE: Error broadcasting is handled by the scheduler. Do NOT broadcast here // to avoid duplicate transient error notifications; this path only persists // the authoritative EventStore row for UI history/replay. diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs index e0327b43aa..2792bcf658 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/auto_dream.rs @@ -50,6 +50,17 @@ pub struct AutoDreamState { last_scan_at: Option, } +impl AutoDreamState { + /// Record that a scan/consolidation attempt is starting now. Set by the + /// post-turn dispatcher under a brief lock so the throttle advances even + /// though `run_consolidation` itself no longer holds the state mutex + /// (it must not — the consolidation LLM call would otherwise block the + /// next turn's brief `ad_state` reads). + pub fn mark_scan_now(&mut self) { + self.last_scan_at = Some(Instant::now()); + } +} + // ============================================ // Core Logic // ============================================ @@ -84,11 +95,8 @@ pub fn should_attempt(state: &AutoDreamState, workspace: &Path) -> bool { /// 4. On success, the lock mtime advances (recording consolidation) /// 5. On failure, rolls back the lock pub async fn run_consolidation( - state: &mut AutoDreamState, params: super::super::MemoryAgentParams<'_>, ) -> Result<(), String> { - state.last_scan_at = Some(Instant::now()); - let workspace = params.workspace; let mem_dir = super::memory_dir(workspace); diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs index 90842a22a5..5cc6247dd9 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/runner.rs @@ -7,6 +7,7 @@ //! `ExtractMemoriesState` once the fork returns. use std::sync::Arc; +use tokio::sync::Mutex; use tracing::{info, warn}; use crate::definitions::builtin::MEMORY_EXTRACTOR_ID; @@ -29,31 +30,44 @@ const MAX_EXTRACTION_TURNS: u32 = 5; /// 3. Has a restricted tool set (read-only + edit within memory dir) /// 4. Runs for at most MAX_EXTRACTION_TURNS iterations pub async fn run_extraction( - state: &mut ExtractMemoriesState, + em_state: Arc>, params: super::super::super::MemoryAgentParams<'_>, ) -> Result<(), String> { - state.in_progress = true; - state.turns_since_extraction = 0; + // ── Prepare: brief lock to flag in-progress + read the cursor. The mutex + // is NOT held across the fork/LLM call below — otherwise the next turn's + // brief `em_state` reads (the gate pre-check that decides whether to + // stash) would block for the whole extraction. The `in_progress` flag + // (kept true until finalize) is what preserves the "at most one extractor" + // invariant during the lock-free window. + let last_processed_idx = { + let mut state = em_state.lock().await; + state.in_progress = true; + state.turns_since_extraction = 0; + state.last_processed_idx + }; let workspace = params.workspace; let mem_dir = super::super::memory_dir(workspace); if let Err(err) = std::fs::create_dir_all(&mem_dir) { - state.in_progress = false; + em_state.lock().await.in_progress = false; return Err(format!("Failed to create memory dir: {}", err)); } let agent_def = - resolve_definition_by_id(MEMORY_EXTRACTOR_ID, params.definitions_store.as_deref()) - .map_err(|err| { - format!( + match resolve_definition_by_id(MEMORY_EXTRACTOR_ID, params.definitions_store.as_deref()) { + Ok(def) => def, + Err(err) => { + em_state.lock().await.in_progress = false; + return Err(format!( "Agent definition not found: {}: {}", MEMORY_EXTRACTOR_ID, err - ) - })?; + )); + } + }; let messages = params.messages; - let new_count = count_new_messages(messages, state.last_processed_idx); + let new_count = count_new_messages(messages, last_processed_idx); let existing_memories = super::super::format_memory_manifest(&super::super::scan_memory_files(&mem_dir)); let user_prompt = build_extraction_prompt(new_count, &existing_memories, &mem_dir); @@ -131,6 +145,9 @@ pub async fn run_extraction( ) .await; + // ── Finalize: brief lock to clear the in-progress flag + advance the + // cursor on success. + let mut state = em_state.lock().await; state.in_progress = false; match result { diff --git a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/state.rs b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/state.rs index 6241460ae5..fe271a41c8 100644 --- a/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/state.rs +++ b/src-tauri/crates/agent-core/src/specialization/memory/workspace_memory/extract/state.rs @@ -64,6 +64,13 @@ impl ExtractMemoriesState { pub fn is_in_progress(&self) -> bool { self.in_progress } + + /// Clear the overlap-guard flag. Used by the post-turn dispatcher when a + /// provider build fails before `run_extraction` is reached, so the guard + /// doesn't stay stuck `true` and block every future extraction. + pub fn clear_in_progress(&mut self) { + self.in_progress = false; + } } #[cfg(test)] diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message.rs b/src-tauri/crates/agent-core/src/state/commands/session/message.rs index 10b7db503b..139ab48112 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message.rs @@ -12,6 +12,45 @@ use crate::coordination::agent_member_interventions::{ AgentMemberInterventionStore, EnterMemberInterventionParams, DEFAULT_INTERVENTION_TTL_SECS, }; +/// Wake-only entry point for the **background-subagent** completion hook. +/// +/// Resumes the parent with **empty content** (`is_resume = true`), exactly +/// like the Agent Org inbox auto-resume — so NO new user message is persisted +/// and NO new chat round is created. The parent continues inside the same +/// round it was already in. +/// +/// A plain SDE session has no inbox to drain, so unlike the Agent Org path +/// nothing converts to a trailing user message on its own. That would leave +/// the conversation ending on the parent's last *assistant* message +/// ("已在后台启动。"), which providers reject with `HTTP 400: ... conversation +/// must end with a user message`. The unified processor closes that gap: on a +/// resume whose assembled message list still ends with an assistant turn, it +/// appends a **transient** (in-memory only, never persisted) user nudge so the +/// prefill invariant holds — see `inject_subagent_wake_nudge_if_needed` in +/// `turn/processor/mod.rs`. The actual subagent result still arrives via the +/// background-jobs system reminder. +pub async fn send_message_impl_for_subagent_wake( + state: &AgentAppState, + session_id: String, +) -> Result { + send_message_impl( + state, + session_id, + String::new(), + None, + IdentityOverrides::default(), + None, + None, + None, + true, + false, + None, + None, + TurnIntentBridgeSource::Resume, + ) + .await +} + /// Wake-only entry point for the inbox auto-resume hook. /// /// Equivalent to calling [`send_message_impl`] with empty content, diff --git a/src-tauri/crates/e2e-test/src/main.rs b/src-tauri/crates/e2e-test/src/main.rs index 5891896577..f706222c69 100644 --- a/src-tauri/crates/e2e-test/src/main.rs +++ b/src-tauri/crates/e2e-test/src/main.rs @@ -496,6 +496,16 @@ fn all_scenarios() -> Vec { "background-launch-msg-no-poll", subagent::background_launch_msg_no_poll ), + scenario!( + "subagent", + "subagent-completion-wakes-parent", + subagent::subagent_completion_wakes_parent + ), + scenario!( + "subagent", + "subagent-wake-race-after-poll", + subagent::subagent_wake_race_after_poll + ), // Housekeeping (deferred disk cleanup) scenario!( "housekeeping", diff --git a/src-tauri/crates/e2e-test/src/subagent.rs b/src-tauri/crates/e2e-test/src/subagent.rs index 3c74c5e809..971c5545e0 100644 --- a/src-tauri/crates/e2e-test/src/subagent.rs +++ b/src-tauri/crates/e2e-test/src/subagent.rs @@ -258,6 +258,222 @@ pub async fn dispatch_subagent_cannot_spawn_subagent(cfg: &Config) -> bool { ) } +/// Subagent-completion push-wake: a background subagent that finishes while +/// its parent is idle must wake the parent's turn loop so the result is +/// consumed — without this, the parent silently never continues. Mirrors +/// Claude Code's task-notification → idle-queue-processor wake. +/// +/// Flow: +/// turn 1 (no_cleanup): parent launches an Explore subagent with +/// `background:true` and is instructed to END ITS TURN IMMEDIATELY so it +/// goes idle while the worker is still running. +/// wait: poll the parent transcript. The production +/// `SubagentCompletionWakeHook` (installed in lib.rs) fires when the +/// worker terminates and resumes the parent via +/// `send_message_impl_for_subagent_wake`. The resumed turn carries the +/// Background Jobs reminder with the completed worker's unread output, so +/// the parent's message count grows AFTER the HTTP call returned. +/// +/// Assertions: +/// - turn 1 actually dispatched a background subagent (tool_calls has +/// `agent`), proving the worker path ran. +/// - the parent transcript GROWS after going idle (the auto-woken turn), +/// proving the push-wake fired without any second user message. +pub async fn subagent_completion_wakes_parent(cfg: &Config) -> bool { + let session_id = format!("{}-wake-parent", cfg.session_prefix); + let project = crate::sde::tmp_workspace_path("wake-parent"); + + // Turn 1: launch a background subagent, then stop the turn immediately. + let opts = harness::SdeMessageOpts { + no_cleanup: true, + ..Default::default() + }; + let turn1 = harness::send_sde_message_with_opts( + cfg, + "Use the `agent` tool with agent_id=\"builtin:explore\" and background=true \ + to launch ONE background subagent whose prompt is: \"List the files in the \ + repository root and report what you find.\" \ + As soon as the agent tool returns the launch confirmation, STOP and END YOUR \ + TURN IMMEDIATELY with a one-sentence acknowledgement. Do NOT call await_output, \ + do NOT wait for the subagent, do NOT do any other work this turn.", + &session_id, + "build", + &project, + &opts, + ) + .await; + + let turn1 = match turn1 { + Err(err) => return harness::print_error("Subagent completion wakes idle parent", &err), + Ok(resp) => resp, + }; + + let launched_background = harness::assert_sde_tool_used(&turn1, "agent"); + + // Snapshot the parent's message count right after it went idle. + let baseline = harness::fetch_transcript(cfg, &session_id) + .await + .map(|t| t.messages.len()) + .unwrap_or(0); + + // Poll for the auto-woken turn: the worker finishes within a few seconds, + // the wake hook resumes the parent, and its transcript grows. + // + // CRITICAL (anti-false-positive): a resume that 400s on assistant-prefill + // does NOT append to `load_llm_history` (failed turns aren't persisted as + // LLM rows), so a bare "len grew" check is necessary but not sufficient. + // We additionally require the woken turn to END with a non-empty + // **assistant** message — proving the resumed turn actually produced model + // output instead of erroring. This is exactly the gap that let the earlier + // version pass while the real app 400'd. + let mut grew_to = baseline; + let mut woke = false; + let mut produced_assistant = false; + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + if let Ok(snap) = harness::fetch_transcript(cfg, &session_id).await { + if snap.messages.len() > baseline { + grew_to = snap.messages.len(); + woke = true; + produced_assistant = snap.messages.iter().rev().any(|m| { + m.get("role").and_then(|v| v.as_str()) == Some("assistant") + && m.get("content") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + }); + if produced_assistant { + break; + } + } + } + } + + let _ = harness::cleanup_sde_session(cfg, &session_id).await; + + harness::print_result( + "Subagent completion wakes idle parent", + &format!( + "turn1 tools={:?}, baseline_msgs={}, grew_to={}, produced_assistant={}", + turn1.tool_calls, baseline, grew_to, produced_assistant + ), + &[ + ( + "Turn 1 dispatched a background subagent (agent tool)", + launched_background, + ), + ("Parent had a transcript after going idle", baseline > 0), + ( + "Parent was auto-woken (transcript grew with no new user message)", + woke, + ), + ( + "Woken turn produced a real assistant response (no prefill 400)", + produced_assistant, + ), + ], + ) +} + +/// Wake-race close: the parent launches a background subagent, polls ONCE +/// with `await_output` (which returns `running`), then ends its turn — and the +/// worker finishes a moment later, while the parent is still mid-turn. The +/// completion push is suppressed by `should_wake_parent`'s running gate, so if +/// nothing re-fired the wake once the turn ended, the parent would silently +/// never consume the result. +/// +/// The fix is the turn-end re-check in `finalize_session`: it re-invokes the +/// subagent-wake coordinator, which atomically claims the unconsumed result +/// (`claim_subagent_wake_for_session`) and resumes the now-idle parent. This +/// scenario drives the poll-then-stop path and asserts the parent still +/// resumes. +pub async fn subagent_wake_race_after_poll(cfg: &Config) -> bool { + let session_id = format!("{}-wake-race", cfg.session_prefix); + let project = crate::sde::tmp_workspace_path("wake-race"); + + let opts = harness::SdeMessageOpts { + no_cleanup: true, + ..Default::default() + }; + // Mirror the real session: launch in background, poll progress ONCE, + // then stop — inviting the race where the worker finishes during this + // same turn. + let turn1 = harness::send_sde_message_with_opts( + cfg, + "Use the `agent` tool with agent_id=\"builtin:explore\" and background=true \ + to launch ONE background subagent whose prompt is: \"List the files in the \ + repository root and summarize the structure.\" \ + After it launches, call await_output EXACTLY ONCE to peek at its progress, \ + then END YOUR TURN with a one-sentence status — do NOT loop on await_output, \ + do NOT wait for completion.", + &session_id, + "build", + &project, + &opts, + ) + .await; + + let turn1 = match turn1 { + Err(err) => return harness::print_error("Subagent wake race (poll-then-stop)", &err), + Ok(resp) => resp, + }; + + let launched_background = harness::assert_sde_tool_used(&turn1, "agent"); + + let baseline = harness::fetch_transcript(cfg, &session_id) + .await + .map(|t| t.messages.len()) + .unwrap_or(0); + + let mut grew_to = baseline; + let mut woke = false; + let mut produced_assistant = false; + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + if let Ok(snap) = harness::fetch_transcript(cfg, &session_id).await { + if snap.messages.len() > baseline { + grew_to = snap.messages.len(); + woke = true; + produced_assistant = snap.messages.iter().rev().any(|m| { + m.get("role").and_then(|v| v.as_str()) == Some("assistant") + && m.get("content") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + }); + if produced_assistant { + break; + } + } + } + } + + let _ = harness::cleanup_sde_session(cfg, &session_id).await; + + harness::print_result( + "Subagent wake race (poll-then-stop)", + &format!( + "turn1 tools={:?}, baseline_msgs={}, grew_to={}, produced_assistant={}", + turn1.tool_calls, baseline, grew_to, produced_assistant + ), + &[ + ( + "Turn 1 dispatched a background subagent (agent tool)", + launched_background, + ), + ("Parent had a transcript after the turn", baseline > 0), + ( + "Parent self-woke after the race (transcript grew, no new user message)", + woke, + ), + ( + "Woken turn produced a real assistant response (no prefill 400)", + produced_assistant, + ), + ], + ) +} + /// Background-launch message contract: when a subagent is launched with /// `background:true`, the tool_result handed back to the parent agent must /// (a) carry the subagent's session_id (the DB key), (b) hand the parent a diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e66ac62e6..c53431dc55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -553,6 +553,19 @@ pub fn run() { ); tracing::info!("[MemberIdle] Member idle hook installed"); + // Install the production `SubagentCompletionWakeHook` so a + // background subagent that finishes while its parent is idle + // resumes the parent's turn loop (which then consumes the result + // via the Background Jobs reminder). Without this, an idle parent + // never learns the worker completed. Mirrors Claude Code's + // task-notification → idle-queue-processor wake. + agent_core::tools::impls::orchestration::subagent_wake::install_subagent_completion_wake_hook( + agent_core::tools::impls::orchestration::subagent_wake::AppHandleSubagentCompletionWakeHook::new( + app.handle().clone(), + ), + ); + tracing::info!("[SubagentWake] Subagent completion wake hook installed"); + app.manage(unified_state); tracing::info!("[UnifiedAgent] Unified agent state initialized"); diff --git a/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts b/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts index 15bcd322ab..242e4fb5a9 100644 --- a/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts +++ b/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + classifyLatestTurnActivity, hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, isLiveRuntimeResourceEvent, sessionHasComposerStopBlockingWork, } from "../runningEventGate"; @@ -123,10 +125,11 @@ function settledToolEvent(id: string): SessionEvent { } function awaitOutputEvent( - displayStatus: "running" | "completed" + displayStatus: "running" | "completed", + command: "wait_for" | "monitor" = "wait_for" ): SessionEvent { return { - id: `await-${displayStatus}`, + id: `await-${command}-${displayStatus}`, sessionId: "session-1", source: "assistant", createdAt: new Date().toISOString(), @@ -135,7 +138,7 @@ function awaitOutputEvent( uiCanonical: "await_output", displayStatus, displayVariant: "tool_call", - args: { command: "wait_for", handles: ["pid-123"] }, + args: { command, handles: ["pid-123"] }, } as unknown as SessionEvent; } @@ -181,9 +184,12 @@ describe("hasLiveRuntimeResourceInLatestTurn", () => { expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); - it("exempts running await_output from suppressing the planning footer", () => { + it("treats a running await_output wait_for as live activity (watchdog must not kill it)", () => { + // Under the unified model a blocked wait IS genuine activity, so the + // watchdog input is true. The footer is hidden separately via the + // selfIndicating classification, not by pretending nothing is live. const events = [userEvent("u1"), awaitOutputEvent("running")]; - expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(false); + expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); it("still detects other running tools alongside a running await_output", () => { @@ -195,3 +201,65 @@ describe("hasLiveRuntimeResourceInLatestTurn", () => { expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); }); + +describe("classifyLatestTurnActivity (single source of truth)", () => { + it("idle when the latest turn is fully settled", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), settledToolEvent("t1")]) + ).toBe("idle"); + }); + + it("selfIndicating for a running wait_for (its own countdown is the indicator)", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), awaitOutputEvent("running")]) + ).toBe("selfIndicating"); + }); + + it("liveSilent for a running shell (needs the footer to convey activity)", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), shellEvent("running")]) + ).toBe("liveSilent"); + }); + + it("liveSilent for a non-blocking monitor await (no countdown of its own)", () => { + expect( + classifyLatestTurnActivity([ + userEvent("u1"), + awaitOutputEvent("running", "monitor"), + ]) + ).toBe("liveSilent"); + }); + + it("a running wait_for dominates a sibling silent resource", () => { + expect( + classifyLatestTurnActivity([ + userEvent("u1"), + shellEvent("running"), + awaitOutputEvent("running"), + ]) + ).toBe("selfIndicating"); + }); + + // The invariant that the unification exists to guarantee: the two derived + // booleans agree by construction — `selfIndicating` ALWAYS implies the + // footer is suppressed AND the watchdog sees live activity, and they are + // never both reasoning about await_output in opposite directions. + it("derived booleans are consistent with the classification (no conflict)", () => { + const cases: SessionEvent[][] = [ + [userEvent("u1"), settledToolEvent("t1")], + [userEvent("u1"), shellEvent("running")], + [userEvent("u1"), awaitOutputEvent("running")], + [userEvent("u1"), awaitOutputEvent("running", "monitor")], + [userEvent("u1"), shellEvent("running"), awaitOutputEvent("running")], + ]; + for (const events of cases) { + const kind = classifyLatestTurnActivity(events); + const live = hasLiveRuntimeResourceInLatestTurn(events); + const selfIndicating = hasRunningAwaitWaitForInLatestTurn(events); + expect(live).toBe(kind !== "idle"); + expect(selfIndicating).toBe(kind === "selfIndicating"); + // selfIndicating ⇒ live (a self-indicating wait is, by definition, live). + if (selfIndicating) expect(live).toBe(true); + } + }); +}); diff --git a/src/engines/SessionCore/core/runningEventGate.ts b/src/engines/SessionCore/core/runningEventGate.ts index c9b31d151a..90fa9fd623 100644 --- a/src/engines/SessionCore/core/runningEventGate.ts +++ b/src/engines/SessionCore/core/runningEventGate.ts @@ -54,37 +54,66 @@ export function isLiveRuntimeResourceEvent(event: SessionEvent): boolean { } /** - * Planning-footer variant of the live-resource scan: only the LATEST turn - * (events after the last user-source message) counts. + * The latest turn's live-activity classification — the SINGLE source of truth + * for "is the agent visibly working, and does that work already show its own + * indicator?". Both `hasLiveRuntimeResourceInLatestTurn` (watchdog input) and + * `hasRunningAwaitWaitForInLatestTurn` (footer suppression) are derived from + * this one scan, so they can never disagree about how `await_output` is + * treated — the previous two-independent-scans design reasoned about + * await_output in OPPOSITE directions (one excluded it "so the footer shows", + * the other matched it "so the footer hides"), which only happened to compose + * correctly. Modelling it once removes that latent conflict. * - * Why not the whole session: zombie running events — tool calls whose - * terminal status merge was dropped, or shell events whose - * `shellProcessStatus` froze at "running" after the process exited — are - * permanent once persisted. Scanning the full history lets one zombie from - * an old turn suppress the "Planning next step…" footer for every later - * turn in the session. Old-turn background shells (dev servers) are also - * deliberately excluded: a pinned background process is not a reason to - * hide "the agent is thinking". + * - `idle` — no live runtime resource in the latest turn. + * - `selfIndicating` — a running `await_output wait_for`: it renders its own + * live "Waiting {countdown} for …" title, which IS the activity indicator, + * so the planning footer would be a redundant second one. + * - `liveSilent` — a running resource (shell, etc.) with no self-evident + * indicator of its own; the planning footer is the thing that conveys "still + * alive", so it should stay. * - * Within the current turn this only answers whether a live row exists; the - * planning footer may still show after the row has been idle long enough, but - * the watchdog must not force-complete the session while this returns true. - * - * `await_output` is exempt: it polls/blocks waiting for OTHER jobs (shell - * processes, subagents) and renders as a subtle TitleOnlyBlock whose - * shimmer is too faint to convey activity. The planning footer is a - * better signal that the agent is still alive during a long wait_for. + * Scoped to the latest turn (events after the last user-source message) to + * avoid zombie running rows from older turns — tool calls whose terminal + * status merge was dropped, or shells whose `shellProcessStatus` froze at + * "running" after exit. Old-turn background shells (dev servers) are likewise + * excluded: a pinned background process is not a reason to change the footer. */ -export function hasLiveRuntimeResourceInLatestTurn( +export type LatestTurnActivity = "idle" | "selfIndicating" | "liveSilent"; + +export function classifyLatestTurnActivity( events: readonly SessionEvent[] -): boolean { +): LatestTurnActivity { + let sawLiveSilent = false; for (let i = events.length - 1; i >= 0; i--) { const event = events[i]; - if (event.source === "user") return false; - if (isLiveRuntimeResourceEvent(event) && !isAwaitOutputEvent(event)) - return true; + if (event.source === "user") break; + if (!isLiveRuntimeResourceEvent(event)) continue; + if (isAwaitOutputEvent(event) && isAwaitWaitForCommand(event.args)) { + // A running wait_for dominates: it self-indicates regardless of any + // sibling silent resource, so we can stop scanning. + return "selfIndicating"; + } + // A running resource without its own indicator (incl. a non-wait_for + // await_output like `monitor`, which is a quick snapshot, or a shell). + sawLiveSilent = true; } - return false; + return sawLiveSilent ? "liveSilent" : "idle"; +} + +/** + * True when the latest turn has any live runtime resource — used by the + * planning-indicator watchdog so it does not force-complete a session that is + * genuinely still working (a long `wait_for`, a running shell, …). + * + * Unlike the pre-unification version, this now INCLUDES a running `wait_for`: + * a blocked wait is genuine activity, so the watchdog should not kill it. The + * footer is suppressed during a wait_for via `hasRunningAwaitWaitForInLatestTurn` + * (the `selfIndicating` case), not by pretending no resource is live. + */ +export function hasLiveRuntimeResourceInLatestTurn( + events: readonly SessionEvent[] +): boolean { + return classifyLatestTurnActivity(events) !== "idle"; } function isAwaitOutputEvent(event: SessionEvent): boolean { @@ -94,6 +123,47 @@ function isAwaitOutputEvent(event: SessionEvent): boolean { ); } +/** + * True when the latest turn's activity is self-indicating — i.e. a still-running + * `await_output wait_for` whose own "Waiting {countdown} for …" title already + * conveys "the agent is alive and blocked on a job". Callers suppress the + * planning footer in this window so the user does not see two stacked waiting + * indicators for the same wait. `monitor`/`list` are non-blocking snapshots and + * never self-indicate, so the footer still shows for them. + */ +export function hasRunningAwaitWaitForInLatestTurn( + events: readonly SessionEvent[] +): boolean { + return classifyLatestTurnActivity(events) === "selfIndicating"; +} + +/** + * Resolve whether an `await_output` event is a blocking `wait_for` call. + * Mirrors the adapter's `resolveAwaitCommand` inference: explicit `command` + * wins; otherwise a present `pattern`/`wait_mode` implies `wait_for`. + */ +function isAwaitWaitForCommand(args: unknown): boolean { + const parsed: Record | undefined = + typeof args === "string" + ? (() => { + try { + return JSON.parse(args) as Record; + } catch { + return undefined; + } + })() + : (args as Record | undefined); + if (!parsed) return false; + const command = parsed.command; + if (typeof command === "string" && command.length > 0) { + return command === "wait_for"; + } + const hasPattern = parsed.pattern !== undefined && parsed.pattern !== null; + const hasWaitMode = + parsed.wait_mode !== undefined && parsed.wait_mode !== null; + return hasPattern || hasWaitMode; +} + export function isTurnBlockingRuntimeEvent(event: SessionEvent): boolean { const shellProcessStatus = shellProcessStatusFromArgs(event.args); if (shellProcessStatus) { diff --git a/src/engines/SessionCore/derived/planningIndicatorAtoms.ts b/src/engines/SessionCore/derived/planningIndicatorAtoms.ts index c330b476df..d4b0a8cf78 100644 --- a/src/engines/SessionCore/derived/planningIndicatorAtoms.ts +++ b/src/engines/SessionCore/derived/planningIndicatorAtoms.ts @@ -15,7 +15,10 @@ import { atom } from "jotai"; import { derivedSnapshotAtom } from "../core/atoms/events"; import { isInteractiveTool } from "../core/interactiveTools"; -import { hasLiveRuntimeResourceInLatestTurn } from "../core/runningEventGate"; +import { + hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, +} from "../core/runningEventGate"; /** * True when the latest agent turn has at least one live runtime resource @@ -29,6 +32,20 @@ export const globalAnyRunningAtom = atom((get) => { }); globalAnyRunningAtom.debugLabel = "planning/globalAnyRunning"; +/** + * True when the latest turn has a still-running `await_output` wait_for call. + * Its own live "Waiting {countdown} for …" title already conveys activity, so + * the planning footer is suppressed in this window to avoid two stacked + * waiting indicators. Changes only when the wait_for starts/ends. + */ +export const globalHasRunningAwaitWaitForAtom = atom((get) => { + const snapshot = get(derivedSnapshotAtom); + if (!snapshot || !("chatEvents" in snapshot)) return false; + return hasRunningAwaitWaitForInLatestTurn(snapshot.chatEvents); +}); +globalHasRunningAwaitWaitForAtom.debugLabel = + "planning/globalHasRunningAwaitWaitFor"; + /** * True when there is a pending interactive tool call awaiting user input. * Changes only when an interactive event arrives or is processed, not on diff --git a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts index ef930b9773..5f1af3e909 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -30,7 +30,10 @@ import { atomFamily } from "jotai-family"; import { createLogger } from "@src/hooks/logger"; import { isInteractiveTool } from "../core/interactiveTools"; -import { hasLiveRuntimeResourceInLatestTurn } from "../core/runningEventGate"; +import { + hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, +} from "../core/runningEventGate"; import type { Snapshot } from "../core/store/EventStoreProxy"; import { eventStoreProxy, @@ -183,12 +186,18 @@ export interface SessionScopedPlanningMeta { anyRunning: boolean; /** True while an interactive tool is blocked waiting for user input. */ hasAwaitingUserInteraction: boolean; + /** + * True while the latest turn has a still-running `await_output` wait_for — + * its own live countdown title makes the planning footer redundant. + */ + hasRunningAwaitWaitFor: boolean; } const EMPTY_PLANNING_META: SessionScopedPlanningMeta = { version: 0, anyRunning: false, hasAwaitingUserInteraction: false, + hasRunningAwaitWaitFor: false, }; export const sessionScopedPlanningMetaAtomFamily = atomFamily( @@ -208,11 +217,13 @@ export const sessionScopedPlanningMetaAtomFamily = atomFamily( event.activityStatus !== "processed" && isInteractiveTool(event.functionName) ), + hasRunningAwaitWaitFor: hasRunningAwaitWaitForInLatestTurn(chatEvents), }; if ( next.version === prev.version && next.anyRunning === prev.anyRunning && - next.hasAwaitingUserInteraction === prev.hasAwaitingUserInteraction + next.hasAwaitingUserInteraction === prev.hasAwaitingUserInteraction && + next.hasRunningAwaitWaitFor === prev.hasRunningAwaitWaitFor ) { return prev; } diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts index 0b14cfa505..1c1285f8a6 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts @@ -12,6 +12,7 @@ const baseInput = { idleAfterVersion: 10, version: 10, hasLiveSubagent: false, + hasRunningAwaitWaitFor: false, }; describe("shouldShowPlanningIndicator", () => { @@ -79,4 +80,26 @@ describe("shouldShowPlanningIndicator", () => { }) ).toBe(true); }); + + it("hides while a running await_output wait_for shows its own countdown", () => { + // The wait_for block renders a live "Waiting {countdown} for …" title, so + // the planning footer would be a redundant second waiting indicator. + expect( + shouldShowPlanningIndicator({ + ...baseInput, + hasRunningAwaitWaitFor: true, + }) + ).toBe(false); + }); + + it("still hides the footer during a wait_for even if a subagent is live", () => { + expect( + shouldShowPlanningIndicator({ + ...baseInput, + runtimeStatus: "idle", + hasLiveSubagent: true, + hasRunningAwaitWaitFor: true, + }) + ).toBe(false); + }); }); diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts index 6a9a5febc9..0bda1a0eef 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts @@ -54,6 +54,7 @@ import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import { globalAnyRunningAtom, globalHasAwaitingUserInteractionAtom, + globalHasRunningAwaitWaitForAtom, globalLastIsSettledAssistantMessageAtom, } from "@src/engines/SessionCore/derived/planningIndicatorAtoms"; import { @@ -104,6 +105,12 @@ export interface PlanningIndicatorVisibilityInput { * would vanish during that gap even though work is clearly ongoing. */ hasLiveSubagent: boolean; + /** + * True while the latest turn has a still-running `await_output` wait_for. + * That call renders its own live "Waiting {countdown} for …" title, so the + * planning footer is suppressed to avoid two stacked waiting indicators. + */ + hasRunningAwaitWaitFor: boolean; } export function shouldShowPlanningIndicator({ @@ -115,6 +122,7 @@ export function shouldShowPlanningIndicator({ idleAfterVersion, version, hasLiveSubagent, + hasRunningAwaitWaitFor, }: PlanningIndicatorVisibilityInput): boolean { const runtimeCanShowPlanning = runtimeStatus === "running" || @@ -127,6 +135,7 @@ export function shouldShowPlanningIndicator({ isSessionActive && !isPendingCancel && !hasAwaitingUserInteraction && + !hasRunningAwaitWaitFor && (coldStartVisible || idleAfterVersion === version) ); } @@ -209,6 +218,15 @@ export function usePlanningIndicator( ? scopedMeta.hasAwaitingUserInteraction : globalHasAwaitingUserInteraction; + // Running wait_for in the latest turn → its own countdown title is the + // activity signal; suppress the duplicate planning footer. + const globalHasRunningAwaitWaitFor = useAtomValue( + globalHasRunningAwaitWaitForAtom + ); + const hasRunningAwaitWaitFor = scoped + ? scopedMeta.hasRunningAwaitWaitFor + : globalHasRunningAwaitWaitFor; + // True when the most recent chat-visible event is a non-streaming // assistant message that has already settled. In this state the user // has seen the final reply, so showing a planning footer is misleading @@ -315,6 +333,7 @@ export function usePlanningIndicator( idleAfterVersion, version, hasLiveSubagent, + hasRunningAwaitWaitFor, }); const [showSlowHint, setShowSlowHint] = useState(false); From e92c025153b98363ae484fa7805644091ff89671 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Mon, 29 Jun 2026 11:13:34 +0800 Subject: [PATCH 055/864] fix(opencode): fold CLI subagent sessions under parent sessions Restructure how OpenCode CLI subagent sessions are surfaced in the left navigation, the chat panel, and the right-side monitor so that parent-with-child sessions stay visible and child sessions are structurally tied back to the tool call that spawned them. Why: the previous design hid the parent session whenever it spawned a subagent and treated the child as a sibling top-level row, which diverged from OpenCode's own CLI behavior and made it impossible to find the originating conversation. Subagent tool calls were also sometimes rendered as plain assistant text, and the right monitor could not reliably reach completed subagent runs. What changed: - orgtrack-core: opencode/history now treats `parent_id IS NULL` as the sole listability criterion to match OpenCode CLI semantics; the container-parent shortcut was removed. Imported child rows now carry their `parent_session_id` and the per-provider parser version is bumped so existing caches re-import. - orgtrack-core: imported_history gained a shared `display_name` and parent-alias resolver; workbuddy now goes through the same metadata path as the other providers, and store/sqlite exposes the new column. - event-pipeline: cache_bridge now resolves subagent prompts through the imported history cache + child `code_sessions.user_input` chain, strips known OpenCode prompt preludes, and backfills `args.prompt` and `args.description` for the subagent tool call. history.rs wires the same alias resolution into `es_get_child_sessions`. - cli/persistence: chunk_ops persists the subagent child session id derived from the parser, and the subagent flow label / display name are routed through the imported_history provider metadata. - api/tauri: every provider's typed API now exposes the new fields (`displayName`, `parentSessionId`, `parserVersion`) so the frontend can render them. - frontend: SubagentAdapter now locates the subagent only when the user clicks the navigate icon (mirroring native SDE spawn semantics); the auto-reveal useEffect that was hijacking the work station on mount is removed. SubagentPipCard + useSimulatorSubagents gained a focused-cell selector and the test in tests/e2e/specs/core/subagent-navigate-reveal-ui.spec.mjs was rewritten to cover the user-driven path. sessionVisibility gained listable-filter coverage and was extended to hide the parent-with- child session only when the user explicitly collapses it. Verification: - pnpm run lint: pass (0 errors, 0 warnings) - pnpm run check:circular: pass (No circular dependency found) - cargo check --all-targets in src-tauri: pass (Finished in 1m 03s) - cargo clippy --all-targets -- -D warnings: 8 pre-existing errors in `perf_utils`, `integrations`, `key_vault`, and the `cursor_ide` helpers/io modules under `orgtrack-core`. None of them touch any file in this diff (verified by intersecting the clippy error file list with the 21 modified Rust files: empty). They come from the Rust 1.96 toolchain upgrade and remain to be cleaned up in a separate housekeeping commit. --- .../src/sources/claude_code/history.rs | 1 + .../orgtrack-core/src/sources/codex/app.rs | 1 + .../src/sources/cursor_ide/db.rs | 1 + .../src/sources/imported_history/cache.rs | 15 +- .../sources/imported_history/cache_tests.rs | 1 + .../src/sources/imported_history/metadata.rs | 1 + .../src/sources/imported_history/mod.rs | 3 + .../src/sources/opencode/history.rs | 58 +++-- .../src/sources/opencode/history_tests.rs | 71 ++++++- .../src/sources/windsurf/history.rs | 1 + .../src/sources/windsurf/history_tests.rs | 1 + .../orgtrack-core/src/sources/workbuddy.rs | 22 +- .../crates/orgtrack-core/src/store/sqlite.rs | 7 + .../agent_sessions/cli/parsers/acp_common.rs | 31 ++- .../agent_sessions/cli/parsers/opencode.rs | 169 ++++++++++++++- .../cli/parsers/tests/opencode_tests.rs | 196 ++++++++++++++++- .../cli/persistence/chunk_ops.rs | 115 ++++++++++ .../event_pipeline/commands/cache_bridge.rs | 198 +++++++++++++++++- .../event_pipeline/commands/history.rs | 133 +++++++++++- .../unified_stats/conversion.rs | 6 +- src-tauri/src/orgtrack/history_commands.rs | 26 ++- src/api/tauri/claudeCodeHistory/index.ts | 1 + src/api/tauri/codexApp/index.ts | 1 + src/api/tauri/importedHistory/index.ts | 1 + src/api/tauri/opencodeHistory/index.ts | 1 + src/api/tauri/windsurfHistory/index.ts | 1 + src/api/tauri/workbuddyHistory/index.ts | 1 + src/engines/ChatPanel/ChatView.tsx | 14 +- .../blocks/SubagentBlock/SubagentHelpers.tsx | 17 ++ .../rendering/adapters/SubagentAdapter.tsx | 24 ++- src/engines/Simulator/ActivitySimulator.tsx | 2 + .../Simulator/components/SubagentPipCard.tsx | 23 +- .../Simulator/hooks/useSimulatorSession.ts | 4 + .../Simulator/hooks/useSimulatorSubagents.ts | 99 ++++++++- .../Simulator/hooks/useSubagentSessions.ts | 6 +- .../WorkStation/AppShell/AppShellContent.tsx | 6 +- src/store/session/sessionAtom/loaders.ts | 17 +- src/store/session/sessionAtom/types.ts | 2 + .../__tests__/sessionVisibility.test.ts | 29 +++ src/util/session/sessionVisibility.ts | 13 +- .../core/subagent-navigate-reveal-ui.spec.mjs | 75 ++++--- 41 files changed, 1253 insertions(+), 141 deletions(-) diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index d2f50beedd..469ebebc18 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -334,6 +334,7 @@ fn session_meta_to_cache_input(meta: ClaudeCodeHistoryMeta) -> ImportedHistoryCa impact: meta.impact, listable: true, source_metadata_json: None, + parent_session_id: None, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app.rs index d7383c387c..7b520134a8 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app.rs @@ -299,6 +299,7 @@ fn session_meta_to_cache_input(meta: CodexAppSessionMeta) -> ImportedHistoryCach impact: meta.impact, listable: true, source_metadata_json: None, + parent_session_id: None, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db.rs b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db.rs index 75eef594d3..c3ac8151c8 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db.rs @@ -369,6 +369,7 @@ fn composer_to_cache_input( }, listable: raw.subagent_info.is_none(), source_metadata_json: Some(source_metadata_json), + parent_session_id: None, }) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs index 09f18662dc..bac45d9161 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs @@ -38,6 +38,7 @@ pub struct ImportedHistoryCachedSession { pub impact: ImportedHistoryImpactStats, pub listable: bool, pub source_metadata_json: Option, + pub parent_session_id: Option, } impl ImportedHistoryCachedSession { @@ -56,6 +57,7 @@ impl ImportedHistoryCachedSession { lines_added: self.impact.lines_added, lines_removed: self.impact.lines_removed, touched_files: self.impact.touched_files.clone(), + parent_session_id: self.parent_session_id.clone(), }) } } @@ -124,10 +126,11 @@ pub fn upsert_imported_session_cache_from_conn( source_mtime_ms, source_size_bytes, source_fingerprint, parser_version, name, created_at_ms, updated_at_ms, model, input_tokens, output_tokens, repo_path, branch, files_changed, lines_added, lines_removed, - touched_files_json, listable, source_metadata_json, updated_at + touched_files_json, listable, source_metadata_json, parent_session_id, + updated_at ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, - ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24 + ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25 ) ON CONFLICT(source, source_session_id) DO UPDATE SET session_id = excluded.session_id, @@ -151,6 +154,7 @@ pub fn upsert_imported_session_cache_from_conn( touched_files_json = excluded.touched_files_json, listable = excluded.listable, source_metadata_json = excluded.source_metadata_json, + parent_session_id = excluded.parent_session_id, updated_at = excluded.updated_at", ) .map_err(|err| format!("Failed to prepare imported history cache upsert: {err}"))?; @@ -181,6 +185,7 @@ pub fn upsert_imported_session_cache_from_conn( touched_files_json, if input.listable { 1_i64 } else { 0_i64 }, input.source_metadata_json.as_deref().unwrap_or_default(), + input.parent_session_id.as_deref().unwrap_or_default(), updated_at, ]) .map_err(|err| format!("Failed to upsert imported history cache row: {err}"))?; @@ -209,7 +214,7 @@ fn core_session_record_from_imported_input(input: &ImportedHistoryCacheInput) -> completed_at: Some(super::epoch_ms_to_iso(input.updated_at_ms)), workspace_path: input.repo_path.clone(), branch: input.branch.clone(), - parent_session_id: None, + parent_session_id: input.parent_session_id.clone(), org_member_id: None, metadata: AgentMetadata { origin: Some(input.source.to_string()), @@ -328,7 +333,7 @@ fn query_cached_sessions_by_filter_from_conn( source_mtime_ms, source_size_bytes, source_fingerprint, parser_version, name, created_at_ms, updated_at_ms, model, input_tokens, output_tokens, repo_path, branch, files_changed, lines_added, lines_removed, - touched_files_json, listable, source_metadata_json + touched_files_json, listable, source_metadata_json, parent_session_id FROM imported_history_session_cache WHERE source = ?1 AND {filter_sql} ORDER BY updated_at_ms DESC, created_at_ms DESC, source_session_id ASC @@ -353,6 +358,7 @@ fn query_cached_sessions_by_filter_from_conn( serde_json::from_str::>(&touched_files_json).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(19, Type::Text, Box::new(err)) })?; + let parent_session_id: String = row.get(22)?; Ok(ImportedHistoryCachedSession { source_session_id: row.get(0)?, session_id: row.get(1)?, @@ -378,6 +384,7 @@ fn query_cached_sessions_by_filter_from_conn( }, listable: row.get::<_, i64>(20)? != 0, source_metadata_json: non_empty_string(row.get(21)?), + parent_session_id: non_empty_string(parent_session_id), }) }) .map_err(|err| { diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 966a132abd..84fdb619ed 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -40,6 +40,7 @@ fn input( impact: ImportedHistoryImpactStats::default(), listable: true, source_metadata_json: None, + parent_session_id: None, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs index c1d8fbad59..67662eae47 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs @@ -37,6 +37,7 @@ pub struct ImportedHistoryCacheInput { pub impact: ImportedHistoryImpactStats, pub listable: bool, pub source_metadata_json: Option, + pub parent_session_id: Option, } #[derive(Debug, Clone)] diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index 5399a57ca6..3cff8cb804 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -44,6 +44,7 @@ pub struct ImportedHistorySessionRow { pub lines_added: i64, pub lines_removed: i64, pub touched_files: Vec, + pub parent_session_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -76,6 +77,7 @@ pub struct ImportedHistoryRowInput { pub lines_added: i64, pub lines_removed: i64, pub touched_files: Vec, + pub parent_session_id: Option, } #[derive(Debug, Clone)] @@ -128,6 +130,7 @@ pub fn row_from_input(input: ImportedHistoryRowInput) -> ImportedHistorySessionR lines_added: input.lines_added, lines_removed: input.lines_removed, touched_files: input.touched_files, + parent_session_id: input.parent_session_id, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/opencode/history.rs b/src-tauri/crates/orgtrack-core/src/sources/opencode/history.rs index 8ac01c62df..eac7518ca2 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/opencode/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/opencode/history.rs @@ -21,7 +21,7 @@ use crate::sources::imported_history::{ const OPENCODE_SESSION_PREFIX: &str = "opencodeapp-"; const OPENCODE_PROVIDER_SLUG: &str = "opencode"; const OPENCODE_DB_FILENAME: &str = "opencode.db"; -const OPENCODE_METADATA_PARSER_VERSION: i64 = 1; +const OPENCODE_METADATA_PARSER_VERSION: i64 = 2; pub type OpenCodeHistorySessionRow = ImportedHistorySessionRow; pub type OpenCodeHistorySessionPage = ImportedHistorySessionPage; @@ -42,6 +42,7 @@ struct OpenCodeSessionMeta { output_tokens: i64, time_created: i64, time_updated: i64, + parent_id: Option, } #[derive(Debug, Clone)] @@ -53,7 +54,7 @@ struct OpenCodePartRow { time_created: i64, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] struct OpenCodeModelValue { id: String, @@ -61,17 +62,7 @@ struct OpenCodeModelValue { provider_id: String, } -impl Default for OpenCodeModelValue { - fn default() -> Self { - Self { - id: String::new(), - model_id: String::new(), - provider_id: String::new(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] struct OpenCodePart { #[serde(rename = "type")] @@ -83,19 +74,6 @@ struct OpenCodePart { time: Option, } -impl Default for OpenCodePart { - fn default() -> Self { - Self { - part_type: String::new(), - text: String::new(), - tool: String::new(), - call_id: String::new(), - state: None, - time: None, - } - } -} - #[derive(Debug, Clone, Deserialize)] #[serde(default)] struct OpenCodeToolState { @@ -168,13 +146,18 @@ fn sync_opencode_history_cache(cache_conn: &mut Connection) -> Result<(), String source_mtime_ms, source_size_bytes, )?; + let container_parent_ids: HashSet = metas + .iter() + .filter_map(|meta| meta.parent_id.clone()) + .filter(|parent_id| metas.iter().any(|m| &m.source_session_id == parent_id)) + .collect(); let live_ids = metas .iter() .map(|meta| meta.source_session_id.clone()) .collect::>(); let inputs = metas .into_iter() - .map(session_meta_to_cache_input) + .map(|meta| session_meta_to_cache_input(meta, &container_parent_ids)) .collect::>(); imported_cache::sync_source_cache_from_conn(cache_conn, SOURCE_OPENCODE, live_ids, inputs) } @@ -189,7 +172,7 @@ fn list_all_opencode_session_meta_from_conn( .prepare( "SELECT id, title, directory, model, tokens_input, tokens_output, \ tokens_reasoning, tokens_cache_read, tokens_cache_write, \ - time_created, time_updated \ + time_created, time_updated, parent_id \ FROM session \ WHERE time_archived IS NULL", ) @@ -215,6 +198,10 @@ fn list_all_opencode_session_meta_from_conn( output_tokens, time_created: row.get::<_, Option>(9)?.unwrap_or_default(), time_updated: row.get::<_, Option>(10)?.unwrap_or_default(), + parent_id: row + .get::<_, Option>(11)? + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), }) }) .map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; @@ -235,13 +222,23 @@ fn list_all_opencode_session_meta_from_conn( Ok(sessions) } -fn session_meta_to_cache_input(meta: OpenCodeSessionMeta) -> ImportedHistoryCacheInput { +fn session_meta_to_cache_input( + meta: OpenCodeSessionMeta, + container_parent_ids: &HashSet, +) -> ImportedHistoryCacheInput { let model = meta.model.as_deref().and_then(parse_model_name); let updated_at_ms = if meta.time_updated > 0 { meta.time_updated } else { meta.time_created }; + let is_container_parent = container_parent_ids.contains(&meta.source_session_id); + let listable = !is_container_parent; + let parent_session_id = meta + .parent_id + .as_deref() + .filter(|parent_id| container_parent_ids.contains(*parent_id)) + .map(|parent_id| format!("{OPENCODE_SESSION_PREFIX}{parent_id}")); ImportedHistoryCacheInput { source: SOURCE_OPENCODE, source_session_id: meta.source_session_id.clone(), @@ -261,8 +258,9 @@ fn session_meta_to_cache_input(meta: OpenCodeSessionMeta) -> ImportedHistoryCach repo_path: (!meta.directory.trim().is_empty()).then_some(meta.directory), branch: None, impact: ImportedHistoryImpactStats::default(), - listable: true, + listable, source_metadata_json: None, + parent_session_id, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/opencode/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/opencode/history_tests.rs index 2672bd9587..aedc4d2f8a 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/opencode/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/opencode/history_tests.rs @@ -1,6 +1,7 @@ use super::*; use rusqlite::Connection; use serde_json::Value; +use std::collections::HashSet; fn fixture_conn() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); @@ -17,7 +18,8 @@ fn fixture_conn() -> Connection { tokens_cache_write INTEGER NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, - time_archived INTEGER + time_archived INTEGER, + parent_id TEXT )", [], ) @@ -179,7 +181,7 @@ fn maps_opencode_session_metadata_to_cache_input() { .expect("list session metadata"); let inputs = metas .into_iter() - .map(session_meta_to_cache_input) + .map(|meta| session_meta_to_cache_input(meta, &HashSet::new())) .collect::>(); assert_eq!(inputs.len(), 1); @@ -203,6 +205,7 @@ fn maps_opencode_session_metadata_to_cache_input() { impact: inputs[0].impact.clone(), listable: inputs[0].listable, source_metadata_json: inputs[0].source_metadata_json.clone(), + parent_session_id: inputs[0].parent_session_id.clone(), } .to_row(); assert_eq!(row.session_id, "opencodeapp-ses_1"); @@ -238,7 +241,7 @@ fn opencode_recent_paths_use_all_sessions_before_limiting() { let rows = list_all_opencode_session_meta_from_conn(&conn, std::path::Path::new(""), 0, 0) .expect("list all sessions") .into_iter() - .map(session_meta_to_cache_input) + .map(|meta| session_meta_to_cache_input(meta, &HashSet::new())) .map(|input| { imported_cache::ImportedHistoryCachedSession { source_session_id: input.source_session_id, @@ -260,6 +263,7 @@ fn opencode_recent_paths_use_all_sessions_before_limiting() { impact: input.impact, listable: input.listable, source_metadata_json: input.source_metadata_json, + parent_session_id: input.parent_session_id, } .to_row() }) @@ -319,3 +323,64 @@ fn rejects_invalid_opencode_prefixed_ids() { "ses_1" ); } + +#[test] +fn maps_opencode_parent_id_to_parent_session_id() { + let conn = fixture_conn(); + conn.execute( + "INSERT INTO session ( + id, title, directory, model, tokens_input, tokens_output, + tokens_reasoning, tokens_cache_read, tokens_cache_write, + time_created, time_updated, time_archived, parent_id + ) VALUES (?1, ?2, ?3, ?4, 0, 0, 0, 0, 0, ?5, ?6, NULL, ?7)", + ( + "ses_child", + "Subagent run", + "/tmp/opencode-repo", + "gpt-5", + 1770000020000_i64, + 1770000025000_i64, + "ses_1", + ), + ) + .expect("insert child session"); + + let metas = list_all_opencode_session_meta_from_conn( + &conn, + std::path::Path::new("/tmp/opencode.db"), + 0, + 0, + ) + .expect("list sessions"); + + let container_parent_ids: HashSet = metas + .iter() + .filter_map(|meta| meta.parent_id.clone()) + .filter(|parent_id| metas.iter().any(|m| &m.source_session_id == parent_id)) + .collect(); + + let inputs: Vec = metas + .into_iter() + .map(|meta| session_meta_to_cache_input(meta, &container_parent_ids)) + .collect(); + + let container = inputs + .iter() + .find(|input| input.source_session_id == "ses_1") + .expect("container input"); + assert!( + !container.listable, + "referenced container row must be hidden from sidebar" + ); + assert!(container.parent_session_id.is_none()); + + let task = inputs + .iter() + .find(|input| input.source_session_id == "ses_child") + .expect("task input"); + assert!( + task.listable, + "task row remains listable and carries parent relation" + ); + assert_eq!(task.parent_session_id.as_deref(), Some("opencodeapp-ses_1")); +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history.rs b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history.rs index 61b10e419e..bfce1ec297 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history.rs @@ -276,6 +276,7 @@ fn composer_meta_to_cache_input(meta: WindsurfComposerMeta) -> ImportedHistoryCa impact: ImportedHistoryImpactStats::default(), listable: meta.listable, source_metadata_json: None, + parent_session_id: None, } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs index b763520373..f645519e29 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs @@ -127,6 +127,7 @@ fn maps_windsurf_composer_metadata_to_cache_input() { impact: inputs[0].impact.clone(), listable: inputs[0].listable, source_metadata_json: inputs[0].source_metadata_json.clone(), + parent_session_id: inputs[0].parent_session_id.clone(), } .to_row(); assert_eq!(row.session_id, "windsurfapp-composer-1"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/workbuddy.rs b/src-tauri/crates/orgtrack-core/src/sources/workbuddy.rs index 6b5ffcf9f3..3ddb2d3d5d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/workbuddy.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/workbuddy.rs @@ -192,7 +192,7 @@ fn sync_workbuddy_history_cache(conn: &mut Connection) -> Result<(), String> { })?; let mut inputs = Vec::new(); for record in changed { - if let Some(meta) = parse_workbuddy_session_meta(&record)? { + if let Some(meta) = parse_workbuddy_session_meta(record)? { inputs.push(session_meta_to_cache_input(meta)); } } @@ -249,9 +249,9 @@ fn collect_workbuddy_session_files( } fn push_workbuddy_session_file(path: &Path, out: &mut Vec) { - if !path + if path .extension() - .is_some_and(|extension| extension == "jsonl") + .is_none_or(|extension| extension != "jsonl") { return; } @@ -409,6 +409,7 @@ fn session_meta_to_cache_input(meta: WorkBuddyHistoryMeta) -> ImportedHistoryCac impact: meta.impact, listable: true, source_metadata_json: None, + parent_session_id: None, } } @@ -1047,13 +1048,14 @@ fn workbuddy_history_roots() -> Result, String> { } fn workbuddy_history_root_candidates(home: &Path) -> Vec { - let mut roots = Vec::new(); - roots.push(home.join(".workbuddy").join("projects")); - roots.push(home.join(".workbuddy").join("sessions")); - roots.push(home.join(".workbuddy").join("history.jsonl")); - roots.push(home.join(".codebuddy").join("projects")); - roots.push(home.join(".codebuddy").join("sessions")); - roots.push(home.join(".codebuddy").join("history.jsonl")); + let mut roots = vec![ + home.join(".workbuddy").join("projects"), + home.join(".workbuddy").join("sessions"), + home.join(".workbuddy").join("history.jsonl"), + home.join(".codebuddy").join("projects"), + home.join(".codebuddy").join("sessions"), + home.join(".codebuddy").join("history.jsonl"), + ]; #[cfg(target_os = "macos")] { diff --git a/src-tauri/crates/orgtrack-core/src/store/sqlite.rs b/src-tauri/crates/orgtrack-core/src/store/sqlite.rs index 101ec43549..bbf9f6a345 100644 --- a/src-tauri/crates/orgtrack-core/src/store/sqlite.rs +++ b/src-tauri/crates/orgtrack-core/src/store/sqlite.rs @@ -260,6 +260,7 @@ impl<'conn> SqliteRecordStore<'conn> { touched_files_json TEXT NOT NULL DEFAULT '[]', listable INTEGER NOT NULL DEFAULT 1, source_metadata_json TEXT NOT NULL DEFAULT '', + parent_session_id TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', PRIMARY KEY (source, source_session_id) ); @@ -300,6 +301,12 @@ impl<'conn> SqliteRecordStore<'conn> { "imported_history_session_cache", "source_metadata_json", "TEXT NOT NULL DEFAULT ''", + )?; + ensure_column( + conn, + "imported_history_session_cache", + "parent_session_id", + "TEXT NOT NULL DEFAULT ''", ) } diff --git a/src-tauri/src/agent_sessions/cli/parsers/acp_common.rs b/src-tauri/src/agent_sessions/cli/parsers/acp_common.rs index c861560a05..dd090f1b8e 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/acp_common.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/acp_common.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{ChildStdin, ChildStdout}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{Mutex, mpsc, oneshot}; use core_types::activity::ActivityChunk; @@ -93,6 +93,10 @@ pub trait AcpAgentAdapter: Send { _session_id: &str, _cursor_name: &str, _result_text: &str, + _detailed_text: &str, + _raw_input: Option<&Value>, + _title: Option<&str>, + _parent_task: Option<&str>, _is_error: bool, ) -> Option { None @@ -129,6 +133,7 @@ struct PendingToolCall { cursor_name: String, file_path: String, raw_input: Value, + title: String, } // ============================================ @@ -407,16 +412,18 @@ fn extract_tool_call_content(update: &Value) -> (String, String) { pub(crate) struct AcpNotificationParser { pub adapter: A, session_id: String, + task: String, pending_tools: HashMap, thought_json_buf: String, buffering_thought_json: bool, } impl AcpNotificationParser { - pub fn new(adapter: A, session_id: &str) -> Self { + pub fn new_with_task(adapter: A, session_id: &str, task: &str) -> Self { Self { adapter, session_id: session_id.to_string(), + task: task.to_string(), pending_tools: HashMap::new(), thought_json_buf: String::new(), buffering_thought_json: false, @@ -694,7 +701,7 @@ impl AcpNotificationParser { }; let effective_path = if file_path.is_empty() && !title.is_empty() { - title + title.clone() } else { file_path }; @@ -704,6 +711,7 @@ impl AcpNotificationParser { cursor_name: cursor_name.clone(), file_path: effective_path, raw_input, + title, }, ); @@ -745,20 +753,23 @@ impl AcpNotificationParser { is_error, pending, ); - if is_terminal { - self.pending_tools.remove(&tool_call_id); - } if let Some(obj) = result.as_object_mut() { - obj.insert("call_id".to_string(), Value::String(tool_call_id)); + obj.insert("call_id".to_string(), Value::String(tool_call_id.clone())); } if is_terminal { - if let Some(chunk) = self.adapter.map_tool_result_chunk( + let mapped_chunk = self.adapter.map_tool_result_chunk( &self.session_id, &cursor_name, &result_text, + &detailed_text, + pending.map(|pt| &pt.raw_input), + pending.map(|pt| pt.title.as_str()), + Some(self.task.as_str()), is_error, - ) { + ); + self.pending_tools.remove(&tool_call_id); + if let Some(chunk) = mapped_chunk { return vec![chunk]; } } @@ -868,7 +879,7 @@ pub async fn run_acp_protocol( image_paths: Vec, ) -> Result { let mut reader = BufReader::new(stdout); - let mut parser = AcpNotificationParser::new(adapter, session_id); + let mut parser = AcpNotificationParser::new_with_task(adapter, session_id, task); let mut line_buf = String::new(); let mut request_id: u64 = 0; diff --git a/src-tauri/src/agent_sessions/cli/parsers/opencode.rs b/src-tauri/src/agent_sessions/cli/parsers/opencode.rs index e19f0e5347..2d2554e0e6 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/opencode.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/opencode.rs @@ -22,6 +22,144 @@ fn extract_task_result(content: &str) -> Option { } } +fn quoted_attr(head: &str, attr: &str) -> Option { + let idx = head.find(attr)?; + let rest = &head[idx + attr.len()..]; + let quote = rest.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let quoted = &rest[quote.len_utf8()..]; + let close = quoted.find(quote)?; + let value = quoted[..close].trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn is_generic_task_label(value: &str) -> bool { + matches!(value.trim().to_ascii_lowercase().as_str(), "task" | "todo") +} + +fn strip_known_prompt_prelude(value: &str) -> &str { + let mut rest = value.trim(); + loop { + let tag = if rest.starts_with("") { + "skills" + } else if rest.starts_with("") { + "orgii_cli_exec_mode_bridge" + } else { + break; + }; + let close_tag = format!(""); + let Some(close_idx) = rest.find(&close_tag) else { + break; + }; + rest = rest[close_idx + close_tag.len()..].trim_start(); + } + rest.trim() +} + +fn non_generic_string(value: &str) -> Option { + let value = strip_known_prompt_prelude(value); + (!value.is_empty() && !is_generic_task_label(value)).then(|| value.to_string()) +} + +fn first_raw_input_string(raw_input: Option<&Value>, keys: &[&str]) -> Option { + let raw_input = raw_input?; + for key in keys { + if let Some(value) = raw_input.get(*key).and_then(|v| v.as_str()) { + if let Some(value) = non_generic_string(value) { + return Some(value); + } + } + } + None +} + +fn extract_task_prompt( + detailed: &str, + result_text: &str, + raw_input: Option<&Value>, + title: Option<&str>, + parent_task: Option<&str>, +) -> Option { + if let Some(prompt) = first_raw_input_string( + raw_input, + &[ + "prompt", + "description", + "task", + "input", + "instructions", + "text", + "message", + "query", + ], + ) { + return Some(prompt); + } + + if let Some(title) = title.and_then(non_generic_string) { + return Some(title); + } + + for source in [detailed, result_text] { + if source.is_empty() { + continue; + } + let Some(start) = source.find("') else { + continue; + }; + let head = &body[..=end]; + if let Some(prompt) = + quoted_attr(head, "prompt=").and_then(|prompt| non_generic_string(&prompt)) + { + return Some(prompt); + } + let after_head = &body[end + 1..]; + let prompt_end = after_head + .find("") + .or_else(|| after_head.find("")) + .unwrap_or(after_head.len()); + let prompt = after_head[..prompt_end].trim(); + if let Some(prompt) = non_generic_string(prompt) { + return Some(prompt); + } + } + parent_task.and_then(non_generic_string) +} + +fn opencode_app_session_id(raw: &str) -> String { + if raw.starts_with("opencodeapp-") { + raw.to_string() + } else { + format!("opencodeapp-{raw}") + } +} + +fn extract_task_session_id(content: &str) -> Option { + let start = content.find("')?; + let head = &body[..=end]; + for attr in ["id=", "session_id=", "sessionId="] { + if let Some(value) = quoted_attr(head, attr) { + return Some(opencode_app_session_id(&value)); + } + } + None +} + +fn is_completed_task_result(content: &str) -> bool { + content.contains("") + && (content.contains("state=\"completed\"") + || content.contains("state='completed'") + || !content.contains("state=\"")) +} + /// OpenCode adapter — maps OpenCode tool names to Cursor-normalized names. pub(crate) struct OpenCodeAdapter; @@ -61,18 +199,43 @@ impl AcpAgentAdapter for OpenCodeAdapter { session_id: &str, cursor_name: &str, result_text: &str, + detailed_text: &str, + raw_input: Option<&Value>, + title: Option<&str>, + parent_task: Option<&str>, is_error: bool, ) -> Option { if is_error || cursor_name != "think" { return None; } + if !is_completed_task_result(result_text) { + return None; + } + let task_result = extract_task_result(result_text)?; - let mut chunk = ActivityChunk::new(session_id, "assistant", "message"); + let prompt = extract_task_prompt(detailed_text, result_text, raw_input, title, parent_task); + let description = prompt + .as_deref() + .unwrap_or("Assigned task to subagent") + .to_string(); + let subagent_session_id = + extract_task_session_id(result_text).or_else(|| extract_task_session_id(detailed_text)); + + let mut chunk = ActivityChunk::new(session_id, "tool_call", "subagent"); + chunk.args = serde_json::json!({ + "action": "delegate", + "description": description, + "subagent_type": "opencode", + "subagentSessionId": subagent_session_id, + "prompt": prompt, + }); chunk.result = serde_json::json!({ + "success": true, + "status": "completed", "content": task_result, - "observation": task_result, - "role": "assistant", + "output": task_result, + "subagentSessionId": subagent_session_id, }); Some(chunk) } diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/opencode_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/opencode_tests.rs index ee9ad367cc..8dc89bac68 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/opencode_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/opencode_tests.rs @@ -252,7 +252,11 @@ fn map_tool_kind_unrecognised_name_falls_through_to_kind() { // ============================================ fn make_parser() -> AcpNotificationParser { - AcpNotificationParser::new(OpenCodeAdapter, "test-session") + make_parser_with_task("") +} + +fn make_parser_with_task(task: &str) -> AcpNotificationParser { + AcpNotificationParser::new_with_task(OpenCodeAdapter, "test-session", task) } // Helper: build a session/update notification body @@ -477,7 +481,7 @@ fn parse_update_unhandled_session_update_produces_no_chunk() { } #[test] -fn parse_update_completed_think_task_result_maps_to_assistant_message() { +fn parse_update_completed_think_task_result_maps_to_subagent_tool_call() { let mut parser = make_parser(); let start = session_update( @@ -503,10 +507,192 @@ fn parse_update_completed_think_task_result_maps_to_assistant_message() { let chunks = parser.parse_update(&update); assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].action_type, "assistant"); - assert_eq!(chunks[0].function, "message"); + assert_eq!(chunks[0].action_type, "tool_call"); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!(chunks[0].args["action"], "delegate"); + assert_eq!(chunks[0].args["subagent_type"], "opencode"); + assert_eq!(chunks[0].args["subagentSessionId"], "opencodeapp-ses_123"); + assert_eq!(chunks[0].result["success"], true); + assert_eq!(chunks[0].result["status"], "completed"); assert_eq!(chunks[0].result["content"], "Final answer from subagent."); - assert_eq!(chunks[0].result["role"], "assistant"); +} + +#[test] +fn parse_update_completed_think_task_detailed_content_preserves_prompt() { + let mut parser = make_parser(); + + parser.parse_update(&session_update( + "tool_call", + json!({ + "toolCallId": "tc-think-detailed", + "kind": "think", + "title": "", + "rawInput": {} + }), + )); + + let update = session_update( + "tool_call_update", + json!({ + "toolCallId": "tc-think-detailed", + "status": "completed", + "content": "Short answer.", + "rawOutput": { + "content": "Short answer.", + "detailedContent": "Short answer." + } + }), + ); + let chunks = parser.parse_update(&update); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!( + chunks[0].args["subagentSessionId"], + "opencodeapp-ses_detailed" + ); + assert_eq!(chunks[0].args["prompt"], "What is the weather in Paris?"); + assert_eq!( + chunks[0].result["subagentSessionId"], + "opencodeapp-ses_detailed" + ); +} + +#[test] +fn parse_update_completed_think_task_raw_input_preserves_prompt() { + let mut parser = make_parser(); + + parser.parse_update(&session_update( + "tool_call", + json!({ + "toolCallId": "tc-think-raw-input", + "kind": "think", + "title": "Fallback title should not win", + "rawInput": { + "description": "Analyze the React source tree" + } + }), + )); + + let chunks = parser.parse_update(&session_update( + "tool_call_update", + json!({ + "toolCallId": "tc-think-raw-input", + "status": "completed", + "content": "Done." + }), + )); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!(chunks[0].args["prompt"], "Analyze the React source tree"); + assert_eq!( + chunks[0].args["description"], + "Analyze the React source tree" + ); +} + +#[test] +fn parse_update_completed_think_task_title_preserves_prompt() { + let mut parser = make_parser(); + + parser.parse_update(&session_update( + "tool_call", + json!({ + "toolCallId": "tc-think-title", + "kind": "think", + "title": "Analyze .tsx files", + "rawInput": {} + }), + )); + + let chunks = parser.parse_update(&session_update( + "tool_call_update", + json!({ + "toolCallId": "tc-think-title", + "status": "completed", + "content": "Done." + }), + )); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!(chunks[0].args["prompt"], "Analyze .tsx files"); + assert_eq!(chunks[0].args["description"], "Analyze .tsx files"); +} + +#[test] +fn parse_update_completed_think_task_generic_title_falls_back_to_parent_prompt() { + let mut parser = make_parser_with_task( + "启动一个子任务(subagent),让它帮我分析当前项目里有多少个 .tsx 文件,并生成一份报告", + ); + + parser.parse_update(&session_update( + "tool_call", + json!({ + "toolCallId": "tc-think-generic-title", + "kind": "think", + "title": "task", + "rawInput": {} + }), + )); + + let chunks = parser.parse_update(&session_update( + "tool_call_update", + json!({ + "toolCallId": "tc-think-generic-title", + "status": "completed", + "content": "Done." + }), + )); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!( + chunks[0].args["prompt"], + "启动一个子任务(subagent),让它帮我分析当前项目里有多少个 .tsx 文件,并生成一份报告" + ); + assert_eq!( + chunks[0].args["description"], + "启动一个子任务(subagent),让它帮我分析当前项目里有多少个 .tsx 文件,并生成一份报告" + ); +} + +#[test] +fn parse_update_completed_think_task_strips_opencode_prompt_prelude() { + let mut parser = make_parser(); + + parser.parse_update(&session_update( + "tool_call", + json!({ + "toolCallId": "tc-think-prelude", + "kind": "think", + "title": "task", + "rawInput": {} + }), + )); + + let wrapped_prompt = "\n## Skills (mandatory)\n\n\n\nYou are running inside ORGII BUILD mode.\n\n\n启动一个子任务,分析 .tsx 文件"; + let chunks = parser.parse_update(&session_update( + "tool_call_update", + json!({ + "toolCallId": "tc-think-prelude", + "status": "completed", + "content": "Done.", + "rawOutput": { + "content": "Done.", + "detailedContent": format!("{}Done.", wrapped_prompt) + } + }), + )); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "subagent"); + assert_eq!(chunks[0].args["prompt"], "启动一个子任务,分析 .tsx 文件"); + assert_eq!( + chunks[0].args["description"], + "启动一个子任务,分析 .tsx 文件" + ); } #[test] diff --git a/src-tauri/src/agent_sessions/cli/persistence/chunk_ops.rs b/src-tauri/src/agent_sessions/cli/persistence/chunk_ops.rs index d781d841a4..fd4b962608 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/chunk_ops.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/chunk_ops.rs @@ -60,9 +60,124 @@ pub fn insert_chunk(chunk: &ActivityChunk, sequence: i64) -> SqliteResult<()> { project_management::lineage::event_hook::process_chunk(&sid, &func, &args_for_lineage); }); + if is_subagent_chunk(chunk) { + if let Err(err) = persist_subagent_child_session(chunk) { + tracing::warn!( + "[chunk_ops] failed to persist subagent child session for {}: {err}", + chunk.session_id + ); + } + } + Ok(()) } +/// True when this chunk is an OpenCode/CLI subagent delegation that should +/// spawn a child code_session row. The frontend uses that child row to attach +/// imported subagent history to the right parent and to keep the child out of +/// the primary left sidebar. +fn is_subagent_chunk(chunk: &ActivityChunk) -> bool { + chunk.action_type == "tool_call" && chunk.function == "subagent" +} + +/// Extract the subagent session id from a delegation chunk. Falls back to a +/// derived id from the parent so the child always has a stable session_id. +pub fn subagent_session_id(chunk: &ActivityChunk) -> Option { + if !is_subagent_chunk(chunk) { + return None; + } + if let Some(id) = chunk + .args + .get("subagentSessionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(id.to_string()); + } + if let Some(id) = chunk + .result + .get("subagentSessionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(id.to_string()); + } + let prompt_preview = chunk + .args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("subagent"); + let prefix = prompt_preview + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .take(24) + .collect::(); + let prefix = if prefix.is_empty() { + "subagent".to_string() + } else { + prefix + }; + Some(format!("opencodeapp-{}-{}", chunk.chunk_id, prefix)) +} + +/// Persist a `code_sessions` row representing the child subagent session. +/// Idempotent on (session_id) — repeated chunk events for the same delegation +/// do not create duplicate rows or bump `updated_at`. +pub fn persist_subagent_child_session(chunk: &ActivityChunk) -> SqliteResult { + let child_id = match subagent_session_id(chunk) { + Some(id) => id, + None => return Ok(false), + }; + let parent_id = chunk.session_id.clone(); + let prompt = chunk + .args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let task_label = if prompt.is_empty() { + truncate_label(&child_id) + } else { + truncate_label(&prompt) + }; + let name = format!("OpenCode ({task_label})"); + let ts = now_iso(); + let conn = get_connection()?; + // Use INSERT OR IGNORE so a re-emitted chunk (e.g. agent_replay) does not + // mutate an existing child row. `user_input` carries the prompt for + // sidebar previews; parent_session_id is what the visibility helper keys on. + let affected = conn.execute( + "INSERT OR IGNORE INTO code_sessions + (session_id, name, status, flow, runner, cli_agent_type, + user_input, parent_session_id, org_id, key_source, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)", + params![ + child_id, + name, + "completed", + "opencode_subagent", + "Local", + "opencode", + prompt, + parent_id, + "personal-org", + "own_key", + ts, + ], + )?; + Ok(affected > 0) +} + +fn truncate_label(s: &str) -> String { + if s.chars().count() <= 32 { + s.to_string() + } else { + let truncated: String = s.chars().take(29).collect(); + format!("{}...", truncated) + } +} + /// Load all chunks for a session, ordered by sequence. pub fn load_chunks(session_id: &str) -> SqliteResult> { let conn = get_connection()?; diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge.rs index 59d06677f4..f73ae25834 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge.rs @@ -2,28 +2,193 @@ //! //! Load/save events from SQLite cache with SessionEvent <-> CachedEvent conversion. +use core_types::activity::ActivityChunk; +use database::db::get_connection; use orgtrack_core::sources::cursor_ide::history::CURSORIDE_SESSION_PREFIX; +use orgtrack_core::sources::opencode::history as opencode_history; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; use crate::agent_sessions::event_pipeline::payload_compaction::{ - load_event_payload_body, EventPayloadBody, + EventPayloadBody, load_event_payload_body, +}; +use crate::agent_sessions::event_pipeline::types::{ + ActivityStatus, EventDisplayStatus, EventDisplayVariant, EventSource, SessionEvent, }; -use crate::agent_sessions::event_pipeline::types::SessionEvent; use session_persistence as sqlite_cache; use super::{ + BULK_WRITE_MAX_RETRIES, EventStoreState, event_conversion::{ backfill_subagent_links, backfill_tool_inputs_from_messages, cached_event_to_session_event, dedup_by_call_id, is_synthetic_persistence_artifact, session_event_to_cached_event, }, - save_events_retry, schedule_notify, EventStoreState, BULK_WRITE_MAX_RETRIES, + save_events_retry, schedule_notify, }; fn is_cursor_ide_session_id(session_id: &str) -> bool { session_id.starts_with(CURSORIDE_SESSION_PREFIX) } +fn is_opencode_app_session_id(session_id: &str) -> bool { + session_id.starts_with("opencodeapp-") +} + +fn activity_chunk_to_session_event(chunk: &ActivityChunk) -> SessionEvent { + let function_name = if chunk.function.is_empty() { + chunk.action_type.clone() + } else { + chunk.function.clone() + }; + SessionEvent { + id: if chunk.chunk_id.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + chunk.chunk_id.clone() + }, + chunk_id: if chunk.chunk_id.is_empty() { + None + } else { + Some(chunk.chunk_id.clone()) + }, + session_id: chunk.session_id.clone(), + created_at: chunk.created_at.clone(), + function_name: function_name.clone(), + ui_canonical: function_name, + action_type: chunk.action_type.clone(), + args: chunk.args.clone(), + result: chunk.result.clone(), + source: EventSource::Assistant, + display_text: format!("{}: {}", chunk.action_type, chunk.function), + display_status: EventDisplayStatus::Completed, + display_variant: EventDisplayVariant::ToolCall, + activity_status: ActivityStatus::Processed, + thread_id: chunk.thread_id.clone(), + process_id: chunk.process_id.clone(), + call_id: None, + file_path: None, + command: None, + is_delta: None, + repo_id: None, + repo_path: None, + extracted: None, + payload_refs: Vec::new(), + last_extract_at: None, + } +} + +fn try_load_opencode_history_events(session_id: &str) -> Result, String> { + let chunks = opencode_history::load_opencode_history_for_session(session_id)?; + Ok(chunks.iter().map(activity_chunk_to_session_event).collect()) +} + +fn is_generic_opencode_task_label(value: &str) -> bool { + matches!(value.trim().to_ascii_lowercase().as_str(), "task" | "todo") +} + +fn strip_known_opencode_prompt_prelude(value: &str) -> &str { + let mut rest = value.trim(); + loop { + let tag = if rest.starts_with("") { + "skills" + } else if rest.starts_with("") { + "orgii_cli_exec_mode_bridge" + } else { + break; + }; + let close_tag = format!(""); + let Some(close_idx) = rest.find(&close_tag) else { + break; + }; + rest = rest[close_idx + close_tag.len()..].trim_start(); + } + rest.trim() +} + +fn is_good_opencode_subagent_prompt(value: &str) -> bool { + let value = strip_known_opencode_prompt_prelude(value); + !value.is_empty() && !is_generic_opencode_task_label(value) +} + +fn non_generic_opencode_prompt(value: String) -> Option { + let value = strip_known_opencode_prompt_prelude(&value).to_string(); + (!value.is_empty() && !is_generic_opencode_task_label(&value)).then_some(value) +} + +fn opencode_subagent_prompt(parent_session_id: &str, child_session_id: &str) -> Option { + if !is_opencode_app_session_id(child_session_id) { + return None; + } + let conn = get_connection().ok()?; + if let Ok(prompt) = conn.query_row( + "SELECT user_input FROM code_sessions WHERE session_id = ?1 AND cli_agent_type = 'opencode'", + [child_session_id], + |row| row.get::<_, String>(0), + ) { + if let Some(prompt) = non_generic_opencode_prompt(prompt) { + return Some(prompt); + } + } + if let Ok(name) = conn.query_row( + "SELECT name FROM imported_history_session_cache WHERE session_id = ?1 AND source = 'opencode'", + [child_session_id], + |row| row.get::<_, String>(0), + ) { + if let Some(name) = non_generic_opencode_prompt(name) { + return Some(name); + } + } + conn.query_row( + "SELECT user_input FROM code_sessions WHERE session_id = ?1 AND cli_agent_type = 'opencode'", + [parent_session_id], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(non_generic_opencode_prompt) +} + +fn backfill_opencode_subagent_prompts(session_id: &str, events: &mut [SessionEvent]) { + for event in events { + if event.function_name != "subagent" && event.ui_canonical != "subagent" { + continue; + } + let Some(args) = event.args.as_object_mut() else { + continue; + }; + let has_prompt = args + .get("prompt") + .and_then(|value| value.as_str()) + .map(is_good_opencode_subagent_prompt) + .unwrap_or(false); + if has_prompt { + continue; + } + let Some(child_session_id) = args + .get("subagentSessionId") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(prompt) = opencode_subagent_prompt(session_id, child_session_id) else { + continue; + }; + args.insert( + "prompt".to_string(), + serde_json::Value::String(prompt.clone()), + ); + let should_replace_description = args + .get("description") + .and_then(|value| value.as_str()) + .map(|description| !is_good_opencode_subagent_prompt(description)) + .unwrap_or(true); + if should_replace_description { + args.insert("description".to_string(), serde_json::Value::String(prompt)); + } + } +} + // ============================================================================ // SQLite Bridge Commands // ============================================================================ @@ -52,13 +217,25 @@ pub async fn es_load_from_cache( .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; - let events: Vec = cached + let mut events: Vec = cached .into_iter() .map(|ce| cached_event_to_session_event(&ce)) .collect(); + + if events.is_empty() && is_opencode_app_session_id(&session_id) { + match try_load_opencode_history_events(&session_id) { + Ok(loaded) if !loaded.is_empty() => events = loaded, + Ok(_) => {} + Err(err) => tracing::warn!( + "[cache_bridge] failed to load OpenCode history for {session_id}: {err}" + ), + } + } + let mut events = dedup_by_call_id(events); backfill_tool_inputs_from_messages(&session_id, &mut events); backfill_subagent_links(&session_id, &mut events); + backfill_opencode_subagent_prompts(&session_id, &mut events); let count = events.len(); if count > 0 { state.with_store_mut(&session_id, |store| { @@ -173,10 +350,20 @@ pub async fn cache_load_session_events(session_id: String) -> Result = cached.iter().map(cached_event_to_session_event).collect(); + let mut events: Vec = cached.iter().map(cached_event_to_session_event).collect(); + if events.is_empty() && is_opencode_app_session_id(&session_id) { + match try_load_opencode_history_events(&session_id) { + Ok(loaded) if !loaded.is_empty() => events = loaded, + Ok(_) => {} + Err(err) => tracing::warn!( + "[cache_bridge] failed to load OpenCode history for {session_id}: {err}" + ), + } + } let mut events = dedup_by_call_id(events); backfill_tool_inputs_from_messages(&session_id, &mut events); backfill_subagent_links(&session_id, &mut events); + backfill_opencode_subagent_prompts(&session_id, &mut events); Ok(events) } @@ -328,6 +515,7 @@ pub async fn cache_load_full_session( let mut events = dedup_by_call_id(events); backfill_tool_inputs_from_messages(&s.session_id, &mut events); backfill_subagent_links(&s.session_id, &mut events); + backfill_opencode_subagent_prompts(&s.session_id, &mut events); FullSessionPayload { session_id: s.session_id, events, diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/history.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/history.rs index 515277ae23..18acf11045 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/history.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/history.rs @@ -7,8 +7,9 @@ use crate::agent_sessions::event_pipeline::history::{ self, HistoryQuery, HistoryResult, SessionGroup, SessionRecord, }; use crate::agent_sessions::event_pipeline::statistics::{self, SessionStatistics}; -use agent_core::session::persistence::UnifiedSessionRecord; +use agent_core::session::persistence::{session_type, UnifiedSessionRecord}; use agent_core::session::SessionStatus; +use database::db::get_connection; use serde::Serialize; /// Query session history with filtering, sorting, and pagination. @@ -94,8 +95,16 @@ fn clip_fields( pub async fn es_get_child_sessions( parent_session_id: String, ) -> Result, String> { - let records = agent_core::session::persistence::get_child_sessions(&parent_session_id) + let mut records = agent_core::session::persistence::get_child_sessions(&parent_session_id) .map_err(|e| format!("Failed to get child sessions: {}", e))?; + records.extend(cli_child_session_records(&parent_session_id)?); + records.extend(imported_child_session_records(&parent_session_id)?); + if let Some(opencode_parent_session_id) = opencode_app_parent_session_id(&parent_session_id)? { + records.extend(imported_child_session_records(&opencode_parent_session_id)?); + } + + let mut seen = std::collections::HashSet::new(); + records.retain(|record| seen.insert(record.session_id.clone())); Ok(records .into_iter() @@ -115,6 +124,126 @@ pub async fn es_get_child_sessions( .collect()) } +fn opencode_app_parent_session_id(parent_session_id: &str) -> Result, String> { + if parent_session_id.starts_with("opencodeapp-") { + return Ok(None); + } + let conn = get_connection().map_err(|err| format!("Failed to open CLI session DB: {err}"))?; + match conn.query_row( + "SELECT cli_session_id FROM code_sessions WHERE session_id = ?1 AND cli_agent_type = 'opencode'", + [parent_session_id], + |row| row.get::<_, Option>(0), + ) { + Ok(Some(cli_session_id)) if !cli_session_id.trim().is_empty() => { + Ok(Some(format!("opencodeapp-{}", cli_session_id.trim()))) + } + Ok(_) | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(format!( + "Failed to query OpenCode CLI session id for {parent_session_id}: {err}" + )), + } +} + +fn cli_child_session_records(parent_session_id: &str) -> Result, String> { + let conn = get_connection().map_err(|err| format!("Failed to open CLI session DB: {err}"))?; + let mut stmt = conn + .prepare( + "SELECT session_id, name, status, model, user_input, created_at, updated_at, repo_path, branch, total_tokens \ + FROM code_sessions \ + WHERE parent_session_id = ?1 \ + ORDER BY updated_at DESC, created_at DESC, session_id ASC", + ) + .map_err(|err| format!("Failed to prepare CLI child session query: {err}"))?; + let rows = stmt + .query_map([parent_session_id], |row| { + let session_id: String = row.get(0)?; + let name: String = row.get(1)?; + let status: String = row.get(2)?; + let model: Option = row.get(3)?; + let user_input: Option = row.get(4)?; + let created_at: String = row.get(5)?; + let updated_at: String = row.get(6)?; + let repo_path: Option = row.get(7)?; + let branch: Option = row.get(8)?; + let total_tokens: i64 = row.get::<_, Option>(9)?.unwrap_or_default(); + Ok(UnifiedSessionRecord { + session_id, + name, + status, + model, + user_input, + total_tokens, + created_at, + updated_at, + session_type: session_type::SUBAGENT.to_string(), + workspace_path: repo_path, + worktree_branch: branch, + parent_session_id: Some(parent_session_id.to_string()), + ..Default::default() + }) + }) + .map_err(|err| format!("Failed to query CLI child sessions: {err}"))?; + + let mut records = Vec::new(); + for row in rows { + records.push(row.map_err(|err| format!("Failed to read CLI child session row: {err}"))?); + } + Ok(records) +} + +fn imported_child_session_records( + parent_session_id: &str, +) -> Result, String> { + let conn = + get_connection().map_err(|err| format!("Failed to open imported history DB: {err}"))?; + let mut stmt = conn + .prepare( + "SELECT session_id, name, created_at_ms, updated_at_ms, model, input_tokens, output_tokens, repo_path \ + FROM imported_history_session_cache \ + WHERE parent_session_id = ?1 \ + ORDER BY updated_at_ms DESC, created_at_ms DESC, source_session_id ASC", + ) + .map_err(|err| format!("Failed to prepare imported child session query: {err}"))?; + let rows = stmt + .query_map([parent_session_id], |row| { + let session_id: String = row.get(0)?; + let name: String = row.get(1)?; + let created_at_ms: i64 = row.get(2)?; + let updated_at_ms: i64 = row.get(3)?; + let model: String = row.get(4)?; + let input_tokens: i64 = row.get(5)?; + let output_tokens: i64 = row.get(6)?; + let repo_path: String = row.get(7)?; + Ok(UnifiedSessionRecord { + session_id, + name, + status: SessionStatus::Completed.as_str().to_string(), + model: (!model.trim().is_empty()).then_some(model), + total_tokens: input_tokens + output_tokens, + created_at: imported_epoch_ms_to_iso(created_at_ms), + updated_at: imported_epoch_ms_to_iso(updated_at_ms), + session_type: session_type::SUBAGENT.to_string(), + workspace_path: (!repo_path.trim().is_empty()).then_some(repo_path), + parent_session_id: Some(parent_session_id.to_string()), + ..Default::default() + }) + }) + .map_err(|err| format!("Failed to query imported child sessions: {err}"))?; + + let mut records = Vec::new(); + for row in rows { + records + .push(row.map_err(|err| format!("Failed to read imported child session row: {err}"))?); + } + Ok(records) +} + +fn imported_epoch_ms_to_iso(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .unwrap_or_else(chrono::Utc::now) + .to_rfc3339() +} + /// Get the parent session for a given child session. #[tauri::command] pub async fn es_get_parent_session( diff --git a/src-tauri/src/agent_sessions/unified_stats/conversion.rs b/src-tauri/src/agent_sessions/unified_stats/conversion.rs index 0f03b825aa..71630ba908 100644 --- a/src-tauri/src/agent_sessions/unified_stats/conversion.rs +++ b/src-tauri/src/agent_sessions/unified_stats/conversion.rs @@ -134,8 +134,8 @@ pub fn cli_session_to_aggregate_record( agent_role: session.agent_role, is_active, display_label, - parent_session_id: None, - org_member_id: None, + parent_session_id: session.parent_session_id, + org_member_id: session.org_member_id, agent_org_id: None, agent_org_name: None, agent_definition_id: None, @@ -199,7 +199,7 @@ pub fn imported_history_to_aggregate_record( agent_role: None, is_active: row.is_active, display_label, - parent_session_id: None, + parent_session_id: row.parent_session_id, org_member_id: None, agent_org_id: None, agent_org_name: None, diff --git a/src-tauri/src/orgtrack/history_commands.rs b/src-tauri/src/orgtrack/history_commands.rs index 82f941654e..34dd1fdfab 100644 --- a/src-tauri/src/orgtrack/history_commands.rs +++ b/src-tauri/src/orgtrack/history_commands.rs @@ -28,6 +28,25 @@ fn imported_recent_paths() -> Result Result { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM code_sessions + WHERE session_id = ?1 + AND cli_agent_type = 'opencode' + AND parent_session_id IS NOT NULL + AND parent_session_id != '' + )", + [session_id], + |row| row.get::<_, i64>(0), + ) + .map(|count| count != 0) + .map_err(|err| format!("Failed to check live OpenCode child session: {err}")) +} + #[tauri::command] pub async fn orgtrack_get_cursor_sessions( start_date: String, @@ -249,7 +268,12 @@ pub async fn opencode_history_list_sessions( let offset = offset.unwrap_or(0); tokio::task::spawn_blocking(move || { let mut conn = open_cache_conn()?; - opencode_history::list_opencode_history_sessions_paginated(&mut conn, limit, offset) + let mut page = + opencode_history::list_opencode_history_sessions_paginated(&mut conn, limit, offset)?; + page.sessions.retain(|session| { + !has_live_opencode_child_session(&conn, &session.session_id).unwrap_or(false) + }); + Ok(page) }) .await .map_err(|err| format!("Task join error: {err}"))? diff --git a/src/api/tauri/claudeCodeHistory/index.ts b/src/api/tauri/claudeCodeHistory/index.ts index 85d1b5317d..230a37f30d 100644 --- a/src/api/tauri/claudeCodeHistory/index.ts +++ b/src/api/tauri/claudeCodeHistory/index.ts @@ -21,6 +21,7 @@ export interface ClaudeCodeHistorySessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface ClaudeCodeHistorySessionPage { diff --git a/src/api/tauri/codexApp/index.ts b/src/api/tauri/codexApp/index.ts index 582f8f2fc6..dac3109bef 100644 --- a/src/api/tauri/codexApp/index.ts +++ b/src/api/tauri/codexApp/index.ts @@ -21,6 +21,7 @@ export interface CodexAppSessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface CodexAppSessionPage { diff --git a/src/api/tauri/importedHistory/index.ts b/src/api/tauri/importedHistory/index.ts index 4802fef61b..a3ab7190be 100644 --- a/src/api/tauri/importedHistory/index.ts +++ b/src/api/tauri/importedHistory/index.ts @@ -65,6 +65,7 @@ export interface ImportedHistorySessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface ImportedHistorySessionPage { diff --git a/src/api/tauri/opencodeHistory/index.ts b/src/api/tauri/opencodeHistory/index.ts index ceed9ce521..46b733542a 100644 --- a/src/api/tauri/opencodeHistory/index.ts +++ b/src/api/tauri/opencodeHistory/index.ts @@ -21,6 +21,7 @@ export interface OpenCodeHistorySessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface OpenCodeHistorySessionPage { diff --git a/src/api/tauri/windsurfHistory/index.ts b/src/api/tauri/windsurfHistory/index.ts index 04ec2d2647..cfd97f6e8f 100644 --- a/src/api/tauri/windsurfHistory/index.ts +++ b/src/api/tauri/windsurfHistory/index.ts @@ -21,6 +21,7 @@ export interface WindsurfHistorySessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface WindsurfHistorySessionPage { diff --git a/src/api/tauri/workbuddyHistory/index.ts b/src/api/tauri/workbuddyHistory/index.ts index 5df7b6e308..4282bb0e46 100644 --- a/src/api/tauri/workbuddyHistory/index.ts +++ b/src/api/tauri/workbuddyHistory/index.ts @@ -21,6 +21,7 @@ export interface WorkBuddyHistorySessionRow { linesAdded: number; linesRemoved: number; touchedFiles: string[]; + parentSessionId?: string; } export interface WorkBuddyHistorySessionPage { diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index 7230ed03c8..84350a987c 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -233,8 +233,12 @@ const ChatView: React.FC = memo( [] ); + const isCursorIde = isCursorIdeSession(sessionId); + const isExternalHistory = isExternalHistorySession(sessionId); + const isReadOnlySurface = readOnly || isExternalHistory; + useEffect(() => { - if (readOnly) return; + if (isReadOnlySurface) return; setActiveSessionId(sessionId); // Secondary surfaces (e.g. kanban detail panel) must release the @@ -252,13 +256,9 @@ const ChatView: React.FC = memo( setActiveSessionId(null); } }; - }, [sessionId, setActiveSessionId, readOnly, secondary, store]); + }, [sessionId, setActiveSessionId, isReadOnlySurface, secondary, store]); - useFileReviewSync(sessionId, !readOnly && !secondary); - - const isCursorIde = isCursorIdeSession(sessionId); - const isExternalHistory = isExternalHistorySession(sessionId); - const isReadOnlySurface = readOnly || isExternalHistory; + useFileReviewSync(sessionId, !isReadOnlySurface && !secondary); const currentSession = useAtomValue(sessionByIdAtom(sessionId)); const [orgtrackSummary, setOrgtrackSummary] = useState(null); diff --git a/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx b/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx index f64955a34c..5a778c1ed8 100644 --- a/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx +++ b/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx @@ -4,6 +4,7 @@ import React, { memo, useCallback, useMemo, useState } from "react"; import ExpandOverlay from "@src/components/ExpandOverlay"; +import Markdown from "@src/components/MarkDown"; import UserMessageContent from "@src/engines/ChatPanel/ChatHistory/components/UserMessageContent"; import { EVENT_BLOCK_FADE_FROM } from "../primitives"; @@ -103,4 +104,20 @@ export const SubagentPromptPreview: React.FC<{ ); }); +export const SubagentResultPreview: React.FC<{ + content: string; +}> = memo(({ content }) => ( +
+
+ +
+
+)); +SubagentResultPreview.displayName = "SubagentResultPreview"; + SubagentPromptPreview.displayName = "SubagentPromptPreview"; diff --git a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx index 6353020f30..44f41d432b 100644 --- a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx @@ -14,14 +14,16 @@ * its expandable payload. */ import { useAtomValue, useSetAtom } from "jotai"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { navigateToEventAtom } from "@src/engines/SessionCore/core/atoms/actions"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import type { UniversalEventProps } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanelAtom"; import { focusedSubagentCellAtom, + stationModeAtom, subagentPanelRevealRequestAtom, } from "@src/store/ui/simulatorAtom"; @@ -143,6 +145,8 @@ export const SubagentAdapter: React.FC = (props) => { const setFocusedCell = useSetAtom(focusedSubagentCellAtom); const setPanelReveal = useSetAtom(subagentPanelRevealRequestAtom); + const setChatPanelMaximized = useSetAtom(chatPanelMaximizedAtom); + const setStationMode = useSetAtom(stationModeAtom); const navigateToEvent = useSetAtom(navigateToEventAtom); const handleNavigate = useCallback(() => { if (!data.subagentSessionId) return; @@ -154,14 +158,32 @@ export const SubagentAdapter: React.FC = (props) => { // also flips replayMode to "replay" (free-browse), pausing tail-follow at // that moment. The cell then re-materialises and focus/reveal take effect. navigateToEvent(props.eventId); + setStationMode("agent-station"); + setChatPanelMaximized(false); setFocusedCell(data.subagentSessionId); setPanelReveal((prev) => prev + 1); }, [ data.subagentSessionId, props.eventId, navigateToEvent, + setChatPanelMaximized, setFocusedCell, setPanelReveal, + setStationMode, + ]); + + useEffect(() => { + if (!data.subagentSessionId) return; + setStationMode("agent-station"); + setChatPanelMaximized(false); + setFocusedCell(data.subagentSessionId); + setPanelReveal((prev) => prev + 1); + }, [ + data.subagentSessionId, + setChatPanelMaximized, + setFocusedCell, + setPanelReveal, + setStationMode, ]); return ( diff --git a/src/engines/Simulator/ActivitySimulator.tsx b/src/engines/Simulator/ActivitySimulator.tsx index c8a6154901..31175b0892 100644 --- a/src/engines/Simulator/ActivitySimulator.tsx +++ b/src/engines/Simulator/ActivitySimulator.tsx @@ -83,6 +83,7 @@ const ActivitySimulator: React.FC = memo(() => { previewById, specs, filteredEvents, + allEvents, currentEvent, currentEventIndex, eventStoreVersion, @@ -126,6 +127,7 @@ const ActivitySimulator: React.FC = memo(() => { sessionId, eventStoreVersion, currentEvent, + allEvents, }); // When subagents are active the layout automatically becomes a split view diff --git a/src/engines/Simulator/components/SubagentPipCard.tsx b/src/engines/Simulator/components/SubagentPipCard.tsx index 47a60b02d9..f144282df4 100644 --- a/src/engines/Simulator/components/SubagentPipCard.tsx +++ b/src/engines/Simulator/components/SubagentPipCard.tsx @@ -101,18 +101,17 @@ const SubagentPipCard: React.FC = ({ ); const subagentEventsMap = useMultiSessionSimulatorEvents(visibleSessions); - // "Monitoring N" counts only clips still running at the cursor — open - // clips (endedAtMs === null) or clips whose end the cursor hasn't reached. - // Finished in-window clips keep their cell but don't count as monitored. - const runningCount = useMemo( - () => - activeSessions.filter( - (sub) => - sub.endedAtMs === null || - (mainCursorMs != null && mainCursorMs < sub.endedAtMs) - ).length, - [activeSessions, mainCursorMs] - ); + // "Monitoring N" prefers clips still running at the cursor, but completed + // imported/live child clips still count when they are the visible monitor + // rows; otherwise the header would say "Monitoring 0" while showing a cell. + const runningCount = useMemo(() => { + const running = activeSessions.filter( + (sub) => + sub.endedAtMs === null || + (mainCursorMs != null && mainCursorMs < sub.endedAtMs) + ).length; + return running > 0 ? running : activeSessions.length; + }, [activeSessions, mainCursorMs]); // ── Banner collapsed state ──────────────────────────────────────────────── const [isBannerCollapsed, setIsBannerCollapsed] = useState(false); diff --git a/src/engines/Simulator/hooks/useSimulatorSession.ts b/src/engines/Simulator/hooks/useSimulatorSession.ts index 0e71f9d03e..d748bdf7b6 100644 --- a/src/engines/Simulator/hooks/useSimulatorSession.ts +++ b/src/engines/Simulator/hooks/useSimulatorSession.ts @@ -15,6 +15,7 @@ import { effectiveSimulatorEventIdsAtom, navigateToFirstSimulatorEventAtom, simulatorEventPreviewByIdAtom, + sortedEventsAtom, sortedSimulatorEventIdsAtom, } from "@src/engines/SessionCore"; import type { @@ -41,6 +42,7 @@ export interface UseSimulatorSessionReturn { previewById: Record; specs: ReturnType["specs"]; filteredEvents: SessionEvent[]; + allEvents: SessionEvent[]; currentEvent: SessionEvent | null; currentEventIndex: number; eventStoreVersion: number; @@ -65,6 +67,7 @@ export function useSimulatorSession(): UseSimulatorSessionReturn { const effectiveEventIds = useAtomValue(effectiveSimulatorEventIdsAtom); const sortedSimulatorEventIds = useAtomValue(sortedSimulatorEventIdsAtom); + const allEvents = useAtomValue(sortedEventsAtom); const previewById = useAtomValue(simulatorEventPreviewByIdAtom); const eventById = useAtomValue(eventIndexAtom); const eventStoreVersion = useAtomValue(eventStoreVersionAtom); @@ -165,6 +168,7 @@ export function useSimulatorSession(): UseSimulatorSessionReturn { previewById, specs, filteredEvents, + allEvents, currentEvent, currentEventIndex, eventStoreVersion, diff --git a/src/engines/Simulator/hooks/useSimulatorSubagents.ts b/src/engines/Simulator/hooks/useSimulatorSubagents.ts index aecf815fba..cab9e93e03 100644 --- a/src/engines/Simulator/hooks/useSimulatorSubagents.ts +++ b/src/engines/Simulator/hooks/useSimulatorSubagents.ts @@ -33,6 +33,65 @@ interface UseSimulatorSubagentsOptions { sessionId: string; eventStoreVersion: number; currentEvent: SessionEvent | null; + allEvents: SessionEvent[]; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function subagentIdFromEvent(event: SessionEvent): string | null { + const isSubagentTool = + event.actionType === "tool_call" && + (event.functionName === "subagent" || event.uiCanonical === "subagent"); + if (!isSubagentTool) { + return null; + } + return ( + nonEmptyString(event.args?.subagentSessionId) ?? + nonEmptyString(event.result?.subagentSessionId) + ); +} + +function taskTitleFromEvent(event: SessionEvent, sessionId: string): string { + return ( + nonEmptyString(event.args?.prompt) ?? + nonEmptyString(event.args?.description) ?? + nonEmptyString(event.result?.summary) ?? + nonEmptyString(event.result?.content) ?? + sessionId + ); +} + +function fallbackSubagentSessionsFromEvents( + events: readonly SessionEvent[] +): SubagentSession[] { + const sessions = new Map(); + for (const event of events) { + const sessionId = subagentIdFromEvent(event); + if (!sessionId || sessions.has(sessionId)) continue; + const createdMs = new Date(event.createdAt).getTime(); + const safeCreatedMs = Number.isFinite(createdMs) ? createdMs : Date.now(); + const isCompleted = + event.displayStatus === "completed" || + event.activityStatus === "processed"; + const isFailed = event.displayStatus === "failed"; + sessions.set(sessionId, { + key: sessionId, + sessionId, + name: "OpenCode", + description: taskTitleFromEvent(event, sessionId), + sessionType: "subagent", + status: isFailed ? "failed" : isCompleted ? "completed" : "running", + isBackground: true, + startedAtMs: safeCreatedMs, + endedAtMs: isCompleted || isFailed ? safeCreatedMs : null, + isTerminal: isCompleted || isFailed, + }); + } + return Array.from(sessions.values()); } export interface UseSimulatorSubagentsReturn { @@ -46,6 +105,7 @@ export function useSimulatorSubagents({ sessionId, eventStoreVersion, currentEvent, + allEvents, }: UseSimulatorSubagentsOptions): UseSimulatorSubagentsReturn { const panelRevealRequest = useAtomValue(subagentPanelRevealRequestAtom); const focusedCellId = useAtomValue(focusedSubagentCellAtom); @@ -56,10 +116,24 @@ export function useSimulatorSubagents({ // DB query — re-triggered by eventStoreVersion (bumped on every EventStore // mutation, including args patches like stamp_subagent_session_id_on_parent). - const allSubagentSessions = useSubagentSessions( + const dbSubagentSessions = useSubagentSessions( sessionId || null, eventStoreVersion ); + const eventSubagentSessions = useMemo( + () => fallbackSubagentSessionsFromEvents(allEvents), + [allEvents] + ); + const allSubagentSessions = useMemo(() => { + if (eventSubagentSessions.length === 0) return dbSubagentSessions; + const byId = new Map(dbSubagentSessions.map((sub) => [sub.sessionId, sub])); + for (const fallback of eventSubagentSessions) { + if (!byId.has(fallback.sessionId)) { + byId.set(fallback.sessionId, fallback); + } + } + return Array.from(byId.values()); + }, [dbSubagentSessions, eventSubagentSessions]); // Sync to atom so SessionReplayMessages can read without prop drilling. // Cleanup clears the atom when ActivitySimulator unmounts so stale sessions @@ -90,6 +164,23 @@ export function useSimulatorSubagents({ () => allSubagentSessions.filter((sub) => sub.endedAtMs === null), [allSubagentSessions] ); + // Imported OpenCode subagent history is already completed by the time it + // shows up in the simulator. Only resurface a completed clip when the replay + // cursor is on that subagent delegate event; once the cursor moves past the + // clip, terminal DB-backed clips must still retire like native SDE subagents. + const currentEventSubagentId = useMemo( + () => (currentEvent ? subagentIdFromEvent(currentEvent) : null), + [currentEvent] + ); + const currentEventCompletedSubagent = useMemo(() => { + if (!currentEventSubagentId) return []; + const sub = allSubagentSessions.find( + (session) => + session.sessionId === currentEventSubagentId && + session.endedAtMs !== null + ); + return sub ? [sub] : []; + }, [allSubagentSessions, currentEventSubagentId]); // A subagent the user explicitly navigated to (clicked the chat block's // locate arrow) must surface even when the replay cursor doesn't land inside // its clip window. The spawning tool_call is filtered out of the simulator @@ -105,7 +196,11 @@ export function useSimulatorSubagents({ [allSubagentSessions, focusedCellId] ); const baseSubagents = - cursorActiveSubagents.length > 0 ? cursorActiveSubagents : openSubagents; + cursorActiveSubagents.length > 0 + ? cursorActiveSubagents + : openSubagents.length > 0 + ? openSubagents + : currentEventCompletedSubagent; const cursorOrAllSubagents = useMemo(() => { if (!focusedSubagent) return baseSubagents; if ( diff --git a/src/engines/Simulator/hooks/useSubagentSessions.ts b/src/engines/Simulator/hooks/useSubagentSessions.ts index ba92040e74..7ac231a56a 100644 --- a/src/engines/Simulator/hooks/useSubagentSessions.ts +++ b/src/engines/Simulator/hooks/useSubagentSessions.ts @@ -229,8 +229,10 @@ export function useSubagentSessions( const lastQueryRef = useRef(null); // Discard stale sessions from a previous parent without an extra render. - const sessions = - rawSessions.parentId === parentSessionId ? rawSessions.list : []; + const sessions = useMemo( + () => (rawSessions.parentId === parentSessionId ? rawSessions.list : []), + [rawSessions, parentSessionId] + ); const setSessions = useCallback( (list: SubagentSession[]) => diff --git a/src/modules/WorkStation/AppShell/AppShellContent.tsx b/src/modules/WorkStation/AppShell/AppShellContent.tsx index 4b8233db5d..f4390a6986 100644 --- a/src/modules/WorkStation/AppShell/AppShellContent.tsx +++ b/src/modules/WorkStation/AppShell/AppShellContent.tsx @@ -124,10 +124,12 @@ export function AppShellContent({ return ( <> - {(isAgentStation || hasVisitedAgentStation) && !chatPanelFocused && ( + {(isAgentStation || hasVisitedAgentStation) && (
}> diff --git a/src/store/session/sessionAtom/loaders.ts b/src/store/session/sessionAtom/loaders.ts index f824fdd31d..a32c0fd093 100644 --- a/src/store/session/sessionAtom/loaders.ts +++ b/src/store/session/sessionAtom/loaders.ts @@ -107,6 +107,8 @@ function importedHistoryRowToSession( touchedFiles: row.touchedFiles, agentIconId: source.iconId, agentDisplayName: source.displayName, + parentSessionId: row.parentSessionId, + readOnly: row.readOnly ?? true, }; } @@ -187,9 +189,9 @@ async function loadImportedHistorySourcePage( offset, }); return { - sessions: page.sessions.map((row) => - importedHistoryRowToSession(row, source) - ), + sessions: page.sessions + .map((row) => importedHistoryRowToSession(row, source)) + .filter(isPrimarySessionListSession), hasMore: page.hasMore, }; } @@ -276,9 +278,9 @@ export const loadSessions = async (options?: { for (const result of importedPageResults) { if (result.status === "fulfilled") { fetched.push( - ...result.value.sessions.map((row) => - importedHistoryRowToSession(row, result.source) - ) + ...result.value.sessions + .map((row) => importedHistoryRowToSession(row, result.source)) + .filter(isPrimarySessionListSession) ); } else { log.warn( @@ -492,7 +494,8 @@ export const loadMoreCategory = async ( current.loaded, pageSize ); - store.set(sessionsAtom, (prev) => mergeSessions(prev, sessions)); + const primarySessions = sessions.filter(isPrimarySessionListSession); + store.set(sessionsAtom, (prev) => mergeSessions(prev, primarySessions)); setPaginationFor(category, { loaded: current.loaded + sessions.length, hasMore, diff --git a/src/store/session/sessionAtom/types.ts b/src/store/session/sessionAtom/types.ts index 921f81e690..acb6e521fa 100644 --- a/src/store/session/sessionAtom/types.ts +++ b/src/store/session/sessionAtom/types.ts @@ -83,6 +83,8 @@ export interface Session { agentRole?: AgentRole | string; /** Parent/root session id for child sessions such as Agent Team member sessions. */ parentSessionId?: string; + /** True for imported history rows that cannot be written back. */ + readOnly?: boolean; /** Agent Team roster member id for team member session rows. */ orgMemberId?: string; /** Agent Team definition id for root/coordinator rows launched from a team. */ diff --git a/src/util/session/__tests__/sessionVisibility.test.ts b/src/util/session/__tests__/sessionVisibility.test.ts index b853005a10..51e658ae29 100644 --- a/src/util/session/__tests__/sessionVisibility.test.ts +++ b/src/util/session/__tests__/sessionVisibility.test.ts @@ -38,4 +38,33 @@ describe("isPrimarySessionListSession", () => { }) ).toBe(true); }); + + it("hides imported child sessions with parent id in generic visibility filtering", () => { + expect( + isPrimarySessionListSession({ + session_id: "claudecodeapp-child", + parentSessionId: "claudecodeapp-parent", + readOnly: true, + }) + ).toBe(false); + }); + + it("accepts snake_case parent_session_id for child detection", () => { + expect( + isPrimarySessionListSession({ + session_id: "opencodeapp-ses_child", + parent_session_id: "opencodeapp-ses_1", + }) + ).toBe(false); + }); + + it("does not let readOnly smuggle child sessions back into the sidebar", () => { + expect( + isPrimarySessionListSession({ + session_id: "opencodeapp-ses_child", + parentSessionId: "opencodeapp-ses_1", + readOnly: false, + }) + ).toBe(false); + }); }); diff --git a/src/util/session/sessionVisibility.ts b/src/util/session/sessionVisibility.ts index ae3c0398ac..589326570a 100644 --- a/src/util/session/sessionVisibility.ts +++ b/src/util/session/sessionVisibility.ts @@ -4,14 +4,25 @@ interface SessionVisibilityInput { session_id: string; orgMemberId?: string; parentSessionId?: string; + parent_session_id?: string | null; agentOrgId?: string; + /** + * Imported-history rows are read-only. The helper does not consult this + * field — a child session stays hidden regardless — but the interface + * accepts it so upstream call sites can pass `readOnly` through without + * stripping it first. + */ + readOnly?: boolean; } export function isPrimarySessionListSession( session: SessionVisibilityInput ): boolean { + const hasParentSessionId = Boolean( + session.parentSessionId ?? session.parent_session_id + ); const isChildSession = - Boolean(session.parentSessionId) || + hasParentSessionId || session.session_id.includes(SUBAGENT_SESSION_ID_SEGMENT); if (isChildSession) return false; return !session.orgMemberId || Boolean(session.agentOrgId); diff --git a/tests/e2e/specs/core/subagent-navigate-reveal-ui.spec.mjs b/tests/e2e/specs/core/subagent-navigate-reveal-ui.spec.mjs index f81ef4288c..8caec3b79d 100644 --- a/tests/e2e/specs/core/subagent-navigate-reveal-ui.spec.mjs +++ b/tests/e2e/specs/core/subagent-navigate-reveal-ui.spec.mjs @@ -141,10 +141,11 @@ const PARENT_EVENTS = [ makeLateEvent(PARENT_SESSION_ID, atOffset(20)), ]; -async function seedParentAtCursor(currentEventId) { +async function seedParentAtCursor(currentEventId, options = {}) { + const { chatPanelMaximized = false } = options; unwrap( await invokeE2E("seedChatEvents", PARENT_SESSION_ID, PARENT_EVENTS, { - chatPanelMaximized: false, + chatPanelMaximized, chatWidth: 460, currentEventId, stationMode: "agent-station", @@ -154,25 +155,35 @@ async function seedParentAtCursor(currentEventId) { } async function cellSnapshot() { - return execJS(` - const childId = ${JSON.stringify(CHILD_ID)}; - const cell = document.querySelector( - '[data-subagent-cell-thread-id="' + childId + '"]' - ); - const navBtn = document.querySelector( - '[data-tool-call-name="agent"] [data-testid="event-navigate"]' - ); - return { - cellPresent: !!cell, - cellFocused: cell - ? cell.getAttribute('data-subagent-cell-focused') === 'true' - : false, - navBtnPresent: !!navBtn, - monitorTaskInBody: (document.body.innerText || '').includes( - ${JSON.stringify(MONITOR_TASK)} - ), - }; - `); + const [dom, chatState] = await Promise.all([ + execJS(` + const childId = ${JSON.stringify(CHILD_ID)}; + const body = document.body.innerText || ''; + const cell = document.querySelector( + '[data-subagent-cell-thread-id="' + childId + '"]' + ); + const navBtn = document.querySelector( + '[data-tool-call-name="agent"] [data-testid="event-navigate"]' + ); + return { + cellPresent: !!cell, + cellFocused: cell + ? cell.getAttribute('data-subagent-cell-focused') === 'true' + : false, + navBtnPresent: !!navBtn, + monitorTaskInBody: body.includes( + ${JSON.stringify(MONITOR_TASK)} + ), + hasMonitoringHeader: /Monitoring the progress of \\d+ subagents|正在监控 \\d+ 个 Subagent 的进度/.test(body), + }; + `), + invokeE2E("inspectChatState"), + ]); + const state = chatState?.ok === true ? chatState.value : chatState; + return { + ...dom, + chatFocused: Boolean(state?.chatPanelMaximized), + }; } async function clickChatNavigate() { @@ -239,24 +250,26 @@ describe("Subagent navigate-arrow revives a retired monitor cell", () => { expect(snap.monitorTaskInBody).toBe(false); }); - it("clicking the arrow seeks the cursor back so the cell re-materialises AND focuses", async () => { + it("clicking the arrow reveals the monitor cell from a Messages-focused transcript", async () => { + await seedParentAtCursor(LATE_EVENT_ID, { chatPanelMaximized: true }); + + await waitForCell( + (snap) => snap.navBtnPresent && !snap.cellPresent, + "baseline should hide the retired monitor cell while the chat arrow remains visible" + ); + const click = await clickChatNavigate(); expect(click.clicked).toBe(true); - // navigateToEventAtom(delegateEventId) moves the cursor to +2min, inside - // the [2,8] clip window → cursor-filtered subagent reappears in the - // monitor strip, and focusedSubagentCellAtom rings it. await waitForCell( - (snap) => snap.cellPresent && snap.cellFocused, - "navigate click must revive the retired monitor cell and focus it" + (snap) => + snap.cellPresent && snap.cellFocused && snap.hasMonitoringHeader, + "navigate click must reveal the monitor snapshot" ); const snap = await cellSnapshot(); expect(snap.cellPresent).toBe(true); expect(snap.cellFocused).toBe(true); - // The monitor cell's own task label is now on screen (it was absent in the - // baseline), confirming the cell is genuinely rendered, not just attribute - // residue. - expect(snap.monitorTaskInBody).toBe(true); + expect(snap.hasMonitoringHeader).toBe(true); }); }); From cce0be24dc83458163919b1223336e26ed0ff252 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Mon, 29 Jun 2026 11:31:41 +0800 Subject: [PATCH 056/864] feat(session): hydrate explicit imported context snippets Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/core/session/context_import.rs | 39 ++++++++++++++++ .../src/core/session/persistence/messages.rs | 18 ++++++-- .../core/session/prompt/section_builders.rs | 46 +++++++++++++++++++ .../src/core/session/prompt/section_tests.rs | 37 ++++++++++++++- .../src/core/session/turn/processor/prompt.rs | 12 +++++ .../tools/impls/project/import_context.rs | 18 ++++++-- .../commands/session/debug/context_cache.rs | 2 + src/api/tauri/agent/contextCacheSnapshot.ts | 1 + .../core/context-import-card-ui.spec.mjs | 1 + 9 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/session/context_import.rs b/src-tauri/crates/agent-core/src/core/session/context_import.rs index 1a800554e1..e4e2618271 100644 --- a/src-tauri/crates/agent-core/src/core/session/context_import.rs +++ b/src-tauri/crates/agent-core/src/core/session/context_import.rs @@ -76,6 +76,7 @@ pub struct ContextSnapshotMeta { pub title: Option, pub token_estimate: i64, pub pinned: bool, + pub snippet: Option, pub created_at: String, } @@ -87,6 +88,26 @@ impl ContextSnapshotMeta { title: Option, token_estimate: i64, pinned: bool, + ) -> Self { + Self::new_with_snippet( + target_session_id, + source_kind, + source_id, + title, + token_estimate, + pinned, + None, + ) + } + + pub fn new_with_snippet( + target_session_id: impl Into, + source_kind: ContextSourceKind, + source_id: impl Into, + title: Option, + token_estimate: i64, + pinned: bool, + snippet: Option, ) -> Self { let source_id = source_id.into(); let namespace = ContextNamespace::new(source_kind.clone(), source_id.clone()).storage_key(); @@ -99,6 +120,9 @@ impl ContextSnapshotMeta { title, token_estimate: token_estimate.max(0), pinned, + snippet: snippet + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), created_at: chrono::Utc::now().to_rfc3339(), } } @@ -196,6 +220,21 @@ mod tests { assert_eq!(snap.namespace, "session:source"); assert_eq!(snap.token_estimate, 0); assert!(snap.pinned); + assert!(snap.snippet.is_none()); + } + + #[test] + fn snapshot_keeps_non_empty_snippet() { + let snap = ContextSnapshotMeta::new_with_snippet( + "target", + ContextSourceKind::Memory, + "memory-key", + None, + 10, + false, + Some(" useful context ".into()), + ); + assert_eq!(snap.snippet.as_deref(), Some("useful context")); } #[test] diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 06f940b70b..12d74e4135 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -663,6 +663,7 @@ pub fn ensure_context_metadata_schema(conn: &rusqlite::Connection) -> SqliteResu title TEXT, token_estimate INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, + snippet TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_context_snapshots_target @@ -697,6 +698,12 @@ pub fn ensure_context_metadata_schema(conn: &rusqlite::Connection) -> SqliteResu CREATE INDEX IF NOT EXISTS idx_session_embedding_state_work_item ON session_embedding_state(work_item_id);", )?; + if let Err(err) = conn.execute("ALTER TABLE context_snapshots ADD COLUMN snippet TEXT", []) { + let msg = err.to_string(); + if !msg.contains("duplicate column name") { + return Err(err); + } + } Ok(()) } @@ -707,8 +714,8 @@ pub fn save_context_snapshot(meta: &ContextSnapshotMeta) -> SqliteResult<()> { conn.execute( "INSERT INTO context_snapshots (snapshot_id, target_session_id, source_kind, source_id, namespace, - title, token_estimate, pinned, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + title, token_estimate, pinned, snippet, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(snapshot_id) DO UPDATE SET target_session_id = excluded.target_session_id, source_kind = excluded.source_kind, @@ -717,6 +724,7 @@ pub fn save_context_snapshot(meta: &ContextSnapshotMeta) -> SqliteResult<()> { title = excluded.title, token_estimate = excluded.token_estimate, pinned = excluded.pinned, + snippet = excluded.snippet, created_at = excluded.created_at", params![ meta.snapshot_id, @@ -727,6 +735,7 @@ pub fn save_context_snapshot(meta: &ContextSnapshotMeta) -> SqliteResult<()> { meta.title, meta.token_estimate, if meta.pinned { 1 } else { 0 }, + meta.snippet, meta.created_at, ], )?; @@ -739,7 +748,7 @@ pub fn load_context_snapshots(target_session_id: &str) -> SqliteResult SqliteResult )), } } + +// ============================================ +// Explicit imported context +// ============================================ + +pub(crate) fn build_imported_context_section( + snapshots: &[crate::session::context_import::ContextSnapshotMeta], +) -> Option { + let hydrated: Vec<_> = snapshots + .iter() + .filter_map(|snapshot| { + let snippet = snapshot.snippet.as_deref()?.trim(); + if snippet.is_empty() { + return None; + } + Some((snapshot, truncate_at_boundary(snippet, 2_000))) + }) + .collect(); + if hydrated.is_empty() { + return None; + } + + let mut lines = vec![ + "# Imported Context".to_string(), + "The following excerpts were explicitly imported with `import_context`. Treat them as source-scoped context, not hidden global memory. If you rely on one, mention the source when useful.".to_string(), + ]; + for (snapshot, snippet) in hydrated.into_iter().take(8) { + let title = snapshot + .title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&snapshot.source_id); + lines.push(format!( + "\n## {} (`{}`)\n- Source: `{}` `{}`\n- Namespace: `{}`\n- Snapshot: `{}`\n\n{}", + title, + snapshot.source_kind.as_str(), + snapshot.source_kind.as_str(), + snapshot.source_id, + snapshot.namespace, + snapshot.snapshot_id, + snippet + )); + } + Some(lines.join("\n")) +} + diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs b/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs index 4074980202..4a2d691de5 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs @@ -1,6 +1,6 @@ use super::section_builders::{ - build_agent_org_context_section, build_project_environment, build_rules_section, - cap_rule_content, format_user_profile, + build_agent_org_context_section, build_imported_context_section, build_project_environment, + build_rules_section, cap_rule_content, format_user_profile, }; use crate::coordination::agent_org_runs::{AgentOrgContextMember, AgentOrgRunContext}; use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, CreateTaskParams, TaskStatus}; @@ -256,3 +256,36 @@ fn project_env_lists_each_additional_dir() { assert!(out.contains("/tmp/pr-f-alpha"), "first path missing: {out}"); assert!(out.contains("/tmp/pr-f-beta"), "second path missing: {out}"); } + +#[test] +fn imported_context_section_hydrates_explicit_snippets() { + let snapshot = crate::session::context_import::ContextSnapshotMeta::new_with_snippet( + "target-session", + crate::session::context_import::ContextSourceKind::Session, + "source-session", + Some("Source Session".to_string()), + 12, + true, + Some("Important decision: imports must be explicit.".to_string()), + ); + + let section = build_imported_context_section(&[snapshot]).expect("section"); + assert!(section.contains("# Imported Context")); + assert!(section.contains("Source Session")); + assert!(section.contains("session:source-session")); + assert!(section.contains("Important decision: imports must be explicit.")); +} + +#[test] +fn imported_context_section_omits_metadata_only_snapshots() { + let snapshot = crate::session::context_import::ContextSnapshotMeta::new( + "target-session", + crate::session::context_import::ContextSourceKind::Session, + "source-session", + Some("Source Session".to_string()), + 12, + true, + ); + + assert!(build_imported_context_section(&[snapshot]).is_none()); +} diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs index 4fdb035841..75d503945d 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs @@ -193,6 +193,18 @@ impl UnifiedMessageProcessor { dynamic_sections.push(mem_section.to_string()); } + match crate::core::session::persistence::load_context_snapshots(session_id) { + Ok(snapshots) => { + if let Some(section) = crate::core::session::prompt::section_builders::build_imported_context_section(&snapshots) { + dynamic_sections.push(section); + } + } + Err(err) => warn!( + "[unified_processor] Failed to load imported context snapshots: {}", + err + ), + } + // Inject scratchpad directory context so the LLM has a concrete // per-session temp dir to write to instead of inventing /tmp paths. if self.runtime.native_harness_type.is_none() { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs index 849b01ebd5..46a2582862 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/project/import_context.rs @@ -1,8 +1,8 @@ //! Explicit context import tool. //! -//! This records source metadata only. Actual retrieval/snippet hydration is -//! intentionally a later step so cross-session context remains explicit and -//! auditable rather than silently injected. +//! This records source metadata plus an optional explicit snippet. Hydration is +//! always opt-in: imported context appears in the prompt only after the agent +//! records a source via this tool. use async_trait::async_trait; use serde_json::Value; @@ -82,6 +82,10 @@ impl Tool for ImportContextTool { "pinned": { "type": "boolean", "description": "Whether this import should be pinned in context selection." + }, + "snippet": { + "type": "string", + "description": "Optional explicit source excerpt to hydrate into the next prompt. Keep it short and relevant." } }, "required": ["source_kind", "source_id"] @@ -98,13 +102,15 @@ impl Tool for ImportContextTool { let title = optional_string(¶ms, "title"); let token_estimate = optional_int(¶ms, "token_estimate").unwrap_or(0) as i64; let pinned = optional_bool(¶ms, "pinned").unwrap_or(false); - let meta = ContextSnapshotMeta::new( + let snippet = optional_string(¶ms, "snippet"); + let meta = ContextSnapshotMeta::new_with_snippet( self.session_id.clone(), source_kind, source_id, title, token_estimate, pinned, + snippet, ); let snapshot_id = meta.snapshot_id.clone(); let namespace = meta.namespace.clone(); @@ -141,7 +147,8 @@ mod tests { "source_id": "source-session", "title": "Source Session", "token_estimate": 321, - "pinned": true + "pinned": true, + "snippet": "Important prior decision: keep imports explicit." }), &CallContext::new("call-import-context", "target-session"), ) @@ -154,5 +161,6 @@ mod tests { assert_eq!(snapshots[0].namespace, "session:source-session"); assert_eq!(snapshots[0].token_estimate, 321); assert!(snapshots[0].pinned); + assert_eq!(snapshots[0].snippet.as_deref(), Some("Important prior decision: keep imports explicit.")); } } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs index b1c6931368..3feffcbddf 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/context_cache.rs @@ -20,6 +20,7 @@ pub struct ContextSnapshotWire { pub title: Option, pub token_estimate: i64, pub pinned: bool, + pub snippet: Option, pub created_at: String, } @@ -34,6 +35,7 @@ impl From for ContextSnapshotWire { title: value.title, token_estimate: value.token_estimate, pinned: value.pinned, + snippet: value.snippet, created_at: value.created_at, } } diff --git a/src/api/tauri/agent/contextCacheSnapshot.ts b/src/api/tauri/agent/contextCacheSnapshot.ts index c4886c89be..2324c1ff1d 100644 --- a/src/api/tauri/agent/contextCacheSnapshot.ts +++ b/src/api/tauri/agent/contextCacheSnapshot.ts @@ -9,6 +9,7 @@ export interface ContextSnapshotWire { title?: string | null; tokenEstimate: number; pinned: boolean; + snippet?: string | null; createdAt: string; } diff --git a/tests/e2e/specs/core/context-import-card-ui.spec.mjs b/tests/e2e/specs/core/context-import-card-ui.spec.mjs index 73b2833335..7592768d58 100644 --- a/tests/e2e/specs/core/context-import-card-ui.spec.mjs +++ b/tests/e2e/specs/core/context-import-card-ui.spec.mjs @@ -145,6 +145,7 @@ function makeContextImportEvents(sessionId) { imported_context_count: 1, cache_read_tokens: 900, cache_write_tokens: 100, + snippet: "Hydrated snippet: cache context source decision.", namespace: "session:source-session-cache-context", snapshot_id: snapshotId, observation: `Imported context snapshot ${snapshotId} from session:source-session-cache-context into namespace session:source-session-cache-context`, From 6f472b7a6e1058f0d81a88e3242fb283a0fda13d Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 29 Jun 2026 12:40:32 +0800 Subject: [PATCH 057/864] fix: ignore inline session code references Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../ChatPanel/blocks/MessageReferenceCards.helpers.ts | 8 +++++++- .../blocks/__tests__/MessageReferenceCards.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts b/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts index 035f67be10..033a676121 100644 --- a/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts +++ b/src/engines/ChatPanel/blocks/MessageReferenceCards.helpers.ts @@ -41,6 +41,10 @@ function stripFencedCodeBlocks(content: string): string { .join("\n"); } +function stripInlineCodeSpans(content: string): string { + return content.replace(/(`+)[^\n]*?\1/g, ""); +} + function normalizeUrlCandidate(candidate: string): string | null { return normalizeHttpUrlCandidate(candidate, { stripTextBoundaries: true }); } @@ -152,7 +156,9 @@ export function extractMessageReferences( content: string, excludeUrls?: ReadonlySet ): MessageReferenceItem[] { - const searchableContent = stripFencedCodeBlocks(content); + const searchableContent = stripInlineCodeSpans( + stripFencedCodeBlocks(content) + ); const references: MessageReferenceItem[] = []; const seen = new Set(); diff --git a/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts b/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts index 9a615077c3..219121b663 100644 --- a/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts +++ b/src/engines/ChatPanel/blocks/__tests__/MessageReferenceCards.test.ts @@ -247,6 +247,14 @@ staged file lint stats expect(references.find((item) => item.kind === "session")).toBeUndefined(); }); + it("does not extract session cards from inline code examples", () => { + const references = extractMessageReferences( + "- `ChatHistory` 内容容器改成全宽\n- `审计-policy-啊permission-那些... [session:sdeagent-ee970f47-dfcb-4a78-97e5-fc56e3451821]`" + ); + + expect(references.find((item) => item.kind === "session")).toBeUndefined(); + }); + it("keeps serialized session pill labels instead of falling back to ids", () => { const id = "sdeagent-ee970f47-dfcb-4a78-97e5-fc56e3451821"; const references = extractMessageReferences( From 7d2cac9c7725657fb9d09b309d1b52e4506a6387 Mon Sep 17 00:00:00 2001 From: "Ash (Simon fork)" Date: Mon, 29 Jun 2026 13:32:32 +0800 Subject: [PATCH 058/864] fix(session): enforce compaction route consistency Pre-commit hook ran. Total eslint: 3, total circular: 0 --- .../src/core/model_context/compaction.rs | 12 +++++- .../model_context/tests/compaction_tests.rs | 12 ++++++ .../agent-core/src/core/providers/factory.rs | 37 +++++++++---------- .../agent-core/src/core/providers/reliable.rs | 10 ++++- .../core/providers/tests/reliable_tests.rs | 12 ++++++ 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/model_context/compaction.rs b/src-tauri/crates/agent-core/src/core/model_context/compaction.rs index 1b88664177..fa20bd5c40 100644 --- a/src-tauri/crates/agent-core/src/core/model_context/compaction.rs +++ b/src-tauri/crates/agent-core/src/core/model_context/compaction.rs @@ -43,7 +43,9 @@ pub struct CompactionConfig { #[serde(default = "default_keep_ratio")] pub keep_ratio: f32, - /// Model to use for summarization. If empty, uses the agent's main model. + /// Legacy summarization model override. Ignored by runtime compaction: summaries + /// must use the same resolved model/route as the foreground turn. Kept only + /// for config/backward-compatible deserialization. #[serde(default)] pub model: Option, @@ -345,7 +347,13 @@ impl ContextCompactor { Self::estimate_messages_tokens(recent), ); - let summary_model = config.model.as_deref().unwrap_or(model); + // Route-consistency invariant: compaction is part of the same logical + // turn as the foreground request, so it must use the exact resolved + // model/route that the runtime provider was built for. Do not honor + // `config.model` here; silently switching summarization to a cheaper + // model/provider breaks cost attribution, prompt-cache behavior, and + // route debugging. + let summary_model = model; let mut messages_to_summarize: Vec = older.to_vec(); let mut ptl_retries = 0; diff --git a/src-tauri/crates/agent-core/src/core/model_context/tests/compaction_tests.rs b/src-tauri/crates/agent-core/src/core/model_context/tests/compaction_tests.rs index b5a12effa7..9bea28580c 100644 --- a/src-tauri/crates/agent-core/src/core/model_context/tests/compaction_tests.rs +++ b/src-tauri/crates/agent-core/src/core/model_context/tests/compaction_tests.rs @@ -651,3 +651,15 @@ fn ptl_ignores_unrelated_errors() { "authentication failed" )); } + + +#[test] +fn compaction_summary_model_ignores_config_override_for_route_consistency() { + let mut config = default_config(); + config.model = Some("cheap/fallback-summary-model".to_string()); + + // Runtime compaction must not honor this override. The live provider was + // constructed for the foreground route; summary side-query gets the + // foreground model from ContextCompactor::compact and uses the same route. + assert_eq!(config.model.as_deref(), Some("cheap/fallback-summary-model")); +} diff --git a/src-tauri/crates/agent-core/src/core/providers/factory.rs b/src-tauri/crates/agent-core/src/core/providers/factory.rs index 25a5cbe050..dc659e048d 100644 --- a/src-tauri/crates/agent-core/src/core/providers/factory.rs +++ b/src-tauri/crates/agent-core/src/core/providers/factory.rs @@ -35,7 +35,13 @@ pub fn create_provider( create_provider_with_reliability(model, account_id, &ReliabilityConfig::default()) } -/// Create a provider wrapped in [`ReliableProvider`] for retry + fallback. +/// Create a provider wrapped in [`ReliableProvider`] for retry. +/// +/// Runtime session routing is intentionally strict: the selected account + +/// model pair is the only route for both foreground turns and compaction side +/// queries. Cross-model fallback would make route/cost/cache attribution lie. +/// Low-level tests can still construct `ReliableProvider::with_fallbacks` +/// directly; production session construction rejects configured fallbacks. /// /// The primary model is always tried first. If `reliability.fallback_models` /// is non-empty, those are tried in order after the primary is exhausted. @@ -146,27 +152,20 @@ pub fn create_provider_with_native_harness( let spec = resolve_spec_for_account(model, account_id)?; let resolved = resolve_credentials(spec, account_id)?; + if !reliability.fallback_models.is_empty() { + return Err(ProviderError::Other(format!( + "Cross-model fallback is disabled for runtime route consistency; selected route is {}/{}, configured fallback_models={:?}", + spec.name, model, reliability.fallback_models + ))); + } + let primary = build_provider_from_resolved(&resolved, spec, model, code_assist_session_id); let primary_name = format!("{}/{}", spec.name, model); - // Build fallback providers (best-effort — skip any that fail credential resolution) - let mut providers: Vec<(String, Box)> = vec![(primary_name, primary)]; - - for fallback_model in &reliability.fallback_models { - match create_fallback_provider(fallback_model, account_id) { - Ok((name, provider)) => { - tracing::info!("[reliable] Registered fallback provider: {}", name); - providers.push((name, provider)); - } - Err(err) => { - tracing::warn!("[reliable] Skipping fallback '{}': {}", fallback_model, err); - } - } - } - - // Wrap in ReliableProvider (even with a single provider, for retry behavior) - Ok(Box::new(ReliableProvider::with_fallbacks( - providers, + // Wrap in ReliableProvider (single resolved route; retry-only, no route fallback). + Ok(Box::new(ReliableProvider::single( + primary_name, + primary, reliability.max_retries, reliability.base_backoff_ms, ))) diff --git a/src-tauri/crates/agent-core/src/core/providers/reliable.rs b/src-tauri/crates/agent-core/src/core/providers/reliable.rs index 0adf2b76e4..8024d8a7fd 100644 --- a/src-tauri/crates/agent-core/src/core/providers/reliable.rs +++ b/src-tauri/crates/agent-core/src/core/providers/reliable.rs @@ -48,7 +48,10 @@ static RATE_LIMIT_COOLDOWNS: LazyLock>> = /// Providers are tried in order. For each provider, up to /// `max_retries + 1` attempts are made before moving to the next. pub struct ReliableProvider { - /// Ordered list of (name, provider). First is primary, rest are fallbacks. + /// Ordered list of (name, provider). First is primary. Runtime ORG2 + /// sessions intentionally reject cross-model fallbacks at construction + /// time, so this list is normally length 1; the vector shape remains for + /// tests and explicit low-level callers. providers: Vec<(String, Box)>, /// Maximum retry attempts per provider (0 = no retries, just one attempt). max_retries: u32, @@ -60,6 +63,11 @@ pub struct ReliableProvider { } impl ReliableProvider { + /// Return provider labels in the order this wrapper would try them. + pub fn provider_chain_names(&self) -> Vec { + self.providers.iter().map(|(name, _)| name.clone()).collect() + } + /// Create a new reliable provider wrapping a single provider. pub fn single( name: String, diff --git a/src-tauri/crates/agent-core/src/core/providers/tests/reliable_tests.rs b/src-tauri/crates/agent-core/src/core/providers/tests/reliable_tests.rs index 548d4d81fe..9889bf74b3 100644 --- a/src-tauri/crates/agent-core/src/core/providers/tests/reliable_tests.rs +++ b/src-tauri/crates/agent-core/src/core/providers/tests/reliable_tests.rs @@ -333,3 +333,15 @@ async fn auth_error_skips_retries() { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ProviderError::AuthError(_))); } + + +#[test] +fn provider_chain_names_reports_single_runtime_route() { + let reliable = ReliableProvider::single( + "zenmux/gpt-5.5".into(), + Box::new(FailNProvider::new(0, |_| ProviderError::Other("".into()))), + 3, + MIN_BASE_BACKOFF_MS, + ); + assert_eq!(reliable.provider_chain_names(), vec!["zenmux/gpt-5.5"]); +} From 4dcb7b82d4d857688166dfc88142cd773d9cd9fa Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:10:41 +0530 Subject: [PATCH 059/864] fix(agent): correct Copilot ACP launch flags and model passthrough The Copilot CLI command appended a `--stdio` flag that does not exist on `copilot` (v1.0.65); ACP is served over stdio by `--acp` alone, matching the kiro-cli and opencode adapters. It also ran the user-selected model through map_claude_model, which is Claude-specific, even though Copilot routes across vendors (gpt-*, claude-*, gemini-*). Drop the bogus flag, add --no-ask-user so non-interactive runs never block on the ask_user tool, and pass --model through unchanged. Tests updated to assert the corrected flag set plus resume and model passthrough. --- .../cli/session_runner/command.rs | 14 +++++++---- .../cli/tests/runner_command_tests.rs | 24 ++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/agent_sessions/cli/session_runner/command.rs b/src-tauri/src/agent_sessions/cli/session_runner/command.rs index 019977c645..e3601a27e9 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/command.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/command.rs @@ -177,15 +177,21 @@ pub(super) fn build_command( cmd } ModelType::Copilot => { - let mut cmd = vec!["copilot".into(), "--acp".into(), "--stdio".into()]; + // Copilot exposes ACP over stdio only (no `--stdio`/`--port` flag). + // `--allow-all-tools` + `--no-ask-user` keep the agent autonomous so + // it never blocks on a permission or ask_user prompt. + let mut cmd = vec!["copilot".into(), "--acp".into()]; cmd.push("--allow-all-tools".to_string()); + cmd.push("--no-ask-user".to_string()); if let Some(rid) = resume_id { cmd.push("--resume".into()); cmd.push(rid.into()); } + // Copilot routes across vendors (gpt-*, claude-*, gemini-*); pass the + // model id through unchanged instead of Claude-specific normalization. if let Some(m) = model { cmd.push("--model".into()); - cmd.push(map_claude_model(m)); + cmd.push(m.into()); } cmd } @@ -206,8 +212,8 @@ pub(super) fn build_command( /// /// Fallback mapping for when the proxy's resolved `model_name` is unavailable /// (e.g., fallback allocation path, pool sync failure, or local billing mode). -/// The hosted service normalizes "claude-sonnet-4.5" → "sonnet-4.5", but CLIs -/// (Claude Code, Copilot) expect full names like "claude-sonnet-4.5". +/// The hosted service normalizes "claude-sonnet-4.5" → "sonnet-4.5", but the +/// Claude Code CLI expects full names like "claude-sonnet-4.5". /// This re-adds the "claude-" prefix for Claude-family models. /// Non-Claude models (gpt-*, gemini-*, grok-*, raptor-*) pass through unchanged. /// diff --git a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs index 74379d361c..e315976be2 100644 --- a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs +++ b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs @@ -218,8 +218,30 @@ fn build_copilot_basic() { ); assert_eq!(cmd[0], "copilot"); assert!(cmd.contains(&"--acp".to_string())); - assert!(cmd.contains(&"--stdio".to_string())); assert!(cmd.contains(&"--allow-all-tools".to_string())); + assert!(cmd.contains(&"--no-ask-user".to_string())); + // Copilot serves ACP over stdio only; there is no `--stdio` flag. + assert!(!cmd.contains(&"--stdio".to_string())); +} + +#[test] +fn build_copilot_resume_and_model_passthrough() { + let cmd = build_command( + &ModelType::Copilot, + Some("gpt-5.4"), + "task", + Some("resume-123"), + None, + None, + None, + None, + &[], + ); + assert!(cmd.contains(&"--resume".to_string())); + assert!(cmd.contains(&"resume-123".to_string())); + assert!(cmd.contains(&"--model".to_string())); + // Model id is passed through unchanged (Copilot is multi-vendor). + assert!(cmd.contains(&"gpt-5.4".to_string())); } // ============================================ From baab6ee1f28fa3a1a36bee2f92acfbb8e08b6d4f Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:52:23 +0800 Subject: [PATCH 060/864] feat(projects): add Kanban work item view Expose status filtering and Kanban task interactions in project work item surfaces so list and board views share the same status model. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../ChatPanel/panels/ProjectPanelView.tsx | 223 +++++++++++++++--- .../components/KanbanColumn/index.scss | 8 + .../components/KanbanColumn/index.tsx | 5 +- src/features/KanbanBoard/index.scss | 4 + .../components/ProjectWorkItemsTabContent.tsx | 116 ++++++--- .../components/WorkItemsPageHeader/index.tsx | 67 +----- .../WorkItemsStatusFilterSelect.tsx | 87 +++++++ .../WorkItems/workItemsViewModel.ts | 19 ++ 8 files changed, 405 insertions(+), 124 deletions(-) create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect.tsx diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index 3d126ae66e..22cb6e556b 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -8,19 +8,37 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; -import { enrichedWorkItemToUI, projectApi } from "@src/api/http/project"; +import { + type WorkItemFrontmatter, + enrichedWorkItemToUI, + projectApi, +} from "@src/api/http/project"; +import Select from "@src/components/Select"; +import type { SelectOption } from "@src/components/Select"; import TabPill from "@src/components/TabPill"; import { ChatPanelHeaderBreadcrumb, usePublishChatPanelHeader, } from "@src/engines/ChatPanel/header"; +import KanbanBoard from "@src/features/KanbanBoard"; +import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard"; import { createLogger } from "@src/hooks/logger"; import { useProjectDataChanged } from "@src/hooks/project"; import WorkItemContentStack from "@src/modules/ProjectManager/WorkItems/components/WorkItemContentStack"; import { MultiSelectBar } from "@src/modules/ProjectManager/WorkItems/components/WorkItemsFooterBars"; import WorkItemsListContent from "@src/modules/ProjectManager/WorkItems/components/WorkItemsListContent"; +import WorkItemsStatusFilterSelect from "@src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect"; import { useMultiSelect } from "@src/modules/ProjectManager/WorkItems/hooks/useMultiSelect"; -import { groupWorkItemsForStatusFilter } from "@src/modules/ProjectManager/WorkItems/workItemsViewModel"; +import { + type StatusFilterType, + WORK_ITEMS_DEFAULT_STATUS, +} from "@src/modules/ProjectManager/WorkItems/types"; +import { + countWorkItemsByStatus, + filterWorkItemsByStatus, + groupWorkItemsForStatusFilter, + workItemsToKanbanTasks, +} from "@src/modules/ProjectManager/WorkItems/workItemsViewModel"; import { PROJECT_PROPERTY_CONCISE_FIELDS, ProjectContentEditor, @@ -45,6 +63,7 @@ import type { WorkItem } from "@src/types/core/workItem"; const logger = createLogger("ProjectPanelView"); type ProjectPanelTab = "overview" | "workItems"; +type ProjectPanelWorkItemsView = "List" | "Kanban"; interface ProjectPanelViewProps { selectedProject: ChatPanelSelectedProject; @@ -68,7 +87,10 @@ export const ProjectPanelView: React.FC = ({ selectedProject.project ); const [activePanelTab, setActivePanelTab] = - useState("overview"); + useState("workItems"); + const [activeWorkItemsView, setActiveWorkItemsView] = + useState("List"); + const [statusFilter, setStatusFilter] = useState("all"); const [projectDescription, setProjectDescription] = useState( sidebarProjectDescription ); @@ -235,6 +257,26 @@ export const ProjectPanelView: React.FC = ({ [getWorkItemShortId, loadProjectWorkItems, projectSlug] ); + const statusCounts = useMemo( + () => countWorkItemsByStatus(workItems), + [workItems] + ); + + const filteredWorkItems = useMemo( + () => filterWorkItemsByStatus(workItems, statusFilter), + [statusFilter, workItems] + ); + + const groupedWorkItems = useMemo( + () => groupWorkItemsForStatusFilter(filteredWorkItems, statusFilter), + [filteredWorkItems, statusFilter] + ); + + const kanbanTasks = useMemo( + () => workItemsToKanbanTasks(filteredWorkItems), + [filteredWorkItems] + ); + const { selectedIds, bulkDeleting, @@ -243,7 +285,7 @@ export const ProjectPanelView: React.FC = ({ handleUnselectAll, handleBulkDelete, } = useMultiSelect({ - filteredWorkItems: workItems, + filteredWorkItems, onDelete: handleDeleteWorkItem, projectSlug, getShortId: getWorkItemShortId, @@ -326,6 +368,22 @@ export const ProjectPanelView: React.FC = ({ : t("projects:workItems.label"), })); + const viewOptions = useMemo( + () => [ + { + value: "List", + label: t("projects:workItems.tabs.list"), + triggerLabel: t("projects:workItems.tabs.list"), + }, + { + value: "Kanban", + label: t("projects:workItems.tabs.kanban"), + triggerLabel: t("projects:workItems.tabs.kanban"), + }, + ], + [t] + ); + const handleSelectWorkItem = useCallback( (workItemId: string) => { const workItem = workItems.find((item) => item.session_id === workItemId); @@ -353,6 +411,71 @@ export const ProjectPanelView: React.FC = ({ ] ); + const handleSelectWorkItemFromKanban = useCallback( + (task: KanbanTask) => { + handleSelectWorkItem(task.id); + }, + [handleSelectWorkItem] + ); + + const handleUpdateWorkItem = useCallback( + async (workItemId: string, updates: Partial) => { + if (!projectSlug) return; + const shortId = getWorkItemShortId(workItemId); + if (!shortId) return; + + const payload = {} as Parameters< + typeof projectApi.updateWorkItemPartial + >[2]; + if (updates.name !== undefined) payload.title = updates.name; + if (updates.spec !== undefined) payload.body = updates.spec; + if (updates.workItemStatus !== undefined) { + payload.status = updates.workItemStatus; + } + if (updates.priority !== undefined) payload.priority = updates.priority; + if (Object.keys(payload).length === 0) return; + + const updated = await projectApi.updateWorkItemPartial( + projectSlug, + shortId, + payload + ); + const updatedItem = enrichedWorkItemToUI(updated); + setWorkItems((currentItems) => + currentItems.map((item) => + item.session_id === workItemId ? updatedItem : item + ) + ); + }, + [getWorkItemShortId, projectSlug] + ); + + const handleAddKanbanTask = useCallback( + async (status: TaskStatus) => { + if (!projectSlug) return; + const shortId = await projectApi.allocateWorkItemId(projectSlug); + const now = new Date().toISOString(); + const frontmatter: WorkItemFrontmatter = { + id: shortId, + short_id: shortId, + title: t("projects:workItems.newWorkItemName", { + defaultValue: "New Work Item", + }), + project: selectedProject.project.id, + status: status || WORK_ITEMS_DEFAULT_STATUS, + priority: "none", + labels: [], + created_at: now, + updated_at: now, + starred: false, + todos: [], + }; + await projectApi.writeWorkItem(projectSlug, shortId, frontmatter, ""); + await loadProjectWorkItems(); + }, + [loadProjectWorkItems, projectSlug, selectedProject.project.id, t] + ); + const handleDescriptionChange = useCallback((markdown: string) => { setProjectDescription(markdown); }, []); @@ -384,11 +507,6 @@ export const ProjectPanelView: React.FC = ({ ); - const groupedWorkItems = useMemo( - () => groupWorkItemsForStatusFilter(workItems, "all"), - [workItems] - ); - const workItemsContent = workItemsLoading ? ( = ({ }} /> ) : ( - +
+ {activeWorkItemsView === "Kanban" ? ( + { + void handleUpdateWorkItem(taskId, { workItemStatus: newStatus }); + }} + onTaskClick={handleSelectWorkItemFromKanban} + onAddTask={(status: TaskStatus) => { + void handleAddKanbanTask(status); + }} + showAddButton={true} + className="kanban-board--linear" + /> + ) : ( + + )} +
); const descriptionContent = ( @@ -434,7 +569,7 @@ export const ProjectPanelView: React.FC = ({ className="flex min-h-0 flex-1 flex-col" data-testid="chat-panel-project-section" > -
+
= ({ fillWidth={false} size="chatPanel" /> + {activePanelTab === "workItems" ? ( +
+ { - if (Array.isArray(value)) return; - onStatusFilterChange(value.toString()); - }} - options={statusFilterOptions} - size="small" - variant="ghost" - radius="lg" - dropdownWidthMode="match" - dropdownMinWidth={172} - dropdownAlign="right" - className="w-auto" + onStatusFilterChange(value)} + statusCounts={statusCounts} /> )} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect.tsx new file mode 100644 index 0000000000..1299b5f427 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect.tsx @@ -0,0 +1,87 @@ +import { List } from "lucide-react"; +import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { DROPDOWN_ITEM } from "@src/components/Dropdown/tokens"; +import Select from "@src/components/Select"; +import type { SelectOption } from "@src/components/Select"; +import { WORK_ITEM_STATUS_OPTIONS } from "@src/modules/ProjectManager/config/manage"; + +import { FILTER_TO_STATUS, STATUS_FILTER_KEYS } from "../types"; +import type { StatusFilterType } from "../types"; + +type StatusCountMap = Record & Record; + +interface WorkItemsStatusFilterSelectProps { + value: StatusFilterType; + onChange: (value: StatusFilterType) => void; + statusCounts: StatusCountMap; +} + +const WorkItemsStatusFilterSelect: React.FC< + WorkItemsStatusFilterSelectProps +> = ({ value, onChange, statusCounts }) => { + const { t } = useTranslation("projects"); + + const getStatusFilterIcon = useCallback((key: StatusFilterType) => { + if (key === "all") { + return ; + } + + const status = FILTER_TO_STATUS[key]; + const option = status + ? WORK_ITEM_STATUS_OPTIONS.find((item) => item.value === status) + : undefined; + if (!option?.icon) { + return ; + } + + return ( + + {option.icon} + + ); + }, []); + + const statusFilterOptions = useMemo( + () => + STATUS_FILTER_KEYS.map((key) => { + const count = statusCounts[key] ?? 0; + const label = t(`workItems.statusFilters.${key}`); + return { + value: key, + label: ( + + + {getStatusFilterIcon(key)} + + {label} + {count} + + ), + triggerLabel: label, + }; + }), + [getStatusFilterIcon, statusCounts, t] + ); + + return ( + + + + + + + {connectionsLoading ? ( +
+ + {t("projects:githubIssuesImport.loadingConnections")} +
+ ) : connectionOptions.length > 0 ? ( + -
- ) : ( - - - - )} - - {source === SUPABASE_SOURCE && ( - - - - )} - - )} - - {source === SUPABASE_SOURCE && ( - <> - - - - - - - - {mode === CREATE_MODE ? ( -
- - - - {verificationStatus === "ok" ? ( - - {t("navigation:collaboration.supabaseSetupVerified")} - - ) : null} -
- ) : null} -
- - {mode === CREATE_MODE ? ( - -
- -
-
- ) : null} - - )} + ) : ( + + + + )} - {source === SUPABASE_SOURCE && mode === JOIN_MODE && ( - + {source === SUPABASE_SOURCE && ( - - - )} + )} + + )} - {latestInviteLink && ( - + {source === SUPABASE_SOURCE && ( + <> + -
- + + + + + + {mode === CREATE_MODE ? ( +
+ + + {verificationStatus === "ok" ? ( + + {t("navigation:collaboration.supabaseSetupVerified")} + + ) : null}
- + ) : null} - )} - {error &&

{error}

} -
+ {mode === CREATE_MODE ? ( + +
+ +
+
+ ) : null} + + )} + + {source === SUPABASE_SOURCE && mode === JOIN_MODE && ( + + + + + + )} + + {latestInviteLink && ( + + +
+ + +
+
+
+ )} + + {error &&

{error}

}
- } - footer={ - <> - - - - } - /> +
+ +
+ + +
+
); }; From 68c78b61248d7f97bcfb22dc57026b83f3fffc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=88=E7=AC=91=E9=A3=8E=E7=94=9F=E9=97=B4?= Date: Mon, 29 Jun 2026 16:26:38 +0800 Subject: [PATCH 064/864] fix(sidebar): unify grouped load more row Unify duplicate backend Load more rows in grouped session sidebar views and keep the unified row clickable when only some backend categories are still ready to load. --- .../__tests__/menuSectionBuilders.test.ts | 74 ++++++++- .../__tests__/paginationHelpers.test.ts | 148 +++++++++++++++++- .../connectors/useSessionMenuItems/index.tsx | 17 +- .../useSessionMenuItems/paginationHelpers.tsx | 79 +++++++++- .../useWorkstationSidebarHandlers.ts | 18 ++- 5 files changed, 319 insertions(+), 17 deletions(-) diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts index 31670d06e8..752222ea0c 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts @@ -6,14 +6,20 @@ import type { Session, SessionListCategory } from "@src/store/session"; import { buildByAgentMenuItems, buildByTimeMenuItems, + buildByWorkspaceMenuItems, } from "../menuSectionBuilders"; -function makeSession(sessionId: string, updatedAt: string): Session { +function makeSession( + sessionId: string, + updatedAt: string, + repoPath?: string +): Session { return { session_id: sessionId, status: "completed", created_at: updatedAt, updated_at: updatedAt, + repoPath, }; } @@ -47,8 +53,8 @@ function appendGroupSessions( function appendTrailingLoadMoreItems(items: NavigationMenuItem[]): void { items.push({ - id: "load-more-cursor_ide", - key: "load-more-cursor_ide", + id: "load-more-unified", + key: "load-more-unified", label: "Load more", }); } @@ -70,6 +76,43 @@ function getLoadMoreItemIds(items: readonly NavigationMenuItem[]): string[] { } describe("session menu section builders", () => { + it("appends one unified backend load-more row in the by-time view", () => { + const today = new Date().toISOString(); + const items = buildByTimeMenuItems({ + unpinnedSessions: [makeSession("cursoride-1", today)], + dateGroupLabels: { + today: "Today", + yesterday: "Yesterday", + thisWeek: "This Week", + older: "Older", + }, + appendPinnedSessions, + appendGroupSessions, + appendTrailingLoadMoreItems, + }); + + expect(getLoadMoreItemIds(items)).toEqual(["load-more-unified"]); + }); + + it("appends one unified backend load-more row in the by-workspace view", () => { + const items = buildByWorkspaceMenuItems({ + unpinnedSessions: [ + makeSession( + "cursoride-1", + "2026-06-09T00:00:00.000Z", + "/workspace/orgii" + ), + ], + repoPathToName: new Map([["/workspace/orgii", "ORGII"]]), + noWorkspaceLabel: "No Workspace", + appendPinnedSessions, + appendGroupSessions, + appendTrailingLoadMoreItems, + }); + + expect(getLoadMoreItemIds(items)).toEqual(["load-more-unified"]); + }); + it("does not append a backend load-more row when a time group has local hidden sessions", () => { // Use the current day so the sessions always land in the "today" group // regardless of when the suite runs (a fixed past date would drift into @@ -95,6 +138,29 @@ describe("session menu section builders", () => { expect(getLoadMoreItemIds(items)).toEqual(["load-more-group-time:today"]); }); + it("does not append a backend load-more row when a workspace group has local hidden sessions", () => { + const sessions = Array.from({ length: 11 }, (_, index) => + makeSession( + `cursoride-${index}`, + "2026-06-09T00:00:00.000Z", + "/workspace/orgii" + ) + ); + + const items = buildByWorkspaceMenuItems({ + unpinnedSessions: sessions, + repoPathToName: new Map([["/workspace/orgii", "ORGII"]]), + noWorkspaceLabel: "No Workspace", + appendPinnedSessions, + appendGroupSessions, + appendTrailingLoadMoreItems, + }); + + expect(getLoadMoreItemIds(items)).toEqual([ + "load-more-group-workspace:/workspace/orgii", + ]); + }); + it("does not append a backend load-more row below an agent group with local hidden sessions", () => { const sessions = Array.from({ length: 11 }, (_, index) => makeSession(`cursoride-${index}`, "2026-06-09T00:00:00.000Z") @@ -112,7 +178,7 @@ describe("session menu section builders", () => { ]); }); - it("appends the backend load-more row after local hidden sessions are expanded", () => { + it("appends the per-category backend load-more row in the by-agent view", () => { const sessions = Array.from({ length: 10 }, (_, index) => makeSession(`cursoride-${index}`, "2026-06-09T00:00:00.000Z") ); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts index 91b28f5aae..d44f5e24ab 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts @@ -1,9 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; -import type { Session } from "@src/store/session"; +import { + SESSION_LIST_CATEGORIES, + type Session, + type SessionListCategory, + type SessionPaginationMap, +} from "@src/store/session"; -import { appendSessionGroup } from "../paginationHelpers"; +import { + UNIFIED_LOAD_MORE_ID, + appendSessionGroup, + getUnifiedLoadMoreState, + isUnifiedLoadMoreId, + loadUnifiedReadyCategories, + unifiedLoadMoreRow, +} from "../paginationHelpers"; function makeSession(sessionId: string): Session { return { @@ -22,6 +34,21 @@ function buildSessionRow(session: Session): NavigationMenuItem { }; } +function makePagination( + overrides: Partial = {} +): SessionPaginationMap { + return Object.fromEntries( + SESSION_LIST_CATEGORIES.map((category) => [ + category, + overrides[category] ?? { + loaded: 0, + hasMore: false, + loading: false, + }, + ]) + ) as SessionPaginationMap; +} + describe("appendSessionGroup", () => { it("returns false when all sessions are visible", () => { const items: NavigationMenuItem[] = []; @@ -56,3 +83,118 @@ describe("appendSessionGroup", () => { ]); }); }); + +describe("unified backend load-more helpers", () => { + it("returns all ready categories while exposing one visible unified state", () => { + const firstCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const secondCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; + const state = getUnifiedLoadMoreState( + makePagination({ + [firstCategory]: { loaded: 10, hasMore: true, loading: false }, + [secondCategory]: { loaded: 10, hasMore: true, loading: false }, + }) + ); + + expect(state).toEqual({ + visible: true, + loading: false, + disabled: false, + readyCategories: [firstCategory, secondCategory], + }); + }); + + it("excludes loading categories from ready categories and marks unified state loading", () => { + const loadingCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const readyCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; + const state = getUnifiedLoadMoreState( + makePagination({ + [loadingCategory]: { loaded: 10, hasMore: true, loading: true }, + [readyCategory]: { loaded: 10, hasMore: true, loading: false }, + }) + ); + + expect(state.visible).toBe(true); + expect(state.loading).toBe(true); + expect(state.disabled).toBe(false); + expect(state.readyCategories).toEqual([readyCategory]); + }); + + it("keeps the unified row enabled while loading when categories are ready", () => { + const readyCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const state = getUnifiedLoadMoreState( + makePagination({ + [readyCategory]: { loaded: 10, hasMore: true, loading: false }, + [SESSION_LIST_CATEGORIES[1] as SessionListCategory]: { + loaded: 10, + hasMore: true, + loading: true, + }, + }) + ); + const row = unifiedLoadMoreRow(state, "Loading"); + + expect(row.id).toBe(UNIFIED_LOAD_MORE_ID); + expect(row.key).toBe(UNIFIED_LOAD_MORE_ID); + expect(row.label).toBe("Loading"); + expect(row.disabled).toBe(false); + expect(row.trailingElement).toBeDefined(); + }); + + it("disables the unified row when every remaining category is already loading", () => { + const loadingCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const state = getUnifiedLoadMoreState( + makePagination({ + [loadingCategory]: { loaded: 10, hasMore: true, loading: true }, + }) + ); + const row = unifiedLoadMoreRow(state, "Loading"); + + expect(state.disabled).toBe(true); + expect(row.disabled).toBe(true); + }); + + it("only matches the unified backend load-more id", () => { + expect(isUnifiedLoadMoreId(UNIFIED_LOAD_MORE_ID)).toBe(true); + expect(isUnifiedLoadMoreId("load-more-cursor_ide")).toBe(false); + }); + + it("loads every ready category and skips loading categories", async () => { + const loadingCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const firstReadyCategory = + SESSION_LIST_CATEGORIES[1] as SessionListCategory; + const secondReadyCategory = + SESSION_LIST_CATEGORIES[2] as SessionListCategory; + const loadCategory = vi.fn(() => Promise.resolve()); + + const result = loadUnifiedReadyCategories({ + pagination: makePagination({ + [loadingCategory]: { loaded: 10, hasMore: true, loading: true }, + [firstReadyCategory]: { loaded: 10, hasMore: true, loading: false }, + [secondReadyCategory]: { loaded: 10, hasMore: true, loading: false }, + }), + loadCategory, + }); + + expect(result).toBeInstanceOf(Promise); + await result; + expect(loadCategory).toHaveBeenCalledTimes(2); + expect(loadCategory).toHaveBeenNthCalledWith(1, firstReadyCategory); + expect(loadCategory).toHaveBeenNthCalledWith(2, secondReadyCategory); + }); + + it("does not load categories when the unified row is disabled", () => { + const readyCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const loadCategory = vi.fn(() => Promise.resolve()); + + const result = loadUnifiedReadyCategories({ + disabled: true, + pagination: makePagination({ + [readyCategory]: { loaded: 10, hasMore: true, loading: false }, + }), + loadCategory, + }); + + expect(result).toBeNull(); + expect(loadCategory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx index fb3514716e..0fd78de8bd 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx @@ -32,11 +32,12 @@ import { buildByWorkspaceMenuItems, } from "./menuSectionBuilders"; import { - LOAD_MORE_CATEGORIES, appendSessionGroup, getLoadMoreGroupId, + getUnifiedLoadMoreState, isLoadMoreId, loadMoreRow, + unifiedLoadMoreRow, } from "./paginationHelpers"; import type { UseSessionMenuItemsParams, @@ -176,13 +177,13 @@ export function useSessionMenuItems({ const trailingLoadMoreItems = useMemo(() => { if (isFiltering) return []; - const rows: NavigationMenuItem[] = []; - for (const category of LOAD_MORE_CATEGORIES) { - const row = loadMoreRowFor(category); - if (row) rows.push(row); - } - return rows; - }, [isFiltering, loadMoreRowFor]); + const state = getUnifiedLoadMoreState(pagination); + if (!state.visible) return []; + const label = state.loading + ? tCommon("sessions:chat.loading") + : tCommon("common:actions.loadMore"); + return [unifiedLoadMoreRow(state, label)]; + }, [isFiltering, pagination, tCommon]); const appendTrailingLoadMoreItems = useCallback( (items: NavigationMenuItem[]) => { diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx index e53ce2a075..985f88c535 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx @@ -2,7 +2,11 @@ import { MoreHorizontal } from "lucide-react"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import { SESSION_LIST_CATEGORIES } from "@src/store/session"; -import type { Session, SessionListCategory } from "@src/store/session"; +import type { + Session, + SessionListCategory, + SessionPaginationMap, +} from "@src/store/session"; import { LOAD_MORE_GROUP_PREFIX, LOAD_MORE_PREFIX } from "../types"; import { DEFAULT_GROUP_VISIBLE_COUNT } from "./dateGroupingHelpers"; @@ -11,6 +15,20 @@ import type { BuildSessionRow } from "./types"; export const LOAD_MORE_CATEGORIES: readonly SessionListCategory[] = SESSION_LIST_CATEGORIES; +export const UNIFIED_LOAD_MORE_ID = "load-more-unified"; + +interface UnifiedLoadMoreState { + visible: boolean; + loading: boolean; + disabled: boolean; + readyCategories: SessionListCategory[]; +} + +interface LoadUnifiedReadyCategoriesParams { + disabled?: boolean; + pagination: SessionPaginationMap; + loadCategory: (category: SessionListCategory) => Promise; +} export function loadMoreRow( category: SessionListCategory, @@ -46,17 +64,76 @@ export function groupLoadMoreRow( }; } +export function unifiedLoadMoreRow( + state: UnifiedLoadMoreState, + label: string +): NavigationMenuItem { + return { + id: UNIFIED_LOAD_MORE_ID, + key: UNIFIED_LOAD_MORE_ID, + label, + icon: MoreHorizontal, + iconName: "more-horizontal", + trailingElement: state.loading ? renderBreathingStatusDot() : undefined, + visualTone: "secondary", + disabled: state.disabled, + }; +} + export function isLoadMoreId(id: string): SessionListCategory | null { if (!id.startsWith(LOAD_MORE_PREFIX)) return null; const category = id.slice(LOAD_MORE_PREFIX.length) as SessionListCategory; return SESSION_LIST_CATEGORIES.includes(category) ? category : null; } +export function isUnifiedLoadMoreId(id: string): boolean { + return id === UNIFIED_LOAD_MORE_ID; +} + export function getLoadMoreGroupId(id: string): string | null { if (!id.startsWith(LOAD_MORE_GROUP_PREFIX)) return null; return id.slice(LOAD_MORE_GROUP_PREFIX.length) || null; } +export function getUnifiedLoadMoreState( + pagination: SessionPaginationMap +): UnifiedLoadMoreState { + let visible = false; + let loading = false; + const readyCategories: SessionListCategory[] = []; + + for (const category of LOAD_MORE_CATEGORIES) { + const state = pagination[category]; + if (state.loading) { + visible = true; + loading = true; + continue; + } + if (state.hasMore) { + visible = true; + readyCategories.push(category); + } + } + + return { + visible, + loading, + disabled: readyCategories.length === 0, + readyCategories, + }; +} + +export function loadUnifiedReadyCategories({ + disabled, + pagination, + loadCategory, +}: LoadUnifiedReadyCategoriesParams): Promise | null { + if (disabled) return null; + const { readyCategories } = getUnifiedLoadMoreState(pagination); + if (readyCategories.length === 0) return null; + return Promise.all(readyCategories.map((category) => loadCategory(category))); +} + interface AppendSessionGroupParams { items: NavigationMenuItem[]; groupId: string; diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts index 80262aaecc..0e0c3b5395 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts @@ -1,6 +1,6 @@ import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; -import { useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { type Dispatch, type SetStateAction, useCallback } from "react"; import { deleteSession } from "@src/api/tauri/agent"; @@ -21,6 +21,7 @@ import { type SessionListCategory, loadMoreCategory, removeSession, + sessionPaginationAtom, upsertSession, } from "@src/store/session"; import { @@ -35,6 +36,10 @@ import { NEW_SESSION_MENU_ITEM_ID, getDraftIdFromMenuItemId, } from "./sidebarConnectorUtils"; +import { + isUnifiedLoadMoreId, + loadUnifiedReadyCategories, +} from "./useSessionMenuItems/paginationHelpers"; const log = createLogger("WorkstationSidebar"); @@ -86,6 +91,7 @@ export function useWorkstationSidebarHandlers({ const setBenchmarkActiveBatchTaskId = useSetAtom( benchmarkActiveBatchTaskIdAtom ); + const pagination = useAtomValue(sessionPaginationAtom); const handleDeleteSession = useCallback( async (sessionId: string) => { try { @@ -156,6 +162,15 @@ export function useWorkstationSidebarHandlers({ return; } + if (isUnifiedLoadMoreId(item.id)) { + void loadUnifiedReadyCategories({ + disabled: item.disabled, + pagination, + loadCategory: loadMoreCategoryAction, + }); + return; + } + const loadMoreGroupId = getLoadMoreGroupId(item.id); if (loadMoreGroupId) { setGroupVisibleCounts((previousCounts) => { @@ -209,6 +224,7 @@ export function useWorkstationSidebarHandlers({ [ getLoadMoreGroupId, isLoadMoreId, + pagination, sessionMap, openSession, goToNewSession, From 6d4adcb99bbd15b87e19357f2bba0a41151492f5 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:07:12 +0800 Subject: [PATCH 065/864] fix(projects): defer GitHub repo validation feedback Avoid showing the invalid GitHub repo error while users are still typing their first repo path characters. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../GitHubIssuesImportWizard/index.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/modules/ProjectManager/Projects/components/GitHubIssuesImportWizard/index.tsx b/src/modules/ProjectManager/Projects/components/GitHubIssuesImportWizard/index.tsx index bf933683b0..164c547219 100644 --- a/src/modules/ProjectManager/Projects/components/GitHubIssuesImportWizard/index.tsx +++ b/src/modules/ProjectManager/Projects/components/GitHubIssuesImportWizard/index.tsx @@ -64,6 +64,8 @@ const GitHubIssuesImportWizard: React.FC = ({ const [connections, setConnections] = useState([]); const [connectionsLoading, setConnectionsLoading] = useState(true); const [saving, setSaving] = useState(false); + const [repoInputTouched, setRepoInputTouched] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); useEffect(() => { let cancelled = false; @@ -92,10 +94,13 @@ const GitHubIssuesImportWizard: React.FC = ({ }, []); const parsedRepo = useMemo(() => parseGitHubRepo(repoInput), [repoInput]); - const repoError = - repoInput.trim() && !parsedRepo - ? t("projects:githubIssuesImport.errors.invalidRepo") - : undefined; + const shouldShowRepoError = + Boolean(repoInput.trim()) && + !parsedRepo && + (repoInputTouched || submitAttempted); + const repoError = shouldShowRepoError + ? t("projects:githubIssuesImport.errors.invalidRepo") + : undefined; const connectionOptions = useMemo( () => connections.map((connection) => ({ @@ -110,6 +115,7 @@ const GitHubIssuesImportWizard: React.FC = ({ ); const handleSubmit = useCallback(async () => { + setSubmitAttempted(true); if (!canSubmit || !parsedRepo) return; setSaving(true); @@ -205,7 +211,11 @@ const GitHubIssuesImportWizard: React.FC = ({ > { + setRepoInput(value); + if (parsedRepo) setRepoInputTouched(false); + }} + onBlur={() => setRepoInputTouched(true)} placeholder={t( "projects:githubIssuesImport.placeholders.repo" )} From 79fe7eda35e12d1135e89315f13b96a885936777 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:50:33 +0800 Subject: [PATCH 066/864] ci(release): add linux build for 1.1.7 Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .github/workflows/release.yaml | 149 ++++++++++++++++++++++++++++++++- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 315bda262a..e4bee0113b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,4 +1,4 @@ -# Build, sign, notarize, and release ORGII for macOS (Apple Silicon) and Windows (x64). +# Build, sign, notarize, and release ORGII for macOS (Apple Silicon), Windows (x64), and Linux (x64). # # Trigger: push a tag matching v* (e.g. v1.1.0, v1.1.1). # Release tags must be valid three-part SemVer because Tauri app/updater @@ -414,3 +414,150 @@ jobs: ${{ steps.artifacts.outputs.latest_nsis }} ${{ steps.artifacts.outputs.nsis_sig }} latest.json + + # ── Linux x64 build ─────────────────────────────────────────────────── + build-linux: + name: Build & Release (Linux x64) + runs-on: ubuntu-22.04 + needs: build-windows + steps: + # ── Checkout ──────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # ── Stamp version from git tag ─────────────────────────────── + - name: Set version from tag + run: | + FULL_VERSION="${GITHUB_REF_NAME#v}" + if ! [[ "$FULL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be valid three-part SemVer: $GITHUB_REF_NAME" >&2 + exit 1 + fi + SEMVER="$FULL_VERSION" + echo "VERSION=$FULL_VERSION" >> "$GITHUB_ENV" + echo "SEMVER=$SEMVER" >> "$GITHUB_ENV" + sed -i "s/\"version\": \".*\"/\"version\": \"$SEMVER\"/" src-tauri/tauri.conf.json + sed -i "s/\"version\": \".*\"/\"version\": \"$FULL_VERSION\"/" package.json + + # ── System dependencies ────────────────────────────────────── + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + # ── Node.js + pnpm ────────────────────────────────────────── + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "pnpm" + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + # ── Rust ──────────────────────────────────────────────────── + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + shared-key: "release-linux-x64" + + # ── Build Tauri app ────────────────────────────────────────── + - name: Build ORGII + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ORGII_DIAGNOSTICS_TOKEN: ${{ secrets.ORGII_DIAGNOSTICS_TOKEN }} + ORGII_APP_VERSION: ${{ env.SEMVER }} + run: pnpm tauri build --target x86_64-unknown-linux-gnu + + # ── Gather artifacts ───────────────────────────────────────── + - name: Gather release artifacts + id: artifacts + run: | + BUNDLE_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" + + DEB=$(find "$BUNDLE_DIR/deb" -name "*.deb" | head -1) + APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" | head -1) + UPDATER_TAR=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz" ! -name "*.sig" | head -1) + UPDATER_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz.sig" | head -1) + + for artifact in "$DEB" "$APPIMAGE" "$UPDATER_TAR" "$UPDATER_SIG"; do + if [ ! -f "$artifact" ]; then + echo "Missing Linux release artifact: $artifact" >&2 + exit 1 + fi + done + + LATEST_DEB="ORG2-latest-linux-x64.deb" + LATEST_APPIMAGE="ORG2-latest-linux-x64.AppImage" + cp "$DEB" "$LATEST_DEB" + cp "$APPIMAGE" "$LATEST_APPIMAGE" + + echo "deb=$DEB" >> "$GITHUB_OUTPUT" + echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT" + echo "updater_tar=$UPDATER_TAR" >> "$GITHUB_OUTPUT" + echo "updater_sig=$UPDATER_SIG" >> "$GITHUB_OUTPUT" + echo "latest_deb=$LATEST_DEB" >> "$GITHUB_OUTPUT" + echo "latest_appimage=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" + echo "updater_tar_name=$(basename "$UPDATER_TAR")" >> "$GITHUB_OUTPUT" + + echo "=== Linux artifacts ===" + echo "DEB: $DEB" + echo "AppImage: $APPIMAGE" + echo "Updater tar: $UPDATER_TAR" + echo "Updater sig: $UPDATER_SIG" + + # ── Merge Linux updater entry into latest.json ─────────────── + - name: Generate latest.json for Linux updater + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + gh release download "$TAG" --pattern latest.json --output latest.json + + SIGNATURE=$(cat "${{ steps.artifacts.outputs.updater_sig }}") + TAR_NAME="${{ steps.artifacts.outputs.updater_tar_name }}" + DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${TAR_NAME}" + + jq \ + --arg signature "$SIGNATURE" \ + --arg url "$DOWNLOAD_URL" \ + '.platforms["linux-x86_64"] = { signature: $signature, url: $url }' \ + latest.json > latest-with-linux.json + + mv latest-with-linux.json latest.json + + echo "=== latest.json with Linux updater ===" + cat latest.json + + # ── Upload to existing GitHub Release ─────────────────────── + - name: Upload Linux artifacts to release + uses: softprops/action-gh-release@v3 + with: + draft: false + overwrite_files: true + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} + files: | + ${{ steps.artifacts.outputs.deb }} + ${{ steps.artifacts.outputs.latest_deb }} + ${{ steps.artifacts.outputs.appimage }} + ${{ steps.artifacts.outputs.latest_appimage }} + ${{ steps.artifacts.outputs.updater_tar }} + ${{ steps.artifacts.outputs.updater_sig }} + latest.json diff --git a/package.json b/package.json index fba52262c5..9d9747b313 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orgii", - "version": "1.1.6", + "version": "1.1.7", "description": "Self-evolving agentic development framework — ORGII desktop app", "main": "src/index.tsx", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 880f3f39bd..7e6df947a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ORG2", - "version": "1.1.6", + "version": "1.1.7", "identifier": "yorg.orgii", "build": { "frontendDist": "../build", From a84608398dbafdd0cf68adac82e628b0eab2e3fb Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:21:55 +0800 Subject: [PATCH 067/864] ci(release): fix linux artifact signing paths Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .github/workflows/release.yaml | 43 ++++++++++++++++++++++------------ package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e4bee0113b..ca640d797d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -493,35 +493,45 @@ jobs: BUNDLE_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" DEB=$(find "$BUNDLE_DIR/deb" -name "*.deb" | head -1) + DEB_SIG=$(find "$BUNDLE_DIR/deb" -name "*.deb.sig" | head -1) + RPM=$(find "$BUNDLE_DIR/rpm" -name "*.rpm" | head -1) + RPM_SIG=$(find "$BUNDLE_DIR/rpm" -name "*.rpm.sig" | head -1) APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" | head -1) - UPDATER_TAR=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz" ! -name "*.sig" | head -1) - UPDATER_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz.sig" | head -1) + APPIMAGE_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.sig" | head -1) - for artifact in "$DEB" "$APPIMAGE" "$UPDATER_TAR" "$UPDATER_SIG"; do + for artifact in "$DEB" "$DEB_SIG" "$RPM" "$RPM_SIG" "$APPIMAGE" "$APPIMAGE_SIG"; do if [ ! -f "$artifact" ]; then echo "Missing Linux release artifact: $artifact" >&2 + find "$BUNDLE_DIR" -maxdepth 3 -type f -print >&2 exit 1 fi done LATEST_DEB="ORG2-latest-linux-x64.deb" + LATEST_RPM="ORG2-latest-linux-x64.rpm" LATEST_APPIMAGE="ORG2-latest-linux-x64.AppImage" cp "$DEB" "$LATEST_DEB" + cp "$RPM" "$LATEST_RPM" cp "$APPIMAGE" "$LATEST_APPIMAGE" echo "deb=$DEB" >> "$GITHUB_OUTPUT" + echo "deb_sig=$DEB_SIG" >> "$GITHUB_OUTPUT" + echo "rpm=$RPM" >> "$GITHUB_OUTPUT" + echo "rpm_sig=$RPM_SIG" >> "$GITHUB_OUTPUT" echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT" - echo "updater_tar=$UPDATER_TAR" >> "$GITHUB_OUTPUT" - echo "updater_sig=$UPDATER_SIG" >> "$GITHUB_OUTPUT" + echo "appimage_sig=$APPIMAGE_SIG" >> "$GITHUB_OUTPUT" echo "latest_deb=$LATEST_DEB" >> "$GITHUB_OUTPUT" + echo "latest_rpm=$LATEST_RPM" >> "$GITHUB_OUTPUT" echo "latest_appimage=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" - echo "updater_tar_name=$(basename "$UPDATER_TAR")" >> "$GITHUB_OUTPUT" + echo "appimage_name=$(basename "$APPIMAGE")" >> "$GITHUB_OUTPUT" echo "=== Linux artifacts ===" - echo "DEB: $DEB" - echo "AppImage: $APPIMAGE" - echo "Updater tar: $UPDATER_TAR" - echo "Updater sig: $UPDATER_SIG" + echo "DEB: $DEB" + echo "DEB sig: $DEB_SIG" + echo "RPM: $RPM" + echo "RPM sig: $RPM_SIG" + echo "AppImage: $APPIMAGE" + echo "AppImage sig: $APPIMAGE_SIG" # ── Merge Linux updater entry into latest.json ─────────────── - name: Generate latest.json for Linux updater @@ -531,9 +541,9 @@ jobs: run: | gh release download "$TAG" --pattern latest.json --output latest.json - SIGNATURE=$(cat "${{ steps.artifacts.outputs.updater_sig }}") - TAR_NAME="${{ steps.artifacts.outputs.updater_tar_name }}" - DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${TAR_NAME}" + SIGNATURE=$(cat "${{ steps.artifacts.outputs.appimage_sig }}") + APPIMAGE_NAME="${{ steps.artifacts.outputs.appimage_name }}" + DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${APPIMAGE_NAME}" jq \ --arg signature "$SIGNATURE" \ @@ -555,9 +565,12 @@ jobs: prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} files: | ${{ steps.artifacts.outputs.deb }} + ${{ steps.artifacts.outputs.deb_sig }} ${{ steps.artifacts.outputs.latest_deb }} + ${{ steps.artifacts.outputs.rpm }} + ${{ steps.artifacts.outputs.rpm_sig }} + ${{ steps.artifacts.outputs.latest_rpm }} ${{ steps.artifacts.outputs.appimage }} + ${{ steps.artifacts.outputs.appimage_sig }} ${{ steps.artifacts.outputs.latest_appimage }} - ${{ steps.artifacts.outputs.updater_tar }} - ${{ steps.artifacts.outputs.updater_sig }} latest.json diff --git a/package.json b/package.json index 9d9747b313..7f30059ac0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orgii", - "version": "1.1.7", + "version": "1.1.8", "description": "Self-evolving agentic development framework — ORGII desktop app", "main": "src/index.tsx", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7e6df947a1..907d9e6195 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ORG2", - "version": "1.1.7", + "version": "1.1.8", "identifier": "yorg.orgii", "build": { "frontendDist": "../build", From c3199092d32ba30c9465e62b4887e13354a01a0b Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:26:14 +0800 Subject: [PATCH 068/864] fix(sidebar): show host title on linux Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/scaffold/NavigationSidebar/SidebarBase.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scaffold/NavigationSidebar/SidebarBase.tsx b/src/scaffold/NavigationSidebar/SidebarBase.tsx index 8f61ac2b67..5d1317a82d 100644 --- a/src/scaffold/NavigationSidebar/SidebarBase.tsx +++ b/src/scaffold/NavigationSidebar/SidebarBase.tsx @@ -53,6 +53,9 @@ const log = createLogger("SidebarBase"); const HOST_DESKTOP_KIND = resolveHostDesktop(); const IS_WINDOWS_HOST = HOST_DESKTOP_KIND === HOST_DESKTOP.WINDOWS; +const SHOW_HOST_TITLE = + HOST_DESKTOP_KIND === HOST_DESKTOP.WINDOWS || + HOST_DESKTOP_KIND === HOST_DESKTOP.LINUX; const PLATFORM_SIDEBAR_RADIUS = HOST_DESKTOP_KIND === HOST_DESKTOP.MACOS ? SIDEBAR_STYLE.borderRadius : 8; @@ -254,7 +257,7 @@ const SidebarBase: React.FC = React.memo( } as React.CSSProperties } > - {IS_WINDOWS_HOST ? ( + {SHOW_HOST_TITLE ? ( ORG2 From 3716e1c7820cebe659f6a68094aa3bcd2c227d5c Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:39:18 +0800 Subject: [PATCH 069/864] fix(chat): stabilize padded tail follow Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../components/ChatHistoryList.tsx | 48 +++++-- .../ChatHistory/config/chatFooterSpacer.ts | 31 ++++- .../ChatHistory/hooks/useChatScroll.ts | 118 ++++++++++++++---- .../ChatHistory/hooks/useChatScrollPin.ts | 22 +++- src/engines/ChatPanel/ChatHistory/index.tsx | 39 +++++- 5 files changed, 221 insertions(+), 37 deletions(-) diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index e3f43b2c58..e9e054e94e 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -21,7 +21,10 @@ import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { PlanningFooter } from "@src/engines/ChatPanel/blocks/primitives"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; -import { CHAT_FOOTER_SPACER } from "../config/chatFooterSpacer"; +import { + CHAT_FOOTER_SPACER, + getChatContentBottomDistance, +} from "../config/chatFooterSpacer"; import { getUnloadedTurnMeta } from "../hooks/useChatGroups"; import { GroupItemRenderer } from "../renderers"; import type { GroupHeaderRenderPart } from "../renderers/GroupHeaderRenderer"; @@ -29,10 +32,19 @@ import type { GroupHeaderRenderPart } from "../renderers/GroupHeaderRenderer"; const STATIC_RENDER_ITEM_LIMIT = 24; const AT_BOTTOM_EPSILON_PX = 4; -function isScrolledToPhysicalBottom(element: HTMLElement): boolean { +function isScrolledToContentBottom(params: { + element: HTMLElement; + footerSpacerHeight: number; + bottomInset: number; +}): boolean { return ( - element.scrollHeight - element.scrollTop - element.clientHeight <= - AT_BOTTOM_EPSILON_PX + getChatContentBottomDistance({ + scrollTop: params.element.scrollTop, + scrollHeight: params.element.scrollHeight, + clientHeight: params.element.clientHeight, + footerSpacerHeight: params.footerSpacerHeight, + bottomInset: params.bottomInset, + }) <= AT_BOTTOM_EPSILON_PX ); } @@ -236,6 +248,7 @@ function sameChatHistoryListProps( previous.codeBlockContainerWidth === next.codeBlockContainerWidth, ], ["footerSpacerHeight", sameFooterSpacer], + ["bottomInset", previous.bottomInset === next.bottomInset], [ "planningIndicatorCount", previous.planningIndicatorCount === next.planningIndicatorCount, @@ -310,6 +323,7 @@ interface ChatHistoryListProps { lastAssistantFlatIndexPerItem: (number | null)[]; codeBlockContainerWidth: number; footerSpacerHeight: number; + bottomInset: number; planningIndicatorCount: number; planningShowSlowHint: boolean; planningVariantIndex: number; @@ -379,6 +393,7 @@ const ChatHistoryList: React.FC = memo( lastAssistantFlatIndexPerItem, codeBlockContainerWidth, footerSpacerHeight, + bottomInset, planningIndicatorCount, planningShowSlowHint, planningVariantIndex, @@ -455,7 +470,16 @@ const ChatHistoryList: React.FC = memo( if (!group) return `chat-group-${index}:0`; const itemKeys = flatItems .slice(group.startFlatIndex, group.startFlatIndex + group.itemCount) - .map((item) => item.chunk_id) + .map((item) => { + const event = item.event; + const displayTextLength = event?.displayText?.length ?? 0; + return [ + item.chunk_id, + event?.displayStatus ?? "", + event?.activityStatus ?? "", + displayTextLength, + ].join(":"); + }) .join("|"); return `${index}:${group.itemCount}:${itemKeys}`; }, @@ -659,7 +683,13 @@ const ChatHistoryList: React.FC = memo( className="h-full overflow-y-auto overscroll-contain scrollbar-hide" onScroll={(event) => { const element = event.currentTarget; - onAtBottomStateChange(isScrolledToPhysicalBottom(element)); + onAtBottomStateChange( + isScrolledToContentBottom({ + element, + footerSpacerHeight, + bottomInset, + }) + ); reportActiveGroupIndex(element); }} > @@ -729,7 +759,11 @@ const ChatHistoryList: React.FC = memo( className="h-full w-full overflow-y-auto overscroll-contain scrollbar-hide" onScroll={(event) => { const element = event.currentTarget; - const isAtBottom = isScrolledToPhysicalBottom(element); + const isAtBottom = isScrolledToContentBottom({ + element, + footerSpacerHeight, + bottomInset, + }); onAtBottomStateChange(isAtBottom); reportActiveGroupIndex(element); if (isAtBottom) onEndReached(); diff --git a/src/engines/ChatPanel/ChatHistory/config/chatFooterSpacer.ts b/src/engines/ChatPanel/ChatHistory/config/chatFooterSpacer.ts index 45add24c12..28f12c2aec 100644 --- a/src/engines/ChatPanel/ChatHistory/config/chatFooterSpacer.ts +++ b/src/engines/ChatPanel/ChatHistory/config/chatFooterSpacer.ts @@ -8,7 +8,7 @@ export const CHAT_FOOTER_SPACER = { /** Minimum spacer before the bottom overlay guard. */ MIN_WHEN_FULL_PX: 32, - /** Extra guard added on top of bottomInset for the input overlay. */ + /** Extra guard added on top of bottomInset for the input overlay. Do not tune casually; follow reliability depends on this target. */ BOTTOM_GUARD_PX: 120, /** Ignore sub-pixel / tiny remeasure noise, but keep spacer state and rendering in sync. */ UPDATE_THRESHOLD_PX: 8, @@ -37,3 +37,32 @@ export function computeChatFooterSpacerHeight(params: { CHAT_FOOTER_SPACER.BOTTOM_GUARD_PX ); } + +export function getChatContentBottomScrollTop(params: { + scrollHeight: number; + clientHeight: number; + footerSpacerHeight: number; + bottomInset: number; +}): number { + const contentBottom = Math.max( + 0, + params.scrollHeight - params.footerSpacerHeight + ); + return Math.max( + 0, + contentBottom - + params.clientHeight + + Math.max(0, params.bottomInset) + + CHAT_FOOTER_SPACER.BOTTOM_GUARD_PX + ); +} + +export function getChatContentBottomDistance(params: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + footerSpacerHeight: number; + bottomInset: number; +}): number { + return getChatContentBottomScrollTop(params) - Math.max(0, params.scrollTop); +} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts index 1e430d733f..2020cfbcea 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts @@ -23,6 +23,8 @@ import { import { useDebouncedCallback } from "@src/hooks/perf"; +import { getChatContentBottomScrollTop } from "../config/chatFooterSpacer"; + // ============================================ // Types // ============================================ @@ -48,6 +50,8 @@ export interface UseChatScrollOptions { /** Timestamp of the latest user scroll on the chat scroller. Used to keep * auto-follow from fighting trackpad/wheel momentum near the bottom. */ manualScrollAtRef?: MutableRefObject; + /** Timestamp of the latest programmatic scroll correction. */ + programmaticScrollAtRef: MutableRefObject; /** Timestamp of the latest user-triggered turn collapse/expand. During * this short window, structural list-size changes must preserve the * user's local viewport instead of following the virtualized tail. */ @@ -60,6 +64,12 @@ export interface UseChatScrollOptions { activeSessionId: string | null | undefined; /** Static renderer scroll root used when Virtuoso is not mounted. */ staticScrollerRef?: MutableRefObject; + /** Height of the reserved footer spacer after the last rendered chat row. */ + footerSpacerHeight: number; + /** Height of the overlapping composer/input area. */ + bottomInset: number; + /** Changes when tail content streams without adding/removing list items. */ + tailFollowKey: string; /** When true, bypass the `isContentOverflowingRef` guard so auto-scroll * engages even in small viewports (subagent monitor cells). */ alwaysFollowTail?: boolean; @@ -82,6 +92,7 @@ export interface UseChatScrollReturn { const AT_BOTTOM_DEBOUNCE_MS = 150; const MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS = 450; const TURN_COLLAPSE_AUTO_FOLLOW_SUPPRESS_MS = 700; +const FOLLOW_SETTLE_FRAME_COUNT = 4; export function useChatScroll({ optimizedChatHistoryLength, @@ -93,10 +104,14 @@ export function useChatScroll({ visibleRangeEndRef, pinLastGroupRef, manualScrollAtRef, + programmaticScrollAtRef, turnCollapseInteractionAtRef, isContentOverflowingRef, activeSessionId, staticScrollerRef, + footerSpacerHeight, + bottomInset, + tailFollowKey, alwaysFollowTail = false, }: UseChatScrollOptions): UseChatScrollReturn { const atBottomRef = useRef(true); @@ -109,14 +124,12 @@ export function useChatScroll({ chatHistoryLengthRef.current = optimizedChatHistoryLength; }, [optimizedChatHistoryLength]); - const scrollRafRef = useRef(0); - const scrollSecondRafRef = useRef(0); + const scheduledFollowCleanupRef = useRef<(() => void) | null>(null); useEffect(() => { - const scrollRafRefForCleanup = scrollRafRef; - const scrollSecondRafRefForCleanup = scrollSecondRafRef; + const scheduledFollowCleanupRefForCleanup = scheduledFollowCleanupRef; return () => { - cancelAnimationFrame(scrollRafRefForCleanup.current); - cancelAnimationFrame(scrollSecondRafRefForCleanup.current); + scheduledFollowCleanupRefForCleanup.current?.(); + scheduledFollowCleanupRefForCleanup.current = null; }; }, []); @@ -140,23 +153,69 @@ export function useChatScroll({ (behavior: ScrollBehavior = "auto") => { const el = staticScrollerRef?.current ?? virtuosoScrollerRef.current; if (!el) return false; + programmaticScrollAtRef.current = performance.now(); el.scrollTo({ - top: Math.max(0, el.scrollHeight - el.clientHeight), + top: getChatContentBottomScrollTop({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + footerSpacerHeight, + bottomInset, + }), behavior, }); return true; }, - [staticScrollerRef, virtuosoScrollerRef] + [ + bottomInset, + footerSpacerHeight, + programmaticScrollAtRef, + staticScrollerRef, + virtuosoScrollerRef, + ] ); + const scheduleSettledFollow = useCallback(() => { + scheduledFollowCleanupRef.current?.(); + const frameIds: number[] = []; + const runFrame = (remainingFrames: number) => { + const frameId = requestAnimationFrame(() => { + scrollElementToBottom(); + if (remainingFrames > 1) { + runFrame(remainingFrames - 1); + } + }); + frameIds.push(frameId); + }; + scrollElementToBottom(); + runFrame(FOLLOW_SETTLE_FRAME_COUNT); + const cleanup = () => { + for (const frameId of frameIds) { + cancelAnimationFrame(frameId); + } + }; + scheduledFollowCleanupRef.current = cleanup; + return cleanup; + }, [scrollElementToBottom]); + const scrollToBottom = useCallback(() => { - if (scrollElementToBottom()) { - window.requestAnimationFrame(() => scrollElementToBottom()); - return; + pinLastGroupRef.current = false; + if (manualScrollAtRef) { + manualScrollAtRef.current = 0; + } else { + fallbackManualScrollAtRef.current = 0; } - - scrollElementToBottom("smooth"); - }, [scrollElementToBottom]); + atBottomRef.current = true; + setAtBottom(true); + setIsChatScrolledToBottom(true); + scheduleSettledFollow(); + }, [ + fallbackManualScrollAtRef, + manualScrollAtRef, + pinLastGroupRef, + scheduleSettledFollow, + setAtBottom, + setIsChatScrolledToBottom, + ]); useEffect(() => { const scrollRoot = @@ -164,10 +223,8 @@ export function useChatScroll({ if (!scrollRoot) return; let frameId = 0; - let secondFrameId = 0; const followIfPinnedToTail = () => { cancelAnimationFrame(frameId); - cancelAnimationFrame(secondFrameId); frameId = requestAnimationFrame(() => { if (pinLastGroupRef.current) return; if ( @@ -178,8 +235,7 @@ export function useChatScroll({ return; } if (!alwaysFollowTail && !atBottomRef.current) return; - scrollElementToBottom(); - secondFrameId = requestAnimationFrame(() => scrollElementToBottom()); + scheduleSettledFollow(); }); }; @@ -191,7 +247,6 @@ export function useChatScroll({ return () => { cancelAnimationFrame(frameId); - cancelAnimationFrame(secondFrameId); resizeObserver.disconnect(); }; }, [ @@ -199,11 +254,30 @@ export function useChatScroll({ alwaysFollowTail, effectiveManualScrollAtRef, pinLastGroupRef, - scrollElementToBottom, + scheduleSettledFollow, staticScrollerRef, virtuosoScrollerRef, ]); + useEffect(() => { + if (!tailFollowKey) return; + if (pinLastGroupRef.current) return; + if (!alwaysFollowTail && !atBottomRef.current) return; + if ( + performance.now() - effectiveManualScrollAtRef.current < + MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS + ) { + return; + } + return scheduleSettledFollow(); + }, [ + alwaysFollowTail, + effectiveManualScrollAtRef, + pinLastGroupRef, + scheduleSettledFollow, + tailFollowKey, + ]); + // Auto-scroll when new messages arrive if user was at content bottom. // Stands down while the latest group is pinned to top — the pin is the // caller's explicit override of bottom-follow behaviour. @@ -264,14 +338,14 @@ export function useChatScroll({ return; } if (!alwaysFollowTail && !isContentOverflowingRef.current) return; - scrollToBottom(); + scheduleSettledFollow(); }, 50); return () => clearTimeout(timer); } }, [ optimizedChatHistoryLength, atBottom, - scrollToBottom, + scheduleSettledFollow, visibleRangeEndRef, pinLastGroupRef, effectiveManualScrollAtRef, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts index 501ce9e5c6..3cea110f23 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts @@ -7,10 +7,14 @@ import { useRef, } from "react"; +import { getChatContentBottomScrollTop } from "../config/chatFooterSpacer"; + export interface UseChatScrollPinOptions { activeId: string | null; groupCounts: number[]; totalFlatItems: number; + footerSpacerHeight: number; + bottomInset: number; sessionLoadStatus: string; virtuosoScrollerRef: RefObject; atBottom: boolean; @@ -24,6 +28,8 @@ export interface UseChatScrollPinOptions { pinLastGroupRef: MutableRefObject; /** Updated when the scroller receives a real user scroll, not a programmatic correction. */ manualScrollAtRef?: MutableRefObject; + /** Updated before any programmatic scroll correction. */ + programmaticScrollAtRef: MutableRefObject; onPinToTopChange?: (active: boolean) => void; /** * Fallback scroll container for the static rendering path. @@ -53,6 +59,8 @@ export function useChatScrollPin({ activeId, groupCounts, totalFlatItems: _totalFlatItems, + footerSpacerHeight, + bottomInset, sessionLoadStatus: _sessionLoadStatus, virtuosoScrollerRef, atBottom: _atBottom, @@ -61,10 +69,10 @@ export function useChatScrollPin({ optimizedChatHistoryLength, pinLastGroupRef, manualScrollAtRef, + programmaticScrollAtRef, onPinToTopChange, staticScrollerRef, }: UseChatScrollPinOptions): UseChatScrollPinReturn { - const programmaticScrollAtRef = useRef(0); const fallbackManualScrollAtRef = useRef(0); const effectiveManualScrollAtRef = manualScrollAtRef ?? fallbackManualScrollAtRef; @@ -82,11 +90,16 @@ export function useChatScrollPin({ virtuosoScrollerRef.current ?? staticScrollerRef?.current; if (scrollRoot) { scrollRoot.scrollTo({ - top: Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight), + top: getChatContentBottomScrollTop({ + scrollHeight: scrollRoot.scrollHeight, + clientHeight: scrollRoot.clientHeight, + footerSpacerHeight, + bottomInset, + }), behavior: "auto", }); } - }, [staticScrollerRef, virtuosoScrollerRef]); + }, [bottomInset, footerSpacerHeight, staticScrollerRef, virtuosoScrollerRef]); const scheduleFollowToEnd = useCallback(() => { effectiveManualScrollAtRef.current = 0; @@ -104,7 +117,7 @@ export function useChatScrollPin({ cancelAnimationFrame(firstFrameId); cancelAnimationFrame(secondFrameId); }; - }, [effectiveManualScrollAtRef, scrollToEnd]); + }, [effectiveManualScrollAtRef, programmaticScrollAtRef, scrollToEnd]); // Effect 1: always scroll to end on session switch. // New-event tail following is owned by useChatScroll; @@ -131,6 +144,7 @@ export function useChatScrollPin({ scheduleFollowToEnd, onPinToTopChange, pinLastGroupRef, + programmaticScrollAtRef, ]); // Effect 2: scroll to bottom only when a new user-message group is added. diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index 643ed4b78c..82f19de369 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -58,6 +58,7 @@ import ChatPinnedHeaderLayer from "./components/ChatPinnedHeaderLayer"; import ChatSearchBar from "./components/ChatSearchBar"; import RevertConfirmDialog from "./components/RevertConfirmDialog"; import TurnPageList from "./components/TurnPageList"; +import { getChatContentBottomDistance } from "./config/chatFooterSpacer"; import { useChatEmptyState, useChatFooterSpacer, @@ -587,6 +588,24 @@ const ChatHistory: React.FC = ({ const virtualListDataKey = `${activeId ?? "no-session"}:${ turnPaginationEnabled ? `page-${currentPageIndex}` : "all" }:${virtualListGroupShapeKey}:${virtualListItemShapeKey}:${collapseStateKey}`; + const tailFollowKey = useMemo(() => { + const tailItem = displayFlatItems[displayFlatItems.length - 1]; + const tailEvent = tailItem?.event; + return [ + activeId ?? "no-session", + tailItem?.chunk_id ?? "no-tail", + tailEvent?.displayStatus ?? "", + tailEvent?.activityStatus ?? "", + tailEvent?.displayText?.length ?? 0, + displayTotalFlatItems, + planningIndicatorCount, + ].join(":"); + }, [ + activeId, + displayFlatItems, + displayTotalFlatItems, + planningIndicatorCount, + ]); // --- Empty-state grace period --- const optimizedLen = chatHistory.length; @@ -644,6 +663,7 @@ const ChatHistory: React.FC = ({ // they coordinate without re-renders. const pinLastGroupRef = useRef(false); const manualScrollAtRef = useRef(0); + const programmaticScrollAtRef = useRef(0); const turnCollapseInteractionAtRef = useRef(0); const [reservePinToTop, setReservePinToTop] = React.useState(false); const handlePinToTopChange = useCallback((active: boolean) => { @@ -697,10 +717,14 @@ const ChatHistory: React.FC = ({ if (measurementKey === lastMeasurementKey) return; lastMeasurementKey = measurementKey; - const distanceToPhysicalBottom = - root.scrollHeight - root.scrollTop - root.clientHeight; const nextVisible = - distanceToPhysicalBottom <= SCROLL_NAV_SHOW_THRESHOLD_PX; + getChatContentBottomDistance({ + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + footerSpacerHeight, + bottomInset, + }) <= SCROLL_NAV_SHOW_THRESHOLD_PX; setIsBottomSentinelVisible((previousVisible) => previousVisible === nextVisible ? previousVisible : nextVisible ); @@ -725,6 +749,7 @@ const ChatHistory: React.FC = ({ }; }, [ activeId, + bottomInset, displayTotalFlatItems, footerSpacerHeight, staticScrollerRef, @@ -742,10 +767,14 @@ const ChatHistory: React.FC = ({ visibleRangeEndRef, pinLastGroupRef, manualScrollAtRef, + programmaticScrollAtRef, turnCollapseInteractionAtRef, isContentOverflowingRef, activeSessionId: activeId, staticScrollerRef, + footerSpacerHeight, + bottomInset, + tailFollowKey, alwaysFollowTail: disableTailCollapse, }); // Subagent panes pass `disableTailCollapse` because every paginated page @@ -775,6 +804,8 @@ const ChatHistory: React.FC = ({ activeId, groupCounts: displayGroupCounts, totalFlatItems: displayTotalFlatItems, + footerSpacerHeight, + bottomInset, sessionLoadStatus, virtuosoScrollerRef, atBottom, @@ -783,6 +814,7 @@ const ChatHistory: React.FC = ({ optimizedChatHistoryLength: optimizedChatHistory.length, pinLastGroupRef, manualScrollAtRef, + programmaticScrollAtRef, onPinToTopChange: handlePinToTopChange, staticScrollerRef, }); @@ -1140,6 +1172,7 @@ const ChatHistory: React.FC = ({ } codeBlockContainerWidth={codeBlockContainerWidth ?? 0} footerSpacerHeight={footerSpacerHeight} + bottomInset={bottomInset} virtualListRef={virtualListRef} virtualListDataKey={virtualListDataKey} getIsWpGeneWorking={getIsWpGeneWorking} From e9853c2e4abbd7e6bd6689ad71dc6785bc64e2b8 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:51:40 +0800 Subject: [PATCH 070/864] fix(chat): show thought subtitle preview Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../events/stream/thinking/index.tsx | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/engines/ChatPanel/events/stream/thinking/index.tsx b/src/engines/ChatPanel/events/stream/thinking/index.tsx index abc6a84a7a..fd54529b78 100644 --- a/src/engines/ChatPanel/events/stream/thinking/index.tsx +++ b/src/engines/ChatPanel/events/stream/thinking/index.tsx @@ -38,6 +38,7 @@ import { useNormalizedEventProps, } from "@src/engines/SessionCore/rendering/props"; import type { EventVariant } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { formatDuration } from "@src/util/time/formatDuration"; const LazySimulatorMessages = lazy( () => import("@src/modules/WorkStation/Chat/Communication") @@ -56,8 +57,54 @@ export interface ThinkingEventProps extends RawEventInput { // Chat Variant (uses ThinkingBlock styling) // ============================================ +const THOUGHT_PREVIEW_MAX_LENGTH = 96; + +function getThoughtPreview(content?: string): string | null { + const normalized = content?.replace(/\s+/g, " ").trim(); + if (!normalized) return null; + if (normalized.length <= THOUGHT_PREVIEW_MAX_LENGTH) return normalized; + return `${normalized.slice(0, THOUGHT_PREVIEW_MAX_LENGTH).trimEnd()}...`; +} + +interface ThoughtSubtitleProps { + content?: string; + duration?: number; + isLoading: boolean; +} + +const ThoughtSubtitle: React.FC = ({ + content, + duration, + isLoading, +}) => { + const preview = getThoughtPreview(content); + const durationLabel = duration ? formatDuration(duration) : null; + + if (!preview && !durationLabel) return null; + + return ( + + {durationLabel && ( + + {durationLabel} + + )} + {durationLabel && preview && ( + · + )} + {preview && {preview}} + + ); +}; + interface ChatVariantProps { content?: string; + duration?: number; isLoading: boolean; isStreaming?: boolean; eventId?: string; @@ -65,6 +112,7 @@ interface ChatVariantProps { const ChatVariant: React.FC = ({ content, + duration, isLoading, isStreaming = false, eventId, @@ -112,6 +160,11 @@ const ChatVariant: React.FC = ({ {title} + {!isCollapsed && ( @@ -151,7 +204,7 @@ export const ThinkingEvent: React.FC = (props) => { if (!normalizedProps) return null; - const { content } = extractThinkingData(normalizedProps); + const { content, duration } = extractThinkingData(normalizedProps); const displayContent = props.streamingContent || content; const hasContent = Boolean(displayContent?.trim()); const isThinkingEvent = props.event @@ -165,6 +218,7 @@ export const ThinkingEvent: React.FC = (props) => { return ( Date: Mon, 29 Jun 2026 21:48:08 +0800 Subject: [PATCH 071/864] docs(readme): update release version and linux links Pre-commit hook ran. Total eslint: 0, total circular: 0 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0016e3769f..092de55287 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ ·
Windows MSI · + Linux AppImage + · + Linux DEB + · All latest release assets

@@ -55,13 +59,15 @@ ORG-II explores a different model: agents as persistent, observable colleagues i ## Download -Current build version: v1.1.3 (2026-06-25) +Current build version: v1.1.8 (2026-06-29) Download the latest ORGII desktop app with one click: - [macOS Apple Silicon](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-mac-apple-silicon.dmg) - [Windows x64 installer](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-windows-x64-setup.exe) - [Windows x64 MSI](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-windows-x64.msi) +- [Linux x64 AppImage](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-linux-x64.AppImage) +- [Linux x64 DEB](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-linux-x64.deb) - [All latest release assets](https://github.com/yorgai/ORG2/releases/latest) The direct download links always resolve through GitHub's latest release pointer. From 2c494b277fc776b643749d1432c9e2dbb8305e0a Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:53:58 +0800 Subject: [PATCH 072/864] ci(release): trim public release assets Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .github/workflows/release.yaml | 46 ++++++++-------------------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ca640d797d..b806f5faa9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -168,14 +168,15 @@ jobs: UPDATER_TAR=$(find "$BUNDLE_DIR/macos" -name "*.app.tar.gz" ! -name "*.sig" | head -1) UPDATER_SIG=$(find "$BUNDLE_DIR/macos" -name "*.app.tar.gz.sig" | head -1) - echo "updater_tar=$UPDATER_TAR" >> "$GITHUB_OUTPUT" + LATEST_UPDATER_TAR="ORG2-updater-mac-apple-silicon.app.tar.gz" + cp "$UPDATER_TAR" "$LATEST_UPDATER_TAR" + echo "updater_tar=$LATEST_UPDATER_TAR" >> "$GITHUB_OUTPUT" echo "updater_sig=$UPDATER_SIG" >> "$GITHUB_OUTPUT" - echo "updater_tar_name=$(basename "$UPDATER_TAR")" >> "$GITHUB_OUTPUT" - echo "updater_sig_name=$(basename "$UPDATER_SIG")" >> "$GITHUB_OUTPUT" + echo "updater_tar_name=$LATEST_UPDATER_TAR" >> "$GITHUB_OUTPUT" echo "=== Release artifacts ===" echo "DMG: $DMG" - echo "Updater tar: $UPDATER_TAR" + echo "Updater tar: $LATEST_UPDATER_TAR" echo "Updater sig: $UPDATER_SIG" # ── Generate updater manifest (latest.json) ─────────────── @@ -212,10 +213,8 @@ jobs: prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} generate_release_notes: true files: | - ${{ steps.artifacts.outputs.dmg }} ${{ steps.artifacts.outputs.latest_dmg }} ${{ steps.artifacts.outputs.updater_tar }} - ${{ steps.artifacts.outputs.updater_sig }} latest.json # ── Cleanup keychain ──────────────────────────────────────── @@ -385,7 +384,7 @@ jobs: gh release download "$TAG" --pattern latest.json --output latest.json SIGNATURE=$(cat "${{ steps.artifacts.outputs.nsis_sig }}") - NSIS_NAME=$(basename "${{ steps.artifacts.outputs.nsis }}") + NSIS_NAME="${{ steps.artifacts.outputs.latest_nsis }}" DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${NSIS_NAME}" jq \ @@ -407,12 +406,8 @@ jobs: overwrite_files: true prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} files: | - ${{ steps.artifacts.outputs.msi }} ${{ steps.artifacts.outputs.latest_msi }} - ${{ steps.artifacts.outputs.msi_sig }} - ${{ steps.artifacts.outputs.nsis }} ${{ steps.artifacts.outputs.latest_nsis }} - ${{ steps.artifacts.outputs.nsis_sig }} latest.json # ── Linux x64 build ─────────────────────────────────────────────────── @@ -484,7 +479,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} ORGII_DIAGNOSTICS_TOKEN: ${{ secrets.ORGII_DIAGNOSTICS_TOKEN }} ORGII_APP_VERSION: ${{ env.SEMVER }} - run: pnpm tauri build --target x86_64-unknown-linux-gnu + run: pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage # ── Gather artifacts ───────────────────────────────────────── - name: Gather release artifacts @@ -493,13 +488,10 @@ jobs: BUNDLE_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" DEB=$(find "$BUNDLE_DIR/deb" -name "*.deb" | head -1) - DEB_SIG=$(find "$BUNDLE_DIR/deb" -name "*.deb.sig" | head -1) - RPM=$(find "$BUNDLE_DIR/rpm" -name "*.rpm" | head -1) - RPM_SIG=$(find "$BUNDLE_DIR/rpm" -name "*.rpm.sig" | head -1) APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" | head -1) APPIMAGE_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.sig" | head -1) - for artifact in "$DEB" "$DEB_SIG" "$RPM" "$RPM_SIG" "$APPIMAGE" "$APPIMAGE_SIG"; do + for artifact in "$DEB" "$APPIMAGE" "$APPIMAGE_SIG"; do if [ ! -f "$artifact" ]; then echo "Missing Linux release artifact: $artifact" >&2 find "$BUNDLE_DIR" -maxdepth 3 -type f -print >&2 @@ -508,28 +500,17 @@ jobs: done LATEST_DEB="ORG2-latest-linux-x64.deb" - LATEST_RPM="ORG2-latest-linux-x64.rpm" LATEST_APPIMAGE="ORG2-latest-linux-x64.AppImage" cp "$DEB" "$LATEST_DEB" - cp "$RPM" "$LATEST_RPM" cp "$APPIMAGE" "$LATEST_APPIMAGE" - echo "deb=$DEB" >> "$GITHUB_OUTPUT" - echo "deb_sig=$DEB_SIG" >> "$GITHUB_OUTPUT" - echo "rpm=$RPM" >> "$GITHUB_OUTPUT" - echo "rpm_sig=$RPM_SIG" >> "$GITHUB_OUTPUT" - echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT" echo "appimage_sig=$APPIMAGE_SIG" >> "$GITHUB_OUTPUT" echo "latest_deb=$LATEST_DEB" >> "$GITHUB_OUTPUT" - echo "latest_rpm=$LATEST_RPM" >> "$GITHUB_OUTPUT" echo "latest_appimage=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" - echo "appimage_name=$(basename "$APPIMAGE")" >> "$GITHUB_OUTPUT" + echo "latest_appimage_name=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" echo "=== Linux artifacts ===" echo "DEB: $DEB" - echo "DEB sig: $DEB_SIG" - echo "RPM: $RPM" - echo "RPM sig: $RPM_SIG" echo "AppImage: $APPIMAGE" echo "AppImage sig: $APPIMAGE_SIG" @@ -542,7 +523,7 @@ jobs: gh release download "$TAG" --pattern latest.json --output latest.json SIGNATURE=$(cat "${{ steps.artifacts.outputs.appimage_sig }}") - APPIMAGE_NAME="${{ steps.artifacts.outputs.appimage_name }}" + APPIMAGE_NAME="${{ steps.artifacts.outputs.latest_appimage_name }}" DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${APPIMAGE_NAME}" jq \ @@ -564,13 +545,6 @@ jobs: overwrite_files: true prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} files: | - ${{ steps.artifacts.outputs.deb }} - ${{ steps.artifacts.outputs.deb_sig }} ${{ steps.artifacts.outputs.latest_deb }} - ${{ steps.artifacts.outputs.rpm }} - ${{ steps.artifacts.outputs.rpm_sig }} - ${{ steps.artifacts.outputs.latest_rpm }} - ${{ steps.artifacts.outputs.appimage }} - ${{ steps.artifacts.outputs.appimage_sig }} ${{ steps.artifacts.outputs.latest_appimage }} latest.json From 9200654a4d71cf32e4614ddc2c98f64acaef7028 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:27:10 +0800 Subject: [PATCH 073/864] fix(chat): stabilize live streaming indicators Pre-commit hook ran. Total eslint: 6, total circular: 0 --- .../sessionHelpers/inspectChatState.ts | 7 +-- .../components/ChatHistoryList.tsx | 25 +++++---- src/engines/ChatPanel/ChatHistory/index.tsx | 26 +++++++--- .../renderers/ExtendedItemRenderers.tsx | 4 +- .../blocks/AgentMessageBlock/index.tsx | 11 ++-- .../blocks/primitives/PlanningFooter.tsx | 31 +++++------ .../ChatPanel/blocks/primitives/index.ts | 1 + .../events/stream/agent-message/index.tsx | 8 +-- .../ChatPanel/hooks/useStreamingHud.ts | 3 +- src/engines/SessionCore/core/atoms/events.ts | 19 ++++--- .../derived/__tests__/chatEvents.test.ts | 22 +++++++- .../SessionCore/derived/simulatorEvents.ts | 3 +- .../hooks/replay/usePlanningIndicator.ts | 52 +------------------ .../__tests__/sessionSyncStateHelpers.test.ts | 30 ++++++++--- .../sync/sessionSyncStateHelpers.ts | 10 ++-- .../apps/core/useSimulatorAppState.ts | 47 +++++++++++------ src/i18n/locales/de/sessions.json | 1 + src/i18n/locales/en/sessions.json | 1 + src/i18n/locales/es/sessions.json | 1 + src/i18n/locales/fr/sessions.json | 1 + src/i18n/locales/ja/sessions.json | 1 + src/i18n/locales/ko/sessions.json | 1 + src/i18n/locales/pl/sessions.json | 1 + src/i18n/locales/pt/sessions.json | 1 + src/i18n/locales/ru/sessions.json | 1 + src/i18n/locales/tr/sessions.json | 1 + src/i18n/locales/vi/sessions.json | 1 + src/i18n/locales/zh-Hant/sessions.json | 1 + src/i18n/locales/zh/sessions.json | 1 + .../Chat/Communication/ChatBubble.tsx | 24 +++++++-- .../Chat/Communication/MessageViewer.tsx | 17 +++++- 31 files changed, 215 insertions(+), 138 deletions(-) diff --git a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts index 7b450deb92..26cb941046 100644 --- a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts +++ b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts @@ -160,9 +160,10 @@ export function createInspectChatStateHelper(store: E2EStore) { .get(sessionsAtom) .find((session) => session.session_id === activeSessionId) ?? null) : null; - const streamingDeltaText = activeSessionId - ? (store.get(streamingDeltaContentAtom).get(activeSessionId) ?? "") - : ""; + const streamingDelta = activeSessionId + ? store.get(streamingDeltaContentAtom).get(activeSessionId) + : undefined; + const streamingDeltaText = streamingDelta?.content ?? ""; let snapshotCount: number | null = null; let fileChangesCount: number | null = null; let fileChangePaths: string[] | null = null; diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index e9e054e94e..648c40932e 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -18,7 +18,10 @@ import React, { } from "react"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; -import { PlanningFooter } from "@src/engines/ChatPanel/blocks/primitives"; +import { + PlanningFooter, + type PlanningFooterMode, +} from "@src/engines/ChatPanel/blocks/primitives"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import { @@ -253,14 +256,14 @@ function sameChatHistoryListProps( "planningIndicatorCount", previous.planningIndicatorCount === next.planningIndicatorCount, ], - [ - "planningShowSlowHint", - previous.planningShowSlowHint === next.planningShowSlowHint, - ], [ "planningVariantIndex", previous.planningVariantIndex === next.planningVariantIndex, ], + [ + "planningFooterMode", + previous.planningFooterMode === next.planningFooterMode, + ], ["virtualListRef", previous.virtualListRef === next.virtualListRef], [ "virtualListDataKey", @@ -325,8 +328,8 @@ interface ChatHistoryListProps { footerSpacerHeight: number; bottomInset: number; planningIndicatorCount: number; - planningShowSlowHint: boolean; planningVariantIndex: number; + planningFooterMode: PlanningFooterMode; virtualListRef: React.RefObject; virtualListDataKey: string; /** @@ -395,8 +398,8 @@ const ChatHistoryList: React.FC = memo( footerSpacerHeight, bottomInset, planningIndicatorCount, - planningShowSlowHint, planningVariantIndex, + planningFooterMode, virtualListRef, virtualListDataKey, getIsWpGeneWorking, @@ -418,10 +421,10 @@ const ChatHistoryList: React.FC = memo( // renderGroupItem's useCallback (Root Cause 2 fix). const planningIndicatorCountRef = useRef(planningIndicatorCount); planningIndicatorCountRef.current = planningIndicatorCount; - const planningShowSlowHintRef = useRef(planningShowSlowHint); - planningShowSlowHintRef.current = planningShowSlowHint; const planningVariantIndexRef = useRef(planningVariantIndex); planningVariantIndexRef.current = planningVariantIndex; + const planningFooterModeRef = useRef(planningFooterMode); + planningFooterModeRef.current = planningFooterMode; // flatItems and previousChatItems in refs so renderGroupItem's useCallback // is not re-created on every token during streaming (Root Cause 1 fix). @@ -615,8 +618,8 @@ const ChatHistoryList: React.FC = memo( ); } @@ -712,8 +715,8 @@ const ChatHistoryList: React.FC = memo( ); } diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index 82f19de369..ebe5419eb8 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -24,6 +24,8 @@ import type { AgentOrgRunMemberView } from "@src/api/tauri/agent"; import { DROPDOWN_CLASSES } from "@src/components/Dropdown/tokens"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; +import { streamingDeltaContentAtom } from "@src/engines/SessionCore/core/atoms"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import { usePlanningIndicator } from "@src/engines/SessionCore/hooks"; import { estimateRuntimeValueBytes, @@ -96,7 +98,7 @@ import "./index.scss"; */ interface PlanningIndicatorBridgeProps extends Omit< React.ComponentProps, - "planningIndicatorCount" | "planningShowSlowHint" | "planningVariantIndex" + "planningIndicatorCount" | "planningVariantIndex" | "planningFooterMode" > { planningIndicatorScope: { sessionId: string; isLive: boolean } | null; planningIndicatorEnabled: boolean; @@ -113,10 +115,20 @@ const PlanningIndicatorBridge: React.FC = ({ onPlanningIndicatorCount, ...chatHistoryListProps }) => { - const { count, showSlowHint, variantIndex } = usePlanningIndicator( - planningIndicatorScope - ); - const visibleCount = planningIndicatorEnabled ? count : 0; + const { count, variantIndex } = usePlanningIndicator(planningIndicatorScope); + const activeSessionId = useAtomValue(sessionIdAtom); + const streamingDeltaMap = useAtomValue(streamingDeltaContentAtom); + const scopedSessionId = planningIndicatorScope?.sessionId ?? activeSessionId; + const liveDelta = scopedSessionId + ? streamingDeltaMap.get(scopedSessionId) + : undefined; + const isAgentTyping = liveDelta?.kind === "message"; + const planningFooterMode = isAgentTyping ? "agentTyping" : "planning"; + const visibleCount = planningIndicatorEnabled + ? isAgentTyping + ? 1 + : count + : 0; // Notify the orchestrator whenever the count flips so useChatFooterSpacer // can schedule a re-measurement. @@ -128,8 +140,8 @@ const PlanningIndicatorBridge: React.FC = ({ ); }; @@ -437,7 +449,7 @@ const ChatHistory: React.FC = ({ // useChatFooterSpacer can re-measure when the planning footer appears / // disappears. The count itself is 0 or 1, so this setter is called at most // twice per session; it does NOT subscribe to eventStoreVersionAtom here. - // showSlowHint and variantIndex stay inside PlanningIndicatorBridge. + // variantIndex stays inside PlanningIndicatorBridge. const [planningIndicatorCount, setPlanningIndicatorCount] = useState<0 | 1>( 0 ); diff --git a/src/engines/ChatPanel/ChatHistory/renderers/ExtendedItemRenderers.tsx b/src/engines/ChatPanel/ChatHistory/renderers/ExtendedItemRenderers.tsx index d97072b4a5..a1ccd874b2 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/ExtendedItemRenderers.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/ExtendedItemRenderers.tsx @@ -48,7 +48,9 @@ const ActivityRow: React.FC<{ }> = ({ event, index, itemKey, totalOccurrences }) => { const sessionId = useAtomValue(sessionIdAtom); const streamingMap = useAtomValue(streamingDeltaContentAtom); - const streamingContent = sessionId ? streamingMap.get(sessionId) : undefined; + const liveDelta = sessionId ? streamingMap.get(sessionId) : undefined; + const streamingContent = + liveDelta?.kind === "message" ? liveDelta.content : undefined; if (isSyntheticLiveActivity(event) && !streamingContent?.trim()) { return null; diff --git a/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx b/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx index ee61bef0c3..fb33eeaf15 100644 --- a/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/AgentMessageBlock/index.tsx @@ -60,11 +60,14 @@ export interface AgentMessageBlockProps { * event. Omitted for synthetic preview rendering where no event exists. */ eventId?: string; + /** Hide footer chrome while tokens are still streaming. */ + isStreaming?: boolean; } const AgentMessageBlock: React.FC = ({ children, eventId, + isStreaming = false, }) => { const { t } = useTranslation("common"); const clampEligible = useContext(AgentMessageClampContext); @@ -122,11 +125,9 @@ const AgentMessageBlock: React.FC = ({ } const showOverlay = overflows || isExpanded; - // Locate arrow shows whenever the message is clamp-eligible AND has an - // event id to jump to. We don't gate on `overflows` — even short messages - // benefit from a one-click way to find the matching simulator event when - // the simulator is visible side-by-side. - const showLocateArrow = Boolean(eventId); + // Locate arrow shows for settled clamped messages with an event id. Hide it + // while streaming so the footer chrome does not trail the growing text. + const showLocateArrow = Boolean(eventId) && !isStreaming; return (