From faa328ceb76223eb545153e3a7730c1cbb13e347 Mon Sep 17 00:00:00 2001 From: rike Date: Tue, 8 Sep 2026 15:36:00 +0800 Subject: [PATCH 1/2] feat(codex): select models and hand off native sessions --- .github/workflows/ci.yml | 2 +- Cargo.lock | 14 +- Cargo.toml | 2 +- README.en.md | 12 +- README.md | 12 +- VERSION | 2 +- crates/farhelm-protocol/src/lib.rs | 71 +++- deploy/README.en.md | 16 +- deploy/README.md | 16 +- farhelm-agent/src/codex/context.rs | 8 +- farhelm-agent/src/codex/handoff.rs | 67 ++++ farhelm-agent/src/codex/mod.rs | 136 +++++++- farhelm-agent/src/codex/models.rs | 73 +++++ farhelm-agent/src/codex/protocol.rs | 4 + farhelm-agent/src/codex/tests.rs | 230 +++++++++++++ farhelm-agent/src/codex/transport.rs | 31 +- farhelm-agent/src/main.rs | 87 ++++- farhelm-console/e2e/codex.spec.ts | 2 +- farhelm-console/e2e/composer.spec.ts | 124 ++++++- farhelm-console/package.json | 2 +- farhelm-console/src/api/features.ts | 34 +- .../src/components/codex/Conversation.tsx | 33 +- .../src/components/codex/Dialogs.tsx | 2 +- .../src/components/codex/NativeSession.tsx | 59 ++++ .../src/components/codex/SessionSettings.tsx | 50 ++- .../src/components/codex/useOperations.ts | 12 +- .../src/components/codex/workspace.css | 55 ++-- farhelm-console/src/main.tsx | 1 - farhelm-console/src/styles.css | 6 +- farhelm-console/src/theme.ts | 3 +- farhelm-hub/src/codex_settings.rs | 127 ++++++++ farhelm-hub/src/codex_settings_tests.rs | 306 ++++++++++++++++++ farhelm-hub/src/lib.rs | 44 ++- farhelm-hub/src/live_tests.rs | 7 +- farhelm-hub/src/session_display.rs | 12 +- 35 files changed, 1542 insertions(+), 120 deletions(-) create mode 100644 farhelm-agent/src/codex/handoff.rs create mode 100644 farhelm-agent/src/codex/models.rs create mode 100644 farhelm-console/src/components/codex/NativeSession.tsx create mode 100644 farhelm-hub/src/codex_settings.rs create mode 100644 farhelm-hub/src/codex_settings_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7039803..a46bc3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - run: ./tests/deployment-package.sh - uses: actions/upload-artifact@v4 with: - name: farhelm-V0.9.0-linux-x86_64 + name: farhelm-V0.10.0-linux-x86_64 path: | dist/release/farhelm-* dist/release/SHA256SUMS diff --git a/Cargo.lock b/Cargo.lock index 44cd6b0..00060f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,7 +569,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "farhelm-agent" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "base64", @@ -599,14 +599,14 @@ dependencies = [ [[package]] name = "farhelm-core" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "farhelm-hub" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "argon2", @@ -642,7 +642,7 @@ dependencies = [ [[package]] name = "farhelm-lifecycle" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "tempfile", @@ -650,7 +650,7 @@ dependencies = [ [[package]] name = "farhelm-protocol" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", "serde_json", @@ -658,14 +658,14 @@ dependencies = [ [[package]] name = "farhelm-test-support" -version = "0.9.0" +version = "0.10.0" dependencies = [ "farhelm-protocol", ] [[package]] name = "farhelm-updater" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 41fabae..e766fda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" rust-version = "1.98" license = "Apache-2.0" diff --git a/README.en.md b/README.en.md index 5d65f97..e3af773 100644 --- a/README.en.md +++ b/README.en.md @@ -4,14 +4,14 @@

A remote control plane for personal research and GPU training environments

See training-host status from your phone and safely extend remote control without exposing inbound ports on training machines.

- V0.9.0 · + V0.10.0 · Deployment guide · 简体中文

> [!IMPORTANT] -> `V0.9.0` completes the project workspace, native Codex model and permission details, light/dark appearance, and custom accent colors. It fixes cached long-history switching, notification/experiment/audit pagination, and schedule synchronization. The Rust Agent, persistent local Codex, outbound WSS, experiment reports, and browser notifications remain in place. +> `V0.10.0` adds native Codex model and reasoning-effort selection, session renaming and native identity checks, and improves chat typography, reading layout, and the composer. The Rust Agent, persistent local Codex, outbound WSS, native permissions, experiment reports, schedules, and browser notifications remain in place. ## Quick install @@ -131,7 +131,7 @@ Desktop global navigation and mobile bottom navigation remain visible in Codex. Assistant replies support Markdown tables, code copying, math, and safe HTTPS links. Execution details in each turn form a collapsed group with visible failures. The composer stays visible, and reading older content does not jump to the bottom on new messages. Large-message continuation is separate from loading earlier conversations. Switching sessions immediately displays cached history and retains each draft; failed refreshes preserve visible content. Inactive body and parsing caches total at most 32 MiB, with at most 20 inactive histories, and are cleared on logout. Steer and interrupt target the visible active turn. -The system uses black and white base colors with one customizable theme color; the logo retains its original brand colors. Settings offer separate light/dark mode and theme color choices (graphite, blue, green, violet, rose, orange, or a custom color) for primary buttons, selected states, links, and focus, applied immediately and saved in the current browser. The rounded composer stays visible; locally served Manrope and Noto Sans SC fonts share a consistent reading column. Submission and expanding execution details use brief animations that respect the system’s reduced-motion preference; cached session switches do not replay the entry animation. +The system uses black and white base colors with one customizable theme color; the logo retains its original brand colors. Settings offer separate light/dark mode and theme color choices (graphite, blue, green, violet, rose, orange, or a custom color) for primary buttons, selected states, links, and focus, applied immediately and saved in the current browser. The rounded composer stays visible, with its input and tools sharing one surface. Platform fonts take priority, with locally served Noto Sans SC for Chinese, a consistent reading column, and a clear type hierarchy. Submission and expanding execution details use brief animations that respect the system’s reduced-motion preference; cached session switches do not replay the entry animation. Upgrade Hub before Agent when moving from V0.7.1. SQLite schema remains 7. Older Agents retain HTTP compatibility; upgraded Agents use WSS without also claiming work over HTTP. Existing Python directories remain on installed machines for rollback validation and are never invoked by the new version. @@ -154,7 +154,11 @@ flowchart LR FarHelm is one monorepo, but Hub and Agent are compiled separately and retain distinct privileges and attack surfaces. Codex communicates only over local stdio. Authentication remains local; Hub relays bodies only in bounded memory for at most 20 seconds. -The Codex composer displays the session model, reasoning effort, and permission details returned by the server. Resuming inherits native Codex settings; new browser sessions use project configuration, with an optional isolated Git worktree. Unconfirmed settings are marked unknown rather than inferred from legacy session modes. Adjust the model and permissions in Codex on the server. Browser approval prompts are not supported yet, so operations requiring user approval are declined. Upgrade Hub before Agent and verify that Agent advertises `codex.session_context`. Explicit inspect/edit creation through older APIs and the CLI remains compatible. +The Codex composer displays the session model, reasoning effort, and permission details returned by the server. Click the model to choose from the local Codex catalog, including supported reasoning efforts, for the next queued instruction and subsequent turns. Steering an active turn retains its running model. If persistence is unconfirmed, retries keep the original instruction and model to prevent duplicate execution or silently changing the request. Without a selection, native session settings are inherited; schedules inherit the session model when they execute. The browser never changes global model or permission settings. Resuming inherits native Codex settings; new browser sessions use project configuration, with an optional isolated Git worktree. Unconfirmed settings are marked unknown rather than inferred from legacy session modes. Browser approval prompts are not supported yet, so operations requiring user approval are declined. Upgrade Hub before Agent: model selection and native identity checks require `codex.model_choice` and `codex.native_identity`, respectively; permission display continues to use `codex.session_context`. Explicit inspect/edit creation through older APIs and the CLI remains compatible. + +The browser and server Codex use the same native session ID, without assigning a generic name to new sessions. The session menu supports renaming and “Continue in native Codex,” which checks local persistence and the native name and copies `codex resume `. An empty session might not be stored yet; send a message before checking. Other clients must use the same server, user, and Codex data directory. Existing desktop lists may need a refresh or reopening the project. Older sessions still named `Codex session` can be renamed for matching labels; temporary message summaries are never automatically saved as formal names. + +Native Codex holds an exclusive session writer. If the dialog says FarHelm still holds the connection, choose “Release connection and continue” before resuming in a native client. Agent closes only its own Codex connection, after checking that all threads are idle, stored, and have no background terminals, with a 15-second bound. Other clients and local training programs are unaffected. Close the native client's session connection before returning to browser editing; browser submissions and schedules still attempt to resume sessions as planned. ## Local development diff --git a/README.md b/README.md index 95b014b..f7e1dda 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@

面向个人科研与 GPU 训练环境的远程控制平面

从手机查看训练服务器状态,并在不开放训练机入站端口的前提下安全扩展远程控制能力。

- V0.9.0 · + V0.10.0 · 部署文档 · English

> [!IMPORTANT] -> `V0.9.0` 完善项目工作区、原生 Codex 模型与权限展示、浅深主题和自定义主题色,修复长会话缓存切换、通知/实验/审计分页刷新及定时任务同步。继续使用 Rust Agent、常驻本机 Codex 和出站 WSS,保留实验上报与页面通知。 +> `V0.10.0` 支持选择原生 Codex 模型与推理强度、重命名和核对跨端会话,改善聊天字体、阅读布局和输入区。继续使用 Rust Agent、常驻本机 Codex 和出站 WSS,保留原生权限、实验上报、定时任务与页面通知。 ## 快速安装 @@ -131,7 +131,7 @@ printf '训练失败,请登录查看详情' | farhelm-agent experiment report 助手回复支持 Markdown 表格、代码复制、公式及安全 HTTPS 链接。每轮执行过程默认合并折叠,失败可直接辨认。输入区固定可见,阅读旧内容时不会被新消息拉回底部;大消息的“继续加载此消息”与“加载更早对话”分别处理。切换会话立即显示已有缓存,并保留各自草稿;刷新失败不清空已显示内容。非活动正文缓存与解析缓存合计不超过 32 MiB,最多保留 20 个非活动历史,退出登录清空。补充与中断只针对可见的活动轮次。 -系统外观使用黑白基础色和一个可自定义主题色,Logo 保留品牌原色。设置中可独立选择浅深模式及主题色(石墨、蓝、绿、紫、玫瑰、橙或自定义颜色),用于主要按钮、选中状态、链接与焦点,即时生效并保存在当前浏览器。圆润的输入框固定可见;界面使用本地加载的 Manrope 与 Noto Sans SC 字体和统一阅读列。提交与展开过程提供简短动效,遵循系统“减少动态效果”设置;已缓存会话切换不重播进入动画。 +系统外观使用黑白基础色和一个可自定义主题色,Logo 保留品牌原色。设置中可独立选择浅深模式及主题色(石墨、蓝、绿、紫、玫瑰、橙或自定义颜色),用于主要按钮、选中状态、链接与焦点,即时生效并保存在当前浏览器。圆润的输入框固定可见,输入和工具共用一个表面;界面优先使用系统字体,以本地加载的 Noto Sans SC 补齐中文,并统一阅读列和字号层级。提交与展开过程提供简短动效,遵循系统“减少动态效果”设置;已缓存会话切换不重播进入动画。 从 V0.7.1 升级先更新 Hub,再更新 Agent。SQLite schema 保持 7,旧 Agent 的 HTTP 链路保留兼容;新 Agent 使用 WSS,连接期间不重复领取任务。旧 Python 目录保留在安装机供回退验证,新版本不再调用它。 @@ -154,7 +154,11 @@ flowchart LR FarHelm 是一个 monorepo,但 Hub 与 Agent 分离编译并保持不同权限与攻击面。Codex 只通过本机 stdio 通信;登录凭据保留在本机,Hub 仅在最长 20 秒的有界内存中中转正文。 -Codex 输入区显示服务器返回的会话模型、推理强度和权限详情。恢复会话沿用原生 Codex 设置;网页新建会话默认使用项目配置,隔离 Git 工作区为可选项。尚未确认的设置明确标为未知,不根据会话旧模式猜测权限。模型和权限在服务器上的 Codex 中调整;网页暂不支持人工审批,需要用户批准的操作会被拒绝。请先升级 Hub,再升级 Agent,确认 Agent 已提供 `codex.session_context` 能力。旧 API/CLI 显式创建的 inspect/edit 模式继续兼容。 +Codex 输入区显示服务器返回的会话模型、推理强度和权限详情。点击模型可从本机 Codex 提供的目录选择模型与推理强度,用于下一条排队指令及后续对话;补充正在运行的轮次仍使用原模型。保存未确认时,重试沿用原指令和模型,避免重复执行或悄悄改变请求。未选择时继承会话设置,定时任务在执行时继承会话模型。网页不会修改全局模型或权限。恢复会话沿用原生 Codex 设置;网页新建会话默认使用项目配置,隔离 Git 工作区为可选项。尚未确认的设置明确标为未知,不根据会话旧模式猜测权限。网页暂不支持人工审批,需要用户批准的操作会被拒绝。请先升级 Hub,再升级 Agent,模型选择和原生会话核对分别要求 `codex.model_choice`、`codex.native_identity` 能力;权限显示沿用 `codex.session_context`。旧 API/CLI 显式创建的 inspect/edit 模式继续兼容。 + +网页与服务器 Codex 使用同一个原生会话 ID,不再为新会话强制设置通用名称。会话菜单支持重命名,以及“在原生 Codex 中继续”:核对本地保存状态、原生名称并复制 `codex resume <会话 ID>`。空会话可能尚未落盘,先发送一条消息再核对。其他客户端须连接同一服务器、同一用户及 Codex 数据目录;桌面端已有列表可能需要刷新或重新打开项目。旧会话如果仍叫 `Codex session`,可重命名以统一两端显示;网页不会把临时消息摘要自动写成正式名称。 + +原生 Codex 对会话有写入锁。若弹窗提示 FarHelm 仍持有连接,点击“释放连接后继续”再到原生客户端恢复;Agent 只在所有会话空闲、已落盘且没有后台终端时关闭自己的 Codex 连接,检查最多 15 秒,不影响其他客户端或本地训练程序。回到网页续写前关闭原生客户端的会话连接;网页发送和定时执行仍会按原计划尝试恢复会话。 ## 本地开发 diff --git a/VERSION b/VERSION index ac39a10..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/crates/farhelm-protocol/src/lib.rs b/crates/farhelm-protocol/src/lib.rs index f1ad759..7e8cf8c 100644 --- a/crates/farhelm-protocol/src/lib.rs +++ b/crates/farhelm-protocol/src/lib.rs @@ -56,6 +56,8 @@ impl AgentHeartbeat { "codex.item_offsets".to_owned(), "codex.native".to_owned(), "codex.session_context".to_owned(), + "codex.model_choice".to_owned(), + "codex.native_identity".to_owned(), "agent.live".to_owned(), ], protocol: FARHELM_PROTOCOL.to_owned(), @@ -560,6 +562,65 @@ pub struct SendCodexMessageRequest { pub turn_id: Option, #[serde(default = "default_prompt_delivery")] pub delivery: PromptDelivery, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_choice: Option, +} + +/// Explicit next-turn settings, persisted only with the Agent's prompt receipt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodexModelChoice { + pub model: String, + pub reasoning_effort: String, +} + +impl CodexModelChoice { + #[must_use] + pub fn is_valid(&self) -> bool { + valid_model_id(&self.model) && valid_reasoning_effort(&self.reasoning_effort) + } +} + +#[must_use] +pub fn valid_model_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')) +} + +#[must_use] +pub fn valid_reasoning_effort(value: &str) -> bool { + matches!( + value, + "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra" + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexModelOption { + pub model: String, + pub display_name: String, + pub reasoning_efforts: Vec, + pub default_reasoning_effort: String, + pub is_default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexModelList { + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexNativeIdentity { + pub session_id: String, + pub persisted: bool, + #[serde(default)] + pub held_by_agent: bool, + pub native_name: Option, + pub source: Option, + pub history_mode: Option, } const fn default_prompt_delivery() -> PromptDelivery { @@ -604,6 +665,14 @@ pub fn valid_session_title(title: &str) -> bool { ) } +#[must_use] +pub fn valid_session_name(name: &str) -> bool { + valid_session_title(name) + && name.chars().count() <= 128 + && !name.chars().any(char::is_control) + && !name.contains(['/', '\\']) +} + #[must_use] pub fn public_event_payload(event_type: &str, payload: &serde_json::Value) -> serde_json::Value { use serde_json::{Map, Value}; @@ -766,7 +835,7 @@ mod tests { "agent_id": "gpu-a", "hostname": "trainer-a", "agent_version": "0.1.0", - "capabilities": ["codex.ephemeral_submit", "codex.session_display", "codex.item_offsets", "codex.native", "codex.session_context", "agent.live"] + "capabilities": ["codex.ephemeral_submit", "codex.session_display", "codex.item_offsets", "codex.native", "codex.session_context", "codex.model_choice", "codex.native_identity", "agent.live"] }) ); } diff --git a/deploy/README.en.md b/deploy/README.en.md index cb3bc75..1e498f4 100644 --- a/deploy/README.en.md +++ b/deploy/README.en.md @@ -1,4 +1,4 @@ -# FarHelm V0.9.0 deployment and lifecycle +# FarHelm V0.10.0 deployment and lifecycle [简体中文](README.md) · [English](README.en.md) @@ -154,9 +154,9 @@ farhelm-agent uninstall --keep-data ## Migrating from V0.2.0 -Hosts already on `V0.3.0` through `V0.8.0` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.9.0. +Hosts already on `V0.3.0` through `V0.9.0` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.10.0. -Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.9.0. +Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.10.0. ## Security notes @@ -189,3 +189,13 @@ On the Hub host, run `sudo farhelm-hub update --version V0.9.0` first. Then run Check device names beside projects, model/permission details, appearance settings, cached session switching, notification pagination/details, and list synchronization after creating/cancelling schedules. Existing server Codex settings continue to apply. Agents without `codex.session_context` must be upgraded before the browser can create sessions with native permissions. Failed submission retries preserve the original operation identity. Rollback replaces only the executable and retains the current database, configuration, and receipts. Never restore an old queue snapshot. V0.8.0 can still read schema 7, but does not provide the V0.9.0 interface or native permission display features. + +## V0.9.0 → V0.10.0 + +First run `sudo farhelm-hub update --version V0.10.0` on the Hub host, then `farhelm-agent update --version V0.10.0` as the original user on the Agent host, and refresh the browser. Model selection and native session actions require both roles to be upgraded. Projects, sessions, receipts, and schema 7 remain compatible; no re-pairing is needed. + +Open a session, click the model name at the bottom right of the composer, choose a model and reasoning effort from the local Codex catalog, and send an instruction. The selection applies to the next queued turn and subsequent conversation. Steering an active turn retains its model; permissions and global configuration remain unchanged. Schedules inherit session settings at execution time. + +If a native client cannot find the session, use “Continue in native Codex” in the session menu to check persistence and the ID, then run the copied `codex resume` command as the same user on the same server. Send a message to persist a new empty session, or rename an older generic session to align labels. Desktop lists may need a refresh or reopening the project. Rollback still replaces only the executable and keeps the current database; V0.9.0 lacks the new model picker and native session menu. + +If FarHelm still holds the native writer, first choose “Release connection and continue” in that dialog. Active work, background terminals, or unsaved sessions prevent handoff; resolve them and retry. They are never terminated by handoff. Close the native client's session connection before sending from the browser again. Existing schedules continue as planned and are not automatically paused by handoff. diff --git a/deploy/README.md b/deploy/README.md index d3c94e7..4e2713f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,4 +1,4 @@ -# FarHelm V0.9.0 部署与生命周期 +# FarHelm V0.10.0 部署与生命周期 [简体中文](README.md) · [English](README.en.md) @@ -154,9 +154,9 @@ farhelm-agent uninstall --keep-data ## 从 V0.2.0 迁移 -已安装 `V0.3.0` 至 `V0.8.0` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.9.0。 +已安装 `V0.3.0` 至 `V0.9.0` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.10.0。 -旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.9.0。 +旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.10.0。 ## 安全说明 @@ -189,3 +189,13 @@ V0.7 的页面通知通过现有 SSE 和持久通知中心工作,无需 VAPID 检查项目旁的设备名称、模型/权限详情、主题设置、缓存会话切换、通知分页与详情,以及创建/取消定时任务后的列表同步。服务器原有 Codex 配置继续生效;旧 Agent 缺少 `codex.session_context` 时需升级后才能通过网页新建原生权限会话。失败重试保留原操作身份。 回退只替换二进制并保留当前数据库、配置和收据;不要恢复旧队列快照。V0.8.0 仍可读取 schema 7,回退后不提供 V0.9.0 的界面与原生权限展示能力。 + +## V0.9.0 → V0.10.0 + +先在 Hub 主机执行 `sudo farhelm-hub update --version V0.10.0`,再以原用户在 Agent 主机执行 `farhelm-agent update --version V0.10.0`,最后刷新网页。新模型选择与原生会话菜单需要两端都完成升级;项目、会话、收据和 schema 7 保持兼容,不需要重新配对。 + +打开会话,点击输入区右下角的模型名称,选择本机 Codex 提供的模型与推理强度,再发送一条指令。选择用于下一轮排队及后续对话,当前运行轮次的补充指令不会更换模型;权限和全局配置保持原样。定时任务沿用执行时的会话设置。 + +如果在原生客户端找不到会话,打开会话菜单的“在原生 Codex 中继续”核对保存状态和 ID,并在同服务器、同用户的终端执行复制的 `codex resume` 命令。新空会话先发送消息,旧通用名称可通过“重命名会话”统一;桌面端可能需要刷新列表或重新打开项目。回退仍只替换二进制并保留当前数据库;V0.9.0 不提供新的模型选择和原生会话菜单。 + +若 FarHelm 仍占用原生写入连接,先在该弹窗点击“释放连接后继续”。存在活动任务、后台终端或未保存会话时会拒绝交接,处理完后可重试;不会终止它们。原生客户端用完后关闭会话连接,再回到网页发送。已有定时任务仍按计划执行,不会因交接自动暂停。 diff --git a/farhelm-agent/src/codex/context.rs b/farhelm-agent/src/codex/context.rs index 602c52e..cc5c1ed 100644 --- a/farhelm-agent/src/codex/context.rs +++ b/farhelm-agent/src/codex/context.rs @@ -7,13 +7,7 @@ pub fn project(value: &Value, previous: Option<&CodexSessionContext>) -> CodexSe if let Some(model) = value.get("model") { context.model = model .as_str() - .filter(|name| { - !name.is_empty() - && name.len() <= 128 - && name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) - }) + .filter(|name| farhelm_protocol::valid_model_id(name)) .map(str::to_owned); } if let Some(effort) = value.get("reasoningEffort") { diff --git a/farhelm-agent/src/codex/handoff.rs b/farhelm-agent/src/codex/handoff.rs new file mode 100644 index 0000000..f5e18c5 --- /dev/null +++ b/farhelm-agent/src/codex/handoff.rs @@ -0,0 +1,67 @@ +//! Explicit idle handoff releases native exclusive writers, without touching other clients. +use super::*; + +impl Codex { + pub async fn activity(&self) -> tokio::sync::RwLockReadGuard<'_, ()> { + self.inner.activity.read().await + } + + pub async fn handoff(&self, session: &str) -> Result { + tokio::time::timeout(Duration::from_secs(15), self.handoff_idle(session)) + .await + .context("codex_handoff_unconfirmed")? + } + + async fn handoff_idle(&self, session: &str) -> Result { + // Never queue an exclusive lock that could block nested read guards. + let _exclusive = self + .inner + .activity + .try_write() + .context("codex_handoff_busy")?; + if !self.inner.loaded.read().await.contains_key(session) { + return Ok(json!({"session_id":session})); + } + let connection = self.connection().await?; + let loaded = connection + .request("thread/loaded/list", json!({"limit":128})) + .await + .context("codex_handoff_unverified")?; + ensure!(loaded["nextCursor"].is_null(), "codex_handoff_unverified"); + let ids = loaded["data"] + .as_array() + .context("codex_handoff_unverified")?; + ensure!(ids.len() <= 128, "codex_handoff_unverified"); + for id in ids { + let id = id.as_str().context("codex_handoff_unverified")?; + let value = connection + .request("thread/read", json!({"threadId":id,"includeTurns":false})) + .await + .context("codex_handoff_unverified")?; + let thread = &value["thread"]; + ensure!(thread["status"]["type"] == "idle", "codex_handoff_busy"); + ensure!( + thread["path"] + .as_str() + .is_some_and(|p| std::path::Path::new(p).is_file()), + "codex_handoff_unsaved" + ); + let terminals = connection + .request( + "thread/backgroundTerminals/list", + json!({"threadId":id,"limit":1}), + ) + .await + .context("codex_handoff_unverified")?; + ensure!( + terminals["data"].as_array().is_some_and(Vec::is_empty) + && terminals["nextCursor"].is_null(), + "codex_handoff_background" + ); + } + connection.close_idle().await?; + self.inner.connection.lock().await.take(); + self.inner.loaded.write().await.clear(); + Ok(json!({"session_id":session})) + } +} diff --git a/farhelm-agent/src/codex/mod.rs b/farhelm-agent/src/codex/mod.rs index b139a80..20e0b16 100644 --- a/farhelm-agent/src/codex/mod.rs +++ b/farhelm-agent/src/codex/mod.rs @@ -1,6 +1,8 @@ //! Local Codex is the transcript/authentication authority. No vendor body is a Hub record. mod context; +mod handoff; pub mod history; +mod models; mod protocol; #[cfg(test)] mod tests; @@ -49,6 +51,8 @@ struct Inner { inflight: Mutex>>, status: watch::Sender, loaded: RwLock>, + models: Mutex>, + activity: RwLock<()>, } struct LoadedThread { thread: Value, @@ -72,6 +76,8 @@ impl Codex { reads: Arc::new(Semaphore::new(4)), inflight: Mutex::new(HashMap::new()), loaded: RwLock::new(HashMap::new()), + models: Mutex::new(None), + activity: RwLock::new(()), status: watch::channel(CodexStatus { state: "starting".into(), version: None, @@ -88,6 +94,7 @@ impl Codex { self.inner.status.subscribe() } pub async fn warm(&self) -> Result<()> { + let _activity = self.activity().await; let connection = self.connection().await?; self.refresh_account(&connection).await } @@ -335,7 +342,14 @@ impl Codex { } pub async fn call(&self, method: &str, params: Value) -> Result { - if !matches!(method, "codex.session.history" | "codex.session.display") { + let _activity = self.activity().await; + if !matches!( + method, + "codex.session.history" + | "codex.session.display" + | "codex.models.list" + | "codex.session.native" + ) { return self.call_inner(method, params).await; } let key = serde_json::to_string(&(method, ¶ms))?; @@ -367,6 +381,70 @@ impl Codex { async fn call_inner(&self, method: &str, params: Value) -> Result { let _permit = self.inner.reads.acquire().await?; match method { + "codex.models.list" => Ok(serde_json::to_value(self.model_list().await?)?), + "codex.session.native" => { + let id = params["session_id"] + .as_str() + .context("session_id_missing")?; + let value = self + .connection() + .await? + .request("thread/read", json!({"threadId":id,"includeTurns":false})) + .await?; + let thread = &value["thread"]; + ensure!(thread["id"] == id, "session_identity_mismatch"); + let persisted = thread["ephemeral"] != true + && thread["path"] + .as_str() + .is_some_and(|p| std::path::Path::new(p).is_file()); + let native_name = thread["name"] + .as_str() + .filter(|s| s.chars().count() <= 128) + .map(history::redact_paths); + Ok(serde_json::to_value( + farhelm_protocol::CodexNativeIdentity { + session_id: id.into(), + persisted, + held_by_agent: self.inner.loaded.read().await.contains_key(id), + native_name, + source: thread["source"] + .as_str() + .filter(|s| { + matches!(*s, "cli" | "vscode" | "exec" | "appServer" | "unknown") + }) + .map(str::to_owned), + history_mode: thread["historyMode"] + .as_str() + .filter(|s| matches!(*s, "legacy" | "paginated")) + .map(str::to_owned), + }, + )?) + } + "codex.session.rename" => { + let id = params["session_id"] + .as_str() + .context("session_id_missing")?; + let name = params["name"].as_str().context("session_name_missing")?; + ensure!( + farhelm_protocol::valid_session_name(name), + "invalid_session_name" + ); + self.connection() + .await? + .request("thread/name/set", json!({"threadId":id,"name":name})) + .await?; + let mut index = self.inner.index.write().await; + index.revision += 1; + let revision = index.revision; + index.changed.insert(id.into(), revision); + if let Some(row) = index.rows.get_mut(id) { + row["name"] = json!(name); + } + if let Some(loaded) = self.inner.loaded.write().await.get_mut(id) { + loaded.thread["name"] = json!(name); + } + Ok(json!({"session_id":id})) + } "codex.projects.discover" => { let rows = self.index(true).await?; let mut projects = BTreeMap::::new(); @@ -555,6 +633,9 @@ impl Codex { return Ok(thread_result(&loaded.thread)); } let mut request = json!({"cwd":params["cwd"]}); + if method == "codex.session.start" { + request["ephemeral"] = json!(false); + } // Resuming belongs to Codex: explicit overrides here would erase the user's // persisted permission profile and approval policy. Legacy explicit creation // still supports inspect/edit; the Console opts into native project defaults. @@ -590,9 +671,6 @@ impl Codex { .await?; let thread = &response["thread"]; ensure!(thread["cwd"] == params["cwd"], "session_project_mismatch"); - if method == "codex.session.start" { - connection.request("thread/name/set",json!({"threadId":thread["id"],"name":history::formal_title(&thread["name"]).unwrap_or_else(||"Codex session".into())})).await?; - } let mut row = thread.clone(); row["archived"] = json!(false); if let Some(id) = row["id"].as_str() { @@ -606,7 +684,11 @@ impl Codex { context: context::project(&response, None), }, ); - self.inner.index.write().await.rows.insert(id, row); + let mut index = self.inner.index.write().await; + index.revision += 1; + let revision = index.revision; + index.changed.insert(id.clone(), revision); + index.rows.insert(id, row); } Ok(thread_result(thread)) } @@ -647,17 +729,59 @@ impl Codex { } } + #[cfg(test)] pub async fn turn( &self, session: &str, prompt: &str, operation: &str, + emit: F, + ) -> Result + where + F: FnMut(&'static str, Value) -> Fut, + Fut: Future>, + { + self.turn_with_model(session, prompt, operation, None, emit) + .await + } + + pub async fn model_list(&self) -> Result { + let mut cached = self.inner.models.lock().await; + if let Some((time, models)) = &*cached + && time.elapsed() < Duration::from_secs(30) + { + return Ok(models.clone()); + } + let connection = self.connection().await?; + let models = tokio::time::timeout(Duration::from_secs(15), models::list(&connection)) + .await + .context("codex_models_timeout")??; + *cached = Some((Instant::now(), models.clone())); + Ok(models) + } + + pub async fn turn_with_model( + &self, + session: &str, + prompt: &str, + operation: &str, + choice: Option<&farhelm_protocol::CodexModelChoice>, mut emit: F, ) -> Result where F: FnMut(&'static str, Value) -> Fut, Fut: Future>, { + let _activity = self.activity().await; + if let Some(choice) = choice { + ensure!(choice.is_valid(), "invalid_model_choice"); + let models = self.model_list().await?; + ensure!( + models.models.iter().any(|m| m.model == choice.model + && m.reasoning_efforts.contains(&choice.reasoning_effort)), + "model_choice_unavailable" + ); + } let connection = self.connection().await?; let mut events = connection.events.subscribe(); if let Some(loaded) = self.inner.loaded.write().await.get_mut(session) { @@ -670,6 +794,8 @@ impl Codex { thread_id: session, input: [UserInput::Text { text: prompt }], client_user_message_id: operation, + model: choice.map(|c| c.model.as_str()), + effort: choice.map(|c| c.reasoning_effort.as_str()), })?, ) .await diff --git a/farhelm-agent/src/codex/models.rs b/farhelm-agent/src/codex/models.rs new file mode 100644 index 0000000..27df55f --- /dev/null +++ b/farhelm-agent/src/codex/models.rs @@ -0,0 +1,73 @@ +//! A bounded projection of the configured native provider's model picker. +use super::*; +use farhelm_protocol::{CodexModelList, CodexModelOption, valid_model_id, valid_reasoning_effort}; + +pub(super) async fn list(connection: &Connection) -> Result { + let mut cursor = Value::Null; + let mut seen = std::collections::HashSet::new(); + let mut models = BTreeMap::new(); + for _ in 0..16 { + let page = connection + .request( + "model/list", + json!({"limit":100,"cursor":cursor,"includeHidden":false}), + ) + .await?; + for row in page["data"].as_array().context("codex_models_invalid")? { + if row["hidden"] == true { + continue; + } + let Some(model) = row["model"].as_str().filter(|m| valid_model_id(m)) else { + continue; + }; + let efforts: Vec = row["supportedReasoningEfforts"] + .as_array() + .into_iter() + .flatten() + .filter_map(|r| { + r["reasoningEffort"] + .as_str() + .filter(|e| valid_reasoning_effort(e)) + .map(str::to_owned) + }) + .collect(); + let Some(default) = row["defaultReasoningEffort"] + .as_str() + .filter(|e| efforts.iter().any(|v| v == e)) + else { + continue; + }; + let display = row["displayName"] + .as_str() + .filter(|s| { + !s.is_empty() + && s.chars().count() <= 128 + && !s.chars().any(char::is_control) + && !s.contains(['/', '\\']) + }) + .unwrap_or(model); + models.insert( + model.to_owned(), + CodexModelOption { + model: model.into(), + display_name: display.into(), + reasoning_efforts: efforts, + default_reasoning_effort: default.into(), + is_default: row["isDefault"] == true, + }, + ); + ensure!(models.len() <= 512, "codex_models_capacity"); + } + cursor = page["nextCursor"].clone(); + if cursor.is_null() { + return Ok(CodexModelList { + models: models.into_values().collect(), + }); + } + ensure!( + cursor.as_str().is_some_and(|s| s.len() <= 512) && seen.insert(cursor.to_string()), + "codex_models_cursor_invalid" + ); + } + bail!("codex_models_capacity") +} diff --git a/farhelm-agent/src/codex/protocol.rs b/farhelm-agent/src/codex/protocol.rs index 777f101..39fb167 100644 --- a/farhelm-agent/src/codex/protocol.rs +++ b/farhelm-agent/src/codex/protocol.rs @@ -72,6 +72,10 @@ pub struct TurnStartParams<'a> { pub thread_id: &'a str, pub input: [UserInput<'a>; 1], pub client_user_message_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option<&'a str>, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/farhelm-agent/src/codex/tests.rs b/farhelm-agent/src/codex/tests.rs index ad0a557..33933db 100644 --- a/farhelm-agent/src/codex/tests.rs +++ b/farhelm-agent/src/codex/tests.rs @@ -129,6 +129,11 @@ async fn native_resume_inherits_settings_and_creation_retains_explicit_legacy_mo .filter(|v| v["method"] == "thread/start") .collect::>(); assert_eq!(starts.len(), 3); + assert!(starts.iter().all(|v| v["params"]["ephemeral"] == false)); + assert!( + recorded.iter().all(|v| v["method"] != "thread/name/set"), + "new native sessions must not acquire a generic forced name" + ); assert!(starts[0]["params"].get("sandbox").is_none()); assert!(starts[0]["params"].get("approvalPolicy").is_none()); assert_eq!(starts[1]["params"]["sandbox"], "read-only"); @@ -137,6 +142,133 @@ async fn native_resume_inherits_settings_and_creation_retains_explicit_legacy_mo } } +#[tokio::test] +async fn model_choices_follow_native_catalog_and_never_override_permissions() { + let directory = tempfile::tempdir().unwrap(); + let bin = fixture(directory.path(), "0.153.4"); + let record = directory.path().join("model-requests"); + let script = std::fs::read_to_string(&bin).unwrap() + .replace(" [ -n \"$id\" ] || continue", &format!(" [ -n \"$id\" ] || continue\n printf '%s\\n' \"$line\" >> '{}'", record.display())) + .replace(" *) result='{}' ;;", r#" *'"method":"model/list"'*) + case "$line" in + *'"cursor":"second"'*) result='{"data":[{"model":"local-fast","displayName":"Fast","supportedReasoningEfforts":[{"reasoningEffort":"low"}],"defaultReasoningEffort":"low","hidden":false}],"nextCursor":null}' ;; + *) result='{"data":[{"model":"local-deep","displayName":"Deep","supportedReasoningEfforts":[{"reasoningEffort":"medium"},{"reasoningEffort":"high"}],"defaultReasoningEffort":"medium","isDefault":true},{"model":"hidden","hidden":true},{"model":"/private/model"}],"nextCursor":"second"}' ;; + esac ;; + *) result='{}' ;;"#); + transport::write_fixture_executable(&bin, &script); + let native = Codex::new(Some(bin)); + native + .call( + "codex.session.start", + json!({"cwd":"/tmp/project","mode":"inspect","inherit_permissions":true}), + ) + .await + .unwrap(); + let catalog = native.call("codex.models.list", json!({})).await.unwrap(); + assert_eq!(catalog["models"].as_array().unwrap().len(), 2); + let good = farhelm_protocol::CodexModelChoice { + model: "local-deep".into(), + reasoning_effort: "high".into(), + }; + native + .turn_with_model("new", "hello", "chosen", Some(&good), |_, _| async { + Ok(()) + }) + .await + .unwrap(); + for invalid in [ + farhelm_protocol::CodexModelChoice { + model: "local-fast".into(), + reasoning_effort: "high".into(), + }, + farhelm_protocol::CodexModelChoice { + model: "missing".into(), + reasoning_effort: "low".into(), + }, + ] { + assert!( + native + .turn_with_model("new", "hello", "invalid", Some(&invalid), |_, _| async { + Ok(()) + }) + .await + .unwrap_err() + .to_string() + .contains("model_choice_unavailable") + ); + } + native.shutdown().await; + let requests: Vec = std::fs::read_to_string(record) + .unwrap() + .lines() + .map(|v| serde_json::from_str(v).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .filter(|v| v["method"] == "model/list") + .count(), + 2, + "cached catalog must avoid redundant native reads" + ); + let turns: Vec<_> = requests + .iter() + .filter(|v| v["method"] == "turn/start") + .collect(); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0]["params"]["model"], "local-deep"); + assert_eq!(turns[0]["params"]["effort"], "high"); + assert!(turns[0]["params"].get("sandboxPolicy").is_none()); + assert!(turns[0]["params"].get("approvalPolicy").is_none()); +} + +#[tokio::test] +async fn native_rename_updates_index_and_rejects_invalid_names() { + let directory = tempfile::tempdir().unwrap(); + let native = Codex::new(Some(fixture(directory.path(), "0.153.4"))); + native + .call( + "codex.sessions.list", + json!({"project_path":"/tmp/project","archived":"all"}), + ) + .await + .unwrap(); + native + .call( + "codex.session.rename", + json!({"session_id":"s","name":"可在原生客户端找到的名称"}), + ) + .await + .unwrap(); + let listing = native + .call( + "codex.sessions.list", + json!({"project_path":"/tmp/project","archived":"all"}), + ) + .await + .unwrap(); + assert_eq!(listing["sessions"][0]["title"], "可在原生客户端找到的名称"); + assert!( + native + .call( + "codex.session.rename", + json!({"session_id":"s","name":"Codex session"}) + ) + .await + .is_err() + ); + assert!( + native + .call( + "codex.session.rename", + json!({"session_id":"s","name":"/private/path"}) + ) + .await + .is_err() + ); + native.shutdown().await; +} + #[tokio::test] async fn slow_optional_settings_do_not_hold_history_for_the_native_rpc_timeout() { let directory = tempfile::tempdir().unwrap(); @@ -523,3 +655,101 @@ async fn loaded_empty_thread_can_send_and_interleaved_items_keep_offsets_before_ ); native.shutdown().await; } + +#[tokio::test] +async fn handoff_refuses_active_unsaved_and_background_work_then_closes_stdio() { + let directory = tempfile::tempdir().unwrap(); + let bin = directory.path().join("codex"); + let row_path = directory.path().join("row"); + let terminal_path = directory.path().join("terminals"); + let rollout = directory.path().join("rollout"); + let closed = directory.path().join("closed"); + let mut row = json!({"thread":{"id":"s","cwd":"/tmp/project","path":rollout,"status":{"type":"idle"},"turns":[]}}); + std::fs::write(&row_path, row.to_string()).unwrap(); + std::fs::write(&terminal_path, r#"{"data":[],"nextCursor":null}"#).unwrap(); + transport::write_fixture_executable( + &bin, + &r#"#!/bin/sh +if [ "$1" = '--version' ]; then printf 'codex-cli 0.153.4\n'; exit 0; fi +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + [ -n "$id" ] || continue + case "$line" in + *'"method":"initialize"'*) result='{"userAgent":"fixture"}' ;; + *'"method":"account/read"'*) result='{"requiresOpenaiAuth":false,"account":null}' ;; + *'"method":"thread/start"'*|*'"method":"thread/read"'*) result=$(cat 'ROW') ;; + *'"method":"thread/loaded/list"'*) result='{"data":["s"],"nextCursor":null}' ;; + *'"method":"thread/backgroundTerminals/list"'*) result=$(cat 'TERMINALS') ;; + *) result='{}' ;; + esac + printf '{"id":%s,"result":%s}\n' "$id" "$result" +done +printf 'closed\n' >> 'CLOSED' +"# + .replace("ROW", row_path.to_str().unwrap()) + .replace("TERMINALS", terminal_path.to_str().unwrap()) + .replace("CLOSED", closed.to_str().unwrap()), + ); + let native = Codex::new(Some(bin)); + native + .call( + "codex.session.start", + json!({"cwd":"/tmp/project","mode":"inspect"}), + ) + .await + .unwrap(); + let connection = native.connection().await.unwrap(); + let active = native.activity().await; + assert!( + native + .handoff("s") + .await + .unwrap_err() + .to_string() + .contains("codex_handoff_busy") + ); + drop(active); + assert!( + native + .handoff("s") + .await + .unwrap_err() + .to_string() + .contains("codex_handoff_unsaved") + ); + std::fs::write(&rollout, "synthetic").unwrap(); + row["thread"]["status"]["type"] = json!("active"); + std::fs::write(&row_path, row.to_string()).unwrap(); + assert!( + native + .handoff("s") + .await + .unwrap_err() + .to_string() + .contains("codex_handoff_busy") + ); + row["thread"]["status"]["type"] = json!("idle"); + std::fs::write(&row_path, row.to_string()).unwrap(); + std::fs::write( + &terminal_path, + r#"{"data":[{"command":"PRIVATE_COMMAND","cwd":"PRIVATE_PATH"}],"nextCursor":null}"#, + ) + .unwrap(); + let error = native.handoff("s").await.unwrap_err().to_string(); + assert_eq!(error, "codex_handoff_background"); + assert!(connection.alive.load(Ordering::Acquire)); + assert!(!closed.exists()); + std::fs::write(&terminal_path, r#"{"data":[],"nextCursor":null}"#).unwrap(); + assert_eq!( + native.handoff("s").await.unwrap(), + json!({"session_id":"s"}) + ); + assert!(!connection.alive.load(Ordering::Acquire)); + assert_eq!(std::fs::read_to_string(closed).unwrap(), "closed\n"); + // A duplicate handoff cannot restart a process or acquire a fresh native writer. + assert_eq!( + native.handoff("s").await.unwrap(), + json!({"session_id":"s"}) + ); + assert!(native.inner.connection.lock().await.is_none()); +} diff --git a/farhelm-agent/src/codex/transport.rs b/farhelm-agent/src/codex/transport.rs index 6e9faa3..2ae41b6 100644 --- a/farhelm-agent/src/codex/transport.rs +++ b/farhelm-agent/src/codex/transport.rs @@ -22,7 +22,7 @@ type Reply = std::result::Result; type Pending = Arc>>>; pub struct Connection { - stdin: Arc>, + stdin: Arc>>, pending: Pending, next_id: AtomicU64, pub alive: Arc, @@ -42,9 +42,9 @@ impl Connection { .kill_on_drop(true) .spawn() .context("codex_start_failed")?; - let stdin = Arc::new(AsyncMutex::new( + let stdin = Arc::new(AsyncMutex::new(Some( child.stdin.take().context("codex_stdin_missing")?, - )); + ))); let stdout = child.stdout.take().context("codex_stdout_missing")?; let pending: Pending = Arc::default(); let alive = Arc::new(AtomicBool::new(true)); @@ -113,7 +113,14 @@ impl Connection { if let Some(sender) = sender { let result = if value.get("error").is_some() { // Vendor error text may contain prompts, paths or credentials. - Err(format!("codex_request_rejected:{}", value["error"]["code"])) + if value["error"]["message"] + .as_str() + .is_some_and(|s| s.contains("already has an active writer")) + { + Err("codex_session_in_use".into()) + } else { + Err(format!("codex_request_rejected:{}", value["error"]["code"])) + } } else { Ok(value["result"].clone()) }; @@ -202,6 +209,18 @@ impl Connection { let _ = child.wait().await; self.reader.abort(); } + + /// Only called after the owner verifies that every loaded thread is idle. + /// Closing stdin lets Codex flush and release its writers without a forced kill. + pub async fn close_idle(&self) -> Result<()> { + self.stdin.lock().await.take(); + tokio::time::timeout(Duration::from_secs(5), self.child.lock().await.wait()) + .await + .context("codex_handoff_unconfirmed")??; + self.alive.store(false, Ordering::Release); + self.reader.abort(); + Ok(()) + } } impl Drop for Connection { @@ -221,13 +240,15 @@ impl Drop for PendingGuard { } } -async fn write_json(stdin: &AsyncMutex, value: &Value) -> Result<()> { +async fn write_json(stdin: &AsyncMutex>, value: &Value) -> Result<()> { let mut bytes = serde_json::to_vec(value)?; ensure!(bytes.len() as u64 <= MAX_FRAME, "codex_request_too_large"); bytes.push(b'\n'); stdin .lock() .await + .as_mut() + .context("codex_connection_closed")? .write_all(&bytes) .await .context("codex_write_failed") diff --git a/farhelm-agent/src/main.rs b/farhelm-agent/src/main.rs index fd88315..2ceb90a 100644 --- a/farhelm-agent/src/main.rs +++ b/farhelm-agent/src/main.rs @@ -1435,8 +1435,19 @@ async fn execute_remote_command( } else { "codex.turn.failed" }; - let error_detail = - "Codex operation failed; inspect the local Agent diagnostics".to_owned(); + let error_detail = [ + "codex_session_in_use", + "model_choice_unavailable", + "codex_handoff_busy", + "codex_handoff_unsaved", + "codex_handoff_background", + "codex_handoff_unconfirmed", + "codex_handoff_unverified", + ] + .into_iter() + .find(|code| error.chain().any(|e| e.to_string().contains(code))) + .unwrap_or("Codex operation failed; inspect the local Agent diagnostics") + .to_owned(); let status = if event_type == "codex.turn.orphaned" { "orphaned" } else { @@ -1479,6 +1490,13 @@ async fn execute_remote_command_inner( worker_runtime: &CodexRuntime, command: &RemoteCommand, ) -> Result { + let handoff = + command.action == CommandAction::CodexSessionResume && command.payload["handoff"] == true; + let _activity = if handoff { + None + } else { + Some(worker_runtime.codex.activity().await) + }; if command.action == CommandAction::ProjectApprove { let candidate_ids = command .payload @@ -1530,6 +1548,37 @@ async fn execute_remote_command_inner( .and_then(serde_json::Value::as_str) .unwrap_or("inspect"); match command.action { + CommandAction::CodexSessionResume if handoff => { + let session_id = command.payload["session_id"] + .as_str() + .context("handoff omitted session")?; + let binding = store + .session_binding(session_id)? + .context("handoff references unknown session")?; + ensure!(binding.project_id == project_id, "session_project_mismatch"); + worker_runtime.codex.handoff(session_id).await + } + CommandAction::CodexSessionResume if command.payload.get("rename_to").is_some() => { + let session_id = command.payload["session_id"] + .as_str() + .context("rename omitted session")?; + let name = command.payload["rename_to"] + .as_str() + .context("rename omitted name")?; + let binding = store + .session_binding(session_id)? + .context("rename references unknown session")?; + ensure!(binding.project_id == project_id, "session_project_mismatch"); + let result = worker_runtime + .codex + .call( + "codex.session.rename", + serde_json::json!({"session_id":session_id,"name":name}), + ) + .await?; + store.enqueue_event(&format!("{}:rename",command.command_id), "codex.session.updated", &serde_json::json!({"session_id":session_id,"project_id":project_id,"mode":binding.mode,"state":"idle","title":name,"active_turn_id":null,"update_kind":"metadata","updated_at_unix":unix_time()}), unix_time())?; + Ok(result) + } CommandAction::CodexSessionCreate | CommandAction::CodexSessionResume => { let method = if command.action == CommandAction::CodexSessionCreate { "codex.session.start" @@ -1607,7 +1656,13 @@ async fn execute_remote_command_inner( prompt, idempotency_key: command.command_id.clone(), }; - let (_, turn_id) = run_auto_prompt_inner(store, worker_runtime, &job).await?; + let choice = command + .payload + .get("model_choice") + .map(|v| serde_json::from_value::(v.clone())) + .transpose()?; + let (_, turn_id) = + run_auto_prompt_inner(store, worker_runtime, &job, choice.as_ref()).await?; Ok(serde_json::json!({"session_id":session_id,"turn_id":turn_id})) } CommandAction::CodexTurnSteer | CommandAction::CodexTurnInterrupt => { @@ -1659,7 +1714,7 @@ async fn run_scheduled_prompt( prompt: prompt.prompt, idempotency_key: prompt.schedule_id.clone(), }; - run_auto_prompt_inner(&store, worker, &job).await + run_auto_prompt_inner(&store, worker, &job, None).await } .await; let state = match &result { @@ -1844,7 +1899,7 @@ async fn run_auto_prompt( job: AutoPrompt, ) -> Result<()> { let store = ExperimentStore::open_async(database).await?; - let result = run_auto_prompt_inner(&store, worker_runtime, &job).await; + let result = run_auto_prompt_inner(&store, worker_runtime, &job, None).await; let now = unix_time(); match result { Ok((session_id, turn_id)) => store.finish_auto_prompt( @@ -1881,7 +1936,9 @@ async fn run_auto_prompt_inner( store: &ExperimentStore, worker_runtime: &CodexRuntime, job: &AutoPrompt, + model_choice: Option<&farhelm_protocol::CodexModelChoice>, ) -> Result<(String, String)> { + let _activity = worker_runtime.codex.activity().await; let (cwd, mode) = if let Some(session_id) = &job.session_id { if let Some(binding) = store.session_binding(session_id)? { ensure!( @@ -1942,7 +1999,7 @@ async fn run_auto_prompt_inner( } enqueue_session_state(store, job, &session_id, &mode, "queued", "session-ready")?; let sequence = std::sync::atomic::AtomicU64::new(0); - let result = worker_runtime.codex.turn(&session_id, &job.prompt, &job.idempotency_key, |event_type, data| { + let result = worker_runtime.codex.turn_with_model(&session_id, &job.prompt, &job.idempotency_key, model_choice, |event_type, data| { let event_number = sequence.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let payload = serde_json::json!({"operation_id":job.watch_id,"watch_id":job.watch_id,"project_id":job.project_id,"session_id":session_id,"data":data}); let session_id = &session_id; @@ -2049,7 +2106,10 @@ async fn read_request( store: &ExperimentStore, request: farhelm_protocol::AgentReadRequest, ) -> Result { - let outcome = if request.method == "codex.session.history" { + let outcome = if matches!( + request.method.as_str(), + "codex.session.history" | "codex.models.list" | "codex.session.native" + ) { let session_id = request .params .get("session_id") @@ -2069,9 +2129,16 @@ async fn read_request( .codex .call(&request.method, request.params) .await - .and_then(|value| { - let page: farhelm_protocol::CodexTranscriptPage = serde_json::from_value(value)?; - Ok(serde_json::to_value(page)?) + .and_then(|value| match request.method.as_str() { + "codex.models.list" => Ok(serde_json::to_value(serde_json::from_value::< + farhelm_protocol::CodexModelList, + >(value)?)?), + "codex.session.native" => Ok(serde_json::to_value(serde_json::from_value::< + farhelm_protocol::CodexNativeIdentity, + >(value)?)?), + _ => Ok(serde_json::to_value(serde_json::from_value::< + farhelm_protocol::CodexTranscriptPage, + >(value)?)?), }) } else if request.method == "codex.session.display" { let ids: Option> = request diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index e97ce4f..9a93e68 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -128,7 +128,7 @@ test('compact navigation preserves every destination and aligns the reading colu const nav = page.getByRole('navigation', { name: '系统导航' }) for (const name of ['总览', 'Agent', '实验', 'Codex', '通知', '审计', '设置']) await expect(nav.getByRole('menuitem', { name, exact: true })).toBeVisible() expect((await page.locator('.app-sider').boundingBox())!.width).toBe(88) - expect((await page.locator('.codex-desktop-rail').boundingBox())!.width).toBe(280) + expect((await page.locator('.codex-desktop-rail').boundingBox())!.width).toBe(300) const heading = await page.locator('.conversation-title h1').boundingBox() const body = await page.locator('.codex-transcript').boundingBox() expect(Math.abs(heading!.x - body!.x)).toBeLessThan(2) diff --git a/farhelm-console/e2e/composer.spec.ts b/farhelm-console/e2e/composer.spec.ts index b198620..71b2532 100644 --- a/farhelm-console/e2e/composer.spec.ts +++ b/farhelm-console/e2e/composer.spec.ts @@ -2,7 +2,7 @@ import AxeBuilder from '@axe-core/playwright' import { expect, test, type Page } from '@playwright/test' import type { SessionContext } from '../src/api/features' -async function setup(page: Page, modern = true) { +export async function setup(page: Page, modern = true) { const context: SessionContext = { model: 'gpt-5.4', reasoning_effort: 'high', sandbox: 'danger-full-access', approval_policy: 'never' } const state = { context: modern ? context : undefined, reads: 0, creations: [] as { agent_id: string; mode: string; inherit_permissions?: boolean }[] } const session = { session_id: 's', agent_id: 'a', project_id: 'p', title: '会话设置验收', mode: 'inspect', state: 'idle', updated_at_unix: 1 } @@ -14,8 +14,9 @@ async function setup(page: Page, modern = true) { await page.route('**/api/v1/**', async route => { const path = new URL(route.request().url()).pathname if (path.endsWith('/auth/session')) return route.fulfill({ json: { authenticated: true, user: 'admin', csrf_token: 'test', expires_at_unix: 2000000000 } }) - if (path.endsWith('/agents')) return route.fulfill({ json: { protocol: 'farhelm/1', agents: ['a', 'b'].map(id => ({ agent_id: id, hostname: id, agent_version: '0.8.0', last_seen_unix: 1, online: true, credential_state: 'paired', capabilities: modern ? ['codex.session_context'] : [] })) } }) + if (path.endsWith('/agents')) return route.fulfill({ json: { protocol: 'farhelm/1', agents: ['a', 'b'].map(id => ({ agent_id: id, hostname: id, agent_version: '0.8.0', last_seen_unix: 1, online: true, credential_state: 'paired', capabilities: modern ? ['codex.session_context', 'codex.model_choice', 'codex.native_identity'] : [] })) } }) if (path.endsWith('/projects')) return route.fulfill({ json: { protocol: 'farhelm/1', projects: ['a', 'b'].map(id => ({ agent_id: id, candidate_id: 'same-candidate', suggested_project_id: 'p', display_name: '项目', state: 'approved' })) } }) + if (path.endsWith('/models')) return route.fulfill({ json: { models: [{ model: 'gpt-5.4', display_name: 'GPT-5.4', reasoning_efforts: ['medium', 'high'], default_reasoning_effort: 'medium', is_default: true }, { model: 'local-fast', display_name: 'Local Fast', reasoning_efforts: ['low'], default_reasoning_effort: 'low', is_default: false }] } }) if (path.endsWith('/transcript')) { state.reads++; return route.fulfill({ json: { session_id: 's', turns: [{ turn_id: 't', status: 'completed', items: [{ item_id: 'i', kind: 'assistant_message', text: '这是合成验收内容。' }] }], context: state.context } }) } if (path.endsWith('/codex/sessions') && route.request().method() === 'POST') { state.creations.push(route.request().postDataJSON()); return route.fulfill({ json: { state: 'completed', data: { session_id: 's' } } }) } if (path.endsWith('/codex/sessions') || path.endsWith('/session-display')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: [session], incomplete_agents: [] } }) @@ -58,11 +59,11 @@ test('native model and permissions remain session facts, with readable long and await page.getByRole('button', { name: '刷新对话' }).click() const model = page.getByRole('button', { name: /会话模型:custom-model-/ }); await expect(model).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) - await model.click(); await expect(page.locator('.session-settings-details:visible dd').first()).toHaveText(state.context.model!) + await model.click(); await expect(page.getByText(`当前模型 ${state.context.model} 不在可用目录中,仍可沿用原设置。`)).toBeVisible() await page.keyboard.press('Escape'); await page.getByRole('button', { name: '会话权限:可编辑' }).click() await expect(page.getByText('网页暂不支持人工审批;需要你批准的操作会被拒绝,不会自动放行。')).toBeVisible() await page.keyboard.press('Escape'); state.context = {}; await page.getByRole('button', { name: '刷新对话' }).click() - await expect(page.getByRole('button', { name: '会话模型:模型未提供' })).toBeVisible() + await expect(page.getByRole('button', { name: '会话模型:选择模型' })).toBeVisible() await expect(page.getByRole('button', { name: '会话权限:跟随 Codex' })).toBeVisible() await page.getByLabel('给 Codex 发送指令').fill('保留草稿') state.context = { model: 'gpt-5.4', sandbox: 'read-only', approval_policy: 'on-request' } @@ -97,3 +98,118 @@ test('older Agents are not represented as inheriting native permissions', async await expect(dialog.getByRole('button', { name: '创建会话', exact: true })).toBeDisabled() expect(state.creations).toEqual([]) }) + +test('model choices use native options, remain per session and freeze retry settings', async ({ page }) => { + await setup(page) + const requests: { body: unknown; identity: string | undefined }[] = [] + await page.route('**/codex/sessions/s/messages', async route => { + requests.push({ body: route.request().postDataJSON(), identity: route.request().headers()['idempotency-key'] }) + if (requests.length === 1) return route.fulfill({ status: 503, json: { error: 'agent_save_unconfirmed' } }) + return route.fulfill({ json: { state: 'accepted' } }) + }) + await page.goto('/codex?session=s') + await page.getByRole('button', { name: /会话模型:gpt-5.4/ }).click() + await page.getByRole('combobox', { name: '选择模型', exact: true }).click() + await page.getByTitle('Local Fast', { exact: true }).click() + await expect(page.getByRole('combobox', { name: '选择推理强度' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('button', { name: '会话模型:local-fast,推理强度低' })).toBeVisible() + await page.getByLabel('给 Codex 发送指令').fill('使用选中的模型') + await page.getByRole('button', { name: '发送指令' }).click() + await expect(page.getByText('尚未确认 Agent 保存,重试会核对同一次操作')).toBeVisible() + await page.getByRole('button', { name: /会话模型:local-fast/ }).click() + await page.getByRole('combobox', { name: '选择模型', exact: true }).click() + await page.getByTitle('GPT-5.4', { exact: true }).click() + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '发送指令' }).click() + await expect.poll(() => requests.length).toBe(2) + expect(requests[0].body).toEqual({ prompt: '使用选中的模型', delivery: 'queue', model_choice: { model: 'local-fast', reasoning_effort: 'low' } }) + expect(requests[1]).toEqual(requests[0]) +}) + +test('native identity exposes the same thread and renames only after server completion', async ({ page }) => { + await setup(page) + const names: string[] = [] + await page.route('**/codex/sessions/s/native', route => route.fulfill({ json: { session_id: 's', persisted: true, native_name: names.at(-1) ?? 'Codex session', source: 'vscode', history_mode: 'paginated' } })) + await page.route('**/codex/sessions/s/name', async route => { names.push(route.request().postDataJSON().name); await new Promise(r => setTimeout(r, 250)); return route.fulfill({ json: { state: 'completed', data: { session_id: 's' } } }) }) + await page.goto('/codex?session=s') + await page.getByRole('button', { name: '会话操作' }).click() + await page.getByRole('menuitem', { name: '在原生 Codex 中继续' }).click() + const dialog = page.getByRole('dialog', { name: '在原生 Codex 中继续' }) + await expect(dialog.getByText('会话已保存在服务器 Codex 中')).toBeVisible() + await expect(dialog.locator('code')).toHaveText('codex resume s') + await dialog.getByRole('button', { name: '重命名会话' }).click() + const rename = page.getByRole('dialog', { name: '重命名会话' }) + await rename.getByLabel('会话名称').fill('跨端统一名称') + await rename.getByRole('button', { name: '保存到 Codex' }).click() + await expect(rename).toBeHidden() + expect(names).toEqual(['跨端统一名称']) +}) + +test('composer has one surface and readable text without an inner focus frame', async ({ page }, info) => { + await setup(page) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }); await page.goto('/codex?session=s') + await page.getByLabel('给 Codex 发送指令').fill('中文输入与布局检查') + await page.getByLabel('给 Codex 发送指令').focus() + const values = await page.evaluate(() => { + const style = (selector: string) => getComputedStyle(document.querySelector(selector)!) + const input = style('.composer textarea') + return { frame: style('.composer').backgroundColor, tools: [...document.querySelectorAll('.composer-control')].map(n => getComputedStyle(n).backgroundColor), border: input.borderTopWidth, outline: input.outlineStyle, body: style('.message-body').fontSize, list: style('.session-copy > span').fontSize, bottom: document.querySelector('.composer-wrap')!.getBoundingClientRect().bottom, height: innerHeight } + }) + expect(values.tools.every(c => c === 'rgba(0, 0, 0, 0)')).toBe(true) + expect(values.border).toBe('0px'); expect(values.outline).toBe('none') + expect(parseFloat(values.body)).toBeGreaterThanOrEqual(info.project.name === 'mobile' ? 16 : 17) + expect(parseFloat(values.list)).toBeGreaterThanOrEqual(15) + expect(values.bottom).toBeLessThanOrEqual(values.height) + } +}) + +test('native handoff reports busy safely and a deliberate retry uses a new operation', async ({ page }) => { + await setup(page) + let held = true + const identities: string[] = [] + await page.route('**/codex/sessions/s/native', route => route.fulfill({ json: { session_id: 's', persisted: true, held_by_agent: held, native_name: '同一条原生会话' } })) + await page.route('**/codex/sessions/s/handoff', route => { + identities.push(route.request().headers()['idempotency-key']) + return route.fulfill({ json: { command_id: `handoff-${identities.length}`, state: 'accepted' } }) + }) + await page.route('**/commands/handoff-*', route => { + const failed = route.request().url().endsWith('handoff-1') + if (!failed) held = false + return route.fulfill({ json: { state: failed ? 'failed' : 'completed', detail: failed ? 'codex_handoff_busy' : undefined, data: { session_id: 's' } } }) + }) + await page.goto('/codex?session=s') + await page.getByRole('button', { name: '会话操作' }).click() + await page.getByRole('menuitem', { name: '在原生 Codex 中继续' }).click() + const dialog = page.getByRole('dialog', { name: '在原生 Codex 中继续' }) + await dialog.getByRole('button', { name: '释放连接后继续' }).click() + await expect(dialog.getByText('Agent 正在读取或执行对话,请结束活动任务后再交接')).toBeVisible() + expect(held).toBe(true) + await dialog.getByRole('button', { name: '释放连接后继续' }).click() + await expect(dialog.getByText('已释放 FarHelm 的空闲连接,可以在原生客户端继续。')).toBeVisible() + await expect(dialog.getByRole('button', { name: '释放连接后继续' })).toHaveCount(0) + expect(new Set(identities).size).toBe(2) +}) + +test('model drafts belong to the selected session through rapid navigation', async ({ page }) => { + await setup(page) + const sessions = ['s', 'other'].map(id => ({ session_id: id, agent_id: 'a', project_id: 'p', title: id === 's' ? '会话设置验收' : '另一模型会话', mode: 'inspect', state: 'idle', updated_at_unix: 1 })) + await page.route('**/codex/sessions?*', route => route.fulfill({ json: { protocol: 'farhelm/1', sessions } })) + await page.route('**/session-display', route => route.fulfill({ json: { protocol: 'farhelm/1', sessions, incomplete_agents: [] } })) + await page.route('**/codex/sessions/other', route => route.fulfill({ json: sessions[1] })) + await page.route('**/codex/sessions/other/transcript*', route => route.fulfill({ json: { session_id: 'other', turns: [], context: { model: 'gpt-5.4', reasoning_effort: 'high' } } })) + await page.goto('/codex?session=s') + await page.getByRole('button', { name: /会话模型:/ }).click() + await page.getByRole('combobox', { name: '选择模型', exact: true }).click() + await page.getByTitle('Local Fast', { exact: true }).click() + await page.keyboard.press('Escape') + const choose = async (name: string) => { + if (page.viewportSize()!.width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + await page.getByRole('button', { name, exact: true }).filter({ visible: true }).click() + } + await choose('另一模型会话') + await expect(page.getByRole('button', { name: /会话模型:gpt-5.4/ })).toBeVisible() + await choose('会话设置验收') + await expect(page.getByRole('button', { name: /会话模型:local-fast/ })).toBeVisible() +}) diff --git a/farhelm-console/package.json b/farhelm-console/package.json index 04b9087..dd9ed98 100644 --- a/farhelm-console/package.json +++ b/farhelm-console/package.json @@ -1,7 +1,7 @@ { "name": "farhelm-console", "private": true, - "version": "0.9.0", + "version": "0.10.0", "type": "module", "packageManager": "pnpm@10.17.1", "engines": { diff --git a/farhelm-console/src/api/features.ts b/farhelm-console/src/api/features.ts index 6024def..720c1a0 100644 --- a/farhelm-console/src/api/features.ts +++ b/farhelm-console/src/api/features.ts @@ -9,6 +9,12 @@ export type CodexSession = { session_id: string; agent_id: string; project_id: s export type TranscriptItem = { item_id: string; kind: 'user_message' | 'assistant_message' | 'command_summary' | 'file_change_summary' | 'error'; text: string; text_offset?: number; text_complete?: boolean; status?: string; exit_code?: number; duration_ms?: number; streaming?: boolean } export type TranscriptTurn = { turn_id: string; status: string; started_at_unix?: number; completed_at_unix?: number; items: TranscriptItem[] } export type SessionContext = { model?: string; reasoning_effort?: string; sandbox?: string; approval_policy?: string; approvals_reviewer?: string } +export type ModelChoice = { model: string; reasoning_effort: string } +export type ModelOption = { model: string; display_name: string; reasoning_efforts: string[]; default_reasoning_effort: string; is_default: boolean } +export type NativeIdentity = { session_id: string; persisted: boolean; held_by_agent?: boolean; native_name?: string; source?: string; history_mode?: string } +export function fetchModels(sessionId: string, signal?: AbortSignal) { return json<{ models: ModelOption[] }>(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/models`, signal) } +export function fetchNativeIdentity(sessionId: string, signal?: AbortSignal) { return json(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/native`, signal) } +export function renameSession(csrf: string, sessionId: string, name: string) { return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/name`, csrf, { name }) } export type TranscriptPage = { older_loaded?: boolean; protocol?: string; session_id: string; turns: TranscriptTurn[]; context?: SessionContext; next_cursor?: string; continuation?: { kind: 'message' | 'history'; turn_id: string; item_id: string; text_offset: number } } export type DisplayPage = { sessions: CodexSession[]; next_cursor?: string; incomplete_agents: { agent_id: string; reason: string }[] } export async function fetchSessionDisplay(csrf: string, request: { mode: 'labels' | 'search'; session_ids?: string[]; query?: string; agent_id?: string; project_id?: string; archived?: 'false' | 'true' | 'all'; cursor?: string }, signal?: AbortSignal): Promise { @@ -68,14 +74,24 @@ const pendingOperations = new Map() const savedReceipts = new Map() let receiptGeneration = 0 export function clearOperationReceipts() { pendingOperations.clear(); savedReceipts.clear(); receiptGeneration++ } -export type Operation = { command_id?: string; state?: string; status_url?: string; data?: { turn_id?: string; session_id?: string }; result?: { turn_id?: string; session_id?: string } } +export type Operation = { command_id?: string; state?: string; detail?: string; status_url?: string; data?: { turn_id?: string; session_id?: string }; result?: { turn_id?: string; session_id?: string } } +const codexErrors: Record = { + codex_session_in_use: '会话正由其他 Codex 客户端占用,请关闭那边的会话连接后再发送', + model_choice_unavailable: '所选模型或推理强度已不可用,请重新选择', + codex_handoff_busy: 'Agent 正在读取或执行对话,请结束活动任务后再交接', + codex_handoff_unsaved: 'Agent 还有尚未落盘的空会话,请先发送消息并等待保存', + codex_handoff_background: 'Codex 还有后台终端任务,请先在原客户端处理完再交接', + codex_handoff_unverified: '暂时无法确认 Codex 已空闲,连接尚未释放,请稍后重试', + codex_handoff_unconfirmed: '连接退出尚未确认,请重新核对原生会话状态', +} +export function codexOperationError(detail?: string) { return codexErrors[detail ?? ''] } export class ApiError extends Error { constructor(message: string, public code: string, public status: number) { super(message); this.name = 'ApiError' } } async function apiError(response: Response) { const value = await response.json().catch(() => ({})) as { error?: string } - const errors: Record = { operation_expired: '这次操作已过期,草稿已保留;核对状态后可修改指令重新提交', operation_failed: '这次操作已失败,草稿已保留;请先核对执行结果', agent_offline: 'Agent 离线,连接恢复后重试', agent_upgrade_required: '请先升级 Agent 至 V0.9.0', agent_save_unconfirmed: '尚未确认 Agent 保存,重试会核对同一次操作', invalid_schedule_time: '时间必须在 60 秒至 365 天之间', session_is_not_running: '当前会话已没有活动对话', visible_turn_changed: '活动对话已改变,请刷新后再操作', idempotency_conflict: '操作身份与之前的请求冲突' } - return new ApiError(errors[value.error ?? ''] ?? (response.status === 401 ? '登录已过期,请重新登录' : `请求失败(HTTP ${response.status})${value.error ? `:${value.error}` : ''}`), value.error ?? 'request_failed', response.status) + const errors: Record = { operation_expired: '这次操作已过期,草稿已保留;核对状态后可修改指令重新提交', operation_failed: '这次操作已失败,草稿已保留;请先核对执行结果', agent_offline: 'Agent 离线,连接恢复后重试', agent_upgrade_required: '当前 Agent 尚不支持此功能,请更新 Agent', agent_save_unconfirmed: '尚未确认 Agent 保存,重试会核对同一次操作', invalid_schedule_time: '时间必须在 60 秒至 365 天之间', session_is_not_running: '当前会话已没有活动对话', visible_turn_changed: '活动对话已改变,请刷新后再操作', idempotency_conflict: '操作身份与之前的请求冲突', invalid_model_choice: '模型或推理强度无效,请重新选择', model_change_requires_queue: '切换模型需要排队下一轮', invalid_session_name: '请输入 1–128 字的明确名称,不能包含路径或控制字符' } + return new ApiError(errors[value.error ?? ''] ?? codexErrors[value.error ?? ''] ?? (response.status === 401 ? '登录已过期,请重新登录' : `请求失败(HTTP ${response.status})${value.error ? `:${value.error}` : ''}`), value.error ?? 'request_failed', response.status) } export async function mutate(url: string, csrf: string, body?: unknown, method = 'POST', operationId?: string): Promise { const generation = receiptGeneration @@ -113,7 +129,10 @@ export async function waitForCommand(operation: Operation): Promise { if (!status?.state || !['completed', 'failed', 'expired', 'unknown', 'orphaned'].includes(status.state)) return clearTimeout(timer); unsubscribe() if (status.state === 'completed') { savedReceipts.delete(id); resolve(status) } - else reject(new Error(`操作结果:${status.state}`)) + else { + if (['failed', 'expired'].includes(status.state)) savedReceipts.delete(id) + reject(new Error(codexErrors[status.detail ?? ''] ?? `操作结果:${status.state}`)) + } } const observer = new QueryObserver(queryClient, { queryKey: keys.operation(id), queryFn: ({ signal }) => json(`/api/v1/commands/${encodeURIComponent(id)}`, signal), staleTime: Infinity }) unsubscribe = observer.subscribe(check) @@ -129,12 +148,15 @@ export async function waitForCommand(operation: Operation): Promise { export function createSession(csrf: string, agentId: string, projectId: string, mode: 'inspect' | 'edit', inheritPermissions = false) { return mutate('/api/v1/codex/sessions', csrf, { agent_id: agentId, project_id: projectId, mode, ...(inheritPermissions ? { inherit_permissions: true } : {}) }) } -export function sendMessage(csrf: string, sessionId: string, prompt: string, delivery: 'queue' | 'steer', turnId?: string, operationId?: string) { - return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/messages`, csrf, { prompt, delivery, ...(delivery === 'steer' ? { turn_id: turnId } : {}) }, 'POST', operationId) +export function sendMessage(csrf: string, sessionId: string, prompt: string, delivery: 'queue' | 'steer', turnId?: string, operationId?: string, modelChoice?: ModelChoice) { + return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/messages`, csrf, { prompt, delivery, ...(delivery === 'steer' ? { turn_id: turnId } : {}), ...(modelChoice ? { model_choice: modelChoice } : {}) }, 'POST', operationId) } export function interruptSession(csrf: string, sessionId: string, turnId?: string) { return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/interrupt`, csrf, { turn_id: turnId }) } +export function handoffSession(csrf: string, sessionId: string) { + return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/handoff`, csrf, {}) +} export function createSchedule(csrf: string, sessionId: string, prompt: string, trigger: ScheduleTrigger) { return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/schedules`, csrf, { prompt, trigger }) } diff --git a/farhelm-console/src/components/codex/Conversation.tsx b/farhelm-console/src/components/codex/Conversation.tsx index bedd2be..266f251 100644 --- a/farhelm-console/src/components/codex/Conversation.tsx +++ b/farhelm-console/src/components/codex/Conversation.tsx @@ -3,16 +3,17 @@ import { Sender } from '@ant-design/x' import { useQuery } from '@tanstack/react-query' import { Alert, Button, Dropdown, Empty, Input, Modal, Radio, Skeleton, Space, Tooltip } from 'antd' import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type ComponentRef } from 'react' -import { ApiError, fetchSessionDisplay, fetchTranscript, interruptSession, json, waitForCommand, type CodexSession, type TranscriptPage } from '../../api/features' +import { ApiError, codexOperationError, fetchSessionDisplay, fetchTranscript, interruptSession, json, waitForCommand, type CodexSession, type TranscriptPage } from '../../api/features' import { cacheHistory, keys, queryClient, mergeSession } from '../../api/cache' import { ActivityIndicator, Welcome } from './Welcome' import { Transcript, type TranscriptPosition } from './Transcript' import { errorText, sessionName, stateNames } from './presentation' import type { SessionDraft } from './useOperations' import { useAgents } from '../../hooks/useAgents' -import { ModelDetails, PermissionDetails } from './SessionSettings' +import { ModelPicker, PermissionDetails } from './SessionSettings' +import { NativeSession, RenameSession } from './NativeSession' -const ComposerInput = forwardRef, ComponentProps>((props, ref) => ) +const ComposerInput = forwardRef, ComponentProps>((props, ref) => ) const senderComponents = { input: ComposerInput } type Props = { recent: CodexSession[]; onSelect: (id: string) => void; onNew: () => void; onBrowse: () => void; csrf: string; id?: string; session?: CodexSession; draft: SessionDraft; onDraft: (change: (old: SessionDraft) => SessionDraft) => void; onSend: (turn?: string) => void; onRail: () => void; onCollapse: () => void; collapsed: boolean; onSchedule: (session: CodexSession) => void; onSchedules: (session: CodexSession) => void } @@ -28,6 +29,7 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const page = history.data; const turns = page?.turns ?? [] const [readingMore, setReadingMore] = useState(false) const [modal, modalHolder] = Modal.useModal() + const [sessionDialog, setSessionDialog] = useState<'rename' | 'native'>() const [moreError, setMoreError] = useState() const [newMessages, setNewMessages] = useState(false) const [away, setAway] = useState(false) @@ -44,6 +46,8 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const restoring = useRef(false) const anchorLocked = useRef(false) const capture = useCallback(() => { + // A new reading position supersedes callbacks from the previous layout. + restoring.current = false const node = scroll.current; if (!node || follow.current) { anchor.current = undefined; return } const top = node.getBoundingClientRect().top const first = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > top) @@ -55,14 +59,15 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const saved = anchor.current if (saved) { const target = node.querySelector(`[data-message-key="${CSS.escape(saved.key)}"]`) - if (target) { node.scrollTop += target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset; restoring.current = false; requestAnimationFrame(() => requestAnimationFrame(() => { anchorLocked.current = false })) } + if (target) { node.scrollTop += target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset; restoring.current = false; requestAnimationFrame(() => requestAnimationFrame(() => { if (anchor.current === saved) anchorLocked.current = false })) } else if (!restoring.current && position.current?.reveal(saved.turn)) { restoring.current = true requestAnimationFrame(() => { + if (anchor.current !== saved) return const target = node.querySelector(`[data-message-key="${CSS.escape(saved.key)}"]`) if (target) node.scrollTop += target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset restoring.current = false - requestAnimationFrame(() => requestAnimationFrame(() => { anchorLocked.current = false })) + requestAnimationFrame(() => requestAnimationFrame(() => { if (anchor.current === saved) anchorLocked.current = false })) }) } } @@ -93,7 +98,12 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const next = await fetchTranscript(id, page.next_cursor, controller.signal) if (!controller.signal.aborted) { queryClient.setQueryData(keys.history(id), cacheHistory(id, next, true)); setMoreError(undefined) } } catch (reason) { anchorLocked.current = false; if (!controller.signal.aborted) setMoreError(reason instanceof Error ? reason : new Error(errorText(reason))) } - finally { continuationRead.current = undefined; if (active.current) setReadingMore(false) } + finally { + // Keep the next-page action busy until the new DOM and virtual measurements + // can restore the reading position; otherwise a fast second click races it. + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + continuationRead.current = undefined; if (active.current) setReadingMore(false) + } }, [id, capture, refetchHistory, page]) const continueHistory = useCallback(() => { void load(true) }, [load]) const anchorInteraction = useCallback(() => { follow.current = false; setAway(true); capture() }, [capture]) @@ -113,12 +123,15 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const pending = draft.pending.filter((p) => p.delivery === 'steer' ? !p.command_id : !p.turn_id || !turns.some((t) => t.turn_id === p.turn_id && t.items.some((i) => i.kind === 'user_message'))) const continuation = page?.continuation const needsMessage = continuation?.kind === 'message' && !turns.some((t) => t.turn_id === continuation.turn_id && t.items.some((i) => i.item_id === continuation.item_id && i.text_complete)) - const menu = { items: [{ key: 'schedules', icon: , label: '定时任务', disabled: !session }, { key: 'copy', icon: , label: '复制会话 ID', disabled: !id }], onClick: ({ key }: { key: string }) => { + const nativeIdentity = !!session && !!agent?.capabilities?.includes('codex.native_identity') + const menu = { items: [{ key: 'rename', label: '重命名会话', disabled: !nativeIdentity }, { key: 'native', label: '在原生 Codex 中继续', disabled: !nativeIdentity }, { key: 'schedules', icon: , label: '定时任务', disabled: !session }, { key: 'copy', icon: , label: '复制会话 ID', disabled: !id }], onClick: ({ key }: { key: string }) => { + if (key === 'rename' || key === 'native') setSessionDialog(key) if (key === 'schedules' && session) onSchedules(session) if (key === 'copy' && id) void navigator.clipboard.writeText(id).catch(() => onDraft((old) => ({ ...old, error: `复制失败,会话 ID:${id}` }))) } } const lastReceipt = draft.pending.at(-1) const receiptFailed = !!lastReceipt && ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(lastReceipt.state) + const receiptError = receiptFailed ? codexOperationError(lastReceipt?.detail) : undefined const prompt = (text: string) => { onDraft((old) => old.text.trim() ? old : { ...old, text }); sender.current?.focus({ preventScroll: true, cursor: 'end' }) } const submit = () => { // An explicit send returns to the latest turn; background deltas keep the reading anchor. @@ -128,6 +141,8 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess } return
{modalHolder} + {session && sessionDialog === 'rename' && setSessionDialog(undefined)} />} + {session && sessionDialog === 'native' && setSessionDialog(undefined)} onRename={() => setSessionDialog('rename')} />}
-
{codexNotice && }{draft.error && onDraft((old) => ({ ...old, error: undefined }))} />}{failure && turns.length > 0 && void load(!!moreError)}>重试} />}
+
{codexNotice && }{draft.error && onDraft((old) => ({ ...old, error: undefined }))} />}{receiptError && }{failure && turns.length > 0 && void load(!!moreError)}>重试} />}
{ anchorLocked.current = false }} onTouchMove={() => { anchorLocked.current = false }} onScroll={() => { const node = scroll.current; if (!node || restoring.current || anchorLocked.current) return; follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; setAway(!follow.current); if (follow.current) setNewMessages(false); capture() }}> {!id ? : loading && !page ?
正在读取对话历史…
: failure && !turns.length ?

{unavailable}

{failure.message}

}>
: page && !turns.length && session ? : null} {page?.next_cursor && !needsMessage && } @@ -149,7 +164,7 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess {visibleTurn &&
onDraft((old) => ({ ...old, delivery: event.target.value as 'queue' | 'steer' }))}>排队下一轮补充当前对话
}
-
+
onDraft(old => ({ ...old, model_choice: choice }))} /> } loading={draft.sending} disabled={!session || !draft.text.trim() || draft.sending} aria-label="发送指令">发送
diff --git a/farhelm-console/src/components/codex/Dialogs.tsx b/farhelm-console/src/components/codex/Dialogs.tsx index f66881f..9e51e40 100644 --- a/farhelm-console/src/components/codex/Dialogs.tsx +++ b/farhelm-console/src/components/codex/Dialogs.tsx @@ -52,7 +52,7 @@ export function ScheduleDialog({ csrf, target, prompt, onClose }: { csrf: string void queryClient.invalidateQueries({ queryKey: ['codex', 'schedules', target.session_id] }) if (active.current) onClose() } catch (e) { if (active.current) setError(errorText(e)) } finally { if (active.current) setBusy(false) } - }}>{error && }{({ getFieldValue }) => getFieldValue('delivery') === 'at_time' ? : <>{experiments.error && void experiments.refetch()}>重试} />} : <>{experiments.error && void experiments.refetch()}>重试} />} + + + +} + +export function NativeSession({ session, csrf, onClose, onRename }: { session: CodexSession; csrf: string; onClose: () => void; onRename: () => void }) { + const info = useQuery({ queryKey: ['codex', 'native', session.session_id], queryFn: ({ signal }) => fetchNativeIdentity(session.session_id, signal), refetchOnMount: 'always' }) + const [copied, setCopied] = useState(false), [error, setError] = useState() + const [busy, setBusy] = useState(false), [released, setReleased] = useState(false) + const active = useRef(true) + useEffect(() => { active.current = true; return () => { active.current = false } }, []) + const command = /^[A-Za-z0-9_-]+$/.test(session.session_id) ? `codex resume ${session.session_id}` : undefined + return 完成}> + {info.isPending ? : info.error ? } onClick={() => void info.refetch()}>重试} /> :
+ +
服务器 · 项目
{session.agent_id} · {session.project_id}
原生名称
{info.data?.native_name || '未设置名称,Codex 使用消息摘要'}
会话 ID
{session.session_id}
+ {info.data?.native_name && !formalTitle(info.data.native_name) &&

旧版本使用了通用名称,可能与网页显示的摘要不同。

} + {info.data?.held_by_agent &&

FarHelm 仍持有原生会话连接。切换客户端前可释放此 Agent 的空闲 Codex 连接;有活动任务、后台终端或未保存会话时不会交接。

} + {released &&

已释放 FarHelm 的空闲连接,可以在原生客户端继续。

} + {command && <>

在该服务器的终端中继续:

{command}
} +

桌面端请重新打开对应项目或刷新会话列表。列表有筛选时,可用同一会话 ID 定位;FarHelm 不会复制会话到其他服务器或账户。

+

回到网页续写前,请关闭原生客户端的会话连接。网页再次发送或定时任务执行时会尝试恢复会话。

+ {error && } +
} +
+} diff --git a/farhelm-console/src/components/codex/SessionSettings.tsx b/farhelm-console/src/components/codex/SessionSettings.tsx index f572503..0480a04 100644 --- a/farhelm-console/src/components/codex/SessionSettings.tsx +++ b/farhelm-console/src/components/codex/SessionSettings.tsx @@ -1,7 +1,8 @@ import { DownOutlined, EyeOutlined, SafetyOutlined } from '@ant-design/icons' -import { Button, Popover } from 'antd' +import { Alert, Button, Popover, Select, Skeleton } from 'antd' +import { useQuery } from '@tanstack/react-query' import { cloneElement, useEffect, useState, type ReactElement, type ReactNode } from 'react' -import type { CodexSession, SessionContext } from '../../api/features' +import { fetchModels, type CodexSession, type SessionContext, type ModelChoice } from '../../api/features' function SettingsPopover({ title, placement, content, children }: { title: string; placement: 'topLeft' | 'topRight'; content: ReactNode; children: ReactElement<{ 'aria-expanded'?: boolean }> }) { const [open, setOpen] = useState(false) @@ -15,6 +16,39 @@ function SettingsPopover({ title, placement, content, children }: { title: strin } const efforts: Record = { none: '无', minimal: '最低', low: '低', medium: '中', high: '高', xhigh: '极高', max: '最高', ultra: '超高' } + +export function ModelPicker({ context, session, choice, onChange, supported, sending, steer }: { context?: SessionContext; session?: CodexSession; choice?: ModelChoice; onChange: (choice?: ModelChoice) => void; supported: boolean; sending: boolean; steer: boolean }) { + const [open, setOpen] = useState(false) + const models = useQuery({ queryKey: ['codex', 'models', session?.agent_id], enabled: open && !!session && supported, queryFn: ({ signal }) => fetchModels(session!.session_id, signal) }) + const effective = choice ?? context + const model = effective?.model + const effort = effective?.reasoning_effort + const selected = models.data?.models.find(m => m.model === model) + const pending = !!choice && (choice.model !== context?.model || choice.reasoning_effort !== context?.reasoning_effort) + const label = model ?? '选择模型' + useEffect(() => { + if (!open) return + const dismiss = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', dismiss) + return () => document.removeEventListener('keydown', dismiss) + }, [open]) + return + {!supported ?

请升级 Agent 后使用模型切换。当前模型由服务器 Codex 管理。

: models.isPending ? : models.error ? void models.refetch()}>重试} /> : <> + + ({ value, label: efforts[value] ?? value }))} onChange={value => onChange({ model: selected.model, reasoning_effort: value })} />} +

{steer ? '补充当前对话仍使用正在运行的模型;这里的选择用于下一轮排队。' : '选择用于下一条排队指令及后续对话,权限保持不变。'}

+ {choice && } + } +
}> + + +} const permissions: Record = { 'read-only': { name: '仅分析', description: 'Codex 当前使用只读沙盒,可以读取和分析文件。修改文件需要更改服务器上的会话权限。' }, 'workspace-write': { name: '可编辑', description: 'Codex 可以在当前工作区修改文件并运行命令;工作区外访问受服务器上的权限规则限制。' }, @@ -39,15 +73,3 @@ export function PermissionDetails({ context, session, supported }: { context?: S } - -export function ModelDetails({ context, loading, disabled }: { context?: SessionContext; loading: boolean; disabled: boolean }) { - const model = context?.model - const effort = context?.reasoning_effort ? efforts[context.reasoning_effort] ?? context.reasoning_effort : undefined - const label = model ?? (loading ? '读取模型…' : '模型未提供') - return -
模型
{label}
推理强度
{effort ?? 'Codex 未提供'}
-

{model ? '来自 Codex 当前会话或最近保存的配置。模型与推理强度在服务器上的 Codex 中调整。' : '当前 Codex 或 Agent 尚未返回模型信息。不会根据版本号猜测模型;刷新对话可重新读取。'}

-
}> - - -} diff --git a/farhelm-console/src/components/codex/useOperations.ts b/farhelm-console/src/components/codex/useOperations.ts index 47c6bdf..db3c970 100644 --- a/farhelm-console/src/components/codex/useOperations.ts +++ b/farhelm-console/src/components/codex/useOperations.ts @@ -1,10 +1,10 @@ import { useCallback, useRef, useState } from 'react' import { useQueries } from '@tanstack/react-query' -import { ApiError, json, sendMessage, type Operation } from '../../api/features' +import { ApiError, json, sendMessage, type ModelChoice, type Operation } from '../../api/features' import { cacheOperation, keys, queryClient } from '../../api/cache' import { errorText } from './presentation' -export type PendingMessage = { id: string; text: string; state: string; command_id?: string; turn_id?: string; visible_turn_id?: string; delivery: 'queue' | 'steer' } -export type SessionDraft = { text: string; error?: string; sending: boolean; delivery: 'queue' | 'steer'; pending: PendingMessage[] } +export type PendingMessage = { id: string; text: string; state: string; detail?: string; command_id?: string; turn_id?: string; visible_turn_id?: string; delivery: 'queue' | 'steer'; model_choice?: ModelChoice } +export type SessionDraft = { text: string; error?: string; sending: boolean; delivery: 'queue' | 'steer'; pending: PendingMessage[]; model_choice?: ModelChoice } const empty: SessionDraft = { text: '', sending: false, delivery: 'queue', pending: [] } export function useOperations(csrf: string) { @@ -22,11 +22,11 @@ export function useOperations(csrf: string) { const delivery = visibleTurn ? draft.delivery : 'queue' const retry = draft.pending.find((p) => p.state === 'unconfirmed' && p.text === text && p.delivery === delivery) if (retry?.delivery === 'steer' && retry.visible_turn_id !== visibleTurn) { update(session, (old) => ({ ...old, error: '活动对话已改变,不能将重试指令发送到另一轮;请先核对原操作' })); return } - const pending: PendingMessage = retry ? { ...retry, state: 'submitting' } : { id: crypto.randomUUID(), text, state: 'submitting', delivery, visible_turn_id: visibleTurn } + const pending: PendingMessage = retry ? { ...retry, state: 'submitting' } : { id: crypto.randomUUID(), text, state: 'submitting', delivery, visible_turn_id: visibleTurn, model_choice: delivery === 'queue' ? draft.model_choice : undefined } locks.current.add(session) update(session, (old) => ({ ...old, sending: true, error: undefined, pending: [...old.pending.filter((p) => p.id !== pending.id), pending] })) try { - const receipt = await sendMessage(csrf, session, text, delivery, visibleTurn, pending.id) + const receipt = await sendMessage(csrf, session, text, delivery, visibleTurn, pending.id, pending.model_choice) if (receipt.command_id) cacheOperation(receipt.command_id, receipt) update(session, (old) => ({ ...old, text: old.text.trim() === text ? '' : old.text, pending: old.pending.map((p) => p.id === pending.id ? { ...p, command_id: receipt.command_id, state: receipt.state ?? 'accepted' } : p) })) } catch (reason) { @@ -38,7 +38,7 @@ export function useOperations(csrf: string) { const draft = drafts[id] ?? empty return { ...draft, pending: draft.pending.map((row) => { const receipt = row.command_id ? queryClient.getQueryData(keys.operation(row.command_id)) : undefined - return receipt ? { ...row, state: receipt.state ?? row.state, turn_id: receipt.data?.turn_id ?? receipt.result?.turn_id ?? row.turn_id } : row + return receipt ? { ...row, state: receipt.state ?? row.state, detail: receipt.detail, turn_id: receipt.data?.turn_id ?? receipt.result?.turn_id ?? row.turn_id } : row }) } }, update, send } } diff --git a/farhelm-console/src/components/codex/workspace.css b/farhelm-console/src/components/codex/workspace.css index df59574..bf62b1c 100644 --- a/farhelm-console/src/components/codex/workspace.css +++ b/farhelm-console/src/components/codex/workspace.css @@ -3,7 +3,7 @@ .codex-shell > .ant-layout { min-height: 0; height: 100%; } .codex-shell .app-header { flex-shrink: 0; } .app-content.codex-content { min-height: 0; padding: 0; display: flex; overflow: hidden; } -.codex-workspace { width: 100%; min-height: 0; display: grid; grid-template-columns: var(--layout-projects) minmax(0, 1fr); overflow: hidden; color: var(--farhelm-text); font-family: var(--font-ui); --reading-gutter: 56px; } +.codex-workspace { width: 100%; min-height: 0; display: grid; grid-template-columns: var(--layout-projects) minmax(0, 1fr); overflow: hidden; color: var(--farhelm-text); font-family: var(--font-ui); --reading-gutter: clamp(24px, 3vw, 48px); } .codex-workspace.rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); } .rail-collapsed .codex-desktop-rail { visibility: hidden; } .codex-desktop-rail { min-width: 0; overflow: hidden; border-right: 1px solid var(--farhelm-border-soft); background: var(--farhelm-panel); } @@ -64,12 +64,12 @@ .session-partial .ant-btn { height: 44px; padding: 0 8px; } .codex-conversation { min-width: 0; min-height: 0; display: grid; grid-template-areas: "head" "alerts" "history" "composer"; grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--farhelm-surface); } -.conversation-head { grid-area: head; min-width: 0; min-height: 88px; padding: 12px max(12px, calc((100% - var(--layout-reading)) / 2 - 44px)); display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 0; } +.conversation-head { grid-area: head; min-width: 0; min-height: 64px; padding: 8px max(12px, calc((100% - var(--layout-reading)) / 2 - 44px)) 8px max(0px, calc(var(--reading-gutter) - 44px), calc((100% - var(--layout-reading)) / 2 - 44px)); display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 0; } .conversation-identity { display: flex; min-width: 0; align-items: center; gap: 0; } .conversation-head .ant-btn { min-width: 44px; min-height: 44px; color: var(--farhelm-muted); } .conversation-head .ant-btn { border-radius: var(--radius-pill); } .conversation-title { min-width: 0; } -.conversation-title h1 { margin: 2px 0 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 20px; line-height: 30px; font-weight: 500; } +.conversation-title h1 { margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 20px; line-height: 28px; font-weight: 600; } .conversation-meta { color: var(--farhelm-muted); font-size: 12px; line-height: 20px; overflow-wrap: anywhere; } .conversation-head .conversation-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; } .conversation-meta > span { overflow: hidden; text-overflow: ellipsis; } @@ -82,12 +82,12 @@ .history-loading { max-width: var(--layout-reading); margin: 0 auto; padding: 32px 0; color: var(--farhelm-muted); } .history-loading > span { display: block; margin-bottom: 24px; } .load-earlier { display: block; margin: 0 auto 24px; } -.codex-transcript, .codex-turn { display: flex; flex-direction: column; gap: 24px; min-width: 0; } -.codex-transcript { max-width: var(--layout-reading); margin: 0 auto; gap: 40px; } +.codex-transcript, .codex-turn { display: flex; flex-direction: column; gap: 20px; min-width: 0; } +.codex-transcript { max-width: var(--layout-reading); margin: 0 auto; gap: 32px; } .turn-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 20px; color: var(--farhelm-muted); font-size: 12px; } .turn-heading::before, .turn-heading::after { display: none; } .turn-heading time { font-variant-numeric: tabular-nums; } -.turn-heading.heading-quiet { min-height: 0; height: 0; margin-bottom: -24px; } +.turn-heading.heading-quiet { min-height: 0; height: 0; margin-bottom: -20px; } .turn-heading.heading-quiet::before, .turn-heading.heading-quiet::after { display: none; } .turn-state { font-size: 12px; } .transcript-part { min-width: 0; } @@ -98,7 +98,7 @@ .assistant-mark { display: grid; place-items: center; width: 20px; height: 20px; color: var(--farhelm-text); } .codex-message.user:not(.pending-message) .message-role { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .codex-message.user .message-role { justify-content: flex-end; } -.message-body { min-width: 0; font-size: 16px; line-height: 28px; overflow-wrap: anywhere; } +.message-body { min-width: 0; font-size: var(--font-message); line-height: var(--line-message); overflow-wrap: anywhere; } .codex-message.user .message-body { padding: 16px; border-radius: var(--radius-panel); background: var(--farhelm-canvas); } .user-text, .markdown-fallback { white-space: pre-wrap; } .codex-message.error { border-left: 2px solid var(--farhelm-danger); padding-left: 16px; } @@ -109,10 +109,10 @@ .pending-message { margin-top: 24px; } .pending-message.is-submitting { animation: message-submit var(--motion-enter) ease-out; } .virtual-transcript { position: relative; display: block; } -.virtual-turn { position: absolute; top: 0; left: 0; width: 100%; padding-bottom: 40px; } +.virtual-turn { position: absolute; top: 0; left: 0; width: 100%; padding-bottom: 32px; } -.execution-summary.ant-thought-chain { border: 1px solid transparent; border-radius: var(--radius-control); background: var(--farhelm-canvas); padding: 0 12px; } -.execution-summary.is-open, .execution-summary.has-failure { border-color: var(--farhelm-border); } +.execution-summary.ant-thought-chain { border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; padding: 0; } +.execution-summary.is-open, .execution-summary.has-failure { border-color: var(--farhelm-border); background: var(--farhelm-canvas); padding-inline: 12px; } .execution-summary.has-failure { border-color: color-mix(in srgb, var(--farhelm-danger) 35%, var(--farhelm-border-soft)); } .execution-summary .ant-thought-chain-node-title { min-height: 44px; align-items: center; padding-inline-end: 0; } .execution-summary .ant-thought-chain-node-box { flex: 1; min-width: 0; } @@ -144,28 +144,28 @@ .jump-to-bottom[hidden] { display: none; } .composer-wrap { grid-area: composer; min-width: 0; padding: 16px var(--reading-gutter) 12px; background: var(--farhelm-surface); } -.composer.ant-sender { position: relative; isolation: isolate; max-width: var(--layout-reading); margin: 0 auto; padding: 16px; min-height: 136px; border: 1px solid var(--farhelm-border); border-radius: var(--radius-composer); background: var(--farhelm-surface); box-shadow: var(--shadow-composer); transition: border-color var(--motion-press) var(--ease-out); } -.composer.ant-sender::after { content: ''; position: absolute; inset: -1px; z-index: -1; border-radius: inherit; pointer-events: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--farhelm-accent) 12%, transparent); opacity: 0; transition: opacity var(--motion-panel) var(--ease-out); } +.composer.ant-sender { position: relative; isolation: isolate; max-width: var(--layout-reading); margin: 0 auto; padding: 16px; min-height: 112px; border: 1px solid var(--farhelm-border-soft); border-radius: var(--radius-composer); background: var(--farhelm-canvas); box-shadow: none; transition: border-color var(--motion-press) var(--ease-out); } +.composer.ant-sender::after { display: none; } .composer.ant-sender:focus-within { border-color: var(--farhelm-accent); } .composer.ant-sender:focus-within::after { opacity: 1; } -.composer.has-draft { background: var(--farhelm-surface); } +.composer.has-draft { background: var(--farhelm-canvas); } .composer .ant-sender-content { padding: 0; } -.composer .ant-sender-footer { margin-top: 16px; padding: 0; background: transparent; border: 0; } -.composer .ant-input { padding: 0; border: 0; box-shadow: none !important; background: transparent; color: var(--farhelm-text); max-height: clamp(64px, calc(var(--codex-visible-height) - 430px), 128px) !important; overflow-y: auto !important; transition: none; font-size: 16px; line-height: 28px; } +.composer .ant-sender-footer { margin-top: 12px; padding: 0; background: transparent; border: 0; } +.composer .ant-input { padding: 0; border: 0; border-radius: 0; outline: none; box-shadow: none !important; background: transparent; color: var(--farhelm-text); max-height: clamp(64px, calc(var(--codex-visible-height) - 430px), 128px) !important; overflow-y: auto !important; transition: none; font-size: 16px; line-height: 28px; } .composer .ant-input::placeholder { color: var(--farhelm-muted); } .composer-actions { min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 8px; } .composer-tools, .composer-submit { display: flex; align-items: center; gap: 8px; min-width: 0; } .composer-tools { flex: none; } -.composer-submit { justify-content: flex-end; } +.composer-submit { justify-content: flex-end; flex: 1; } .composer-actions .ant-btn { min-width: 44px; height: 44px; min-height: 44px; } -.composer-control.ant-btn { border: 0; border-radius: var(--radius-pill); padding-inline: 12px; color: var(--farhelm-text); background: var(--farhelm-raised); font-size: 14px; gap: 8px; box-shadow: none; } +.composer-control.ant-btn { border: 0; border-radius: var(--radius-pill); padding-inline: 10px; color: var(--farhelm-muted); background: transparent; font-size: var(--font-label); gap: 8px; box-shadow: none; } .composer-control.ant-btn:not(:disabled):hover, .composer-control.ant-btn[aria-describedby] { color: var(--farhelm-text); background: var(--farhelm-selection); } .composer-control.ant-btn:focus-visible { outline: 2px solid var(--farhelm-accent); outline-offset: 2px; } .composer-control.ant-btn:active { transform: scale(.97); } .composer-control .control-chevron { font-size: 10px; color: var(--farhelm-muted); flex: none; } .permission-short { display: none; } .schedule-action.ant-btn { width: 44px; padding: 0; flex: none; font-size: 16px; } -.model-action.ant-btn { min-width: 76px; max-width: 280px; } +.model-action.ant-btn { min-width: 76px; max-width: 280px; flex: 0 1 auto; } .model-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .model-effort { color: var(--farhelm-muted); flex: none; } .session-settings-details { width: min(304px, calc(100vw - 64px)); font-size: 14px; line-height: 22px; } @@ -174,7 +174,21 @@ .session-settings-details dl { margin: 0 0 12px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; } .session-settings-details dt { color: var(--farhelm-muted); } .session-settings-details dd { margin: 0; overflow-wrap: anywhere; } -.settings-detail-note { color: var(--farhelm-muted); font-size: 12px; line-height: 20px; } +.settings-detail-note { color: var(--farhelm-muted); font-size: var(--font-detail); line-height: 22px; } +.model-picker { display: grid; gap: 10px; } +.model-picker > label { color: var(--farhelm-muted); font-size: var(--font-detail); } +.model-picker .ant-select { width: 100%; } +.model-picker .settings-detail-note { margin: 2px 0 0; } +.model-pending { color: var(--farhelm-accent); } +.native-session-details { font-size: var(--font-label); } +.native-session-details dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; } +.native-session-details dt { color: var(--farhelm-muted); } +.native-session-details dd { margin: 0; overflow-wrap: anywhere; } +.native-resume-command { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--farhelm-canvas); border-radius: var(--radius-control); } +.native-resume-command code { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.native-resume-command .ant-btn { min-height: 44px; } +.codex-rail-head h2, .session-group-heading.ant-btn, .session-copy > span, .new-session-button.ant-btn { font-size: var(--font-label); } +.session-time, .session-group-heading .project-device, .session-partial, .session-copy small, .conversation-meta, .composer-hint, .message-role, .tool-item-head { font-size: var(--font-detail); } .create-session-form .ant-checkbox-wrapper { min-height: 44px; align-items: center; } .send-action.ant-btn { min-width: 44px; width: 44px; padding: 0; flex: none; border-radius: var(--radius-pill); font-size: 20px; box-shadow: none; } .send-action.ant-btn:not(:disabled), .send-action.ant-btn:not(:disabled):hover { color: var(--farhelm-on-accent); background: var(--farhelm-accent); border-color: transparent; } @@ -240,7 +254,8 @@ .codex-workspace, .codex-workspace.rail-collapsed { grid-template-columns: 1fr; --reading-gutter: 16px; } .codex-desktop-rail, .desktop-only { display: none !important; } .mobile-only { display: inline-flex !important; } - .conversation-head { min-height: 80px; padding: 8px; gap: 4px; } + .conversation-head { min-height: 64px; padding: 8px; gap: 4px; } + .codex-workspace { --font-message: 16px; --line-message: 28px; } .conversation-identity { gap: 4px; } .conversation-head > .ant-space { flex-shrink: 0; } .conversation-title h1 { font-size: 16px; line-height: 24px; } diff --git a/farhelm-console/src/main.tsx b/farhelm-console/src/main.tsx index 5dc3400..de50b6e 100644 --- a/farhelm-console/src/main.tsx +++ b/farhelm-console/src/main.tsx @@ -5,7 +5,6 @@ import { registerSW } from 'virtual:pwa-register' import App from './App' // Unicode ranges load only the local font slices needed by the current page. -import '@fontsource-variable/manrope' import '@fontsource-variable/noto-sans-sc' import './styles.css' diff --git a/farhelm-console/src/styles.css b/farhelm-console/src/styles.css index e41b3cd..93af724 100644 --- a/farhelm-console/src/styles.css +++ b/farhelm-console/src/styles.css @@ -17,8 +17,12 @@ --radius-composer: 28px; --radius-pill: 999px; --layout-navigation: 88px; - --layout-projects: 280px; + --layout-projects: 300px; --layout-reading: 960px; + --font-label: 15px; + --font-detail: 13px; + --font-message: 17px; + --line-message: 29px; --shadow-composer: 0 2px 12px rgb(0 0 0 / 4%); --farhelm-border-soft: color-mix(in srgb, var(--farhelm-border) 55%, transparent); --farhelm-panel: color-mix(in srgb, var(--farhelm-canvas) 65%, var(--farhelm-surface)); diff --git a/farhelm-console/src/theme.ts b/farhelm-console/src/theme.ts index 4f70b15..1062d97 100644 --- a/farhelm-console/src/theme.ts +++ b/farhelm-console/src/theme.ts @@ -2,7 +2,7 @@ import { theme, type ThemeConfig } from 'antd' export type ColorMode = 'light' | 'dark' -export const uiFont = '"Manrope Variable", "Noto Sans SC Variable", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' +export const uiFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans SC Variable", "Microsoft YaHei", system-ui, sans-serif' export const palettes = { light: { canvas: '#F7F7F7', surface: '#FFFFFF', raised: '#F0F0F0', border: '#E3E3E3', text: '#181818', muted: '#666666', danger: '#181818', selection: '#EAEAEA' }, dark: { canvas: '#171717', surface: '#212121', raised: '#303030', border: '#414141', text: '#F5F5F5', muted: '#B5B5B5', danger: '#F5F5F5', selection: '#383838' }, @@ -72,6 +72,7 @@ const common: ThemeConfig['token'] = { borderRadiusLG: 20, borderRadiusSM: 10, controlHeight: 44, + fontSize: 15, fontFamily: uiFont, fontFamilyCode: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', diff --git a/farhelm-hub/src/codex_settings.rs b/farhelm-hub/src/codex_settings.rs new file mode 100644 index 0000000..e733b9d --- /dev/null +++ b/farhelm-hub/src/codex_settings.rs @@ -0,0 +1,127 @@ +//! Native settings and identities use the existing authenticated memory relay. +use super::*; +use farhelm_protocol::{CodexModelList, CodexNativeIdentity, CodexSessionSummary}; +type ReadError = (StatusCode, &'static str); + +async fn target( + state: &AppState, + id: &str, + capability: &str, +) -> Result { + let id = id.to_owned(); + let session = match database(state, move |s| s.events.session(&id)).await { + Ok(Some(session)) => session, + Ok(None) => return Err((StatusCode::NOT_FOUND, "session_not_found")), + Err(_) => { + return Err((StatusCode::INTERNAL_SERVER_ERROR, "event_store_failed")); + } + }; + let agents = state.agents.read().await; + let Some(agent) = agents + .get(&session.agent_id) + .filter(|a| is_online(unix_time(), a.last_seen_unix)) + else { + return Err((StatusCode::SERVICE_UNAVAILABLE, "agent_offline")); + }; + if !agent.capabilities.iter().any(|c| c == capability) { + return Err((StatusCode::CONFLICT, "agent_upgrade_required")); + } + Ok(session) +} + +async fn read( + state: &AppState, + id: &str, + method: &str, + capability: &str, +) -> Result { + let session = target(state, id, capability).await?; + session_display::relay_method( + state, + &session.agent_id, + method, + serde_json::json!({"session_id":id}), + tokio::time::Instant::now() + Duration::from_secs(20), + ) + .await + .map_err(|code| { + ( + if code == "agent_read_timeout" { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }, + code, + ) + }) +} + +pub(super) async fn models(State(state): State, Path(id): Path) -> Response { + match read(&state, &id, "codex.models.list", "codex.model_choice").await { + Ok(value) => match serde_json::from_value::(value) { + Ok(list) => Json(list).into_response(), + Err(_) => api_error(StatusCode::BAD_GATEWAY, "agent_read_invalid"), + }, + Err((status, code)) => api_error(status, code), + } +} + +pub(super) async fn native(State(state): State, Path(id): Path) -> Response { + match read(&state, &id, "codex.session.native", "codex.native_identity").await { + Ok(value) => match serde_json::from_value::(value) { + Ok(info) if info.session_id == id => Json(info).into_response(), + _ => api_error(StatusCode::BAD_GATEWAY, "agent_read_invalid"), + }, + Err((status, code)) => api_error(status, code), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RenameRequest { + name: String, +} + +pub(super) async fn rename( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Response { + let name = request.name.trim(); + if !farhelm_protocol::valid_session_name(name) { + return api_error(StatusCode::BAD_REQUEST, "invalid_session_name"); + } + let Some(key) = idempotency_header(&headers) else { + return api_error(StatusCode::BAD_REQUEST, "missing_idempotency_key"); + }; + let session = match target(&state, &id, "codex.native_identity").await { + Ok(session) => session, + Err((status, code)) => return api_error(status, code), + }; + // An optional metadata operation on the existing session command preserves schema 7 + // and old receipt readers. Capability gating prevents an old Agent ignoring the name. + create_typed_response(&state, &session.agent_id, CommandAction::CodexSessionResume, + serde_json::json!({"session_id":id,"project_id":session.project_id,"mode":session.mode,"rename_to":name}), key, 300).await +} + +pub(super) async fn handoff( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Response { + let Some(key) = idempotency_header(&headers) else { + return api_error(StatusCode::BAD_REQUEST, "missing_idempotency_key"); + }; + let session = match target(&state, &id, "codex.native_identity").await { + Ok(session) => session, + Err((status, code)) => return api_error(status, code), + }; + if session.active_turn_id.is_some() + || session.state == farhelm_protocol::CodexSessionState::Running + { + return api_error(StatusCode::CONFLICT, "codex_handoff_busy"); + } + create_typed_response(&state, &session.agent_id, CommandAction::CodexSessionResume, + serde_json::json!({"session_id":id,"project_id":session.project_id,"mode":session.mode,"handoff":true}), key, 30).await +} diff --git a/farhelm-hub/src/codex_settings_tests.rs b/farhelm-hub/src/codex_settings_tests.rs new file mode 100644 index 0000000..f550b91 --- /dev/null +++ b/farhelm-hub/src/codex_settings_tests.rs @@ -0,0 +1,306 @@ +use super::tests::{browser_cookie, test_state}; +use super::*; +use axum::body::to_bytes; +use serde_json::{Value, json}; +use tower::ServiceExt; + +async fn ready() -> AppState { + let state = test_state(); + state.agents.write().await.insert( + "gpu-a".into(), + StoredAgent { + hostname: "gpu-a".into(), + agent_version: PRODUCT_VERSION.into(), + last_seen_unix: unix_time(), + codex: None, + credential_state: AgentCredentialState::Paired, + capabilities: AgentHeartbeat::new("gpu-a", "gpu-a", PRODUCT_VERSION).capabilities, + }, + ); + state.events.ingest("gpu-a", &[farhelm_protocol::AgentEvent {protocol:FARHELM_PROTOCOL.into(),agent_id:"gpu-a".into(),sequence:1,event_id:"settings-session".into(),event_type:"codex.session.updated".into(),created_at_unix:unix_time(),payload:json!({"session_id":"s","project_id":"cc08","mode":"inspect","state":"running","title":"Original","active_turn_id":"active","updated_at_unix":unix_time()})}]).unwrap(); + state +} + +fn request(suffix: &str, body: Option, csrf: bool) -> Request { + let mut builder = Request::builder() + .uri(format!("/api/v1/codex/sessions/s/{suffix}")) + .header(header::COOKIE, browser_cookie()); + if csrf { + builder = builder.header("x-csrf-token", "test-csrf"); + } + if body.is_some() { + builder = builder + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .header("idempotency-key", "settings-operation-0001"); + } + builder + .body(body.map_or_else(Body::empty, |v| Body::from(v.to_string()))) + .unwrap() +} + +#[tokio::test] +async fn native_reads_are_authenticated_scoped_and_project_only_whitelisted_fields() { + for (suffix, method, data) in [ + ( + "models", + "codex.models.list", + json!({"models":[{"model":"model-a","display_name":"Model A","reasoning_efforts":["high"],"default_reasoning_effort":"high","is_default":true,"credential":"PRIVATE_CATALOG"}],"cwd":"PRIVATE_PATH"}), + ), + ( + "native", + "codex.session.native", + json!({"session_id":"s","persisted":true,"native_name":"Original","source":"vscode","history_mode":"paginated","cwd":"PRIVATE_PATH","preview":"PRIVATE_PREVIEW"}), + ), + ] { + let state = ready().await; + let router = app(state.clone()); + assert_eq!( + router + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/codex/sessions/s/{suffix}")) + .body(Body::empty()) + .unwrap() + ) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + let read = tokio::spawn(router.oneshot(request(suffix, None, false))); + let queued = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(r) = state + .read_broker + .lock() + .await + .queues + .get_mut("gpu-a") + .and_then(|q| q.pop_front()) + { + break r; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(queued.method, method); + assert_eq!(queued.params, json!({"session_id":"s"})); + let report = AgentReadReportRequest { + protocol: FARHELM_PROTOCOL.into(), + agent_id: "gpu-a".into(), + request_id: queued.request_id.clone(), + ok: true, + data: Some(data), + detail: None, + }; + assert_eq!( + report_agent_read( + State(state.clone()), + Extension(AgentIdentity::Dedicated("gpu-b".into())), + Path(queued.request_id.clone()), + Json(report.clone()) + ) + .await + .status(), + StatusCode::BAD_REQUEST + ); + report_agent_read( + State(state.clone()), + Extension(AgentIdentity::Dedicated("gpu-a".into())), + Path(queued.request_id), + Json(report), + ) + .await; + let response = read.await.unwrap().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let text = String::from_utf8( + to_bytes(response.into_body(), 16384) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!(!text.contains("PRIVATE")); + assert!(state.read_broker.lock().await.waiters.is_empty()); + for suffix in ["", "-wal"] { + if let Ok(bytes) = + std::fs::read(format!("{}{suffix}", state.config.database_path.display())) + { + assert!(!String::from_utf8_lossy(&bytes).contains("PRIVATE")); + } + } + } +} + +#[tokio::test] +async fn rename_waits_for_native_execution_and_obeys_csrf_capability_and_identity() { + let state = ready().await; + let router = app(state.clone()); + assert_eq!( + router + .clone() + .oneshot(request("name", Some(json!({"name":"New name"})), false)) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + router + .clone() + .oneshot(request("name", Some(json!({"name":"/private/path"})), true)) + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + let first = router + .clone() + .oneshot(request("name", Some(json!({"name":"New name"})), true)) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::ACCEPTED); + let first: Value = + serde_json::from_slice(&to_bytes(first.into_body(), 8192).await.unwrap()).unwrap(); + let retry = router + .clone() + .oneshot(request("name", Some(json!({"name":"New name"})), true)) + .await + .unwrap(); + let retry: Value = + serde_json::from_slice(&to_bytes(retry.into_body(), 8192).await.unwrap()).unwrap(); + assert_eq!(first["command_id"], retry["command_id"]); + assert_eq!( + router + .clone() + .oneshot(request("name", Some(json!({"name":"Different"})), true)) + .await + .unwrap() + .status(), + StatusCode::CONFLICT + ); + assert_eq!( + state.events.session("s").unwrap().unwrap().title.as_deref(), + Some("Original") + ); + let cmd = state + .typed_commands + .claim("gpu-a", unix_time()) + .unwrap() + .unwrap(); + assert_eq!(cmd.payload.unwrap()["rename_to"], "New name"); + state + .agents + .write() + .await + .get_mut("gpu-a") + .unwrap() + .capabilities + .clear(); + assert_eq!( + router + .oneshot(request("name", Some(json!({"name":"New name"})), true)) + .await + .unwrap() + .status(), + StatusCode::CONFLICT + ); +} + +#[tokio::test] +async fn model_overrides_cannot_steer_or_reach_an_older_agent() { + let state = ready().await; + let router = app(state.clone()); + let choice = json!({"model":"model-a","reasoning_effort":"high"}); + for (body, expected) in [ + ( + json!({"prompt":"hi","model_choice":{"model":"/path","reasoning_effort":"high"}}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"prompt":"hi","model_choice":choice,"delivery":"steer","turn_id":"active"}), + StatusCode::CONFLICT, + ), + ] { + assert_eq!( + router + .clone() + .oneshot(request("messages", Some(body), true)) + .await + .unwrap() + .status(), + expected + ); + } + state + .agents + .write() + .await + .get_mut("gpu-a") + .unwrap() + .capabilities + .clear(); + assert_eq!( + router + .oneshot(request( + "messages", + Some(json!({"prompt":"hi","model_choice":choice})), + true + )) + .await + .unwrap() + .status(), + StatusCode::CONFLICT + ); +} + +#[tokio::test] +async fn handoff_requires_csrf_idle_metadata_and_a_durable_identity() { + let state = ready().await; + let router = app(state.clone()); + assert_eq!( + router + .clone() + .oneshot(request("handoff", Some(json!({})), false)) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + router + .clone() + .oneshot(request("handoff", Some(json!({})), true)) + .await + .unwrap() + .status(), + StatusCode::CONFLICT + ); + state.events.ingest("gpu-a", &[farhelm_protocol::AgentEvent {protocol:FARHELM_PROTOCOL.into(),agent_id:"gpu-a".into(),sequence:2,event_id:"settings-idle".into(),event_type:"codex.session.updated".into(),created_at_unix:unix_time(),payload:json!({"session_id":"s","project_id":"cc08","mode":"inspect","state":"idle","active_turn_id":null,"updated_at_unix":unix_time()})}]).unwrap(); + let first = router + .clone() + .oneshot(request("handoff", Some(json!({})), true)) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::ACCEPTED); + let first: Value = + serde_json::from_slice(&to_bytes(first.into_body(), 8192).await.unwrap()).unwrap(); + let retry = router + .oneshot(request("handoff", Some(json!({})), true)) + .await + .unwrap(); + let retry: Value = + serde_json::from_slice(&to_bytes(retry.into_body(), 8192).await.unwrap()).unwrap(); + assert_eq!(first["command_id"], retry["command_id"]); + let command = state + .typed_commands + .claim("gpu-a", unix_time()) + .unwrap() + .unwrap(); + assert_eq!(command.action, CommandAction::CodexSessionResume); + assert_eq!(command.payload.unwrap()["handoff"], true); +} diff --git a/farhelm-hub/src/lib.rs b/farhelm-hub/src/lib.rs index c52c168..b2b9033 100644 --- a/farhelm-hub/src/lib.rs +++ b/farhelm-hub/src/lib.rs @@ -52,6 +52,9 @@ include!(concat!(env!("OUT_DIR"), "/embedded_console.rs")); pub const ONLINE_WINDOW_SECS: u64 = 45; +mod codex_settings; +#[cfg(test)] +mod codex_settings_tests; mod command_store; mod event_store; mod live; @@ -346,6 +349,22 @@ pub fn app(state: AppState) -> Router { ) .route("/api/v1/projects", get(list_projects)) .route("/api/v1/projects/import", post(import_projects)) + .route( + "/api/v1/codex/sessions/{session_id}/models", + get(codex_settings::models), + ) + .route( + "/api/v1/codex/sessions/{session_id}/native", + get(codex_settings::native), + ) + .route( + "/api/v1/codex/sessions/{session_id}/name", + post(codex_settings::rename), + ) + .route( + "/api/v1/codex/sessions/{session_id}/handoff", + post(codex_settings::handoff), + ) .route( "/api/v1/codex/sessions", get(list_codex_sessions).post(create_codex_session), @@ -1486,6 +1505,16 @@ async fn send_codex_message( if request.prompt.is_empty() || request.prompt.len() > 32 * 1024 { return api_error(StatusCode::BAD_REQUEST, "invalid_prompt"); } + if request + .model_choice + .as_ref() + .is_some_and(|choice| !choice.is_valid()) + { + return api_error(StatusCode::BAD_REQUEST, "invalid_model_choice"); + } + if request.delivery == PromptDelivery::Steer && request.model_choice.is_some() { + return api_error(StatusCode::CONFLICT, "model_change_requires_queue"); + } let Some(key) = idempotency_header(&headers) else { return api_error(StatusCode::BAD_REQUEST, "missing_idempotency_key"); }; @@ -1502,6 +1531,16 @@ async fn send_codex_message( return api_error(StatusCode::INTERNAL_SERVER_ERROR, "event_store_failed"); } }; + if request.model_choice.is_some() + && !state + .agents + .read() + .await + .get(&session.agent_id) + .is_some_and(|a| a.capabilities.iter().any(|c| c == "codex.model_choice")) + { + return api_error(StatusCode::CONFLICT, "agent_upgrade_required"); + } let (action, turn_id) = match request.delivery { PromptDelivery::Queue => (CommandAction::CodexTurnStart, None), PromptDelivery::Steer => { @@ -1514,10 +1553,13 @@ async fn send_codex_message( (CommandAction::CodexTurnSteer, Some(turn_id)) } }; - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "session_id":session_id,"project_id":session.project_id,"mode":session.mode, "turn_id":turn_id,"prompt":request.prompt,"delivery":request.delivery }); + if let Some(choice) = &request.model_choice { + payload["model_choice"] = serde_json::json!(choice); + } drop(request); create_typed_response(&state, &session.agent_id, action, payload, key, 300).await } diff --git a/farhelm-hub/src/live_tests.rs b/farhelm-hub/src/live_tests.rs index 9e27ec2..5ec7ebf 100644 --- a/farhelm-hub/src/live_tests.rs +++ b/farhelm-hub/src/live_tests.rs @@ -124,7 +124,7 @@ async fn live_commands_reads_receipts_replacement_and_transient_privacy() { .header("cookie", browser_cookie()) .header("x-csrf-token", "test-csrf") .header("idempotency-key", "live-operation-00001") - .json(&json!({"prompt":"SYNTHETIC_PRIVATE_PROMPT_080","delivery":"queue"})) + .json(&json!({"prompt":"SYNTHETIC_PRIVATE_PROMPT_080","delivery":"queue","model_choice":{"model":"synthetic-local-model","reasoning_effort":"high"}})) .send() .await .unwrap() @@ -136,6 +136,10 @@ async fn live_commands_reads_receipts_replacement_and_transient_privacy() { command.payload.as_ref().unwrap()["prompt"], "SYNTHETIC_PRIVATE_PROMPT_080" ); + assert_eq!( + command.payload.as_ref().unwrap()["model_choice"], + json!({"model":"synthetic-local-model","reasoning_effort":"high"}) + ); let encoded: String = inspect .query_row( "SELECT payload_json FROM typed_commands WHERE command_id=?1", @@ -144,6 +148,7 @@ async fn live_commands_reads_receipts_replacement_and_transient_privacy() { ) .unwrap(); assert!(!encoded.contains("SYNTHETIC_PRIVATE_PROMPT_080")); + assert!(!encoded.contains("synthetic-local-model")); let report = CommandReportRequest { protocol: FARHELM_PROTOCOL.into(), agent_id: "gpu-a".into(), diff --git a/farhelm-hub/src/session_display.rs b/farhelm-hub/src/session_display.rs index d84941f..a85084e 100644 --- a/farhelm-hub/src/session_display.rs +++ b/farhelm-hub/src/session_display.rs @@ -34,6 +34,16 @@ async fn relay( agent: &str, params: Value, deadline: tokio::time::Instant, +) -> Result { + relay_method(state, agent, "codex.session.display", params, deadline).await +} + +pub(super) async fn relay_method( + state: &AppState, + agent: &str, + method: &str, + params: Value, + deadline: tokio::time::Instant, ) -> Result { let request_id = format!("read_{}", &random_token()[..20]); let (sender, receiver) = oneshot::channel(); @@ -52,7 +62,7 @@ async fn relay( .or_default() .push_back(AgentReadRequest { request_id: request_id.clone(), - method: "codex.session.display".into(), + method: method.into(), params, }); broker From 4f349d3c6f8d1f8d42702f146249888617e3f611 Mon Sep 17 00:00:00 2001 From: rike Date: Tue, 8 Sep 2026 15:56:33 +0800 Subject: [PATCH 2/2] fix(codex): preserve reading anchors through virtual measurements --- farhelm-console/e2e/codex.spec.ts | 25 +++++++++++++++---- .../src/components/codex/Conversation.tsx | 21 ++++++++++++---- .../src/components/codex/Transcript.tsx | 13 ++++++++-- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index 9a93e68..96fd564 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -425,6 +425,10 @@ test('one MiB message resumes separately from earlier turns without duplicate te test('2000 turns load through every page and retain the visible anchor when virtualization starts', async ({ page }, info) => { test.skip(info.project.name !== 'desktop', 'Long-history acceptance runs once') test.setTimeout(90000) + // Delayed layout/measurement must not move the reader when rows switch from + // ordinary DOM to estimated virtual heights, or when another page is prepended. + const cdp = await page.context().newCDPSession(page) + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }) await setup(page) const cursors: number[] = [] await page.route('**/ses-a/transcript*', (route) => { @@ -439,6 +443,10 @@ test('2000 turns load through every page and retain the visible anchor when virt for (let index = 1; index < 100; index++) { const button = page.getByRole('button', { name: '加载更早对话' }) await button.scrollIntoViewIfNeeded() + if (index <= 3) await expect.poll(() => page.locator('.conversation-scroll').evaluate(node => { + const bounds = node.getBoundingClientRect() + return [...node.querySelectorAll('[data-message-key]')].some(item => { const rect = item.getBoundingClientRect(); return rect.bottom > bounds.top && rect.top < bounds.bottom }) + })).toBe(true) const previous = await page.locator('.conversation-scroll').evaluate((node) => { const top = node.getBoundingClientRect().top const first = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > top) @@ -448,12 +456,19 @@ test('2000 turns load through every page and retain the visible anchor when virt await expect.poll(() => cursors.length).toBe(index + 1) await expect(page.locator('.conversation-scroll [aria-busy="true"]')).toHaveCount(0) if (index < 99) await expect(button).toBeEnabled() - if (index === 2 && previous) await expect.poll(async () => { - return page.locator('.conversation-scroll').evaluate((node, old) => { - const item = node.querySelector(`[data-message-key="${CSS.escape(old.key!)}"]`) - return item ? Math.abs(item.getBoundingClientRect().top - node.getBoundingClientRect().top - old.offset) : 99999 + if (index <= 3 && previous) await expect.poll(async () => { + return page.locator('.conversation-scroll').evaluate(async (node, old) => { + let largest = 0 + // Check sustained position, including delayed ResizeObserver frames. + for (let frame = 0; frame < 6; frame++) { + await new Promise(requestAnimationFrame) + const item = node.querySelector(`[data-message-key="${CSS.escape(old.key!)}"]`) + largest = Math.max(largest, item ? Math.abs(item.getBoundingClientRect().top - node.getBoundingClientRect().top - old.offset) : 99999) + } + return largest }, previous) - }).toBeLessThan(5) + }, { message: `Reading anchor after page ${index + 1}` }).toBeLessThan(5) + if (index === 3) await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }) } expect(new Set(cursors).size).toBe(100) await expect(page.getByRole('button', { name: '加载更早对话' })).toHaveCount(0) diff --git a/farhelm-console/src/components/codex/Conversation.tsx b/farhelm-console/src/components/codex/Conversation.tsx index 266f251..b93ff1d 100644 --- a/farhelm-console/src/components/codex/Conversation.tsx +++ b/farhelm-console/src/components/codex/Conversation.tsx @@ -45,27 +45,31 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const position = useRef(undefined) const restoring = useRef(false) const anchorLocked = useRef(false) + const restoredTop = useRef(undefined) const capture = useCallback(() => { // A new reading position supersedes callbacks from the previous layout. restoring.current = false const node = scroll.current; if (!node || follow.current) { anchor.current = undefined; return } const top = node.getBoundingClientRect().top - const first = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > top) + const bottom = node.getBoundingClientRect().bottom + const first = [...node.querySelectorAll('[data-message-key]')].find((item) => { const bounds = item.getBoundingClientRect(); return bounds.bottom > top && bounds.top < bottom }) anchor.current = first ? { key: first.dataset.messageKey!, turn: first.closest('[data-turn-id]')?.dataset.turnId ?? '', offset: first.getBoundingClientRect().top - top } : undefined }, []) const restore = useCallback(() => { const node = scroll.current; if (!node) return - if (follow.current) { node.scrollTop = node.scrollHeight; return } + const move = (top: number) => { node.scrollTop = top; restoredTop.current = node.scrollTop } + if (follow.current) { move(node.scrollHeight); return } const saved = anchor.current if (saved) { const target = node.querySelector(`[data-message-key="${CSS.escape(saved.key)}"]`) - if (target) { node.scrollTop += target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset; restoring.current = false; requestAnimationFrame(() => requestAnimationFrame(() => { if (anchor.current === saved) anchorLocked.current = false })) } + if (target) { move(node.scrollTop + target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset); restoring.current = false; requestAnimationFrame(() => requestAnimationFrame(() => { if (anchor.current === saved) anchorLocked.current = false })) } else if (!restoring.current && position.current?.reveal(saved.turn)) { + restoredTop.current = node.scrollTop restoring.current = true requestAnimationFrame(() => { if (anchor.current !== saved) return const target = node.querySelector(`[data-message-key="${CSS.escape(saved.key)}"]`) - if (target) node.scrollTop += target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset + if (target) move(node.scrollTop + target.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset) restoring.current = false requestAnimationFrame(() => requestAnimationFrame(() => { if (anchor.current === saved) anchorLocked.current = false })) }) @@ -152,7 +156,14 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess } />}
-
{ anchorLocked.current = false }} onTouchMove={() => { anchorLocked.current = false }} onScroll={() => { const node = scroll.current; if (!node || restoring.current || anchorLocked.current) return; follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; setAway(!follow.current); if (follow.current) setNewMessages(false); capture() }}> +
{ anchorLocked.current = false }} onTouchMove={() => { anchorLocked.current = false }} onScroll={() => { + const node = scroll.current; if (!node || restoring.current || anchorLocked.current) return + // A delayed scroll event from our own correction is not a new reading + // position. Keep the original message anchor through later measurements. + if (restoredTop.current !== undefined && Math.abs(node.scrollTop - restoredTop.current) < 1) return + restoredTop.current = undefined + follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; setAway(!follow.current); if (follow.current) setNewMessages(false); capture() + }}> {!id ? : loading && !page ?
正在读取对话历史…
: failure && !turns.length ?

{unavailable}

{failure.message}

}>
: page && !turns.length && session ? : null} {page?.next_cursor && !needsMessage && } diff --git a/farhelm-console/src/components/codex/Transcript.tsx b/farhelm-console/src/components/codex/Transcript.tsx index 10c76e9..fc1506d 100644 --- a/farhelm-console/src/components/codex/Transcript.tsx +++ b/farhelm-console/src/components/codex/Transcript.tsx @@ -42,14 +42,23 @@ export function Transcript({ turns, scroll, position, ...props }: Props) { // Native library measurements are keyed by turn, so appending text never remounts other turns. // eslint-disable-next-line react-hooks/incompatible-library -- Virtualizer owns mutable measurements and must stay outside compiler memoization. const virtual = useVirtualizer({ count: turns.length, getScrollElement: () => scroll, getItemKey, estimateSize: () => 480, overscan: 4, enabled, scrollMargin: margin }) + useLayoutEffect(() => { + // Conversation owns the visible-message anchor. The library's estimate-to-size + // compensation would otherwise apply a second scroll adjustment (e.g. 480→234). + virtual.shouldAdjustScrollPositionOnItemSizeChange = () => false + }, [virtual]) useLayoutEffect(() => { position.current = { reveal: (turn) => { const index = turns.findIndex((row) => row.turn_id === turn) if (!enabled || index < 0) return false - virtual.scrollToIndex(index, { align: 'start' }); return true + const offset = virtual.getOffsetForIndex(index, 'start')?.[0] + if (!scroll || offset === undefined) return false + // Materialize the turn once; scrollToIndex's later reconciliation would + // compete with Conversation restoring the exact message/offset within it. + scroll.scrollTop = offset; return true } } return () => { position.current = undefined } - }, [turns, position, enabled, virtual]) + }, [turns, position, enabled, virtual, scroll]) useLayoutEffect(() => { if (container.current && scroll) setMargin(container.current.getBoundingClientRect().top - scroll.getBoundingClientRect().top + scroll.scrollTop) }, [turns.length, scroll]) return
{enabled ? virtual.getVirtualItems().map((row) =>
) : turns.map((turn, index) => )}