mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-13 09:26:28 +08:00
Release v1.142.0
This commit is contained in:
@@ -63,6 +63,8 @@ internal/exec-plans/*
|
||||
!internal/exec-plans/tui-codex-snapshot-inventory.json
|
||||
!internal/exec-plans/tui-codex-snapshot-inventory.md
|
||||
!internal/exec-plans/tui-cli-surfaces-plan.md
|
||||
!internal/exec-plans/codex-lime-tui-cli-difference-report.md
|
||||
!internal/exec-plans/tui-cli-codex-sync-next-plan.md
|
||||
!internal/exec-plans/cli-codex-test-inventory.json
|
||||
!internal/exec-plans/cli-structure-inventory.json
|
||||
!internal/exec-plans/tui-structure-inventory.json
|
||||
|
||||
+21
-20
@@ -1,41 +1,42 @@
|
||||
## Lime v1.141.0
|
||||
## Lime v1.142.0
|
||||
|
||||
Simplified Chinese release notes are the primary version.
|
||||
|
||||
### New Features
|
||||
|
||||
- Added dedicated Code Mode protocol, runtime, host, and session-facade crates for stdio/gRPC, V8 execution, typed content, cancellation, reconnect, and session-lease lifecycles.
|
||||
- Extended the shared App Server CLI/TUI surface with thread resume and management, MCP/Skills/Plugin queries, model and permission controls, JSON/JSONL, prompt history, approvals, request-user-input, and queued-input editing.
|
||||
- Added authenticated remote WebSocket session transport with `ws/wss`, Bearer tokens, protocol identity checks, ping/pong, and fail-closed policy; CLI/TUI reuse the same session facade without duplicating runtime or persistence.
|
||||
- Added Codex-aligned TUI Markdown, diff, syntax highlighting, OSC 8 links, width-aware tables, clipboard/image paste, slash-command popup, static pager, transcript overlay, and queued follow-up preview.
|
||||
- Continued Codex-shaped current owners across TUI/CLI: multi-agent navigation and overview, model catalog, collaboration modes, paginated resume sessions and transcript pager, composer textarea, history search, viewport handling, and five-language terminal copy.
|
||||
- Added unified App Server execution-process lifecycle and command-event mirroring for canonical Thread/Turn/Item command-start, live-output, completion, and test-run projection.
|
||||
- Added an authenticated WebSocket foundation to `@limecloud/app-server-client` for the same JSON-RPC session, with `ws/wss`, Bearer token, tenant headers, identity/protocol handshake, ping/pong, and message limits.
|
||||
- Added Electron `safeStorage`-backed Cloud session credential storage with set/status/delete IPC; status exposes metadata only and never reads back tokens.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Tightened the App Server `command/exec` permission boundary to reject client-supplied grants and preserve fail-closed typed lowering.
|
||||
- Fixed Code Mode host/client disconnect, duplicate execution, stale-cell, pending-callback, and session-close cleanup so old-generation state cannot leak into reconnects.
|
||||
- Fixed CLI/TUI terminal lifecycle, external-editor handoff, Unicode cursor, narrow-terminal truncation, queue recovery, and failed-terminal projection boundaries.
|
||||
- Fixed regressions in file-system watching, Agent Runtime typed content, MCP notification projection, and tool-lifecycle summaries.
|
||||
- Unified bounded output capture across pipe, PTY, Windows restricted runner, remote Environment, and unified exec with stable head/tail retention, UTF-8 boundaries, and omission accounting.
|
||||
- Fixed App Server process-owner connection isolation, duplicate active `processId` rejection, disconnect cleanup, stdin/resize/terminate lifecycle, and per-process output replay.
|
||||
- Fixed TUI terminal restoration, disconnected editing, Unicode cursor behavior, narrow-terminal wrapping, queue recovery, history hydration, approval/input requests, and multi-agent projection boundaries.
|
||||
- Hardened sidecar `dataDir` and Cloud App Server endpoint validation against `undefined`/relative paths, URL credentials, query/fragment leakage, and insecure public WebSocket token connections.
|
||||
|
||||
### Improvements and Refactoring
|
||||
|
||||
- Migrated Code Mode out of the old `tool-runtime` embedded process/V8 implementation; production calls now use the four current crates, with only explicit compatibility exports left at the old boundary.
|
||||
- Converged CLI Rust command owners and Codex-shaped modules, and aligned npm root/platform launchers, native payload staging, signal forwarding, and release ordering under `packages/cli`.
|
||||
- Migrated TUI interaction, rendering, and terminal algorithms by snapshot-inventory classification while keeping canonical Thread/Turn/Item as the only session source of truth.
|
||||
- Added CLI/TUI/Code Mode, remote transport, Windows restricted-execution, Electron release-workflow, and governance guards; Cloud remains only an authenticated-transport extension point, not a production path.
|
||||
- Moved TUI aggregate state into unique `app/`, `chat_composer/`, `textarea/`, and `viewport` owners while retaining the App Server canonical projection as the sole session source of truth.
|
||||
- Reworked App Server and tool-runtime execution output around per-process bounded queues and a shared framer, removing global FIFO, tail-only capture, and lossy per-chunk UTF-8 semantics.
|
||||
- Added isolated Docker CLI configuration and active Docker-context endpoint injection to the DeepSWE harness.
|
||||
- Kept Cloud transport foundation-only: credentials, tenant isolation, reconnect, rate limits, audit, and a production endpoint remain Cloud-service prerequisites.
|
||||
|
||||
### Testing and Quality
|
||||
|
||||
- Added Code Mode protocol/runtime/host/facade lifecycle, typed-content, gRPC, reconnect, and cancellation tests, plus App Server permission-boundary regressions.
|
||||
- Expanded CLI Gate B, TUI Gate B, real stdio/PTY, npm packaged launcher, remote-auth, snapshot-inventory, terminal-rendering, and cross-platform release guards.
|
||||
- Kept five-language copy, protocol generation, script/CLI boundaries, Electron Forge, version consistency, and legacy-governance checks in the release gate.
|
||||
- `tui` Rust unit tests: 390/390 passed; `@limecloud/app-server-client`: 11 test files and 139 tests passed.
|
||||
- CLI Gate B, TUI Gate B, Agent Runtime current Electron fixtures, App Server client contracts, structure inventory, harness, legacy governance, and script-boundary checks passed.
|
||||
- Preserved five-language copy, generated protocol, Electron Forge, version-consistency, real PTY/stdio, and canonical Thread/Turn/Item identity gates.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated architecture, command, governance, quality-workflow, and CLI/TUI/Code Mode execution-plan documentation to record the single Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item path and the Cloud transport boundary.
|
||||
- Updated architecture, command, quality, roadmap, and TUI/CLI execution-plan documentation for execution processes, remote transport, credential storage, and the single Product Surface -> App Server JSON-RPC -> RuntimeCore path.
|
||||
|
||||
### Other
|
||||
|
||||
- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.141.0`.
|
||||
- Excluded local SQLite/WAL, runtime database, and `.DS_Store` artifacts under `undefined/` from this release candidate; the old Code Mode implementation is `dead/deleted`, with no parallel runtime, compat, or deprecated owner added.
|
||||
- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.142.0`.
|
||||
- The local `rusty_v8 v150.4.0` Darwin/aarch64 prebuilt archive returned 404, so the V8-dependent workspace full build was not completed locally; Windows hardware matrix, signing/notarization, and full Forge artifacts remain platform-runner evidence.
|
||||
- Excluded the untracked local Mach-O `rust_out` and ignored local build directories from the release candidate; the existing v1.141.0 tag was not deleted or overwritten.
|
||||
|
||||
**Full changes**: `v1.140.0` -> `v1.141.0`
|
||||
**Full changes**: `v1.141.0` -> `v1.142.0`
|
||||
|
||||
+21
-20
@@ -1,39 +1,40 @@
|
||||
## Lime v1.141.0
|
||||
## Lime v1.142.0
|
||||
|
||||
### 新功能
|
||||
|
||||
- 新增独立 Code Mode protocol、runtime、host 与 session facade crate,统一承载 stdio/gRPC、V8 执行、typed content、取消、重连和 session lease 生命周期。
|
||||
- CLI/TUI 继续扩展为统一 App Server 产品面:支持 Thread 恢复与管理、MCP/Skills/Plugin 查询、模型与权限控制、JSON/JSONL、prompt history、审批、request_user_input 和队列输入编辑。
|
||||
- 新增远程 WebSocket session transport,支持 `ws/wss`、Bearer token、协议身份校验、ping/pong 与 fail-closed 认证策略;CLI/TUI 复用同一 session facade,不复制 runtime 或持久化。
|
||||
- TUI 新增 Codex 对齐的 Markdown、Diff、语法高亮、OSC 8 链接、宽度感知表格、剪贴板/图片粘贴、slash command popup、static pager、transcript overlay 和 queued follow-up 预览。
|
||||
- TUI/CLI 继续按 Codex 形状拆分 current owner:多 Agent 导航与概览、模型目录、协作模式、恢复会话分页与 transcript pager、composer textarea、历史搜索、viewport 和五语言终端文案。
|
||||
- App Server 增加统一执行进程生命周期与 command event mirror,向 canonical Thread/Turn/Item 投影 command started、实时输出、完成状态和测试运行信息。
|
||||
- `@limecloud/app-server-client` 提供同一 JSON-RPC session 的认证 WebSocket foundation,支持 `ws/wss`、Bearer token、租户 header、身份/协议握手、ping/pong 和消息大小上限。
|
||||
- Electron 增加基于 `safeStorage` 的 Cloud session credential store 与 set/status/delete IPC;状态只返回 metadata,不提供 token 读取。
|
||||
|
||||
### 修复
|
||||
|
||||
- 收紧 App Server `command/exec` 权限边界,拒绝客户端注入的授权字段并保持 typed lowering fail-closed。
|
||||
- 修复 Code Mode host/client 的断线、重复 execution、stale cell、pending callback 和 session close 清理,避免旧代际状态泄漏到重连会话。
|
||||
- 修复 CLI/TUI 终端生命周期、external editor、Unicode 光标、窄终端截断、队列恢复与失败终态投影边界。
|
||||
- 修复文件系统 watch、Agent Runtime typed content、MCP 通知投影和工具生命周期摘要的回归断言。
|
||||
- 统一 pipe、PTY、Windows restricted runner、远程 Environment 和 unified exec 的有界输出捕获,保留稳定 head/tail、UTF-8 边界和 omission 统计,避免大输出阻塞或跨进程串流。
|
||||
- 修复 App Server process owner 的连接隔离、重复 active `processId`、断连清理、stdin/resize/terminate 生命周期和逐进程 output replay。
|
||||
- 修复 TUI 终端恢复、断线编辑、Unicode 光标、窄终端换行、队列恢复、历史 hydration、审批/输入请求和多 Agent 状态投影边界。
|
||||
- 收紧 sidecar `dataDir` 与 Cloud App Server endpoint 校验,拒绝 `undefined`/相对路径、URL 凭证、query/fragment 和不安全的公开 WebSocket token 连接。
|
||||
|
||||
### 优化与重构
|
||||
|
||||
- 将 Code Mode 旧 `tool-runtime` 内嵌 process/V8 实现物理迁出并删除,生产调用统一切换到四层 current crate;旧路径仅保留显式兼容导出。
|
||||
- CLI Rust 目录与命令 owner 按 Codex 形状收敛,npm 根包/平台包 launcher、native payload、signal forwarding 与发布顺序统一到 `packages/cli`。
|
||||
- TUI 交互、渲染和终端算法按 snapshot inventory 分层迁移,保持 canonical Thread/Turn/Item 为唯一会话事实源。
|
||||
- 补充 CLI/TUI/Code Mode、远程 transport、Windows restricted execution、Electron release workflow 和治理边界守卫;Cloud 仅保留经认证 transport 的扩展点,不进入生产路径。
|
||||
- 将 TUI 聚合状态迁入 `app/`、`chat_composer/`、`textarea/`、`viewport` 等唯一 current owner,继续以 App Server canonical projection 为会话事实源。
|
||||
- App Server 与 tool-runtime 的执行输出改用 per-process bounded queue 和统一 framer,删除全局 FIFO、tail-only capture 与逐 chunk lossy UTF-8 语义。
|
||||
- DeepSWE harness 支持隔离 Docker CLI 配置和当前 Docker context endpoint 注入,避免本地 daemon 配置污染 verifier。
|
||||
- Cloud transport 保持 foundation-only:凭证、租户隔离、重连、限流、审计和生产 endpoint 仍需 Cloud 服务完成后才能启用。
|
||||
|
||||
### 测试与质量
|
||||
|
||||
- 新增 Code Mode protocol/runtime/host/facade 生命周期、typed content、gRPC、重连与取消测试,并补齐 App Server 权限边界回归。
|
||||
- 扩展 CLI Gate B、TUI Gate B、真实 stdio/PTY、npm packaged launcher、远程认证、snapshot inventory、终端渲染和跨平台发布守卫。
|
||||
- 保持五语言文案、协议生成、脚本/CLI 边界、Electron Forge、版本一致性和 legacy 治理检查为发布门禁。
|
||||
- `tui` Rust 单元测试 390/390 通过;`@limecloud/app-server-client` 11 个测试文件、139 项通过。
|
||||
- CLI Gate B、TUI Gate B、Agent Runtime current Electron fixture、App Server client contracts、结构 inventory、harness、legacy governance 和脚本边界检查通过。
|
||||
- 保持五语言文案、协议生成、Electron Forge、版本一致性、真实 PTY/stdio 和 canonical Thread/Turn/Item identity 为发布门禁。
|
||||
|
||||
### 文档
|
||||
|
||||
- 更新架构、命令、治理、质量工作流与 CLI/TUI、Code Mode 执行计划,记录 Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item 的唯一业务主链及 Cloud transport 边界。
|
||||
- 更新架构、命令、质量、路线图与 TUI/CLI 执行计划,记录执行进程、远程 transport、凭证存储和唯一 Product Surface -> App Server JSON-RPC -> RuntimeCore 主链边界。
|
||||
|
||||
### 其他
|
||||
|
||||
- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.141.0`。
|
||||
- 本次发布候选排除 `undefined/` 下本地 SQLite/WAL、runtime 数据库和 `.DS_Store` 运行产物;Code Mode 旧物理实现归 `dead/deleted`,不新增平行 runtime、compat 或 deprecated owner。
|
||||
- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.142.0`。
|
||||
- 本地 `rusty_v8 v150.4.0` Darwin/aarch64 预构建 archive 返回 404,包含 V8 的 workspace 完整编译未在本机完成;Windows 真机矩阵、签名/公证和完整 Forge 产物留给对应平台 runner。
|
||||
- 发布候选排除未跟踪的本机 Mach-O `rust_out` 与被忽略的本地生成目录;不删除、不覆盖既有 v1.141.0 tag。
|
||||
|
||||
**完整变更**: `v1.140.0` -> `v1.141.0`
|
||||
**完整变更**: `v1.141.0` -> `v1.142.0`
|
||||
|
||||
@@ -23,6 +23,7 @@ export const {
|
||||
shell,
|
||||
systemPreferences,
|
||||
protocol,
|
||||
safeStorage,
|
||||
Tray,
|
||||
WebContentsView,
|
||||
} = electron;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ElectronHostCommands } from "./hostCommands";
|
||||
import type { ElectronAppServerHost } from "./appServerHost";
|
||||
import { SystemUtilityHost } from "./systemUtilityHost";
|
||||
import { VoiceModelHost } from "./voiceModelHost";
|
||||
import type { SecureCredentialStore } from "./secureCredentialStore";
|
||||
|
||||
const {
|
||||
browserWindowCtorMock,
|
||||
@@ -233,6 +234,7 @@ function createHost(
|
||||
throw new Error("App Server should not be called");
|
||||
},
|
||||
appDataRoot = userDataDir,
|
||||
secureCredentialStore: SecureCredentialStore | null = null,
|
||||
) {
|
||||
const appServerHost = {
|
||||
request,
|
||||
@@ -242,6 +244,7 @@ function createHost(
|
||||
userDataDir,
|
||||
emit,
|
||||
appDataRoot,
|
||||
secureCredentialStore,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +284,82 @@ async function withRemotePngServer<T>(
|
||||
}
|
||||
}
|
||||
|
||||
describe("ElectronHostCommands secure Cloud credential boundary", () => {
|
||||
it("只允许写入和读取 metadata,不把 token 回传 renderer", async () => {
|
||||
const userDataDir = await createTempUserDataDir();
|
||||
const store: SecureCredentialStore = {
|
||||
setCloudSessionCredential: vi.fn(async (credential) => ({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: credential.tenantId,
|
||||
endpoint: credential.endpoint,
|
||||
updatedAt: "2026-09-06T00:00:00.000Z",
|
||||
})),
|
||||
readCloudSessionCredential: vi.fn(async () => ({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
})),
|
||||
getCloudSessionCredentialMetadata: vi.fn(async () => ({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
updatedAt: "2026-09-06T00:00:00.000Z",
|
||||
})),
|
||||
deleteCloudSessionCredential: vi.fn(async () => ({ deleted: true })),
|
||||
};
|
||||
const host = createHost(
|
||||
userDataDir,
|
||||
() => undefined,
|
||||
undefined,
|
||||
userDataDir,
|
||||
store,
|
||||
);
|
||||
|
||||
await expect(
|
||||
host.invoke("cloud_session_credential_set", {
|
||||
request: {
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
updatedAt: "2026-09-06T00:00:00.000Z",
|
||||
});
|
||||
await expect(
|
||||
host.invoke("cloud_session_credential_status"),
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
updatedAt: "2026-09-06T00:00:00.000Z",
|
||||
});
|
||||
await expect(
|
||||
host.invoke("cloud_session_credential_delete"),
|
||||
).resolves.toEqual({ deleted: true });
|
||||
expect(
|
||||
JSON.stringify(store.getCloudSessionCredentialMetadata),
|
||||
).not.toContain("session-secret");
|
||||
expect(store.readCloudSessionCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("未配置 secure store 时 fail closed", async () => {
|
||||
const userDataDir = await createTempUserDataDir();
|
||||
const host = createHost(userDataDir);
|
||||
|
||||
await expect(
|
||||
host.invoke("cloud_session_credential_status"),
|
||||
).rejects.toThrow("secure credential storage is not configured");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -40,6 +40,10 @@ import { LayeredDesignProjectHost } from "./layeredDesignProjectHost";
|
||||
import { openResourceManagerWindow } from "./resourceManagerWindowHost";
|
||||
import { SystemUtilityHost } from "./systemUtilityHost";
|
||||
import { VoiceModelHost } from "./voiceModelHost";
|
||||
import type {
|
||||
CloudSessionCredential,
|
||||
SecureCredentialStore,
|
||||
} from "./secureCredentialStore";
|
||||
|
||||
type HostArgs = Record<string, unknown> | null | undefined;
|
||||
type AppServerParams = Record<string, unknown>;
|
||||
@@ -79,6 +83,7 @@ export class ElectronHostCommands {
|
||||
readonly #layeredDesignProjectHost = new LayeredDesignProjectHost();
|
||||
readonly #systemUtilityHost: SystemUtilityHost;
|
||||
readonly #voiceModelHost: VoiceModelHost;
|
||||
readonly #secureCredentialStore: SecureCredentialStore | null;
|
||||
#oauthCallbackBridgeServer: Server | null = null;
|
||||
#oauthCallbackBridgeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -87,10 +92,12 @@ export class ElectronHostCommands {
|
||||
userDataDir = app.getPath("userData"),
|
||||
emit: HostEventEmitter = () => undefined,
|
||||
appDataRoot = resolveCurrentDesktopStorageRoots(userDataDir).appDataRoot,
|
||||
secureCredentialStore: SecureCredentialStore | null = null,
|
||||
) {
|
||||
this.#appServerHost = appServerHost;
|
||||
this.#userDataDir = userDataDir;
|
||||
this.#emit = emit;
|
||||
this.#secureCredentialStore = secureCredentialStore;
|
||||
this.#systemUtilityHost = new SystemUtilityHost({
|
||||
appDataRoot,
|
||||
readConfig: () => this.#readConfig(),
|
||||
@@ -192,6 +199,12 @@ export class ElectronHostCommands {
|
||||
return null;
|
||||
case "app_server_host_diagnostics":
|
||||
return this.#appServerHost.getDiagnostics();
|
||||
case "cloud_session_credential_set":
|
||||
return await this.#setCloudSessionCredential(args);
|
||||
case "cloud_session_credential_status":
|
||||
return await this.#requireSecureCredentialStore().getCloudSessionCredentialMetadata();
|
||||
case "cloud_session_credential_delete":
|
||||
return await this.#requireSecureCredentialStore().deleteCloudSessionCredential();
|
||||
case "report_frontend_crash":
|
||||
this.#reportFrontendCrash(args);
|
||||
return { success: true };
|
||||
@@ -570,6 +583,30 @@ export class ElectronHostCommands {
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
async #setCloudSessionCredential(args: HostArgs): Promise<unknown> {
|
||||
const request = readRequest(args);
|
||||
const credential: CloudSessionCredential = {
|
||||
tenantId:
|
||||
readString(request, "tenantId") ??
|
||||
readString(request, "tenant_id") ??
|
||||
readRequiredString(request, "tenantId"),
|
||||
endpoint:
|
||||
readString(request, "endpoint") ??
|
||||
readRequiredString(request, "endpoint"),
|
||||
token: readRequiredRawString(request, "token"),
|
||||
};
|
||||
return await this.#requireSecureCredentialStore().setCloudSessionCredential(
|
||||
credential,
|
||||
);
|
||||
}
|
||||
|
||||
#requireSecureCredentialStore(): SecureCredentialStore {
|
||||
if (!this.#secureCredentialStore) {
|
||||
throw new Error("secure credential storage is not configured");
|
||||
}
|
||||
return this.#secureCredentialStore;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(
|
||||
|
||||
@@ -13,6 +13,9 @@ export const ELECTRON_HOST_COMMANDS = [
|
||||
"app_server_handle_json_lines",
|
||||
"app_server_drain_events",
|
||||
"app_server_host_diagnostics",
|
||||
"cloud_session_credential_set",
|
||||
"cloud_session_credential_status",
|
||||
"cloud_session_credential_delete",
|
||||
"get_runtime_provider_selection",
|
||||
"sync_tray_model_shortcuts",
|
||||
"take_pending_skill_package_open_requests",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ElectronBrowserTabHost, isBrowserTabCommand } from "./browserTabHost";
|
||||
import { ElectronDevHttpBridge } from "./devHttpBridge";
|
||||
import { ElectronHostCommands } from "./hostCommands";
|
||||
import { ElectronSecureCredentialStore } from "./secureCredentialStore";
|
||||
import {
|
||||
buildMainWindowChromeOptions,
|
||||
buildMainWindowStartupHtml,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
powerMonitor,
|
||||
screen,
|
||||
session,
|
||||
safeStorage,
|
||||
type MenuItemConstructorOptions,
|
||||
type OpenDialogOptions,
|
||||
protocol,
|
||||
@@ -144,6 +146,10 @@ const hostCommands = new ElectronHostCommands(
|
||||
app.getPath("userData"),
|
||||
broadcast,
|
||||
desktopStorageRoots.appDataRoot,
|
||||
new ElectronSecureCredentialStore(
|
||||
desktopStorageRoots.appDataRoot,
|
||||
safeStorage,
|
||||
),
|
||||
);
|
||||
const updateHost = new ElectronUpdateHost(broadcast, {
|
||||
open: openUpdateNotificationWindow,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ElectronSecureCredentialStore,
|
||||
type SafeStorageLike,
|
||||
} from "./secureCredentialStore";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function createSafeStorage(available = true): SafeStorageLike {
|
||||
return {
|
||||
isEncryptionAvailable: () => available,
|
||||
encryptString: (plainText) =>
|
||||
Buffer.from(Buffer.from(plainText, "utf8").toString("base64url"), "utf8"),
|
||||
decryptString: (encrypted) =>
|
||||
Buffer.from(encrypted.toString("utf8"), "base64url").toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(
|
||||
path.join(os.tmpdir(), "lime-secure-credentials-"),
|
||||
);
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("ElectronSecureCredentialStore", () => {
|
||||
it("加密保存 Cloud session,并只返回不含 token 的 metadata", async () => {
|
||||
const root = await createRoot();
|
||||
const store = new ElectronSecureCredentialStore(root, createSafeStorage());
|
||||
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
});
|
||||
|
||||
const stored = await readFile(
|
||||
path.join(root, "credentials", "app-server-session.v1"),
|
||||
"utf8",
|
||||
);
|
||||
expect(stored).not.toContain("session-secret");
|
||||
await expect(
|
||||
store.getCloudSessionCredentialMetadata(),
|
||||
).resolves.toMatchObject({
|
||||
available: true,
|
||||
exists: true,
|
||||
tenantId: "tenant-001",
|
||||
});
|
||||
await expect(store.readCloudSessionCredential()).resolves.toEqual({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝不安全 endpoint、空 token 和明文 ws 公网地址", async () => {
|
||||
const root = await createRoot();
|
||||
const store = new ElectronSecureCredentialStore(root, createSafeStorage());
|
||||
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "https://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
}),
|
||||
).rejects.toThrow("endpoint must use ws:// or wss://");
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server?token=secret",
|
||||
token: "session-secret",
|
||||
}),
|
||||
).rejects.toThrow("endpoint must not contain userinfo, query, or fragment");
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "ws://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
}),
|
||||
).rejects.toThrow("insecure ws:// endpoints must be loopback URLs");
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: " ",
|
||||
}),
|
||||
).rejects.toThrow("token must not be empty");
|
||||
});
|
||||
|
||||
it("加密能力不可用时拒绝写入,但删除仍可幂等执行", async () => {
|
||||
const root = await createRoot();
|
||||
const store = new ElectronSecureCredentialStore(
|
||||
root,
|
||||
createSafeStorage(false),
|
||||
);
|
||||
|
||||
await expect(
|
||||
store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
}),
|
||||
).rejects.toThrow("secure credential storage is unavailable");
|
||||
await expect(store.getCloudSessionCredentialMetadata()).resolves.toEqual({
|
||||
available: false,
|
||||
exists: false,
|
||||
});
|
||||
await expect(store.deleteCloudSessionCredential()).resolves.toEqual({
|
||||
deleted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("损坏的密文 fail closed,删除后回到空状态", async () => {
|
||||
const root = await createRoot();
|
||||
const filePath = path.join(root, "credentials", "app-server-session.v1");
|
||||
const store = new ElectronSecureCredentialStore(root, createSafeStorage());
|
||||
await store.setCloudSessionCredential({
|
||||
tenantId: "tenant-001",
|
||||
endpoint: "wss://gateway.example.test/v1/app-server",
|
||||
token: "session-secret",
|
||||
});
|
||||
await writeFile(filePath, "corrupt", "utf8");
|
||||
|
||||
await expect(store.readCloudSessionCredential()).rejects.toThrow(
|
||||
"stored Cloud session credential is invalid",
|
||||
);
|
||||
await expect(store.deleteCloudSessionCredential()).resolves.toEqual({
|
||||
deleted: true,
|
||||
});
|
||||
await expect(store.getCloudSessionCredentialMetadata()).resolves.toEqual({
|
||||
available: true,
|
||||
exists: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const CREDENTIALS_DIRECTORY = "credentials";
|
||||
const CREDENTIAL_FILE_NAME = "app-server-session.v1";
|
||||
const CREDENTIAL_VERSION = 1;
|
||||
const MAX_TENANT_ID_LENGTH = 256;
|
||||
const MAX_ENDPOINT_LENGTH = 2_048;
|
||||
const MAX_TOKEN_LENGTH = 64 * 1024;
|
||||
|
||||
export type CloudSessionCredential = {
|
||||
tenantId: string;
|
||||
endpoint: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CloudSessionCredentialMetadata = {
|
||||
available: boolean;
|
||||
exists: boolean;
|
||||
tenantId?: string;
|
||||
endpoint?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export interface SecureCredentialStore {
|
||||
setCloudSessionCredential(
|
||||
credential: CloudSessionCredential,
|
||||
): Promise<CloudSessionCredentialMetadata>;
|
||||
readCloudSessionCredential(): Promise<CloudSessionCredential | null>;
|
||||
getCloudSessionCredentialMetadata(): Promise<CloudSessionCredentialMetadata>;
|
||||
deleteCloudSessionCredential(): Promise<{ deleted: boolean }>;
|
||||
}
|
||||
|
||||
export interface SafeStorageLike {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encryptString(plainText: string): Buffer;
|
||||
decryptString(encrypted: Buffer): string;
|
||||
}
|
||||
|
||||
type CredentialEnvelope = {
|
||||
version: typeof CREDENTIAL_VERSION;
|
||||
credential: CloudSessionCredential;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export class ElectronSecureCredentialStore implements SecureCredentialStore {
|
||||
readonly #filePath: string;
|
||||
readonly #safeStorage: SafeStorageLike;
|
||||
|
||||
constructor(appDataRoot: string, safeStorage: SafeStorageLike) {
|
||||
this.#filePath = path.join(
|
||||
path.resolve(appDataRoot),
|
||||
CREDENTIALS_DIRECTORY,
|
||||
CREDENTIAL_FILE_NAME,
|
||||
);
|
||||
this.#safeStorage = safeStorage;
|
||||
}
|
||||
|
||||
async setCloudSessionCredential(
|
||||
credential: CloudSessionCredential,
|
||||
): Promise<CloudSessionCredentialMetadata> {
|
||||
const normalized = normalizeCredential(credential);
|
||||
this.#assertEncryptionAvailable();
|
||||
|
||||
const envelope: CredentialEnvelope = {
|
||||
version: CREDENTIAL_VERSION,
|
||||
credential: normalized,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const encrypted = this.#safeStorage.encryptString(JSON.stringify(envelope));
|
||||
const directory = path.dirname(this.#filePath);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
|
||||
const tempPath = `${this.#filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
let moved = false;
|
||||
try {
|
||||
await writeFile(tempPath, encrypted, { mode: 0o600 });
|
||||
try {
|
||||
await rename(tempPath, this.#filePath);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object"
|
||||
? (error as NodeJS.ErrnoException).code
|
||||
: undefined;
|
||||
if (code !== "EEXIST" && code !== "EPERM") {
|
||||
throw error;
|
||||
}
|
||||
await rm(this.#filePath, { force: true });
|
||||
await rename(tempPath, this.#filePath);
|
||||
}
|
||||
moved = true;
|
||||
} finally {
|
||||
if (!moved) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return metadataFromEnvelope(envelope, true);
|
||||
}
|
||||
|
||||
async readCloudSessionCredential(): Promise<CloudSessionCredential | null> {
|
||||
const envelope = await this.#readEnvelope();
|
||||
return envelope?.credential ?? null;
|
||||
}
|
||||
|
||||
async getCloudSessionCredentialMetadata(): Promise<CloudSessionCredentialMetadata> {
|
||||
const exists = await fileExists(this.#filePath);
|
||||
const available = this.#isEncryptionAvailable();
|
||||
if (!exists || !available) {
|
||||
return { available, exists };
|
||||
}
|
||||
|
||||
const envelope = await this.#readEnvelope();
|
||||
return envelope
|
||||
? metadataFromEnvelope(envelope, true)
|
||||
: { available, exists: false };
|
||||
}
|
||||
|
||||
async deleteCloudSessionCredential(): Promise<{ deleted: boolean }> {
|
||||
const existed = await fileExists(this.#filePath);
|
||||
await rm(this.#filePath, { force: true });
|
||||
return { deleted: existed };
|
||||
}
|
||||
|
||||
async #readEnvelope(): Promise<CredentialEnvelope | null> {
|
||||
if (!(await fileExists(this.#filePath))) {
|
||||
return null;
|
||||
}
|
||||
this.#assertEncryptionAvailable();
|
||||
|
||||
try {
|
||||
const encrypted = await readFile(this.#filePath);
|
||||
const decoded = JSON.parse(
|
||||
this.#safeStorage.decryptString(encrypted),
|
||||
) as unknown;
|
||||
return parseEnvelope(decoded);
|
||||
} catch {
|
||||
throw new Error("stored Cloud session credential is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#isEncryptionAvailable(): boolean {
|
||||
try {
|
||||
return this.#safeStorage.isEncryptionAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#assertEncryptionAvailable(): void {
|
||||
if (!this.#isEncryptionAvailable()) {
|
||||
throw new Error("secure credential storage is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCredential(
|
||||
credential: CloudSessionCredential,
|
||||
): CloudSessionCredential {
|
||||
const tenantId = normalizeField(
|
||||
credential.tenantId,
|
||||
"tenantId",
|
||||
MAX_TENANT_ID_LENGTH,
|
||||
);
|
||||
const endpoint = normalizeEndpoint(credential.endpoint);
|
||||
const token = normalizeField(credential.token, "token", MAX_TOKEN_LENGTH);
|
||||
return { tenantId, endpoint, token };
|
||||
}
|
||||
|
||||
function normalizeField(
|
||||
value: unknown,
|
||||
name: string,
|
||||
maxLength: number,
|
||||
): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${name} must be a string`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`${name} must not be empty`);
|
||||
}
|
||||
if (normalized.length > maxLength) {
|
||||
throw new Error(`${name} exceeds the maximum length`);
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/u.test(normalized)) {
|
||||
throw new Error(`${name} contains control characters`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value: unknown): string {
|
||||
const endpoint = normalizeField(value, "endpoint", MAX_ENDPOINT_LENGTH);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error("endpoint must be a valid WebSocket URL");
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error("endpoint must use ws:// or wss://");
|
||||
}
|
||||
if (!url.hostname || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("endpoint must not contain userinfo, query, or fragment");
|
||||
}
|
||||
if (url.protocol === "ws:" && !isLoopbackUrl(url)) {
|
||||
throw new Error("insecure ws:// endpoints must be loopback URLs");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isLoopbackUrl(url: URL): boolean {
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "[::1]" ||
|
||||
(isIP(hostname) === 4 && hostname.startsWith("127.")) ||
|
||||
(isIP(hostname) === 6 && hostname === "::1")
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvelope(value: unknown): CredentialEnvelope {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("invalid credential envelope");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== CREDENTIAL_VERSION ||
|
||||
typeof record.updatedAt !== "string"
|
||||
) {
|
||||
throw new Error("invalid credential envelope");
|
||||
}
|
||||
const credential = record.credential;
|
||||
if (
|
||||
!credential ||
|
||||
typeof credential !== "object" ||
|
||||
Array.isArray(credential)
|
||||
) {
|
||||
throw new Error("invalid credential envelope");
|
||||
}
|
||||
const normalized = normalizeCredential(credential as CloudSessionCredential);
|
||||
return {
|
||||
version: CREDENTIAL_VERSION,
|
||||
credential: normalized,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function metadataFromEnvelope(
|
||||
envelope: CredentialEnvelope,
|
||||
available: boolean,
|
||||
): CloudSessionCredentialMetadata {
|
||||
return {
|
||||
available,
|
||||
exists: true,
|
||||
tenantId: envelope.credential.tenantId,
|
||||
endpoint: envelope.credential.endpoint,
|
||||
updatedAt: envelope.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,9 @@ Future Cloud -> authenticated transport ----> LimeCore gateway
|
||||
-> ThreadStore + Thread/Turn/Item projection
|
||||
```
|
||||
|
||||
`Product Surface`、`Host/Transport` 与业务 runtime 是三层边界。Desktop、TUI、CLI 可以拥有各自的窗口/终端生命周期、输入法、快捷键和展示投影,但不能拥有 provider loop、工具 registry、approval authority、Thread/Turn/Item 状态机或持久化副本。`app-server-protocol` 是跨 surface 合同,`app-server-client` 是 Rust surface 的连接/session owner;本地 stdio 是当前实现,未来 Cloud 通过同一 session facade 接入认证后的远端 transport。TUI 的 `resume` picker 只读取 App Server `thread/list`,选中后进入标准 `thread/resume`;连接中断只由 TUI session owner 做 bounded reconnect,并以原 Thread id hydrate canonical history,composer draft 保持在 surface 内存中,旧 connection 的 pending approval 丢弃且不得跨连接回放。TUI 的 effort/permission 快捷键只 lowering 到 `thread/settings/update`;`/model` picker 只消费 typed `model/list` 的可见 catalog,并在选择后 lowering 到同一 settings method,不复制 Codex 配置或 provider catalog。
|
||||
`Product Surface`、`Host/Transport` 与业务 runtime 是三层边界。Desktop、TUI、CLI 可以拥有各自的窗口/终端生命周期、输入法、快捷键和展示投影,但不能拥有 provider loop、工具 registry、approval authority、Thread/Turn/Item 状态机或持久化副本。`app-server-protocol` 是跨 surface 合同,`app-server-client` 是 Rust surface 的连接/session owner;本地 stdio 是当前实现,未来 Cloud 通过同一 session facade 接入认证后的远端 transport。TUI 的 `resume` picker 与 Codex-shaped `Agents Overview` 只读取 App Server `thread/list`,选中后进入标准 `thread/resume`;Overview 的状态分组、子线程状态冒泡与刷新仅投影 canonical Thread/notification,不访问私有 daemon/state DB。Overview 的新任务、改名和停止操作分别 lowering 到现有 `thread/start` + `turn/start`、`thread/name/set` 与 `turn/interrupt`,不复制 Codex agents daemon;刷新请求使用单一 request identity,合并 pending 请求并重放刷新期间到达的最新 thread notification。TUI 在 `app/app_server_event_targets.rs` 复用 Codex 同名 `server_notification_thread_target`、`server_request_thread_id` 与 `ServerNotificationThreadTarget`:Thread-scoped notification 可以更新 Overview/导航,但只有当前 Thread 能写入当前 `ConversationProjection`;foreign Thread 的可交互 server request 进入对应 `ThreadEventStore` 的 bounded replay channel,`pending_interactive_replay` 只允许仍未解决的 request 在 `thread/resume` 后重放;Dynamic Tool 与 MCP elicitation 当前没有 TUI consumer,立即 fail closed,buffer 满时也直接 reject,不得落入当前 Thread 的 BottomPane。连接中断只由 TUI `app/reconnect.rs` session owner 做 bounded reconnect,并以原 Thread id hydrate canonical history,composer draft 保持在 surface 内存中,旧 connection 的 pending approval 丢弃且不得跨连接回放。TUI 的 `app/app_server_events.rs` 只负责 transport event 分流和 notification dispatch;`app/app_server_requests.rs` 负责 reverse server request routing 与 reject/queue 语义;`app/thread_events.rs` 负责 canonical Thread/Turn/Item notification projection 和 agent liveness;`app/pending_interactive_replay.rs` 负责 Codex 对齐的 pending request identity;`app/thread_settings.rs` 负责当前 thread 的 model/provider、effort、permission profile 与 collaboration mode read model;`app/event_dispatch.rs` 的 `App::handle_event` 负责 App Server-backed settings、协作模式、权限和 Agents Overview action。TUI 的 effort/permission 快捷键只 lowering 到 `thread/settings/update`;`/model` picker 只消费 typed `model/list` 的可见 catalog,并在选择后 lowering 到同一 settings method,不复制 Codex 配置或 provider catalog。
|
||||
|
||||
TUI 的终端输入与绘制调度 owner 对齐 Codex `tui`:`tui::EventBroker` 统一持有可暂停/恢复的 crossterm 输入源,`tui::TuiEventStream` 将 key、paste、resize、focus 和 draw 归一化后交给 runtime;`tui::FrameRequester` 与 `frame_rate_limiter` 合并异步重绘并限制频率。`TerminalGuard` 只负责 terminal mode 生命周期,并在外部编辑器或恢复流程中暂停 broker,确保 stdin 不被后台 reader 占用。该层不得承接 App Server 请求、Thread 状态或第二套业务事件总线。
|
||||
TUI 的终端输入与绘制调度 owner 对齐 Codex `tui`:`tui::EventBroker` 统一持有可暂停/恢复的 crossterm 输入源,`tui::TuiEventStream` 将 key、paste、resize、focus 和 draw 归一化后交给 runtime;`tui::FrameRequester` 与 `frame_rate_limiter` 合并异步重绘并限制频率。workspace 级 crossterm 固定使用 Codex 同源的 `openai-oss-forks/crossterm` revision `45fecb9508105988f42fe6ff0441783ed3717f92`,其 terminal readiness 和外部消费输入修复是 external editor 交接的唯一依赖事实源。`tui::Tui` 只负责 terminal mode 生命周期,并在外部编辑器或恢复流程中暂停 broker,确保 stdin 不被后台 reader 占用。该层不得承接 App Server 请求、Thread 状态或第二套业务事件总线。真实 TUI Gate B 使用 PTY 驱动键盘和 alternate screen,并按 Codex 测试依赖使用 `vt100::Parser` 还原关闭前的实际屏幕;不能用删除 ANSI 后的字节拼接冒充用户可见状态。
|
||||
|
||||
CLI 的 npm 分发边界对齐 `/Users/coso/Documents/dev/rust/codex/codex-cli`:`@limecloud/lime` 根包只发布 ESM launcher,并通过 optional dependency alias 选择平台包;平台包在 `vendor/<target-triple>/bin` 原子携带 `lime`、`app-server`、`code-mode-host`、Windows sandbox helpers 与 App Server 所需动态运行库。launcher 只负责平台解析、包管理器归属、参数/stdin/stdout 转发、signal forwarding 和退出原因镜像,不下载 release asset、不回退 `cargo run`,也不承接 App Server 业务。平台包必须先于根包串行发布,避免根包引用尚不存在的载荷版本;尚无真实构建/运行证据的平台不进入 optional dependency catalog。
|
||||
|
||||
@@ -113,6 +113,7 @@ Renderer 可以临时保存输入框、选择态、展开态和 optimistic UI;
|
||||
| `electron/preload.ts` | `contextBridge`、最小暴露面和 IPC 调用入口。 |
|
||||
| `electron/ipcChannels.ts` | IPC channel 常量与协议白名单。 |
|
||||
| `electron/*Host.ts` | 一个桌面能力一个 owner,例如 App Server sidecar、文件/项目壳、窗口、通知、更新、浏览器、语音。 |
|
||||
| `electron/secureCredentialStore.ts` | 使用 Electron `safeStorage` 保存受管 Cloud session credential;只向 IPC 投影存在性和 metadata。 |
|
||||
| `electron/desktopResourceReadiness.ts` | 运行时读取并校验 macOS/Windows desktop resource manifest 的身份与必需资源存在性;不替代发布 verifier。 |
|
||||
| `electron/appServerHost.ts` | sidecar 生命周期与 `app_server_handle_json_lines` 的宿主边界。 |
|
||||
| `electron/forge/`、`forge.config.mjs` | Forge 打包、maker、签名和 release 事实源。 |
|
||||
@@ -121,6 +122,12 @@ Electron 负责窗口、托盘、Dock、系统文件选择、权限、外部链
|
||||
|
||||
新增 Electron 命令前先判断是否只是已有 `app_server_handle_json_lines` 的转发。只有系统宿主能力才新增 IPC;业务能力一律优先新增 App Server JSON-RPC。
|
||||
|
||||
Cloud session credential 是 Desktop Host 的平台能力,不是 Renderer 或 App Server 的第二份
|
||||
业务状态。`secureCredentialStore` 只接受经过校验的 `ws/wss` endpoint 和非空 token,使用
|
||||
`safeStorage` 加密写入 `AppDataRoot/credentials`;Renderer 只能写入、读取 metadata 或删除,
|
||||
不能通过 IPC 读取 token。safeStorage 不可用时 fail closed,且本能力不会改变 Cloud endpoint
|
||||
默认关闭的裁决。
|
||||
|
||||
App Server 发起的 reverse JSON-RPC request 仍复用同一 JSONL/stdio、`app_server_drain_events` 与 `app_server_handle_json_lines` 通道。Electron 只负责从 typed connection drain notification/request,并把 Renderer 的 Response/Error 原样写回 sidecar;不得解释 server request method、生成业务 decision、持有 pending waiter 或把 request 降级成 Electron IPC 业务命令。
|
||||
|
||||
Desktop Host 的 sidecar 启动/恢复诊断属于宿主旁路,不是 App Server 业务 read model。`app_server_host_diagnostics` 是只读 Electron IPC 命令,由 `ElectronAppServerHost` 返回有界、脱敏的启动阶段、连接代际、sidecar 运行状态和最近失败上下文;Renderer 只能通过 `src/lib/api/desktopHostDiagnostics.ts` 做 schema 校验后把它并入现有 crash diagnostic bundle。该命令不得承载 Thread/Turn/Item、provider、工具或 session 状态,也不得替代 `app_server_handle_json_lines`。
|
||||
@@ -904,12 +911,12 @@ Pending 必须在 canonical/projected/in-memory 的 Thread/session read/list、r
|
||||
|
||||
| Crate | 准入 |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `config`、`infra`、`core` | 配置、平台无关基础设施和稳定公共模型;不接受默认塞入的新 runtime 逻辑。 |
|
||||
| `services`、`processor` | 有明确领域 owner 的服务与处理器;中心 facade 只做 dispatch。 |
|
||||
| `knowledge`、`embedding`、`document-preview` | 独立领域能力。 |
|
||||
| `gateway`、`websocket`、`server`、`server-utils` | 网络/服务边界。 |
|
||||
| `providers` / `lime-providers` | `dead / deleted / forbidden-to-restore`;provider 网络、wire lowering、stream、catalog 只归 `model-provider`。 |
|
||||
| `scheduler`、`automation_execution` 对应 owner | 调度与自动化领域,不承接 turn loop。 |
|
||||
| `config`、`infra`、`core` | 配置、平台无关基础设施和稳定公共模型;不接受默认塞入的新 runtime 逻辑。 |
|
||||
| `services`、`processor` | 有明确领域 owner 的服务与处理器;中心 facade 只做 dispatch。 |
|
||||
| `knowledge`、`embedding`、`document-preview` | 独立领域能力。 |
|
||||
| `gateway`、`websocket`、`server`、`server-utils` | 网络/服务边界。 |
|
||||
| `providers` / `lime-providers` | `dead / deleted / forbidden-to-restore`;provider 网络、wire lowering、stream、catalog 只归 `model-provider`。 |
|
||||
| `scheduler`、`automation_execution` 对应 owner | 调度与自动化领域,不承接 turn loop。 |
|
||||
| `cli` | Rust CLI 入口,只通过 `app-server-client` 消费 App Server 产品协议;`execpolicy check` 只读取规则文件并输出匹配结果,不承接执行或 sandbox authority。 |
|
||||
|
||||
新增 Rust crate 必须说明:现有 domain 为什么不适合、公开 contract 是什么、依赖方向是什么、如何避免落入 `core`/`services` 平铺层。
|
||||
@@ -2382,14 +2389,28 @@ Renderer commandExec gateway
|
||||
-> typed App Server client
|
||||
-> command/exec
|
||||
-> App Server CommandExecServer keyed by (ConnectionId, processId)
|
||||
-> App Server ExecutionProcessServer keyed by connection-scoped internal owner id
|
||||
-> tool-runtime LocalExecutionProcessHandle
|
||||
|
||||
command/exec/write|resize|terminate
|
||||
command/exec/write|terminate
|
||||
-> same (ConnectionId, processId)
|
||||
-> ExecutionProcessServer owner for stdin/terminate/timeout
|
||||
-> same supervisor control handle
|
||||
command/exec/resize
|
||||
-> same (ConnectionId, processId)
|
||||
-> same session control handle
|
||||
command/exec/outputDelta
|
||||
-> same owner connection, raw bytes as deltaBase64
|
||||
```
|
||||
|
||||
公开 `processId` 仍只在 originating `ConnectionId` 内有效;为避免不同连接复用公开 ID 时污染全局执行索引,
|
||||
`CommandExecServer` 将其 lowering 为 `command-exec-{connectionId}-{processId}` base internal owner id;当该 base
|
||||
owner 仍被终态 snapshot/output 保留时,为下一次合法复用追加 instance UUID。该 internal id 不进入 command/exec
|
||||
response 或 notification,但启动、stdout/stderr delta、终态 snapshot、超时终止、stdin 写入和断连清理均通过同一
|
||||
`ExecutionProcessServer` owner;PTY resize 继续使用同一 session control handle。这样 command/exec 与 Thread shell、
|
||||
unified exec 和 background terminal 共享 process lifecycle/status/output owner,而不复制协议层 session 映射或改变
|
||||
公开 connection-scoped 语义。
|
||||
|
||||
`permissionProfile` 是唯一允许的 profile 选择入口。App Server 在 `command/exec` ingress 根据当前 YAML
|
||||
`default_permissions`、named profile inheritance、请求 cwd 和平台 sandbox readiness 解析 profile,再把
|
||||
`sandboxPolicy` 与内部 `GrantedPermissionProfile` lowering 给 `tool-runtime`;`grantedPermissions` 不属于
|
||||
@@ -2403,14 +2424,33 @@ output cap 为 1 MiB,超时退出码为 `124`;PTY resize 只允许正数尺
|
||||
Desktop terminal 只消费 `src/lib/api/commandExec.ts`,通过 xterm 展示真实 outputDelta,不在 Renderer 伪造 prompt、
|
||||
session reconnect、明文 stdin 或 fallback output。Electron 仅承担既有 JSONL sidecar 转发职责。
|
||||
|
||||
v2 protocol/schema、CommandExecServer、connection cleanup、typed client、Renderer gateway、GUI terminal 和负向回流
|
||||
guard 为 `current`;旧 `project_shell_*`、`run_project_shell_command`、Project Shell v0 DTO/schema、旧 gateway 与
|
||||
Electron host 为 `dead / deleted / forbidden-to-restore`;`compat` 与 `deprecated` 均为空。
|
||||
v2 protocol/schema、CommandExecServer、connection cleanup、`ExecutionProcessServer` lifecycle owner、typed client、
|
||||
Renderer gateway、GUI terminal 和负向回流 guard 为 `current`;旧 `project_shell_*`、`run_project_shell_command`、
|
||||
Project Shell v0 DTO/schema、旧 gateway 与 Electron host 为 `dead / deleted / forbidden-to-restore`;`compat` 与
|
||||
`deprecated` 均为空。
|
||||
|
||||
Architecture impact: major because a public JSON-RPC command family replaced the private Project Shell IPC/session owner.
|
||||
Architecture map updated: sections 6.1, 33.1 and command boundary document. Responsible developer confirmation: root,
|
||||
2026-08-08. Confirmation content: 已核对 Desktop/App Server/runtime owner、ConnectionId 隔离、notification 顺序、
|
||||
raw bytes lowering、删除边界和 GUI/contract 验证门禁。
|
||||
Architecture impact: major because a public JSON-RPC command family replaced the private Project Shell IPC/session owner,
|
||||
and command/exec lifecycle now converges on the shared App Server process owner. Architecture map updated: sections 6.1,
|
||||
33.1 and command boundary document. Responsible developer confirmation: root, 2026-09-06. Confirmation content: 已核对
|
||||
Desktop/App Server/runtime owner、公开与 internal process ID 隔离、notification 顺序、raw bytes lowering、删除边界和
|
||||
GUI/contract 验证门禁。
|
||||
|
||||
### 33.2 Unified exec command/test lifecycle
|
||||
|
||||
Thread 内的 Codex `exec_command` 不再把 `Command` Item 当成只在终态读取的 batch outcome。`tool-runtime::unified_exec`
|
||||
从同一个 `RuntimeLiveExecutionGateway` 读取 `ExecutionProcessSnapshot`,把 `processId`、`executionProcessStatus`、
|
||||
`stdinWritable`、bounded output counters、exit code 与 `executionSurface=unified_exec` 写入每次 canonical result;
|
||||
因此短命令、yield 后仍运行的命令和 `write_stdin` 续接都保留同一 process identity。App Server
|
||||
`runtime_backend::coding_events::CodingEventMirror` 消费 `Command` Item 的 `ItemStarted -> ItemUpdated ->
|
||||
ItemCompleted`,统一产出 `command.started/output/exited` 与 test command 的 `test.started/completed`,Workbench/read
|
||||
model 不再只依赖最后一个 ToolEnd metadata。需要 workspace sandbox backend 的命令仍由 Agent sandbox executor 承接;
|
||||
unified exec 只能通过 `RuntimeLiveExecutionGateway -> ExecutionProcessServer`,不能降级裸进程或创建并行 owner。
|
||||
|
||||
这一变更仍沿用 `Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`,不新增协议方法、
|
||||
不把 process owner 暴露为第二套 Renderer API。architecture impact: major because canonical command/test lifecycle 的
|
||||
事实源从终态 batch metadata 收敛为共享 process snapshot + Item lifecycle。Responsible developer confirmation: root,
|
||||
2026-09-06. Confirmation content: 已核对 unified exec、ExecutionProcessServer、Command Item lifecycle、sandbox fail-closed
|
||||
分支、read model hydrate 与现有 command/exec owner 的依赖方向。
|
||||
|
||||
## 34. Exact Filesystem Owner
|
||||
|
||||
|
||||
@@ -29,17 +29,25 @@ All surfaces
|
||||
|
||||
| 需求 | Owner |
|
||||
| --------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain |
|
||||
| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` |
|
||||
| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` |
|
||||
| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host |
|
||||
| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package |
|
||||
| TUI composer、terminal lifecycle、terminal projection | `tui` |
|
||||
| CLI 参数、非交互输出、进程退出码 | `cli` |
|
||||
| 本地/未来远端 App Server 会话、请求并发和 reverse request | `app-server-client` |
|
||||
| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain |
|
||||
| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` |
|
||||
| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` |
|
||||
| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host |
|
||||
| Cloud session credential 的平台加密保存、删除与 metadata | `electron/secureCredentialStore.ts` + Desktop Host IPC |
|
||||
| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package |
|
||||
| TUI composer、terminal lifecycle、terminal projection | `tui` |
|
||||
| CLI 参数、非交互输出、进程退出码 | `cli` |
|
||||
| 本地/未来远端 App Server 会话、请求并发和 reverse request | `app-server-client` |
|
||||
|
||||
禁止为业务调用新增第二个 Electron/CLI 后端、renderer mock fallback、临时 DevBridge 命令或 legacy wrapper。生产失败必须显式失败;mock 仅在测试夹具中显式注入。
|
||||
|
||||
Cloud session credential 的 IPC 只允许 `cloud_session_credential_set`、
|
||||
`cloud_session_credential_status` 和 `cloud_session_credential_delete`。写入由
|
||||
Electron `safeStorage` 加密后落在统一 `AppDataRoot/credentials`,状态接口只返回
|
||||
`exists`、`tenantId`、endpoint 和更新时间,不提供 token 读取 IPC。token 不得进入 URL、
|
||||
query、Debug 或日志;safeStorage 不可用时必须 fail closed。该 owner 只是 Cloud
|
||||
transport foundation,不会启用默认远端 App Server,也不替代 App Server JSON-RPC。
|
||||
|
||||
## TUI/CLI 与 Cloud transport 边界
|
||||
|
||||
`app-server-client` 是所有非 Renderer Rust surface 的会话 owner。它负责 JSON-RPC request id、并发 pending request、notification、reverse server request、断线与关闭;`tui` 和 `cli` 只能消费其 typed facade。当前 production transport 支持本地 stdio 与受控 authenticated WebSocket foundation。transport trait 是两种实现共用的依赖倒置边界;远端 token 仅允许 `wss://` 或 loopback `ws://`,remote 默认固定 `app-server` 身份和 `appserver.v0` 协议版本,生产 Cloud endpoint 仍需在租户、重连和审计策略明确后单独启用,不增加假 endpoint 或 fallback。
|
||||
@@ -182,11 +190,14 @@ root 并重建受管目录,不删除 Thread/Turn/Item、event log、projection
|
||||
|
||||
Codex exact 独立命令执行与 Desktop 交互终端只允许走 connection-scoped App Server JSON-RPC:
|
||||
|
||||
`src/lib/api/commandExec.ts -> typed App Server client -> command/exec -> App Server CommandExecServer -> tool-runtime local process supervisor`
|
||||
`src/lib/api/commandExec.ts -> typed App Server client -> command/exec -> App Server CommandExecServer -> ExecutionProcessServer -> tool-runtime local process supervisor`
|
||||
|
||||
一次性命令通过 `command/exec` 返回 `exitCode/stdout/stderr`;流式命令通过
|
||||
`command/exec/outputDelta` 投影 raw bytes 的 `deltaBase64`,并由同一连接内的 `processId` 过滤。交互终端的
|
||||
输入、PTY 尺寸和终止分别使用 `command/exec/write`、`command/exec/resize`、`command/exec/terminate`。
|
||||
输入、PTY 尺寸和终止分别使用 `command/exec/write`、`command/exec/resize`、`command/exec/terminate`;stdin 写入、超时终止、
|
||||
断连清理和 lifecycle/status/output owner 统一复用内部 `ExecutionProcessServer`。公开 `processId` 仍按
|
||||
`ConnectionId` 隔离,内部 owner ID 以 `command-exec-{connectionId}-{processId}` 为 base;终态 owner 仍保留时,后续
|
||||
复用追加 instance UUID,不进入 command/exec wire。
|
||||
`outputBytesCap`、`timeoutMs` 保持 omitted/null/value 语义;stdin close 后的非空写入、非 TTY resize、零值尺寸、
|
||||
未知 process id 和同一连接重复 active id 均 fail closed。断连、response 发送失败或 notification writer 失败都清理该
|
||||
连接拥有的进程。Electron 只转发 App Server JSONL,不持有第二套终端会话、轮询 drain 或 renderer mock fallback。
|
||||
|
||||
@@ -28,12 +28,14 @@
|
||||
- 路线图主线:`internal/roadmap/`
|
||||
- Runtime 当前对齐入口:`internal/aiprompts/architecture.md` 与 `internal/exec-plans/codex-alignment-v1-coordination-plan.md`
|
||||
- Refactor v2 全项目 Gate A/B 验收计划:`internal/exec-plans/project-gate-a-b-acceptance-plan.md`
|
||||
- 当前发布执行计划:`internal/exec-plans/release-v1.141.0-plan.md`
|
||||
- 当前发布执行计划:`internal/exec-plans/release-v1.142.0-plan.md`
|
||||
- 已结束版本的发布过程不保留在 active tree;结果以 Git tag、GitHub Release、Release Notes 与 Git history 为准。
|
||||
- Codex App GUI 对齐执行计划:`internal/exec-plans/codex-app-gui-alignment-plan.md`
|
||||
- Codex Desktop 跨平台底层对比与对齐计划:`internal/exec-plans/codex-desktop-platform-parity-plan.md`
|
||||
- Codex Desktop 选择性 Goose 参考执行计划:`internal/exec-plans/codex-desktop-selective-goose-reference-plan.md`
|
||||
- Desktop + CLI/TUI 多 Surface 执行计划:`internal/exec-plans/tui-cli-surfaces-plan.md`
|
||||
- TUI/CLI 继续同步 Codex 执行计划:`internal/exec-plans/tui-cli-codex-sync-next-plan.md`
|
||||
- Codex/Lime TUI/CLI 全量差异报告:`internal/exec-plans/codex-lime-tui-cli-difference-report.md`
|
||||
- Codex TUI snapshot 逐项分类账本:`internal/exec-plans/tui-codex-snapshot-inventory.json`
|
||||
- Codex `fs/changed` Renderer 消费链对齐计划:`internal/exec-plans/fs-changed-codex-alignment-plan.md`
|
||||
- Codex CLI Rust 测试逐项分类账本:`internal/exec-plans/cli-codex-test-inventory.json`
|
||||
|
||||
@@ -5323,3 +5323,81 @@ projection 收敛;冷启动 orphan 与被新 active Turn 取代的旧 Turn 仍
|
||||
本轮还修复了受控 OpenAI-compatible fixture 的生命周期竞态:`server.keepAliveTimeout=1_000` 会在 Go 题工具间隔中截断已 `response-finish` 但客户端尚未消费的 SSE 复用 socket,使 turn 永久保持 `inProgress`。fixture 现在将 idle 连接回收统一留给显式 `close()`,不强杀 active response;定向 fixture/DeepSWE 回归 `46/46`,最终 Go 与五题复跑均通过。该修复属于 `current / controlled-fixture-lifecycle`;旧同进程页面 reload 证据属于 `dead / historical-only`,无 `compat / deprecated` 新增。
|
||||
|
||||
验证:单题 Go Gate B、完整五题 controlled smoke、desktop aggregate、DeepSWE Desktop contract/provider fixture 定向回归均通过;本轮未安装依赖、未调用 live provider、未运行 Docker/Podman/nerdctl/Colima/Pier verifier,因此 DeepSWE score、Pier artifacts、`DesktopCodingPass` 和 DSW-11 仍保持 blocked/incomplete。
|
||||
|
||||
### 2026-09-06 P2-A bounded process output 单一 owner 收口
|
||||
|
||||
状态:`completed / current`。
|
||||
|
||||
主目标与写集:本切片直接关闭 coding 路线图 P2-A,将 sandbox/process backend 在进入
|
||||
RuntimeCore 前的输出语义收敛到 `tool-runtime::execution_process`。窄写集仅包含
|
||||
`lime-rs/crates/tool-runtime/src/execution_process{.rs,/**}`、`tool-runtime/src/unified_exec.rs`、
|
||||
`lime-rs/crates/app-server/src/execution_process{.rs,/tests.rs}`、四份 `internal/roadmap/coding/**` 路线图与本记录;
|
||||
工作树其他 Electron、TUI、App Server runtime、client package 和前端热区不在本切片写集。
|
||||
|
||||
完成结果:
|
||||
|
||||
- 新增 `BoundedProcessOutput`唯一 retained-output owner,默认上限 1 MiB,稳定保留 head 与最新 tail,
|
||||
精确统计 `omitted_bytes`,并在 snapshot 中插入 `... N bytes omitted ...`。
|
||||
- 本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远程 Environment 共享同一输出边界;
|
||||
远程旧 128 KiB tail-only 截断已删除。
|
||||
- reader -> process handle 改为 64-slot bounded broadcast;慢消费者可跳过中间 delta,但权威 terminal
|
||||
snapshot 仍保留有界 head/tail,无消费者时大输出也不会堵塞子进程。
|
||||
- `ProcessOutputFramers` 对 stdout/stderr/combined 独立保留最多 3 个不完整字节,只在完整
|
||||
UTF-8 scalar 边界发 lifecycle delta;正常 EOF、PTY/Windows terminal 与普通 pipe 100 ms grace
|
||||
超时中止 reader 后均 flush 未完整后缀,raw transcript、replay 与 omission 统计不重复计数。
|
||||
- unified exec observation 改用同一有界缓冲;App Server replay 改为逐进程 queue,
|
||||
`drain_output` 必须明确 `process_id`,不再用全局 FIFO 让一个进程驱逐另一进程输出。
|
||||
|
||||
验证结果:`npm run test:rust:unit -- -p tool-runtime unified_exec` 11/11 通过;
|
||||
`npm run test:rust:unit -- -p app-server execution_process` 20/20 通过;修正远程 chunk sequence
|
||||
告警后 `execution_process_server_uses_environment_process_transport` 1/1 通过且零告警。
|
||||
`npm run test:rust:unit -- -p tool-runtime execution_process` 28/28 通过,包含普通 pipe grace-timeout
|
||||
UTF-8 flush 回归。Windows 当前 SHA 的 restricted-token 大输出真机
|
||||
evidence 未运行,历史 schema v3 7/7 不提升为当前 SHA 平台证据。
|
||||
跨 crate `npm run test:rust:related -- ...execution_process...` 完成编译并运行 7 个反向依赖的
|
||||
library tests;主体通过,但被工作树既有并行热区 `runtime_backend/coding_events` 中的
|
||||
`canceled_command_item_projects_canceled_test_result` 与
|
||||
`canonical_command_completion_emits_redacted_terminal_interaction_before_raw_item` 两项断言阻塞(`1776/1780`);
|
||||
两项 external backend JSONL timeout 独立复跑各为 `1/1` 通过,判定为并发运行抖动。本切片不越界修改该并行写集。
|
||||
`npm run test:contracts`全绿:1034 协议类型零漂移、App Server client 299 checks,命令、harness、modality、scripts、Electron release、CLI 与 docs boundary 均通过。
|
||||
`npm run smoke:agent-runtime-current-fixture` 全绿:真实 Electron/preload/IPC/App Server/runtime/read model/GUI 聚合通过,`liveProviderUsed=false`。
|
||||
`npm run governance:legacy-report`全绿:2063 文件扫描、零引用候选、零分类漂移、零边界违规;`rustfmt --check` 与 `git diff --check` 通过。
|
||||
|
||||
分类:`BoundedProcessOutput`、`ProcessOutputFramers`、各 backend 统一捕获、unified exec 有界 observation
|
||||
与 App Server per-process replay 为 `current`;128 KiB 默认、手写 tail-only capture、全局跨进程 FIFO、
|
||||
unbounded output channel 与逐 chunk lossy UTF-8 delta 为 `dead / deleted / forbidden-to-restore`;
|
||||
无 `compat / deprecated`。
|
||||
|
||||
架构影响:非重大。本切片没有改变 crate/package、App Server JSON-RPC 协议、领域 owner 或依赖方向,
|
||||
只在既有 `tool-runtime -> App Server -> RuntimeCore` 主链内收敛输出边界,因此不更新架构图。
|
||||
本切片完成度 `100%`;P2-A/P2-B/P2-C current owner 已闭环,coding 整体工程闭环估算 `99.4%`,
|
||||
下一刀直接进入当前 tree 的 Pier/DeepSWE verifier 与正式 distribution evidence。
|
||||
|
||||
### 2026-09-06 Pier verifier 环境注入与真实重跑
|
||||
|
||||
状态:`blocked / verifier-environment`。
|
||||
|
||||
为恢复已有候选 run
|
||||
`.lime/benchmark/v2/runs/20260827T060242Z-oxvg-structural-selector-preservation` 的真实 Pier
|
||||
判分,`runPierVerifier()` 新增隔离 Docker CLI 配置和当前 Docker context endpoint 注入:优先使用
|
||||
`LIME_DEEPSWE_DOCKER_CONFIG`,否则使用继承配置或仓库隔离配置;当 `DOCKER_HOST` 未显式提供时,
|
||||
从 `docker context show/inspect` 解析 endpoint 并传给 Pier 子进程。适配器测试新增 context endpoint
|
||||
回归,定向测试从 `25/25` 提升为 `26/26`。
|
||||
|
||||
真实重跑记录:首次重跑已越过旧的 Compose plugin 不可见问题和 Docker daemon preflight,Pier
|
||||
创建了 `verify-20260827T060242Z-oxvg-structural-selector-preservation-retry-1`,但在拉取
|
||||
`public.ecr.aws/d3j8x8q7/swe-bench-202605:kh749pxv8w35vksyfhe7p8tg8x82p0vg-v1.1` 时失败:
|
||||
Colima guest 的 dockerd 使用不可用的 `[::1]:53` DNS,未继承 guest `/etc/environment` 中的
|
||||
`192.168.5.2:7890` HTTP(S) 代理。当前 run 没有生成 `reward.json`、`ctrf.json` 或有效的
|
||||
`test-stdout.txt`,不得提升为 verifier pass、`reward=1`、`DesktopCodingPass` 或 DSW-11 完成。
|
||||
|
||||
验证:`npx vitest run scripts/harness/deepswe-adapter.test.mjs` 26/26;Prettier、
|
||||
`npm run test:contracts`、`npm run governance:legacy-report` 和 `git diff --check` 全绿。
|
||||
Colima 已启动且 `docker compose version` 为 `v5.1.4`;宿主机直接访问 `public.ecr.aws` 成功,
|
||||
但 Docker daemon 镜像拉取仍因 DNS/代理配置失败。当前唯一 blocker 是为本地 Colima Docker daemon
|
||||
注入代理或可用 DNS 并重启 daemon;该系统配置变更需显式确认后执行。
|
||||
|
||||
分类:Docker context/Compose plugin 注入、adapter 回归与候选 run 证据为 `current`;缺失 endpoint
|
||||
导致的 `/var/run/docker.sock` 回退已修复;本地 daemon 未配置代理导致的 verifier 失败为
|
||||
`blocked`,无 `compat / deprecated`。下一刀在确认系统配置变更后重跑同一候选 run,不重新执行
|
||||
live provider。
|
||||
|
||||
@@ -192,6 +192,7 @@ Renderer typed gateway
|
||||
当前子进度:
|
||||
|
||||
- [x] C-1 已确认 `command/exec` 与 standalone `process/*` 归 connection-scoped App Server + `tool-runtime::execution_process` current owner;实时 raw-byte delta、stdin/PTY、interrupt、terminate、timeout、bounded output、跨连接隔离、响应前 notification barrier 和断连清理均已有 owner 与回归覆盖,不新增第二套 process supervisor。
|
||||
- [x] C-1 unified exec lifecycle slice(2026-09-06):`tool-runtime::unified_exec` 将同一 `ExecutionProcessSnapshot` 的 process/status/stdin/bounded-output facts 写入 canonical result;App Server `CodingEventMirror` 消费 `Command` Item 的 `ItemStarted -> ItemUpdated -> ItemCompleted`,投影统一的 `command.started/output/exited` 与 `test.started/completed`,并复用现有 `ExecutionProcessServer`,不新增 JSON-RPC 或并行 supervisor。统一 exec 与 sandbox-aware `RuntimeLiveExecutionGateway` 的 owner 方向已对齐;定向 Cargo 测试因本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 未执行成功。
|
||||
- [x] C-2 已接入 session-scoped shell approval cache:key 绑定 command、cwd、requested sandbox policy 和 requested permissions;仅 `exec_command` 支持 `allow_for_session`,browser/guardian 不复用 shell cache;provider session close 清理内存 cache,且 cache 命中使用 `RuntimeToolApprovalSource::Reused` 跳过重复用户审批。
|
||||
- [x] C-2 sandbox-denied retry 已由统一 orchestrator 承接:按 approval policy 判断是否允许 danger-full-access 重试;普通 user approval 复用于 sandbox retry,strict guardian 重新审核,拒绝和 cancel fail closed;新增 Cached 零审批、key 隔离、shell/browser contract 回归测试。
|
||||
- [x] C-3 Windows 安全证据入口已落到 current `tool-runtime::execution_process` owner:新增 `windows_restricted_execution` integration test,并接入 `.github/workflows/quality.yml` 的 Windows runner;测试矩阵覆盖 workspace write、`.git/.codex/.agents` 与外部路径拒绝、allowlisted stdin handle、bounded large output、Job Object descendant cleanup/terminate。
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# Codex 与 Lime TUI/CLI 全量差异报告
|
||||
|
||||
基线:Codex checkout `/Users/coso/Documents/dev/rust/codex`,Rust commit
|
||||
`cac96cd7b1756ab42e8925d938817a2ac10ebb6e`。本报告只记录对照结果,不把 Codex
|
||||
私有产品能力误判成 Lime current 需求。逐项路径、符号、哈希、测试名和分类以以下
|
||||
JSON 为最终事实源:
|
||||
|
||||
- [TUI 目录/符号账本](./tui-structure-inventory.json)
|
||||
- [TUI 802 个 snapshot 账本](./tui-codex-snapshot-inventory.json)
|
||||
- [CLI 目录/符号账本](./cli-structure-inventory.json)
|
||||
- [CLI 433 个测试账本](./cli-codex-test-inventory.json)
|
||||
|
||||
## 1. 总量差异
|
||||
|
||||
| 维度 | Codex | Lime | 差异 | 结论 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| TUI Rust 源文件 | 579 | 91 | 缺 506,Lime 独有 18 | 目录体系未同构 |
|
||||
| TUI Rust 类型/函数符号 | 12,000 | 1,491 | 缺 9,878,Lime 独有 791 | 大量行为仍聚合在 Lime owner |
|
||||
| TUI snapshot | 802 | 0 | 802 未复制为 insta 文件 | 账本已分类,未机械迁移 |
|
||||
| TUI 测试标记 | 4,145 | 372 | 缺 3,773(以扫描口径) | Cargo 实际 TUI 测试 390/390 |
|
||||
| CLI Rust 源文件 | 82 | 11 | 缺 71 | 产品专属文件被排除,current 仍较薄 |
|
||||
| execpolicy 源文件 | 17 | 17 | 0 | 目录同构 |
|
||||
| CLI npm 文件 | 7 | 7 | 3 个文件名不同 | launcher 已改为 Lime 命名 |
|
||||
| Codex CLI 测试 | 433 | - | 50 covered,88 partial,64 deferred,231 excluded | `missing=0` 不等于行为全覆盖 |
|
||||
|
||||
## 2. TUI 目录与文件差异
|
||||
|
||||
### 2.1 Codex 缺失于 Lime 的文件族
|
||||
|
||||
`filesMissingInLime=506`,完整路径清单在 TUI 结构账本。按一级 owner 计数如下:
|
||||
|
||||
| owner | 缺失数 | 主要差异 |
|
||||
| --- | ---: | --- |
|
||||
| 根级文件 | 112 | startup、config、terminal probe、history、streaming 等大量 owner |
|
||||
| `app/` | 69 | history UI/pagination、startup、resize/reflow、safety buffering、thread state/routing、完整 app tests |
|
||||
| `bottom_pane/` | 75 | footer、popup、selection、mentions、skills、hooks、textarea/vim、unified exec |
|
||||
| `chatwidget/` | 110 | Codex 主交互状态机、输入流、权限、工具生命周期、replay、streaming、settings |
|
||||
| `history_cell/` | 20 | approval、exec、MCP、patch、plan、request_user_input、session 等历史项渲染 |
|
||||
| `render/` | 7 | renderable、line utils、streaming highlight |
|
||||
| `streaming/` | 10 | chunking、fence、controller、commit tick、table holdback、render |
|
||||
| `status/` | 10 | account/card/format/rate limits/remote/thread usage |
|
||||
| `tui/` | 12 | history tail、input boundary、job control、keyboard modes、scrollback、Windows console |
|
||||
| 其它目录 | 81 | `onboarding`、`pets`、`terminal_probe`、`ide_context`、`notifications`、`keymap_setup`、`public_widgets` 等 |
|
||||
|
||||
重点缺失的 Codex 同名顶层 owner:
|
||||
|
||||
`chatwidget.rs`、`history_cell/`、`render/`、`streaming/`、`terminal_probe.rs`、
|
||||
`cwd_prompt.rs`、`session_start.rs`、`session_resume.rs`、`app_command.rs`、
|
||||
`app_event.rs`、`app_event_sender.rs`、`app_server_connection.rs`、
|
||||
`app_server_approval_conversions.rs`、`backend_banners.rs`、`dynamic_tools.rs`、
|
||||
`dynamic_tools_mcp.rs`、`file_search.rs`、`hooks_rpc.rs`、`ide_context.rs`、
|
||||
`terminal_title.rs`、`tooltips.rs`、`ui_consts.rs`、`workspace_command.rs`。
|
||||
|
||||
### 2.2 Lime 独有文件
|
||||
|
||||
这些文件在 Codex 无同名 owner,属于结构差异或待收敛 owner:
|
||||
|
||||
`bottom_pane/render.rs`、`command_popup.rs`、`entry.rs`、`highlight.rs`、`locale.rs`、
|
||||
`markdown/local_links.rs`、`markdown/table.rs`、`model_picker.rs`、
|
||||
`pending_input_preview.rs`、`projection.rs`、`projection_tests.rs`、`reconnect.rs`、
|
||||
`runtime.rs`、`runtime_pty_tests.rs`、`settings.rs`、`status_indicator.rs`、
|
||||
`view.rs`、`viewport.rs`。
|
||||
|
||||
其中 `projection.rs`、`runtime.rs`、`view.rs` 是 Lime 聚合 owner;`reconnect.rs` 是
|
||||
顶层委托壳,真实 current owner 已在 `app/reconnect.rs`,且当前未发现消费者;
|
||||
`viewport.rs` 已有 Codex-shaped state/test,但尚未完全接入 runtime resize/reflow 主路径。
|
||||
|
||||
### 2.3 Codex 集成测试目录缺失
|
||||
|
||||
Lime 没有 `crates/tui/tests/` 集成测试目录。Codex 的以下 11 个测试文件没有同构副本:
|
||||
|
||||
`tests/all.rs`、`tests/test_backend.rs`、`tests/manager_dependency_regression.rs`、
|
||||
`tests/suite/focus_palette.rs`、`tests/suite/reconnect.rs`、
|
||||
`tests/suite/resize_reflow.rs`、`tests/suite/status_indicator.rs`、
|
||||
`tests/suite/vt100_history.rs`、`tests/suite/vt100_live_commit.rs`、
|
||||
`tests/suite/mod.rs`、`tests/fixtures/oss-story.jsonl`。
|
||||
|
||||
因此当前差异是:Lime 主要依赖 inline unit tests、`projection_tests.rs`、
|
||||
`runtime_pty_tests.rs` 和 Gate B;Codex 还有真实 `vt100` history/live-commit、focus、
|
||||
resize/reflow、reconnect 集成套件和独立测试 backend/support。
|
||||
|
||||
## 3. TUI 符号差异
|
||||
|
||||
结构扫描结果为 Codex `12,000` 个符号、Lime `1,491` 个符号:
|
||||
|
||||
- `symbolNamesMissingInLime=9,878`:完整名称、路径和类型在
|
||||
`tui-structure-inventory.json.comparisons.symbolNamesMissingInLime`。
|
||||
- `symbolNamesOnlyInLime=791`:完整名称、路径和类型在
|
||||
`tui-structure-inventory.json.comparisons.symbolNamesOnlyInLime`。
|
||||
- 缺失符号主要集中于 `ChatWidget`/输入流、`HistoryCell`、`Renderable`、
|
||||
streaming controller、permission/approval、MCP、startup/history、terminal probe、
|
||||
account/status 和 Codex 私有产品面。
|
||||
- Lime 独有符号主要来自 `Projection`、`Runtime`、`View`、`ModelCatalog`、
|
||||
`CollaborationModes`、resume/archive 聚合 owner,以及当前 App Server canonical
|
||||
projection 的类型。
|
||||
|
||||
不能把符号同名率当作语义覆盖率:Codex 私有 runtime 状态必须通过 App Server JSON-RPC
|
||||
canonical contract 重建,不能直接把私有类型搬进 Lime。
|
||||
|
||||
## 4. TUI snapshot 差异
|
||||
|
||||
Codex snapshot 基线为 802 个,Lime 当前 snapshot 文件为 0;账本已逐项保存路径和 SHA-256。
|
||||
|
||||
| Codex 模块 | snapshot 数 | 分类 |
|
||||
| --- | ---: | --- |
|
||||
| `app` | 56 | contract/dead |
|
||||
| `bottom_pane` | 244 | merge/dead |
|
||||
| `chatwidget` | 278 | merge/dead |
|
||||
| `history_cell` | 56 | merge/dead |
|
||||
| `status` | 23 | merge/dead |
|
||||
| `resume_picker` | 18 | contract |
|
||||
| `markdown_render` | 20 | direct |
|
||||
| `diff_render` | 23 | direct |
|
||||
| `pager_overlay` | 9 | merge |
|
||||
| `status_indicator_widget` | 8 | merge |
|
||||
| `cwd_prompt` | 5 | contract |
|
||||
| `onboarding` | 4 | dead |
|
||||
| `model_migration` | 4 | dead |
|
||||
| `inline_visualization` | 4 | defer |
|
||||
| `custom_terminal` | 3 | defer |
|
||||
| `debug_config` | 3 | dead |
|
||||
| 其它模块 | 44 | 按账本逐项分类 |
|
||||
|
||||
分类总计:`direct=48`、`merge=579`、`contract=80`、`defer=25`、`dead=70`。
|
||||
|
||||
- `direct`:`diff_render`、`markdown_render`、`insert_history`、`render`、
|
||||
`terminal_hyperlinks`、`terminal_palette`、`table_detect`、`wrapping` 等纯终端算法。
|
||||
- `merge`:与 Lime 交互相关、但必须合入 `app/composer/entry/view/picker` current owner。
|
||||
- `contract`:跨 App Server、Thread/Turn/Item、历史恢复、multi-agent 或持久化状态。
|
||||
- `defer`:`custom_terminal`、`git_action_directives`、`inline_visualization`、
|
||||
`keymap_setup`、`startup_hooks_review`。
|
||||
- `dead`:Codex account、onboarding、migration、update、marketplace、Luna reserve、
|
||||
ChatGPT plan/account、产品专属 debug/config 等。
|
||||
|
||||
## 5. TUI 能力差异裁决
|
||||
|
||||
### 已对齐或已有 current owner
|
||||
|
||||
App Server 事件/请求分离、ThreadEventStore、replay filter、agent picker、resume/archive、
|
||||
queue edit、approval、request_user_input、interrupt、external editor、model catalog、
|
||||
多 agent overview、terminal restore、clipboard、markdown/diff/hyperlink/wrapping、
|
||||
status indicator、真实 PTY Gate B 均已有 current owner 和回归测试。
|
||||
|
||||
### 部分对齐
|
||||
|
||||
1. Codex `chatwidget` 的完整状态机被 Lime `app` + `bottom_pane/chat_composer` + `projection`
|
||||
分散承接,目录和类型没有一一对应。
|
||||
2. Codex `history_cell` 的历史项渲染被 Lime `entry.rs`/`thread_transcript.rs` 承接,
|
||||
但 approval、MCP、patch、plan、hook、exec 的完整 cell owner 和 snapshot 套件未同构。
|
||||
3. Codex `render`/`streaming` 被 Lime `view.rs`、`entry.rs`、`markdown.rs`、`highlight.rs`
|
||||
分散承接,没有独立 `render` 和 `streaming` owner。
|
||||
4. Codex `status`、`backend_banners`、`rate_limit_refresh`、`safety_buffering`、
|
||||
`misalignment_policy`、`history_ui`、`working_directory`、`session_start/resume` 仍有
|
||||
行为或测试缺口。
|
||||
5. `viewport.rs` 已建立,但 resize/reflow 主路径还没有完全接入。
|
||||
6. `reconnect.rs` 顶层壳仍在,应该在确认无引用后删除并保留负向守卫。
|
||||
|
||||
### 明确暂缓
|
||||
|
||||
`custom_terminal`、`git_action_directives`、`inline_visualization`、`keymap_setup`、
|
||||
`startup_hooks_review`;另外包括 Codex 完整 vt100 snapshot suite、Windows terminal replay、
|
||||
以及非当前 Desktop parity 优先级的产品能力。
|
||||
|
||||
### 明确禁止复制
|
||||
|
||||
Codex account/onboarding/update/marketplace/pets/theme、Luna reserve、ChatGPT 专属状态、
|
||||
Codex daemon/state DB、Codex rollout/history DB、Codex misalignment 专属协议枚举、
|
||||
第二套 runtime、`TerminalGuard`、`TuiTerminal`、旧 `lime-cli`、`terminal-ui`、旧
|
||||
bounded-EOF restart 和旧 crossterm registry 输入路径,均为 `dead/deleted/forbidden-to-restore`。
|
||||
|
||||
## 6. TUI Cargo/依赖差异
|
||||
|
||||
Codex TUI 是独立产品 crate:
|
||||
|
||||
- package `codex-tui`,`autobins=false`。
|
||||
- binary `codex-tui`,另有 `md-events` binary。
|
||||
- lib name `codex_tui`。
|
||||
- Codex 私有依赖覆盖 config/login/rollout/state/model-provider/sandboxing/plugin/
|
||||
connectors/history/feedback/file-search/cloud-config/otel/terminal-detection 等 owner。
|
||||
- 测试依赖包括 `insta`、`wiremock`、`serial_test`、`assert_matches`、`vt100`、Codex test support。
|
||||
|
||||
Lime TUI 当前为:
|
||||
|
||||
- package `tui`,只有 lib `tui`,没有 `tui` binary 和 `md-events`。
|
||||
- 依赖收敛到 `app-server-client`、`app-server-protocol`、`agent-protocol`、
|
||||
`tool/runtime` 现有 workspace owner;不复制 Codex 私有 crate。
|
||||
- 已对齐 crossterm fork 和 feature:
|
||||
`https://github.com/openai-oss-forks/crossterm@45fecb9508105988f42fe6ff0441783ed3717f92`,
|
||||
`bracketed-paste` + `event-stream`。
|
||||
- 仍缺 Codex 的独立 binary、`insta` snapshot 测试基础设施、完整 test backend/support。
|
||||
|
||||
## 7. CLI Rust 目录与符号差异
|
||||
|
||||
### 7.1 文件
|
||||
|
||||
Codex `codex-rs/cli` 为 82 个文件,Lime `lime-rs/crates/cli` 为 11 个文件。
|
||||
Lime current 文件为:
|
||||
|
||||
`debug_sandbox.rs`、`exit_status.rs`、`lib.rs`、`main.rs`、`mcp_cmd.rs`、`plugin_cmd.rs`、
|
||||
`queue_cmd.rs`、`sandbox_setup.rs`、`wsl_paths.rs`、`Cargo.toml`、`tests/execpolicy.rs`。
|
||||
|
||||
Codex 缺失文件完整路径在 `cli-structure-inventory.json.comparisons.rustFilesMissingInLime`,
|
||||
其中包括:
|
||||
|
||||
- 产品/诊断:`src/doctor/**`、`src/login.rs`、`src/marketplace_cmd.rs`、`src/app_cmd.rs`、
|
||||
`src/bin/logs_client.rs`、`src/state_db_recovery.rs`、`src/migrate_rollouts.rs`。
|
||||
- Cloud/远程:`src/cloud_config.rs`、`src/remote_control_cmd.rs`、
|
||||
`src/exec_server_telemetry*.rs`、`src/debug_sandbox/cloud_config*.rs`。
|
||||
- 平台私有:`src/desktop_app/**`、`src/debug_sandbox/pid_tracker.rs`、
|
||||
`src/debug_sandbox/seatbelt.rs`。
|
||||
- 测试:`tests/app_server.rs`、`tests/cloud_*.rs`、`tests/debug_*.rs`、`tests/features.rs`、
|
||||
`tests/mcp_*.rs`、`tests/plugin_cli.rs`、`tests/queue.rs`、`tests/sandbox_*.rs`、
|
||||
`tests/marketplace_*.rs`、`tests/login.rs`、`tests/update.rs` 等。
|
||||
|
||||
### 7.2 符号
|
||||
|
||||
- CLI Rust 缺失 Codex 符号 `774` 个,Lime 独有 `65` 个;完整路径/名称在结构账本。
|
||||
- `execpolicy` 为 17/17 文件、95/95 符号,同构完成。
|
||||
- Lime current 命名已按 Codex 形状收敛:`MultitoolCli`、`Subcommand`、`CompletionCommand`、
|
||||
`TuiCli`、`ExecCli`、`ResumeCommand`、`McpCli`、`PluginCli`、`FeaturesCli`、
|
||||
`QueueCommand`、`DebugCommand`、`SandboxStateArgs`、`SandboxSetupCommand`、
|
||||
`handle_exit_status` 等。
|
||||
- 当前仍有 Lime 独有命名/产品扩展:`SkillsCli`、`ThreadCommand`、`ConnectionArgs`、
|
||||
`QueueSubcommand`、`FeatureListArgs`、`PluginIdArgs` 等,需要继续确认是否应成为
|
||||
current contract 或删除。
|
||||
|
||||
## 8. CLI 测试差异
|
||||
|
||||
Codex CLI 账本共 433 个测试,来自 53 个源文件。当前分类:
|
||||
|
||||
| 分类/状态 | 数量 | 含义 |
|
||||
| --- | ---: | --- |
|
||||
| `direct / covered` | 9 | 纯 parser、WSL、execpolicy 等直接覆盖 |
|
||||
| `contract / covered` | 41 | 通过 App Server JSON-RPC/current owner 覆盖 |
|
||||
| `contract / partial` | 88 | 有 current owner,但行为或 Gate B 未完全同构 |
|
||||
| `cloud-deferred / deferred` | 64 | authenticated remote transport 基础完成前暂缓 |
|
||||
| `product-specific / excluded` | 231 | Codex account/doctor/marketplace/updater/desktop/state 私有能力 |
|
||||
| `missing / pending` | 0 | 每个测试都已明确绑定到分类;不代表行为全部实现 |
|
||||
|
||||
### 已覆盖 current CLI 行为
|
||||
|
||||
parser command tree、sandbox parser、sandbox command lowering、sandbox setup parser、
|
||||
execpolicy check、debug models/clear memories、features list/enable/disable、基本 plugin
|
||||
add/list/read/search/enable/disable/remove、MCP list/add/get/remove/start/stop、queue add/list、
|
||||
thread archive/delete/unarchive/fork/resume、非交互 exec、TUI/exec 入口、WSL path 算法和
|
||||
退出码处理。
|
||||
|
||||
### 88 个 partial 的能力族
|
||||
|
||||
1. permission options:Codex `approve-for-me`、`not-so-yolo` 别名和 root/exec/resume 合并
|
||||
优先级未逐字复制;Lime 以 `permissionProfile/list` + `turn/start` 为 current owner。
|
||||
2. plugin control plane:基本 CRUD 已覆盖,marketplace cache、远程 catalog refresh、
|
||||
未配置 repo-local marketplace 等 Codex 边界未同构。
|
||||
3. MCP mutation:OAuth registration/logout 尚无 `mcpServer/oauth/*` App Server contract,
|
||||
当前必须 fail closed,禁止 CLI 直接读 credential store。
|
||||
4. queue:本地 queue 行为已覆盖,远程 queue 证据随 Cloud deferred。
|
||||
5. app-server entrypoint:CLI 转发参数已保留,transport/daemon/proxy/signed/capability
|
||||
语义仍由 sibling App Server owner 承担。
|
||||
6. thread lifecycle:canonical resume/archive/delete/unarchive/fork 已接入,但 Codex 私有
|
||||
daemon/state/rollout storage 不复制。
|
||||
7. sandbox:sandbox-state replay、managed network、named profile 全矩阵仍不完整。
|
||||
|
||||
### 64 个 Cloud deferred 能力族
|
||||
|
||||
remote-control、exec-server、remote API-key auth、Cloud managed permission profile、
|
||||
Cloud MCP config/OAuth、remote plugin catalog、remote queue、remote working directory、
|
||||
parent lifetime/telemetry、远程 sandbox 和所有 authenticated transport 相关测试。
|
||||
完整测试名在 `cli-codex-test-inventory.json` 中 `status=deferred` 的条目。
|
||||
|
||||
### 231 个 product-specific 排除能力族
|
||||
|
||||
Codex `doctor` 全部诊断面、account/login、marketplace add/remove/upgrade、updater/update、
|
||||
desktop launcher、macOS PID tracker/seatbelt denial internals、state DB recovery、rollout
|
||||
migration、logs client、Codex 内部 support client 和已退役 command alias。完整测试名在账本中
|
||||
`status=excluded` 的条目;这些不能恢复成 Lime compat/current。
|
||||
|
||||
## 9. npm/launcher 差异
|
||||
|
||||
### 文件
|
||||
|
||||
Codex `codex-cli`:`.gitignore`、`bin/codex.js`、`package.json`、`scripts/README.md`、
|
||||
`scripts/build_npm_package.py`、`scripts/init_firewall.sh`、`scripts/run_in_container.sh`。
|
||||
|
||||
Lime `packages/cli`:`.gitignore`、`README.md`、`bin/lime.js`、`package.json`、
|
||||
`scripts/README.md`、`scripts/build_npm_package.py`、`tests/npm-package.test.mjs`。
|
||||
|
||||
Lime 缺少 `init_firewall.sh`、`run_in_container.sh`;两者属于 Cloud/容器专属,不应伪造
|
||||
成生产入口。Lime 多了 README 和 npm package test。
|
||||
|
||||
### manifest
|
||||
|
||||
| 字段 | Codex | Lime | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| package | `@openai/codex` | `@limecloud/lime` | 品牌差异,按产品要求为 Lime |
|
||||
| bin | `codex` | `lime` | 已收敛到 Lime |
|
||||
| version | `0.0.0-dev` | `1.141.0` | 发布策略差异 |
|
||||
| Node | `>=16` | `>=18` | 运行时基线差异 |
|
||||
| pnpm | `10.34.5` | `9.15.9` | workspace 基线差异 |
|
||||
| license | Apache-2.0 | MIT | 产品法律文本差异 |
|
||||
| scripts | 无 | `build:npm`、`test` | Lime 增加构建/测试入口 |
|
||||
|
||||
### 平台包
|
||||
|
||||
Codex 支持 `x86_64-unknown-linux-musl`、`aarch64-unknown-linux-musl`、
|
||||
`x86_64-apple-darwin`、`aarch64-apple-darwin`、`x86_64-pc-windows-msvc`、
|
||||
`aarch64-pc-windows-msvc`,并在 Linux 分支同时覆盖 Android。
|
||||
|
||||
Lime 当前只有 `x86_64-unknown-linux-gnu`、`x86_64-apple-darwin`、
|
||||
`aarch64-apple-darwin`、`x86_64-pc-windows-msvc`;缺 Linux ARM64、Linux musl、Windows ARM64、
|
||||
Android/Linux musl 分支。
|
||||
|
||||
### launcher 命名和实现
|
||||
|
||||
Lime launcher 源码仍残留 Codex-shaped 变量/函数名:`codexPackageRoot`、
|
||||
`findCodexExecutable`、`isPnpmOwnedCodexInstall`;这是当前未完成的命名差异,不能把
|
||||
Codex 名字留在 Lime current owner 中。应改为 Lime 语义(例如 package root 和
|
||||
`findLimeExecutable`),并同步 npm 测试和结构账本。Lime 额外设置
|
||||
`DYLD_LIBRARY_PATH`/`LD_LIBRARY_PATH` 以加载 bundled native libraries,这是运行时适配,
|
||||
不是 Codex 同构差异。
|
||||
|
||||
## 10. 架构与 Cloud 预留
|
||||
|
||||
唯一业务主链必须保持:
|
||||
|
||||
`Desktop Host 或 CLI/TUI Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`。
|
||||
|
||||
Cloud 只能作为 `app-server-client` 的 authenticated transport,继续消费同一 protocol、
|
||||
runtime 和 canonical read model;不能复制 runtime、工具 registry、状态机或持久化。
|
||||
|
||||
因此下列“差异”是架构约束,不是待机械复制项:Codex 私有 crate 依赖、Codex daemon/state DB、
|
||||
Codex rollout/history DB、hosted `--code-mode-host` URL、bubblewrap network proxy、
|
||||
account/onboarding/update/marketplace/doctor/pets/theme,以及 Codex 产品内部 telemetry。
|
||||
|
||||
## 11. 运行证据差异
|
||||
|
||||
已验证:
|
||||
|
||||
- `cargo test --locked -p tui -p cli`:TUI 390/390,CLI 50/50。
|
||||
- `cargo clippy --locked -p tui --no-deps -- -D warnings`、`cargo check --locked -p tui`。
|
||||
- `cargo metadata --locked`、`cargo fmt --package tui -- --check`、`git diff --check`。
|
||||
- TUI/CLI inventory Vitest、`npm run test:contracts`、`npm run governance:legacy-report`。
|
||||
- TUI Gate B 七场景通过;`complete` 场景连续 5 次通过,包含 queue edit、agents overview、
|
||||
external editor、approval/request_user_input、interrupt、terminal restored。
|
||||
|
||||
仍缺:
|
||||
|
||||
- Codex `insta` snapshot 全量迁移或等价 contract snapshot。
|
||||
- Codex `tests/suite` 的 focus/reconnect/resize/vt100 集成套件。
|
||||
- Windows 原生 TUI/CLI/packaged 运行证据和完整 terminal probe replay。
|
||||
- npm Linux musl/ARM64、Windows ARM64 平台包 Gate B。
|
||||
- MCP OAuth App Server contract、Cloud authenticated transport 真实 Gate B。
|
||||
- runtime resize/reflow 对 `viewport.rs` 的完整接线。
|
||||
|
||||
## 12. 最终结论与优先级
|
||||
|
||||
当前状态不是“Codex TUI/CLI 已完全复制”,而是“Codex-shaped Lime current host 已可运行,
|
||||
且所有已知差异已进入账本”。最重要的剩余差异按优先级为:
|
||||
|
||||
1. 补齐 Lime `chatwidget/history_cell/render/streaming` 的 current owner 拆分和测试基础设施,
|
||||
同时把可迁移的 direct/merge snapshot 转为 Lime owner 的稳定 contract snapshot。
|
||||
2. 接通 `viewport` resize/reflow,并补 Codex 形状的 reconnect/focus/vt100 集成套件。
|
||||
3. 收敛 CLI partial:MCP OAuth contract、plugin edge cases、sandbox profile/network 矩阵、
|
||||
app-server entrypoint transport tests。
|
||||
4. 完成 npm 平台目标与 packaged Gate B;Cloud 只在 authenticated transport contract 完整后推进。
|
||||
5. 清理无消费者的顶层 `tui/src/reconnect.rs`,并继续用结构/治理守卫阻止
|
||||
`lime-cli`、`terminal-ui`、旧 runtime 和旧 launcher 命名回流。
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lime v1.142.0 发布执行计划
|
||||
|
||||
状态:进行中
|
||||
日期:2026-09-08
|
||||
目标版本:`1.142.0`
|
||||
目标 tag:`v1.142.0`
|
||||
|
||||
## 主目标
|
||||
|
||||
发布 v1.141.0 之后当前工作树中的 TUI/CLI Codex 对齐、App Server 执行进程与有界输出、
|
||||
认证 WebSocket transport foundation、Electron 安全凭证存储、Cloud App Server 配置解析、
|
||||
DeepSWE harness 环境注入及配套治理/测试改动;完成版本事实源、双语单页 release notes、
|
||||
质量门禁、release commit、tag、main 推送和远端复核。
|
||||
|
||||
## Release Candidate
|
||||
|
||||
- `release metadata`:`package.json`、`packages/cli/package.json`、`lime-rs/Cargo.toml`、
|
||||
`lime-rs/Cargo.lock`、`RELEASE_NOTES.md`、`RELEASE_NOTES.en.md`、本计划及
|
||||
`internal/exec-plans/README.md`。
|
||||
- `candidate changes`:当前工作树全部已跟踪的产品、文档、测试、schema、依赖、脚本、
|
||||
Electron、App Server、Runtime、tool-runtime、CLI/TUI 与执行计划改动,以及除本地二进制外
|
||||
全部未跟踪源码、测试、脚本和文档文件。
|
||||
- `excluded changes`:未跟踪的本机 Mach-O 构建产物 `rust_out`;不删除、不覆盖,保留在工作树。
|
||||
`.lime/`、`dist/`、`dist-electron/` 和 `lime-rs/target/` 为被忽略的本地生成目录,不进入提交。
|
||||
|
||||
## 架构确认
|
||||
|
||||
本轮仍遵循唯一业务主链:
|
||||
|
||||
```text
|
||||
Electron Desktop Host / CLI-TUI Host
|
||||
-> App Server JSON-RPC
|
||||
-> RuntimeCore
|
||||
-> Thread / Turn / Item projection
|
||||
```
|
||||
|
||||
Remote WebSocket 仅实现同一 App Server session 的认证 transport foundation;Electron 凭证
|
||||
存储只由 Desktop Host `safeStorage` owner 承担,不能读取 token 或创建第二套 runtime、会话状态、
|
||||
工具 registry 或持久化。执行进程输出继续由 `tool-runtime` 统一拥有,App Server 只负责生命周期与
|
||||
canonical projection。相关架构边界已在 `internal/aiprompts/architecture.md` 中同步;本轮没有
|
||||
新增平行业务后端。
|
||||
|
||||
## 退出条件
|
||||
|
||||
- 根应用、CLI npm 包、Rust workspace 与 Cargo.lock 统一为 `1.142.0`;双语 release notes
|
||||
只保留 v1.142.0;目标 tag 在写操作前不存在。
|
||||
- `npm run verify:app-version`、`npm run typecheck`、`npm run test:contracts`、受影响包/Rust
|
||||
定向测试、CLI/TUI Gate B、Agent Runtime current fixture、治理扫描和 `git diff --check` 通过。
|
||||
- `npm run verify:gui-smoke` 若环境允许则通过;无法执行时在收尾报告说明限制。
|
||||
- staged 内容覆盖全部 candidate changes 与 release metadata,仅排除 `rust_out` 及被忽略生成目录;
|
||||
完成 `Release v1.142.0` commit、`v1.142.0` tag,并推送 `origin/main` 与远端 tag 后复核。
|
||||
|
||||
## 验证记录
|
||||
|
||||
已完成:
|
||||
|
||||
- `npm run verify:app-version`:通过(改版本前基线 `1.141.0`)。
|
||||
- `npm run typecheck`:首次被新增 remote `ws` 声明未纳入根级编译阻断;补充
|
||||
`packages/app-server-client/tsconfig.json` 的 `.d.ts` include 与 `remote.ts` reference 后通过。
|
||||
- `npm run test:contracts`:通过(协议类型、App Server client、命令、harness、modality、脚本、
|
||||
Electron release、CLI、docs boundary)。
|
||||
- `npm --prefix "packages/app-server-client" test`:11 个测试文件、139 项通过。
|
||||
- `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`:390/390 通过。
|
||||
- `npm run smoke:cli-gate-b`、`npm run smoke:tui-gate-b`:真实 CLI/TUI、stdio App Server、
|
||||
canonical projection、队列编辑与终端恢复通过。
|
||||
- `npm run smoke:agent-runtime-current-fixture`:通过,真实 Electron/preload/IPC/App Server/
|
||||
Runtime/read model、Claw、Coding Workbench、Skills、MCP、媒体、审批、恢复等 current fixture
|
||||
全部通过,`liveProviderUsed=false`。
|
||||
- `npm run verify:gui-smoke`:通过;真实 Electron/preload/IPC、App Server `appserver.v0`、Claw
|
||||
workbench、响应式布局与 memory settings smoke 通过,app-server 版本为 `1.142.0`。
|
||||
- `npm run governance:legacy-report`:通过;结构/脚本/legacy 边界无违规。
|
||||
- `npx vitest run scripts/app-server/cli-gate-b.test.mjs scripts/app-server/tui-gate-b.test.mjs
|
||||
scripts/app-server/tui-structure-inventory.test.mjs scripts/app-server/terminal-gate-binaries.test.mjs
|
||||
scripts/harness/deepswe-adapter.test.mjs`:5 个测试文件、46 项通过。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
环境限制:直接运行 `cargo test` 触发 `rusty_v8 v150.4.0` Darwin/aarch64 预构建 archive 404,
|
||||
导致包含 V8 的 app-server/cli/lime-agent workspace 编译未完成;仓库 runner 已用于此前 current
|
||||
fixture 和可运行的定向测试,不能把该本机 archive 失败误报为产品测试通过。Windows restricted
|
||||
execution 真机矩阵、签名/公证和完整 Forge 产物由对应平台 runner 提供,本轮未在本机执行。
|
||||
|
||||
## 收尾分类
|
||||
|
||||
- `current`:TUI/CLI Codex-shaped owner、App Server execution process 与 command event mirror、
|
||||
bounded output、authenticated remote WebSocket foundation、Electron secure credential store、
|
||||
Cloud App Server parser、current fixture 与治理守卫。
|
||||
- `compat`:无新增;既有显式兼容导出不扩展。
|
||||
- `deprecated`:无新增。
|
||||
- `dead / deleted`:旧全局输出 FIFO、tail-only capture、未校验 sidecar data-dir、token URL/query
|
||||
传递、旧聚合 TUI 状态和禁止恢复的平行 runtime 入口。
|
||||
|
||||
当前完成度:版本文件与候选整理 95%;待用户确认后执行 release commit、tag、推送和远端复核。
|
||||
@@ -0,0 +1,263 @@
|
||||
# TUI/CLI 继续同步 Codex 执行计划
|
||||
|
||||
状态:planned
|
||||
日期:2026-09-08
|
||||
参考实现:`/Users/coso/Documents/dev/rust/codex`
|
||||
当前基线:Rust commit `cac96cd7b1756ab42e8925d938817a2ac10ebb6e`
|
||||
|
||||
本计划承接 [全量差异报告](./codex-lime-tui-cli-difference-report.md),目标是继续按
|
||||
Codex 的真实目录、文件、公开类型、函数名和测试名同步 Lime 的 TUI/CLI。禁止根据截图
|
||||
或主观设计补造同名壳;每个实现批次必须先读取对应 Codex 源文件和测试,再迁移到 Lime
|
||||
current owner。
|
||||
|
||||
## 1. 基线与完成目标
|
||||
|
||||
当前事实:
|
||||
|
||||
| 维度 | Codex | Lime | 当前结论 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| TUI Rust 文件 | 579 | 91 | 目录体系仍未同构 |
|
||||
| TUI 类型/函数符号 | 12,000 | 1,491 | 结构差异大,须按 owner 分批收敛 |
|
||||
| TUI snapshot | 802 | 0 | 已建立逐项分类账本,未迁入快照文件 |
|
||||
| CLI Rust 文件 | 82 | 11 | current CLI 较薄,产品专属文件不机械复制 |
|
||||
| execpolicy 文件 | 17 | 17 | 已同构 |
|
||||
| CLI 测试 | 433 | - | 50 covered、88 partial、64 Cloud deferred、231 excluded |
|
||||
|
||||
完成目标不是让文件数量形式上相等,而是:
|
||||
|
||||
1. Codex current TUI 行为都有明确 Lime owner、测试和分类;可迁移的目录/符号/测试使用
|
||||
Codex 原名,不能继续由 Lime 聚合文件隐式承载。
|
||||
2. TUI 的 direct/merge 场景具备稳定的 Lime snapshot 或等价 TestBackend/VT100 断言;
|
||||
contract 场景具备真实 App Server JSON-RPC evidence。
|
||||
3. CLI 的 88 个 partial 要么补齐 current contract 和真实 Gate B,要么记录明确的
|
||||
`defer`/`excluded` 理由,不保留 `missing/pending`。
|
||||
4. Desktop、CLI/TUI 继续共享 App Server、RuntimeCore、Thread/Turn/Item projection;
|
||||
Cloud 只增加 authenticated transport,不复制 runtime、状态机、工具 registry 或存储。
|
||||
|
||||
## 2. 不变量与命名规则
|
||||
|
||||
- Codex 路径、模块名、`pub struct/enum/type/fn` 和测试名是基线;迁移前保存上游路径、
|
||||
source commit 和内容 hash。
|
||||
- `current` 只能落在 `lime-rs/crates/**`、`packages/cli` 或 App Server current owner;
|
||||
`compat` 只能委托;`deprecated` 只能迁出;`dead` 必须删除并补回流守卫。
|
||||
- TUI 业务链固定为:`TUI Host -> app-server-client -> App Server JSON-RPC -> RuntimeCore -> canonical projection`。
|
||||
- 不复制 Codex 私有 auth、account、rollout/history DB、daemon、provider manager、
|
||||
marketplace、doctor、pets、theme、update、ChatGPT 状态或平台私有 sandbox internals。
|
||||
- 不恢复 `lime-cli`、`terminal-ui`、`TerminalGuard`、`TuiTerminal`、旧 bounded EOF
|
||||
restart、旧 crossterm registry 输入路径;这些只能出现在 negative guard/history evidence。
|
||||
- 生产路径不使用 mock;fixture 只能用于测试,且必须通过真实 stdio/PTY/JSON-RPC 边界。
|
||||
- 新增代码不得使用 `codex*` 品牌前缀;只有与 Codex 对齐的领域 owner 名称可以保留
|
||||
`ChatWidget`、`HistoryCell`、`Renderable` 等语义名。
|
||||
|
||||
## 3. 写集与避让范围
|
||||
|
||||
### 本计划允许的写集
|
||||
|
||||
- `lime-rs/crates/tui/**`
|
||||
- `lime-rs/crates/cli/**`
|
||||
- `lime-rs/crates/app-server-client/**`、`lime-rs/crates/app-server-protocol/**`(仅为已确认的
|
||||
TUI/CLI contract 缺口)
|
||||
- `packages/cli/**`、`pnpm-lock.yaml`、`lime-rs/Cargo.toml`、`lime-rs/Cargo.lock`
|
||||
- `scripts/app-server/{tui,cli}-*.mjs` 及对应测试、TUI/CLI inventory 生成器和账本
|
||||
- `.gitignore`、`internal/exec-plans/README.md`、本计划和差异报告
|
||||
|
||||
### 明确避让
|
||||
|
||||
`electron/**`、无关 `src/**`、Codex Desktop/Goose 计划热区、服务端外部仓库、
|
||||
`lime-rs/crates/agent/**` 和已有未跟踪产物。若必须改变 App Server protocol,先单独登记
|
||||
contract 写集、消费者、schema、锁文件和测试,不在 TUI 批次夹写。
|
||||
|
||||
## 4. 阶段 A:TUI Codex owner 同构
|
||||
|
||||
### A0 迁移准备
|
||||
|
||||
- [ ] 为每个批次建立 `upstream-path -> lime-owner -> classification -> test` 映射,来源必须
|
||||
是 Codex 当前 checkout,不得只依据文件名猜测。
|
||||
- [ ] 复核 `tui-structure-inventory.json` 和 `tui-codex-snapshot-inventory.json` 的 hash、
|
||||
source commit 与分类;新增 upstream 文件必须先分类再写代码。
|
||||
- [ ] 维护唯一 owner 表,确认 `projection.rs`、`runtime.rs`、`view.rs` 等 Lime 聚合 owner
|
||||
的拆分边界;不创建第二个 Thread/Turn/Item 模型。
|
||||
|
||||
### A1 纯终端 direct owner
|
||||
|
||||
对应 Codex:`render/`、`streaming/`、`markdown_render/`、`diff_render`、`insert_history`、
|
||||
`terminal_hyperlinks`、`terminal_palette`、`table_detect`、`wrapping`。
|
||||
|
||||
- [ ] 按 Codex 文件名补齐 `tui/src/render/` 的 `mod.rs`、`line_utils.rs`、`renderable.rs`、
|
||||
`highlight.rs` 和 streaming highlight;把 Lime `highlight.rs`/`view.rs` 中对应纯算法迁入
|
||||
唯一 owner。
|
||||
- [ ] 按 Codex 真实测试名迁移 48 个 `direct` snapshot 场景,使用 `insta` 或等价
|
||||
`TestBackend/Buffer` 断言;不引入 Codex runtime 状态。
|
||||
- [ ] 对 `markdown_render`、`diff_render`、`wrapping`、OSC 8 和宽字符边界补窄终端、UTF-8
|
||||
grapheme、URL、表格、重排回归。
|
||||
- [ ] 删除迁移后的 Lime-only 重复纯渲染函数,更新 inventory 并增加禁止重复 owner 的结构守卫。
|
||||
|
||||
退出条件:48 个 direct snapshot 全部有 Lime owner 和稳定断言;`cargo test -p tui`、
|
||||
TUI Clippy、结构 inventory 和 TUI Gate B 通过。
|
||||
|
||||
### A2 transcript/history owner
|
||||
|
||||
对应 Codex:`history_cell/`、`exec_cell/`、`app/history_ui.rs`、`app/history_pagination.rs`、
|
||||
`app/transcript_export.rs`、`pager_overlay/`。
|
||||
|
||||
- [ ] 建立 `history_cell` 的 canonical entry adapter,只消费 App Server `Thread/Turn/Item`
|
||||
projection;按 Codex 文件拆分 approvals、exec、MCP、patches、plans、messages、notices、
|
||||
session 和 request_user_input。
|
||||
- [ ] 建立 `exec_cell/{mod,model,live_output,render}.rs`,把 command live output 与 Lime
|
||||
`entry.rs` 的重复渲染收回同一 owner。
|
||||
- [ ] 将 `Ctrl+T` transcript overlay、resume transcript、pager 和 export 统一到
|
||||
`history_cell`/`pager_overlay` 的渲染链;不创建 rollout/history DB。
|
||||
- [ ] 迁移 `merge=579` 中与 history/transcript/pager 相关场景;跨 runtime 的 80 个
|
||||
`contract` 场景必须绑定 App Server fixture 和 canonical identity。
|
||||
|
||||
退出条件:Message/Reasoning/Command/Patch/MCP/Plan/Multi-Agent/approval/request_user_input
|
||||
各有稳定 cell owner;历史分页、overlay、export、live output 具备 VT100/PTY 证据。
|
||||
|
||||
### A3 composer/chatwidget/bottom_pane owner
|
||||
|
||||
对应 Codex:`chatwidget/`、`bottom_pane/`、`public_widgets/composer_input.rs`、
|
||||
`keymap/`。
|
||||
|
||||
- [ ] 逐文件对照 Codex `chatwidget/{constructor,input_flow,input_submission,interaction,
|
||||
interrupts,tool_lifecycle,turn_lifecycle,streaming,transcript,settings}.rs`,把 Lime
|
||||
`app`、`bottom_pane/chat_composer` 和 `projection` 中相应逻辑迁入 Codex-shaped owner。
|
||||
- [ ] 按 Codex 目录补齐 composer 的 `attachment_state`、`draft_state`、`history_search`、
|
||||
`popup_state`、`slash_input`、`vim_history`、`vim_search`、`footer_state` 和测试;保留
|
||||
Lime 的图片/队列 canonical contract,不复制 Codex 私有 provider/auth。
|
||||
- [ ] 按 Codex `bottom_pane` 补齐 action banner、footer、selection、file search、skills、
|
||||
hooks、MCP elicitation 和 unified exec 的可用子集;没有 App Server consumer 的动态工具
|
||||
和 MCP elicitation 继续 reject/fail closed。
|
||||
- [ ] 将 Lime `command_popup.rs`、`pending_input_preview.rs`、`status_indicator.rs`、
|
||||
`settings.rs` 迁移到对应 Codex owner,迁移后删除重复聚合实现。
|
||||
|
||||
退出条件:composer key/event/input/interrupt/queue/attachment/history 逐函数有 Codex 来源;
|
||||
所有当前可用 popup、approval、request_user_input、status、queue 场景有测试和五语言文案。
|
||||
|
||||
### A4 app/startup/reconnect/resize owner
|
||||
|
||||
对应 Codex:`app/{startup,startup_prompts,working_directory,session_picker,reconnect,
|
||||
resize_reflow,thread_routing,thread_session_state,thread_title}.rs`、`tui/*`。
|
||||
|
||||
- [ ] 把 `viewport.rs` 接入 runtime resize/reflow 主路径,使用实际 Ratatui rendered rows,
|
||||
覆盖宽度变化、底部对齐、手动滚动和 alternate-screen round trip。
|
||||
- [ ] 迁移 Codex startup/session/working-directory 语义到 App Server contract;没有 Lime
|
||||
current contract 的字段明确标为 `contract/defer`,不能在 TUI 本地合成。
|
||||
- [ ] 补齐 `tests/suite/reconnect.rs`、`resize_reflow.rs`、`status_indicator.rs`、
|
||||
`focus_palette.rs` 的 Lime 版本;顶层 `tui/src/reconnect.rs` 在零消费者后删除。
|
||||
- [ ] 把 terminal lifecycle 继续固定在 `Tui`/`Terminal`/`EventBroker`/`FrameRequester`,
|
||||
不恢复 `TerminalGuard` 或 runtime EOF 重启。
|
||||
|
||||
退出条件:TUI resize/reflow、focus、reconnect 和 terminal restore 通过真实 PTY/VT100;
|
||||
旧 reconnect 壳和旧输入恢复路径不存在 current 引用。
|
||||
|
||||
## 5. 阶段 B:TUI 测试体系同构
|
||||
|
||||
- [ ] 新增 Codex-shaped `lime-rs/crates/tui/tests/`:`all.rs`、`test_backend.rs`、
|
||||
`manager_dependency_regression.rs`、`suite/mod.rs`、`suite/focus_palette.rs`、
|
||||
`suite/reconnect.rs`、`suite/resize_reflow.rs`、`suite/status_indicator.rs`、
|
||||
`suite/vt100_history.rs`、`suite/vt100_live_commit.rs`、`fixtures/oss-story.jsonl`。
|
||||
- [ ] 从 Codex 测试复制测试结构和测试名,仅替换 Lime App Server fixture、canonical
|
||||
projection 和五语言文案;不得把 Codex 私有 backend/state DB 带入测试。
|
||||
- [ ] 引入 `insta`、`serial_test`、`assert_matches` 等依赖前先确认只用于 TUI test target,
|
||||
同步 Cargo.lock 和最小结构守卫。
|
||||
- [ ] 对 802 个 snapshot 保持逐项账本:`direct` 迁移、`merge` 合并、`contract` 绑定真实
|
||||
protocol、`defer` 保留退出条件、`dead` 禁止复制。
|
||||
|
||||
退出条件:TUI 集成目录、snapshot、TestBackend、VT100 history/live commit 和 PTY Gate B
|
||||
同时通过;账本无未分类条目,且测试失败不会回退到 mock backend。
|
||||
|
||||
## 6. 阶段 C:CLI 88 个 partial 收口
|
||||
|
||||
### C1 CLI current contract
|
||||
|
||||
- [ ] `permission options`:按 Codex `approve-for-me`、`not-so-yolo` 和 root/exec/resume
|
||||
precedence 测试逐项决定是否能映射 Lime `permissionProfile/list` + `turn/start`;不能
|
||||
映射的别名不添加假兼容。
|
||||
- [ ] `plugin`:补 marketplace cache、repo-local marketplace、catalog refresh、JSON
|
||||
output 和 enable/disable 边界;所有 mutation 继续走 `plugin/*` App Server owner。
|
||||
- [ ] `mcp`:先补 `mcpServer/oauth/*` protocol、schema、client、handler 和 fixture,再
|
||||
实现 login/logout;协议未完成前继续 fail closed,禁止 CLI 直接读 credential store。
|
||||
- [ ] `queue`:补本地 queue 的完整命令/错误矩阵;远程 queue 只在 Cloud transport evidence
|
||||
完成后启用,不添加本地 fallback。
|
||||
- [ ] `app-server entrypoint`:复制 Codex 参数和错误测试到 Lime sibling App Server owner;
|
||||
transport/daemon/proxy/capability 只在有 current handler 时暴露。
|
||||
- [ ] `sandbox`:补 sandbox-state replay、managed network、named profile 矩阵;缺少
|
||||
App Server/tool-runtime owner 时保持 fail closed,不在 CLI 自建策略。
|
||||
|
||||
### C2 CLI 结构与命名
|
||||
|
||||
- [ ] 保持 `MultitoolCli`、`Subcommand`、`TuiCli`、`ExecCli`、`ResumeCommand`、
|
||||
`McpCli`、`PluginCli`、`FeaturesCli`、`QueueCommand`、`DebugCommand`、
|
||||
`SandboxSetupCommand`、`handle_exit_status` 等 Codex-shaped owner。
|
||||
- [ ] 将 `packages/cli/bin/lime.js` 的 `codexPackageRoot`、`findCodexExecutable`、
|
||||
`isPnpmOwnedCodexInstall` 改为 Lime 语义,并同步 npm package test 和 structure inventory。
|
||||
- [ ] 扩展 Linux ARM64/musl、Windows ARM64、Android 目标前必须先有可复现构建产物、
|
||||
native payload、launcher smoke 和 CI evidence;没有产物的 optional package 不得发布。
|
||||
- [ ] 保持 `init_firewall.sh`、`run_in_container.sh` 为 Codex Cloud/容器参考,不复制为
|
||||
Lime 伪生产入口。
|
||||
|
||||
退出条件:88 个 partial 全部变成 `covered`、有记录的 `defer` 或 `excluded`;CLI Gate B、
|
||||
npm staging/launcher、JSON/JSONL/stdin/exit/signal/completion 和结构 inventory 通过。
|
||||
|
||||
## 7. 阶段 D:Cloud transport foundation
|
||||
|
||||
仅在阶段 A-C 的本地 current contract 稳定后推进:
|
||||
|
||||
- [ ] 完成 authenticated WebSocket transport 的 tenant identity、protocol version、TLS、
|
||||
credential lifecycle 和 token redaction contract。
|
||||
- [ ] 补跨网络 disconnect/reconnect/resume、tenant isolation、rate limit、audit 和
|
||||
credential leak negative tests;TUI/CLI 继续复用同一 session facade。
|
||||
- [ ] 远程 queue、remote plugin catalog、Cloud managed permission profile、remote sandbox
|
||||
和 exec-server 测试仍对应 CLI ledger 的 64 个 `cloud-deferred` 条目。
|
||||
- [ ] 未取得 LimeCore 服务端合同、租户隔离证明和真实远端 Gate B 前,不接入默认 CLI/TUI、
|
||||
Electron sidecar 或 npm production package,不创建假 Cloud endpoint。
|
||||
|
||||
退出条件:安全评审、服务端合同、真实 authenticated remote Gate B 和审计 evidence 全部具备。
|
||||
|
||||
## 8. 治理与删除
|
||||
|
||||
- [ ] 每个批次刷新 TUI/CLI structure、snapshot、test inventory,并把新增差异分类为
|
||||
`current/compat/deprecated/dead`。
|
||||
- [ ] 零消费者后删除顶层 `tui/src/reconnect.rs`;旧路径只保留 retired guard/negative test。
|
||||
- [ ] 对 `lime-cli`、`terminal-ui`、`TerminalGuard`、`TuiTerminal`、旧 runtime、旧
|
||||
crossterm registry、旧 launcher 命名增加结构负向测试。
|
||||
- [ ] 若发现跨命令组 legacy policy/mock residual,登记 `tech-debt-tracker.md` 的 `CCD-012`,
|
||||
不在本计划中新增兼容层。
|
||||
|
||||
## 9. 验证门禁
|
||||
|
||||
每个阶段按最小边界验证,阶段收尾再扩大:
|
||||
|
||||
```bash
|
||||
cargo test --locked -p tui -p cli
|
||||
cargo clippy --locked -p tui --no-deps -- -D warnings
|
||||
cargo clippy --locked -p cli --no-deps -- -D warnings
|
||||
npx vitest run scripts/app-server/tui-structure-inventory.test.mjs
|
||||
npx vitest run scripts/app-server/tui-snapshot-inventory.test.mjs
|
||||
npm run test:contracts
|
||||
npm run smoke:cli-gate-b
|
||||
npm run smoke:tui-gate-b
|
||||
npm run governance:legacy-report
|
||||
npm --prefix packages/cli test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
涉及协议/锁文件时追加 `cargo metadata --locked`、相关 App Server crate 测试和
|
||||
`npm run test:rust:related -- <paths...>`;涉及真实 TUI 生命周期时必须追加 PTY、alternate
|
||||
screen、键盘输入和终端恢复证据;涉及 Cloud 时必须追加服务端合同和 authenticated remote Gate B。
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
- TUI structure inventory 中所有 Codex current 能力均有 Lime current owner;聚合 Lime-only
|
||||
owner 已拆除或有明确保留理由。
|
||||
- 802 个 snapshot 全部有 `direct/merge/contract/defer/dead` 分类,direct/merge/contract
|
||||
的可交付子集均有对应测试和证据,dead 不进入 current。
|
||||
- TUI Codex-shaped integration suite 全绿;390 个现有 TUI 测试不回退。
|
||||
- CLI ledger 不再有 `missing/pending`;partial 逐项收口或有可验证 defer/excluded 记录。
|
||||
- Desktop 与 CLI/TUI 仍消费同一个 App Server JSON-RPC/RuntimeCore/read model;Cloud 仍是
|
||||
authenticated transport 扩展点。
|
||||
- `lime-cli`、`terminal-ui`、旧 runtime、旧 launcher 和第二套状态机无 current 引用,治理
|
||||
扫描通过。
|
||||
|
||||
下一执行批次:**A0 + A1(纯终端 direct owner 与 48 个 direct snapshot)**。完成后再进入
|
||||
A2 transcript/history,不并行扩张 Cloud 或复制 Codex 产品专属模块。
|
||||
@@ -187,6 +187,9 @@ Lime 与 LimeCore 只共享 App Server protocol/session 合同;租户隔离、
|
||||
- `ClientSession::start_remote`、TUI `AppServerSession::connect_remote` 与 CLI `start_session` 复用同一 initialize/request/event/shutdown actor;TUI reconnect 和 session picker 也按同一 remote 配置工作,未创建第二套 runtime、Thread 状态或持久化。
|
||||
- CLI 参数与 Codex 形状对齐:`--remote URL`、`--remote-auth-token-env ENV_VAR`;缺失 endpoint、缺失/空 token 环境变量均在连接前失败。
|
||||
- `cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server-client -p cli -p tui`:44 + 34 + 177 全部通过;新增 remote auth policy、WebSocket roundtrip、CLI parser 和 fail-closed 回归。
|
||||
- TypeScript `packages/app-server-client/src/remote.ts` 复用既有 `AppServerConnection`,补齐 Node/Electron 可用的 authenticated WebSocket transport;Bearer token 与 tenant id 仅进入 header,握手在 `initialized` 前固定校验 `app-server`/`appserver.v0`,并复用 JSON-RPC request、notification、reverse request、cancel 和单读泵语义。
|
||||
- TypeScript remote transport 只作为 Cloud foundation,不接入 renderer 或默认 Electron sidecar;生产 Cloud endpoint 仍由 Cloud 服务的租户、凭证、恢复、限流和审计合同决定。
|
||||
- `npm --prefix "packages/app-server-client" test`:11 个测试文件、139 项通过,包含 6 项真实 `ws` `WebSocketServer` roundtrip/header/握手/身份/协议安全回归,以及 browser entry 不暴露 Node/WebSocket transport 的边界回归;`npm run check:protocol-types`、Node syntax、Prettier 与 `git diff --check` 通过。
|
||||
- P4 剩余未完成:identity、tenant isolation、凭证存储、协议版本协商、跨网络 resume/rate limit/audit 与真实 Cloud endpoint;在这些条件完成前,Cloud 继续保持架构扩展点,不进入生产发布路径。
|
||||
- `npx vitest run scripts/app-server/{cli,tui}-gate-b.test.mjs scripts/app-server/tui-snapshot-inventory.test.mjs`:7 项通过;包含 Windows CLI/TUI workflow 结构守卫。
|
||||
- `cargo clippy -p tui -p cli --no-deps -- -D warnings`:通过。
|
||||
@@ -585,7 +588,7 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event`
|
||||
- `bottom_pane/request_user_input` 迁移为 Codex 同名目录模块,并新增 `render.rs`,将问题渲染与
|
||||
`bottom_pane/render.rs` 的 approval 渲染职责分离。`bottom_pane/chat_composer.rs` 现为 composer
|
||||
唯一 owner,类型名为 `ChatComposer`;终端生命周期 owner 已从 Lime-only `terminal.rs` 迁到
|
||||
Codex 同名 `tui.rs`,`TerminalGuard` 仍保留其真实生命周期职责。
|
||||
Codex 同名 `tui.rs`;终端生命周期类型已进一步收敛为 Codex 同名 `Tui`。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(188/188)、
|
||||
`cargo fmt --manifest-path "lime-rs/Cargo.toml" --all`、
|
||||
`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs" "scripts/app-server/tui-snapshot-inventory.test.mjs"`
|
||||
@@ -602,19 +605,19 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event`
|
||||
`tui/frame_requester.rs`。`EventBroker` 是唯一 crossterm 输入 owner,支持暂停/恢复并在
|
||||
`TuiEventStream` 中统一 key、paste、resize、focus 与 draw;`FrameRequester` 合并异步重绘
|
||||
并限制最高 120 FPS。
|
||||
- `TerminalGuard` 现在持有 broker、draw channel 和 frame requester;外部编辑器 suspend/resume
|
||||
- `Tui` 现在持有 broker、draw channel 和 frame requester;外部编辑器 `with_restored`
|
||||
会释放并恢复 stdin。runtime 与 session picker 均消费 `TuiEventStream`,不再各自直接创建
|
||||
`EventStream`,业务事件仍由 App Server session 与 `App` 处理。
|
||||
- 新增 Codex 同名事件流/帧调度回归 21 项,`cargo test -p tui` 当前为 214/214;结构账本刷新为
|
||||
624 文件,TUI/CLI structure、snapshot、test inventory 11/11,`cargo clippy -p tui -p cli
|
||||
--no-deps -- -D warnings` 与 workspace fmt 通过。
|
||||
- 分类:`current = tui::event_stream + tui::frame_requester + TerminalGuard`;
|
||||
- 分类:`current = tui::event_stream + tui::frame_requester + Tui`;
|
||||
`compat/deprecated = none`;Codex job-control、inline scrollback、keyboard enhancement 与
|
||||
platform probe 仍按账本 `defer/product-specific`,本轮不伪造第二套 runtime 或平台 owner。
|
||||
|
||||
## 2026-09-05 PTY editor handoff recovery
|
||||
|
||||
- 外部编辑器返回后的 `TerminalGuard::resume` 只重新启用 raw/alternate/bracketed-paste 模式并恢复
|
||||
- 外部编辑器返回后的 `Tui::with_restored` 重新启用 raw/alternate/bracketed-paste 模式并恢复
|
||||
`EventBroker`,不再调用会同步发送 `CSI 6n` 的 Ratatui 清屏路径;下一轮正常 draw 负责重绘恢复后的
|
||||
composer。这样不会让 TUI 的事件 reader 与编辑器/PTY 的光标响应竞争 stdin。
|
||||
- 删除 Gate B 中针对旧同步光标查询的 PTY 回写步骤,改为直接断言编辑器交接标记和恢复后的 prompt;
|
||||
@@ -625,7 +628,7 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event`
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui -p cli --no-deps -- -D warnings`、
|
||||
`npm run smoke:tui-gate-b`(complete、approval、user-input、interrupt、failure、queue-edit 六场景)
|
||||
全部通过。
|
||||
- 分类:`current = tui::tui::TerminalGuard + tui::runtime_pty_tests`;
|
||||
- 分类:`current = tui::Tui + tui::runtime_pty_tests`;
|
||||
`compat/deprecated = none`;旧同步 CPR 查询与调试输出为 `dead / deleted / forbidden-to-restore`。
|
||||
|
||||
## 2026-09-05 Codex text formatting owner
|
||||
@@ -689,3 +692,522 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event`
|
||||
resume_picker_transcript_preview_tests.rs`;`dead/deleted/forbidden-to-restore = session_picker.rs`;
|
||||
Codex 的本地 state DB、归档恢复后台 loader、远端跨网络 resume 和完整密集表格 UI 仍为
|
||||
`deferred`,不能以本地伪实现替代 App Server owner。
|
||||
|
||||
## 2026-09-06 Codex resume picker pager and incremental pagination
|
||||
|
||||
- `resume_picker.rs` 新增 `PickerTranscriptPager`,复用 current `PagerOverlay`、
|
||||
`entry::hyperlink_lines_with_locale` 和 canonical `TranscriptEntry`。Ctrl+T 在选中会话上打开
|
||||
独立 transcript pager,加载中/失败/空记录均可见;Esc、Q、Ctrl+T 关闭,Home/End、上下键和
|
||||
PageUp/PageDown 沿用 pager 导航,不把 picker transcript 复制成第二个 projection owner。
|
||||
- `thread/list` 改为单页 `ThreadPage` 请求;`PickerState` 复用 Codex 形状的
|
||||
`PaginationState`/`PageCursor`/`PageLoadMode` 保存 cursor 和 request token,仅在列表尾部滚动时
|
||||
追加下一页,按 thread id 去重,查询、状态、目录、排序变化会失效旧 cursor,重复 cursor 和
|
||||
stale response fail closed。
|
||||
- 归档成功会清理对应 transcript pager;footer 与 transcript 状态文案覆盖五个产品 locale。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(288/288)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(3/3)、
|
||||
`npm run test:contracts`、`npm run governance:legacy-report`、`npm run smoke:tui-gate-b`(六场景)
|
||||
与 `git diff --check` 均通过。
|
||||
- 分类:新增 pager、分页状态、locale 和测试属于 `current`;没有新增 `compat` 或
|
||||
`deprecated`;旧 `lime-cli`、`terminal-ui`、Codex 私有 history/state DB 仍为
|
||||
`dead/deleted/forbidden-to-restore`。剩余差距回到 P3 composer 分模块迁移和 Codex snapshot
|
||||
场景选择性迁移,不复制无 Lime owner 的产品模块。
|
||||
|
||||
## 2026-09-06 Codex composer textarea owner
|
||||
|
||||
- 迁移到 Codex 同名 `bottom_pane/textarea.rs`,`TextArea` 统一持有可编辑文本和
|
||||
UTF-8/grapheme 安全光标;`ChatComposer` 保留提交、排队、历史搜索和权限/编辑器动作编排,
|
||||
不再直接维护第二套文本/光标状态。
|
||||
- 迁移 `insert`、左右移动、行首尾、前后 grapheme 删除和整段替换;保留现有
|
||||
`InputResult` 与 App Server queue/turn lowering,不引入 Codex 私有 Vim、mention catalog 或
|
||||
attachment runtime。
|
||||
- 新增 `TextArea` 同名语义测试,覆盖复合 emoji 删除和多行行首尾;验证:
|
||||
`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(290/290)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`npm run smoke:tui-gate-b`(六场景)和 `git diff --check` 通过。
|
||||
- 分类:`TextArea` 与 `ChatComposer` 为 `current`;未新增 `compat/deprecated`,未恢复
|
||||
Codex 私有 history DB、Vim/mention/connector 产品面。下一步继续从 snapshot inventory 选择
|
||||
可由 Lime current owner 承接的 composer/history 场景。
|
||||
|
||||
## 2026-09-06 Codex composer history search owner
|
||||
|
||||
- 新增 Codex 同名 `bottom_pane/chat_composer/history_search.rs`,由
|
||||
`HistorySearchState`、`find_match`、`find_older`、`find_newer` 承接纯历史匹配规则;
|
||||
`ChatComposer` 只编排查询输入、draft 恢复、选中结果和提交,不再内嵌搜索算法副本。
|
||||
- 补充大小写不敏感、旧/新方向边界、空查询和无选中项回归;未改变 App Server
|
||||
`promptHistory/read|append` 或队列/turn lowering。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib bottom_pane::chat_composer`
|
||||
(13/13)与 `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`
|
||||
通过。分类:`history_search.rs` 与 `ChatComposer` 为 `current`,无 `compat/deprecated`,
|
||||
Codex 私有 rollout/history DB 仍为 `dead/deleted/forbidden-to-restore`。
|
||||
|
||||
## 2026-09-06 Codex composer state owners and textarea wrapping
|
||||
|
||||
- `ChatComposer` 现在按 Codex 目录拆出 `chat_composer/attachment_state.rs` 与
|
||||
`chat_composer/draft_state.rs`。`AttachmentState` 成为本地图片队列的唯一 owner,
|
||||
`DraftState` 持有 `TextArea` 和待恢复 draft;`App` 不再维护平行 `pending_images` 字段,
|
||||
runtime/view/test 通过 composer 委托保持原有 API 和 App Server lowering。
|
||||
- `bottom_pane/textarea/wrapping.rs` 按 Codex 同名纯终端算法迁入 grapheme-safe
|
||||
`wrapped_lines`、`cursor_position` 与 `visible_prefix`,`TextArea` 光标定位复用该结果,
|
||||
支持软空格挂接、非断行空格、中文/emoji UTF-8 边界和满行 cursor sentinel;不引入 Vim、
|
||||
mention catalog、history DB 或第二套 runtime 状态。
|
||||
- 新增 `bottom_pane/textarea/wrapping_tests.rs` 与 `attachment_state` 单测,覆盖换行边界、
|
||||
显示宽度、附件有序取回/恢复/删除;结构账本刷新后保留 Codex 路径、函数和类型双向对照。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(296/296)、
|
||||
`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(3/3)通过;
|
||||
后续收尾仍需执行 clippy、相关 Rust 测试、contracts、治理扫描、TUI Gate B 和 diff check。
|
||||
- 分类:`bottom_pane/textarea.rs`、`bottom_pane/textarea/wrapping.rs`、
|
||||
`chat_composer/{attachment_state,draft_state}.rs` 与委托调用属于 `current`;没有新增
|
||||
`compat/deprecated`;Codex 私有 Vim/mention/connector/history DB 继续为
|
||||
`dead/deleted/forbidden-to-restore`。
|
||||
|
||||
## 2026-09-06 Codex textarea state and reconnect boundary
|
||||
|
||||
- `bottom_pane/textarea.rs` 补齐 Codex 同名 `TextAreaState`、`cursor_pos`、
|
||||
`cursor_pos_with_state`、`desired_height`、`insert_str`、`replace_range`、`set_cursor` 和
|
||||
Ratatui `StatefulWidgetRef` 渲染接口;主 composer 通过 `DraftState::textarea_state_mut` 保存
|
||||
viewport scroll,长行和多行输入按实际视觉行滚动,光标始终留在可见区域。
|
||||
- 新增 `bottom_pane/chat_composer/reconnect.rs` 与 `reconnect_tests.rs`,提供 Codex 形状的
|
||||
`ChatComposer::handle_disconnected_key`:断线编辑会恢复 history preview,Enter/Tab 不会
|
||||
消费草稿,字符与基本移动/删除仍保持 UTF-8 安全;重连尝试和 session 生命周期仍由
|
||||
`tui::reconnect` + App Server owner 承担。
|
||||
- 结构账本扫描器改为识别缩进的 `impl` 方法,避免只记录顶层函数造成 Codex 符号漏报;账本
|
||||
新增 reconnect 文件与 `TextAreaState`/cursor state 符号守卫。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(301/301;含 stateful
|
||||
render 清屏回归)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`rustfmt --check`(本轮 touched TUI 文件)、结构账本 Vitest(3/3)和 `git diff --check`
|
||||
通过;workspace 全量 `cargo fmt --check` 仍受既有并行改动阻塞,未执行全仓格式化。
|
||||
- 分类:`TextAreaState`、stateful composer render、reconnect boundary 与对应测试属于
|
||||
`current`;没有新增 `compat/deprecated`,Codex 私有 Vim/mention/connector/history DB
|
||||
继续为 `dead/deleted/forbidden-to-restore`。下一步回到 snapshot inventory,选择能由 Lime
|
||||
current owner 承接的 composer attachment/draft snapshot 和 multi-agent navigation 场景。
|
||||
|
||||
## 2026-09-06 Codex composer key-event boundary
|
||||
|
||||
- 对照 Codex `ChatComposer::handle_key_event` 的入口语义,公共 composer 现在先忽略
|
||||
`KeyEventKind::Release`,只让 Press/Repeat 进入历史搜索、编辑、提交和排队分派;主循环已有
|
||||
的 Press 过滤保持不变,断线入口继续由 `handle_disconnected_key` 独立处理。
|
||||
- 新增 `key_release_does_not_insert_or_submit` 回归,证明释放事件不会改变草稿、触发提交或
|
||||
消费当前输入;没有改变 `TextArea::input`、App Server queue/turn lowering 或 Ctrl-C 边界。
|
||||
- 验证:touched `chat_composer.rs` `rustfmt --check`、
|
||||
`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib bottom_pane::chat_composer`
|
||||
(18/18)、`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`
|
||||
均通过;随后完整 TUI、结构 inventory、contracts、治理扫描与真实 TUI Gate B 作为收尾门禁。
|
||||
- 分类:`current = ChatComposer::handle_key_event` 的 Press/Repeat 边界及其测试;
|
||||
`compat/deprecated = none`;Codex 私有 Vim/mention/connector/history DB 仍为
|
||||
`dead/deleted/forbidden-to-restore`。本轮继续推进同一 `CLI/TUI Host -> App Server JSON-RPC`
|
||||
主链,不复制第二套 runtime 或历史状态 owner。
|
||||
|
||||
### 收尾门禁
|
||||
|
||||
- 完整 `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`:318/318 通过。
|
||||
- `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings` 与
|
||||
touched 文件 `rustfmt --check` 通过。
|
||||
- TUI structure inventory 与 802 条 Codex snapshot inventory:5/5 测试通过;
|
||||
`npm run test:contracts`、`npm run governance:legacy-report`、`git diff --check` 通过。
|
||||
- `npm run smoke:tui-gate-b`:complete、approval、user-input、interrupt、failure、queue-edit
|
||||
六场景通过,真实 PTY/alternate screen/键盘输入/终端恢复与 canonical App Server ledger 均通过。
|
||||
|
||||
## 2026-09-06 Codex model catalog and collaboration mode wiring
|
||||
|
||||
- 按 Codex `tui/src/model_catalog.rs` 保留 `ModelCatalog`、`with_collaboration_modes` 和
|
||||
`try_list_models`,模型 picker 通过该 catalog 读取服务端模型;移除当前 Lime 没有真实消费者的
|
||||
`model_by_id`/`default_model` 扩展,避免用测试 API 冒充产品能力。
|
||||
- 按 Codex `tui/src/collaboration_modes.rs` 接入 `default_mask`、`default_mode_mask`、`next_mask`
|
||||
和 `plan_mask`:启动/设置同步优先服务端 Default preset,缺失时才回退到服务端首个可见 preset;
|
||||
`Shift+Tab` 仅在无 active turn、无 modal/popup 时循环服务端模式,结果通过现有
|
||||
`thread/settings/update` lowering,不创建 TUI 私有模式状态源。
|
||||
- `AppServerSession::start_thread` 现在返回完整 typed `ThreadStartResponse`。TUI 使用服务端实际
|
||||
model/provider/reasoning effort 初始化本地只读状态,因此未传 `--model/--provider` 的自动模型场景
|
||||
也能构造合法 collaboration mode;`exec` 入口同步适配该 typed 返回值。
|
||||
- 新增 App 回归覆盖默认模式选择、`BackTab` 模式循环和运行中不抢占输入;模式更新失败保持当前
|
||||
本地状态,服务端未提供 mode catalog 时 `/plan` 继续 fail closed。
|
||||
|
||||
本轮验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(324/324)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`node scripts/app-server/tui-structure-inventory.mjs`(646 files)及
|
||||
`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs" "scripts/app-server/tui-snapshot-inventory.test.mjs"`
|
||||
(5/5)通过。`cargo fmt --check` 未作为全仓绿灯报告:当前并行工作树已有未归本轮的大量格式差异,
|
||||
本轮未执行全仓格式化;新增/修改逻辑由 Clippy、编译和单测覆盖,`git diff --check` 待本轮收尾门禁执行。
|
||||
|
||||
分类:`current = tui::model_catalog + tui::collaboration_modes + AppServerSession typed thread/start +
|
||||
App mode cycling`;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 未消费的
|
||||
model catalog 查找扩展、旧 lime-cli、terminal-ui、Codex 私有 history/state DB`。下一刀继续从
|
||||
snapshot inventory 选择 multi-agent navigation 或剩余可由 Lime current owner 承接的模式/模型场景,
|
||||
不复制 Codex 私有 runtime、账号和 Cloud 状态。
|
||||
|
||||
## 2026-09-06 Codex collaboration mode status localization and effort clearing
|
||||
|
||||
- 为 `plan mode`、`plan mode unavailable on this server` 和 `collaboration mode updated` 补齐
|
||||
`zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 状态映射,并加入逐语言回归;未知状态仍保持
|
||||
原文,状态翻译继续由 `Locale::status` 作为唯一展示边界承接。
|
||||
- 模式切换后 runtime 直接采用服务端 `CollaborationModeSettings.reasoning_effort`,不再用旧
|
||||
effort 值覆盖 `Some(None)` 的显式清除;保留 mask 缺省值通过 lowering 时的 fallback 语义。
|
||||
- 更新 App 模式循环测试,覆盖 Default preset 的显式 effort 清除;没有新增 compat/deprecated
|
||||
路径,也没有复制 Codex runtime、history DB 或 Cloud 状态。
|
||||
|
||||
本轮验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(325/325)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、touched
|
||||
文件 `rustfmt --check`、`git diff --check`、`npm run test:contracts`(299 项 client contract)、
|
||||
`npm run governance:legacy-report`(零引用候选/分类漂移/边界违规)、
|
||||
`npm run governance:cli-boundary`、TUI structure/Gate B Vitest(6/6)及
|
||||
`npm run smoke:tui-gate-b`(六场景真实 PTY、alternate screen、App Server ledger、终端恢复)
|
||||
均通过。全仓 `cargo fmt --check` 仍受并行工作树既有格式差异影响,未执行全仓格式化。
|
||||
|
||||
分类:`current = Locale::status` 的协作模式状态、runtime collaboration mode lowering 与
|
||||
对应测试;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、
|
||||
terminal-ui、Codex 私有 runtime/history/state DB`。下一刀继续从 Codex snapshot inventory 选择
|
||||
具备 Lime current owner 和真实证据的 multi-agent navigation 或剩余模式/模型交互场景。
|
||||
|
||||
## 2026-09-06 Codex settings synchronization boundary
|
||||
|
||||
- 对齐 Codex `chatwidget/settings.rs` 的有效状态语义:`App::set_settings` 在 model 有显式值时
|
||||
同步当前 collaboration mode 的 model;reasoning effort 只有显式提供时才覆盖当前 mode,避免
|
||||
CLI 未传 `--effort` 时把服务端 preset 默认值误清空。
|
||||
- collaboration mode 切换仍直接把服务端 mode settings 写入 `App`,因此 `Some(None)` 的显式
|
||||
effort 清除不会被全局 settings fallback 重新覆盖。
|
||||
- 新增 `settings_updates_keep_the_active_collaboration_mode_in_sync`,同时覆盖 model 更新、显式
|
||||
effort 更新和未提供 effort 时保留 preset;未新增 compat/deprecated 路径。
|
||||
|
||||
本轮增量验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(326/326)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、touched
|
||||
TUI 文件 `rustfmt --check` 均通过;前一节记录的 contracts、governance、结构快照和真实
|
||||
TUI Gate B 证据继续有效。
|
||||
|
||||
分类:`current = App::set_settings` 与 collaboration mode 有效状态同步;
|
||||
`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、
|
||||
Codex 私有 runtime/history/state DB`。下一刀继续从 multi-agent contract snapshots 选择能由
|
||||
Lime App Server current owner 承接的纯展示/导航能力。
|
||||
|
||||
## 2026-09-06 Codex multi-agent interaction regression boundary
|
||||
|
||||
- 对照 Codex `app/agent_navigation.rs` 的 parent-owned/liveness 语义,`SubAgentActivity::Started`
|
||||
现在在 parent stream 观察到时立即标记 child thread 为 parent-owned;即使 `ThreadStarted`、
|
||||
`ItemStarted` 和 resume 的到达顺序交错,也不会向子 Agent 直接提交或排队用户输入。
|
||||
- `App::can_accept_direct_input` 成为 TUI runtime 的唯一直接输入护栏,`Submit` 与 `Queue` 共用
|
||||
同一检查和 canonical 状态文案,移除 runtime 内的重复判断。
|
||||
- `/subagents` 执行回归确认 slash command 会消费本地命令、清空 composer 并打开 Codex 命名的
|
||||
Agent picker;新增五产品 locale 的 picker title/footer TestBackend 渲染回归。footer 改为
|
||||
不在单行区域叠加底边框,避免窄终端裁掉可见控制文案;新增当前 Agent footer 渲染回归。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(347/347)通过,
|
||||
touched TUI 文件 `rustfmt` 与 `git diff --check` 通过。新增行为只复用 App Server
|
||||
`Thread`/`SubAgentActivity`/`thread/resume` 和 canonical `ConversationProjection`,没有新增
|
||||
runtime、持久化或 Cloud 假 endpoint。
|
||||
|
||||
分类:`current = app::{agent_navigation,agent_picker} + multi_agents + App direct-input guard`
|
||||
及其回归;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、
|
||||
terminal-ui、Codex 私有 agents daemon/state DB`。完整 contracts、governance、TUI Gate B
|
||||
收尾门禁仍需在本轮最终状态上复跑。
|
||||
|
||||
## 2026-09-06 Codex Agents Overview owner alignment
|
||||
|
||||
- 按 Codex 真实目录补齐 `app/agents_overview.rs`、`app/agents_overview_render.rs`、
|
||||
`app/agents_overview_threads.rs`、`app/agents_overview_view.rs` 与独立
|
||||
`app/agents_overview_tests.rs`。Overview 的状态分组、根线程/子线程状态冒泡、搜索、分组切换、
|
||||
刷新、选择和窄终端渲染均由同名 current owner 承接。
|
||||
- `/agents` 现在打开 Agents Overview,并返回显式 `RefreshAgentsOverview` action;`/subagents`
|
||||
保留为当前会话 Agent Picker。运行时通过 `AppServerSession::list_thread_page` 分页读取
|
||||
canonical v2 `ThreadList`,不访问 Codex 私有 daemon/state DB。ThreadStarted/Closed/
|
||||
StatusChanged/NameUpdated/Archived/Deleted 通知在 `App::apply_notification` 边界更新 Overview,
|
||||
刷新代次用于丢弃过期结果。
|
||||
- 新增五种产品 locale 的 Overview 标题、控制提示、搜索前缀和状态摘要;`agent_picker` 继续
|
||||
作为 `/subagents` 的 current 会话导航 surface,Overview 可见时不渲染或接收其输入。
|
||||
- Codex 的 shared agents daemon 生命周期、后台线程 turn owner 与完整输入状态恢复尚未迁入;
|
||||
Lime 已将新任务、改名、停止和交互请求重放接到现有 App Server `thread/start`、`turn/start`、
|
||||
`thread/name/set`、`turn/interrupt` 与当前 `BottomPane`,但 daemon 专属的跨会话语义仍分类为
|
||||
`deferred`,本轮不伪造 endpoint、request 或平行 runtime。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(354/354)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`node scripts/app-server/tui-structure-inventory.mjs`(Codex/Lime 文件缺口为 0)、
|
||||
touched TUI `rustfmt` 与 `git diff --check` 通过。
|
||||
|
||||
分类:`current = app/agents_overview* + AppServer ThreadList/notification projection +
|
||||
RefreshAgentsOverview action + existing child-thread command lowering + app/agent_picker`
|
||||
(`/subagents` current 会话导航);`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 agents daemon/state DB`;
|
||||
`deferred = Codex daemon 专属跨会话 dispatch/stop/replay 语义`。下一刀应补真实 App Server
|
||||
child-thread command contract 后,再迁移 daemon 级跨会话恢复,不得先恢复 Codex 私有 daemon。
|
||||
|
||||
## 2026-09-06 Agents Overview refresh state and resume command
|
||||
|
||||
- `AgentsOverviewState` 补齐 Codex 同名生命周期字段:`initialized`、`request_id`、
|
||||
`refresh_pending`、`refresh_thread_ids`、`refresh_notifications`、`refresh_task`、
|
||||
`visible_thread_ids`、`rendered_full_screen` 和共享 `view_state` 镜像。当前 `Vec<Thread>` 仍是
|
||||
Lime 的唯一投影 owner;共享镜像只服务于同名 host adapter,不创建第二套交互状态。
|
||||
- 刷新请求现在具备单一 request identity;并发刷新被合并,刷新期间同一线程同类通知采用
|
||||
last-write-wins,响应完成后按到达顺序重放。Overview 的选中行、过滤结果和共享 view state
|
||||
同步维护,状态对象 drop 时取消已安装的异步 refresh task。
|
||||
- 新增 Codex 风格 `/resume` slash command,直接触发现有运行中 resume picker;命令目录、五语言
|
||||
描述、设置命令解析和 App 回归同步更新。`agent_picker` 由 `/subagents` 触发并保持 current,
|
||||
不参与 Overview 产品渲染。
|
||||
- 验证:Agents Overview 和 slash command 定向测试通过;完整 TUI 测试与 clippy 待本轮收尾门禁
|
||||
重跑。真实 PTY 继续覆盖既有六场景;Overview -> picker 的 PTY 证据仍待终端控制键在
|
||||
portable-pty 环境稳定映射后补齐。
|
||||
|
||||
分类:`current = AgentsOverviewState request/notification lifecycle、/resume、/agents、/subagents、
|
||||
既有 App Server thread/list + thread/resume`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = lime-cli、terminal-ui、Codex agents daemon/state DB`;
|
||||
`deferred = 依赖真实 App Server child-thread command contract 的跨会话 daemon 行为`。下一刀优先补
|
||||
App Server child-thread contract 和 portable-pty 可重复的 Overview 流程,再扩展完整 Codex
|
||||
`BottomPaneView`,不恢复私有 daemon。
|
||||
|
||||
## 2026-09-06 Agents Overview thread targeting and PTY Gate B
|
||||
|
||||
- 按 Codex 真实目录新增 `app/app_server_event_targets.rs`,保留同名
|
||||
`ServerNotificationThreadTarget`、`server_notification_thread_target` 与
|
||||
`server_request_thread_id`。`App::apply_notification` 先更新 Overview/导航,再只把当前 Thread
|
||||
notification 写入当前 `ConversationProjection`;后台 Thread 的 message/item/turn 事件不会污染
|
||||
前台 transcript。
|
||||
- foreign Thread reverse request 的分发复制 Codex `unsupported_request` 处理形状。只有 Lime
|
||||
`BottomPane` 已实现的 command/file/permission approval 与 `request_user_input` 可以进入 Overview
|
||||
对应 `ThreadEventStore` 的 bounded replay channel;`pending_interactive_replay` 维护 request identity,Dynamic Tool 和 MCP elicitation 当前没有 TUI consumer,立即走既有 reject,channel 满时也 fail closed
|
||||
路径,不会在恢复时静默丢失。`unsupported_requests_return_for_rejection` 锁住该 fail-closed 合同。
|
||||
- `agents-overview` 成为默认第七个 TUI Gate B 场景:真实 `/agents -> Ctrl+N -> prompt -> Enter`
|
||||
经过 `thread/start + turn/start`,等待 `ThreadStatusChanged(Active)` 投影为 `1 working` 后,
|
||||
真实发送 Down、Ctrl+R、Ctrl+X、Enter 与 Ctrl+D,依次验证选择后台 Thread、改名、停止同一
|
||||
Turn、恢复 transcript 和退出 TUI。ledger 断言精确 input、Thread/Turn identity、
|
||||
`turn.started,message.delta` 与同 identity `turnCancel`;PTY 在关闭 alternate screen 前使用 Codex
|
||||
同款 `vt100::Parser` 还原 24x100 实际屏幕并验证终端恢复。
|
||||
- 共享 terminal backend fixture 对所有 `turnStart` 统一先发 Codex 生命周期事件
|
||||
`turn.started`,修复“输出可见但 Thread 永远 Ready”的伪闭环;七个场景各自使用独立
|
||||
cwd/data/app-data,避免前序 Thread 改变 Overview 选择顺序。`terminal-gate-binaries.mjs` 在默认
|
||||
路径下用仓库受校验 rusty-v8 环境重建 current `cli`/`app-server`,显式二进制覆盖则保持不变,
|
||||
因而 Gate 不再测试新 harness 却复用旧产品二进制。
|
||||
- 按 Codex `key_hint.rs::is_plain_text_key_event` 的同名边界接入 Overview 新任务、搜索与改名输入,
|
||||
允许 Shift 字符并拒绝 Control/Alt 文本,真实 PTY 中 `Gate B background` 不再丢失大写字符。
|
||||
用户指定的 GPT-6 viewport workstream 同时落地 `viewport.rs`,字段与状态语义映射 Codex
|
||||
`custom_terminal.rs`/`tui.rs` 的 `viewport_area`、`last_known_screen_size`、
|
||||
`last_known_cursor_pos`、`alt_saved_viewport`、resize 与 alternate-screen round trip;它不创建第二 renderer。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(373/373)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
terminal Gate/构建 helper Vitest(8/8)、structure/snapshot inventory(5/5)、
|
||||
`npm run smoke:tui-gate-b`(七场景,`queue-edit=ok agents-overview=ok terminal=restored`)、
|
||||
`npm run smoke:cli-gate-b`、`npm run test:contracts`、`npm run governance:legacy-report` 和
|
||||
structure inventory(657 files)通过;CLI boundary 为
|
||||
`current=cli+tui+npm-platform-packages retired=lime-cli`。全 crate
|
||||
`cargo fmt --check` 仍报告并行工作树中既有的 TUI import/折行差异,本轮没有批量格式化或覆盖。
|
||||
|
||||
分类:`current = app/app_server_event_targets.rs + 当前 Thread projection 路由 + Overview
|
||||
ThreadEventStore + pending_interactive_replay + 七场景 TUI Gate B + app/agent_picker(`/subagents` 会话导航);
|
||||
`deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有
|
||||
runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产 Cloud`。本刀已用真实 stdio/PTY
|
||||
覆盖 child-thread start/name/interrupt/resume;下一刀继续从 Codex TUI 测试账本选择能由 Lime
|
||||
App Server current owner 承接的未覆盖交互,不恢复私有 daemon。
|
||||
|
||||
## 2026-09-06 Codex App Server event owner alignment
|
||||
|
||||
- 按 Codex 真实目录新增 `tui/src/app/app_server_events.rs`,由同名
|
||||
`handle_app_server_event` 与 `handle_server_notification_event` 承接 TUI transport event 和 typed
|
||||
notification;reverse server request 由同名 `tui/src/app/app_server_requests.rs::handle_server_request_event`
|
||||
承接。交互 `runtime.rs` 不再直接匹配 notification/server request,只保留 terminal
|
||||
lifecycle、session shutdown 与 bounded reconnect 编排。
|
||||
- `app-server-client::AppServerEvent` 按 Codex 定义迁到 `app-server-client/src/lib.rs`,并精确收敛为
|
||||
`Lagged`、`ServerNotification`、`ServerRequest`、`Disconnected` 四个变体。旧 Lime-only
|
||||
`SessionEvent`、`RawNotification` 和 `RawServerRequest` 类型形状已删除,没有 compat alias。
|
||||
- 未知 notification 在 typed client 边界忽略;未知 reverse request 在同一 transport 上立即返回
|
||||
JSON-RPC `METHOD_NOT_FOUND`,不再泄漏给 CLI/TUI。复制 Codex 测试名
|
||||
`remote_unknown_server_request_is_rejected`,覆盖 request id、错误码、错误文案和拒绝后的正常
|
||||
shutdown。
|
||||
- 结构 inventory 守卫新增 `app/app_server_events.rs`、`app/app_server_requests.rs` 和三个 handler 符号,并禁止交互 runtime
|
||||
恢复直接 typed event 分发、禁止 client 恢复 `SessionEvent` 或 raw event 变体。inventory 更新为
|
||||
Codex/Lime 合计 658 个 Rust 文件。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server-client -p tui -p cli`
|
||||
(50 + 373 + CLI library 8 / binary 40 / integration 2)、三 crate Clippy `-D warnings`、
|
||||
`npm run test:contracts`、TUI structure Vitest、`npm run smoke:cli-gate-b` 与
|
||||
`npm run smoke:tui-gate-b` 均通过。真实 CLI/TUI evidence 经过 current stdio App Server、
|
||||
canonical `turn.started,message.delta,item.started,item.completed,turn.completed`;TUI 七场景继续
|
||||
保持 `queue-edit=ok agents-overview=ok terminal=restored`。Gate 构建仅有既有
|
||||
`app-server/src/processor/turn.rs` 两条 dead-code warning,不在本刀写集。
|
||||
|
||||
分类:`current = app/app_server_events.rs + app/app_server_requests.rs + app-server-client::AppServerEvent + typed
|
||||
notification/reverse-request routing + app/agent_picker(`/subagents` 会话导航);
|
||||
`deprecated = none`;`dead/deleted/forbidden-to-restore = SessionEvent、raw event 变体、旧
|
||||
lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Codex shared agents
|
||||
daemon 与生产 Cloud`。下一刀从 Codex `app/input.rs` 与 Lime `App::handle_terminal_event` 做逐函数
|
||||
对照,把 App 输入 owner 迁入同名目录并继续降低 `app.rs` 体量;不得自行设计第二套 input reducer。
|
||||
|
||||
## 2026-09-06 Codex TUI input and session lifecycle owner alignment
|
||||
|
||||
- 按当前 Codex `tui/src/app.rs::handle_tui_event` 与 `tui/src/app/input.rs::handle_key_event`
|
||||
拆分终端输入 owner:`runtime.rs` 直接把 `TuiEvent` 交给 `App::handle_tui_event`,后者负责
|
||||
paste 换行规范化、断线草稿、活动弹层和输入视图路由;键盘快捷键与 composer key dispatch
|
||||
进入同名 `app/input.rs`。旧 Lime-only `handle_terminal_event` 与
|
||||
`handle_disconnected_event` 生产入口已删除,没有 compat wrapper。
|
||||
- 对照 Codex `chatwidget/slash_dispatch.rs` 的真实分支修正命令语义:新增 `/agents` 并打开
|
||||
Agents Overview;`/subagents` 恢复为打开当前会话 Agent Picker。早先章节中
|
||||
“`/subagents` 打开 Overview / `agent_picker` 仅为 compat”的过渡记录由本节取代。两个入口都消费
|
||||
本地 slash command,不进入 App Server turn input;`/agents` 描述覆盖 `zh-CN`、`zh-TW`、
|
||||
`en-US`、`ja-JP`、`ko-KR`。
|
||||
- 按 Codex `tui/src/app/session_lifecycle.rs` 把 `open_agent_picker` 迁到同名 owner;picker 继续只消费
|
||||
App Server-backed navigation/thread identity,不新增私有 daemon、历史库或第二套 runtime。
|
||||
- 按 Codex `tui/src/app.rs` 的 `mod tests;` 形状把内联 App 回归迁到同名 `app/tests.rs`,测试名称、
|
||||
断言和覆盖范围保持不变;`app.rs` 从 1542 行降到 665 行,不再越过仓库 1000 行业务逻辑红线。
|
||||
- 结构守卫锁定 `app/input.rs`、`app/session_lifecycle.rs`、`handle_tui_event`、
|
||||
`handle_key_event`、`open_agent_picker`、`app/tests.rs` 与 `mod tests;`,并禁止 runtime 恢复两个旧
|
||||
handler。structure inventory 更新为 Codex/Lime 合计 661 个 Rust 文件。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(374/374)、`cargo test
|
||||
--manifest-path "lime-rs/Cargo.toml" -p cli`(library 8 / binary 40 / integration 2)、两 crate Clippy
|
||||
`-D warnings`、touched Rustfmt、TUI structure/snapshot/Gate B Vitest(11/11)、`npm run
|
||||
test:contracts`、`npm run governance:legacy-report`、`npm run smoke:cli-gate-b`、`npm run
|
||||
smoke:tui-gate-b` 与 `git diff --check` 通过。TUI Gate B 七场景继续保持 `queue-edit=ok
|
||||
agents-overview=ok terminal=restored`;CLI Gate B 保持 JSON/JSONL/stdin/error exit/completion 全链。
|
||||
Gate 构建仍只有既有 `app-server/src/processor/turn.rs` 两条 dead-code warning,不在本节写集。
|
||||
|
||||
分类:`current = App::handle_tui_event + app/input.rs::handle_key_event +
|
||||
app/session_lifecycle.rs::open_agent_picker + /agents + /subagents`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = handle_terminal_event、handle_disconnected_event、旧 lime-cli、
|
||||
terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产
|
||||
Cloud`。下一刀应继续对照 Codex `app/event_dispatch.rs`、`app_event.rs` 与 Lime runtime action match,
|
||||
把 App action/event owner 收敛到 Codex 命名和目录,同时保持公共 JSON-RPC 主链不变。
|
||||
|
||||
## 2026-09-06 Codex reconnect owner alignment
|
||||
|
||||
- 按 Codex `tui/src/app/reconnect.rs` 将 Lime 的 App Server 重连实现迁入
|
||||
`tui/src/app/reconnect.rs`,runtime 直接从该 owner 引用 `reconnect_session` 与
|
||||
`ReconnectedSession`。重连继续复用共享 `connect_session`、`thread/resume`、权限 profile
|
||||
和设置更新,不建立第二个 transport 或 runtime。
|
||||
- 顶层 `tui/src/reconnect.rs` 仅保留委托导出,未复制实现;待引用迁移完成并获得删除确认后再清理。
|
||||
- 结构守卫新增 `app/reconnect.rs` 和 runtime owner 引用断言;inventory 同步记录该 Codex 目录。
|
||||
- 分类:`current = app/reconnect.rs + App Server session resume`;`compat = reconnect.rs`
|
||||
(仅委托导出);`deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、
|
||||
terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Cloud production transport`。
|
||||
|
||||
## 2026-09-06 Codex event dispatch owner alignment
|
||||
|
||||
- 按 Codex `tui/src/app/event_dispatch.rs::handle_event` 建立 Lime 的
|
||||
`app/event_dispatch.rs` owner。设置、协作模式、权限 profile、模型选择和 Agents Overview
|
||||
action 现在由 `App::handle_event` 消费,并通过显式 `EventContext` 复用当前 App Server session
|
||||
与共享状态引用。
|
||||
- `AppAction` 继续表示 TUI 输入 reducer 的结果,不改名为 `AppEvent`;未迁移的提交、队列、
|
||||
picker、编辑器和终端滚动 action 仍由 runtime 处理,避免把终端资源生命周期错误下沉到 App。
|
||||
- runtime 保留明确的不可达保护分支,确保未来新增 action 不会静默绕过 Codex-shaped owner;
|
||||
结构守卫新增 `event_dispatch.rs`、`handle_event`、`EventContext`、`EventDispatch` 断言。
|
||||
- 分类:`current = app/event_dispatch.rs::handle_event + EventContext`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`;
|
||||
`deferred = Codex account/daemon/product-only AppEvent variants 与 Cloud production transport`。
|
||||
|
||||
## 2026-09-06 Codex thread event owner alignment
|
||||
|
||||
- 按 Codex `tui/src/app/thread_events.rs` 将 Lime 的 Thread/Turn/Item 通知投影从
|
||||
`app/app_server_events.rs` 迁入 `app/thread_events.rs`。`apply_notification`、liveness
|
||||
observation 和 sub-agent activity 现在由线程事件 owner 维护,App Server 文件只保留 transport
|
||||
分流、请求队列和队列刷新。
|
||||
- 保持同一 canonical `ServerNotification` 投影和 App Server request 生命周期,不引入 per-thread
|
||||
第二状态库;当前 Lime 的单投影模型仍是产品边界内的唯一 read model。
|
||||
- 分类:`current = app/thread_events.rs::{apply_notification,observe_notification,observe_item} +
|
||||
ThreadEventStore + pending_interactive_replay`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`;
|
||||
`deferred = Codex daemon 专属跨会话行为与 Cloud production transport`。
|
||||
|
||||
## 2026-09-06 Codex thread settings owner alignment
|
||||
|
||||
- 按 Codex `tui/src/app/thread_settings.rs` 将 Lime 的模型/provider、effort、权限 profile 和
|
||||
collaboration mode 状态同步迁入 `app/thread_settings.rs`。`set_settings`、profile 去重与
|
||||
cycling、model picker catalog 更新及 mode 选择均保留原有行为和 API 名称。
|
||||
- App Server 持久化仍由 `AppServerSession` 承担;该模块只维护 TUI 的当前线程 settings/read
|
||||
model,不创建 Codex legacy config 或 provider catalog 副本。
|
||||
- 分类:`current = app/thread_settings.rs`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`;
|
||||
`deferred = Codex account/managed config product-only settings`。
|
||||
|
||||
## 2026-09-06 Codex reverse request owner alignment
|
||||
|
||||
- 按 Codex `tui/src/app/app_server_requests.rs` 的目录和函数命名,将
|
||||
`handle_server_request_event` 从 `app/app_server_events.rs` 迁入同名
|
||||
`app/app_server_requests.rs`。`app_server_events.rs` 现在只负责 transport event 分流和
|
||||
typed notification dispatch;reverse request 的 queue/reject、foreign thread 路由和
|
||||
current-time response 由 requests owner 维护。
|
||||
- 共享 `AppServerEvent`、App Server JSON-RPC 和 canonical Thread/Turn/Item projection 不变;
|
||||
没有新增 TUI 私有 request bus、daemon、状态库或 compat wrapper。
|
||||
- 结构守卫新增 `app/app_server_requests.rs` 文件断言,并断言
|
||||
`handle_server_request_event` 不再回流到 `app_server_events.rs`;inventory 更新为
|
||||
Codex/Lime 合计 666 个 Rust 文件。
|
||||
- 验证:`cargo check --manifest-path "lime-rs/Cargo.toml" -p tui`、TUI structure
|
||||
inventory Vitest、`git diff --check`;后续完成整组 Rust/Clippy 与 Gate B 回归后再关闭本阶段。
|
||||
|
||||
分类:`current = app/app_server_events.rs + app/app_server_requests.rs + app-server-client::AppServerEvent`;
|
||||
`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、
|
||||
第二套 runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产 Cloud`。
|
||||
|
||||
## 2026-09-06 Codex external-editor PTY recovery
|
||||
|
||||
- 该阶段加入的 runtime 有界 EOF 重建只是定位外部编辑器恢复失败的临时实现,已由 2026-09-07
|
||||
`Tui::with_restored` 对齐替换并删除;不得恢复为第二套输入恢复策略。
|
||||
- 真实 `complete` 场景稳定复现 editor 返回后丢失提交,为后续 Codex 同构恢复入口提供了回归基线。
|
||||
- 分类:`dead/deleted/forbidden-to-restore = runtime bounded TuiEventStream restart`;
|
||||
`compat/deprecated = none`;旧 terminal lifecycle、`terminal-ui` 和第二套 runtime 继续
|
||||
`dead/deleted/forbidden-to-restore`。
|
||||
|
||||
## 2026-09-07 Codex replay filter owner alignment
|
||||
|
||||
- 对照 Codex `tui/src/app/replay_filter.rs`,在 Lime 当前 `tui/src/app/replay_filter.rs` 建立同名
|
||||
replay 过滤 owner,并由 `thread_events.rs` 在 snapshot drain 时调用。直接迁移
|
||||
`snapshot_has_pending_interactive_request`、`event_is_notice`、`omit_completed_agent_deltas`;
|
||||
这些函数只依赖 Lime 已有的 `ThreadEventSnapshot`、`ThreadBufferedEvent` 与 v2 typed
|
||||
`ServerNotification`/`ServerRequest`。
|
||||
- Codex 的 `omit_resolved_misalignment_errors` 暂不迁移:Lime v2 当前 `CodexErrorInfo` 没有
|
||||
`MisalignmentPolicyViolation`,也没有对应的 review/continuation owner。不得为了补齐函数名而
|
||||
伪造协议枚举、状态机或 no-op 过滤;该能力记录为 `defer`,直到 App Server current owner
|
||||
提供真实 wire 合同。
|
||||
- `thread_events.rs` 在 `drain_snapshot` 中先清理已完成 Agent message 的 streaming delta,并在
|
||||
有最新 turn identity 时移除已被后续 turn 解决的 misalignment error;interactive request
|
||||
是否仍可 replay 继续由 `PendingInteractiveReplayState` 唯一负责。
|
||||
- 本轮不机械复制 Codex 的 `thread_routing`、`thread_session_state`、`thread_title` 或
|
||||
`transcript_export`:Lime 当前没有对应的 Codex rollout DB、HistoryCell、私有 provider/auth
|
||||
或独立 routing owner。它们分别归并现有 Thread event/session/projection owner,或标记
|
||||
`defer`,不得创建平行状态模型。
|
||||
|
||||
分类:`current = app/replay_filter.rs + thread_events.rs snapshot replay`;
|
||||
`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = rollout/history DB 导出 owner、
|
||||
旧 lime-cli、terminal-ui、第二套 runtime`;`deferred = Codex 私有 misalignment error 过滤、
|
||||
thread title generation、shared daemon 与 Cloud production transport`。
|
||||
|
||||
退出条件:replay filter 定向单测、`cargo test -p tui`、TUI Clippy、TUI structure/inventory、
|
||||
`npm run test:contracts`、`npm run governance:legacy-report` 与真实 `npm run smoke:tui-gate-b`
|
||||
全部通过;未迁移的 Codex 文件在 inventory 中保持明确分类。
|
||||
|
||||
## 2026-09-07 Codex Tui external-program lifecycle alignment
|
||||
|
||||
- 按 Codex `tui/src/tui.rs` 复制同名 `set_modes`、`restore_keep_raw`、
|
||||
`flush_terminal_input_buffer` 与 `Tui::with_restored` 生命周期。恢复入口依次暂停
|
||||
`EventBroker`、退出 active alternate screen、向外部编辑器交还终端、重建 TUI modes、清理编辑器
|
||||
留下的 typeahead、重新进入 alternate screen、恢复事件并调度 frame。
|
||||
- 删除 runtime 的 50 次 `TuiEventStream` EOF 重建和分离的 `suspend/resume` 调用;终端关闭仍按
|
||||
Codex 语义由输入流 `None` 直接结束,编辑器恢复只有 `with_restored` 一个 owner。
|
||||
- 将 Lime-only `TerminalGuard`/`TuiTerminal` 改为 Codex 同名 `Tui`/`Terminal`,并更新结构测试,
|
||||
明确禁止旧类型名回流。Unix 使用 `tcflush(TCIFLUSH)`;Windows 使用
|
||||
`FlushConsoleInputBuffer`,不引入平台假实现。
|
||||
- 真实 `complete` 场景验证 editor fd 0/1/2 继承 PTY、编辑后的 prompt 进入 canonical turn 并完成;
|
||||
完整 `npm run smoke:tui-gate-b` 七场景通过,保持
|
||||
`queue-edit=ok agents-overview=ok terminal=restored`。
|
||||
- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(390/390)、
|
||||
`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、
|
||||
`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(12/12)和真实七场景
|
||||
TUI Gate B 通过;当前机器仅安装 `aarch64-apple-darwin` target,Windows 实现未在本机编译运行。
|
||||
|
||||
分类:`current = tui::{Tui,Terminal,set_modes,restore_keep_raw,flush_terminal_input_buffer} +
|
||||
Tui::with_restored + EventBroker + FrameRequester`;`compat/deprecated = none`;
|
||||
`dead/deleted/forbidden-to-restore = TerminalGuard、TuiTerminal、runtime bounded EOF restart、旧
|
||||
lime-cli、terminal-ui、第二套 runtime`;`deferred = Windows CI 运行证据、Codex 私有产品面与生产
|
||||
Cloud transport`。
|
||||
|
||||
## 2026-09-07 Codex crossterm fork recovery verification
|
||||
|
||||
- 按 `/Users/coso/Documents/dev/rust/codex/codex-rs/Cargo.toml` 增加 workspace 级
|
||||
`[patch.crates-io]`,固定 `crossterm` 到 `openai-oss-forks/crossterm`
|
||||
revision `45fecb9508105988f42fe6ff0441783ed3717f92`,并同步 `lime-rs/Cargo.lock`。
|
||||
该 fork 的 terminal readiness、外部消费输入缓冲和不完整 escape 隔离修复直接覆盖
|
||||
TUI `Tui::with_restored` 后首个 Enter 偶发丢失的根因;没有在 Lime 增加第二套输入恢复逻辑。
|
||||
- `cargo test -p tui` 390/390、`cargo clippy -p tui --no-deps -- -D warnings`、
|
||||
`cargo check --locked -p tui` 全部通过。
|
||||
- 真实 `LIME_TUI_GATE_B_SCENARIOS=complete npm run smoke:tui-gate-b` 连续 5 次通过;每次均
|
||||
记录 external editor 修改后的 prompt、canonical `turn.started,message.delta,item.started,
|
||||
item.completed,turn.completed` 和 `terminal=restored`。完整七场景
|
||||
`npm run smoke:tui-gate-b` 同样通过,`queue-edit=ok`、`agents-overview=ok`。
|
||||
|
||||
分类保持:`current = crossterm` Codex 固定 fork + `tui::{Tui,Terminal,EventBroker,TuiEventStream}`;
|
||||
`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 crossterm registry 输入路径、
|
||||
TerminalGuard、TuiTerminal、runtime bounded EOF restart、旧 lime-cli、terminal-ui、第二套 runtime`;
|
||||
`deferred = Windows CI 运行证据、Codex 私有产品面与生产 Cloud transport`。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,7 +139,7 @@ User coding intent
|
||||
| sequence gate | 已扩展到 patch / command / test / action lifecycle,并能把 `action.resolved` 从历史 `action.required` 回填到对应 tool;终态 turn 后继续追加 execution stream 会 fail closed。 | 后续只随新增 lifecycle fact 扩展,不再为旧 thread item 兼容新增旁路。 |
|
||||
| projection | `CodingWorkbenchView` selector 已合并 RuntimeEvent 与 `thread_read.commands/tests/pending_requests/changeSummary/model_routing`,输出 active command/test/action、变更摘要、diagnostics 与 routing facts;session overview、output、log、action submit 和 diagnostics 均从同一 projection 派生。 | 继续守住旧 thread item 不回流;新增 UI fact 先进入 RuntimeEvent / read model / projection,再进入组件。 |
|
||||
| Workspace UI | Coding mode 继续作为 current Workbench surface;变更、输出、日志、审批、诊断、失败继续、provider 设置深链、执行策略恢复和 session overview 均已消费 `CodingWorkbenchView` 或 current reliability/routing evidence;`smoke:code-artifact-workbench-electron-fixture -- --scenario gui-coding-input` 已证明 GUI 输入到 changes / outputs / logs / recovery metadata 的 current 闭环。 | 新增可见状态继续保持 projection-only;新增 coding fact 必须先进入 RuntimeEvent / read model / projection。 |
|
||||
| ExecutionBackend | P2 已接入 RuntimeBackend tool event mirror、真实 `patch-apply` workdir apply service、command/test/action facts、output refs、checkpoint refs、artifact/evidence refs、policy/sandbox/action metadata、tool confirmation resume、默认 policy rule catalog、组织 / 用户 / runtime / request policy layer、network rules 与 macOS/Linux sandbox runner。Windows `windowsSandbox/readiness`、setup mode、elevated sandbox-account、unelevated current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 和 warning lifecycle 已接入同一 current owner;Windows runner schema v3 `7/7` 与复验 run 是平台 evidence。P2-B first-core-slice 已新增 `agent_tools::execution::process` owner,具备 process snapshot、stdout/stderr delta、有界 retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已新增 `executionProcess/start`、`executionProcess/writeStdin`、`executionProcess/interrupt`、`executionProcess/terminate`、`executionProcess/status`、`executionProcess/drainOutput` JSON-RPC 控制面,client / schema / processor 契约已接通。P2-B second slice 已让 no-sandbox shell path 使用 live process 输出 `tool.output.delta`,并在启动前复用 Lime `ToolExecutionDecision` 与 Agent `ToolRegistry::check_tool_permissions`;live process 启动成功后会立即发 metadata-only delta,把 `processId / running / live_process` hydrate 到 `thread_read.commands`,终态前也会发 metadata-only delta,让 Workbench/read model 不必只等 `ToolEnd.metadata` 才知道进程结束;`executionProcess/start` 也已收敛为受控入口:只接受 shell canonical tool,必须携带 `workingDirectory`,先过 Lime decision 与 Agent safety / permission preflight,workspace sandbox backend required / enforced 时 fail-closed,不再用 `cwd` 覆盖实际工作目录。需要 workspace sandbox backend 的命令仍留在 Agent sandbox executor,不走裸进程。`agent_tools::tool_orchestrator` 已新增 `LiveExecutionProcessRegistry` seam,App Server `ExecutionProcessServer` 已实现该 registry,可承接 agent live process control handle、bounded output replay 与 final snapshot 到现有 `executionProcess/*` owner;主对话 shell 终态已把 embedded / sandbox / live process metadata 投影为 `processId / executionProcessStatus / executionSurface / outputBytes / outputOmittedBytes / outputTruncated / stdoutBytes / stderrBytes`,并 hydrate 到 `thread_read.commands` 与 `CodingWorkbenchView.commands`。RuntimeBackend 主对话 Bash / PowerShell foreground no-sandbox 路径已通过 Agent native tool hook 复用同一个 App Server `ExecutionProcessServer`;Workbench 输出面板已通过 `src/lib/api/executionProcess.ts` 复用 App Server current client,live process 可触发 interrupt / terminate / status / drain 控制;RuntimeBackend lifecycle 已把 `executionProcessControlStatus=registered` 与 `stdinWritable` 透传到 `thread_read.commands` / `CodingWorkbenchView.commands`,控制操作成功后会刷新 current session read model;Workbench 已在 `stdinWritable=true` 时显示 stdin 输入,提交后复用 `executionProcess/writeStdin` current API 并刷新 current session read model;组件只消费 injected controls,不直接散落 JSON-RPC 调用。RuntimeBackend 已新增 `RuntimeBackendProcessControlCapabilities` host 合同,默认 `none` 不声明共享 registry,显式 factory 入口才把 shared `ExecutionProcessServer` 的 status / drain / interrupt / terminate / stdin 能力透传给 host;当前仓库没有生产 `RuntimeBackendHost` 实现,只有 feature-gated 测试 host。 | 外部 Agent host 消费该合同只在真实 host adapter 出现时继续;当前下一刀回到可验证的 P2-B 默认 command/test sandbox-aware process owner,或先在 `tool-runtime` 实现 P2-C Windows restricted-token runner。不得在 adapter 内伪造本地 registry,也不得绕过 policy / sandbox。 |
|
||||
| ExecutionBackend | P2 已把 canonical command/test 收敛到 unified exec + `RuntimeToolExecutionAttempt` + App Server `ExecutionProcessServer`,共享 policy/sandbox、process snapshot、stdin/control、terminal lifecycle 与 Thread/Turn/Item projection。P2-A 的 `BoundedProcessOutput` 已覆盖本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 和远端 Environment:默认保留 1 MiB head/latest tail、精确记录 omitted bytes、插入 omission marker;64-slot broadcast、UTF-8 scalar framing、unified exec bounded observation 与逐进程 replay 均由 current owner 承接。Windows runner schema v3 `7/7` 是历史平台 evidence,当前 SHA 的 Windows 大输出仍待真机复验。 | P2-A/P2-B current owner 已闭环;下一刀直接进入 Pier/DeepSWE verifier 与正式 distribution evidence。外部 Agent host 只在真实 adapter 出现时消费既有 capability,不得伪造 registry 或绕过 policy/sandbox。 |
|
||||
| 多模型 slot | P5 已进入 App Server current routing facts:`base/coding/review/fast/local` slot 解析、Provider Store readiness、Model Registry metadata、`thread_read.model_routing`、Reliability routing card 与五语言诊断文案已接通;未配置 provider 会结构化 `needs_setup / blocked`,不降级 mock。 | 具体 provider/key 编辑深链和独立 `model_slot.resolved` 事件仅在 evidence join 需要时继续补强。 |
|
||||
| P8 守卫 | 骨架阶段已完成防回流收口:旧 thread item adapter 已从 Workbench current surface 删除;`code_orchestrated / auto -> react` 归一收进 TS / Rust 单一 compat helper;legacy `agent_runtime_*` 只允许 retired guard / contract negative test / test-only fixture / 明确 compat 文档;`mockPriorityCommands` 为空,生产 `safeInvoke` 不回退 mock。 | 后续只随新增 coding fact / command / GUI 主路径补守卫,不再把 guard/test-only 字符串当主线 blocker。 |
|
||||
|
||||
@@ -153,59 +153,52 @@ User coding intent
|
||||
| 整体目标完成度 | P0-P8 全量 coding profile 的完成比例,不因文档完成而大幅提高。 |
|
||||
| 可交付状态 | 是否达到 Lime GUI 产品可用闭环:真实 current 主链 + GUI smoke + evidence。 |
|
||||
|
||||
当前整体目标完成度按工程闭环估算约 `99.1%`。Windows sandbox setup/readiness、双 mode restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 与 warning lifecycle 已有 current owner 和 Windows schema v3 `7/7` evidence;剩余主要缺口是默认 command/test sandbox-aware process runner 完整替代 batch outcome bridge、Pier/DeepSWE verifier artifacts 与覆盖当前 SHA 的正式分发证据。P2-A 当前只证明 `tool-runtime::execution_process` 的 live process retained output 有界,通用 sandbox output buffer 仍需在各 backend 执行边界补齐;P2-B 已新增 execution process owner、本地 process runner、App Server current control API、受控 `executionProcess/start`、no-sandbox shell live process bridge、终态 lifecycle delta、live process registry seam 与 RuntimeBackend 共享 `ExecutionProcessServer` hook,让主对话 Bash/PowerShell foreground no-sandbox 执行和 Workbench `executionProcess/*` 控制面指向同一个 process owner;Workbench 输出面板已能对 live process 调用 current `executionProcess/*` 控制面,状态 / 输出控制成功后会刷新 current read model,并能从 `CodingWorkbenchView.commands` 恢复 `executionProcessControlStatus` 与 `stdinWritable`;stdin 输入入口已复用 `executionProcess/writeStdin` current API 并在写入成功后刷新 read model;agent batch/live shell lifecycle metadata 已补齐启动、输出与终态的 `stdinWritable / stdin_writable`,注册到 shared registry 后启动与终态 delta 也会携带 `executionProcessControlStatus`;RuntimeBackend host 合同已能显式声明共享 process control capability。不能宣称全量完成的主因仍是两条:默认 command/test sandbox-aware process runner/control owner 尚未完全替代 batch outcome bridge,以及 Pier/DeepSWE verifier artifacts 与覆盖当前 SHA 的正式分发证据尚未完成。外部 Agent host 当前没有生产 adapter,不再作为可立即执行的 blocker。
|
||||
当前整体目标完成度按工程闭环估算约 `99.4%`。P2-A/P2-B/P2-C 已完成 current owner 收敛:canonical command/test 共享 sandbox-aware process/control owner;所有本地 sandbox/process backend 与远端 Environment 共享 1 MiB head/tail、精确 omission、bounded broadcast/replay、UTF-8 framing 和有界 observation;Windows sandbox setup/readiness、双 mode restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 与 warning lifecycle 也已有 current owner。剩余主要缺口是 Pier/DeepSWE verifier artifacts、覆盖当前 SHA 的正式 distribution evidence,以及 Windows 当前 SHA 的 restricted-token 大输出真机复验。外部 Agent host 当前没有生产 adapter,不再作为可立即执行的 blocker。
|
||||
|
||||
## 下一刀
|
||||
|
||||
下一刀继续沿主链补强可执行能力,而不是重复 P1-P7 baseline:
|
||||
下一刀进入正式 verifier/distribution evidence,不再重复 P1-P8 current owner 实现:
|
||||
|
||||
### 下次从这里开始
|
||||
|
||||
主任务:`P2-B live process lifecycle fifth slice`。RuntimeBackend 主对话 Bash/PowerShell foreground no-sandbox 路径已通过 Agent native tool hook 复用 App Server `ExecutionProcessServer`,让 Workbench 已接入的 interrupt / terminate / status / drain / stdin 写入控制面可指向同一个 `executionProcess/*` current owner;status / drain / control / stdin 操作成功后已触发 current read model refresh,`executionProcessControlStatus` 与 `stdinWritable` 已进入 RuntimeCore lifecycle hydrate;agent batch/live shell lifecycle delta 也已在启动、输出和终态同步携带 stdin/control capability。RuntimeBackend host 合同已定义默认 none 与显式 shared control capability;仓库当前没有生产 `RuntimeBackendHost` adapter 可继续接入。不新增旧命令、不绕过 `ToolExecutionDecision`。剩余是默认 command/test sandbox-aware process runner/control owner 和 Windows restricted token 完整化。
|
||||
主任务:为当前工作树生成 Pier/DeepSWE verifier artifacts 与正式 distribution evidence。P2-A/P2-B 已闭环;不得再以 output buffer、live process adapter、旧 UI 或文档 inventory 替代 verifier 工作。Windows 当前 SHA 的 restricted-token 大输出必须由 Windows 真机 runner 补证,非 Windows 结果不能提升为平台通过。
|
||||
|
||||
入口文件:
|
||||
|
||||
- `lime-rs/crates/app-server/src/runtime_factory.rs`、`lime-rs/crates/app-server/src/runtime_backend.rs`、`lime-rs/crates/app-server/src/runtime_backend/live_execution_process.rs`:RuntimeBackend 共享 registry 注入点。
|
||||
- `lime-rs/crates/app-server/src/runtime_backend.rs`:外部 host adapter;host 合同已携带 `process_control` capability,后续只能让外部 host 显式消费该合同并接入真实 owner,不能在 adapter 内伪造 owner。
|
||||
- `lime-rs/crates/agent/src/agent_tools/tool_orchestrator.rs`:当前 shell live process bridge 与 batch outcome 接线。
|
||||
- `lime-rs/crates/agent/src/agent_tools/execution/process.rs`:process snapshot、stdout/stderr delta、stdin、interrupt、terminate、status owner。
|
||||
- `lime-rs/crates/app-server/src/execution_process.rs`:App Server current process control owner。
|
||||
- `lime-rs/crates/app-server/src/runtime_backend/coding_events.rs` 与 `lime-rs/crates/app-server/src/runtime/coding_activity_projection.rs`:process facts 到 read model hydrate。
|
||||
- `src/lib/api/executionProcess.ts` 与 `src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx`:前端 current 控制面。
|
||||
- `scripts/harness/`:DeepSWE adapter、preflight、Desktop controlled/live verifier 与聚合入口。
|
||||
- `internal/exec-plans/evidence/` 与 `.lime/benchmark/v2/`:当前 SHA 的持久 evidence、运行期机器可读结果与 claim boundary。
|
||||
- `packages/cli/`、`forge.config.mjs` 与 release resource verifier:正式 CLI/Desktop distribution 产物边界。
|
||||
- `.github/workflows/`:Windows restricted-token 大输出与 packaged matrix 的平台执行入口。
|
||||
|
||||
退出条件:
|
||||
|
||||
- command / test 默认执行不再只依赖 terminal `ToolEnd` + batch metadata,启动后能稳定产生 process lifecycle facts。
|
||||
- RuntimeBackend 主对话 live process 注册到 App Server `ExecutionProcessServer` 后,`executionProcess/status|drainOutput|interrupt|terminate` 能控制同一进程。
|
||||
- stdin capability / status hydrate 进入 RuntimeCore lifecycle,Workbench 控制状态可从 `CodingWorkbenchView.commands/tests` 恢复;`stdinWritable=true` 时 Workbench 通过 `executionProcess/writeStdin` 写入 stdin 并刷新 read model;RuntimeBackend host 只能在看到 shared process control capability 后消费共享 owner。
|
||||
- 需要 workspace sandbox backend 的命令仍走 Agent sandbox executor;no-sandbox live process 与 App Server `executionProcess/start` 都必须继续消费 `ToolExecutionDecision.requires_sandboxed_execution()`。
|
||||
- verifier 输入绑定当前 commit/tree identity,产物可机械复验,不能引用已删除 source 或历史 SHA 冒充当前结果。
|
||||
- DeepSWE/Pier 的 controlled、live、score 与 platform claim 分层记录;无 live provider 或容器环境时保持明确 blocked/incomplete。
|
||||
- CLI/Desktop distribution 从真实打包产物启动,证明 sibling sidecar、App Server JSON-RPC、RuntimeCore 与 canonical projection,不以源码运行替代。
|
||||
- Windows restricted-token 大输出复验覆盖 1 MiB head/tail、omitted bytes、UTF-8 delta 和 terminal completion;失败时 readiness/distribution claim fail closed。
|
||||
|
||||
最小验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --manifest-path "lime-rs/Cargo.toml" -p lime-agent -p app-server --check
|
||||
cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::execution -- --nocapture
|
||||
cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::tool_orchestrator -- --nocapture
|
||||
cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server execution_process -- --nocapture
|
||||
cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend::coding_events -- --nocapture
|
||||
npx vitest run "src/lib/api/executionProcess.test.ts" "src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.test.tsx" --silent=passed-only --disableConsoleIntercept
|
||||
npm run harness:deepswe:preflight
|
||||
npm run smoke:cli-npm-gate-b
|
||||
npm run test:contracts
|
||||
npm run smoke:agent-runtime-current-fixture
|
||||
npm run governance:legacy-report
|
||||
```
|
||||
|
||||
```text
|
||||
P2-B live process lifecycle fifth slice
|
||||
-> RuntimeBackend shared ExecutionProcessServer hook landed for foreground no-sandbox Bash/PowerShell
|
||||
-> stdin input UX wired to executionProcess/writeStdin after status hydrate
|
||||
-> RuntimeBackend host contract now exposes shared process control capability
|
||||
-> agent batch/live shell lifecycle deltas carry stdin/control capability from process start to terminal state
|
||||
-> external Agent host consumption waits for a real production host adapter
|
||||
-> next: default command/test sandbox-aware process owner or P2-C Windows sandbox completeness
|
||||
P2-A/P2-B current owner closed
|
||||
-> next: current-tree Pier/DeepSWE verifier artifacts
|
||||
-> next: CLI/Desktop distribution evidence
|
||||
-> next: Windows current-SHA restricted-token large-output evidence
|
||||
```
|
||||
|
||||
P8 骨架阶段已经收口,不再重复做旧字符串 inventory:真实 turn 已能从 Agent tool stream 派生 `file.read`、`file.changed`、`patch.*`、`command.*`、`test.*`、`permission.denied`、`sandbox.blocked` 和 `action.*` 基础 facts,并通过 `CodingWorkbenchView`、GUI coding input smoke、Evidence Pack、`legacySurfaceCatalog`、`test:contracts` 与 `governance:legacy-report` 证明 current 主链和防回流守卫成立。
|
||||
|
||||
后续高价值顺序:
|
||||
|
||||
1. 继续 P2-B:Workbench live process interrupt / terminate / status / drain / stdin 写入已接入 current API,agent live process registry seam、App Server owner、RuntimeBackend 共享 hook、status/control/stdin capability hydrate 与 RuntimeBackend host control capability 合同已落地;仓库当前没有生产外部 Agent host adapter,下一刀回到默认 command/test sandbox-aware process runner/control owner;no-sandbox shell live process 已落地,不再重复做裸进程接管。
|
||||
1. P2-B 已收口:Workbench live process interrupt / terminate / status / drain / stdin 写入、agent live process registry seam、App Server owner、RuntimeBackend 共享 hook、status/control/stdin capability hydrate、unified exec canonical lifecycle 与 sandbox `RuntimeToolExecutionAttempt` shared owner 回归均已落地;仓库当前没有生产外部 Agent host adapter,不再重复做裸进程接管。
|
||||
2. 再补 P2-D:为 shell command 建 session-scope approval key,减少重复审批,但只通过 settings 草案进入持久规则。
|
||||
3. 继续把默认 command/test 执行切到统一 process/control owner,并补当前工作树的 Pier/DeepSWE verifier 与正式分发证据。Windows restricted-token runner、setup/readiness 和平台 schema v3 `7/7` 已完成 current owner 收敛;任何后续 token/ACL/network 改动都必须重新跑 Windows matrix,失败时 readiness 回退 `updateRequired`。
|
||||
3. 为当前工作树补 Pier/DeepSWE verifier 与正式分发证据;Windows restricted-token runner、setup/readiness 和平台 schema v3 `7/7` 已完成 owner 收敛,但当前 SHA 仍需重跑大输出 matrix,失败时 readiness 回退 `updateRequired`。
|
||||
4. 若需要外部 CLI / harness 接入,按 P6 只输出 RuntimeEvent / ReadModel adapter,不允许成为生产必需主链。
|
||||
|
||||
@@ -167,7 +167,7 @@ turn start
|
||||
- 命令、测试和 patch 的失败分类可被 projection 直接消费。
|
||||
- Windows / macOS / Linux 平台差异进入 policy/sandbox diagnostics,不隐藏为普通失败。
|
||||
|
||||
当前状态:`in_progress / current coding skeleton usable / P2-A live-process retained output bounded / P2-B no-sandbox live process landed / P2-C Windows runner enforced with platform evidence`。P2 主干已接入 runtime tool event mirror、真实 `patch-apply` apply service、per-file checkpoint/content/diff refs、policy/sandbox/action facts、output snapshot、file checkpoint snapshot、history hydrate 与 active command/test/action read model;artifact/checkpoint refs 继续由 RuntimeCore read-model 边界承接。`tool-runtime::sandbox` 已承接 sandbox label、命令文本抽取、只读 shell 分类和 workspace sandbox backend plan;Agent 前台 `Bash/PowerShell` 已能消费 workspace sandbox context,macOS seatbelt 与 Linux bubblewrap 在可用时进入 `ready/enforced=true` current runner。Windows 已接入 `elevated/unelevated` setup mode、sandbox-account/current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、readiness read-back 和 warning lifecycle;Windows runner schema v3 `7/7` 与复验 run 是平台 evidence,后续改动必须保持 fail-closed。P2-A 当前只证明 `tool-runtime::execution_process` 的 live process retained output 有界;通用 sandbox output buffer 仍需在各 backend 执行边界补齐,RuntimeCore 继续作为 output snapshot owner。P2-B 已新增 `tool-runtime::execution_process` owner、App Server `executionProcess/*` 控制面与 no-sandbox live process bridge;剩余高价值骨干是把 command/test 默认执行从 batch outcome bridge 切到 sandbox-aware process runner/control owner,并复用 `executionProcess/*` current API。
|
||||
当前状态:`completed for P2 current owner / P2-A bounded output boundary / P2-B sandbox-aware canonical command-test owner / P2-C Windows runner owner`。P2 主干已接入 runtime tool event mirror、真实 `patch-apply` apply service、per-file checkpoint/content/diff refs、policy/sandbox/action facts、output snapshot、history hydrate 与 active command/test/action read model;artifact/checkpoint refs 继续由 RuntimeCore read-model 边界承接。Agent 前台 `exec_command` 已通过 `RuntimeToolExecutionAttempt` 进入 unified exec 与 App Server shared process owner,macOS seatbelt 与 Linux bubblewrap 在可用时进入 current runner;Windows 已接入双 setup mode、restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、readiness 和 warning lifecycle。P2-A 已让 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 共享 1 MiB head/tail、精确 omission、64-slot broadcast、UTF-8 framing、unified exec bounded observation 与逐进程 replay。下一步直接进入当前工作树的 verifier/distribution evidence;Windows schema v3 `7/7` 只作历史平台基线,当前 SHA 的大输出仍需 Windows 真机复验。
|
||||
|
||||
2026-06-14 增量:
|
||||
|
||||
@@ -227,7 +227,19 @@ turn start
|
||||
- 2026-06-26 P2-B RuntimeBackend host contract 验证证据:`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend runtime_backend -- --nocapture`、`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend agent_factory_can_share_execution_process_owner_with_runtime_core -- --nocapture`、`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend runtime_backend_json_rpc_agent_flow_smoke_covers_artifact_read_and_action_response -- --nocapture` 均通过。`rustfmt --edition 2021 --config skip_children=true --check "lime-rs/crates/app-server/src/runtime_backend.rs" "lime-rs/crates/app-server/src/runtime_factory.rs" "lime-rs/crates/app-server/src/lib.rs"`、`npx prettier --check "internal/roadmap/coding/README.md" "internal/roadmap/coding/implementation-plan.md"`、`git diff --check -- ...` 已通过;全量 `rustfmt --edition 2021 --check ...` 曾因无关脏文件 `lime-rs/crates/app-server/src/runtime/plugin_worker_turn.rs` 需要格式化而失败,本轮未改该文件。
|
||||
- 2026-06-26 P2-B live process lifecycle metadata consistency 增量:`agent_tools::execution::process` 现在在 output delta、start snapshot 与 terminal snapshot metadata 中统一输出 `stdinWritable / stdin_writable`;`agent_tools::tool_orchestrator` 在 no-sandbox live process 注册到 `LiveExecutionProcessRegistry` 后,会把 `executionProcessControlStatus / execution_process_control_status` 同步写入 start lifecycle delta 与 terminal lifecycle delta。这样默认 batch/live shell 路径不只在最终 `ToolEnd.metadata` 才暴露 stdin/control capability,`command.output -> thread_read.commands -> CodingWorkbenchView.commands` 可以从启动阶段恢复同一 process owner 的控制状态。
|
||||
- 2026-06-26 P2-B live process lifecycle metadata consistency 验证证据:`CARGO_TARGET_DIR="/tmp/lime-p2b-process-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::execution::process -- --nocapture` 通过 6 个 process owner 测试;`CARGO_TARGET_DIR="/tmp/lime-p2b-process-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::tool_orchestrator -- --nocapture` 通过 14 个 orchestrator 测试,覆盖启动 lifecycle delta 的 `stdinWritable=true`、终态 lifecycle delta 的 `stdinWritable=false`、注册后 start delta 带 `executionProcessControlStatus=registered`,以及 registry output / final snapshot 记录。
|
||||
- 未完成:P2-B 下一刀回到默认 command/test sandbox-aware process runner/control owner;外部 Agent host 消费 `process_control` 合同只在真实生产 host adapter 出现时继续;Windows runner、setup/readiness、平台 evidence 已完成,network/policy 单条规则深链属于 P7/P8 增强。P3/P4 UI evidence、Provider slot diagnostics 与 GUI coding input baseline 已进入 current 骨架闭环,不再作为主线 blocker。
|
||||
- 2026-09-06 P2-B command/exec owner 收口增量:App Server `command/exec` 现在启动后立即注册到共享 `ExecutionProcessServer`,stdout/stderr delta、终态 snapshot、超时终止、stdin 写入和 connection close 都复用同一 process owner;公开 `processId` 仍按 connection scope 复用,内部 owner ID 以 `command-exec-{connectionId}-{processId}` 为 base,终态 owner 已保留时追加 instance UUID,避免跨连接冲突和终态复用冲突。重复 session 在启动前拒绝,resize 或 owner 注册失败会终止已启动进程;新增测试验证 shared status/output replay、跨连接同名 process ID 和终态后复用公开 process ID。
|
||||
- 2026-09-06 P2-B command/exec owner 验证证据:`rustfmt --edition 2021 "lime-rs/crates/app-server/src/command_exec.rs" "lime-rs/crates/app-server/src/command_exec/tests.rs"` 与 `git diff --check -- "lime-rs/crates/app-server/src/command_exec.rs" "lime-rs/crates/app-server/src/command_exec/tests.rs"` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server command_exec --lib -- --nocapture` 与 `cargo test --manifest-path "lime-rs/Cargo.toml" -p tool-runtime execution_process -- --nocapture` 均被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。
|
||||
- 2026-09-06 P2-B unified exec command/test lifecycle 增量:`tool-runtime::unified_exec` 现在把同一 `ExecutionProcessSnapshot` 的 `processId / executionProcessStatus / stdinWritable / outputBytes / outputOmittedBytes / outputTruncated / exit_code` 与 `executionSurface=unified_exec` 写入每次 canonical result;短命令、yield 后 running 命令和 `write_stdin` 续接保持同一 process identity。App Server `runtime_backend::coding_events::CodingEventMirror` 已消费 `Command` Item 的 `ItemStarted -> ItemUpdated -> ItemCompleted`,将统一 exec 产出为 `command.started/output/exited` 与 `test.started/completed`,并保留 bounded output/process metadata;不新增 JSON-RPC method,不绕过 sandbox executor。
|
||||
- 2026-09-06 P2-B unified exec command/test lifecycle 验证证据:`rustfmt --edition 2021 --check "lime-rs/crates/tool-runtime/src/unified_exec.rs" "lime-rs/crates/tool-runtime/src/unified_exec/tests.rs" "lime-rs/crates/app-server/src/runtime_backend/coding_events.rs" "lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs"` 与对应 `git diff --check` 通过;新增 unified exec snapshot metadata 与 `Command` Item 生命周期回归。`cargo test --manifest-path "lime-rs/Cargo.toml" -p tool-runtime unified_exec --lib -- --nocapture`、`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend::coding_events --lib -- --nocapture` 均被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。
|
||||
- 2026-09-06 P2-B command lifecycle projection correction:`CodingEventMirror` 统一 `Command` 的 `commandId`(包括 terminal interaction),对无 `ItemStarted` 的冷恢复终态补发 started/output/exited,并将 `Cancelled`/`Interrupted`/aborted/terminated 映射为 `canceled`(含 `test.completed`)。新增 call-id、一致性、冷恢复和取消回归;未新增协议或 owner。
|
||||
- 2026-09-06 P2-B thread shell live output 收口:`thread/shellCommand` 继续复用共享 `ExecutionProcessServer`,轮询改为按 `processId + afterSequence` drain bounded output,并在同一 turn 内即时落 `command.output`;终态仍由同一 snapshot 生成 `command.exited`,启动/输出/终态统一携带 `executionProcessStatus / executionProcessControlStatus / executionSurface / stdinWritable / outputBytes` 等 owner facts。显式 `status=canceled` 优先于 exit code,避免 terminated shell 被 Thread Item 投影为 failed/completed。新增 session shell lifecycle、delta、process-control metadata 回归;未新增 JSON-RPC method、legacy owner 或裸进程路径。
|
||||
- 2026-09-06 P2-B thread shell live output 验证证据:`rustfmt --edition 2021 --check "lime-rs/crates/app-server/src/runtime/session_shell.rs" "lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs" "lime-rs/crates/app-server/src/runtime/tests/session_shell.rs"`、对应 `git diff --check`、`npm run test:contracts` 与 `npm run governance:legacy-report` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime::tests::session_shell --lib -- --nocapture` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。
|
||||
- 2026-09-06 P2-B unified exec live output bridge 增量:`RuntimeToolExecutorHandle::execute_call` 将 canonical `ToolCall` 的生命周期 emitter 注入 `RuntimeToolExecutionContext`,`tool-runtime::unified_exec` 在每次 `processId + afterSequence` drain 后按原序发出 `ToolOutputDeltaEvent`,并把 `outputSequence / outputKind / outputBytes / outputOmittedBytes / outputTruncated / executionProcessStatus / stdinWritable / executionSurface` 与 terminal snapshot 对齐;`write_stdin` 续接继续使用原始 exec call/turn identity,`CodingEventMirror` 对已流式输出的终态抑制聚合 output 重复,不新增 Agent 依赖、JSON-RPC method 或第二套 output owner。
|
||||
- 2026-09-06 P2-B unified exec live output bridge 验证证据:`rustfmt --edition 2021 --check` 覆盖 `tool_call.rs`、`tool_executor.rs`、`unified_exec.rs`、`unified_exec/tests.rs`、current orchestration 与 App Server unified exec fixtures,`git diff --check` 通过;unified exec fixture 新增短命令 `started -> output -> completed` metadata/call-id/sequence 回归。`cargo check/test` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为 Rust 通过。
|
||||
- 2026-09-06 P2-B sandbox attempt control-owner 回归:`app-server/src/execution_process/tests.rs` 新增带 `RuntimeToolExecutionAttempt` 的 `workspace-write` 交互命令 fixture,证明 sandbox attempt 通过 `ExecutionProcessServer` 同一 current owner 完成启动 snapshot、按 process identity drain output、stdin 写入、终态 status,并核对 `tool_id` 与 attempt call identity;结合 unified exec/current orchestration wiring,P2-B canonical command/test owner 迁移证据闭环。`rustfmt --edition 2021 --check` 与 `git diff --check` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server execution_process --lib` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为 Rust 通过。
|
||||
- 2026-09-06 P2-A bounded output boundary 收口:新增 `tool-runtime::execution_process::BoundedProcessOutput` 与 `ProcessOutputFramers` current owner。默认保留 1 MiB head/latest tail、精确统计 omitted bytes 并插入 omission marker;pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 统一从 raw bytes 捕获,UTF-8 scalar 可跨 reader chunk,EOF flush 不完整后缀。reader -> handle 改为 64-slot bounded broadcast;unified exec observation 不再无界增长;App Server replay 下沉到逐进程 queue,远端输出删除手写 tail-only 截断。
|
||||
- 2026-09-06 P2-A 验证证据:`npm run test:rust:unit -- -p tool-runtime execution_process` 28/28,`npm run test:rust:unit -- -p tool-runtime unified_exec` 11/11,`npm run test:rust:unit -- -p app-server execution_process` 20/20;覆盖 1 MiB head/tail、精确 omission marker、未消费 delta 的大输出 terminal completion、远端 snapshot 一致性、逐进程 replay 隔离与跨 chunk UTF-8。Windows 当前 SHA 的 restricted-token 真机大输出尚未复跑,不提升为平台 evidence。
|
||||
- 未完成:当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence,以及 Windows 当前 SHA 的 restricted-token 大输出真机复验。外部 Agent host 消费 `process_control` 合同只在真实生产 adapter 出现时继续;network/policy 单条规则深链属于 P7/P8 增强。P2-A/P2-B/P2-C current owner、P3/P4 UI evidence、Provider slot diagnostics 与 GUI coding input baseline 均不再作为主线 blocker。
|
||||
|
||||
## P3:AgentUI Coding Projection
|
||||
|
||||
@@ -326,7 +338,7 @@ turn start
|
||||
- 2026-06-15 Windows restricted token hardening 增量:restricted token runner 已补 Job Object `KILL_ON_JOB_CLOSE`、timeout 时 `TerminateJobObject` 杀进程树、ACL rollback RAII 恢复 workspace / denied path DACL,以及 stdout/stderr 并发 pipe reader,避免大输出填满 pipe 后父进程仍阻塞在 `WaitForSingleObject`。
|
||||
- 2026-06-15 P2-A 输出流控增量:`sandbox/output_buffer.rs` 已成为 Agent 有界输出捕获 owner;通用 sandbox executor 从 `Command::output()` 改为 spawn + 并发读取 stdout/stderr 到 head/tail buffer,Windows restricted token pipe reader 改为同一 buffer,embedded Bash 前台执行也改为同一有界捕获路径;`BashTool` metadata 继续输出 stdout/stderr 原始字节数、省略字节数、截断标记,以及 `outputBytes / outputOmittedBytes / outputTruncated`,交给 App Server output refs / evidence refs 消费。
|
||||
- 2026-06-15 P2-A 验证证据:`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib sandbox::output_buffer -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib sandbox::executor::tests::unsandboxed -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib tools::bash::tests::test_execute_large_output_reports_bounded_capture_metadata -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib tools::bash::tests::test_executor_result_metadata_preserves_output_truncation_stats -- --nocapture`。上述测试通过时仅有既有 `worktree_tools.rs` unused `SessionManager` warning。
|
||||
- 2026-06-15 P2-B Workbench process control 增量:`src/lib/api/appServer.ts` 已补齐 App Server current `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput` client 方法,`src/lib/api/executionProcess.ts` 作为唯一前端领域网关返回 `response.result`,不走 `safeInvoke` 旧命令或 mock fallback;`CodingWorkbenchOutputPanel` 只对 `processId + running/starting` 的 `CodingWorkbenchView.commands` 渲染 icon controls,并由 `workspaceConversationCodingViews.tsx` 注入 interrupt / terminate / status / drain handlers。该增量只完成 UI 控制面接入,不代表 command/test 默认执行已切到 sandbox-aware process control owner;下一刀仍需迁默认执行 lifecycle、stdin 与 status hydrate。
|
||||
- 2026-06-15 P2-B Workbench process control 增量:`src/lib/api/appServer.ts` 已补齐 App Server current `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput` client 方法,`src/lib/api/executionProcess.ts` 作为唯一前端领域网关返回 `response.result`,不走 `safeInvoke` 旧命令或 mock fallback;`CodingWorkbenchOutputPanel` 只对 `processId + running/starting` 的 `CodingWorkbenchView.commands` 渲染 icon controls,并由 `workspaceConversationCodingViews.tsx` 注入 interrupt / terminate / status / drain handlers。该增量与后续 unified exec/current orchestration 接线共同完成 command/test 默认 lifecycle、stdin 与 status hydrate 的 current owner 收敛。
|
||||
- 2026-06-15 P2-B Workbench process control 验证证据:`npx vitest run "src/lib/api/executionProcess.test.ts" "src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.test.tsx" "src/lib/api/appServer.test.ts" --silent=passed-only --disableConsoleIntercept` 覆盖前端网关不调用 `safeInvoke`、App Server JSON-RPC method 名、live process 控件渲染与 processId 传递。
|
||||
- 2026-06-15 Windows restricted token smoke gate 增量:`restricted_token.rs` 已新增 Windows-only smoke,直接走 `execute_in_sandbox_with_options` current owner,覆盖 workspace write 允许、外部路径写入拒绝、denied path write deny、ACL DACL restore、large output drain、timeout exit code `124` 和 Job Object 杀子进程树。Windows 实机验证入口固定为 `cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib restricted_token -- --nocapture`。
|
||||
|
||||
@@ -520,6 +532,6 @@ P8 骨架阶段剩余口径:旧 thread item 在 Coding Workbench current UI
|
||||
|
||||
## 当前实施入口
|
||||
|
||||
P1/P2/P3/P4/P5/P7/P8 骨架已经进入 App Server / RuntimeCore current crates 与 Workbench current projection:真实 runtime backend tool stream 会派生 `file.read/file.changed/patch/command/test/permission/sandbox/action` 基础 coding events,并经 schema / sequence / tool lifecycle guard 入库;`patch-apply` 已具备真实 workdir apply service,并已接入 agent `apply_patch` current tool 入口;tool output、file checkpoint previous content、历史 current timeline hydrate、active command/test/action read model、artifact refs、policy/sandbox/network 基础 diagnostics、network rule recovery draft、provider/model routing diagnostics、sandbox 基础 owner、process lifecycle owner、App Server `executionProcess/*` 控制 API、no-sandbox shell live process、live process registry seam、RuntimeBackend 共享 `ExecutionProcessServer` hook、Workbench stdin 写入入口、RuntimeBackend host process control 合同、inventory 配置来源测试、GUI coding input smoke、session overview projection-only 守卫、production mock fallback fail-closed 与 legacy command 防回流契约都已具备 current facts。后续实施不要回到纯文档、旧 UI 修补或旧命令 inventory;下一刀回到默认 command/test sandbox-aware process runner/control owner,外部 Agent host 消费合同等真实生产 adapter 出现后再做。
|
||||
P1/P2/P3/P4/P5/P7/P8 骨架已经进入 App Server / RuntimeCore current crates 与 Workbench current projection:真实 runtime backend tool stream 会派生 `file.read/file.changed/patch/command/test/permission/sandbox/action` 基础 coding events,并经 schema / sequence / tool lifecycle guard 入库;`patch-apply`、output/checkpoint refs、历史 hydrate、active read model、policy/sandbox/network diagnostics、provider/model routing、sandbox/process lifecycle owner、App Server `executionProcess/*`、unified exec canonical command/test、Workbench controls、GUI coding input smoke、production mock fallback fail-closed 与 legacy command 防回流契约都已具备 current facts。P2-A 的跨 backend bounded output 也已完成。后续实施不要回到纯文档、旧 UI、旧命令 inventory 或重复 output owner;下一刀直接进入正式 verifier/distribution evidence,外部 Agent host 消费合同等真实 production adapter 出现后再做。
|
||||
|
||||
下次恢复入口固定为 P2-B live process lifecycle fifth slice:先读 `internal/roadmap/coding/README.md` 的“下次从这里开始”,再从 `lime-rs/crates/agent/src/agent_tools/tool_orchestrator.rs`、`lime-rs/crates/agent/src/agent_tools/execution/process.rs`、`lime-rs/crates/app-server/src/runtime_backend/live_execution_process.rs`、`lime-rs/crates/app-server/src/runtime_factory.rs`、`lime-rs/crates/app-server/src/runtime.rs` 和 `lime-rs/crates/app-server/src/execution_process.rs` 开始。第一目标不是新增 UI 或文档,也不是伪造外部 Agent host,而是补默认 command/test sandbox-aware process runner/control owner;`RuntimeBackendSubmitRequest.process_control` 合同已就绪,只有真实生产 host adapter 出现时才继续消费。同时继续通过 `ToolExecutionDecision.requires_sandboxed_execution()` 防止 sandbox required 命令走裸进程。
|
||||
下次恢复入口固定为当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence:先读 `internal/roadmap/coding/README.md` 的“下次从这里开始”,再核对 `scripts/harness/`、`packages/cli/`、Forge resource verifier、`.github/workflows/` 与 `internal/exec-plans/evidence/`。P2-A/P2-B/P2-C current owner 已完成;第一目标不是新增 UI、文档、output owner 或伪造外部 Agent host,而是生成绑定当前 tree identity 的机器可读证据,并在 Windows runner 复验 restricted-token 大输出。
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
| File read/write abstraction | RuntimeBackend mirror + ExecutionBackend file tool + artifact/checkpoint projection owner | copy | 第一刀已从 `Read/Write/Edit` 派生 `file.read/file.changed`;`file.changed` 已进入 RuntimeCore artifact/read-model 聚合与 file checkpoint API read/list/get/diff/restore 基础链路;previous content 已落到文件系统 checkpoint snapshot owner,restore 从 snapshot ref 回读后写回 live 文件;current timeline hydrate 已可从 persisted items 恢复 checkpoint snapshot refs。后续补 GUI evidence。 |
|
||||
| Patch apply engine | `patch-apply` crate + agent `apply_patch` tool + RuntimeBackend mirror + file artifact / checkpoint owner | copy | 已复制 patch grammar、lenient heredoc parser、streaming parser、fuzzy seek sequence 和真实 apply service 到中性 crate;agent `apply_patch` tool 已接到该 current owner,RuntimeBackend mirror 已用 parser 提取 patch paths 和 `invalid_patch` 分类,并从 tool metadata 投影多文件 `file.changed`。per-file `checkpointRef/contentRef/diffRef` 与结构化 diff 预览已进入 RuntimeCore artifact / file checkpoint / read-model 主链;后续补 GUI evidence,失败必须结构化,不只写日志。 |
|
||||
| Diff summary | App Server `thread_read.change_summary` + AgentUI projection `ChangeSummaryView / PatchView / FileChangeView` | rewrite | 已从 `file.changed` 与 `patch.*` 派生 read model 变更摘要,并投影到 Workbench session overview;完整 per-turn diff 仍按后续 evidence / checkpoint 细化。 |
|
||||
| Shell command execution | RuntimeBackend tool mirror + Project Shell / ExecutionBackend command tool | copy | 已完成 Agent `Bash/PowerShell` tool lifecycle 到 `command.*` / `test.*` facts 的第一刀;大输出已可进入文件系统 output snapshot owner,hydrate 可恢复 output snapshot refs;`thread_read.commands/tests/pending_requests` 已进入 `CodingWorkbenchView` selector 与 Workspace adapter;policy/sandbox blocked 事件已有基础 diagnostics,真实工具拒绝 outcome 已携带 `eventClass/failureCategory/reasonCode/reason/command/cwd/platform` 等 metadata;`agent_tools::tool_orchestrator` 已在真实工具执行前接入 `ToolExecutionDecision`,shell 风险在 `on_request / unless_trusted / granular` 下会先输出 `action.required`,`read-only` sandbox 下非只读 shell 命令会先输出 `sandbox.blocked`,`workspaceSandbox.strict=true` 且 backend 未 enforce 时会先输出 strict sandbox blocked,`never` 保持非人工确认路径;Agent 主工具 inspector 已复用该 decision;App Server current 配置中的 `agent.tool_execution` 和非默认 `agent.workspace_sandbox` 已能影响 execution / inventory / decision 的 effective policy;基础 sandbox 判定与 backend plan 已收敛到 `execution/sandbox.rs`,policy resolver 已收敛到 `execution/service.rs`;macOS/Linux 前台 shell 已接进 sandbox runner,Windows setup/readiness、mode persistence、elevated sandbox-account 与 unelevated current-user restricted-token runner 已接入同一 current owner,Windows runner schema v3 `7/7` evidence 已记录。P2-B 第一刀已新增 `agent_tools::execution::process` owner 和本地 process runner,支持 stdout/stderr delta、stdin write、interrupt、terminate、status 与有界 retained output;现有 shell batch bridge 已把 process metadata 透传到 `tool.output.delta` / `command.output` metadata。剩余主缺口是 command/test 默认执行切换与当前 SHA 的正式 verifier/distribution evidence。 |
|
||||
| Shell command execution | RuntimeBackend tool mirror + Project Shell / ExecutionBackend command tool | copy | canonical `exec_command` 已通过 `ToolExecutionDecision` 与 `RuntimeToolExecutionAttempt` 进入 unified exec,并由 App Server `ExecutionProcessServer` 承接 sandbox-aware process/control owner;`command.*` / `test.*` facts、output refs、active read model、approval/sandbox diagnostics、stdin/control 与 terminal lifecycle 均已接通。P2-A 同时让本地 pipe/PTY/Seatbelt/Bubblewrap/Windows restricted runner 与远端 Environment 共享 bounded output 与 UTF-8 framing。P2-A/P2-B current owner 已闭环,剩余主缺口是当前 SHA 的正式 verifier/distribution 与 Windows 平台复验证据。 |
|
||||
| PTY resize/write/terminate | Project Shell APIs | reference | 仅用于用户显式 shell 或工具执行面。 |
|
||||
| Test output parsing | ExecutionBackend test tool | rewrite | 不从自由文本猜测,需要 status/exit/output refs。 |
|
||||
| Large output truncation | output ref / artifact ref + tool-runtime bounded process output | copy | 大输出进 ref,不重复刷 event;App Server runtime 已能把 tool terminal 大输出落到会话文件系统 snapshot,并在 `artifact/read(include_content=true)` 时回读完整内容;历史 hydrate 可从 `detail.outputs` 恢复 snapshot ref。`tool-runtime::execution_process::BoundedProcessOutput` 已对 live process retained output 统计 `outputBytes / outputOmittedBytes / outputTruncated`;通用 sandbox output buffer 仍需在各 backend 执行边界补齐;Windows restricted-token pipe reader 已复用 bounded process output owner。RuntimeCore 继续负责 snapshot / artifact refs,不让 executor 直接写 App Server sidecar。 |
|
||||
| Large output truncation | output ref / artifact ref + tool-runtime bounded process output | copy | `BoundedProcessOutput` 默认保留 1 MiB head/latest tail,精确统计 `outputBytes / outputOmittedBytes / outputTruncated` 并插入 omission marker;所有本地 backend 与远端 Environment 已复用该 owner。reader 使用 64-slot bounded broadcast,unified exec observation 有界,App Server replay 按进程隔离;UTF-8 scalar 跨 chunk 时由 `ProcessOutputFramers` 延迟到完整边界再发 delta。RuntimeCore 继续负责 snapshot/artifact refs,不让 executor 直接写 App Server sidecar。Windows 当前 SHA 的 restricted-token 大输出仍待真机复验。 |
|
||||
|
||||
## 权限与安全
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
|
||||
## 结论
|
||||
|
||||
Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 current 主链、Workbench projection、policy metadata、output refs、patch/file/command/test facts、Windows sandbox setup/readiness、双 mode restricted-token runner、P2-A 执行端有界输出捕获,以及 P2-B no-sandbox shell live process 都已落地。Windows runner schema v3 `7/7` 与复验 run 已形成平台 evidence。继续对比后,真正高价值遗漏集中在两条骨干和一个策略体验增强:
|
||||
Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 current 主链、Workbench projection、policy metadata、output refs、patch/file/command/test facts、Windows sandbox setup/readiness、双 mode restricted-token runner、P2-A bounded output boundary 与 P2-B canonical sandbox-aware process owner 都已落地。Windows runner schema v3 `7/7` 与复验 run 已形成历史平台 evidence;当前 SHA 的 Windows 大输出复验仍需由 Windows runner 补证。继续对比后,真正高价值遗漏集中在正式 verifier/distribution evidence 和一个策略体验增强:
|
||||
|
||||
1. **统一进程生命周期 owner**:上游有可 write / interrupt / terminate / stream / poll 的 unified exec process;Lime 已有 process owner、本地 runner、App Server `executionProcess/*` 控制面与 no-sandbox shell live process,但 command/test 默认执行尚未切到 sandbox-aware control owner。
|
||||
1. **统一进程生命周期 owner**:上游有可 write / interrupt / terminate / stream / poll 的 unified exec process;Lime 已有 process owner、本地 runner、App Server `executionProcess/*` 控制面与 unified exec canonical command/test path,且回归证明带 `RuntimeToolExecutionAttempt` 的 workspace sandbox command 复用同一 control owner;P2-B owner 缺口已关闭。
|
||||
2. **Windows sandbox 完整性**:Lime 已有 backend plan/readiness contract,以及 elevated sandbox-account、unelevated current-user restricted-token runner、短生命周期 ACL lease、TokenDefaultDacl、Job Object、ConPTY、显式 handle allowlist、Firewall/WFP 和有界 pipe reader;Windows runner schema v3 `7/7` 与复验 run 已完成当前平台 evidence。后续仅在 token/ACL/network 改动时回归。
|
||||
3. **审批缓存与重试体验**:Lime 已有 `action.required` 和多来源策略,但缺 session-scope approval key、sandbox denied 后安全升级重试和规则草案沉淀。
|
||||
|
||||
这些不是 UI polish,也不是旧路清理;它们直接决定 coding turn 在长命令、大输出、Windows 和审批重跑场景下是否可持续。
|
||||
这些不是 UI polish,也不是旧路清理;它们直接决定 coding turn 在大输出、Windows 和审批重跑场景下是否可持续。
|
||||
|
||||
## 对比证据
|
||||
|
||||
| 上游参考能力 | 上游证据 | Lime current 状态 | 缺口判断 |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 统一进程对象 | `core/src/unified_exec/process.rs` 暴露 write / terminate / interrupt / output receiver / state;`tools/runtimes/unified_exec.rs` 把审批、sandbox、network 与 process manager 接在一起。 | `tool-runtime::execution_process` 已提供 process snapshot、stdout/stderr delta、有界 retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已提供 `executionProcess/start`、`writeStdin`、`interrupt`、`terminate`、`status`、`drainOutput` 控制面;no-sandbox shell path 已在 Lime preflight 与 Agent registry permission/safety preflight 之后走 live process;`executionProcess/start` 已收紧为受控 current 入口,workspace sandbox backend required / enforced 时 fail-closed,不再允许 `cwd` 覆盖 policy 判定后的实际工作目录。 | P2-B no-sandbox 路径和 App Server 受控启动已落地;缺口收缩为“command/test 默认执行切到 sandbox-aware process runner/control owner,并接 UI 控制”。 |
|
||||
| Head/tail 输出缓冲 | `core/src/unified_exec/head_tail_buffer.rs` 在进程读取阶段限制保留字节,保留头尾并记录 omitted bytes。 | `tool-runtime::execution_process::BoundedProcessOutput` 限制 live process retained output并记录 omitted bytes;Windows restricted-token blocking pipe reader 已复用同一 owner。 | current foundation 已覆盖 Windows 有界读取;仍需 Windows 真机大输出证据。 |
|
||||
| 统一进程对象 | `core/src/unified_exec/process.rs` 暴露 write / terminate / interrupt / output receiver / state;`tools/runtimes/unified_exec.rs` 把审批、sandbox、network 与 process manager 接在一起。 | `tool-runtime::execution_process` 已提供 process snapshot、stdout/stderr delta、1 MiB head/tail retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已提供 `executionProcess/start`、`writeStdin`、`interrupt`、`terminate`、`status`、`drainOutput` 控制面;unified exec canonical command/test path 已经消费 `RuntimeToolExecutionAttempt`,workspace sandbox 交互回归覆盖 snapshot、drain、stdin、terminal status 与 call identity。 | P2-B owner 与 P2-A output boundary 已落地;剩余是正式 verifier/distribution evidence。 |
|
||||
| Head/tail 输出缓冲 | `core/src/unified_exec/head_tail_buffer.rs` 在进程读取阶段限制保留字节,保留头尾并记录 omitted bytes;`async_watcher.rs` 使用 64-slot broadcast 并在 UTF-8 scalar 边界发 delta。 | `tool-runtime::execution_process::BoundedProcessOutput` 统一限制 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 输出;默认保留 1 MiB,稳定保留 head/latest tail、精确记录 omitted bytes、插入 omission marker,并用 `ProcessOutputFramers` 在 UTF-8 边界发 delta。App Server replay 按进程隔离,unified exec observation 同样有界。 | P2-A `completed / current`;Windows 当前 SHA 的真机大输出仍需平台 evidence,不影响 owner 完成判定。 |
|
||||
| 审批缓存与重试 | `core/src/tools/sandboxing.rs` 有 approval cache、sandbox override、denied-read preservation;`tools/orchestrator.rs` 有 approval -> sandbox -> attempt -> denied retry。 | Lime 有 `ToolExecutionPolicyService` 多来源规则、`action.required`、审批后续跑测试和 sandbox blocked metadata。 | 缺“同一命令 approval key 复用 / sandbox denied 后升级重试 / proposed rule amendment”这一条统一执行语义。 |
|
||||
| 持久 capability SID | `windows-sandbox-rs/src/cap.rs` 按 workspace / writable root 持久化 SID,并用 canonical path key 去重。 | current foundation 使用每次运行创建的 capability SID 与短生命周期 ACL lease,尚未持久化。 | 在真机验证临时 lease 正确后,再裁决是否需要持久 SID owner。 |
|
||||
| TokenDefaultDacl | `windows-sandbox-rs/src/token.rs` 设置 token default DACL,避免受限 token 创建管道 / IPC 对象失败。 | current foundation 已设置 restricted token default DACL,但仅有非 Windows 测试和 SDK 源码核对。 | 需要 Windows 真机 PowerShell pipeline 与子进程 IPC 证据。 |
|
||||
@@ -33,17 +33,23 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c
|
||||
|
||||
目标:在 executor / tool outcome 进入 RuntimeCore 前就限制内存增长。
|
||||
|
||||
状态:`partial / live-process retained output only`。
|
||||
状态:`completed / current`。
|
||||
|
||||
落点:
|
||||
|
||||
- `lime-rs/crates/tool-runtime/src/execution_process.rs`
|
||||
- `lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs`
|
||||
- `lime-rs/crates/tool-runtime/src/unified_exec.rs`
|
||||
- `lime-rs/crates/app-server/src/execution_process.rs`
|
||||
- `lime-rs/crates/app-server/src/runtime/output_refs.rs`
|
||||
|
||||
已落动作:
|
||||
|
||||
- live process retained output 已使用有界 buffer,并输出 `outputBytes / outputOmittedBytes / outputTruncated`。
|
||||
- Windows restricted-token blocking pipe reader 已接入同一有界输出语义;仍缺 Windows 真机大输出和 omitted-bytes 证据。
|
||||
- 本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 已共享 `BoundedProcessOutput`;默认保留 1 MiB,稳定保留 head/latest tail、精确记录 omitted bytes 并插入 omission marker。
|
||||
- reader 到 process handle 使用 64-slot bounded broadcast;慢消费者可 lag,但权威 terminal transcript 不丢失,未消费 delta 的大输出进程也能完成。
|
||||
- `ProcessOutputFramers` 按 stdout/stderr/combined 独立保留最多 3 个不完整 UTF-8 字节,完整 scalar 才进入 lifecycle delta,EOF flush 不完整后缀;raw byte、snapshot 与 omission 统计只计一次。
|
||||
- unified exec 每次 observation 使用同一有界 buffer;App Server replay 下沉到逐进程 queue,避免跨进程互相驱逐。
|
||||
- Windows restricted-token blocking pipe reader 已接入同一语义;当前 SHA 仍缺 Windows 真机大输出和 omitted-bytes 复验证据,不能用非 Windows 测试代替。
|
||||
- RuntimeCore 继续负责 snapshot;executor 不直接写 App Server sidecar,避免跨层依赖。
|
||||
|
||||
收益:解决大输出在进入 output ref 之前压爆内存的问题,直接服务 command/test coding 主线。
|
||||
@@ -52,11 +58,11 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c
|
||||
|
||||
目标:让 command/test execution 从“批处理终态”升级为“可观察、可中断、可续写”的进程对象。
|
||||
|
||||
状态:`in_progress / no-sandbox live process done`。
|
||||
状态:`completed / canonical command-test sandbox-aware owner landed`。
|
||||
|
||||
落点:
|
||||
|
||||
- `lime-rs/crates/agent/src/agent_tools/execution/process.rs`
|
||||
- `lime-rs/crates/tool-runtime/src/execution_process.rs`
|
||||
- `lime-rs/crates/app-server/src/runtime_backend/coding_events/command.rs`
|
||||
- `packages/agent-runtime-projection/src/coding.ts`
|
||||
|
||||
@@ -66,7 +72,8 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c
|
||||
- 已把现有 shell batch bridge 接到 process metadata:`processId / executionProcessStatus / outputBytes / outputOmittedBytes / outputTruncated` 透传到 `tool.output.delta` / `command.output` metadata。
|
||||
- 已通过 App Server current JSON-RPC 暴露 `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput`,并同步 protocol schema、processor、client 与 contract guard。
|
||||
- no-sandbox shell path 已接入 live process:先过 `ToolExecutionDecision`,再复用 Agent `ToolRegistry::check_tool_permissions`,且需要 workspace sandbox backend 的命令继续走 Agent sandbox executor。
|
||||
- 下一刀把 command/test 默认执行切到 sandbox-aware `LocalExecutionProcessHandle` / execution process control owner,并让 Workbench UI 的停止、输入和状态刷新复用同一 current API。
|
||||
- `RuntimeToolExecutionAttempt` 的 workspace sandbox command 已通过 App Server `ExecutionProcessServer` 真实回归覆盖启动 snapshot、按 process cursor drain、stdin/control identity 与 terminal status;该证据与 unified exec/current orchestration wiring 一起关闭 command/test 默认 owner 迁移缺口。
|
||||
- P2-B 已由 unified exec 将 canonical command/test 默认执行接到 sandbox-aware `LocalExecutionProcessHandle` / App Server execution process control owner,Workbench UI 的停止、输入和状态刷新继续复用同一 current API;P2-A/P2-B 均已闭环,下一刀只补正式 verifier/distribution evidence。
|
||||
|
||||
收益:长任务、用户中断、实时日志、测试服务器、交互式 shell 才能成为产品能力,而不是一次性命令结果。
|
||||
|
||||
@@ -129,4 +136,4 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c
|
||||
|
||||
## 下一刀建议
|
||||
|
||||
**P2-C Windows restricted-token runner 基线已完成。** readiness、setup mode、elevated sandbox-account、unelevated current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、warning lifecycle 和 readiness read-back 已收敛到 `tool-runtime` current owner;Windows runner schema v3 `7/7`、quality run 与 readiness 复验 run 是平台 evidence,不能用 macOS 结果替代。下一刀回到 P2-B 默认 command/test sandbox-aware process owner,不得用 no-sandbox process 绕过 sandbox。
|
||||
**P2-A/P2-B/P2-C current owner 均已完成。** 下一刀直接进入当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence;Windows runner schema v3 `7/7`、quality run 与 readiness 复验 run 只证明对应历史 SHA,当前 SHA 的 restricted-token 大输出仍必须在 Windows 真机复跑,不能用 macOS 结果替代,也不得用 no-sandbox process 绕过 sandbox。
|
||||
|
||||
Generated
+61
-37
@@ -31,7 +31,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-protocol"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-runtime"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
@@ -226,7 +226,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "app-server"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"agent-runtime",
|
||||
@@ -292,7 +292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-client"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"app-server-transport",
|
||||
@@ -308,7 +308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-daemon"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"app-server-transport",
|
||||
@@ -320,7 +320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-protocol"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"schemars",
|
||||
@@ -332,7 +332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-test-client"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-server-client",
|
||||
@@ -343,7 +343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-transport"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"axum",
|
||||
@@ -979,7 +979,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cli"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-server-client",
|
||||
@@ -1031,7 +1031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"code-mode-host",
|
||||
@@ -1049,7 +1049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-host"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"code-mode-protocol",
|
||||
@@ -1065,7 +1065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-protocol"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"protoc-bin-vendored",
|
||||
@@ -1079,7 +1079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-runtime"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"code-mode-protocol",
|
||||
@@ -1398,8 +1398,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
source = "git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"crossterm_winapi",
|
||||
@@ -1889,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "document-preview"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"tempfile",
|
||||
@@ -2100,7 +2099,7 @@ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
|
||||
|
||||
[[package]]
|
||||
name = "execpolicy"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3493,7 +3492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"agent-runtime",
|
||||
@@ -3528,7 +3527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -3544,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"async-trait",
|
||||
@@ -3601,7 +3600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -3629,7 +3628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -3651,7 +3650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-knowledge"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -3664,7 +3663,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3690,7 +3689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -3709,7 +3708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -3749,7 +3748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"async-stream",
|
||||
@@ -3792,7 +3791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -3807,7 +3806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3850,7 +3849,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -3872,7 +3871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -4190,7 +4189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "model-provider"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
@@ -4980,7 +4979,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||
|
||||
[[package]]
|
||||
name = "patch-apply"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -6003,7 +6002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime-core"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"app-server-protocol",
|
||||
@@ -7172,7 +7171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thread-store"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"chrono",
|
||||
@@ -7499,7 +7498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tool-runtime"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"app-server-protocol",
|
||||
@@ -7704,8 +7703,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tui"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
"app-server-client",
|
||||
"app-server-protocol",
|
||||
@@ -7727,12 +7727,15 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"two-face",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.2",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"vt100",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8049,6 +8052,27 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width 0.2.2",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
|
||||
+5
-1
@@ -6,7 +6,7 @@ exclude = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -165,6 +165,7 @@ rustls-pemfile = "2"
|
||||
|
||||
# 终端
|
||||
portable-pty = "0.8"
|
||||
vt100 = "0.16.2"
|
||||
|
||||
# App Server remote client transport
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "native-tls"] }
|
||||
@@ -234,3 +235,6 @@ version = "0.2"
|
||||
[workspace.lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
string_slice = "warn"
|
||||
|
||||
[patch.crates-io]
|
||||
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "45fecb9508105988f42fe6ff0441783ed3717f92" }
|
||||
|
||||
@@ -270,9 +270,11 @@ async fn execute_current_tool_attempt(
|
||||
working_directory: request.context.working_directory().clone(),
|
||||
environment: request.context.environment().clone(),
|
||||
tool_call_id: attempt.identity().call_id().to_string(),
|
||||
turn_id: attempt.identity().turn_id().to_string(),
|
||||
cancel_token: request.context.cancel_token().cloned(),
|
||||
turn_context: request.turn_context,
|
||||
attempt: Some(attempt),
|
||||
output_sink: request.context.lifecycle_emitter().cloned(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub use app_server_protocol::app_server_method_catalog;
|
||||
pub use app_server_protocol::is_app_server_notification_method;
|
||||
pub use app_server_protocol::is_app_server_request_method;
|
||||
pub use app_server_protocol::protocol::v2::ServerNotification;
|
||||
use app_server_protocol::protocol::v2::ServerRequest;
|
||||
pub use app_server_protocol::protocol::v2::ThreadRevertParams;
|
||||
pub use app_server_protocol::protocol::v2::ThreadRevertResponse;
|
||||
pub use app_server_protocol::protocol::v2::ThreadRevertedNotification;
|
||||
@@ -402,9 +403,17 @@ mod session;
|
||||
mod transport;
|
||||
|
||||
pub use remote::{RemoteTransport, RemoteTransportConfig};
|
||||
pub use session::{ClientSession, RequestHandle, SessionError, SessionEvent};
|
||||
pub use session::{ClientSession, RequestHandle, SessionError};
|
||||
pub use transport::{SessionTransport, StdioTransport, StdioTransportConfig};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppServerEvent {
|
||||
Lagged { skipped: usize },
|
||||
ServerNotification(Box<ServerNotification>),
|
||||
ServerRequest(Box<ServerRequest>),
|
||||
Disconnected { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
#[error("invalid request params: {0}")]
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use crate::{AppServerClient, ClientError, ClientEvent};
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest};
|
||||
use crate::{AppServerClient, AppServerEvent, ClientError, ClientEvent};
|
||||
#[cfg(test)]
|
||||
use app_server_protocol::protocol::v2::ServerNotification;
|
||||
use app_server_protocol::protocol::v2::ServerRequest;
|
||||
#[cfg(test)]
|
||||
use app_server_protocol::JsonRpcRequest;
|
||||
use app_server_protocol::{
|
||||
InitializeParams, InitializeResponse, JsonRpcError, JsonRpcErrorResponse, JsonRpcMessage,
|
||||
JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, RequestId,
|
||||
JsonRpcNotification, JsonRpcResponse, RequestId,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
@@ -39,15 +43,6 @@ pub enum SessionError {
|
||||
UnsupportedProtocolVersion { actual: String, supported: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SessionEvent {
|
||||
Notification(Box<ServerNotification>),
|
||||
RawNotification(JsonRpcNotification),
|
||||
ServerRequest(Box<ServerRequest>),
|
||||
RawServerRequest(JsonRpcRequest),
|
||||
Disconnected { message: String },
|
||||
}
|
||||
|
||||
enum SessionCommand {
|
||||
Request {
|
||||
method: String,
|
||||
@@ -158,7 +153,7 @@ impl RequestHandle {
|
||||
|
||||
pub struct ClientSession {
|
||||
request_handle: RequestHandle,
|
||||
event_rx: mpsc::UnboundedReceiver<SessionEvent>,
|
||||
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
|
||||
worker: tokio::task::JoinHandle<()>,
|
||||
initialize_response: InitializeResponse,
|
||||
}
|
||||
@@ -232,7 +227,7 @@ impl ClientSession {
|
||||
&self.initialize_response
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<SessionEvent> {
|
||||
pub async fn next_event(&mut self) -> Option<AppServerEvent> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
|
||||
@@ -273,7 +268,7 @@ async fn stop_worker(request_handle: &RequestHandle, worker: tokio::task::JoinHa
|
||||
async fn run_session<T: SessionTransport>(
|
||||
mut transport: T,
|
||||
mut command_rx: mpsc::Receiver<SessionCommand>,
|
||||
event_tx: mpsc::UnboundedSender<SessionEvent>,
|
||||
event_tx: mpsc::UnboundedSender<AppServerEvent>,
|
||||
) {
|
||||
let mut client = AppServerClient::new();
|
||||
let mut pending = HashMap::<RequestId, PendingRequest>::new();
|
||||
@@ -366,7 +361,13 @@ async fn run_session<T: SessionTransport>(
|
||||
let _ = transport.close().await;
|
||||
break;
|
||||
}
|
||||
handle_message(message, &event_tx, &mut pending)
|
||||
if let Some(response) = handle_message(message, &event_tx, &mut pending) {
|
||||
if let Err(error) = transport.send(response).await {
|
||||
disconnect(&event_tx, &mut pending, error.to_string());
|
||||
let _ = transport.close().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
disconnect(&event_tx, &mut pending, "app-server transport closed".to_string());
|
||||
@@ -413,9 +414,9 @@ fn validate_initialize_response<T: SessionTransport>(
|
||||
|
||||
fn handle_message(
|
||||
message: JsonRpcMessage,
|
||||
event_tx: &mpsc::UnboundedSender<SessionEvent>,
|
||||
event_tx: &mpsc::UnboundedSender<AppServerEvent>,
|
||||
pending: &mut HashMap<RequestId, PendingRequest>,
|
||||
) {
|
||||
) -> Option<JsonRpcMessage> {
|
||||
match message {
|
||||
JsonRpcMessage::Response(response) => {
|
||||
if let Some(pending) = pending.remove(&response.id) {
|
||||
@@ -432,37 +433,41 @@ fn handle_message(
|
||||
}
|
||||
message => match AppServerClient::event(message) {
|
||||
Ok(ClientEvent::Lifecycle(notification)) => {
|
||||
let _ = event_tx.send(SessionEvent::Notification(notification));
|
||||
}
|
||||
Ok(
|
||||
ClientEvent::AgentSession(notification) | ClientEvent::Notification(notification),
|
||||
) => {
|
||||
let _ = event_tx.send(SessionEvent::RawNotification(notification));
|
||||
}
|
||||
Ok(ClientEvent::Request(request)) => {
|
||||
let event = ServerRequest::try_from(request.clone())
|
||||
.map(Box::new)
|
||||
.map(SessionEvent::ServerRequest)
|
||||
.unwrap_or(SessionEvent::RawServerRequest(request));
|
||||
let _ = event_tx.send(event);
|
||||
let _ = event_tx.send(AppServerEvent::ServerNotification(notification));
|
||||
}
|
||||
Ok(ClientEvent::AgentSession(_) | ClientEvent::Notification(_)) => {}
|
||||
Ok(ClientEvent::Request(request)) => match ServerRequest::try_from(request.clone()) {
|
||||
Ok(request) => {
|
||||
let _ = event_tx.send(AppServerEvent::ServerRequest(Box::new(request)));
|
||||
}
|
||||
Err(_) => {
|
||||
return Some(JsonRpcMessage::Error(JsonRpcErrorResponse {
|
||||
id: request.id,
|
||||
error: JsonRpcError::new(
|
||||
app_server_protocol::error_codes::METHOD_NOT_FOUND,
|
||||
format!("unsupported app-server request `{}`", request.method),
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
Ok(ClientEvent::Response(_) | ClientEvent::Error(_)) => {}
|
||||
Err(error) => {
|
||||
let _ = event_tx.send(SessionEvent::Disconnected {
|
||||
let _ = event_tx.send(AppServerEvent::Disconnected {
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn disconnect(
|
||||
event_tx: &mpsc::UnboundedSender<SessionEvent>,
|
||||
event_tx: &mpsc::UnboundedSender<AppServerEvent>,
|
||||
pending: &mut HashMap<RequestId, PendingRequest>,
|
||||
message: String,
|
||||
) {
|
||||
fail_pending(pending, &message);
|
||||
let _ = event_tx.send(SessionEvent::Disconnected { message });
|
||||
let _ = event_tx.send(AppServerEvent::Disconnected { message });
|
||||
}
|
||||
|
||||
fn fail_pending(pending: &mut HashMap<RequestId, PendingRequest>, message: &str) {
|
||||
@@ -589,10 +594,16 @@ mod tests {
|
||||
panic!("expected request");
|
||||
};
|
||||
server_tx
|
||||
.send(JsonRpcMessage::Notification(JsonRpcNotification::new(
|
||||
"terminal/test",
|
||||
Some(serde_json::json!({ "sequence": 1 })),
|
||||
)))
|
||||
.send(JsonRpcMessage::Notification(
|
||||
ServerNotification::Warning(
|
||||
app_server_protocol::protocol::v2::WarningNotification {
|
||||
thread_id: Some("thread-1".to_string()),
|
||||
message: "warning".to_string(),
|
||||
code: None,
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
.expect("send notification");
|
||||
server_tx
|
||||
.send(JsonRpcMessage::Response(
|
||||
@@ -614,12 +625,51 @@ mod tests {
|
||||
assert_eq!(response, serde_json::json!({ "value": 7 }));
|
||||
assert!(matches!(
|
||||
session.next_event().await,
|
||||
Some(SessionEvent::RawNotification(notification))
|
||||
if notification.method == "terminal/test"
|
||||
Some(AppServerEvent::ServerNotification(notification))
|
||||
if matches!(*notification, ServerNotification::Warning(_))
|
||||
));
|
||||
server.await.expect("server task");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_unknown_server_request_is_rejected() {
|
||||
let (transport, mut server_rx, server_tx, close_rx) = transport_pair();
|
||||
let (rejected_tx, rejected_rx) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
complete_handshake(&mut server_rx, &server_tx).await;
|
||||
let request_id = RequestId::String("srv-unknown".to_string());
|
||||
server_tx
|
||||
.send(JsonRpcMessage::Request(JsonRpcRequest::new(
|
||||
request_id.clone(),
|
||||
"thread/unknown",
|
||||
None,
|
||||
)))
|
||||
.expect("send server request");
|
||||
|
||||
let JsonRpcMessage::Error(response) = server_rx.recv().await.expect("response") else {
|
||||
panic!("expected JSON-RPC error response");
|
||||
};
|
||||
assert_eq!(response.id, request_id);
|
||||
assert_eq!(
|
||||
response.error.code,
|
||||
app_server_protocol::error_codes::METHOD_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
response.error.message,
|
||||
"unsupported app-server request `thread/unknown`"
|
||||
);
|
||||
rejected_tx.send(()).expect("report rejected request");
|
||||
close_rx.await.expect("client shutdown closes transport");
|
||||
});
|
||||
let session = ClientSession::start(transport, initialize_params())
|
||||
.await
|
||||
.expect("session");
|
||||
|
||||
rejected_rx.await.expect("unknown request rejected");
|
||||
session.shutdown().await.expect("shutdown");
|
||||
server.await.expect("server task");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverse_request_is_typed_and_can_be_resolved() {
|
||||
let (transport, mut server_rx, server_tx, _close_rx) = transport_pair();
|
||||
@@ -644,7 +694,7 @@ mod tests {
|
||||
.await
|
||||
.expect("session");
|
||||
let request = session.next_event().await.expect("server request");
|
||||
let SessionEvent::ServerRequest(request) = request else {
|
||||
let AppServerEvent::ServerRequest(request) = request else {
|
||||
panic!("expected typed current time request");
|
||||
};
|
||||
let ServerRequest::CurrentTimeRead { id, params } = *request else {
|
||||
|
||||
@@ -23,9 +23,9 @@ use tool_runtime::execution_policy::{
|
||||
use tool_runtime::execution_policy_service::ToolExecutionResolverInput;
|
||||
use tool_runtime::execution_process::{
|
||||
live::{LiveExecutionOutputBatch, LiveExecutionOutputQuery, LiveExecutionRequest},
|
||||
start_local_execution_process, ExecutionOutputDelta, ExecutionProcessSnapshot,
|
||||
LiveExecutionProcessRegistry, LocalExecutionProcessControlHandle, LocalExecutionRequest,
|
||||
LocalExecutionSandbox,
|
||||
start_local_execution_process, BoundedProcessOutput, ExecutionOutputDelta,
|
||||
ExecutionProcessSnapshot, LiveExecutionProcessRegistry, LocalExecutionProcessControlHandle,
|
||||
LocalExecutionRequest, LocalExecutionSandbox, ProcessOutputFramers,
|
||||
};
|
||||
use tool_runtime::sandbox::{
|
||||
plan_sandbox_backend, SandboxBackendPlanInput, SandboxBackendPlatform,
|
||||
@@ -48,8 +48,6 @@ pub struct ExecutionProcessServer {
|
||||
#[derive(Debug)]
|
||||
struct ExecutionProcessState {
|
||||
processes: HashMap<String, ExecutionProcessEntry>,
|
||||
output: VecDeque<ExecutionOutputDelta>,
|
||||
output_bytes: usize,
|
||||
next_background_process_id: u64,
|
||||
}
|
||||
|
||||
@@ -57,6 +55,11 @@ struct ExecutionProcessState {
|
||||
struct ExecutionProcessEntry {
|
||||
handle: Option<LocalExecutionProcessControlHandle>,
|
||||
remote_control: Option<RemoteProcessControl>,
|
||||
output_buffer: Option<BoundedProcessOutput>,
|
||||
output_framers: Option<ProcessOutputFramers>,
|
||||
output: VecDeque<ExecutionOutputDelta>,
|
||||
output_bytes: usize,
|
||||
next_output_sequence: u64,
|
||||
snapshot: Option<ExecutionProcessSnapshot>,
|
||||
final_snapshot: Option<ExecutionProcessSnapshot>,
|
||||
background: Option<BackgroundTerminalEntry>,
|
||||
@@ -89,8 +92,6 @@ impl Default for ExecutionProcessState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
processes: HashMap::new(),
|
||||
output: VecDeque::new(),
|
||||
output_bytes: 0,
|
||||
next_background_process_id: 1,
|
||||
}
|
||||
}
|
||||
@@ -166,6 +167,11 @@ impl ExecutionProcessServer {
|
||||
ExecutionProcessEntry {
|
||||
handle: if is_terminal { None } else { Some(handle) },
|
||||
remote_control: None,
|
||||
output_buffer: None,
|
||||
output_framers: None,
|
||||
output: VecDeque::new(),
|
||||
output_bytes: 0,
|
||||
next_output_sequence: 1,
|
||||
snapshot: Some(snapshot.clone()),
|
||||
final_snapshot: if is_terminal { Some(snapshot) } else { None },
|
||||
background: None,
|
||||
@@ -176,13 +182,14 @@ impl ExecutionProcessServer {
|
||||
|
||||
pub fn record_process_output(
|
||||
&self,
|
||||
delta: ExecutionOutputDelta,
|
||||
mut delta: ExecutionOutputDelta,
|
||||
) -> Result<(), ExecutionProcessError> {
|
||||
let mut state = self.inner.lock().map_err(|_| ExecutionProcessError::Lock)?;
|
||||
if !state.processes.contains_key(&delta.process_id) {
|
||||
let Some(entry) = state.processes.get_mut(&delta.process_id) else {
|
||||
return Err(ExecutionProcessError::ProcessNotFound(delta.process_id));
|
||||
}
|
||||
push_output(&mut state, delta);
|
||||
};
|
||||
update_snapshot_output(entry, &mut delta);
|
||||
push_output(entry, delta);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -297,6 +304,11 @@ impl ExecutionProcessServer {
|
||||
ExecutionProcessEntry {
|
||||
handle: None,
|
||||
remote_control: None,
|
||||
output_buffer: None,
|
||||
output_framers: None,
|
||||
output: VecDeque::new(),
|
||||
output_bytes: 0,
|
||||
next_output_sequence: 1,
|
||||
snapshot: None,
|
||||
final_snapshot: None,
|
||||
background,
|
||||
@@ -349,7 +361,9 @@ impl ExecutionProcessServer {
|
||||
tokio::spawn(async move {
|
||||
while let Some(delta) = handle.recv_output().await {
|
||||
if let Ok(mut state) = inner.lock() {
|
||||
push_output(&mut state, delta);
|
||||
if let Some(entry) = state.processes.get_mut(&delta.process_id) {
|
||||
push_output(entry, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
let final_snapshot = handle.wait().await.ok();
|
||||
@@ -446,6 +460,11 @@ impl ExecutionProcessServer {
|
||||
ExecutionProcessEntry {
|
||||
handle: None,
|
||||
remote_control: Some(RemoteProcessControl { commands }),
|
||||
output_buffer: Some(BoundedProcessOutput::default()),
|
||||
output_framers: Some(ProcessOutputFramers::default()),
|
||||
output: VecDeque::new(),
|
||||
output_bytes: 0,
|
||||
next_output_sequence: 1,
|
||||
snapshot: Some(snapshot.clone()),
|
||||
final_snapshot: None,
|
||||
background,
|
||||
@@ -719,25 +738,35 @@ impl ExecutionProcessServer {
|
||||
.unwrap_or(usize::MAX);
|
||||
let after_sequence = query.after_sequence.unwrap_or_default();
|
||||
let state = self.inner.lock().map_err(|_| ExecutionProcessError::Lock)?;
|
||||
let process_id = query
|
||||
.process_id
|
||||
.as_deref()
|
||||
.filter(|process_id| !process_id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ExecutionProcessError::Control(
|
||||
"execution process output requires a process id".to_string(),
|
||||
)
|
||||
})?;
|
||||
let entry = state
|
||||
.processes
|
||||
.get(process_id)
|
||||
.ok_or_else(|| ExecutionProcessError::ProcessNotFound(process_id.to_string()))?;
|
||||
let mut deltas = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
let mut next_sequence = query.after_sequence;
|
||||
|
||||
for delta in state.output.iter() {
|
||||
for delta in entry.output.iter() {
|
||||
if deltas.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if query
|
||||
.process_id
|
||||
.as_ref()
|
||||
.is_some_and(|process_id| process_id != &delta.process_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if delta.sequence <= after_sequence {
|
||||
continue;
|
||||
}
|
||||
let delta_bytes = delta.delta.len();
|
||||
let delta_bytes = if delta.raw_bytes.is_empty() {
|
||||
delta.delta.len()
|
||||
} else {
|
||||
delta.raw_bytes.len()
|
||||
};
|
||||
if !deltas.is_empty() && bytes.saturating_add(delta_bytes) > max_bytes {
|
||||
break;
|
||||
}
|
||||
@@ -1209,33 +1238,35 @@ async fn run_remote_process(
|
||||
} else {
|
||||
tool_runtime::execution_process::ExecutionOutputKind::Stdout
|
||||
};
|
||||
let delta = tool_runtime::execution_process::ExecutionOutputDelta {
|
||||
process_id: process_id.clone(),
|
||||
tool_id: tool_id.clone(),
|
||||
// Environment process cursors are zero-based; the local
|
||||
// execution stream reserves sequence 0 as the initial cursor.
|
||||
sequence: chunk.seq.saturating_add(1),
|
||||
kind,
|
||||
delta: String::from_utf8_lossy(&raw_bytes).into_owned(),
|
||||
bytes: raw_bytes.len() as u64,
|
||||
omitted_bytes: 0,
|
||||
truncated: false,
|
||||
raw_bytes,
|
||||
};
|
||||
if let Ok(mut state) = inner.lock() {
|
||||
if let Some(entry) = state.processes.get_mut(&process_id) {
|
||||
if let Some(snapshot) = entry.snapshot.as_mut() {
|
||||
snapshot.output_bytes = snapshot.output_bytes.saturating_add(delta.bytes);
|
||||
snapshot.retained_output.push_str(&delta.delta);
|
||||
if snapshot.retained_output.len() > 128 * 1024 {
|
||||
let trim = snapshot.retained_output.len() - 128 * 1024;
|
||||
snapshot.retained_output.drain(..trim);
|
||||
snapshot.output_omitted_bytes = snapshot.output_omitted_bytes.saturating_add(trim as u64);
|
||||
snapshot.output_truncated = true;
|
||||
}
|
||||
capture_snapshot_output(entry, &raw_bytes);
|
||||
// Environment cursors are zero-based; local output
|
||||
// cursors reserve zero as the initial position.
|
||||
entry.next_output_sequence = entry
|
||||
.next_output_sequence
|
||||
.max(chunk.seq.saturating_add(1));
|
||||
let frame = entry
|
||||
.output_framers
|
||||
.as_mut()
|
||||
.map(|framers| framers.push(kind, &raw_bytes))
|
||||
.unwrap_or(raw_bytes);
|
||||
if !frame.is_empty() {
|
||||
let mut delta = ExecutionOutputDelta {
|
||||
process_id: process_id.clone(),
|
||||
tool_id: tool_id.clone(),
|
||||
sequence: take_output_sequence(entry),
|
||||
kind,
|
||||
delta: String::from_utf8_lossy(&frame).into_owned(),
|
||||
bytes: 0,
|
||||
omitted_bytes: 0,
|
||||
truncated: false,
|
||||
raw_bytes: frame,
|
||||
};
|
||||
apply_snapshot_output_metadata(entry, &mut delta);
|
||||
push_output(entry, delta);
|
||||
}
|
||||
}
|
||||
push_output(&mut state, delta);
|
||||
}
|
||||
}
|
||||
if response.exited || response.closed || response.failure.is_some() || response.sandbox_denied {
|
||||
@@ -1282,6 +1313,7 @@ async fn finish_remote_process(
|
||||
) {
|
||||
if let Ok(mut state) = inner.lock() {
|
||||
if let Some(entry) = state.processes.get_mut(process_id) {
|
||||
flush_output_framers(entry, process_id, tool_id);
|
||||
let mut snapshot = entry
|
||||
.snapshot
|
||||
.clone()
|
||||
@@ -1313,19 +1345,92 @@ async fn finish_remote_process(
|
||||
}
|
||||
}
|
||||
|
||||
fn push_output(state: &mut ExecutionProcessState, mut delta: ExecutionOutputDelta) {
|
||||
delta.raw_bytes.clear();
|
||||
state.output_bytes = state.output_bytes.saturating_add(delta.delta.len());
|
||||
state.output.push_back(delta);
|
||||
while state.output.len() > OUTPUT_EVENT_CAP || state.output_bytes > OUTPUT_BYTE_CAP {
|
||||
let Some(evicted) = state.output.pop_front() else {
|
||||
state.output_bytes = 0;
|
||||
fn push_output(entry: &mut ExecutionProcessEntry, delta: ExecutionOutputDelta) {
|
||||
let delta_bytes = if delta.raw_bytes.is_empty() {
|
||||
delta.delta.len()
|
||||
} else {
|
||||
delta.raw_bytes.len()
|
||||
};
|
||||
entry.next_output_sequence = entry
|
||||
.next_output_sequence
|
||||
.max(delta.sequence.saturating_add(1));
|
||||
entry.output_bytes = entry.output_bytes.saturating_add(delta_bytes);
|
||||
entry.output.push_back(delta);
|
||||
while entry.output.len() > OUTPUT_EVENT_CAP || entry.output_bytes > OUTPUT_BYTE_CAP {
|
||||
let Some(evicted) = entry.output.pop_front() else {
|
||||
entry.output_bytes = 0;
|
||||
break;
|
||||
};
|
||||
state.output_bytes = state.output_bytes.saturating_sub(evicted.delta.len());
|
||||
let evicted_bytes = if evicted.raw_bytes.is_empty() {
|
||||
evicted.delta.len()
|
||||
} else {
|
||||
evicted.raw_bytes.len()
|
||||
};
|
||||
entry.output_bytes = entry.output_bytes.saturating_sub(evicted_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_snapshot_output(entry: &mut ExecutionProcessEntry, delta: &mut ExecutionOutputDelta) {
|
||||
let bytes = if delta.raw_bytes.is_empty() {
|
||||
delta.delta.as_bytes()
|
||||
} else {
|
||||
delta.raw_bytes.as_slice()
|
||||
};
|
||||
capture_snapshot_output(entry, bytes);
|
||||
apply_snapshot_output_metadata(entry, delta);
|
||||
}
|
||||
|
||||
fn capture_snapshot_output(entry: &mut ExecutionProcessEntry, bytes: &[u8]) {
|
||||
let Some(buffer) = entry.output_buffer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
buffer.push(bytes);
|
||||
let snapshot = buffer.snapshot();
|
||||
if let Some(process_snapshot) = entry.snapshot.as_mut() {
|
||||
process_snapshot.output_bytes = snapshot.bytes;
|
||||
process_snapshot.output_omitted_bytes = snapshot.omitted_bytes;
|
||||
process_snapshot.output_truncated = snapshot.truncated;
|
||||
process_snapshot.retained_output = snapshot.text;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_snapshot_output_metadata(entry: &ExecutionProcessEntry, delta: &mut ExecutionOutputDelta) {
|
||||
let Some(buffer) = entry.output_buffer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let snapshot = buffer.snapshot();
|
||||
delta.bytes = snapshot.bytes;
|
||||
delta.omitted_bytes = snapshot.omitted_bytes;
|
||||
delta.truncated = snapshot.truncated;
|
||||
}
|
||||
|
||||
fn flush_output_framers(entry: &mut ExecutionProcessEntry, process_id: &str, tool_id: &str) {
|
||||
let Some(mut framers) = entry.output_framers.take() else {
|
||||
return;
|
||||
};
|
||||
for (kind, frame) in framers.finish_all() {
|
||||
let mut delta = ExecutionOutputDelta {
|
||||
process_id: process_id.to_string(),
|
||||
tool_id: tool_id.to_string(),
|
||||
sequence: take_output_sequence(entry),
|
||||
kind,
|
||||
delta: String::from_utf8_lossy(&frame).into_owned(),
|
||||
bytes: 0,
|
||||
omitted_bytes: 0,
|
||||
truncated: false,
|
||||
raw_bytes: frame,
|
||||
};
|
||||
apply_snapshot_output_metadata(entry, &mut delta);
|
||||
push_output(entry, delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_output_sequence(entry: &mut ExecutionProcessEntry) -> u64 {
|
||||
let sequence = entry.next_output_sequence;
|
||||
entry.next_output_sequence = entry.next_output_sequence.saturating_add(1);
|
||||
sequence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "execution_process/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -19,7 +19,7 @@ use tool_runtime::execution_process::{
|
||||
LiveExecutionOutputBatch, LiveExecutionOutputQuery, LiveExecutionRequest,
|
||||
RuntimeLiveExecutionGateway,
|
||||
},
|
||||
ExecutionProcessStatus,
|
||||
ExecutionProcessStatus, DEFAULT_OUTPUT_RETAIN_BYTES,
|
||||
};
|
||||
use tool_runtime::tool_executor::{RuntimeToolExecutionIdentity, RuntimeToolExecutionResult};
|
||||
use tool_runtime::unified_exec::{
|
||||
@@ -88,6 +88,47 @@ impl RuntimeToolAttemptRunner for ProcessAttemptRunner {
|
||||
}
|
||||
}
|
||||
|
||||
struct InteractiveProcessAttemptRunner {
|
||||
server: ExecutionProcessServer,
|
||||
process_id: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeToolAttemptRunner for InteractiveProcessAttemptRunner {
|
||||
fn run<'a>(&'a self, attempt: RuntimeToolExecutionAttempt) -> RuntimeToolAttemptFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let snapshot = RuntimeLiveExecutionGateway::start_process(
|
||||
&self.server,
|
||||
"test-thread",
|
||||
"interactive sandbox command",
|
||||
LiveExecutionRequest {
|
||||
process_id: self.process_id.to_string(),
|
||||
tool_id: attempt.identity().call_id().to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
environment_id: "local".to_string(),
|
||||
command: interactive_stdin_command(),
|
||||
working_directory: current_directory(),
|
||||
tty: false,
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox_policy: attempt
|
||||
.effective_sandbox_policy()
|
||||
.label()
|
||||
.map(str::to_string),
|
||||
runtime_metadata: None,
|
||||
env: HashMap::new(),
|
||||
attempt: Some(attempt),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(RuntimeToolExecutionResult::new(
|
||||
true,
|
||||
snapshot.process_id,
|
||||
None,
|
||||
HashMap::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn process_orchestration_input(
|
||||
initial_approval: RuntimeToolInitialApproval,
|
||||
sandbox_policy: RuntimeToolSandboxPolicy,
|
||||
@@ -140,6 +181,61 @@ async fn orchestrated_process_does_not_repeat_policy_approval() {
|
||||
assert_eq!(snapshot.status, ExecutionProcessStatus::Exited);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrated_sandbox_process_reuses_execution_process_control_owner() {
|
||||
let runner = InteractiveProcessAttemptRunner {
|
||||
server: ExecutionProcessServer::default(),
|
||||
process_id: "orchestrated-sandbox-control-process",
|
||||
};
|
||||
let result = orchestrate_runtime_tool_execution(
|
||||
process_orchestration_input(
|
||||
RuntimeToolInitialApproval::Required(RuntimeToolApprovalKind::User),
|
||||
RuntimeToolSandboxPolicy::WorkspaceWrite,
|
||||
None,
|
||||
false,
|
||||
),
|
||||
&RecordingProcessApprovals::default(),
|
||||
&runner,
|
||||
)
|
||||
.await
|
||||
.expect("sandbox process should start after approval");
|
||||
|
||||
assert_eq!(result.output, runner.process_id);
|
||||
assert_eq!(result.metadata.get("toolAttemptNumber"), Some(&json!(1)));
|
||||
assert_eq!(
|
||||
result.metadata.get("effectiveSandboxPolicy"),
|
||||
Some(&json!("workspace-write"))
|
||||
);
|
||||
|
||||
let process_id = runner.process_id;
|
||||
let initial = runner
|
||||
.server
|
||||
.status(process_id)
|
||||
.expect("sandbox process snapshot should be available");
|
||||
assert_eq!(initial.status, ExecutionProcessStatus::Running);
|
||||
assert_eq!(initial.tool_id, "orchestrated-call");
|
||||
|
||||
let ready = wait_for_output(&runner.server, process_id, "READY").await;
|
||||
assert!(ready
|
||||
.deltas
|
||||
.iter()
|
||||
.all(|delta| delta.tool_id == "orchestrated-call"));
|
||||
runner
|
||||
.server
|
||||
.write_stdin(process_id, b"from-control\n")
|
||||
.expect("sandbox process stdin should use the shared control owner");
|
||||
|
||||
let received = wait_for_output(&runner.server, process_id, "RECEIVED:from-control").await;
|
||||
assert!(received
|
||||
.deltas
|
||||
.iter()
|
||||
.all(|delta| delta.tool_id == "orchestrated-call"));
|
||||
let final_snapshot = wait_for_terminal_snapshot(&runner.server, process_id).await;
|
||||
assert_eq!(final_snapshot.status, ExecutionProcessStatus::Exited);
|
||||
assert_eq!(final_snapshot.exit_code, Some(0));
|
||||
assert_eq!(final_snapshot.tool_id, "orchestrated-call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_network_denial_precedes_sandbox_fallback_and_retries_with_network_grant() {
|
||||
let approvals = RecordingProcessApprovals::default();
|
||||
@@ -233,9 +329,11 @@ async fn unified_exec_yields_active_session_then_poll_observes_terminal_process(
|
||||
working_directory: current_directory(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: "unified-exec-call".to_string(),
|
||||
turn_id: "unified-exec-turn".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -263,9 +361,11 @@ async fn unified_exec_yields_active_session_then_poll_observes_terminal_process(
|
||||
working_directory: current_directory(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: "unified-exec-poll".to_string(),
|
||||
turn_id: "unified-exec-turn".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -308,9 +408,11 @@ async fn unified_exec_reports_silent_non_zero_exit_as_terminal() {
|
||||
working_directory: current_directory(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: "unified-exec-silent-failure-call".to_string(),
|
||||
turn_id: "unified-exec-silent-failure-turn".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -354,9 +456,11 @@ async fn unified_exec_starts_short_command_after_repeated_terminal_commands() {
|
||||
working_directory: current_directory(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: format!("unified-exec-sequence-{index}"),
|
||||
turn_id: "unified-exec-sequence-turn".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -385,9 +489,11 @@ async fn unified_exec_starts_short_command_after_repeated_terminal_commands() {
|
||||
working_directory: current_directory(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: "unified-exec-sequence-read".to_string(),
|
||||
turn_id: "unified-exec-sequence-turn".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -434,6 +540,15 @@ async fn execution_process_server_uses_environment_process_transport() {
|
||||
.await
|
||||
.expect("remote exec fixture bind");
|
||||
let address = listener.local_addr().expect("remote exec fixture address");
|
||||
let remote_output = format!(
|
||||
"REMOTE-HEAD\n\u{1f600}\n{}\nREMOTE-TAIL",
|
||||
"x".repeat(DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024)
|
||||
);
|
||||
let fixture_output = remote_output.clone();
|
||||
let fixture_split = fixture_output
|
||||
.find('\u{1f600}')
|
||||
.expect("remote fixture emoji")
|
||||
+ 2;
|
||||
let read_count = Arc::new(AtomicUsize::new(0));
|
||||
let fixture_read_count = Arc::clone(&read_count);
|
||||
let fixture = tokio::spawn(async move {
|
||||
@@ -466,12 +581,19 @@ async fn execution_process_server_uses_environment_process_transport() {
|
||||
let read = fixture_read_count.fetch_add(1, Ordering::SeqCst);
|
||||
if read == 0 {
|
||||
json!({
|
||||
"chunks": [{
|
||||
"seq": 0,
|
||||
"stream": "stdout",
|
||||
"chunk": base64::engine::general_purpose::STANDARD.encode("remote-output")
|
||||
}],
|
||||
"nextSeq": 1,
|
||||
"chunks": [
|
||||
{
|
||||
"seq": 0,
|
||||
"stream": "stdout",
|
||||
"chunk": base64::engine::general_purpose::STANDARD.encode(&fixture_output.as_bytes()[..fixture_split])
|
||||
},
|
||||
{
|
||||
"seq": 1,
|
||||
"stream": "stdout",
|
||||
"chunk": base64::engine::general_purpose::STANDARD.encode(&fixture_output.as_bytes()[fixture_split..])
|
||||
}
|
||||
],
|
||||
"nextSeq": 2,
|
||||
"exited": true,
|
||||
"exitCode": 0,
|
||||
"closed": false,
|
||||
@@ -549,14 +671,42 @@ async fn execution_process_server_uses_environment_process_transport() {
|
||||
.signal("process-remote-fixture")
|
||||
.expect("remote signal should use process/signal");
|
||||
|
||||
let output = wait_for_output(&server, "process-remote-fixture", "remote-output").await;
|
||||
let output = wait_for_output(&server, "process-remote-fixture", "REMOTE-TAIL").await;
|
||||
assert_eq!(
|
||||
output
|
||||
.deltas
|
||||
.iter()
|
||||
.map(|delta| delta.delta.as_str())
|
||||
.collect::<String>(),
|
||||
remote_output
|
||||
);
|
||||
assert!(output
|
||||
.deltas
|
||||
.iter()
|
||||
.any(|delta| delta.delta.contains("remote-output")));
|
||||
.all(|delta| !delta.delta.contains('\u{fffd}')));
|
||||
let delta = output.deltas.last().expect("remote output delta");
|
||||
assert_eq!(delta.bytes, remote_output.len() as u64);
|
||||
assert_eq!(
|
||||
delta.omitted_bytes,
|
||||
remote_output
|
||||
.len()
|
||||
.saturating_sub(DEFAULT_OUTPUT_RETAIN_BYTES) as u64
|
||||
);
|
||||
assert!(delta.truncated);
|
||||
let snapshot = wait_for_terminal_snapshot(&server, "process-remote-fixture").await;
|
||||
assert_eq!(snapshot.status, ExecutionProcessStatus::Exited);
|
||||
assert_eq!(snapshot.exit_code, Some(0));
|
||||
assert_eq!(snapshot.output_bytes, remote_output.len() as u64);
|
||||
assert_eq!(snapshot.output_omitted_bytes, delta.omitted_bytes);
|
||||
assert!(snapshot.output_truncated);
|
||||
let omission_marker = format!("... {} bytes omitted ...", delta.omitted_bytes);
|
||||
assert_eq!(
|
||||
snapshot.retained_output.len(),
|
||||
DEFAULT_OUTPUT_RETAIN_BYTES + omission_marker.len() + 2
|
||||
);
|
||||
assert!(snapshot.retained_output.starts_with("REMOTE-HEAD\n"));
|
||||
assert!(snapshot.retained_output.contains(&omission_marker));
|
||||
assert!(snapshot.retained_output.ends_with("\nREMOTE-TAIL"));
|
||||
assert!(read_count.load(Ordering::SeqCst) >= 1);
|
||||
|
||||
fixture.abort();
|
||||
@@ -616,6 +766,63 @@ async fn execution_process_output_replays_until_cursor_advances() {
|
||||
assert_eq!(after_cursor.next_sequence, Some(cursor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_process_output_replay_is_isolated_per_process() {
|
||||
let server = ExecutionProcessServer::default();
|
||||
let entry = || ExecutionProcessEntry {
|
||||
handle: None,
|
||||
remote_control: None,
|
||||
output_buffer: None,
|
||||
output_framers: None,
|
||||
output: VecDeque::new(),
|
||||
output_bytes: 0,
|
||||
next_output_sequence: 1,
|
||||
snapshot: None,
|
||||
final_snapshot: None,
|
||||
background: None,
|
||||
};
|
||||
{
|
||||
let mut state = server.inner.lock().expect("execution process state");
|
||||
state.processes.insert("first".to_string(), entry());
|
||||
state.processes.insert("second".to_string(), entry());
|
||||
push_output(
|
||||
state.processes.get_mut("first").expect("first process"),
|
||||
replay_delta("first", 1, "first-output"),
|
||||
);
|
||||
for sequence in 1..=(OUTPUT_EVENT_CAP as u64 + 1) {
|
||||
push_output(
|
||||
state.processes.get_mut("second").expect("second process"),
|
||||
replay_delta("second", sequence, "x"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let first = server
|
||||
.drain_output(LiveExecutionOutputQuery {
|
||||
process_id: Some("first".to_string()),
|
||||
after_sequence: None,
|
||||
limit: None,
|
||||
max_bytes: None,
|
||||
})
|
||||
.expect("first process output");
|
||||
assert_eq!(first.deltas.len(), 1);
|
||||
assert_eq!(first.deltas[0].delta, "first-output");
|
||||
}
|
||||
|
||||
fn replay_delta(process_id: &str, sequence: u64, output: &str) -> ExecutionOutputDelta {
|
||||
ExecutionOutputDelta {
|
||||
process_id: process_id.to_string(),
|
||||
tool_id: format!("tool-{process_id}"),
|
||||
sequence,
|
||||
kind: tool_runtime::execution_process::ExecutionOutputKind::Stdout,
|
||||
delta: output.to_string(),
|
||||
bytes: output.len() as u64,
|
||||
omitted_bytes: 0,
|
||||
truncated: false,
|
||||
raw_bytes: output.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execution_process_server_tracks_registered_live_process() {
|
||||
let server = ExecutionProcessServer::default();
|
||||
@@ -863,6 +1070,25 @@ fn shell_output_command(output: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn interactive_stdin_command() -> Vec<String> {
|
||||
if cfg!(windows) {
|
||||
vec![
|
||||
"cmd.exe".to_string(),
|
||||
"/D".to_string(),
|
||||
"/V:ON".to_string(),
|
||||
"/S".to_string(),
|
||||
"/C".to_string(),
|
||||
"echo|set /p=READY & set /p value= & echo RECEIVED:!value!".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf READY; IFS= read -r value; printf 'RECEIVED:%s' \"$value\"".to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_context_lowers_codex_environment_wire() {
|
||||
let cwd = app_server_protocol::protocol::v2::PathUri::from_host_path(current_directory())
|
||||
|
||||
@@ -506,10 +506,7 @@ fn parse_args_from_with_env(
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if let Some(value) = arg.strip_prefix("--data-dir=") {
|
||||
if value.is_empty() {
|
||||
anyhow::bail!("--data-dir requires a path");
|
||||
}
|
||||
data_dir = Some(PathBuf::from(value));
|
||||
data_dir = Some(validate_data_dir(PathBuf::from(value), "--data-dir")?);
|
||||
data_dir_from_cli = true;
|
||||
continue;
|
||||
}
|
||||
@@ -567,10 +564,13 @@ fn parse_args_from_with_env(
|
||||
);
|
||||
}
|
||||
"--data-dir" => {
|
||||
data_dir =
|
||||
Some(PathBuf::from(args.next().ok_or_else(|| {
|
||||
anyhow::anyhow!("--data-dir requires a path")
|
||||
})?));
|
||||
data_dir = Some(validate_data_dir(
|
||||
PathBuf::from(
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("--data-dir requires a path"))?,
|
||||
),
|
||||
"--data-dir",
|
||||
)?);
|
||||
data_dir_from_cli = true;
|
||||
}
|
||||
"--app-data-dir" => {
|
||||
@@ -595,6 +595,12 @@ fn parse_args_from_with_env(
|
||||
}
|
||||
}
|
||||
|
||||
if !data_dir_from_cli {
|
||||
if let Some(path) = data_dir.take() {
|
||||
data_dir = Some(validate_data_dir(path, APP_SERVER_DATA_DIR_ENV)?);
|
||||
}
|
||||
}
|
||||
|
||||
if model_control_source.is_some() && !data_dir_from_cli {
|
||||
anyhow::bail!("--model-control-source requires an explicit --data-dir");
|
||||
}
|
||||
@@ -612,6 +618,19 @@ fn parse_args_from_with_env(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_data_dir(path: PathBuf, source: &str) -> anyhow::Result<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
anyhow::bail!("{source} requires a path");
|
||||
}
|
||||
if path == Path::new("undefined") || path == Path::new("null") {
|
||||
anyhow::bail!("{source} has an invalid path");
|
||||
}
|
||||
if !path.is_absolute() {
|
||||
anyhow::bail!("{source} must be an absolute path");
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn load_app_policy_source(path: &str) -> anyhow::Result<app_server::CapabilityInventorySource> {
|
||||
let json = std::fs::read_to_string(path)
|
||||
.map_err(|error| anyhow::anyhow!("failed to read app policy {path}: {error}"))?;
|
||||
@@ -806,6 +825,31 @@ mod tests {
|
||||
assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/env-app-server")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_rejects_invalid_data_dir_values() {
|
||||
for args in [
|
||||
vec!["--data-dir", "undefined"],
|
||||
vec!["--data-dir=null"],
|
||||
vec!["--data-dir", "relative/app-server"],
|
||||
] {
|
||||
let error = parse_args_from(args.into_iter().map(str::to_string))
|
||||
.expect_err("invalid data dir must fail closed");
|
||||
assert!(error.to_string().contains("data-dir"));
|
||||
}
|
||||
|
||||
let error = parse_args_from_with_env(Vec::new(), Some(OsString::from("undefined")), None)
|
||||
.expect_err("invalid data dir environment must fail closed");
|
||||
assert!(error.to_string().contains("APP_SERVER_DATA_DIR"));
|
||||
|
||||
let config = parse_args_from_with_env(
|
||||
["--data-dir", "/tmp/cli-app-server"].map(str::to_string),
|
||||
Some(OsString::from("undefined")),
|
||||
None,
|
||||
)
|
||||
.expect("explicit CLI data dir must override invalid environment");
|
||||
assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/cli-app-server")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_rejects_retired_product_db_migration_cleanup_policy() {
|
||||
let error =
|
||||
|
||||
@@ -13,16 +13,26 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tool_runtime::execution_process::{live::LiveExecutionRequest, ExecutionProcessStatus};
|
||||
use tool_runtime::execution_process::{
|
||||
live::{LiveExecutionOutputQuery, LiveExecutionRequest},
|
||||
ExecutionOutputDelta, ExecutionProcessStatus,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const OUTPUT_DRAIN_LIMIT: u16 = 128;
|
||||
const OUTPUT_DRAIN_MAX_BYTES: u64 = 1024 * 1024;
|
||||
const COMMAND_OUTPUT_PREVIEW_CHARS: usize = 1_200;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShellProcessOutcome {
|
||||
output: String,
|
||||
exit_code: Option<i32>,
|
||||
elapsed_ms: u64,
|
||||
output_bytes: u64,
|
||||
output_omitted_bytes: u64,
|
||||
output_truncated: bool,
|
||||
process_status: ExecutionProcessStatus,
|
||||
terminal: ShellTerminal,
|
||||
}
|
||||
|
||||
@@ -222,6 +232,10 @@ impl RuntimeCore {
|
||||
output: error.clone(),
|
||||
exit_code: Some(-1),
|
||||
elapsed_ms: 0,
|
||||
output_bytes: 0,
|
||||
output_omitted_bytes: 0,
|
||||
output_truncated: false,
|
||||
process_status: ExecutionProcessStatus::Failed,
|
||||
terminal: ShellTerminal::Failed,
|
||||
};
|
||||
let mut events = vec![
|
||||
@@ -256,7 +270,17 @@ impl RuntimeCore {
|
||||
)?;
|
||||
|
||||
let outcome = self
|
||||
.poll_shell_process(server, process_id, cancellation_token)
|
||||
.poll_shell_process(
|
||||
session_id,
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
process_id,
|
||||
command,
|
||||
cwd,
|
||||
server,
|
||||
cancellation_token,
|
||||
)
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(outcome) => {
|
||||
@@ -282,6 +306,10 @@ impl RuntimeCore {
|
||||
output: error.clone(),
|
||||
exit_code: Some(-1),
|
||||
elapsed_ms: 0,
|
||||
output_bytes: 0,
|
||||
output_omitted_bytes: 0,
|
||||
output_truncated: false,
|
||||
process_status: ExecutionProcessStatus::Failed,
|
||||
terminal: ShellTerminal::Failed,
|
||||
};
|
||||
let mut events = vec![RuntimeEvent::new(
|
||||
@@ -382,16 +410,47 @@ impl RuntimeCore {
|
||||
|
||||
async fn poll_shell_process(
|
||||
&self,
|
||||
server: crate::execution_process::ExecutionProcessServer,
|
||||
session_id: &str,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
process_id: &str,
|
||||
command: &str,
|
||||
cwd: &Path,
|
||||
server: crate::execution_process::ExecutionProcessServer,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<ShellProcessOutcome, String> {
|
||||
let mut canceled = false;
|
||||
let mut after_sequence = None;
|
||||
loop {
|
||||
if cancellation_token.is_cancelled() && !canceled {
|
||||
canceled = true;
|
||||
let _ = server.terminate(process_id);
|
||||
}
|
||||
let output = server
|
||||
.drain_output(LiveExecutionOutputQuery {
|
||||
process_id: Some(process_id.to_string()),
|
||||
after_sequence,
|
||||
limit: Some(OUTPUT_DRAIN_LIMIT),
|
||||
max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES),
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
after_sequence = output.next_sequence.or(after_sequence);
|
||||
for delta in output.deltas {
|
||||
if delta.delta.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.append_and_publish_shell_events(
|
||||
session_id,
|
||||
thread_id,
|
||||
turn_id,
|
||||
vec![RuntimeEvent::new(
|
||||
"command.output",
|
||||
shell_output_event_payload(item_id, process_id, command, cwd, &delta),
|
||||
)],
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let snapshot = server
|
||||
.status(process_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -419,6 +478,10 @@ impl RuntimeCore {
|
||||
output: snapshot.retained_output,
|
||||
exit_code: snapshot.exit_code,
|
||||
elapsed_ms: snapshot.elapsed_ms,
|
||||
output_bytes: snapshot.output_bytes,
|
||||
output_omitted_bytes: snapshot.output_omitted_bytes,
|
||||
output_truncated: snapshot.output_truncated,
|
||||
process_status: snapshot.status,
|
||||
terminal,
|
||||
});
|
||||
}
|
||||
@@ -484,11 +547,13 @@ fn shell_event_payload(
|
||||
cwd: &Path,
|
||||
outcome: Option<&ShellProcessOutcome>,
|
||||
) -> serde_json::Value {
|
||||
let exit_code = outcome.and_then(|outcome| {
|
||||
outcome
|
||||
.exit_code
|
||||
.or_else(|| (!matches!(outcome.terminal, ShellTerminal::Completed)).then_some(-1))
|
||||
});
|
||||
let output_ref = shell_output_ref(item_id);
|
||||
let process_status = outcome
|
||||
.map(|outcome| outcome.process_status.label())
|
||||
.unwrap_or(ExecutionProcessStatus::Running.label());
|
||||
let status = outcome
|
||||
.map(|outcome| shell_terminal_status(outcome.terminal))
|
||||
.unwrap_or("running");
|
||||
json!({
|
||||
"commandId": item_id,
|
||||
"itemId": item_id,
|
||||
@@ -496,13 +561,79 @@ fn shell_event_payload(
|
||||
"command": command,
|
||||
"cwd": cwd.to_string_lossy(),
|
||||
"output": outcome.map(|outcome| outcome.output.as_str()),
|
||||
"exitCode": exit_code,
|
||||
"outputRef": output_ref.clone(),
|
||||
"refIds": [output_ref],
|
||||
"exitCode": outcome.and_then(|outcome| outcome.exit_code),
|
||||
"status": status,
|
||||
"durationMs": outcome.map(|outcome| outcome.elapsed_ms),
|
||||
"source": "user_shell",
|
||||
"metadata": {
|
||||
"commandExecutionSource": "userShell",
|
||||
"processId": process_id,
|
||||
"executionProcessStatus": process_status,
|
||||
"executionProcessControlStatus": "registered",
|
||||
"executionSurface": "user_shell",
|
||||
"stdinWritable": outcome.is_none(),
|
||||
"outputBytes": outcome.map(|outcome| outcome.output_bytes),
|
||||
"outputOmittedBytes": outcome.map(|outcome| outcome.output_omitted_bytes),
|
||||
"outputTruncated": outcome.map(|outcome| outcome.output_truncated),
|
||||
"durationMs": outcome.map(|outcome| outcome.elapsed_ms),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn shell_output_event_payload(
|
||||
item_id: &str,
|
||||
process_id: &str,
|
||||
command: &str,
|
||||
cwd: &Path,
|
||||
delta: &ExecutionOutputDelta,
|
||||
) -> serde_json::Value {
|
||||
let output_ref = shell_output_ref(item_id);
|
||||
let mut metadata = delta.metadata();
|
||||
metadata.insert("commandExecutionSource".to_string(), json!("userShell"));
|
||||
metadata.insert(
|
||||
"executionProcessControlStatus".to_string(),
|
||||
json!("registered"),
|
||||
);
|
||||
metadata.insert("executionSurface".to_string(), json!("user_shell"));
|
||||
metadata.insert("execution_surface".to_string(), json!("user_shell"));
|
||||
metadata.insert("command".to_string(), json!(command));
|
||||
metadata.insert("cwd".to_string(), json!(cwd.to_string_lossy()));
|
||||
json!({
|
||||
"commandId": item_id,
|
||||
"itemId": item_id,
|
||||
"processId": process_id,
|
||||
"outputRef": output_ref.clone(),
|
||||
"refIds": [output_ref],
|
||||
"kind": delta.kind.label(),
|
||||
"delta": delta.delta,
|
||||
"preview": truncate_chars(&delta.delta, COMMAND_OUTPUT_PREVIEW_CHARS),
|
||||
"source": "user_shell_stream",
|
||||
"metadata": metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn shell_output_ref(item_id: &str) -> String {
|
||||
format!("output:user-shell:{item_id}")
|
||||
}
|
||||
|
||||
fn shell_terminal_status(terminal: ShellTerminal) -> &'static str {
|
||||
match terminal {
|
||||
ShellTerminal::Completed => "completed",
|
||||
ShellTerminal::Failed => "failed",
|
||||
ShellTerminal::Canceled => "canceled",
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
let mut output = String::new();
|
||||
for (index, character) in value.chars().enumerate() {
|
||||
if index >= max_chars {
|
||||
output.push('\u{2026}');
|
||||
break;
|
||||
}
|
||||
output.push(character);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn idle_shell_command_persists_a_standalone_turn_and_output() {
|
||||
"turn.accepted",
|
||||
"turn.started",
|
||||
"command.started",
|
||||
"command.output",
|
||||
"command.exited",
|
||||
"turn.completed",
|
||||
],
|
||||
@@ -113,6 +114,16 @@ async fn idle_shell_command_persists_a_standalone_turn_and_output() {
|
||||
assert_eq!(*exit_code, Some(0));
|
||||
assert_eq!(item.metadata["commandExecutionSource"], json!("userShell"));
|
||||
assert!(item.metadata["processId"].as_str().is_some());
|
||||
let output_event = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "command.output")
|
||||
.expect("shell output delta");
|
||||
assert_eq!(output_event.payload["commandId"], item.item_id.as_str());
|
||||
assert_eq!(output_event.payload["delta"], "shell-loop-ready");
|
||||
assert_eq!(
|
||||
output_event.payload["metadata"]["executionProcessControlStatus"],
|
||||
"registered"
|
||||
);
|
||||
|
||||
let restarted = RuntimeCore::with_backend(Arc::new(MockBackend))
|
||||
.with_execution_process_server(ExecutionProcessServer::default())
|
||||
@@ -180,10 +191,17 @@ async fn active_shell_command_reuses_the_current_turn() {
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("command."))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(command_events.len(), 2);
|
||||
assert_eq!(command_events.len(), 3);
|
||||
assert!(command_events
|
||||
.iter()
|
||||
.all(|event| event.turn_id.as_deref() == Some("turn-shell-active")));
|
||||
assert_eq!(
|
||||
command_events
|
||||
.iter()
|
||||
.map(|event| event.event_type.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["command.started", "command.output", "command.exited"]
|
||||
);
|
||||
let thread = read_full_thread(&core, "thread-shell-active").await;
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
assert_eq!(thread.turns[0].turn_id.as_str(), "turn-shell-active");
|
||||
|
||||
@@ -178,6 +178,19 @@ fn command_string(payload: &Value) -> Option<String> {
|
||||
}
|
||||
|
||||
fn command_exit_item_status(payload: &Value) -> &'static str {
|
||||
if let Some(status) = payload
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(|status| status.to_ascii_lowercase())
|
||||
{
|
||||
match status.as_str() {
|
||||
"canceled" | "cancelled" => return "cancelled",
|
||||
"interrupted" => return "interrupted",
|
||||
"failed" | "error" => return "failed",
|
||||
"completed" | "complete" | "success" | "succeeded" | "passed" => return "completed",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let exit_code = payload
|
||||
.get("exitCode")
|
||||
.or_else(|| payload.get("exit_code"))
|
||||
|
||||
@@ -43,15 +43,19 @@ impl CodingEventMirror {
|
||||
pub(super) fn process_event(&mut self, event: &RuntimeAgentEvent) -> CodingMirrorEvents {
|
||||
match event {
|
||||
RuntimeAgentEvent::ItemStarted { item } => CodingMirrorEvents {
|
||||
after_raw: canonical_tool_item(item)
|
||||
.map(|tool| {
|
||||
self.handle_tool_start(
|
||||
tool.name,
|
||||
tool.call_id,
|
||||
canonical_arguments_value(tool.arguments).as_ref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
after_raw: if let Some(command) = canonical_command_item(item) {
|
||||
self.handle_command_start(item, command)
|
||||
} else {
|
||||
canonical_tool_item(item)
|
||||
.map(|tool| {
|
||||
self.handle_tool_start(
|
||||
tool.name,
|
||||
tool.call_id,
|
||||
canonical_arguments_value(tool.arguments).as_ref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
},
|
||||
..CodingMirrorEvents::default()
|
||||
},
|
||||
RuntimeAgentEvent::ToolOutputDelta {
|
||||
@@ -68,15 +72,29 @@ impl CodingEventMirror {
|
||||
),
|
||||
..CodingMirrorEvents::default()
|
||||
},
|
||||
RuntimeAgentEvent::ItemUpdated { item } => {
|
||||
if let Some(command) = canonical_command_item(item) {
|
||||
CodingMirrorEvents {
|
||||
after_raw: self.handle_command_update(item, command),
|
||||
..CodingMirrorEvents::default()
|
||||
}
|
||||
} else {
|
||||
CodingMirrorEvents::default()
|
||||
}
|
||||
}
|
||||
RuntimeAgentEvent::ItemCompleted { item } => {
|
||||
let mut events = canonical_tool_item(item)
|
||||
.and_then(|tool| {
|
||||
tool.output.map(|output| {
|
||||
let result = canonical_agent_tool_result(item, output);
|
||||
self.handle_tool_end(tool.call_id, &result)
|
||||
let mut events = if let Some(command) = canonical_command_item(item) {
|
||||
self.handle_command_completed(item, command)
|
||||
} else {
|
||||
canonical_tool_item(item)
|
||||
.and_then(|tool| {
|
||||
tool.output.map(|output| {
|
||||
let result = canonical_agent_tool_result(item, output);
|
||||
self.handle_tool_end(tool.call_id, &result)
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if let Some(interaction) = terminal_interaction_event(item) {
|
||||
events.before_raw.push(interaction);
|
||||
}
|
||||
@@ -110,47 +128,14 @@ impl CodingEventMirror {
|
||||
})),
|
||||
));
|
||||
}
|
||||
let test_run_id = if is_shell_tool(normalized_name) {
|
||||
let command = command_facts
|
||||
.as_ref()
|
||||
.map(|facts| facts.command.clone())
|
||||
.unwrap_or_default();
|
||||
events.push(RuntimeEvent::new(
|
||||
"command.started",
|
||||
compact_object(json!({
|
||||
"commandId": tool_id,
|
||||
"toolCallId": tool_id,
|
||||
"toolName": normalized_name,
|
||||
"command": command,
|
||||
"canonicalCommand": command_facts.as_ref().map(|facts| facts.canonical_command.clone()),
|
||||
"commandSummary": command_facts.as_ref().map(|facts| facts.summary.clone()),
|
||||
"commandArgv": command_facts.as_ref().map(|facts| facts.argv.clone()),
|
||||
"commandArgvSource": command_facts.as_ref().map(|facts| facts.source),
|
||||
"cwd": cwd_from_value(arguments_value.as_ref()),
|
||||
"source": "runtime_tool",
|
||||
})),
|
||||
));
|
||||
|
||||
if is_likely_test_command(&command) {
|
||||
let test_run_id = stable_scope_id("test", tool_id);
|
||||
events.push(RuntimeEvent::new(
|
||||
"test.started",
|
||||
compact_object(json!({
|
||||
"testRunId": test_run_id,
|
||||
"commandId": tool_id,
|
||||
"command": command,
|
||||
"canonicalCommand": command_facts.as_ref().map(|facts| facts.canonical_command.clone()),
|
||||
"commandSummary": command_facts.as_ref().map(|facts| facts.summary.clone()),
|
||||
"source": "runtime_tool",
|
||||
})),
|
||||
));
|
||||
Some(test_run_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (shell_events, test_run_id) = self.shell_start_events(
|
||||
tool_id,
|
||||
normalized_name,
|
||||
arguments_value.as_ref(),
|
||||
command_facts.as_ref(),
|
||||
"runtime_tool",
|
||||
);
|
||||
events.extend(shell_events);
|
||||
|
||||
self.tools.insert(
|
||||
tool_id.to_string(),
|
||||
@@ -167,6 +152,147 @@ impl CodingEventMirror {
|
||||
events
|
||||
}
|
||||
|
||||
fn shell_start_events(
|
||||
&self,
|
||||
tool_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: Option<&Value>,
|
||||
command_facts: Option<&CommandFacts>,
|
||||
source: &str,
|
||||
) -> (Vec<RuntimeEvent>, Option<String>) {
|
||||
if !is_shell_tool(tool_name) {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let command = command_facts
|
||||
.map(|facts| facts.command.clone())
|
||||
.or_else(|| command_from_arguments(arguments))
|
||||
.unwrap_or_default();
|
||||
let mut events = vec![RuntimeEvent::new(
|
||||
"command.started",
|
||||
compact_object(json!({
|
||||
"commandId": tool_id,
|
||||
"toolCallId": tool_id,
|
||||
"toolName": tool_name,
|
||||
"command": command,
|
||||
"canonicalCommand": command_facts.map(|facts| facts.canonical_command.clone()),
|
||||
"commandSummary": command_facts.map(|facts| facts.summary.clone()),
|
||||
"commandArgv": command_facts.map(|facts| facts.argv.clone()),
|
||||
"commandArgvSource": command_facts.map(|facts| facts.source),
|
||||
"cwd": cwd_from_value(arguments),
|
||||
"source": source,
|
||||
})),
|
||||
)];
|
||||
|
||||
let test_run_id = if is_likely_test_command(&command) {
|
||||
let test_run_id = stable_scope_id("test", tool_id);
|
||||
events.push(RuntimeEvent::new(
|
||||
"test.started",
|
||||
compact_object(json!({
|
||||
"testRunId": test_run_id,
|
||||
"commandId": tool_id,
|
||||
"command": command,
|
||||
"canonicalCommand": command_facts.map(|facts| facts.canonical_command.clone()),
|
||||
"commandSummary": command_facts.map(|facts| facts.summary.clone()),
|
||||
"source": source,
|
||||
})),
|
||||
));
|
||||
Some(test_run_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(events, test_run_id)
|
||||
}
|
||||
|
||||
fn handle_command_start(
|
||||
&mut self,
|
||||
item: &ThreadItem,
|
||||
command: CanonicalCommandItem<'_>,
|
||||
) -> Vec<RuntimeEvent> {
|
||||
let command_id = command_item_id(item);
|
||||
let arguments = command_item_arguments(command);
|
||||
let command_facts = command_facts_from_text(command.command);
|
||||
let (events, test_run_id) = self.shell_start_events(
|
||||
&command_id,
|
||||
"exec_command",
|
||||
Some(&arguments),
|
||||
command_facts.as_ref(),
|
||||
"runtime_unified_exec",
|
||||
);
|
||||
self.tools.insert(
|
||||
command_id,
|
||||
TrackedTool {
|
||||
name: "exec_command".to_string(),
|
||||
arguments: Some(arguments),
|
||||
command_facts,
|
||||
test_run_id,
|
||||
patch_id: None,
|
||||
emitted_output: false,
|
||||
},
|
||||
);
|
||||
events
|
||||
}
|
||||
|
||||
fn handle_command_update(
|
||||
&mut self,
|
||||
item: &ThreadItem,
|
||||
command: CanonicalCommandItem<'_>,
|
||||
) -> Vec<RuntimeEvent> {
|
||||
let command_id = command_item_id(item);
|
||||
if !self.tools.contains_key(&command_id) {
|
||||
let mut events = self.handle_command_start(item, command);
|
||||
events.extend(self.handle_command_output(item, command));
|
||||
return events;
|
||||
}
|
||||
self.handle_command_output(item, command)
|
||||
}
|
||||
|
||||
fn handle_command_output(
|
||||
&mut self,
|
||||
item: &ThreadItem,
|
||||
command: CanonicalCommandItem<'_>,
|
||||
) -> Vec<RuntimeEvent> {
|
||||
let command_id = command_item_id(item);
|
||||
let metadata = command_item_metadata(item);
|
||||
let output_kind = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata_string(metadata, &["outputKind", "output_kind"]));
|
||||
self.handle_tool_output_delta(
|
||||
&command_id,
|
||||
command.output.unwrap_or_default(),
|
||||
output_kind.as_deref(),
|
||||
metadata.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_command_completed(
|
||||
&mut self,
|
||||
item: &ThreadItem,
|
||||
command: CanonicalCommandItem<'_>,
|
||||
) -> CodingMirrorEvents {
|
||||
let command_id = command_item_id(item);
|
||||
let streamed_output = self
|
||||
.tools
|
||||
.get(&command_id)
|
||||
.is_some_and(|tool| tool.emitted_output);
|
||||
let mut after_raw = if streamed_output {
|
||||
Vec::new()
|
||||
} else if self.tools.contains_key(&command_id) {
|
||||
self.handle_command_output(item, command)
|
||||
} else {
|
||||
let mut events = self.handle_command_start(item, command);
|
||||
events.extend(self.handle_command_output(item, command));
|
||||
events
|
||||
};
|
||||
let result = command_item_result(item, command);
|
||||
let terminal = self.handle_tool_end(&command_id, &result);
|
||||
let before_raw = terminal.before_raw;
|
||||
after_raw.extend(terminal.after_raw);
|
||||
CodingMirrorEvents {
|
||||
before_raw,
|
||||
after_raw,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool_output_delta(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
@@ -211,7 +337,9 @@ impl CodingEventMirror {
|
||||
return CodingMirrorEvents::default();
|
||||
};
|
||||
let mut after_raw = match tool.name.as_str() {
|
||||
"Bash" | "PowerShell" => self.shell_tool_end_events(tool_id, &tool, result),
|
||||
"Bash" | "PowerShell" | "exec_command" => {
|
||||
self.shell_tool_end_events(tool_id, &tool, result)
|
||||
}
|
||||
"Read" => file_read_tool_end_events(tool_id, &tool, result),
|
||||
"Write" | "Edit" | "apply_patch" => file_tool_end_events(tool_id, &tool, result),
|
||||
_ => Vec::new(),
|
||||
@@ -242,7 +370,7 @@ impl CodingEventMirror {
|
||||
let command =
|
||||
command_text.or_else(|| command_facts.as_ref().map(|facts| facts.command.clone()));
|
||||
let exit_code = metadata.and_then(|metadata| metadata_i64(metadata, &["exit_code"]));
|
||||
let status = command_status(exit_code, result.success);
|
||||
let status = command_status(exit_code, result.success, metadata);
|
||||
let process_metadata = shell_process_lifecycle_metadata(tool_id, metadata, status);
|
||||
let mut events = Vec::new();
|
||||
|
||||
@@ -324,7 +452,7 @@ fn terminal_interaction_event(item: &ThreadItem) -> Option<RuntimeEvent> {
|
||||
.as_str()
|
||||
.filter(|value| terminal_interaction_summary_is_safe(value))?;
|
||||
let command_id = match &item.payload {
|
||||
ThreadItemPayload::Command { .. } => item.item_id.as_str().to_string(),
|
||||
ThreadItemPayload::Command { .. } => command_item_id(item),
|
||||
ThreadItemPayload::Tool { .. } => {
|
||||
ItemId::new(metadata.get("exec_command_call_id")?.as_str()?.to_string())
|
||||
.as_str()
|
||||
@@ -361,6 +489,14 @@ struct CanonicalToolItem<'a> {
|
||||
output: Option<&'a ToolOutput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CanonicalCommandItem<'a> {
|
||||
command: &'a str,
|
||||
cwd: Option<&'a str>,
|
||||
output: Option<&'a str>,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
fn canonical_tool_item(item: &ThreadItem) -> Option<CanonicalToolItem<'_>> {
|
||||
let ThreadItemPayload::Tool {
|
||||
call_id,
|
||||
@@ -379,6 +515,94 @@ fn canonical_tool_item(item: &ThreadItem) -> Option<CanonicalToolItem<'_>> {
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_command_item(item: &ThreadItem) -> Option<CanonicalCommandItem<'_>> {
|
||||
let ThreadItemPayload::Command {
|
||||
command,
|
||||
cwd,
|
||||
output,
|
||||
exit_code,
|
||||
} = &item.payload
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(CanonicalCommandItem {
|
||||
command,
|
||||
cwd: cwd.as_deref(),
|
||||
output: output.as_deref(),
|
||||
exit_code: *exit_code,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_item_id(item: &ThreadItem) -> String {
|
||||
item.metadata
|
||||
.as_object()
|
||||
.and_then(|metadata| metadata.get("exec_command_call_id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item.item_id
|
||||
.as_str()
|
||||
.strip_prefix("item_")
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| item.item_id.as_str().to_string())
|
||||
}
|
||||
|
||||
fn command_item_arguments(command: CanonicalCommandItem<'_>) -> Value {
|
||||
let mut arguments = serde_json::Map::new();
|
||||
arguments.insert(
|
||||
"cmd".to_string(),
|
||||
Value::String(command.command.to_string()),
|
||||
);
|
||||
if let Some(cwd) = command.cwd {
|
||||
arguments.insert("workdir".to_string(), Value::String(cwd.to_string()));
|
||||
}
|
||||
Value::Object(arguments)
|
||||
}
|
||||
|
||||
fn command_item_metadata(item: &ThreadItem) -> Option<HashMap<String, Value>> {
|
||||
item.metadata.as_object().map(|metadata| {
|
||||
metadata
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn command_item_result(item: &ThreadItem, command: CanonicalCommandItem<'_>) -> AgentToolResult {
|
||||
let mut metadata = command_item_metadata(item).unwrap_or_default();
|
||||
metadata
|
||||
.entry("command".to_string())
|
||||
.or_insert_with(|| Value::String(command.command.to_string()));
|
||||
if let Some(cwd) = command.cwd {
|
||||
metadata
|
||||
.entry("cwd".to_string())
|
||||
.or_insert_with(|| Value::String(cwd.to_string()));
|
||||
}
|
||||
if let Some(exit_code) = command.exit_code {
|
||||
metadata
|
||||
.entry("exit_code".to_string())
|
||||
.or_insert_with(|| Value::from(exit_code));
|
||||
}
|
||||
if matches!(item.status, ItemStatus::Cancelled | ItemStatus::Interrupted) {
|
||||
metadata
|
||||
.entry("itemStatus".to_string())
|
||||
.or_insert_with(|| Value::String("canceled".to_string()));
|
||||
metadata
|
||||
.entry("executionProcessStatus".to_string())
|
||||
.or_insert_with(|| Value::String("terminated".to_string()));
|
||||
}
|
||||
AgentToolResult {
|
||||
success: item.status == ItemStatus::Completed,
|
||||
output: command.output.unwrap_or_default().to_string(),
|
||||
error: None,
|
||||
structured_content: None,
|
||||
images: None,
|
||||
metadata: (!metadata.is_empty()).then_some(metadata),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_arguments_value(arguments: &[ToolArgument]) -> Option<Value> {
|
||||
if arguments.is_empty() {
|
||||
return None;
|
||||
@@ -696,7 +920,7 @@ fn lookup_key(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn is_shell_tool(tool_name: &str) -> bool {
|
||||
matches!(tool_name, "Bash" | "PowerShell")
|
||||
matches!(tool_name, "Bash" | "PowerShell" | "exec_command")
|
||||
}
|
||||
|
||||
fn command_from_arguments(arguments: Option<&Value>) -> Option<String> {
|
||||
@@ -824,7 +1048,14 @@ fn value_bool(value: &Value) -> Option<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
fn command_status(exit_code: Option<i64>, success: bool) -> &'static str {
|
||||
fn command_status(
|
||||
exit_code: Option<i64>,
|
||||
success: bool,
|
||||
metadata: Option<&HashMap<String, Value>>,
|
||||
) -> &'static str {
|
||||
if metadata_indicates_cancellation(metadata) {
|
||||
return "canceled";
|
||||
}
|
||||
match exit_code {
|
||||
Some(0) => "passed",
|
||||
Some(_) => "failed",
|
||||
@@ -833,6 +1064,34 @@ fn command_status(exit_code: Option<i64>, success: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_indicates_cancellation(metadata: Option<&HashMap<String, Value>>) -> bool {
|
||||
if metadata
|
||||
.and_then(|metadata| metadata_string(metadata, &["tool_outcome", "toolOutcome"]))
|
||||
.is_some_and(|outcome| outcome.eq_ignore_ascii_case("aborted"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
metadata
|
||||
.and_then(|metadata| {
|
||||
metadata_string(
|
||||
metadata,
|
||||
&[
|
||||
"itemStatus",
|
||||
"item_status",
|
||||
"executionProcessStatus",
|
||||
"execution_process_status",
|
||||
"status",
|
||||
],
|
||||
)
|
||||
})
|
||||
.is_some_and(|status| {
|
||||
matches!(
|
||||
status.to_ascii_lowercase().as_str(),
|
||||
"canceled" | "cancelled" | "interrupted" | "terminated"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_likely_test_command(command: &str) -> bool {
|
||||
let normalized = command.to_ascii_lowercase();
|
||||
[
|
||||
|
||||
@@ -107,6 +107,223 @@ fn canonical_test_arguments(arguments: Value) -> Vec<ToolArgument> {
|
||||
}
|
||||
}
|
||||
|
||||
fn command_item_event(
|
||||
call_id: &str,
|
||||
command: &str,
|
||||
status: ItemStatus,
|
||||
output: Option<&str>,
|
||||
exit_code: Option<i32>,
|
||||
metadata: Value,
|
||||
) -> RuntimeAgentEvent {
|
||||
let payload = ThreadItemPayload::Command {
|
||||
command: command.to_string(),
|
||||
cwd: Some("/workspace".to_string()),
|
||||
output: output.map(str::to_string),
|
||||
exit_code,
|
||||
};
|
||||
let item = ThreadItem {
|
||||
session_id: SessionId::new("session-test"),
|
||||
thread_id: ThreadId::new("thread-test"),
|
||||
turn_id: TurnId::new("turn-test"),
|
||||
item_id: ItemId::new(call_id),
|
||||
sequence: 1,
|
||||
ordinal: 1,
|
||||
created_at_ms: 1,
|
||||
updated_at_ms: 2,
|
||||
completed_at_ms: status.is_terminal().then_some(2),
|
||||
kind: payload.kind(),
|
||||
status,
|
||||
payload,
|
||||
metadata,
|
||||
};
|
||||
if status.is_terminal() {
|
||||
RuntimeAgentEvent::ItemCompleted { item }
|
||||
} else {
|
||||
RuntimeAgentEvent::ItemUpdated { item }
|
||||
}
|
||||
}
|
||||
|
||||
fn command_item_started(call_id: &str, command: &str) -> RuntimeAgentEvent {
|
||||
let payload = ThreadItemPayload::Command {
|
||||
command: command.to_string(),
|
||||
cwd: Some("/workspace".to_string()),
|
||||
output: None,
|
||||
exit_code: None,
|
||||
};
|
||||
RuntimeAgentEvent::ItemStarted {
|
||||
item: ThreadItem {
|
||||
session_id: SessionId::new("session-test"),
|
||||
thread_id: ThreadId::new("thread-test"),
|
||||
turn_id: TurnId::new("turn-test"),
|
||||
item_id: ItemId::new(call_id),
|
||||
sequence: 1,
|
||||
ordinal: 1,
|
||||
created_at_ms: 1,
|
||||
updated_at_ms: 2,
|
||||
completed_at_ms: None,
|
||||
kind: payload.kind(),
|
||||
status: ItemStatus::InProgress,
|
||||
payload,
|
||||
metadata: json!({}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_exec_command_items_emit_command_and_test_lifecycle() {
|
||||
let mut mirror = CodingEventMirror::default();
|
||||
let command = "cargo test -p app-server coding_events";
|
||||
|
||||
let started = mirror.process_event(&command_item_started("exec-call", command));
|
||||
let updated = mirror.process_event(&command_item_event(
|
||||
"exec-call",
|
||||
command,
|
||||
ItemStatus::InProgress,
|
||||
Some("running tests\n"),
|
||||
None,
|
||||
json!({
|
||||
"exec_command_call_id": "exec-call",
|
||||
"processId": "unified-exec-1",
|
||||
"executionProcessStatus": "running",
|
||||
"executionSurface": "unified_exec",
|
||||
"outputBytes": 14,
|
||||
"outputOmittedBytes": 0,
|
||||
"outputTruncated": false,
|
||||
"stdinWritable": true
|
||||
}),
|
||||
));
|
||||
let completed = mirror.process_event(&command_item_event(
|
||||
"exec-call",
|
||||
command,
|
||||
ItemStatus::Completed,
|
||||
Some("all tests passed\n"),
|
||||
Some(0),
|
||||
json!({
|
||||
"exec_command_call_id": "exec-call",
|
||||
"processId": "unified-exec-1",
|
||||
"executionProcessStatus": "exited",
|
||||
"executionSurface": "unified_exec",
|
||||
"outputBytes": 31,
|
||||
"outputOmittedBytes": 0,
|
||||
"outputTruncated": false,
|
||||
"stdinWritable": false,
|
||||
"exit_code": 0
|
||||
}),
|
||||
));
|
||||
|
||||
let event_types = started
|
||||
.after_raw
|
||||
.iter()
|
||||
.chain(updated.after_raw.iter())
|
||||
.chain(completed.after_raw.iter())
|
||||
.map(|event| event.event_type.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
event_types,
|
||||
vec![
|
||||
"command.started",
|
||||
"test.started",
|
||||
"command.output",
|
||||
"command.exited",
|
||||
"test.completed"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
completed
|
||||
.after_raw
|
||||
.iter()
|
||||
.find(|event| event.event_type == "command.exited")
|
||||
.expect("command.exited")
|
||||
.payload["processId"],
|
||||
"unified-exec-1"
|
||||
);
|
||||
for event in started
|
||||
.after_raw
|
||||
.iter()
|
||||
.chain(updated.after_raw.iter())
|
||||
.chain(completed.after_raw.iter())
|
||||
.filter(|event| event.event_type.starts_with("command."))
|
||||
{
|
||||
assert_eq!(event.payload["commandId"], "exec-call");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_command_item_without_started_event_reconstructs_lifecycle() {
|
||||
let mut mirror = CodingEventMirror::default();
|
||||
let completed = mirror.process_event(&command_item_event(
|
||||
"exec-call-cold",
|
||||
"printf done",
|
||||
ItemStatus::Completed,
|
||||
Some("done\n"),
|
||||
Some(0),
|
||||
json!({
|
||||
"exec_command_call_id": "exec-call-cold",
|
||||
"processId": "unified-exec-cold",
|
||||
"executionProcessStatus": "exited",
|
||||
"executionSurface": "unified_exec",
|
||||
"outputBytes": 5,
|
||||
"outputOmittedBytes": 0,
|
||||
"outputTruncated": false,
|
||||
"stdinWritable": false
|
||||
}),
|
||||
));
|
||||
|
||||
let event_types = completed
|
||||
.after_raw
|
||||
.iter()
|
||||
.map(|event| event.event_type.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
event_types,
|
||||
vec!["command.started", "command.output", "command.exited"]
|
||||
);
|
||||
assert!(completed
|
||||
.after_raw
|
||||
.iter()
|
||||
.all(|event| event.payload["commandId"] == "exec-call-cold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_command_item_projects_canceled_test_result() {
|
||||
let mut mirror = CodingEventMirror::default();
|
||||
let _ = mirror.process_event(&command_item_started(
|
||||
"exec-call-canceled",
|
||||
"cargo test -p app-server",
|
||||
));
|
||||
let canceled = mirror.process_event(&command_item_event(
|
||||
"exec-call-canceled",
|
||||
"cargo test -p app-server",
|
||||
ItemStatus::Cancelled,
|
||||
Some("interrupted\n"),
|
||||
None,
|
||||
json!({
|
||||
"exec_command_call_id": "exec-call-canceled",
|
||||
"processId": "unified-exec-canceled",
|
||||
"executionProcessStatus": "terminated",
|
||||
"executionSurface": "unified_exec",
|
||||
"tool_outcome": "aborted",
|
||||
"stdinWritable": false
|
||||
}),
|
||||
));
|
||||
|
||||
let exited = canceled
|
||||
.after_raw
|
||||
.iter()
|
||||
.find(|event| event.event_type == "command.exited")
|
||||
.expect("command.exited");
|
||||
assert_eq!(exited.payload["status"], "canceled");
|
||||
assert_eq!(
|
||||
canceled
|
||||
.after_raw
|
||||
.iter()
|
||||
.find(|event| event.event_type == "test.completed")
|
||||
.expect("test.completed")
|
||||
.payload["result"],
|
||||
"canceled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_events_emit_command_and_test_lifecycle() {
|
||||
let mut mirror = CodingEventMirror::default();
|
||||
@@ -235,7 +452,7 @@ fn canonical_command_completion_emits_redacted_terminal_interaction_before_raw_i
|
||||
assert_eq!(output.before_raw.len(), 1);
|
||||
assert!(output.after_raw.is_empty());
|
||||
assert_eq!(output.before_raw[0].event_type, "command.interaction");
|
||||
assert_eq!(output.before_raw[0].payload["commandId"], "item_exec-call");
|
||||
assert_eq!(output.before_raw[0].payload["commandId"], "exec-call");
|
||||
assert_eq!(output.before_raw[0].payload["processId"], "process-7");
|
||||
assert_eq!(output.before_raw[0].payload["stdin"], "sent 9 chars");
|
||||
assert!(!output.before_raw[0]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use app_server_client::{ClientSession, SessionEvent, StdioTransportConfig};
|
||||
use app_server_client::{AppServerEvent, ClientSession, StdioTransportConfig};
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecOutputStream, CommandExecParams, CommandExecResponse, CommandExecWriteParams,
|
||||
ServerNotification, ServerRequest, METHOD_COMMAND_EXEC, METHOD_COMMAND_EXEC_WRITE,
|
||||
@@ -208,7 +208,7 @@ async fn run_with_inherited_stdio(
|
||||
return response.context("App Server command/exec failed");
|
||||
}
|
||||
event = session.next_event() => match event {
|
||||
Some(SessionEvent::Notification(notification)) => {
|
||||
Some(AppServerEvent::ServerNotification(notification)) => {
|
||||
if let ServerNotification::CommandExecOutputDelta(delta) = *notification {
|
||||
if delta.process_id != process_id {
|
||||
continue;
|
||||
@@ -226,25 +226,13 @@ async fn run_with_inherited_stdio(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(SessionEvent::ServerRequest(request)) => {
|
||||
Some(AppServerEvent::ServerRequest(request)) => {
|
||||
reject_unexpected_server_request(&request_handle, &request).await?;
|
||||
}
|
||||
Some(SessionEvent::RawServerRequest(request)) => {
|
||||
request_handle
|
||||
.reject(
|
||||
request.id,
|
||||
JsonRpcError::new(
|
||||
error_codes::METHOD_NOT_FOUND,
|
||||
"lime sandbox does not support server requests",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("failed to reject unexpected App Server request")?;
|
||||
}
|
||||
Some(SessionEvent::Disconnected { message }) => {
|
||||
Some(AppServerEvent::Disconnected { message }) => {
|
||||
bail!("App Server disconnected during sandbox execution: {message}");
|
||||
}
|
||||
Some(SessionEvent::RawNotification(_)) => {}
|
||||
Some(AppServerEvent::Lagged { .. }) => {}
|
||||
None => bail!("App Server event stream closed during sandbox execution"),
|
||||
},
|
||||
read = stdin.read(&mut input), if stdin_open => {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
#[cfg(windows)]
|
||||
use app_server_client::SessionEvent;
|
||||
use app_server_client::AppServerEvent;
|
||||
#[cfg(windows)]
|
||||
use app_server_protocol::protocol::v2::{
|
||||
ServerNotification, WindowsSandboxSetupMode, WindowsSandboxSetupStartParams,
|
||||
@@ -129,7 +129,7 @@ async fn run_elevated(
|
||||
Ok(response) if response.started => tokio::time::timeout(Duration::from_secs(130), async {
|
||||
loop {
|
||||
match session.next_event().await {
|
||||
Some(SessionEvent::Notification(notification)) => match *notification {
|
||||
Some(AppServerEvent::ServerNotification(notification)) => match *notification {
|
||||
ServerNotification::WindowsSandboxSetupCompleted(completed)
|
||||
if completed.mode == mode =>
|
||||
{
|
||||
@@ -137,7 +137,7 @@ async fn run_elevated(
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Some(SessionEvent::Disconnected { message }) => {
|
||||
Some(AppServerEvent::Disconnected { message }) => {
|
||||
break Err(anyhow::anyhow!(
|
||||
"App Server disconnected during Windows sandbox setup: {message}"
|
||||
));
|
||||
|
||||
@@ -4,18 +4,19 @@ use crate::sandbox::{
|
||||
use app_server_protocol::protocol::v2::GrantedPermissionProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{mpsc, oneshot, watch, Mutex};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
mod environment;
|
||||
pub mod live;
|
||||
mod output_buffer;
|
||||
mod pty;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
@@ -107,9 +108,11 @@ pub fn audit_windows_world_writable(
|
||||
}
|
||||
|
||||
use environment::resolve_child_environment;
|
||||
pub use output_buffer::{BoundedProcessOutput, BoundedProcessOutputSnapshot, ProcessOutputFramers};
|
||||
|
||||
const DEFAULT_OUTPUT_RETAIN_BYTES: usize = 128 * 1024;
|
||||
pub const DEFAULT_OUTPUT_RETAIN_BYTES: usize = 1024 * 1024;
|
||||
const PROCESS_OUTPUT_CHUNK_BYTES: usize = 8 * 1024;
|
||||
const PROCESS_OUTPUT_CHANNEL_CAPACITY: usize = 64;
|
||||
pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -285,6 +288,7 @@ pub struct ExecutionProcess {
|
||||
failure: Option<String>,
|
||||
started_at: Instant,
|
||||
output: BoundedProcessOutput,
|
||||
output_framers: ProcessOutputFramers,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
@@ -301,6 +305,7 @@ impl ExecutionProcess {
|
||||
failure: None,
|
||||
started_at: Instant::now(),
|
||||
output: BoundedProcessOutput::new(DEFAULT_OUTPUT_RETAIN_BYTES),
|
||||
output_framers: ProcessOutputFramers::default(),
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
@@ -321,21 +326,46 @@ impl ExecutionProcess {
|
||||
&mut self,
|
||||
kind: ExecutionOutputKind,
|
||||
bytes: &[u8],
|
||||
) -> ExecutionOutputDelta {
|
||||
self.sequence = self.sequence.saturating_add(1);
|
||||
) -> Option<ExecutionOutputDelta> {
|
||||
self.output.push(bytes);
|
||||
let frame = self.output_framers.push(kind, bytes);
|
||||
self.output_delta(kind, frame)
|
||||
}
|
||||
|
||||
pub fn finish_output(&mut self, kind: ExecutionOutputKind) -> Option<ExecutionOutputDelta> {
|
||||
let frame = self.output_framers.finish(kind);
|
||||
self.output_delta(kind, frame)
|
||||
}
|
||||
|
||||
pub fn finish_all_output(&mut self) -> Vec<ExecutionOutputDelta> {
|
||||
let frames = self.output_framers.finish_all();
|
||||
frames
|
||||
.into_iter()
|
||||
.filter_map(|(kind, frame)| self.output_delta(kind, frame))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn output_delta(
|
||||
&mut self,
|
||||
kind: ExecutionOutputKind,
|
||||
frame: Vec<u8>,
|
||||
) -> Option<ExecutionOutputDelta> {
|
||||
if frame.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.sequence = self.sequence.saturating_add(1);
|
||||
let snapshot = self.output.snapshot();
|
||||
ExecutionOutputDelta {
|
||||
Some(ExecutionOutputDelta {
|
||||
process_id: self.process_id.clone(),
|
||||
tool_id: self.tool_id.clone(),
|
||||
sequence: self.sequence,
|
||||
kind,
|
||||
delta: String::from_utf8_lossy(bytes).to_string(),
|
||||
delta: String::from_utf8_lossy(&frame).into_owned(),
|
||||
bytes: snapshot.bytes,
|
||||
omitted_bytes: snapshot.omitted_bytes,
|
||||
truncated: snapshot.truncated,
|
||||
raw_bytes: bytes.to_vec(),
|
||||
}
|
||||
raw_bytes: frame,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn interrupt(&mut self) {
|
||||
@@ -404,7 +434,7 @@ impl ExecutionProcessManager {
|
||||
) -> Option<ExecutionOutputDelta> {
|
||||
self.processes
|
||||
.get_mut(process_id)
|
||||
.map(|process| process.append_output(kind, bytes))
|
||||
.and_then(|process| process.append_output(kind, bytes))
|
||||
}
|
||||
|
||||
pub fn interrupt(&mut self, process_id: &str) -> Option<ExecutionProcessSnapshot> {
|
||||
@@ -518,7 +548,7 @@ pub trait LiveExecutionProcessRegistry: Send + Sync {
|
||||
pub struct LocalExecutionProcessHandle {
|
||||
process_id: String,
|
||||
control_tx: LocalExecutionControlSender,
|
||||
output_rx: mpsc::UnboundedReceiver<ExecutionOutputDelta>,
|
||||
output_rx: broadcast::Receiver<ExecutionOutputDelta>,
|
||||
state_rx: watch::Receiver<ExecutionProcessSnapshot>,
|
||||
final_rx: Option<oneshot::Receiver<ExecutionProcessSnapshot>>,
|
||||
final_snapshot: Option<ExecutionProcessSnapshot>,
|
||||
@@ -555,36 +585,55 @@ impl LocalExecutionProcessHandle {
|
||||
}
|
||||
|
||||
pub async fn recv_output(&mut self) -> Option<ExecutionOutputDelta> {
|
||||
self.output_rx.recv().await
|
||||
loop {
|
||||
match self.output_rx.recv().await {
|
||||
Ok(delta) => return Some(delta),
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<LocalExecutionProcessEvent> {
|
||||
if let Ok(delta) = self.output_rx.try_recv() {
|
||||
return Some(LocalExecutionProcessEvent::Output(delta));
|
||||
}
|
||||
if self.final_snapshot.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut final_rx = self.final_rx.take()?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
delta = self.output_rx.recv() => {
|
||||
match delta {
|
||||
Some(delta) => {
|
||||
self.final_rx = Some(final_rx);
|
||||
return Some(LocalExecutionProcessEvent::Output(delta));
|
||||
}
|
||||
None => {
|
||||
let snapshot = final_rx.await.ok()?;
|
||||
self.final_snapshot = Some(snapshot.clone());
|
||||
return Some(LocalExecutionProcessEvent::Exited(snapshot));
|
||||
}
|
||||
loop {
|
||||
loop {
|
||||
match self.output_rx.try_recv() {
|
||||
Ok(delta) => return Some(LocalExecutionProcessEvent::Output(delta)),
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(
|
||||
broadcast::error::TryRecvError::Empty
|
||||
| broadcast::error::TryRecvError::Closed,
|
||||
) => break,
|
||||
}
|
||||
}
|
||||
result = &mut final_rx => {
|
||||
let snapshot = result.ok()?;
|
||||
self.final_snapshot = Some(snapshot.clone());
|
||||
return Some(LocalExecutionProcessEvent::Exited(snapshot));
|
||||
if self.final_snapshot.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut final_rx = self.final_rx.take()?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
delta = self.output_rx.recv() => {
|
||||
match delta {
|
||||
Ok(delta) => {
|
||||
self.final_rx = Some(final_rx);
|
||||
return Some(LocalExecutionProcessEvent::Output(delta));
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
self.final_rx = Some(final_rx);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
let snapshot = final_rx.await.ok()?;
|
||||
self.final_snapshot = Some(snapshot.clone());
|
||||
return Some(LocalExecutionProcessEvent::Exited(snapshot));
|
||||
}
|
||||
}
|
||||
}
|
||||
result = &mut final_rx => {
|
||||
let snapshot = result.ok()?;
|
||||
self.final_snapshot = Some(snapshot.clone());
|
||||
return Some(LocalExecutionProcessEvent::Exited(snapshot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,7 +836,7 @@ fn start_local_execution_process_with_inherited_environment(
|
||||
let process_state = ExecutionProcess::start(start);
|
||||
let initial_snapshot = process_state.snapshot();
|
||||
let process = Arc::new(Mutex::new(process_state));
|
||||
let (output_tx, output_rx) = mpsc::unbounded_channel();
|
||||
let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY);
|
||||
let (control_tx, control_rx) = mpsc::unbounded_channel();
|
||||
let (state_tx, state_rx) = watch::channel(initial_snapshot);
|
||||
let (final_tx, final_rx) = oneshot::channel();
|
||||
@@ -813,7 +862,7 @@ async fn supervise_local_process(
|
||||
stdout: Option<impl AsyncRead + Unpin + Send + 'static>,
|
||||
stderr: Option<impl AsyncRead + Unpin + Send + 'static>,
|
||||
process: Arc<Mutex<ExecutionProcess>>,
|
||||
output_tx: mpsc::UnboundedSender<ExecutionOutputDelta>,
|
||||
output_tx: broadcast::Sender<ExecutionOutputDelta>,
|
||||
state_tx: watch::Sender<ExecutionProcessSnapshot>,
|
||||
final_tx: oneshot::Sender<ExecutionProcessSnapshot>,
|
||||
mut control_rx: mpsc::UnboundedReceiver<LocalExecutionControl>,
|
||||
@@ -833,7 +882,7 @@ async fn supervise_local_process(
|
||||
reader,
|
||||
ExecutionOutputKind::Stderr,
|
||||
Arc::clone(&process),
|
||||
output_tx,
|
||||
output_tx.clone(),
|
||||
state_tx.clone(),
|
||||
))
|
||||
});
|
||||
@@ -886,7 +935,13 @@ async fn supervise_local_process(
|
||||
|
||||
join_output_tasks_with_grace(stdout_task, stderr_task).await;
|
||||
|
||||
let final_snapshot = process.lock().await.snapshot();
|
||||
let (final_deltas, final_snapshot) = {
|
||||
let mut guard = process.lock().await;
|
||||
(guard.finish_all_output(), guard.snapshot())
|
||||
};
|
||||
for delta in final_deltas {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
let _ = state_tx.send(final_snapshot.clone());
|
||||
let _ = final_tx.send(final_snapshot);
|
||||
}
|
||||
@@ -946,7 +1001,7 @@ async fn read_process_stream<R>(
|
||||
mut reader: R,
|
||||
kind: ExecutionOutputKind,
|
||||
process: Arc<Mutex<ExecutionProcess>>,
|
||||
output_tx: mpsc::UnboundedSender<ExecutionOutputDelta>,
|
||||
output_tx: broadcast::Sender<ExecutionOutputDelta>,
|
||||
state_tx: watch::Sender<ExecutionProcessSnapshot>,
|
||||
) where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
@@ -964,86 +1019,15 @@ async fn read_process_stream<R>(
|
||||
let snapshot = guard.snapshot();
|
||||
(delta, snapshot)
|
||||
};
|
||||
let _ = output_tx.send(delta);
|
||||
if let Some(delta) = delta {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
let _ = state_tx.send(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BoundedProcessOutput {
|
||||
retain_bytes: usize,
|
||||
bytes: u64,
|
||||
retained_bytes: usize,
|
||||
omitted_bytes: u64,
|
||||
chunks: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl BoundedProcessOutput {
|
||||
fn new(retain_bytes: usize) -> Self {
|
||||
Self {
|
||||
retain_bytes,
|
||||
bytes: 0,
|
||||
retained_bytes: 0,
|
||||
omitted_bytes: 0,
|
||||
chunks: VecDeque::new(),
|
||||
}
|
||||
let final_delta = process.lock().await.finish_output(kind);
|
||||
if let Some(delta) = final_delta {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
|
||||
fn push(&mut self, bytes: &[u8]) {
|
||||
self.bytes = self.bytes.saturating_add(bytes.len() as u64);
|
||||
if self.retain_bytes == 0 {
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(bytes.len() as u64);
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = if bytes.len() > self.retain_bytes {
|
||||
let omitted = bytes.len() - self.retain_bytes;
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(omitted as u64);
|
||||
bytes[omitted..].to_vec()
|
||||
} else {
|
||||
bytes.to_vec()
|
||||
};
|
||||
self.retained_bytes += chunk.len();
|
||||
self.chunks.push_back(chunk);
|
||||
|
||||
while self.retained_bytes > self.retain_bytes {
|
||||
let overflow = self.retained_bytes - self.retain_bytes;
|
||||
let Some(front) = self.chunks.front_mut() else {
|
||||
break;
|
||||
};
|
||||
if front.len() <= overflow {
|
||||
let removed = self.chunks.pop_front().unwrap_or_default();
|
||||
self.retained_bytes -= removed.len();
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(removed.len() as u64);
|
||||
} else {
|
||||
front.drain(..overflow);
|
||||
self.retained_bytes -= overflow;
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(overflow as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> BoundedProcessOutputSnapshot {
|
||||
let retained = self
|
||||
.chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
BoundedProcessOutputSnapshot {
|
||||
bytes: self.bytes,
|
||||
omitted_bytes: self.omitted_bytes,
|
||||
truncated: self.omitted_bytes > 0,
|
||||
text: String::from_utf8_lossy(&retained).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BoundedProcessOutputSnapshot {
|
||||
bytes: u64,
|
||||
omitted_bytes: u64,
|
||||
truncated: bool,
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn duration_millis(duration: Duration) -> u64 {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::ExecutionOutputKind;
|
||||
|
||||
/// Bounded process output that preserves the first and last bytes.
|
||||
///
|
||||
/// Keeping both ends matches the unified exec contract: command headers and
|
||||
/// final diagnostics remain available while the middle is explicitly counted
|
||||
/// as omitted. The buffer stores bytes only and never grows with process
|
||||
/// output after the configured retention limit is reached.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoundedProcessOutput {
|
||||
retain_bytes: usize,
|
||||
head: Vec<u8>,
|
||||
tail: VecDeque<u8>,
|
||||
bytes: u64,
|
||||
omitted_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for BoundedProcessOutput {
|
||||
fn default() -> Self {
|
||||
Self::new(super::DEFAULT_OUTPUT_RETAIN_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
impl BoundedProcessOutput {
|
||||
pub fn new(retain_bytes: usize) -> Self {
|
||||
Self {
|
||||
retain_bytes,
|
||||
head: Vec::new(),
|
||||
tail: VecDeque::new(),
|
||||
bytes: 0,
|
||||
omitted_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append raw output while retaining only the configured head/tail window.
|
||||
pub fn push(&mut self, bytes: &[u8]) {
|
||||
self.bytes = self.bytes.saturating_add(bytes.len() as u64);
|
||||
let remaining_head = self.head_budget().saturating_sub(self.head.len());
|
||||
let (head, tail) = bytes
|
||||
.split_at_checked(remaining_head)
|
||||
.unwrap_or((bytes, &[]));
|
||||
self.head.extend_from_slice(head);
|
||||
self.push_tail(tail);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> BoundedProcessOutputSnapshot {
|
||||
let retained_bytes = self.head.len().saturating_add(self.tail.len());
|
||||
let marker = (self.omitted_bytes > 0)
|
||||
.then(|| format!("... {} bytes omitted ...", self.omitted_bytes));
|
||||
let marker_bytes = marker
|
||||
.as_ref()
|
||||
.map(|marker| marker.len().saturating_add(2))
|
||||
.unwrap_or_default();
|
||||
let mut retained = Vec::with_capacity(retained_bytes.saturating_add(marker_bytes));
|
||||
retained.extend_from_slice(&self.head);
|
||||
if let Some(marker) = marker {
|
||||
retained.push(b'\n');
|
||||
retained.extend_from_slice(marker.as_bytes());
|
||||
retained.push(b'\n');
|
||||
}
|
||||
retained.extend(self.tail.iter().copied());
|
||||
BoundedProcessOutputSnapshot {
|
||||
bytes: self.bytes,
|
||||
omitted_bytes: self.omitted_bytes,
|
||||
truncated: self.omitted_bytes > 0,
|
||||
text: String::from_utf8_lossy(&retained).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bytes == 0
|
||||
}
|
||||
|
||||
fn head_budget(&self) -> usize {
|
||||
self.retain_bytes / 2
|
||||
}
|
||||
|
||||
fn tail_budget(&self) -> usize {
|
||||
self.retain_bytes.saturating_sub(self.head_budget())
|
||||
}
|
||||
|
||||
fn push_tail(&mut self, bytes: &[u8]) {
|
||||
let remaining_tail = self.tail_budget().saturating_sub(self.tail.len());
|
||||
let excess = bytes.len().saturating_sub(remaining_tail);
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(excess as u64);
|
||||
|
||||
// Drop old tail bytes before skipping incoming bytes. This keeps the
|
||||
// newest suffix when one chunk is larger than the entire tail budget.
|
||||
let bytes = match excess.checked_sub(self.tail.len()) {
|
||||
None => {
|
||||
self.tail.drain(..excess);
|
||||
bytes
|
||||
}
|
||||
Some(skip) => {
|
||||
self.tail.clear();
|
||||
&bytes[skip..]
|
||||
}
|
||||
};
|
||||
self.tail.extend(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoundedProcessOutputSnapshot {
|
||||
pub bytes: u64,
|
||||
pub omitted_bytes: u64,
|
||||
pub truncated: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Frames process output on UTF-8 scalar boundaries without changing raw bytes.
|
||||
///
|
||||
/// A scalar spans at most four bytes, so each stream retains at most three
|
||||
/// incomplete bytes between producer chunks.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProcessOutputFramers {
|
||||
stdout: Utf8OutputFramer,
|
||||
stderr: Utf8OutputFramer,
|
||||
combined: Utf8OutputFramer,
|
||||
}
|
||||
|
||||
impl ProcessOutputFramers {
|
||||
pub fn push(&mut self, kind: ExecutionOutputKind, bytes: &[u8]) -> Vec<u8> {
|
||||
self.framer(kind).push(bytes)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, kind: ExecutionOutputKind) -> Vec<u8> {
|
||||
self.framer(kind).finish()
|
||||
}
|
||||
|
||||
pub fn finish_all(&mut self) -> Vec<(ExecutionOutputKind, Vec<u8>)> {
|
||||
[
|
||||
ExecutionOutputKind::Stdout,
|
||||
ExecutionOutputKind::Stderr,
|
||||
ExecutionOutputKind::Combined,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| {
|
||||
let bytes = self.finish(kind);
|
||||
(!bytes.is_empty()).then_some((kind, bytes))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn framer(&mut self, kind: ExecutionOutputKind) -> &mut Utf8OutputFramer {
|
||||
match kind {
|
||||
ExecutionOutputKind::Stdout => &mut self.stdout,
|
||||
ExecutionOutputKind::Stderr => &mut self.stderr,
|
||||
ExecutionOutputKind::Combined => &mut self.combined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct Utf8OutputFramer {
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Utf8OutputFramer {
|
||||
fn push(&mut self, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut frame = std::mem::take(&mut self.pending);
|
||||
frame.extend_from_slice(bytes);
|
||||
let boundary = utf8_boundary(&frame);
|
||||
self.pending = frame.split_off(boundary);
|
||||
frame
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only a potentially incomplete UTF-8 suffix; malformed bytes remain raw.
|
||||
fn utf8_boundary(bytes: &[u8]) -> usize {
|
||||
let mut boundary = bytes.len().saturating_sub(char::MAX.len_utf8() - 1);
|
||||
while boundary < bytes.len() {
|
||||
match std::str::from_utf8(&bytes[boundary..]) {
|
||||
Ok(value) => return boundary + value.len(),
|
||||
Err(error) => {
|
||||
boundary += error.valid_up_to();
|
||||
if let Some(invalid_len) = error.error_len() {
|
||||
boundary += invalid_len;
|
||||
} else {
|
||||
return boundary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BoundedProcessOutput, ProcessOutputFramers};
|
||||
use crate::execution_process::ExecutionOutputKind;
|
||||
|
||||
#[test]
|
||||
fn preserves_head_and_tail_when_output_exceeds_budget() {
|
||||
let mut output = BoundedProcessOutput::new(10);
|
||||
output.push(b"0123456789");
|
||||
output.push(b"ab");
|
||||
|
||||
let snapshot = output.snapshot();
|
||||
assert_eq!(snapshot.bytes, 12);
|
||||
assert_eq!(snapshot.omitted_bytes, 2);
|
||||
assert!(snapshot.truncated);
|
||||
assert_eq!(snapshot.text, "01234\n... 2 bytes omitted ...\n789ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_budget_drops_every_byte_without_growing() {
|
||||
let mut output = BoundedProcessOutput::new(0);
|
||||
output.push(b"abc");
|
||||
|
||||
let snapshot = output.snapshot();
|
||||
assert_eq!(snapshot.bytes, 3);
|
||||
assert_eq!(snapshot.omitted_bytes, 3);
|
||||
assert_eq!(snapshot.text, "\n... 3 bytes omitted ...\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_byte_budget_keeps_the_latest_byte() {
|
||||
let mut output = BoundedProcessOutput::new(1);
|
||||
output.push(b"abc");
|
||||
|
||||
let snapshot = output.snapshot();
|
||||
assert_eq!(snapshot.omitted_bytes, 2);
|
||||
assert_eq!(snapshot.text, "\n... 2 bytes omitted ...\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_utf8_after_joining_retained_bytes() {
|
||||
let mut output = BoundedProcessOutput::new(8);
|
||||
output.push(&[b'a', b'b', 0xf0, 0x9f]);
|
||||
output.push(&[0x98, 0x80, b'c', b'd']);
|
||||
|
||||
let snapshot = output.snapshot();
|
||||
assert_eq!(snapshot.bytes, 8);
|
||||
assert_eq!(snapshot.omitted_bytes, 0);
|
||||
assert_eq!(snapshot.text, "ab\u{1f600}cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_multibyte_characters_across_chunks() {
|
||||
let mut framers = ProcessOutputFramers::default();
|
||||
|
||||
assert!(framers
|
||||
.push(ExecutionOutputKind::Stdout, &[0xf0, 0x9f])
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
framers.push(ExecutionOutputKind::Stdout, &[0x98, 0x80]),
|
||||
"\u{1f600}".as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_streams_independently_and_flushes_incomplete_suffixes() {
|
||||
let mut framers = ProcessOutputFramers::default();
|
||||
|
||||
assert!(framers
|
||||
.push(ExecutionOutputKind::Stdout, &[0xc3])
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
framers.push(ExecutionOutputKind::Stderr, b"error"),
|
||||
b"error"
|
||||
);
|
||||
assert_eq!(framers.finish(ExecutionOutputKind::Stdout), vec![0xc3]);
|
||||
assert!(framers.finish(ExecutionOutputKind::Stderr).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bytes_do_not_block_later_valid_output() {
|
||||
let mut framers = ProcessOutputFramers::default();
|
||||
|
||||
assert_eq!(
|
||||
framers.push(ExecutionOutputKind::Combined, &[0xff, 0xc3]),
|
||||
vec![0xff]
|
||||
);
|
||||
assert_eq!(
|
||||
framers.push(ExecutionOutputKind::Combined, &[0xa9, b'!']),
|
||||
["\u{e9}".as_bytes(), b"!"].concat()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ pub(super) fn start_local_pty_execution_process(
|
||||
let process_state = ExecutionProcess::start(start);
|
||||
let initial_snapshot = process_state.snapshot();
|
||||
let process = Arc::new(Mutex::new(process_state));
|
||||
let (output_tx, output_rx) = mpsc::unbounded_channel();
|
||||
let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY);
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
let (state_tx, state_rx) = watch::channel(initial_snapshot);
|
||||
let (final_tx, final_rx) = oneshot::channel();
|
||||
@@ -91,7 +91,7 @@ pub(super) fn start_local_pty_execution_process(
|
||||
fn read_pty_process_stream(
|
||||
mut reader: Box<dyn io::Read + Send>,
|
||||
process: Arc<Mutex<ExecutionProcess>>,
|
||||
output_tx: mpsc::UnboundedSender<ExecutionOutputDelta>,
|
||||
output_tx: broadcast::Sender<ExecutionOutputDelta>,
|
||||
state_tx: watch::Sender<ExecutionProcessSnapshot>,
|
||||
) {
|
||||
let mut buffer = vec![0; PROCESS_OUTPUT_CHUNK_BYTES];
|
||||
@@ -108,9 +108,17 @@ fn read_pty_process_stream(
|
||||
let snapshot = guard.snapshot();
|
||||
(delta, snapshot)
|
||||
};
|
||||
let _ = output_tx.send(delta);
|
||||
if let Some(delta) = delta {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
let _ = state_tx.send(snapshot);
|
||||
}
|
||||
let final_delta = process
|
||||
.blocking_lock()
|
||||
.finish_output(ExecutionOutputKind::Combined);
|
||||
if let Some(delta) = final_delta {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -15,7 +15,9 @@ fn start_process() -> ExecutionProcess {
|
||||
#[test]
|
||||
fn process_tracks_output_delta_metadata() {
|
||||
let mut process = start_process();
|
||||
let delta = process.append_output(ExecutionOutputKind::Stdout, b"hello");
|
||||
let delta = process
|
||||
.append_output(ExecutionOutputKind::Stdout, b"hello")
|
||||
.expect("complete output should emit a delta");
|
||||
|
||||
assert_eq!(delta.sequence, 1);
|
||||
assert_eq!(delta.delta, "hello");
|
||||
@@ -31,6 +33,40 @@ fn process_tracks_output_delta_metadata() {
|
||||
assert_eq!(metadata.get("stdin_writable"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_frames_utf8_deltas_across_reader_chunks() {
|
||||
let mut process = start_process();
|
||||
|
||||
assert!(process
|
||||
.append_output(ExecutionOutputKind::Stdout, &[0xf0, 0x9f])
|
||||
.is_none());
|
||||
let delta = process
|
||||
.append_output(ExecutionOutputKind::Stdout, &[0x98, 0x80])
|
||||
.expect("completed scalar should emit");
|
||||
|
||||
assert_eq!(delta.delta, "\u{1f600}");
|
||||
assert_eq!(delta.raw_bytes, "\u{1f600}".as_bytes());
|
||||
assert_eq!(delta.bytes, 4);
|
||||
assert_eq!(process.snapshot().retained_output, "\u{1f600}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_flushes_incomplete_utf8_suffix_at_stream_end() {
|
||||
let mut process = start_process();
|
||||
|
||||
assert!(process
|
||||
.append_output(ExecutionOutputKind::Stderr, &[0xc3])
|
||||
.is_none());
|
||||
let delta = process
|
||||
.finish_output(ExecutionOutputKind::Stderr)
|
||||
.expect("incomplete suffix should flush");
|
||||
|
||||
assert_eq!(delta.delta, "\u{fffd}");
|
||||
assert_eq!(delta.raw_bytes, vec![0xc3]);
|
||||
assert_eq!(delta.bytes, 1);
|
||||
assert_eq!(process.snapshot().retained_output, "\u{fffd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_bounds_retained_output() {
|
||||
let mut output = BoundedProcessOutput::new(8);
|
||||
@@ -41,7 +77,7 @@ fn process_bounds_retained_output() {
|
||||
assert_eq!(snapshot.bytes, 10);
|
||||
assert_eq!(snapshot.omitted_bytes, 2);
|
||||
assert!(snapshot.truncated);
|
||||
assert_eq!(snapshot.text, "34567890");
|
||||
assert_eq!(snapshot.text, "1234\n... 2 bytes omitted ...\n7890");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,10 +160,10 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() {
|
||||
command
|
||||
};
|
||||
let child = command.spawn().expect("direct child should start");
|
||||
let (_stdout_writer, stdout_reader) = tokio::io::duplex(64);
|
||||
let (mut stdout_writer, stdout_reader) = tokio::io::duplex(64);
|
||||
let (_stderr_writer, stderr_reader) = tokio::io::duplex(64);
|
||||
let process = Arc::new(Mutex::new(start_process()));
|
||||
let (output_tx, _output_rx) = mpsc::unbounded_channel();
|
||||
let (output_tx, mut output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY);
|
||||
let (control_tx, control_rx) = mpsc::unbounded_channel();
|
||||
let (state_tx, mut state_rx) = watch::channel(process.lock().await.snapshot());
|
||||
let (final_tx, _final_rx) = oneshot::channel();
|
||||
@@ -143,6 +179,10 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() {
|
||||
final_tx,
|
||||
control_rx,
|
||||
));
|
||||
stdout_writer
|
||||
.write_all(&[0xc3])
|
||||
.await
|
||||
.expect("write incomplete UTF-8 prefix");
|
||||
|
||||
let terminal_snapshot = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
@@ -161,6 +201,12 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() {
|
||||
|
||||
assert_eq!(terminal_snapshot.status, ExecutionProcessStatus::Exited);
|
||||
assert_eq!(terminal_snapshot.exit_code, Some(7));
|
||||
let flushed = tokio::time::timeout(Duration::from_secs(1), output_rx.recv())
|
||||
.await
|
||||
.expect("grace fallback should flush pending output")
|
||||
.expect("output channel should remain available until final flush");
|
||||
assert_eq!(flushed.delta, "\u{fffd}");
|
||||
assert_eq!(flushed.raw_bytes, vec![0xc3]);
|
||||
drop(control_tx);
|
||||
}
|
||||
|
||||
@@ -191,6 +237,34 @@ async fn local_process_does_not_inherit_sensitive_parent_environment() {
|
||||
assert_eq!(final_snapshot.retained_output, "filtered");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn local_process_large_output_completes_without_draining_deltas() {
|
||||
let output_bytes = DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024;
|
||||
let mut handle = start_local_execution_process(LocalExecutionRequest::new(
|
||||
"process-local-large-output",
|
||||
"tool-local-large-output",
|
||||
"exec_command",
|
||||
shell_command(&format!(
|
||||
"awk 'BEGIN {{ for (i = 0; i < {output_bytes}; i++) printf \"x\" }}'"
|
||||
)),
|
||||
))
|
||||
.expect("large-output process should start");
|
||||
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(10), handle.wait())
|
||||
.await
|
||||
.expect("large-output process should not block on an undrained delta receiver")
|
||||
.expect("large-output process should finish");
|
||||
|
||||
assert_eq!(snapshot.status, ExecutionProcessStatus::Exited);
|
||||
assert_eq!(snapshot.exit_code, Some(0));
|
||||
assert_eq!(snapshot.output_bytes, output_bytes as u64);
|
||||
assert_eq!(snapshot.output_omitted_bytes, 64 * 1024);
|
||||
assert!(snapshot
|
||||
.retained_output
|
||||
.contains("... 65536 bytes omitted ..."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_process_next_event_delivers_output_before_exit() {
|
||||
let mut handle = start_local_execution_process(LocalExecutionRequest::new(
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn start_windows_restricted_execution_process(
|
||||
let process_state = ExecutionProcess::start(start);
|
||||
let initial_snapshot = process_state.snapshot();
|
||||
let process = Arc::new(Mutex::new(process_state));
|
||||
let (output_tx, output_rx) = mpsc::unbounded_channel();
|
||||
let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY);
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
let (state_tx, state_rx) = watch::channel(initial_snapshot);
|
||||
let (final_tx, final_rx) = oneshot::channel();
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch, Mutex};
|
||||
use tokio::sync::{broadcast, oneshot, watch, Mutex};
|
||||
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
|
||||
use windows_sys::Win32::System::Threading::{TerminateProcess, WaitForSingleObject};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn supervise(
|
||||
acl_lease: AclLease,
|
||||
null_device_lease: NullDeviceLease,
|
||||
process: Arc<Mutex<ExecutionProcess>>,
|
||||
output_tx: tokio_mpsc::UnboundedSender<ExecutionOutputDelta>,
|
||||
output_tx: broadcast::Sender<ExecutionOutputDelta>,
|
||||
state_tx: watch::Sender<ExecutionProcessSnapshot>,
|
||||
final_tx: oneshot::Sender<ExecutionProcessSnapshot>,
|
||||
control_rx: Receiver<LocalExecutionControl>,
|
||||
@@ -121,8 +121,9 @@ pub(super) fn supervise(
|
||||
}
|
||||
}
|
||||
|
||||
let final_snapshot = {
|
||||
let (final_deltas, final_snapshot) = {
|
||||
let mut guard = process.blocking_lock();
|
||||
let final_deltas = guard.finish_all_output();
|
||||
if !guard.status().is_terminal() {
|
||||
if let Some(error) = failure.as_ref() {
|
||||
guard.fail(error.clone());
|
||||
@@ -130,8 +131,11 @@ pub(super) fn supervise(
|
||||
guard.exit(exit_code);
|
||||
}
|
||||
}
|
||||
guard.snapshot()
|
||||
(final_deltas, guard.snapshot())
|
||||
};
|
||||
for delta in final_deltas {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
let _ = state_tx.send(final_snapshot.clone());
|
||||
let _ = final_tx.send(final_snapshot);
|
||||
|
||||
@@ -169,7 +173,7 @@ fn forward_control(
|
||||
|
||||
fn append_output(
|
||||
process: &Arc<Mutex<ExecutionProcess>>,
|
||||
output_tx: &tokio_mpsc::UnboundedSender<ExecutionOutputDelta>,
|
||||
output_tx: &broadcast::Sender<ExecutionOutputDelta>,
|
||||
state_tx: &watch::Sender<ExecutionProcessSnapshot>,
|
||||
kind: super::ExecutionOutputKind,
|
||||
bytes: &[u8],
|
||||
@@ -179,7 +183,9 @@ fn append_output(
|
||||
let delta = guard.append_output(kind, bytes);
|
||||
(delta, guard.snapshot())
|
||||
};
|
||||
let _ = output_tx.send(delta);
|
||||
if let Some(delta) = delta {
|
||||
let _ = output_tx.send(delta);
|
||||
}
|
||||
let _ = state_tx.send(snapshot);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ impl ToolCall {
|
||||
&self.environments
|
||||
}
|
||||
|
||||
pub(crate) fn lifecycle_emitter(&self) -> Arc<dyn ToolLifecycleEmitter> {
|
||||
Arc::clone(&self.lifecycle_emitter)
|
||||
}
|
||||
|
||||
pub async fn emit_started(&self) {
|
||||
self.lifecycle_emitter
|
||||
.emit(ToolLifecycleEvent::started(self))
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct RuntimeToolExecutionContextInput {
|
||||
pub workspace_sandbox: Option<RuntimeWorkspaceSandboxInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeToolExecutionContext {
|
||||
working_directory: PathBuf,
|
||||
environment_id: Option<String>,
|
||||
@@ -30,6 +30,30 @@ pub struct RuntimeToolExecutionContext {
|
||||
tool_identity: Option<RuntimeToolExecutionIdentity>,
|
||||
execution_attempt: Option<crate::execution_orchestrator::RuntimeToolExecutionAttempt>,
|
||||
filesystem_gateway: Option<Arc<dyn crate::filesystem_gateway::RuntimeFileSystemGateway>>,
|
||||
lifecycle_emitter: Option<Arc<dyn crate::tool_lifecycle::ToolLifecycleEmitter>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RuntimeToolExecutionContext {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RuntimeToolExecutionContext")
|
||||
.field("working_directory", &self.working_directory)
|
||||
.field("environment_id", &self.environment_id)
|
||||
.field("session_id", &self.session_id)
|
||||
.field("workspace_sandbox", &self.workspace_sandbox)
|
||||
.field("environment", &self.environment)
|
||||
.field("tool_identity", &self.tool_identity)
|
||||
.field("execution_attempt", &self.execution_attempt)
|
||||
.field("filesystem_gateway", &self.filesystem_gateway)
|
||||
.field(
|
||||
"lifecycle_emitter",
|
||||
&self
|
||||
.lifecycle_emitter
|
||||
.as_ref()
|
||||
.map(|_| "<lifecycle-emitter>"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -67,6 +91,7 @@ impl RuntimeToolExecutionContext {
|
||||
tool_identity: None,
|
||||
execution_attempt: None,
|
||||
filesystem_gateway: None,
|
||||
lifecycle_emitter: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +139,12 @@ impl RuntimeToolExecutionContext {
|
||||
self.filesystem_gateway.as_ref()
|
||||
}
|
||||
|
||||
pub fn lifecycle_emitter(
|
||||
&self,
|
||||
) -> Option<&Arc<dyn crate::tool_lifecycle::ToolLifecycleEmitter>> {
|
||||
self.lifecycle_emitter.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_tool_identity(mut self, identity: RuntimeToolExecutionIdentity) -> Self {
|
||||
self.tool_identity = Some(identity);
|
||||
self
|
||||
@@ -137,6 +168,14 @@ impl RuntimeToolExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_lifecycle_emitter(
|
||||
mut self,
|
||||
emitter: Arc<dyn crate::tool_lifecycle::ToolLifecycleEmitter>,
|
||||
) -> Self {
|
||||
self.lifecycle_emitter = Some(emitter);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_tool_environment(mut self, environment: &crate::tool_call::ToolEnvironment) -> Self {
|
||||
self.environment_id = Some(environment.environment_id.clone());
|
||||
self.working_directory = environment.cwd.clone();
|
||||
@@ -252,7 +291,8 @@ impl RuntimeToolExecutorHandle {
|
||||
.with_tool_identity(RuntimeToolExecutionIdentity::new(
|
||||
call.call_id(),
|
||||
call.turn_id(),
|
||||
));
|
||||
))
|
||||
.with_lifecycle_emitter(call.lifecycle_emitter());
|
||||
let context = match call.environments() {
|
||||
[] => context,
|
||||
[environment] => context.with_tool_environment(environment),
|
||||
|
||||
@@ -172,12 +172,20 @@ mod tests {
|
||||
#[derive(Default)]
|
||||
struct RecordingEmitter {
|
||||
events: Mutex<Vec<ToolLifecycleEvent>>,
|
||||
output_events: Mutex<Vec<ToolOutputDeltaEvent>>,
|
||||
}
|
||||
|
||||
impl RecordingEmitter {
|
||||
fn events(&self) -> Vec<ToolLifecycleEvent> {
|
||||
self.events.lock().expect("recording emitter lock").clone()
|
||||
}
|
||||
|
||||
fn output_events(&self) -> Vec<ToolOutputDeltaEvent> {
|
||||
self.output_events
|
||||
.lock()
|
||||
.expect("recording output emitter lock")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolLifecycleEmitter for RecordingEmitter {
|
||||
@@ -189,10 +197,24 @@ mod tests {
|
||||
.push(event);
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_output_delta<'a>(
|
||||
&'a self,
|
||||
event: ToolOutputDeltaEvent,
|
||||
) -> ToolLifecycleEmissionFuture<'a> {
|
||||
Box::pin(async move {
|
||||
self.output_events
|
||||
.lock()
|
||||
.expect("recording output emitter lock")
|
||||
.push(event);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct StructuredExecutor;
|
||||
|
||||
struct OutputForwardingExecutor;
|
||||
|
||||
struct PendingExecutor;
|
||||
|
||||
fn structured_result(text: impl Into<String>) -> RuntimeToolExecutionResult {
|
||||
@@ -240,6 +262,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeToolExecutor for OutputForwardingExecutor {
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
request: RuntimeToolExecutionRequest<'a>,
|
||||
) -> RuntimeToolExecutionFuture<'a> {
|
||||
let output_sink = request.context.lifecycle_emitter().cloned();
|
||||
Box::pin(async move {
|
||||
output_sink
|
||||
.expect("tool call lifecycle emitter should be attached")
|
||||
.emit_output_delta(ToolOutputDeltaEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-output".to_string(),
|
||||
tool_name: request.tool_name.to_string(),
|
||||
delta: "streamed\n".to_string(),
|
||||
output_kind: Some("stdout".to_string()),
|
||||
metadata: HashMap::new(),
|
||||
})
|
||||
.await;
|
||||
Ok(RuntimeToolExecutionResult::new(
|
||||
true,
|
||||
"streamed\n".to_string(),
|
||||
None,
|
||||
HashMap::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeToolExecutor for PendingExecutor {
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
@@ -319,6 +369,44 @@ mod tests {
|
||||
assert_eq!(events[1].output, Some(output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_tool_contract_forwards_lifecycle_emitter_to_executor_context() {
|
||||
let emitter = Arc::new(RecordingEmitter::default());
|
||||
let call = ToolCall::new(
|
||||
"turn-1",
|
||||
"call-output",
|
||||
"exec_command",
|
||||
serde_json::json!({ "cmd": "printf streamed" }),
|
||||
vec![ToolEnvironment::new(
|
||||
"local",
|
||||
PathBuf::from("/tmp/workspace"),
|
||||
)],
|
||||
emitter.clone(),
|
||||
);
|
||||
let runtime = RuntimeToolExecutorHandle::new(Arc::new(OutputForwardingExecutor)).bind(
|
||||
RuntimeToolDefinition::new(
|
||||
"exec_command",
|
||||
"Execute a command",
|
||||
serde_json::json!({ "type": "object" }),
|
||||
),
|
||||
RuntimeToolExposure::Direct,
|
||||
);
|
||||
let context = RuntimeToolExecutionContext::new(RuntimeToolExecutionContextInput {
|
||||
working_directory: PathBuf::from("/tmp/workspace"),
|
||||
session_id: "session-1".to_string(),
|
||||
cancel_token: None,
|
||||
workspace_sandbox: None,
|
||||
});
|
||||
|
||||
runtime.execute_call(&call, &context, None).await;
|
||||
|
||||
let output_events = emitter.output_events();
|
||||
assert_eq!(output_events.len(), 1);
|
||||
assert_eq!(output_events[0].call_id, "call-output");
|
||||
assert_eq!(output_events[0].delta, "streamed\n");
|
||||
assert_eq!(output_events[0].output_kind.as_deref(), Some("stdout"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_tool_contract_completes_name_mismatch_as_failure() {
|
||||
let emitter = Arc::new(RecordingEmitter::default());
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::execution_orchestrator::RuntimeToolExecutionAttempt;
|
||||
use crate::execution_process::live::{
|
||||
LiveExecutionOutputQuery, LiveExecutionRequest, RuntimeLiveExecutionGateway,
|
||||
};
|
||||
use crate::execution_process::{ExecutionProcessStatus, TRAILING_OUTPUT_GRACE};
|
||||
use crate::execution_process::{
|
||||
BoundedProcessOutput, ExecutionOutputDelta, ExecutionProcessStatus, TRAILING_OUTPUT_GRACE,
|
||||
};
|
||||
use crate::tool_definition::RuntimeToolDefinition;
|
||||
use crate::tool_executor::{
|
||||
RuntimeToolExecutionError, RuntimeToolExecutionResult, RuntimeToolPolicyErrorKind,
|
||||
@@ -11,6 +13,7 @@ use crate::tool_executor::{
|
||||
use crate::tool_io::{
|
||||
estimate_tool_io_tokens, format_tool_output_for_model, ToolOutputTruncationPolicy,
|
||||
};
|
||||
use crate::tool_lifecycle::{ToolLifecycleEmitter, ToolOutputDeltaEvent};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
@@ -45,9 +48,11 @@ pub struct RuntimeUnifiedExecToolRequest<'a> {
|
||||
pub working_directory: PathBuf,
|
||||
pub environment: HashMap<String, String>,
|
||||
pub tool_call_id: String,
|
||||
pub turn_id: String,
|
||||
pub cancel_token: Option<CancellationToken>,
|
||||
pub turn_context: Option<&'a RuntimeToolTurnContext>,
|
||||
pub attempt: Option<RuntimeToolExecutionAttempt>,
|
||||
pub output_sink: Option<Arc<dyn ToolLifecycleEmitter>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -89,6 +94,7 @@ struct WriteStdinInput {
|
||||
#[derive(Debug)]
|
||||
struct UnifiedExecSession {
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
process_id: String,
|
||||
call_id: String,
|
||||
command: String,
|
||||
@@ -114,6 +120,7 @@ struct UnifiedExecCallOutput {
|
||||
command: String,
|
||||
cwd: String,
|
||||
output: String,
|
||||
process: crate::execution_process::ExecutionProcessSnapshot,
|
||||
exit_code: Option<i32>,
|
||||
observation: UnifiedExecObservationKind,
|
||||
empty_poll_count: u64,
|
||||
@@ -273,6 +280,7 @@ async fn execute_command(
|
||||
let cwd_text = cwd.to_string_lossy().to_string();
|
||||
let session = Arc::new(tokio::sync::Mutex::new(UnifiedExecSession {
|
||||
thread_id: request.thread_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
process_id: process_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
command: command.clone(),
|
||||
@@ -329,6 +337,8 @@ async fn execute_command(
|
||||
gateway,
|
||||
session_id,
|
||||
session,
|
||||
request.turn_id,
|
||||
request.output_sink,
|
||||
clamped_exec_yield_time(input.yield_time_ms),
|
||||
clamped_output_tokens(input.max_output_tokens),
|
||||
request.cancel_token,
|
||||
@@ -346,7 +356,8 @@ async fn write_stdin(
|
||||
unified_exec_error(format!("write_stdin arguments are invalid: {error}"))
|
||||
})?;
|
||||
let session = find_session(input.session_id)?;
|
||||
let process_id = session_facts(&session).await.process_id;
|
||||
let session_identity = session_facts(&session).await;
|
||||
let process_id = session_identity.process_id.clone();
|
||||
{
|
||||
let session = session.lock().await;
|
||||
if session.thread_id != request.thread_id {
|
||||
@@ -362,6 +373,8 @@ async fn write_stdin(
|
||||
gateway,
|
||||
input.session_id,
|
||||
session,
|
||||
session_identity.turn_id,
|
||||
request.output_sink,
|
||||
clamped_write_yield_time(input.yield_time_ms, input.chars.is_empty()),
|
||||
clamped_output_tokens(input.max_output_tokens),
|
||||
request.cancel_token,
|
||||
@@ -389,13 +402,15 @@ async fn collect_process_output(
|
||||
gateway: Arc<dyn RuntimeLiveExecutionGateway>,
|
||||
session_id: i32,
|
||||
session: Arc<tokio::sync::Mutex<UnifiedExecSession>>,
|
||||
turn_id: String,
|
||||
output_sink: Option<Arc<dyn ToolLifecycleEmitter>>,
|
||||
yield_time: Duration,
|
||||
max_output_tokens: usize,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<UnifiedExecCallOutput, RuntimeToolExecutionError> {
|
||||
let started_at = Instant::now();
|
||||
let deadline = started_at + yield_time;
|
||||
let mut output = String::new();
|
||||
let mut output = BoundedProcessOutput::default();
|
||||
|
||||
loop {
|
||||
let (process_id, after_sequence) = {
|
||||
@@ -408,8 +423,8 @@ async fn collect_process_output(
|
||||
limit: Some(OUTPUT_DRAIN_LIMIT),
|
||||
max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES),
|
||||
})?;
|
||||
for delta in drained.deltas {
|
||||
output.push_str(&delta.delta);
|
||||
for delta in &drained.deltas {
|
||||
append_observed_output(&mut output, delta);
|
||||
}
|
||||
if let Some(next_sequence) = drained.next_sequence {
|
||||
session.lock().await.after_sequence = Some(next_sequence);
|
||||
@@ -423,6 +438,15 @@ async fn collect_process_output(
|
||||
}
|
||||
|
||||
let snapshot = gateway.status(&process_id)?;
|
||||
let call_id = session_facts(&session).await.call_id;
|
||||
emit_output_deltas(
|
||||
output_sink.as_ref(),
|
||||
&turn_id,
|
||||
&call_id,
|
||||
&drained.deltas,
|
||||
snapshot.status,
|
||||
)
|
||||
.await;
|
||||
if process_status_is_terminal(snapshot.status) {
|
||||
tokio::time::sleep(TRAILING_OUTPUT_GRACE).await;
|
||||
let final_drain = gateway.drain_output(LiveExecutionOutputQuery {
|
||||
@@ -431,9 +455,17 @@ async fn collect_process_output(
|
||||
limit: Some(OUTPUT_DRAIN_LIMIT),
|
||||
max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES),
|
||||
})?;
|
||||
for delta in final_drain.deltas {
|
||||
output.push_str(&delta.delta);
|
||||
for delta in &final_drain.deltas {
|
||||
append_observed_output(&mut output, delta);
|
||||
}
|
||||
emit_output_deltas(
|
||||
output_sink.as_ref(),
|
||||
&turn_id,
|
||||
&call_id,
|
||||
&final_drain.deltas,
|
||||
snapshot.status,
|
||||
)
|
||||
.await;
|
||||
let facts = session_facts(&session).await;
|
||||
remove_session(session_id);
|
||||
if cancelled {
|
||||
@@ -447,6 +479,7 @@ async fn collect_process_output(
|
||||
if snapshot.status == ExecutionProcessStatus::Failed {
|
||||
let message = snapshot
|
||||
.failure
|
||||
.clone()
|
||||
.unwrap_or_else(|| "execution process failed".to_string());
|
||||
return Err(RuntimeToolExecutionError::new(
|
||||
message,
|
||||
@@ -455,13 +488,15 @@ async fn collect_process_output(
|
||||
)),
|
||||
));
|
||||
}
|
||||
let exit_code = snapshot.exit_code;
|
||||
return Ok(UnifiedExecCallOutput {
|
||||
session_id: None,
|
||||
call_id: facts.call_id,
|
||||
command: facts.command,
|
||||
cwd: facts.cwd,
|
||||
output,
|
||||
exit_code: snapshot.exit_code,
|
||||
output: output.snapshot().text,
|
||||
process: snapshot,
|
||||
exit_code,
|
||||
observation: UnifiedExecObservationKind::Terminal,
|
||||
empty_poll_count: 0,
|
||||
wall_time: started_at.elapsed(),
|
||||
@@ -483,7 +518,8 @@ async fn collect_process_output(
|
||||
call_id: facts.call_id.clone(),
|
||||
command: facts.command.clone(),
|
||||
cwd: facts.cwd.clone(),
|
||||
output,
|
||||
output: output.snapshot().text,
|
||||
process: snapshot,
|
||||
exit_code: None,
|
||||
observation,
|
||||
empty_poll_count: facts.empty_poll_count,
|
||||
@@ -495,10 +531,61 @@ async fn collect_process_output(
|
||||
}
|
||||
}
|
||||
|
||||
fn append_observed_output(output: &mut BoundedProcessOutput, delta: &ExecutionOutputDelta) {
|
||||
if delta.raw_bytes.is_empty() {
|
||||
output.push(delta.delta.as_bytes());
|
||||
} else {
|
||||
output.push(&delta.raw_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_output_deltas(
|
||||
output_sink: Option<&Arc<dyn ToolLifecycleEmitter>>,
|
||||
turn_id: &str,
|
||||
call_id: &str,
|
||||
deltas: &[ExecutionOutputDelta],
|
||||
process_status: ExecutionProcessStatus,
|
||||
) {
|
||||
let Some(output_sink) = output_sink else {
|
||||
return;
|
||||
};
|
||||
for delta in deltas {
|
||||
if delta.delta.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut metadata = delta.metadata();
|
||||
metadata.insert(
|
||||
"executionProcessStatus".to_string(),
|
||||
json!(process_status.label()),
|
||||
);
|
||||
metadata.insert(
|
||||
"stdinWritable".to_string(),
|
||||
json!(!process_status.is_terminal()),
|
||||
);
|
||||
metadata.insert(
|
||||
"stdin_writable".to_string(),
|
||||
json!(!process_status.is_terminal()),
|
||||
);
|
||||
metadata.insert("executionSurface".to_string(), json!("unified_exec"));
|
||||
metadata.insert("execution_surface".to_string(), json!("unified_exec"));
|
||||
output_sink
|
||||
.emit_output_delta(ToolOutputDeltaEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
tool_name: EXEC_COMMAND_TOOL_NAME.to_string(),
|
||||
delta: delta.delta.clone(),
|
||||
output_kind: Some(delta.kind.label().to_string()),
|
||||
metadata,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn session_facts(session: &tokio::sync::Mutex<UnifiedExecSession>) -> UnifiedExecSession {
|
||||
let session = session.lock().await;
|
||||
UnifiedExecSession {
|
||||
thread_id: session.thread_id.clone(),
|
||||
turn_id: session.turn_id.clone(),
|
||||
process_id: session.process_id.clone(),
|
||||
call_id: session.call_id.clone(),
|
||||
command: session.command.clone(),
|
||||
@@ -531,9 +618,12 @@ fn project_output(output: UnifiedExecCallOutput) -> RuntimeToolExecutionResult {
|
||||
if let Some(exit_code) = output.exit_code {
|
||||
structured["exit_code"] = json!(exit_code);
|
||||
}
|
||||
structured["process_id"] = json!(output.process.process_id.clone());
|
||||
structured["process_status"] = json!(output.process.status.label());
|
||||
let success = output.exit_code.map(|code| code == 0).unwrap_or(true);
|
||||
let serialized = serde_json::to_string(&structured).unwrap_or_else(|_| "{}".to_string());
|
||||
let metadata = HashMap::from([
|
||||
let mut metadata = output.process.metadata();
|
||||
metadata.extend([
|
||||
("exec_command_call_id".to_string(), json!(output.call_id)),
|
||||
("command".to_string(), json!(output.command)),
|
||||
("cwd".to_string(), json!(output.cwd)),
|
||||
@@ -557,6 +647,7 @@ fn project_output(output: UnifiedExecCallOutput) -> RuntimeToolExecutionResult {
|
||||
json!(output.empty_poll_count),
|
||||
),
|
||||
("execution_surface".to_string(), json!("unified_exec")),
|
||||
("executionSurface".to_string(), json!("unified_exec")),
|
||||
]);
|
||||
RuntimeToolExecutionResult::new(
|
||||
success,
|
||||
|
||||
@@ -4,6 +4,9 @@ use crate::execution_process::{
|
||||
ExecutionOutputDelta, ExecutionOutputKind, ExecutionProcessSnapshot,
|
||||
};
|
||||
use crate::tool_executor::{RuntimeToolExecutionError, RuntimeToolPolicyErrorKind};
|
||||
use crate::tool_lifecycle::{
|
||||
ToolLifecycleEmissionFuture, ToolLifecycleEmitter, ToolLifecycleEvent, ToolOutputDeltaEvent,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -250,9 +253,37 @@ fn request<'a>(
|
||||
working_directory: std::env::current_dir().unwrap(),
|
||||
environment: HashMap::new(),
|
||||
tool_call_id: call_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cancel_token: None,
|
||||
turn_context: None,
|
||||
attempt: None,
|
||||
output_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingOutputSink {
|
||||
events: Mutex<Vec<ToolOutputDeltaEvent>>,
|
||||
}
|
||||
|
||||
impl RecordingOutputSink {
|
||||
fn events(&self) -> Vec<ToolOutputDeltaEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolLifecycleEmitter for RecordingOutputSink {
|
||||
fn emit<'a>(&'a self, _event: ToolLifecycleEvent) -> ToolLifecycleEmissionFuture<'a> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn emit_output_delta<'a>(
|
||||
&'a self,
|
||||
event: ToolOutputDeltaEvent,
|
||||
) -> ToolLifecycleEmissionFuture<'a> {
|
||||
Box::pin(async move {
|
||||
self.events.lock().unwrap().push(event);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,18 +305,18 @@ fn definitions_expose_only_codex_unified_exec_tools() {
|
||||
#[tokio::test]
|
||||
async fn exec_command_returns_terminal_output_for_short_process() {
|
||||
let gateway = Arc::new(FixtureGateway::default());
|
||||
let output_sink = Arc::new(RecordingOutputSink::default());
|
||||
let params = json!({
|
||||
"cmd": "short",
|
||||
"login": false,
|
||||
"yield_time_ms": 250
|
||||
});
|
||||
|
||||
let result = execute_runtime_unified_exec_tool(
|
||||
gateway,
|
||||
request(EXEC_COMMAND_TOOL_NAME, ¶ms, "call-short"),
|
||||
)
|
||||
.await
|
||||
.expect("short command result");
|
||||
let mut execution_request = request(EXEC_COMMAND_TOOL_NAME, ¶ms, "call-short");
|
||||
execution_request.output_sink = Some(output_sink.clone());
|
||||
let result = execute_runtime_unified_exec_tool(gateway, execution_request)
|
||||
.await
|
||||
.expect("short command result");
|
||||
|
||||
assert!(result.success);
|
||||
let structured = result.structured_content.expect("structured output");
|
||||
@@ -298,6 +329,35 @@ async fn exec_command_returns_terminal_output_for_short_process() {
|
||||
result.metadata.get("exec_command_call_id"),
|
||||
Some(&json!("call-short"))
|
||||
);
|
||||
assert!(result
|
||||
.metadata
|
||||
.get("processId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("unified-exec-")));
|
||||
assert_eq!(
|
||||
result.metadata.get("executionProcessStatus"),
|
||||
Some(&json!("exited"))
|
||||
);
|
||||
assert_eq!(result.metadata.get("outputTruncated"), Some(&json!(false)));
|
||||
let events = output_sink.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].turn_id, "turn-1");
|
||||
assert_eq!(events[0].call_id, "call-short");
|
||||
assert_eq!(events[0].delta, "completed\n");
|
||||
assert_eq!(events[0].output_kind.as_deref(), Some("stdout"));
|
||||
assert_eq!(
|
||||
events[0].metadata.get("processId"),
|
||||
result.metadata.get("processId")
|
||||
);
|
||||
assert_eq!(events[0].metadata.get("outputSequence"), Some(&json!(1)));
|
||||
assert_eq!(
|
||||
events[0].metadata.get("executionProcessStatus"),
|
||||
Some(&json!("exited"))
|
||||
);
|
||||
assert_eq!(
|
||||
events[0].metadata.get("executionSurface"),
|
||||
Some(&json!("unified_exec"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -381,35 +441,43 @@ async fn exec_command_forwards_environment_identity_to_execution_gateway() {
|
||||
#[tokio::test]
|
||||
async fn write_stdin_resumes_and_completes_original_exec_command() {
|
||||
let gateway = Arc::new(FixtureGateway::default());
|
||||
let output_sink = Arc::new(RecordingOutputSink::default());
|
||||
let exec_params = json!({
|
||||
"cmd": "long-running",
|
||||
"login": false,
|
||||
"yield_time_ms": 250
|
||||
});
|
||||
let running = execute_runtime_unified_exec_tool(
|
||||
gateway.clone(),
|
||||
request(EXEC_COMMAND_TOOL_NAME, &exec_params, "call-long"),
|
||||
)
|
||||
.await
|
||||
.expect("running command result");
|
||||
let mut exec_request = request(EXEC_COMMAND_TOOL_NAME, &exec_params, "call-long");
|
||||
exec_request.output_sink = Some(output_sink.clone());
|
||||
let running = execute_runtime_unified_exec_tool(gateway.clone(), exec_request)
|
||||
.await
|
||||
.expect("running command result");
|
||||
let session_id = running
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("session_id"))
|
||||
.and_then(Value::as_i64)
|
||||
.expect("session id") as i32;
|
||||
assert_eq!(
|
||||
running.metadata.get("executionProcessStatus"),
|
||||
Some(&json!("running"))
|
||||
);
|
||||
assert_eq!(
|
||||
running.metadata.get("processId"),
|
||||
Some(&json!(format!("unified-exec-{session_id}")))
|
||||
);
|
||||
|
||||
let write_params = json!({
|
||||
"session_id": session_id,
|
||||
"chars": "continue\n",
|
||||
"yield_time_ms": 250
|
||||
});
|
||||
let completed = execute_runtime_unified_exec_tool(
|
||||
gateway,
|
||||
request(WRITE_STDIN_TOOL_NAME, &write_params, "write-call"),
|
||||
)
|
||||
.await
|
||||
.expect("write stdin result");
|
||||
let mut write_request = request(WRITE_STDIN_TOOL_NAME, &write_params, "write-call");
|
||||
write_request.turn_id = "turn-write".to_string();
|
||||
write_request.output_sink = Some(output_sink.clone());
|
||||
let completed = execute_runtime_unified_exec_tool(gateway, write_request)
|
||||
.await
|
||||
.expect("write stdin result");
|
||||
|
||||
assert!(completed.success);
|
||||
let structured = completed.structured_content.expect("structured output");
|
||||
@@ -427,6 +495,12 @@ async fn write_stdin_resumes_and_completes_original_exec_command() {
|
||||
"stdin": "sent 9 chars",
|
||||
}))
|
||||
);
|
||||
let events = output_sink.events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(events.iter().all(|event| event.turn_id == "turn-1"));
|
||||
assert!(events.iter().all(|event| event.call_id == "call-long"));
|
||||
assert_eq!(events[0].delta, "started\n");
|
||||
assert_eq!(events[1].delta, "finished\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,6 +10,7 @@ use app_server_protocol::protocol::v2::{AdditionalNetworkPermissions, GrantedPer
|
||||
use tool_runtime::execution_process::{
|
||||
audit_windows_world_writable, start_local_execution_process, ExecutionProcessStatus,
|
||||
LocalExecutionProcessHandle, LocalExecutionRequest, LocalExecutionSandbox,
|
||||
DEFAULT_OUTPUT_RETAIN_BYTES,
|
||||
};
|
||||
use tool_runtime::sandbox::SandboxBackend;
|
||||
use windows_sys::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HLOCAL};
|
||||
@@ -380,11 +381,12 @@ async fn restricted_execution_uses_offline_account_and_blocks_network() {
|
||||
#[tokio::test]
|
||||
async fn restricted_execution_bounds_large_output() {
|
||||
let fixture = workspace_fixture();
|
||||
let script = "[Console]::Out.Write(('x' * 400000))";
|
||||
let output_bytes = DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024;
|
||||
let script = format!("[Console]::Out.Write(('x' * {output_bytes}))");
|
||||
let snapshot = run_to_terminal(
|
||||
start_local_execution_process(restricted_request(
|
||||
fixture.path(),
|
||||
powershell_script(script),
|
||||
powershell_script(&script),
|
||||
))
|
||||
.expect("large-output process should start"),
|
||||
)
|
||||
@@ -397,11 +399,16 @@ async fn restricted_execution_bounds_large_output() {
|
||||
"large-output restricted process must exit successfully: {snapshot:#?}"
|
||||
);
|
||||
assert!(snapshot.output_truncated, "large output must be truncated");
|
||||
assert!(
|
||||
snapshot.output_omitted_bytes > 0,
|
||||
"omitted byte count must be reported"
|
||||
assert_eq!(
|
||||
snapshot.output_omitted_bytes,
|
||||
output_bytes.saturating_sub(DEFAULT_OUTPUT_RETAIN_BYTES) as u64
|
||||
);
|
||||
let marker = format!("... {} bytes omitted ...", snapshot.output_omitted_bytes);
|
||||
assert!(snapshot.retained_output.contains(&marker));
|
||||
assert_eq!(
|
||||
snapshot.retained_output.len(),
|
||||
DEFAULT_OUTPUT_RETAIN_BYTES + marker.len() + 2
|
||||
);
|
||||
assert!(snapshot.retained_output.len() <= 128 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -11,15 +11,16 @@ path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
agent-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
app-server-client.workspace = true
|
||||
app-server-protocol.workspace = true
|
||||
arboard.workspace = true
|
||||
base64.workspace = true
|
||||
crossterm.workspace = true
|
||||
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
|
||||
futures.workspace = true
|
||||
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
|
||||
ratatui = { workspace = true, features = ["unstable-rendered-line-info", "unstable-widget-ref"] }
|
||||
pulldown-cmark.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
@@ -28,6 +29,7 @@ tokio.workspace = true
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
tokio-util = { workspace = true, features = ["time"] }
|
||||
tempfile.workspace = true
|
||||
tracing.workspace = true
|
||||
two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] }
|
||||
unicode-segmentation.workspace = true
|
||||
unicode-width.workspace = true
|
||||
@@ -35,13 +37,15 @@ textwrap.workspace = true
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Console"] }
|
||||
which.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
portable-pty.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
which.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
+236
-655
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
||||
//! Multi-agent picker navigation and labeling state for the TUI app.
|
||||
//!
|
||||
//! This module mirrors Codex's `app/agent_navigation.rs` boundary. It owns only stable ordering,
|
||||
//! labels, and liveness facts derived from App Server `Thread`/`SubAgentActivity` data. Thread
|
||||
//! lifecycle and switching remain in `App` and `AppServerSession`.
|
||||
|
||||
use crate::multi_agents::{
|
||||
format_agent_picker_item_name, next_agent_shortcut, previous_agent_shortcut,
|
||||
AgentPickerThreadEntry, SubAgentActivityDisplay,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AgentNavigationDirection {
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct AgentNavigationState {
|
||||
threads: std::collections::HashMap<String, AgentPickerThreadEntry>,
|
||||
order: Vec<String>,
|
||||
stopped_threads: std::collections::HashSet<String>,
|
||||
parent_owned_threads: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AgentNavigationState {
|
||||
pub(crate) fn get(&self, thread_id: &str) -> Option<&AgentPickerThreadEntry> {
|
||||
self.threads.get(thread_id)
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.threads.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn is_parent_owned(&self, thread_id: &str) -> bool {
|
||||
self.parent_owned_threads.contains(thread_id)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_parent_owned(&mut self, thread_id: impl Into<String>) {
|
||||
self.parent_owned_threads.insert(thread_id.into());
|
||||
}
|
||||
|
||||
pub(crate) fn upsert(
|
||||
&mut self,
|
||||
thread_id: impl Into<String>,
|
||||
agent_nickname: Option<String>,
|
||||
agent_role: Option<String>,
|
||||
is_closed: bool,
|
||||
) {
|
||||
let thread_id = thread_id.into();
|
||||
if !self.threads.contains_key(&thread_id) {
|
||||
self.order.push(thread_id.clone());
|
||||
}
|
||||
let (agent_path, is_running) = self
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.map(|entry| (entry.agent_path.clone(), entry.is_running))
|
||||
.unwrap_or((None, false));
|
||||
self.threads.insert(
|
||||
thread_id,
|
||||
AgentPickerThreadEntry {
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
agent_path,
|
||||
is_running: is_running && !is_closed,
|
||||
is_closed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_sub_agent_activity(&mut self, activity: SubAgentActivityDisplay) {
|
||||
if !self.threads.contains_key(&activity.thread_id) {
|
||||
self.order.push(activity.thread_id.clone());
|
||||
}
|
||||
let entry = self
|
||||
.threads
|
||||
.entry(activity.thread_id.clone())
|
||||
.or_insert_with(|| AgentPickerThreadEntry {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
agent_path: None,
|
||||
is_running: false,
|
||||
is_closed: false,
|
||||
});
|
||||
entry.agent_path = Some(activity.agent_path);
|
||||
if activity.is_running_hint
|
||||
&& !entry.is_closed
|
||||
&& !self.stopped_threads.contains(&activity.thread_id)
|
||||
{
|
||||
entry.is_running = true;
|
||||
} else {
|
||||
entry.is_running = false;
|
||||
self.stopped_threads.insert(activity.thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_running(&mut self, thread_id: &str) {
|
||||
if self
|
||||
.threads
|
||||
.get(thread_id)
|
||||
.is_some_and(|entry| entry.is_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.stopped_threads.remove(thread_id);
|
||||
self.set_running(thread_id, true);
|
||||
}
|
||||
|
||||
pub(crate) fn mark_stopped(&mut self, thread_id: &str) {
|
||||
self.stopped_threads.insert(thread_id.to_string());
|
||||
self.set_running(thread_id, false);
|
||||
}
|
||||
|
||||
pub(crate) fn set_running(&mut self, thread_id: &str, is_running: bool) {
|
||||
if let Some(entry) = self.threads.get_mut(thread_id) {
|
||||
entry.is_running = is_running;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_agent_path(&mut self, thread_id: &str, agent_path: Option<String>) {
|
||||
if let Some(agent_path) = agent_path {
|
||||
if let Some(entry) = self.threads.get_mut(thread_id) {
|
||||
entry.agent_path = Some(agent_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_closed(&mut self, thread_id: &str) {
|
||||
if let Some(entry) = self.threads.get_mut(thread_id) {
|
||||
entry.is_closed = true;
|
||||
entry.is_running = false;
|
||||
} else {
|
||||
self.upsert(thread_id, None, None, true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.threads.clear();
|
||||
self.order.clear();
|
||||
self.stopped_threads.clear();
|
||||
self.parent_owned_threads.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, thread_id: &str) {
|
||||
self.threads.remove(thread_id);
|
||||
self.order.retain(|candidate| candidate != thread_id);
|
||||
self.stopped_threads.remove(thread_id);
|
||||
self.parent_owned_threads.remove(thread_id);
|
||||
}
|
||||
|
||||
pub(crate) fn ordered_threads(&self) -> Vec<(&str, &AgentPickerThreadEntry)> {
|
||||
self.order
|
||||
.iter()
|
||||
.filter_map(|thread_id| {
|
||||
self.threads
|
||||
.get(thread_id)
|
||||
.map(|entry| (thread_id.as_str(), entry))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn ordered_path_backed_subagent_threads(
|
||||
&self,
|
||||
primary_thread_id: Option<&str>,
|
||||
) -> Vec<(&str, &AgentPickerThreadEntry)> {
|
||||
self.ordered_threads()
|
||||
.into_iter()
|
||||
.filter(|(thread_id, entry)| {
|
||||
Some(*thread_id) != primary_thread_id
|
||||
&& entry
|
||||
.agent_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| !path.trim().is_empty())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn tracked_thread_ids(&self) -> Vec<String> {
|
||||
self.order
|
||||
.iter()
|
||||
.filter(|thread_id| self.threads.contains_key(*thread_id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn adjacent_thread_id(
|
||||
&self,
|
||||
current_displayed_thread_id: Option<&str>,
|
||||
direction: AgentNavigationDirection,
|
||||
) -> Option<String> {
|
||||
let ordered = self.tracked_thread_ids();
|
||||
if ordered.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let current = current_displayed_thread_id?;
|
||||
let index = ordered.iter().position(|thread_id| thread_id == current)?;
|
||||
let next = match direction {
|
||||
AgentNavigationDirection::Next => (index + 1) % ordered.len(),
|
||||
AgentNavigationDirection::Previous => index.checked_sub(1).unwrap_or(ordered.len() - 1),
|
||||
};
|
||||
Some(ordered[next].clone())
|
||||
}
|
||||
|
||||
pub(crate) fn active_agent_label(
|
||||
&self,
|
||||
current_displayed_thread_id: Option<&str>,
|
||||
primary_thread_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if self.threads.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let thread_id = current_displayed_thread_id?;
|
||||
let is_primary = primary_thread_id == Some(thread_id);
|
||||
Some(
|
||||
self.threads
|
||||
.get(thread_id)
|
||||
.map(|entry| {
|
||||
if !is_primary {
|
||||
if let Some(path) = entry
|
||||
.agent_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
{
|
||||
return format!("`{path}`");
|
||||
}
|
||||
}
|
||||
format_agent_picker_item_name(
|
||||
entry.agent_nickname.as_deref(),
|
||||
entry.agent_role.as_deref(),
|
||||
is_primary,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| format_agent_picker_item_name(None, None, is_primary)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn picker_subtitle() -> String {
|
||||
format!(
|
||||
"Select an agent to watch. Alt+{} previous, Alt+{} next.",
|
||||
key_label(previous_agent_shortcut()),
|
||||
key_label(next_agent_shortcut())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn key_label(key: crossterm::event::KeyCode) -> &'static str {
|
||||
match key {
|
||||
crossterm::event::KeyCode::Left => "Left",
|
||||
crossterm::event::KeyCode::Right => "Right",
|
||||
_ => "key",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn populated_state() -> (AgentNavigationState, String, String, String) {
|
||||
let mut state = AgentNavigationState::default();
|
||||
let main = "main".to_string();
|
||||
let first = "agent-1".to_string();
|
||||
let second = "agent-2".to_string();
|
||||
state.upsert(main.clone(), None, None, false);
|
||||
state.upsert(
|
||||
first.clone(),
|
||||
Some("Robie".into()),
|
||||
Some("explorer".into()),
|
||||
false,
|
||||
);
|
||||
state.upsert(
|
||||
second.clone(),
|
||||
Some("Bob".into()),
|
||||
Some("worker".into()),
|
||||
false,
|
||||
);
|
||||
(state, main, first, second)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_first_seen_order() {
|
||||
let (mut state, main, first, second) = populated_state();
|
||||
state.upsert(
|
||||
first.clone(),
|
||||
Some("Robie".into()),
|
||||
Some("worker".into()),
|
||||
true,
|
||||
);
|
||||
assert_eq!(state.tracked_thread_ids(), vec![main, first, second]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_thread_id_wraps_in_spawn_order() {
|
||||
let (state, main, first, second) = populated_state();
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(second.as_str()), AgentNavigationDirection::Next),
|
||||
Some(main.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(main.as_str()), AgentNavigationDirection::Previous),
|
||||
Some(second.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(second.as_str()), AgentNavigationDirection::Previous),
|
||||
Some(first)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_agent_label_tracks_current_thread() {
|
||||
let (state, main, first, _) = populated_state();
|
||||
assert_eq!(
|
||||
state.active_agent_label(Some(first.as_str()), Some(main.as_str())),
|
||||
Some("Robie [explorer]".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
state.active_agent_label(Some(main.as_str()), Some(main.as_str())),
|
||||
Some("Main [default]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_activity_does_not_revive_after_liveness_terminal_state() {
|
||||
let mut state = AgentNavigationState::default();
|
||||
state.record_sub_agent_activity(SubAgentActivityDisplay {
|
||||
thread_id: "agent-1".into(),
|
||||
agent_path: "/root/child".into(),
|
||||
is_running_hint: false,
|
||||
});
|
||||
state.record_sub_agent_activity(SubAgentActivityDisplay {
|
||||
thread_id: "agent-1".into(),
|
||||
agent_path: "/root/child".into(),
|
||||
is_running_hint: true,
|
||||
});
|
||||
assert!(!state.get("agent-1").expect("tracked").is_running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_subtitle_mentions_codex_shortcuts() {
|
||||
let subtitle = AgentNavigationState::picker_subtitle();
|
||||
assert!(subtitle.contains("Alt+Left"));
|
||||
assert!(subtitle.contains("Alt+Right"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Agent picker UI matching Codex's `/subagents` interaction shape.
|
||||
//!
|
||||
//! Rows are snapshots of the App Server-backed navigation state. Selecting a row only returns a
|
||||
//! thread id; `App` and `AppServerSession` own the actual `thread/resume` request.
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::agent_navigation::AgentNavigationState;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::locale::Locale;
|
||||
use crate::multi_agents::format_agent_picker_item_name;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct AgentPickerEntry {
|
||||
thread_id: String,
|
||||
label: String,
|
||||
is_running: bool,
|
||||
is_closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AgentPickerAction {
|
||||
None,
|
||||
Cancel,
|
||||
Select(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AgentPicker {
|
||||
entries: Vec<AgentPickerEntry>,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
impl AgentPicker {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn from_navigation(
|
||||
navigation: &AgentNavigationState,
|
||||
primary_thread_id: Option<&str>,
|
||||
) -> Self {
|
||||
let entries = navigation
|
||||
.ordered_threads()
|
||||
.into_iter()
|
||||
.map(|(thread_id, entry)| {
|
||||
let is_primary = primary_thread_id == Some(thread_id);
|
||||
let label = format_agent_picker_item_name(
|
||||
entry.agent_nickname.as_deref(),
|
||||
entry.agent_role.as_deref(),
|
||||
is_primary,
|
||||
);
|
||||
AgentPickerEntry {
|
||||
thread_id: thread_id.to_string(),
|
||||
label,
|
||||
is_running: entry.is_running,
|
||||
is_closed: entry.is_closed,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
entries,
|
||||
selected: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, event: Event) -> AgentPickerAction {
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
return AgentPickerAction::Cancel;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => AgentPickerAction::Cancel,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
AgentPickerAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if !self.entries.is_empty() {
|
||||
self.selected = (self.selected + 1).min(self.entries.len() - 1);
|
||||
}
|
||||
AgentPickerAction::None
|
||||
}
|
||||
KeyCode::Enter => self
|
||||
.entries
|
||||
.get(self.selected)
|
||||
.map(|entry| AgentPickerAction::Select(entry.thread_id.clone()))
|
||||
.unwrap_or(AgentPickerAction::None),
|
||||
_ => AgentPickerAction::None,
|
||||
}
|
||||
}
|
||||
_ => AgentPickerAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(&self) -> impl Iterator<Item = &AgentPickerEntry> {
|
||||
self.entries.iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, locale: Locale) {
|
||||
let width = area.width.saturating_mul(4).saturating_div(5).clamp(32, 72);
|
||||
let height = area.height.saturating_mul(3).saturating_div(4).clamp(7, 18);
|
||||
let x = area.x.saturating_add(area.width.saturating_sub(width) / 2);
|
||||
let y = area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(height) / 2);
|
||||
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
|
||||
|
||||
frame.render_widget(Clear, popup);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {} ", locale.agent_picker_title()),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
usize::from(chunks[0].width.saturating_sub(2)),
|
||||
))
|
||||
.block(Block::default().borders(Borders::TOP)),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let items = picker
|
||||
.rows()
|
||||
.map(|entry| {
|
||||
let status = if entry.is_closed {
|
||||
format!(" [{}]", locale.status("closed"))
|
||||
} else if entry.is_running {
|
||||
format!(" [{}]", locale.status("running"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
ListItem::new(truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(format!("{}{}", entry.label, status)),
|
||||
usize::from(chunks[1].width.saturating_sub(2)),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut state = ListState::default();
|
||||
if !items.is_empty() {
|
||||
state.select(Some(picker.selected));
|
||||
}
|
||||
frame.render_stateful_widget(
|
||||
List::new(items)
|
||||
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("> "),
|
||||
chunks[1],
|
||||
&mut state,
|
||||
);
|
||||
let footer = if picker.is_empty() {
|
||||
locale.agent_picker_empty()
|
||||
} else {
|
||||
locale.agent_picker_footer()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
footer,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn picker() -> AgentPicker {
|
||||
let mut navigation = AgentNavigationState::default();
|
||||
navigation.upsert("main", None, None, false);
|
||||
navigation.upsert(
|
||||
"agent-1",
|
||||
Some("Robie".to_string()),
|
||||
Some("explorer".to_string()),
|
||||
false,
|
||||
);
|
||||
AgentPicker::from_navigation(&navigation, Some("main"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_selects_rows_in_stable_navigation_order() {
|
||||
let mut picker = picker();
|
||||
assert_eq!(
|
||||
picker.handle_event(Event::Key(crossterm::event::KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentPickerAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
picker.handle_event(Event::Key(crossterm::event::KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentPickerAction::Select("agent-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_escape_cancels_and_narrow_render_stays_bounded() {
|
||||
let mut picker = picker();
|
||||
assert_eq!(
|
||||
picker.handle_event(Event::Key(crossterm::event::KeyEvent::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentPickerAction::Cancel
|
||||
);
|
||||
let mut terminal = Terminal::new(TestBackend::new(16, 7)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, frame.area(), &picker, Locale::EnUs))
|
||||
.expect("draw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_copy_and_status_render_for_every_product_locale() {
|
||||
let picker = picker();
|
||||
let locales = [
|
||||
Locale::ZhCn,
|
||||
Locale::ZhTw,
|
||||
Locale::EnUs,
|
||||
Locale::JaJp,
|
||||
Locale::KoKr,
|
||||
];
|
||||
for locale in locales {
|
||||
let mut terminal = Terminal::new(TestBackend::new(64, 12)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, frame.area(), &picker, locale))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let text = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let compact = |value: &str| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
.collect::<String>()
|
||||
};
|
||||
assert!(
|
||||
compact(&text).contains(&compact(locale.agent_picker_title())),
|
||||
"locale={locale:?} text={text:?}"
|
||||
);
|
||||
assert!(
|
||||
compact(&text).contains(&compact(locale.agent_picker_footer())),
|
||||
"locale={locale:?} text={text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Codex-shaped Agents Overview state and canonical thread projection.
|
||||
|
||||
use super::agents_overview_view::{AgentsOverviewGroup, AgentsOverviewRow, AgentsOverviewView};
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use anyhow::{anyhow, Result};
|
||||
use app_server_protocol::protocol::v2::ServerNotification;
|
||||
use app_server_protocol::protocol::v2::{Thread, TurnStatus, UserInput};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct AgentsOverviewState {
|
||||
pub(crate) threads: Vec<Thread>,
|
||||
/// True after the first server-backed seed has completed.
|
||||
pub(crate) initialized: bool,
|
||||
/// Monotonic refresh identity. A response from an older request is ignored.
|
||||
pub(crate) request_id: Option<u64>,
|
||||
/// A refresh requested while another refresh is in flight is coalesced.
|
||||
pub(crate) refresh_pending: bool,
|
||||
/// Thread ids with metadata changes that should be refreshed on the next pass.
|
||||
pub(crate) refresh_thread_ids: HashSet<String>,
|
||||
/// Abort handle for a background refresh task, when one is installed by a host.
|
||||
pub(crate) refresh_task: Option<AbortHandle>,
|
||||
/// Notifications received during a refresh, keyed by thread and replayed afterward.
|
||||
pub(crate) refresh_notifications: HashMap<String, Vec<ServerNotification>>,
|
||||
/// Whether the overview is currently rendered as the primary full-screen surface.
|
||||
pub(crate) rendered_full_screen: bool,
|
||||
/// Canonical order of rows currently visible after filtering/grouping.
|
||||
pub(crate) visible_thread_ids: Vec<String>,
|
||||
/// Codex-compatible shared view state for hosts that render through a selection surface.
|
||||
/// `view` remains the Lime terminal owner; this mirror lets adapters share the same shape
|
||||
/// without introducing a second interaction model.
|
||||
pub(crate) view_state: Arc<Mutex<super::agents_overview_view::AgentsOverviewView>>,
|
||||
/// Drafts are retained per thread while the command center is open or a root is resumed.
|
||||
pub(crate) input_states: HashMap<String, String>,
|
||||
pub(crate) view: AgentsOverviewView,
|
||||
pub(crate) refreshing: bool,
|
||||
pub(crate) refresh_generation: u64,
|
||||
}
|
||||
|
||||
impl AgentsOverviewState {
|
||||
pub(crate) fn new(primary_thread_id: Option<&str>) -> Self {
|
||||
let view = AgentsOverviewView::new(Vec::new(), primary_thread_id);
|
||||
Self {
|
||||
threads: Vec::new(),
|
||||
initialized: false,
|
||||
request_id: None,
|
||||
refresh_pending: false,
|
||||
refresh_thread_ids: HashSet::new(),
|
||||
refresh_task: None,
|
||||
refresh_notifications: HashMap::new(),
|
||||
rendered_full_screen: false,
|
||||
visible_thread_ids: Vec::new(),
|
||||
view_state: Arc::new(Mutex::new(view.clone())),
|
||||
input_states: HashMap::new(),
|
||||
view,
|
||||
refreshing: false,
|
||||
refresh_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_refresh(&mut self) -> u64 {
|
||||
if self.refreshing {
|
||||
self.refresh_pending = true;
|
||||
return self.refresh_generation;
|
||||
}
|
||||
self.refresh_generation = self.refresh_generation.wrapping_add(1);
|
||||
self.refreshing = true;
|
||||
self.request_id = Some(self.refresh_generation);
|
||||
self.refresh_generation
|
||||
}
|
||||
|
||||
pub(crate) fn replace_threads(
|
||||
&mut self,
|
||||
threads: Vec<Thread>,
|
||||
primary_thread_id: Option<&str>,
|
||||
) {
|
||||
// App Server's recent index is a seed, not an eviction list. Keep locally observed
|
||||
// sessions until an explicit archive/delete notification removes them.
|
||||
let mut by_id = self
|
||||
.threads
|
||||
.drain(..)
|
||||
.map(|thread| (thread.id.clone(), thread))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for thread in threads {
|
||||
by_id.insert(thread.id.clone(), thread);
|
||||
}
|
||||
self.threads = by_id.into_values().collect();
|
||||
let rows = build_rows(&self.threads, primary_thread_id);
|
||||
self.view.update_rows(rows);
|
||||
self.visible_thread_ids = self
|
||||
.view
|
||||
.visible_rows()
|
||||
.into_iter()
|
||||
.map(|row| row.thread.id.clone())
|
||||
.collect();
|
||||
self.sync_view_state();
|
||||
self.refreshing = false;
|
||||
self.initialized = true;
|
||||
self.request_id = None;
|
||||
self.refresh_thread_ids.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_refresh(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
threads: Vec<Thread>,
|
||||
primary_thread_id: Option<&str>,
|
||||
) -> bool {
|
||||
if generation != self.refresh_generation {
|
||||
return false;
|
||||
}
|
||||
self.replace_threads(threads, primary_thread_id);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn sync_view_state(&self) {
|
||||
if let Ok(mut state) = self.view_state.lock() {
|
||||
*state = self.view.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_refresh_pending(&mut self) -> bool {
|
||||
std::mem::take(&mut self.refresh_pending)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentsOverviewState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.refresh_task.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_rows(
|
||||
threads: &[Thread],
|
||||
primary_thread_id: Option<&str>,
|
||||
) -> Vec<AgentsOverviewRow> {
|
||||
let mut by_id = HashMap::new();
|
||||
for thread in threads.iter().filter(|thread| !thread.ephemeral) {
|
||||
by_id.insert(thread.id.clone(), thread);
|
||||
}
|
||||
let mut children: HashMap<&str, Vec<&Thread>> = HashMap::new();
|
||||
for thread in by_id.values() {
|
||||
if let Some(parent) = thread.parent_thread_id.as_deref() {
|
||||
children.entry(parent).or_default().push(thread);
|
||||
}
|
||||
}
|
||||
let mut rows = by_id
|
||||
.values()
|
||||
.filter(|thread| thread.parent_thread_id.is_none())
|
||||
.map(|thread| AgentsOverviewRow {
|
||||
thread: (*thread).clone(),
|
||||
group: agents_overview_group(thread, &children),
|
||||
is_current: primary_thread_id == Some(thread.id.as_str()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if rows.is_empty() {
|
||||
rows = by_id
|
||||
.values()
|
||||
.map(|thread| AgentsOverviewRow {
|
||||
thread: (*thread).clone(),
|
||||
group: AgentsOverviewGroup::for_status(&thread.status),
|
||||
is_current: primary_thread_id == Some(thread.id.as_str()),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
left.group
|
||||
.cmp(&right.group)
|
||||
.then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at))
|
||||
.then_with(|| left.thread.id.cmp(&right.thread.id))
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_group(
|
||||
thread: &Thread,
|
||||
children: &HashMap<&str, Vec<&Thread>>,
|
||||
) -> AgentsOverviewGroup {
|
||||
children.get(thread.id.as_str()).into_iter().flatten().fold(
|
||||
AgentsOverviewGroup::for_status(&thread.status),
|
||||
|group, child| group.min(agents_overview_group(child, children)),
|
||||
)
|
||||
}
|
||||
|
||||
impl super::App {
|
||||
pub(crate) fn open_agents_overview(&mut self) {
|
||||
let primary = self.primary_thread_id.as_deref();
|
||||
let mut overview = AgentsOverviewState::new(primary);
|
||||
overview.rendered_full_screen = true;
|
||||
self.agents_overview = Some(overview);
|
||||
if !self.composer.text().trim_start().starts_with("/subagents") {
|
||||
self.capture_current_thread_input();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn apply_agents_overview_thread_refresh(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
result: Result<Vec<Thread>, String>,
|
||||
) -> bool {
|
||||
let primary = self.primary_thread_id.clone();
|
||||
let Some(overview) = self.agents_overview.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
match result {
|
||||
Ok(threads) => overview.apply_refresh(generation, threads, primary.as_deref()),
|
||||
Err(error) => {
|
||||
overview.refreshing = false;
|
||||
overview.request_id = None;
|
||||
self.projection
|
||||
.set_status(format!("agents overview unavailable: {error}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn select_agents_overview_thread(&self) -> Option<String> {
|
||||
self.agents_overview
|
||||
.as_ref()
|
||||
.and_then(|overview| overview.view.selected_thread_id().map(str::to_owned))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn agents_overview_view(
|
||||
&self,
|
||||
threads: Vec<Thread>,
|
||||
selected_thread_id: Option<&str>,
|
||||
) -> AgentsOverviewView {
|
||||
let rows = build_rows(&threads, self.primary_thread_id.as_deref());
|
||||
AgentsOverviewView::new(rows, selected_thread_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn repaint_agents_overview(&mut self) {
|
||||
let primary = self.primary_thread_id.clone();
|
||||
if let Some(overview) = self.agents_overview.as_mut() {
|
||||
overview
|
||||
.view
|
||||
.update_rows(build_rows(&overview.threads, primary.as_deref()));
|
||||
overview.visible_thread_ids = overview
|
||||
.view
|
||||
.visible_rows()
|
||||
.into_iter()
|
||||
.map(|row| row.thread.id.clone())
|
||||
.collect();
|
||||
overview.sync_view_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background task through the current App Server session.
|
||||
pub(crate) async fn dispatch_agents_overview_task(
|
||||
&self,
|
||||
app_server: &AppServerSession,
|
||||
prompt: String,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> Result<(String, String)> {
|
||||
let thread = app_server
|
||||
.start_thread_with_session_start_source(
|
||||
cwd.unwrap_or_else(|| self.cwd.clone()),
|
||||
self.model.clone(),
|
||||
self.model_provider.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let thread_id = thread.thread.id.clone();
|
||||
let turn_id = self
|
||||
.submit_agents_overview_prompt(app_server, thread_id.clone(), prompt)
|
||||
.await?;
|
||||
Ok((thread_id, turn_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn submit_agents_overview_prompt(
|
||||
&self,
|
||||
app_server: &AppServerSession,
|
||||
thread_id: String,
|
||||
prompt: String,
|
||||
) -> Result<String> {
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(anyhow!("background task prompt must not be empty"));
|
||||
}
|
||||
app_server
|
||||
.turn_start(
|
||||
thread_id,
|
||||
vec![UserInput::Text {
|
||||
text: prompt,
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn stop_agents_overview_thread(
|
||||
&self,
|
||||
app_server: &AppServerSession,
|
||||
thread_id: String,
|
||||
) -> Result<Option<String>> {
|
||||
let thread = app_server.thread_read(thread_id.clone(), true).await?;
|
||||
let turn_id = thread
|
||||
.thread
|
||||
.turns
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|turn| turn.status == TurnStatus::InProgress)
|
||||
.map(|turn| turn.id);
|
||||
let Some(turn_id) = turn_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
app_server
|
||||
.turn_interrupt(thread_id, turn_id.clone())
|
||||
.await?;
|
||||
Ok(Some(turn_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agents_overview_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Ratatui rendering for the Codex-shaped Agents Overview.
|
||||
|
||||
use super::{AgentsOverviewGroup, AgentsOverviewInputMode, AgentsOverviewView};
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::locale::Locale;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewView, locale: Locale) {
|
||||
let width = area
|
||||
.width
|
||||
.saturating_mul(4)
|
||||
.saturating_div(5)
|
||||
.clamp(48, 100);
|
||||
let height = area
|
||||
.height
|
||||
.saturating_mul(4)
|
||||
.saturating_div(5)
|
||||
.clamp(10, 26);
|
||||
let popup = Rect::new(
|
||||
area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
||||
area.y
|
||||
.saturating_add(area.height.saturating_sub(height) / 2),
|
||||
width.min(area.width),
|
||||
height.min(area.height),
|
||||
);
|
||||
if popup.width < 20 || popup.height < 8 {
|
||||
return;
|
||||
}
|
||||
frame.render_widget(Clear, popup);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!(" {}", locale.agents_overview_title()),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.block(Block::default().borders(Borders::TOP)),
|
||||
chunks[0],
|
||||
);
|
||||
let mut counts = [0usize; 4];
|
||||
for row in view.visible_rows() {
|
||||
counts[row.group as usize] += 1;
|
||||
}
|
||||
let summary_text = format!(
|
||||
"{} {} {} {} {} {} {} {}",
|
||||
counts[0],
|
||||
locale.agents_overview_group_label("need input"),
|
||||
counts[1],
|
||||
locale.agents_overview_group_label("working"),
|
||||
counts[2],
|
||||
locale.agents_overview_group_label("ready"),
|
||||
counts[3],
|
||||
locale.agents_overview_group_label("finished")
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
summary_text,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
let body = chunks[2];
|
||||
let (list_area, details_area) = if body.width >= 90 {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(44),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(34),
|
||||
])
|
||||
.split(body);
|
||||
(layout[0], Some(layout[2]))
|
||||
} else {
|
||||
(body, None)
|
||||
};
|
||||
let items = view
|
||||
.visible_rows()
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let marker = match row.group {
|
||||
AgentsOverviewGroup::NeedsYou => "●",
|
||||
AgentsOverviewGroup::Working => "●",
|
||||
AgentsOverviewGroup::Ready => "○",
|
||||
AgentsOverviewGroup::Finished => "✓",
|
||||
};
|
||||
let title = row
|
||||
.thread
|
||||
.name
|
||||
.as_deref()
|
||||
.or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str()))
|
||||
.unwrap_or("Untitled task");
|
||||
let current = if row.is_current { " current" } else { "" };
|
||||
let project = if view.status_grouping() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" {} · {}",
|
||||
locale.agents_overview_group_label(row.group.label()),
|
||||
row.thread.cwd.display()
|
||||
)
|
||||
};
|
||||
ListItem::new(truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{marker} "), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(title.to_string()),
|
||||
Span::styled(current, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(project, Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
usize::from(list_area.width.saturating_sub(4)),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut state = ListState::default();
|
||||
if let Some(selected) = view.selected_index() {
|
||||
state.select(Some(selected.min(items.len().saturating_sub(1))));
|
||||
}
|
||||
frame.render_stateful_widget(
|
||||
List::new(items)
|
||||
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("> "),
|
||||
list_area,
|
||||
&mut state,
|
||||
);
|
||||
if let Some(details_area) = details_area {
|
||||
render_details(frame, details_area, view, locale);
|
||||
}
|
||||
let footer_text = if let Some(mode) = view.input_mode() {
|
||||
format!(
|
||||
"{}: {} · enter confirm · esc cancel",
|
||||
locale.agents_overview_input_prefix(matches!(mode, AgentsOverviewInputMode::Rename)),
|
||||
view.input()
|
||||
)
|
||||
} else if view.is_searching() {
|
||||
format!(
|
||||
"{}: {} · enter open · esc back",
|
||||
locale.agents_overview_search_prefix(),
|
||||
view.search()
|
||||
)
|
||||
} else {
|
||||
locale.agents_overview_footer().to_string()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
footer_text,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_details(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewView, locale: Locale) {
|
||||
let Some(row) = view.selected_row() else {
|
||||
return;
|
||||
};
|
||||
let title = row
|
||||
.thread
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str()))
|
||||
.unwrap_or("Untitled task");
|
||||
let status = locale.agents_overview_group_label(row.group.label());
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
"Task details",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::default(),
|
||||
Line::from(Span::styled(
|
||||
title,
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(vec![
|
||||
Span::styled(status, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(
|
||||
if row.is_current { " current" } else { "" },
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from(Span::styled(
|
||||
"Project",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(row.thread.cwd.display().to_string()),
|
||||
Line::default(),
|
||||
Line::from(Span::styled(
|
||||
"Latest activity",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(if row.thread.preview.is_empty() {
|
||||
"No activity yet.".to_string()
|
||||
} else {
|
||||
row.thread.preview.clone()
|
||||
}),
|
||||
];
|
||||
if let Some(branch) = row
|
||||
.thread
|
||||
.git_info
|
||||
.as_ref()
|
||||
.and_then(|git| git.branch.as_ref())
|
||||
{
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Branch",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
lines.push(Line::from(branch.clone()));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::agents_overview_view::AgentsOverviewRow;
|
||||
use app_server_protocol::protocol::v2::{SessionSource, ThreadHistoryMode, ThreadStatus};
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn row(id: &str) -> AgentsOverviewRow {
|
||||
AgentsOverviewRow {
|
||||
thread: app_server_protocol::protocol::v2::Thread {
|
||||
id: id.to_string(),
|
||||
extra: None,
|
||||
session_id: id.to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: "preview".to_string(),
|
||||
ephemeral: false,
|
||||
section: None,
|
||||
section_entered_at: None,
|
||||
project_id: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
model_provider: "test".to_string(),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
recency_at: Some(1),
|
||||
status: ThreadStatus::Idle,
|
||||
path: None,
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
cli_version: "test".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
can_accept_direct_input: Some(true),
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some(id.to_string()),
|
||||
turns: Vec::new(),
|
||||
},
|
||||
group: AgentsOverviewGroup::Ready,
|
||||
is_current: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_render_is_bounded_on_wide_and_narrow_terminals() {
|
||||
let view = AgentsOverviewView::new(vec![row("alpha"), row("beta")], None);
|
||||
for (width, height) in [(120, 30), (24, 10), (16, 8)] {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, frame.area(), &view, Locale::EnUs))
|
||||
.expect("draw");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_render_shows_localized_new_task_input_without_overflow() {
|
||||
let mut view = AgentsOverviewView::new(vec![row("alpha")], None);
|
||||
view.handle_event(crossterm::event::Event::Key(
|
||||
crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Char('n'),
|
||||
crossterm::event::KeyModifiers::CONTROL,
|
||||
),
|
||||
));
|
||||
for character in "检查任务".chars() {
|
||||
view.handle_event(crossterm::event::Event::Key(
|
||||
crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Char(character),
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut terminal = Terminal::new(TestBackend::new(24, 10)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, frame.area(), &view, Locale::ZhCn))
|
||||
.expect("draw");
|
||||
let text = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>();
|
||||
let compact = text.chars().filter(|character| !character.is_whitespace());
|
||||
let compact = compact.collect::<String>();
|
||||
assert!(compact.contains("新建任务"), "{text}");
|
||||
assert!(compact.contains("检查任务"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use super::*;
|
||||
use crate::app::agents_overview_view::AgentsOverviewGroup;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
AgentMessageDeltaNotification, ServerNotification, SessionSource, ThreadHistoryMode,
|
||||
ThreadNameUpdatedNotification, ThreadStatus,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn thread(id: &str, parent: Option<&str>, status: ThreadStatus) -> Thread {
|
||||
Thread {
|
||||
id: id.to_string(),
|
||||
extra: None,
|
||||
session_id: id.to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: parent.map(str::to_string),
|
||||
preview: String::new(),
|
||||
ephemeral: false,
|
||||
section: None,
|
||||
section_entered_at: None,
|
||||
project_id: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
model_provider: "test".to_string(),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
recency_at: Some(1),
|
||||
status,
|
||||
path: None,
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
cli_version: "test".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
can_accept_direct_input: Some(true),
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some(id.to_string()),
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_group_bubbles_child_status_and_hides_ephemeral_threads() {
|
||||
let mut root = thread("root", None, ThreadStatus::Idle);
|
||||
let child = thread(
|
||||
"child",
|
||||
Some("root"),
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
);
|
||||
let mut ephemeral = thread("ephemeral", None, ThreadStatus::Idle);
|
||||
ephemeral.ephemeral = true;
|
||||
let rows = build_rows(&[root.clone(), child, ephemeral], Some("root"));
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].group, AgentsOverviewGroup::Working);
|
||||
root.status = ThreadStatus::SystemError;
|
||||
assert_eq!(
|
||||
build_rows(&[root], None)[0].group,
|
||||
AgentsOverviewGroup::NeedsYou
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_refresh_ignores_stale_generation() {
|
||||
let mut state = AgentsOverviewState::new(None);
|
||||
let first = state.begin_refresh();
|
||||
state.refreshing = false;
|
||||
state.request_id = None;
|
||||
let second = state.begin_refresh();
|
||||
assert!(!state.apply_refresh(first, Vec::new(), None));
|
||||
assert!(state.apply_refresh(second, Vec::new(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_refresh_retains_locally_observed_threads() {
|
||||
let mut state = AgentsOverviewState::new(None);
|
||||
state.replace_threads(vec![thread("local", None, ThreadStatus::Idle)], None);
|
||||
state.replace_threads(vec![thread("recent", None, ThreadStatus::Idle)], None);
|
||||
let ids = state
|
||||
.threads
|
||||
.iter()
|
||||
.map(|thread| thread.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(ids.contains(&"local"));
|
||||
assert!(ids.contains(&"recent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_refresh_coalesces_requests_and_exposes_codex_state_fields() {
|
||||
let mut state = AgentsOverviewState::new(None);
|
||||
assert!(!state.initialized);
|
||||
assert!(state.request_id.is_none());
|
||||
let first = state.begin_refresh();
|
||||
assert_eq!(state.request_id, Some(first));
|
||||
assert!(state.refreshing);
|
||||
let second = state.begin_refresh();
|
||||
assert!(state.refresh_pending);
|
||||
assert_eq!(first, second);
|
||||
assert!(state.take_refresh_pending());
|
||||
assert!(!state.take_refresh_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_buffers_latest_notification_during_refresh() {
|
||||
let mut app = super::super::App::default();
|
||||
let mut state = AgentsOverviewState::new(None);
|
||||
state.replace_threads(vec![thread("background", None, ThreadStatus::Idle)], None);
|
||||
state.refreshing = true;
|
||||
app.agents_overview = Some(state);
|
||||
|
||||
let notification = ServerNotification::ThreadNameUpdated(ThreadNameUpdatedNotification {
|
||||
thread_id: "background".to_string(),
|
||||
thread_name: Some("renamed".to_string()),
|
||||
});
|
||||
app.track_agents_overview_notification(¬ification);
|
||||
app.track_agents_overview_notification(&ServerNotification::ThreadNameUpdated(
|
||||
ThreadNameUpdatedNotification {
|
||||
thread_id: "background".to_string(),
|
||||
thread_name: Some("renamed-again".to_string()),
|
||||
},
|
||||
));
|
||||
|
||||
let overview = app.agents_overview.as_ref().expect("overview state");
|
||||
assert_eq!(overview.refresh_notifications["background"].len(), 1);
|
||||
assert_eq!(overview.visible_thread_ids, vec!["background"]);
|
||||
assert_eq!(
|
||||
overview
|
||||
.view_state
|
||||
.lock()
|
||||
.expect("view state")
|
||||
.visible_rows()[0]
|
||||
.thread
|
||||
.name
|
||||
.as_deref(),
|
||||
Some("renamed-again")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_thread_notifications_do_not_mutate_the_current_projection() {
|
||||
let mut app = super::super::App::default();
|
||||
app.set_thread_id("current".to_string());
|
||||
app.agents_overview = Some(AgentsOverviewState::new(Some("current")));
|
||||
|
||||
app.apply_notification(ServerNotification::AgentMessageDelta(
|
||||
AgentMessageDeltaNotification {
|
||||
thread_id: "background".to_string(),
|
||||
turn_id: "turn-background".to_string(),
|
||||
item_id: "message-background".to_string(),
|
||||
delta: "background answer".to_string(),
|
||||
},
|
||||
));
|
||||
|
||||
assert!(app.projection.entries().is_empty());
|
||||
app.apply_notification(ServerNotification::AgentMessageDelta(
|
||||
AgentMessageDeltaNotification {
|
||||
thread_id: "current".to_string(),
|
||||
turn_id: "turn-current".to_string(),
|
||||
item_id: "message-current".to_string(),
|
||||
delta: "current answer".to_string(),
|
||||
},
|
||||
));
|
||||
assert_eq!(app.projection.final_answer(), "current answer");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! App Server thread refresh for Agents Overview.
|
||||
|
||||
use super::App;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use app_server_protocol::protocol::v2::{
|
||||
ServerNotification, SortDirection, ThreadListParams, ThreadSortKey, ThreadStatus,
|
||||
};
|
||||
|
||||
impl App {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn track_agents_overview_notification(&mut self, notification: &ServerNotification) {
|
||||
let primary = self.primary_thread_id.clone();
|
||||
let Some(overview) = self.agents_overview.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let thread_id = match notification {
|
||||
ServerNotification::ThreadStarted(params) => Some(params.thread.id.as_str()),
|
||||
ServerNotification::ThreadArchived(params) => Some(params.thread_id.as_str()),
|
||||
ServerNotification::ThreadDeleted(params) => Some(params.thread_id.as_str()),
|
||||
ServerNotification::ThreadClosed(params) => Some(params.thread_id.as_str()),
|
||||
ServerNotification::ThreadStatusChanged(params) => Some(params.thread_id.as_str()),
|
||||
ServerNotification::ThreadNameUpdated(params) => Some(params.thread_id.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
match notification {
|
||||
ServerNotification::ThreadStarted(params) if !params.thread.ephemeral => {
|
||||
if let Some(existing) = overview
|
||||
.threads
|
||||
.iter_mut()
|
||||
.find(|thread| thread.id == params.thread.id)
|
||||
{
|
||||
*existing = params.thread.clone();
|
||||
} else {
|
||||
overview.threads.push(params.thread.clone());
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadArchived(params) => {
|
||||
overview
|
||||
.threads
|
||||
.retain(|thread| thread.id != params.thread_id);
|
||||
}
|
||||
ServerNotification::ThreadDeleted(params) => {
|
||||
overview
|
||||
.threads
|
||||
.retain(|thread| thread.id != params.thread_id);
|
||||
}
|
||||
ServerNotification::ThreadClosed(params) => {
|
||||
if let Some(thread) = overview
|
||||
.threads
|
||||
.iter_mut()
|
||||
.find(|thread| thread.id == params.thread_id)
|
||||
{
|
||||
thread.status = ThreadStatus::NotLoaded;
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadStatusChanged(params) => {
|
||||
if let Some(thread) = overview
|
||||
.threads
|
||||
.iter_mut()
|
||||
.find(|thread| thread.id == params.thread_id)
|
||||
{
|
||||
thread.status = params.status.clone();
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadNameUpdated(params) => {
|
||||
if let Some(thread) = overview
|
||||
.threads
|
||||
.iter_mut()
|
||||
.find(|thread| thread.id == params.thread_id)
|
||||
{
|
||||
thread.name = params.thread_name.clone();
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
if let Some(thread_id) = thread_id {
|
||||
overview.refresh_thread_ids.insert(thread_id.to_string());
|
||||
if overview.refreshing {
|
||||
let pending = overview
|
||||
.refresh_notifications
|
||||
.entry(thread_id.to_string())
|
||||
.or_default();
|
||||
pending.retain(|previous| {
|
||||
std::mem::discriminant(previous) != std::mem::discriminant(notification)
|
||||
});
|
||||
pending.push(notification.clone());
|
||||
}
|
||||
}
|
||||
overview
|
||||
.view
|
||||
.update_rows(super::agents_overview::build_rows(
|
||||
&overview.threads,
|
||||
primary.as_deref(),
|
||||
));
|
||||
overview.visible_thread_ids = overview
|
||||
.view
|
||||
.visible_rows()
|
||||
.into_iter()
|
||||
.map(|row| row.thread.id.clone())
|
||||
.collect();
|
||||
overview.sync_view_state();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn refresh_changed_agents_overview_threads(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
) -> Result<()> {
|
||||
self.refresh_agents_overview_threads(app_server).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn start_agents_overview_refresh(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
) -> Result<()> {
|
||||
self.refresh_agents_overview_threads(app_server).await
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_agents_overview_threads(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
) -> Result<()> {
|
||||
let Some(overview) = self.agents_overview.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
overview
|
||||
.refresh_thread_ids
|
||||
.extend(overview.threads.iter().map(|thread| thread.id.clone()));
|
||||
if overview.refreshing {
|
||||
overview.refresh_pending = true;
|
||||
return Ok(());
|
||||
}
|
||||
let generation = overview.begin_refresh();
|
||||
let result = async {
|
||||
let mut cursor = None;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let page = app_server
|
||||
.thread_list(ThreadListParams {
|
||||
cursor,
|
||||
limit: Some(100),
|
||||
sort_key: Some(ThreadSortKey::RecencyAt),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
archived: Some(false),
|
||||
..ThreadListParams::default()
|
||||
})
|
||||
.await
|
||||
.context("failed to refresh agents overview threads")?;
|
||||
threads.extend(page.data);
|
||||
let Some(next_cursor) = page.next_cursor else {
|
||||
break;
|
||||
};
|
||||
if !seen.insert(next_cursor.clone()) {
|
||||
bail!("agents overview thread list repeated cursor {next_cursor}");
|
||||
}
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(threads)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(threads) => {
|
||||
let primary = self.primary_thread_id.clone();
|
||||
if let Some(overview) = self.agents_overview.as_mut() {
|
||||
overview.apply_refresh(generation, threads, primary.as_deref());
|
||||
}
|
||||
let buffered = self
|
||||
.agents_overview
|
||||
.as_mut()
|
||||
.map(|overview| {
|
||||
overview
|
||||
.refresh_notifications
|
||||
.drain()
|
||||
.flat_map(|(_, notifications)| notifications)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for notification in buffered {
|
||||
self.track_agents_overview_notification(¬ification);
|
||||
}
|
||||
let refresh_pending = self
|
||||
.agents_overview
|
||||
.as_mut()
|
||||
.is_some_and(|overview| overview.take_refresh_pending());
|
||||
self.projection.set_status("agents overview refreshed");
|
||||
if refresh_pending {
|
||||
Box::pin(self.refresh_agents_overview_threads(app_server)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(overview) = self.agents_overview.as_mut() {
|
||||
overview.refreshing = false;
|
||||
overview.request_id = None;
|
||||
}
|
||||
self.projection
|
||||
.set_status(format!("agents overview unavailable: {error}"));
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
//! Codex-shaped Agents Overview interaction state.
|
||||
//!
|
||||
//! The view owns only terminal selection and filtering. Thread metadata and lifecycle remain
|
||||
//! owned by App Server and are refreshed through `agents_overview_threads`.
|
||||
|
||||
#[path = "agents_overview_render.rs"]
|
||||
mod render;
|
||||
pub(crate) use render::render;
|
||||
|
||||
use app_server_protocol::protocol::v2::{Thread, ThreadActiveFlag, ThreadStatus};
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::keymap::AgentsKeymap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(crate) enum AgentsOverviewGroup {
|
||||
NeedsYou,
|
||||
Working,
|
||||
Ready,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl AgentsOverviewGroup {
|
||||
pub(crate) fn for_status(status: &ThreadStatus) -> Self {
|
||||
match status {
|
||||
ThreadStatus::Active { active_flags }
|
||||
if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval)
|
||||
|| active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) =>
|
||||
{
|
||||
Self::NeedsYou
|
||||
}
|
||||
ThreadStatus::Active { .. } => Self::Working,
|
||||
ThreadStatus::Idle => Self::Ready,
|
||||
ThreadStatus::SystemError => Self::NeedsYou,
|
||||
ThreadStatus::NotLoaded => Self::Finished,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::NeedsYou => "Needs input",
|
||||
Self::Working => "Working",
|
||||
Self::Ready => "Ready",
|
||||
Self::Finished => "Finished",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AgentsOverviewRow {
|
||||
pub(crate) thread: Thread,
|
||||
pub(crate) group: AgentsOverviewGroup,
|
||||
pub(crate) is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AgentsOverviewInputMode {
|
||||
NewTask,
|
||||
Rename,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AgentsOverviewAction {
|
||||
None,
|
||||
Cancel,
|
||||
Refresh,
|
||||
Select,
|
||||
Dispatch {
|
||||
prompt: String,
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
Rename {
|
||||
thread_id: String,
|
||||
name: String,
|
||||
},
|
||||
Stop {
|
||||
thread_id: String,
|
||||
},
|
||||
OpenResumePicker,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct AgentsOverviewView {
|
||||
pub(crate) rows: Vec<AgentsOverviewRow>,
|
||||
selected: usize,
|
||||
search: String,
|
||||
searching: bool,
|
||||
status_grouping: bool,
|
||||
input: String,
|
||||
input_mode: Option<AgentsOverviewInputMode>,
|
||||
agents_keymap: AgentsKeymap,
|
||||
}
|
||||
|
||||
impl AgentsOverviewView {
|
||||
pub(crate) fn new(rows: Vec<AgentsOverviewRow>, selected_thread_id: Option<&str>) -> Self {
|
||||
let mut view = Self {
|
||||
rows,
|
||||
selected: 0,
|
||||
search: String::new(),
|
||||
searching: false,
|
||||
status_grouping: false,
|
||||
input: String::new(),
|
||||
input_mode: None,
|
||||
agents_keymap: AgentsKeymap,
|
||||
};
|
||||
let visible = view.visible_rows();
|
||||
view.selected = selected_thread_id
|
||||
.and_then(|thread_id| visible.iter().position(|row| row.thread.id == thread_id))
|
||||
.or_else(|| visible.iter().position(|row| row.is_current))
|
||||
.unwrap_or(0);
|
||||
view
|
||||
}
|
||||
|
||||
pub(crate) fn selected_thread_id(&self) -> Option<&str> {
|
||||
self.visible_rows()
|
||||
.get(self.selected)
|
||||
.map(|row| row.thread.id.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn selected_index(&self) -> Option<usize> {
|
||||
(!self.visible_rows().is_empty()).then_some(self.selected)
|
||||
}
|
||||
|
||||
pub(crate) fn is_searching(&self) -> bool {
|
||||
self.searching
|
||||
}
|
||||
|
||||
pub(crate) fn search(&self) -> &str {
|
||||
&self.search
|
||||
}
|
||||
|
||||
pub(crate) fn input(&self) -> &str {
|
||||
&self.input
|
||||
}
|
||||
|
||||
pub(crate) fn input_mode(&self) -> Option<AgentsOverviewInputMode> {
|
||||
self.input_mode
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn status_grouping(&self) -> bool {
|
||||
self.status_grouping
|
||||
}
|
||||
|
||||
pub(crate) fn update_rows(&mut self, rows: Vec<AgentsOverviewRow>) {
|
||||
let selected = self.selected_thread_id().map(str::to_owned);
|
||||
let search = self.search.clone();
|
||||
let searching = self.searching;
|
||||
let status_grouping = self.status_grouping;
|
||||
let input = self.input.clone();
|
||||
let input_mode = self.input_mode;
|
||||
*self = Self::new(rows, selected.as_deref());
|
||||
self.search = search;
|
||||
self.searching = searching;
|
||||
self.status_grouping = status_grouping;
|
||||
self.input = input;
|
||||
self.input_mode = input_mode;
|
||||
self.selected = selected
|
||||
.as_deref()
|
||||
.and_then(|thread_id| {
|
||||
self.visible_rows()
|
||||
.iter()
|
||||
.position(|row| row.thread.id == thread_id)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.selected
|
||||
.min(self.visible_rows().len().saturating_sub(1))
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn visible_rows(&self) -> Vec<&AgentsOverviewRow> {
|
||||
let query = self.search.trim().to_ascii_lowercase();
|
||||
let mut rows = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
query.is_empty()
|
||||
|| row
|
||||
.thread
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains(&query)
|
||||
|| row.thread.preview.to_ascii_lowercase().contains(&query)
|
||||
|| row
|
||||
.thread
|
||||
.cwd
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains(&query)
|
||||
|| row.thread.id.to_ascii_lowercase().contains(&query)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if self.status_grouping {
|
||||
rows.sort_by(|left, right| {
|
||||
left.group
|
||||
.cmp(&right.group)
|
||||
.then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at))
|
||||
.then_with(|| left.thread.id.cmp(&right.thread.id))
|
||||
});
|
||||
} else {
|
||||
rows.sort_by(|left, right| {
|
||||
left.thread
|
||||
.cwd
|
||||
.cmp(&right.thread.cwd)
|
||||
.then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at))
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, event: Event) -> AgentsOverviewAction {
|
||||
if let Event::Paste(text) = event {
|
||||
if let Some(mode) = self.input_mode {
|
||||
match mode {
|
||||
AgentsOverviewInputMode::NewTask | AgentsOverviewInputMode::Rename => {
|
||||
self.input.push_str(&text);
|
||||
}
|
||||
}
|
||||
} else if self.searching {
|
||||
if let Some(query) = crate::clipboard_paste::normalize_pasted_search_query(&text) {
|
||||
self.search.push_str(&query);
|
||||
}
|
||||
self.selected = self
|
||||
.selected
|
||||
.min(self.visible_rows().len().saturating_sub(1));
|
||||
}
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
let Event::Key(key) = event else {
|
||||
return AgentsOverviewAction::None;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
return AgentsOverviewAction::Cancel;
|
||||
}
|
||||
if let Some(mode) = self.input_mode {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.input.clear();
|
||||
self.input_mode = None;
|
||||
}
|
||||
KeyCode::Backspace if key.modifiers.is_empty() => {
|
||||
self.input.pop();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let input = std::mem::take(&mut self.input);
|
||||
self.input_mode = None;
|
||||
if input.trim().is_empty() {
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
match mode {
|
||||
AgentsOverviewInputMode::NewTask => {
|
||||
let cwd = (!self.status_grouping)
|
||||
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
|
||||
.flatten();
|
||||
return AgentsOverviewAction::Dispatch { prompt: input, cwd };
|
||||
}
|
||||
AgentsOverviewInputMode::Rename => {
|
||||
if let Some(thread_id) =
|
||||
self.selected_row().map(|row| row.thread.id.clone())
|
||||
{
|
||||
return AgentsOverviewAction::Rename {
|
||||
thread_id,
|
||||
name: input.trim().to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(character) if is_plain_text_key_event(key) => {
|
||||
self.input.push(character);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
if key.code == KeyCode::Esc {
|
||||
if self.searching {
|
||||
self.searching = false;
|
||||
self.search.clear();
|
||||
self.selected = 0;
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
return AgentsOverviewAction::Cancel;
|
||||
}
|
||||
if (key.code == KeyCode::Char('/') && key.modifiers.is_empty())
|
||||
|| self.agents_keymap.search(key)
|
||||
{
|
||||
self.searching = !self.searching;
|
||||
if !self.searching {
|
||||
self.search.clear();
|
||||
}
|
||||
self.selected = 0;
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
if self.searching {
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
self.search.pop();
|
||||
}
|
||||
KeyCode::Char(character) if is_plain_text_key_event(key) => {
|
||||
self.search.push(character);
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(false),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(true),
|
||||
KeyCode::Enter => {
|
||||
self.searching = false;
|
||||
return AgentsOverviewAction::Select;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.selected = self
|
||||
.selected
|
||||
.min(self.visible_rows().len().saturating_sub(1));
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(false),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(true),
|
||||
KeyCode::Enter => return AgentsOverviewAction::Select,
|
||||
KeyCode::Char('o') if self.agents_keymap.resume(key) => {
|
||||
return AgentsOverviewAction::OpenResumePicker;
|
||||
}
|
||||
KeyCode::Char('n') if self.agents_keymap.new_task(key) => {
|
||||
self.input.clear();
|
||||
self.input_mode = Some(AgentsOverviewInputMode::NewTask);
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
KeyCode::Char('r') if self.agents_keymap.rename(key) => {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.input = row.thread.name.clone().unwrap_or_default();
|
||||
self.input_mode = Some(AgentsOverviewInputMode::Rename);
|
||||
}
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
KeyCode::Char('x') if self.agents_keymap.stop(key) => {
|
||||
if let Some(row) = self.selected_row() {
|
||||
if matches!(row.thread.status, ThreadStatus::Active { .. }) {
|
||||
return AgentsOverviewAction::Stop {
|
||||
thread_id: row.thread.id.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
KeyCode::Char('s') if self.agents_keymap.toggle_grouping(key) => {
|
||||
self.status_grouping = !self.status_grouping;
|
||||
self.selected = self
|
||||
.selected
|
||||
.min(self.visible_rows().len().saturating_sub(1));
|
||||
return AgentsOverviewAction::None;
|
||||
}
|
||||
KeyCode::Char('r') if key.modifiers.is_empty() => {
|
||||
return AgentsOverviewAction::Refresh;
|
||||
}
|
||||
KeyCode::Char('g') if key.modifiers.is_empty() => {
|
||||
self.status_grouping = !self.status_grouping;
|
||||
self.selected = self
|
||||
.selected
|
||||
.min(self.visible_rows().len().saturating_sub(1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
AgentsOverviewAction::None
|
||||
}
|
||||
|
||||
pub(crate) fn selected_row(&self) -> Option<&AgentsOverviewRow> {
|
||||
self.visible_rows().get(self.selected).copied()
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, forward: bool) {
|
||||
let count = self.visible_rows().len();
|
||||
if count == 0 {
|
||||
self.selected = 0;
|
||||
return;
|
||||
}
|
||||
self.selected = if forward {
|
||||
(self.selected + 1) % count
|
||||
} else {
|
||||
self.selected.checked_sub(1).unwrap_or(count - 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::ThreadHistoryMode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn thread(id: &str, status: ThreadStatus, updated_at: i64) -> Thread {
|
||||
Thread {
|
||||
id: id.to_string(),
|
||||
extra: None,
|
||||
session_id: format!("session-{id}"),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: format!("preview-{id}"),
|
||||
ephemeral: false,
|
||||
section: None,
|
||||
section_entered_at: None,
|
||||
project_id: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
model_provider: "test".to_string(),
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
recency_at: Some(updated_at),
|
||||
status,
|
||||
path: None,
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
cli_version: "test".to_string(),
|
||||
source: app_server_protocol::protocol::v2::SessionSource::Cli,
|
||||
can_accept_direct_input: Some(true),
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: Some(id.to_string()),
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(id: &str, group: AgentsOverviewGroup, current: bool) -> AgentsOverviewRow {
|
||||
AgentsOverviewRow {
|
||||
thread: thread(id, ThreadStatus::Idle, 1),
|
||||
group,
|
||||
is_current: current,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_groups_and_selects_current_thread() {
|
||||
let view = AgentsOverviewView::new(
|
||||
vec![
|
||||
row("ready", AgentsOverviewGroup::Ready, false),
|
||||
row("main", AgentsOverviewGroup::Working, true),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(view.selected_thread_id(), Some("main"));
|
||||
assert_eq!(
|
||||
AgentsOverviewGroup::for_status(&ThreadStatus::SystemError),
|
||||
AgentsOverviewGroup::NeedsYou
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_search_filters_and_enter_selects() {
|
||||
let mut view = AgentsOverviewView::new(
|
||||
vec![
|
||||
row("alpha", AgentsOverviewGroup::Ready, false),
|
||||
row("beta", AgentsOverviewGroup::Finished, false),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('/'),
|
||||
KeyModifiers::NONE
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('l'),
|
||||
KeyModifiers::NONE
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
assert_eq!(view.visible_rows().len(), 1);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE
|
||||
))),
|
||||
AgentsOverviewAction::Select
|
||||
);
|
||||
assert_eq!(view.selected_thread_id(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_escape_and_refresh_are_explicit_actions() {
|
||||
let mut view = AgentsOverviewView::default();
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('r'),
|
||||
KeyModifiers::NONE
|
||||
))),
|
||||
AgentsOverviewAction::Refresh
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
|
||||
AgentsOverviewAction::Cancel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_codex_shortcuts_dispatch_rename_stop_and_resume() {
|
||||
let mut view = AgentsOverviewView::new(
|
||||
vec![row("ready", AgentsOverviewGroup::Ready, false), {
|
||||
let mut active = row("active", AgentsOverviewGroup::Working, true);
|
||||
active.thread.status = ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
};
|
||||
active
|
||||
}],
|
||||
Some("active"),
|
||||
);
|
||||
assert_eq!(view.selected_thread_id(), Some("active"));
|
||||
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('n'),
|
||||
KeyModifiers::CONTROL,
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
for character in "run checks".chars() {
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char(character),
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentsOverviewAction::Dispatch {
|
||||
prompt: "run checks".to_string(),
|
||||
cwd: Some(PathBuf::from("/workspace")),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('r'),
|
||||
KeyModifiers::CONTROL,
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentsOverviewAction::Rename {
|
||||
thread_id: "active".to_string(),
|
||||
name: "active".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('x'),
|
||||
KeyModifiers::CONTROL,
|
||||
))),
|
||||
AgentsOverviewAction::Stop {
|
||||
thread_id: "active".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('o'),
|
||||
KeyModifiers::CONTROL,
|
||||
))),
|
||||
AgentsOverviewAction::OpenResumePicker
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_rename_accepts_shifted_characters() {
|
||||
let mut view = AgentsOverviewView::new(
|
||||
vec![row("active", AgentsOverviewGroup::Working, true)],
|
||||
Some("active"),
|
||||
);
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char('r'),
|
||||
KeyModifiers::CONTROL,
|
||||
))),
|
||||
AgentsOverviewAction::None
|
||||
);
|
||||
for _ in 0.."active".len() {
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Backspace,
|
||||
KeyModifiers::NONE,
|
||||
)));
|
||||
}
|
||||
for (character, modifiers) in [
|
||||
('G', KeyModifiers::SHIFT),
|
||||
('a', KeyModifiers::NONE),
|
||||
('t', KeyModifiers::NONE),
|
||||
('e', KeyModifiers::NONE),
|
||||
] {
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Char(character),
|
||||
modifiers,
|
||||
)));
|
||||
}
|
||||
assert_eq!(
|
||||
view.handle_event(Event::Key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE,
|
||||
))),
|
||||
AgentsOverviewAction::Rename {
|
||||
thread_id: "active".to_string(),
|
||||
name: "Gate".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Thread targeting helpers for app-server requests and notifications.
|
||||
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest};
|
||||
|
||||
pub(crate) fn server_request_thread_id(request: &ServerRequest) -> Option<&str> {
|
||||
match request {
|
||||
ServerRequest::CurrentTimeRead { params, .. } => Some(params.thread_id.as_str()),
|
||||
ServerRequest::McpServerElicitationRequest { params, .. } => {
|
||||
Some(params.thread_id.as_str())
|
||||
}
|
||||
ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => {
|
||||
Some(params.thread_id.as_str())
|
||||
}
|
||||
ServerRequest::ItemFileChangeRequestApproval { params, .. } => {
|
||||
Some(params.thread_id.as_str())
|
||||
}
|
||||
ServerRequest::ItemPermissionsRequestApproval { params, .. } => {
|
||||
Some(params.thread_id.as_str())
|
||||
}
|
||||
ServerRequest::DynamicToolCall { params, .. } => Some(params.thread_id.as_str()),
|
||||
ServerRequest::ItemToolRequestUserInput { params, .. } => Some(params.thread_id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum ServerNotificationThreadTarget {
|
||||
Thread(String),
|
||||
InvalidThreadId(String),
|
||||
AppScoped,
|
||||
Global,
|
||||
}
|
||||
|
||||
pub(super) fn server_notification_thread_target(
|
||||
notification: &ServerNotification,
|
||||
) -> ServerNotificationThreadTarget {
|
||||
let thread_id = match notification {
|
||||
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
|
||||
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::StrictReviewRequired(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::Error(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::McpServerOauthLoginCompleted(notification) => {
|
||||
notification.thread_id.as_deref()
|
||||
}
|
||||
ServerNotification::McpServerStatusUpdated(notification) => {
|
||||
let Some(thread_id) = notification.thread_id.as_deref() else {
|
||||
return ServerNotificationThreadTarget::AppScoped;
|
||||
};
|
||||
Some(thread_id)
|
||||
}
|
||||
ServerNotification::EnvironmentConnected(notification)
|
||||
| ServerNotification::EnvironmentDisconnected(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ScheduledTaskRunUpdated(notification) => {
|
||||
notification.thread_id.as_deref()
|
||||
}
|
||||
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()),
|
||||
ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadDeleted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadReverted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ThreadNameUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadStatusChanged(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ItemAutoApprovalReviewStarted(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ItemAutoApprovalReviewCompleted(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::AgentMessageDelta(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::CommandExecutionOutputDelta(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::CommandExecutionTerminalInteraction(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::FileChangePatchUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::McpToolCallProgress(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ReasoningSummaryTextDelta(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ReasoningSummaryPartAdded(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ReasoningTextDelta(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::TurnModerationMetadata(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadSettingsUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadTokenUsageUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadGoalUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadGoalCleared(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadQueueChanged(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadProjectUpdated(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ServerRequestResolved(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ConfigWarning(_)
|
||||
| ServerNotification::SkillsChanged(_)
|
||||
| ServerNotification::McpServerEventStream(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::ScheduledTaskChanged(_)
|
||||
| ServerNotification::ModelListUpdated(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::ProcessOutputDelta(_)
|
||||
| ServerNotification::ProcessExited(_)
|
||||
| ServerNotification::CommandExecOutputDelta(_)
|
||||
| ServerNotification::ProjectChanged(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_) => None,
|
||||
};
|
||||
|
||||
match thread_id {
|
||||
Some(thread_id) if thread_id.trim().is_empty() => {
|
||||
ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string())
|
||||
}
|
||||
Some(thread_id) => ServerNotificationThreadTarget::Thread(thread_id.to_string()),
|
||||
None => ServerNotificationThreadTarget::Global,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
server_notification_thread_target, server_request_thread_id, ServerNotificationThreadTarget,
|
||||
};
|
||||
use app_server_protocol::protocol::v2::{
|
||||
AgentMessageDeltaNotification, CurrentTimeReadParams, McpServerStartupState,
|
||||
McpServerStatusUpdatedNotification, ServerNotification, ServerRequest, WarningNotification,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
|
||||
#[test]
|
||||
fn warning_notifications_without_threads_are_global() {
|
||||
let notification = ServerNotification::Warning(WarningNotification {
|
||||
thread_id: None,
|
||||
message: "warning".to_string(),
|
||||
code: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
server_notification_thread_target(¬ification),
|
||||
ServerNotificationThreadTarget::Global
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_notifications_route_to_their_thread() {
|
||||
let notification = ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
delta: "answer".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
server_notification_thread_target(¬ification),
|
||||
ServerNotificationThreadTarget::Thread("thread-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_startup_notifications_without_threads_are_app_scoped() {
|
||||
let notification =
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
thread_id: None,
|
||||
name: "server".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("not available".to_string()),
|
||||
failure_reason: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
server_notification_thread_target(¬ification),
|
||||
ServerNotificationThreadTarget::AppScoped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_requests_expose_their_thread_target() {
|
||||
let request = ServerRequest::CurrentTimeRead {
|
||||
id: RequestId::Integer(1),
|
||||
params: CurrentTimeReadParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(server_request_thread_id(&request), Some("thread-1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! App-server event stream handling for the TUI app.
|
||||
|
||||
use super::App;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use app_server_client::AppServerEvent;
|
||||
use app_server_protocol::protocol::v2::ServerNotification;
|
||||
|
||||
impl App {
|
||||
pub(crate) async fn handle_app_server_event(
|
||||
&mut self,
|
||||
app_server_client: &AppServerSession,
|
||||
event: AppServerEvent,
|
||||
) {
|
||||
match event {
|
||||
AppServerEvent::Lagged { .. } => {}
|
||||
AppServerEvent::ServerNotification(notification) => {
|
||||
self.handle_server_notification_event(app_server_client, *notification)
|
||||
.await;
|
||||
}
|
||||
AppServerEvent::ServerRequest(request) => {
|
||||
self.handle_server_request_event(app_server_client, *request)
|
||||
.await;
|
||||
}
|
||||
AppServerEvent::Disconnected { message } => {
|
||||
self.bottom_pane.clear();
|
||||
self.model_picker = None;
|
||||
self.pager_overlay = None;
|
||||
self.projection
|
||||
.set_status(format!("reconnecting: {message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_server_notification_event(
|
||||
&mut self,
|
||||
app_server_client: &AppServerSession,
|
||||
notification: ServerNotification,
|
||||
) {
|
||||
let queue_changed = matches!(
|
||||
¬ification,
|
||||
ServerNotification::ThreadQueueChanged(params)
|
||||
if self.thread_id.as_deref() == Some(params.thread_id.as_str())
|
||||
);
|
||||
self.apply_notification(notification);
|
||||
if queue_changed {
|
||||
self.refresh_queued_submissions(app_server_client).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_queued_submissions(
|
||||
&mut self,
|
||||
app_server_client: &AppServerSession,
|
||||
) {
|
||||
match app_server_client.list_queued_submissions(100).await {
|
||||
Ok(submissions) => self.set_queued_submissions(submissions),
|
||||
Err(error) => {
|
||||
self.set_queued_submissions(Vec::new());
|
||||
self.projection
|
||||
.set_status(format!("queue unavailable: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! App Server reverse-request routing for the TUI.
|
||||
//!
|
||||
//! Requests that need a user decision enter the BottomPane queue. Requests for a foreign thread
|
||||
//! are retained only when the Agents Overview has an explicit interaction surface; unsupported
|
||||
//! requests fail closed at the App Server boundary.
|
||||
|
||||
use super::app_server_event_targets::server_request_thread_id;
|
||||
use super::App;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use app_server_protocol::protocol::v2::ServerRequest;
|
||||
|
||||
impl App {
|
||||
pub(crate) async fn handle_server_request_event(
|
||||
&mut self,
|
||||
app_server_client: &AppServerSession,
|
||||
request: ServerRequest,
|
||||
) {
|
||||
match request {
|
||||
ServerRequest::CurrentTimeRead { id, .. } => {
|
||||
if let Err(error) = app_server_client.respond_current_time(id).await {
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
}
|
||||
request => {
|
||||
// Requests without a TUI interaction surface must fail closed instead of being
|
||||
// retained for an impossible replay.
|
||||
let unsupported_request = matches!(
|
||||
&request,
|
||||
ServerRequest::McpServerElicitationRequest { .. }
|
||||
| ServerRequest::DynamicToolCall { .. }
|
||||
);
|
||||
let thread_id = server_request_thread_id(&request).map(str::to_owned);
|
||||
if let Some(thread_id) = thread_id {
|
||||
if self.thread_id.as_deref() != Some(thread_id.as_str()) {
|
||||
if !unsupported_request {
|
||||
match self.enqueue_thread_request(&thread_id, request) {
|
||||
Ok(()) => return,
|
||||
Err(request) => {
|
||||
if let Err(error) =
|
||||
app_server_client.reject_server_request(request).await
|
||||
{
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) = app_server_client
|
||||
.reject_server_request(Box::new(request))
|
||||
.await
|
||||
{
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
match self.bottom_pane.enqueue(request) {
|
||||
Ok(()) => self.pager_overlay = None,
|
||||
Err(request) => {
|
||||
if let Err(error) = app_server_client.reject_server_request(request).await {
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Application action dispatch for the TUI.
|
||||
//!
|
||||
//! This is the Codex-shaped owner for actions that require App Server state. Input decoding stays
|
||||
//! in `input.rs`; terminal-specific actions remain in the runtime because they need the live
|
||||
//! terminal guard. The split keeps the central loop focused on event ordering and lifecycle.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::settings::{cycle_setting, EFFORTS};
|
||||
|
||||
pub(crate) struct EventContext<'a> {
|
||||
pub(crate) session: &'a mut AppServerSession,
|
||||
pub(crate) model: &'a mut Option<String>,
|
||||
pub(crate) model_provider: &'a mut Option<String>,
|
||||
pub(crate) effort: &'a mut Option<String>,
|
||||
pub(crate) permissions: &'a mut Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) enum EventDispatch {
|
||||
Handled,
|
||||
Unhandled(AppAction),
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Dispatch one App action that belongs to the App Server-backed application layer.
|
||||
pub(crate) async fn handle_event(
|
||||
&mut self,
|
||||
event: AppAction,
|
||||
context: EventContext<'_>,
|
||||
) -> Result<EventDispatch> {
|
||||
match event {
|
||||
action @ (AppAction::DecreaseEffort | AppAction::IncreaseEffort) => {
|
||||
let direction = if matches!(action, AppAction::DecreaseEffort) {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let next = cycle_setting(&EFFORTS, context.effort.as_deref(), direction);
|
||||
match context
|
||||
.session
|
||||
.update_settings(None, None, Some(next.clone()), None)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*context.effort = Some(next.clone());
|
||||
self.set_settings(
|
||||
context.model.clone(),
|
||||
context.model_provider.clone(),
|
||||
context.effort.clone(),
|
||||
context.permissions.clone(),
|
||||
);
|
||||
self.projection.set_status(format!("effort: {next}"));
|
||||
}
|
||||
Err(error) => self.projection.set_status(error.to_string()),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
action @ (AppAction::PreviousPermissions | AppAction::NextPermissions) => {
|
||||
let direction = if matches!(action, AppAction::PreviousPermissions) {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let next = self.cycle_permission_profile(context.permissions.as_deref(), direction);
|
||||
match context
|
||||
.session
|
||||
.update_settings(None, None, None, Some(next.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*context.permissions = Some(next.clone());
|
||||
self.set_settings(
|
||||
context.model.clone(),
|
||||
context.model_provider.clone(),
|
||||
context.effort.clone(),
|
||||
context.permissions.clone(),
|
||||
);
|
||||
self.projection.set_status(format!("permissions: {next}"));
|
||||
}
|
||||
Err(error) => self.projection.set_status(error.to_string()),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::Respond(response) => {
|
||||
self.note_outbound_response(&response);
|
||||
if let Err(error) = context.session.respond(response).await {
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::SelectModel(selection) => {
|
||||
match context
|
||||
.session
|
||||
.update_settings(
|
||||
Some(selection.model.clone()),
|
||||
Some(selection.provider.clone()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*context.model = Some(selection.model);
|
||||
*context.model_provider = Some(selection.provider);
|
||||
self.set_settings(
|
||||
context.model.clone(),
|
||||
context.model_provider.clone(),
|
||||
context.effort.clone(),
|
||||
context.permissions.clone(),
|
||||
);
|
||||
self.projection.set_status("settings updated");
|
||||
}
|
||||
Err(error) => self.projection.set_status(error.to_string()),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::ChangeCollaborationMode(collaboration_mode) => {
|
||||
match context
|
||||
.session
|
||||
.update_collaboration_mode(collaboration_mode.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*context.model = Some(collaboration_mode.settings.model.clone());
|
||||
*context.effort = collaboration_mode.settings.reasoning_effort.clone();
|
||||
self.set_settings(
|
||||
context.model.clone(),
|
||||
context.model_provider.clone(),
|
||||
context.effort.clone(),
|
||||
context.permissions.clone(),
|
||||
);
|
||||
self.collaboration_mode = Some(collaboration_mode);
|
||||
self.projection.set_status("collaboration mode updated");
|
||||
}
|
||||
Err(error) => self.projection.set_status(error.to_string()),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::RefreshAgentsOverview => {
|
||||
if let Err(error) = self.refresh_agents_overview_threads(context.session).await {
|
||||
self.projection.set_status(error.to_string());
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::DispatchAgentsOverviewTask { prompt, cwd } => {
|
||||
match self
|
||||
.dispatch_agents_overview_task(context.session, prompt, cwd)
|
||||
.await
|
||||
{
|
||||
Ok((thread_id, turn_id)) => self
|
||||
.projection
|
||||
.set_status(format!("background task started: {thread_id} ({turn_id})")),
|
||||
Err(error) => self
|
||||
.projection
|
||||
.set_status(format!("background task failed: {error}")),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::RenameAgentsOverviewThread { thread_id, name } => {
|
||||
match context.session.thread_set_name(thread_id, name).await {
|
||||
Ok(()) => {
|
||||
self.projection.set_status("agent renamed");
|
||||
self.refresh_agents_overview_threads(context.session)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Err(error) => self
|
||||
.projection
|
||||
.set_status(format!("agent rename failed: {error}")),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
AppAction::StopAgentsOverviewThread { thread_id } => {
|
||||
match self
|
||||
.stop_agents_overview_thread(context.session, thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(turn_id)) => self
|
||||
.projection
|
||||
.set_status(format!("stopping background turn {turn_id}")),
|
||||
Ok(None) => self.projection.set_status("background task is idle"),
|
||||
Err(error) => self
|
||||
.projection
|
||||
.set_status(format!("background task stop failed: {error}")),
|
||||
}
|
||||
Ok(EventDispatch::Handled)
|
||||
}
|
||||
other => Ok(EventDispatch::Unhandled(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Keyboard input dispatch for the TUI app.
|
||||
|
||||
use super::*;
|
||||
use crate::app::agent_navigation::AgentNavigationDirection;
|
||||
use crate::pending_input_preview::can_restore_submission;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
impl App {
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> AppAction {
|
||||
if self.resume_picker.is_none()
|
||||
&& self.agents_overview.is_none()
|
||||
&& key_event.kind == KeyEventKind::Press
|
||||
{
|
||||
match key_event.code {
|
||||
KeyCode::PageUp => return AppAction::ScrollUp,
|
||||
KeyCode::PageDown => return AppAction::ScrollDown,
|
||||
KeyCode::Home if key_event.modifiers.contains(KeyModifiers::ALT) => {
|
||||
return AppAction::ScrollTop;
|
||||
}
|
||||
KeyCode::End if key_event.modifiers.contains(KeyModifiers::ALT) => {
|
||||
return AppAction::ScrollBottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.projection.active_turn_id().is_none()
|
||||
&& self.composer.is_empty()
|
||||
{
|
||||
let direction = if crate::multi_agents::previous_agent_shortcut_matches(key_event, true)
|
||||
{
|
||||
Some(AgentNavigationDirection::Previous)
|
||||
} else if crate::multi_agents::next_agent_shortcut_matches(key_event, true) {
|
||||
Some(AgentNavigationDirection::Next)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(direction) = direction {
|
||||
return self
|
||||
.adjacent_agent(direction)
|
||||
.map(AppAction::SwitchThread)
|
||||
.unwrap_or(AppAction::None);
|
||||
}
|
||||
}
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& key_event.code == KeyCode::BackTab
|
||||
&& self.projection.active_turn_id().is_none()
|
||||
{
|
||||
return self
|
||||
.next_collaboration_mode()
|
||||
.map(AppAction::ChangeCollaborationMode)
|
||||
.unwrap_or(AppAction::None);
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.projection.active_turn_id().is_some() => AppAction::Interrupt,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(value),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& value.eq_ignore_ascii_case(&'v') =>
|
||||
{
|
||||
AppAction::PasteImage
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(value),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && value.eq_ignore_ascii_case(&'o') => {
|
||||
AppAction::CopyLastResponse
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(value),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && value.eq_ignore_ascii_case(&'t') => {
|
||||
self.command_popup = None;
|
||||
self.pager_overlay = Some(PagerOverlay::transcript(self.locale));
|
||||
AppAction::None
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::ALT)
|
||||
&& self.composer.is_empty()
|
||||
&& !self.composer.has_pending_images() =>
|
||||
{
|
||||
self.queued_submissions
|
||||
.last()
|
||||
.filter(|submission| can_restore_submission(submission))
|
||||
.cloned()
|
||||
.map(AppAction::EditQueuedSubmission)
|
||||
.unwrap_or(AppAction::None)
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if key_event.code == KeyCode::Enter
|
||||
&& !key_event
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
|
||||
{
|
||||
if let Some(action) = self.run_local_command() {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
if matches!(key_event.code, KeyCode::Enter | KeyCode::Tab)
|
||||
&& self.composer.is_empty()
|
||||
&& self.composer.has_pending_images()
|
||||
{
|
||||
return if key_event.code == KeyCode::Tab {
|
||||
AppAction::Queue(String::new())
|
||||
} else {
|
||||
AppAction::Submit(String::new())
|
||||
};
|
||||
}
|
||||
if key_event.code == KeyCode::Backspace
|
||||
&& self.composer.is_empty()
|
||||
&& self.composer.remove_last_pending_image()
|
||||
{
|
||||
return AppAction::None;
|
||||
}
|
||||
let action = self.composer.handle_key_event(key_event);
|
||||
self.map_composer_action(action)
|
||||
}
|
||||
_ => {
|
||||
let action = self.composer.handle_key_event(key_event);
|
||||
self.map_composer_action(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
//! Pending interactive request state used by Codex-shaped thread event replay.
|
||||
//!
|
||||
//! A request can outlive the view that first received it. The replay state is therefore kept
|
||||
//! separately from the bounded event queue and is updated by inbound server lifecycle
|
||||
//! notifications, outbound responses, and queue eviction.
|
||||
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem};
|
||||
use app_server_protocol::RequestId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ElicitationRequestKey {
|
||||
server_name: String,
|
||||
request_id: RequestId,
|
||||
}
|
||||
|
||||
impl ElicitationRequestKey {
|
||||
fn new(server_name: String, request_id: RequestId) -> Self {
|
||||
Self {
|
||||
server_name,
|
||||
request_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PendingInteractiveRequest {
|
||||
ExecApproval {
|
||||
turn_id: String,
|
||||
item_id: String,
|
||||
approval_id: String,
|
||||
},
|
||||
PatchApproval {
|
||||
turn_id: String,
|
||||
item_id: String,
|
||||
},
|
||||
Elicitation(ElicitationRequestKey),
|
||||
RequestPermissions {
|
||||
turn_id: String,
|
||||
item_id: String,
|
||||
},
|
||||
RequestUserInput {
|
||||
turn_id: String,
|
||||
item_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
// Tracks which interactive prompts are still unresolved in the thread-event buffer.
|
||||
//
|
||||
// Thread snapshots are replayed when switching threads/agents. Most events replay verbatim, but
|
||||
// approvals and input prompts only replay while they are still pending. The fast lookup sets are
|
||||
// paired with turn-indexed queues so turn completion and FIFO user-input answers can clear stale
|
||||
// prompts without scanning the buffered event list.
|
||||
pub(super) struct PendingInteractiveReplayState {
|
||||
exec_approval_call_ids: HashSet<String>,
|
||||
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
patch_approval_call_ids: HashSet<String>,
|
||||
patch_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
elicitation_requests: HashSet<ElicitationRequestKey>,
|
||||
request_permissions_call_ids: HashSet<String>,
|
||||
request_permissions_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
request_user_input_call_ids: HashSet<String>,
|
||||
request_user_input_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
pending_requests_by_request_id: HashMap<RequestId, PendingInteractiveRequest>,
|
||||
}
|
||||
|
||||
impl PendingInteractiveReplayState {
|
||||
pub(super) fn note_server_request(&mut self, request: &ServerRequest) {
|
||||
// Request IDs are the response identity in Lime's protocol. If a server reuses one, make
|
||||
// sure the old classification cannot leak into the replacement request.
|
||||
self.remove_request(request.id());
|
||||
|
||||
match request {
|
||||
ServerRequest::ItemCommandExecutionRequestApproval { id, params } => {
|
||||
let approval_id = params
|
||||
.approval_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| params.item_id.clone());
|
||||
self.exec_approval_call_ids.insert(approval_id.clone());
|
||||
self.exec_approval_call_ids_by_turn_id
|
||||
.entry(params.turn_id.clone())
|
||||
.or_default()
|
||||
.push(approval_id.clone());
|
||||
self.pending_requests_by_request_id.insert(
|
||||
id.clone(),
|
||||
PendingInteractiveRequest::ExecApproval {
|
||||
turn_id: params.turn_id.clone(),
|
||||
item_id: params.item_id.clone(),
|
||||
approval_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerRequest::ItemFileChangeRequestApproval { id, params } => {
|
||||
self.patch_approval_call_ids.insert(params.item_id.clone());
|
||||
self.patch_approval_call_ids_by_turn_id
|
||||
.entry(params.turn_id.clone())
|
||||
.or_default()
|
||||
.push(params.item_id.clone());
|
||||
self.pending_requests_by_request_id.insert(
|
||||
id.clone(),
|
||||
PendingInteractiveRequest::PatchApproval {
|
||||
turn_id: params.turn_id.clone(),
|
||||
item_id: params.item_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { id, params } => {
|
||||
let key = ElicitationRequestKey::new(params.server_name.clone(), id.clone());
|
||||
self.elicitation_requests.insert(key.clone());
|
||||
self.pending_requests_by_request_id
|
||||
.insert(id.clone(), PendingInteractiveRequest::Elicitation(key));
|
||||
}
|
||||
ServerRequest::ItemPermissionsRequestApproval { id, params } => {
|
||||
self.request_permissions_call_ids
|
||||
.insert(params.item_id.clone());
|
||||
self.request_permissions_call_ids_by_turn_id
|
||||
.entry(params.turn_id.clone())
|
||||
.or_default()
|
||||
.push(params.item_id.clone());
|
||||
self.pending_requests_by_request_id.insert(
|
||||
id.clone(),
|
||||
PendingInteractiveRequest::RequestPermissions {
|
||||
turn_id: params.turn_id.clone(),
|
||||
item_id: params.item_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerRequest::ItemToolRequestUserInput { id, params } => {
|
||||
self.request_user_input_call_ids
|
||||
.insert(params.item_id.clone());
|
||||
self.request_user_input_call_ids_by_turn_id
|
||||
.entry(params.turn_id.clone())
|
||||
.or_default()
|
||||
.push(params.item_id.clone());
|
||||
self.pending_requests_by_request_id.insert(
|
||||
id.clone(),
|
||||
PendingInteractiveRequest::RequestUserInput {
|
||||
turn_id: params.turn_id.clone(),
|
||||
item_id: params.item_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_server_notification(&mut self, notification: &ServerNotification) {
|
||||
match notification {
|
||||
ServerNotification::ItemStarted(notification) => match ¬ification.item {
|
||||
ThreadItem::CommandExecution { id, .. } => {
|
||||
self.clear_exec_approval_item(id);
|
||||
}
|
||||
ThreadItem::FileChange { id, .. } => {
|
||||
self.clear_patch_approval_item(id);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ServerNotification::TurnCompleted(params) => {
|
||||
self.clear_exec_approval_turn(¶ms.turn.id);
|
||||
self.clear_patch_approval_turn(¶ms.turn.id);
|
||||
self.clear_request_permissions_turn(¶ms.turn.id);
|
||||
self.clear_request_user_input_turn(¶ms.turn.id);
|
||||
}
|
||||
ServerNotification::ServerRequestResolved(params) => {
|
||||
self.remove_request(¶ms.request_id);
|
||||
}
|
||||
ServerNotification::ThreadClosed(_) => self.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_evicted_server_request(&mut self, request: &ServerRequest) {
|
||||
if self
|
||||
.pending_requests_by_request_id
|
||||
.contains_key(request.id())
|
||||
{
|
||||
self.remove_request(request.id());
|
||||
return;
|
||||
}
|
||||
|
||||
// The request may have been replaced or filtered before eviction. Remove its category
|
||||
// identity as a defensive fallback so an evicted prompt can never replay.
|
||||
match request {
|
||||
ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => {
|
||||
let approval_id = params.approval_id.as_ref().unwrap_or(¶ms.item_id);
|
||||
self.exec_approval_call_ids.remove(approval_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
¶ms.turn_id,
|
||||
approval_id,
|
||||
);
|
||||
}
|
||||
ServerRequest::ItemFileChangeRequestApproval { params, .. } => {
|
||||
self.patch_approval_call_ids.remove(¶ms.item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.patch_approval_call_ids_by_turn_id,
|
||||
¶ms.turn_id,
|
||||
¶ms.item_id,
|
||||
);
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { id, params } => {
|
||||
self.elicitation_requests
|
||||
.remove(&ElicitationRequestKey::new(
|
||||
params.server_name.clone(),
|
||||
id.clone(),
|
||||
));
|
||||
}
|
||||
ServerRequest::ItemPermissionsRequestApproval { params, .. } => {
|
||||
self.request_permissions_call_ids.remove(¶ms.item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.request_permissions_call_ids_by_turn_id,
|
||||
¶ms.turn_id,
|
||||
¶ms.item_id,
|
||||
);
|
||||
}
|
||||
ServerRequest::ItemToolRequestUserInput { params, .. } => {
|
||||
self.request_user_input_call_ids.remove(¶ms.item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.request_user_input_call_ids_by_turn_id,
|
||||
¶ms.turn_id,
|
||||
¶ms.item_id,
|
||||
);
|
||||
}
|
||||
ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_outbound_response(&mut self, request_id: &RequestId) {
|
||||
self.remove_request(request_id);
|
||||
}
|
||||
|
||||
pub(super) fn should_replay_snapshot_request(&self, request: &ServerRequest) -> bool {
|
||||
match request {
|
||||
ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => self
|
||||
.exec_approval_call_ids
|
||||
.contains(params.approval_id.as_ref().unwrap_or(¶ms.item_id)),
|
||||
ServerRequest::ItemFileChangeRequestApproval { params, .. } => {
|
||||
self.patch_approval_call_ids.contains(¶ms.item_id)
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { id, params } => self
|
||||
.elicitation_requests
|
||||
.contains(&ElicitationRequestKey::new(
|
||||
params.server_name.clone(),
|
||||
id.clone(),
|
||||
)),
|
||||
ServerRequest::ItemPermissionsRequestApproval { params, .. } => {
|
||||
self.request_permissions_call_ids.contains(¶ms.item_id)
|
||||
}
|
||||
ServerRequest::ItemToolRequestUserInput { params, .. } => {
|
||||
self.request_user_input_call_ids.contains(¶ms.item_id)
|
||||
}
|
||||
ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn has_pending_thread_approvals(&self) -> bool {
|
||||
!self.exec_approval_call_ids.is_empty()
|
||||
|| !self.patch_approval_call_ids.is_empty()
|
||||
|| !self.elicitation_requests.is_empty()
|
||||
|| !self.request_permissions_call_ids.is_empty()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn has_pending_thread_user_input(&self) -> bool {
|
||||
!self.request_user_input_call_ids.is_empty()
|
||||
}
|
||||
|
||||
fn clear_exec_approval_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.exec_approval_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::ExecApproval {
|
||||
turn_id: pending_turn_id,
|
||||
..
|
||||
} if pending_turn_id == turn_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_exec_approval_item(&mut self, item_id: &str) {
|
||||
let matching = self
|
||||
.pending_requests_by_request_id
|
||||
.values()
|
||||
.filter_map(|pending| match pending {
|
||||
PendingInteractiveRequest::ExecApproval {
|
||||
turn_id,
|
||||
item_id: pending_item_id,
|
||||
approval_id,
|
||||
} if pending_item_id == item_id => Some((turn_id.clone(), approval_id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.exec_approval_call_ids.remove(item_id);
|
||||
for (turn_id, approval_id) in matching {
|
||||
self.exec_approval_call_ids.remove(&approval_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
&turn_id,
|
||||
&approval_id,
|
||||
);
|
||||
}
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::ExecApproval { item_id: pending_item_id, .. }
|
||||
if pending_item_id == item_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_patch_approval_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.patch_approval_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.patch_approval_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::PatchApproval {
|
||||
turn_id: pending_turn_id,
|
||||
..
|
||||
} if pending_turn_id == turn_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_patch_approval_item(&mut self, item_id: &str) {
|
||||
self.patch_approval_call_ids.remove(item_id);
|
||||
Self::remove_call_id_from_turn_map(&mut self.patch_approval_call_ids_by_turn_id, item_id);
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::PatchApproval { item_id: pending_item_id, .. }
|
||||
if pending_item_id == item_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_request_permissions_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.request_permissions_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.request_permissions_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::RequestPermissions {
|
||||
turn_id: pending_turn_id,
|
||||
..
|
||||
} if pending_turn_id == turn_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_request_user_input_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.request_user_input_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
self.pending_requests_by_request_id.retain(|_, pending| {
|
||||
!matches!(
|
||||
pending,
|
||||
PendingInteractiveRequest::RequestUserInput {
|
||||
turn_id: pending_turn_id,
|
||||
..
|
||||
} if pending_turn_id == turn_id
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_call_id_from_turn_map(
|
||||
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
|
||||
call_id: &str,
|
||||
) {
|
||||
call_ids_by_turn_id.retain(|_, call_ids| {
|
||||
call_ids.retain(|queued_call_id| queued_call_id != call_id);
|
||||
!call_ids.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_call_id_from_turn_map_entry(
|
||||
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
|
||||
turn_id: &str,
|
||||
call_id: &str,
|
||||
) {
|
||||
let mut remove_turn_entry = false;
|
||||
if let Some(call_ids) = call_ids_by_turn_id.get_mut(turn_id) {
|
||||
call_ids.retain(|queued_call_id| queued_call_id != call_id);
|
||||
remove_turn_entry = call_ids.is_empty();
|
||||
}
|
||||
if remove_turn_entry {
|
||||
call_ids_by_turn_id.remove(turn_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.exec_approval_call_ids.clear();
|
||||
self.exec_approval_call_ids_by_turn_id.clear();
|
||||
self.patch_approval_call_ids.clear();
|
||||
self.patch_approval_call_ids_by_turn_id.clear();
|
||||
self.elicitation_requests.clear();
|
||||
self.request_permissions_call_ids.clear();
|
||||
self.request_permissions_call_ids_by_turn_id.clear();
|
||||
self.request_user_input_call_ids.clear();
|
||||
self.request_user_input_call_ids_by_turn_id.clear();
|
||||
self.pending_requests_by_request_id.clear();
|
||||
}
|
||||
|
||||
fn remove_request(&mut self, request_id: &RequestId) {
|
||||
let Some(pending) = self.pending_requests_by_request_id.remove(request_id) else {
|
||||
return;
|
||||
};
|
||||
match pending {
|
||||
PendingInteractiveRequest::ExecApproval {
|
||||
turn_id,
|
||||
approval_id,
|
||||
..
|
||||
} => {
|
||||
self.exec_approval_call_ids.remove(&approval_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
&turn_id,
|
||||
&approval_id,
|
||||
);
|
||||
}
|
||||
PendingInteractiveRequest::PatchApproval { turn_id, item_id } => {
|
||||
self.patch_approval_call_ids.remove(&item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.patch_approval_call_ids_by_turn_id,
|
||||
&turn_id,
|
||||
&item_id,
|
||||
);
|
||||
}
|
||||
PendingInteractiveRequest::Elicitation(key) => {
|
||||
self.elicitation_requests.remove(&key);
|
||||
}
|
||||
PendingInteractiveRequest::RequestPermissions { turn_id, item_id } => {
|
||||
self.request_permissions_call_ids.remove(&item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.request_permissions_call_ids_by_turn_id,
|
||||
&turn_id,
|
||||
&item_id,
|
||||
);
|
||||
}
|
||||
PendingInteractiveRequest::RequestUserInput { turn_id, item_id } => {
|
||||
self.request_user_input_call_ids.remove(&item_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.request_user_input_call_ids_by_turn_id,
|
||||
&turn_id,
|
||||
&item_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams,
|
||||
ItemStartedNotification, McpServerElicitationRequest, McpServerElicitationRequestParams,
|
||||
PermissionsRequestApprovalParams, RequestPermissionProfile,
|
||||
ServerRequestResolvedNotification, ThreadClosedNotification, ToolRequestUserInputParams,
|
||||
Turn, TurnCompletedNotification, TurnItemsView, TurnStatus,
|
||||
};
|
||||
|
||||
fn request_user_input(id: i64, item_id: &str, turn_id: &str) -> ServerRequest {
|
||||
ServerRequest::ItemToolRequestUserInput {
|
||||
id: RequestId::Integer(id),
|
||||
params: ToolRequestUserInputParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
questions: Vec::new(),
|
||||
auto_resolution_ms: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_approval(
|
||||
id: i64,
|
||||
item_id: &str,
|
||||
approval_id: Option<&str>,
|
||||
turn_id: &str,
|
||||
) -> ServerRequest {
|
||||
ServerRequest::ItemCommandExecutionRequestApproval {
|
||||
id: RequestId::Integer(id),
|
||||
params: CommandExecutionRequestApprovalParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
started_at_ms: 0,
|
||||
approval_id: approval_id.map(str::to_string),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
command: Some("echo ok".to_string()),
|
||||
cwd: None,
|
||||
available_decisions: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_approval(id: i64, item_id: &str, turn_id: &str) -> ServerRequest {
|
||||
ServerRequest::ItemFileChangeRequestApproval {
|
||||
id: RequestId::Integer(id),
|
||||
params: FileChangeRequestApprovalParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
started_at_ms: 0,
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn permissions_approval(id: i64, item_id: &str, turn_id: &str) -> ServerRequest {
|
||||
ServerRequest::ItemPermissionsRequestApproval {
|
||||
id: RequestId::Integer(id),
|
||||
params: PermissionsRequestApprovalParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
environment_id: None,
|
||||
started_at_ms: 0,
|
||||
cwd: "/tmp".to_string(),
|
||||
reason: None,
|
||||
permissions: RequestPermissionProfile {
|
||||
network: None,
|
||||
file_system: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn elicitation(id: &str) -> ServerRequest {
|
||||
ServerRequest::McpServerElicitationRequest {
|
||||
id: RequestId::String(id.to_string()),
|
||||
params: McpServerElicitationRequestParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: Some("turn".to_string()),
|
||||
server_name: "server".to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: None,
|
||||
message: "Confirm".to_string(),
|
||||
requested_schema: serde_json::Map::new(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_completed(turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn: Turn {
|
||||
id: turn_id.to_string(),
|
||||
items: Vec::new(),
|
||||
items_view: TurnItemsView::Full,
|
||||
status: TurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn resolved(id: RequestId) -> ServerNotification {
|
||||
ServerNotification::ServerRequestResolved(ServerRequestResolvedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
request_id: id,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_interactive_request_is_pending_until_resolved() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let requests = [
|
||||
request_user_input(1, "input", "turn"),
|
||||
exec_approval(2, "exec", Some("approval"), "turn"),
|
||||
patch_approval(3, "patch", "turn"),
|
||||
permissions_approval(4, "permissions", "turn"),
|
||||
elicitation("elicitation"),
|
||||
];
|
||||
|
||||
for request in &requests {
|
||||
state.note_server_request(request);
|
||||
assert!(state.should_replay_snapshot_request(request));
|
||||
}
|
||||
assert!(state.has_pending_thread_approvals());
|
||||
assert!(state.has_pending_thread_user_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbound_response_clears_only_the_matching_request() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let first = request_user_input(1, "input-1", "turn");
|
||||
let second = request_user_input(2, "input-2", "turn");
|
||||
state.note_server_request(&first);
|
||||
state.note_server_request(&second);
|
||||
|
||||
state.note_outbound_response(&RequestId::Integer(1));
|
||||
|
||||
assert!(!state.should_replay_snapshot_request(&first));
|
||||
assert!(state.should_replay_snapshot_request(&second));
|
||||
assert!(state.has_pending_thread_user_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_resolution_clears_request_identity_and_category() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let request = exec_approval(1, "item", Some("approval"), "turn");
|
||||
state.note_server_request(&request);
|
||||
state.note_server_notification(&resolved(RequestId::Integer(1)));
|
||||
|
||||
assert!(!state.should_replay_snapshot_request(&request));
|
||||
assert!(!state.has_pending_thread_approvals());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_started_clears_command_and_file_approval_by_item_id() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let exec = exec_approval(1, "exec-item", Some("approval-id"), "turn");
|
||||
let patch = patch_approval(2, "patch-item", "turn");
|
||||
state.note_server_request(&exec);
|
||||
state.note_server_request(&patch);
|
||||
|
||||
state.note_server_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
started_at_ms: 0,
|
||||
item: ThreadItem::CommandExecution {
|
||||
id: "exec-item".to_string(),
|
||||
metadata: None,
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
command: "echo ok".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
process_id: None,
|
||||
source: Default::default(),
|
||||
status: app_server_protocol::protocol::v2::CommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::new(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
terminal_interactions: Vec::new(),
|
||||
},
|
||||
}));
|
||||
assert!(!state.should_replay_snapshot_request(&exec));
|
||||
assert!(state.should_replay_snapshot_request(&patch));
|
||||
|
||||
state.note_server_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
started_at_ms: 0,
|
||||
item: ThreadItem::FileChange {
|
||||
id: "patch-item".to_string(),
|
||||
metadata: None,
|
||||
changes: Vec::new(),
|
||||
status: app_server_protocol::protocol::v2::PatchApplyStatus::InProgress,
|
||||
},
|
||||
}));
|
||||
assert!(!state.should_replay_snapshot_request(&patch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_completion_clears_all_turn_indexed_prompts() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let requests = [
|
||||
request_user_input(1, "input", "turn"),
|
||||
exec_approval(2, "exec", None, "turn"),
|
||||
patch_approval(3, "patch", "turn"),
|
||||
permissions_approval(4, "permissions", "turn"),
|
||||
];
|
||||
for request in &requests {
|
||||
state.note_server_request(request);
|
||||
}
|
||||
|
||||
state.note_server_notification(&turn_completed("turn"));
|
||||
|
||||
assert!(requests
|
||||
.iter()
|
||||
.all(|request| !state.should_replay_snapshot_request(request)));
|
||||
assert!(!state.has_pending_thread_approvals());
|
||||
assert!(!state.has_pending_thread_user_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_requests_are_removed_in_fifo_order_per_turn() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let first = request_user_input(1, "first", "turn");
|
||||
let second = request_user_input(2, "second", "turn");
|
||||
state.note_server_request(&first);
|
||||
state.note_server_request(&second);
|
||||
|
||||
state.note_outbound_response(&RequestId::Integer(1));
|
||||
|
||||
assert!(!state.should_replay_snapshot_request(&first));
|
||||
assert!(state.should_replay_snapshot_request(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_request_does_not_replay() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let request = permissions_approval(1, "permissions", "turn");
|
||||
state.note_server_request(&request);
|
||||
state.note_evicted_server_request(&request);
|
||||
|
||||
assert!(!state.should_replay_snapshot_request(&request));
|
||||
assert!(!state.has_pending_thread_approvals());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_thread_clears_all_prompt_categories() {
|
||||
let mut state = PendingInteractiveReplayState::default();
|
||||
let requests = [
|
||||
request_user_input(1, "input", "turn"),
|
||||
exec_approval(2, "exec", None, "turn"),
|
||||
patch_approval(3, "patch", "turn"),
|
||||
permissions_approval(4, "permissions", "turn"),
|
||||
elicitation("elicitation"),
|
||||
];
|
||||
for request in &requests {
|
||||
state.note_server_request(request);
|
||||
}
|
||||
|
||||
state.note_server_notification(&ServerNotification::ThreadClosed(
|
||||
ThreadClosedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
},
|
||||
));
|
||||
|
||||
assert!(requests
|
||||
.iter()
|
||||
.all(|request| !state.should_replay_snapshot_request(request)));
|
||||
assert!(!state.has_pending_thread_approvals());
|
||||
assert!(!state.has_pending_thread_user_input());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! App Server reconnection lifecycle for the TUI app.
|
||||
//!
|
||||
//! The reconnect owner lives under `app/` to match Codex. Transport creation remains delegated
|
||||
//! to the shared runtime boundary; this module only restores the canonical thread session and
|
||||
//! its server-backed settings.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use app_server_protocol::protocol::v2::Thread;
|
||||
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::runtime::{connect_session, TuiOptions};
|
||||
|
||||
const RECONNECT_DELAYS: [Duration; 4] = [
|
||||
Duration::ZERO,
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(300),
|
||||
Duration::from_secs(1),
|
||||
];
|
||||
|
||||
/// A successfully reattached App Server session and its canonical thread snapshot.
|
||||
pub(crate) struct ReconnectedSession {
|
||||
pub(crate) session: AppServerSession,
|
||||
pub(crate) thread: Thread,
|
||||
pub(crate) permission_profiles: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn reconnect_session(
|
||||
options: TuiOptions,
|
||||
thread_id: String,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
effort: Option<String>,
|
||||
permissions: Option<String>,
|
||||
) -> Result<ReconnectedSession> {
|
||||
let mut last_error = None;
|
||||
for delay in RECONNECT_DELAYS {
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
let mut candidate = match connect_session(&options).await {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match candidate.resume_thread(thread_id.clone()).await {
|
||||
Ok(response) => {
|
||||
let permission_profiles = candidate
|
||||
.list_permission_profiles(Some(response.cwd.clone()))
|
||||
.await;
|
||||
let permission_profiles = match permission_profiles {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let _ = candidate.shutdown().await;
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = candidate
|
||||
.update_settings(
|
||||
model.clone(),
|
||||
model_provider.clone(),
|
||||
effort.clone(),
|
||||
permissions.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = candidate.shutdown().await;
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
return Ok(ReconnectedSession {
|
||||
session: candidate,
|
||||
thread: response.thread,
|
||||
permission_profiles: permission_profiles
|
||||
.data
|
||||
.into_iter()
|
||||
.filter(|profile| profile.allowed)
|
||||
.map(|profile| profile.id)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = candidate.shutdown().await;
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
let error = last_error
|
||||
.map(|error| format!("{error:#}"))
|
||||
.unwrap_or_else(|| "unknown reconnect failure".to_string());
|
||||
bail!("failed to reconnect App Server and resume thread {thread_id}: {error}")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Helpers for deciding which buffered events to replay when switching threads.
|
||||
//!
|
||||
//! The live event store remains untouched. These filters only normalize the snapshot that is
|
||||
//! about to be replayed into the active TUI projection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ThreadItem};
|
||||
|
||||
use super::thread_events::{ThreadBufferedEvent, ThreadEventSnapshot};
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn snapshot_has_pending_interactive_request(snapshot: &ThreadEventSnapshot) -> bool {
|
||||
snapshot.events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
ThreadBufferedEvent::Request(request)
|
||||
if matches!(
|
||||
request.as_ref(),
|
||||
app_server_protocol::protocol::v2::ServerRequest::ItemCommandExecutionRequestApproval { .. }
|
||||
| app_server_protocol::protocol::v2::ServerRequest::ItemFileChangeRequestApproval { .. }
|
||||
| app_server_protocol::protocol::v2::ServerRequest::McpServerElicitationRequest { .. }
|
||||
| app_server_protocol::protocol::v2::ServerRequest::ItemPermissionsRequestApproval { .. }
|
||||
| app_server_protocol::protocol::v2::ServerRequest::ItemToolRequestUserInput { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn event_is_notice(event: &ThreadBufferedEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
ThreadBufferedEvent::Notification(notification)
|
||||
if matches!(
|
||||
notification.as_ref(),
|
||||
ServerNotification::Warning(_)
|
||||
| ServerNotification::GuardianWarning(_)
|
||||
| ServerNotification::ConfigWarning(_)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// A completed item's full text replaces its earlier streaming deltas during replay.
|
||||
///
|
||||
/// Keep deltas without a later completion so an in-progress or truncated stream still renders.
|
||||
/// Other events are barriers: streaming text may need to flush before a tool or prompt. This only
|
||||
/// changes the replay snapshot; the live notification store remains untouched.
|
||||
pub(super) fn omit_completed_agent_deltas(events: &mut Vec<ThreadBufferedEvent>) {
|
||||
let mut completed = HashSet::new();
|
||||
events.reverse();
|
||||
events.retain(|event| {
|
||||
if let ThreadBufferedEvent::Notification(notification) = event {
|
||||
match notification.as_ref() {
|
||||
ServerNotification::ItemCompleted(notification) => {
|
||||
if let ThreadItem::AgentMessage { id, .. } = ¬ification.item {
|
||||
completed.insert((
|
||||
notification.thread_id.clone(),
|
||||
notification.turn_id.clone(),
|
||||
id.clone(),
|
||||
));
|
||||
} else {
|
||||
completed.clear();
|
||||
}
|
||||
}
|
||||
ServerNotification::AgentMessageDelta(notification) => {
|
||||
return !completed.contains(&(
|
||||
notification.thread_id.clone(),
|
||||
notification.turn_id.clone(),
|
||||
notification.item_id.clone(),
|
||||
));
|
||||
}
|
||||
_ => completed.clear(),
|
||||
}
|
||||
} else {
|
||||
completed.clear();
|
||||
}
|
||||
true
|
||||
});
|
||||
events.reverse();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
AgentMessageDeltaNotification, ConfigWarningNotification, ItemCompletedNotification,
|
||||
ServerRequest, ThreadItem, ToolRequestUserInputParams, WarningNotification,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
|
||||
fn delta(item_id: &str, text: &str) -> ThreadBufferedEvent {
|
||||
ThreadBufferedEvent::Notification(Box::new(ServerNotification::AgentMessageDelta(
|
||||
AgentMessageDeltaNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: text.to_string(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn completed_agent(item_id: &str, text: &str) -> ThreadBufferedEvent {
|
||||
ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted(
|
||||
ItemCompletedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
completed_at_ms: 1,
|
||||
item: ThreadItem::AgentMessage {
|
||||
id: item_id.to_string(),
|
||||
metadata: None,
|
||||
text: text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
delivery: None,
|
||||
},
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_has_pending_interactive_request_matches_user_input() {
|
||||
let request = ServerRequest::ItemToolRequestUserInput {
|
||||
id: RequestId::Integer(1),
|
||||
params: ToolRequestUserInputParams {
|
||||
thread_id: "thread".to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: "item".to_string(),
|
||||
questions: Vec::new(),
|
||||
auto_resolution_ms: None,
|
||||
},
|
||||
};
|
||||
let snapshot = ThreadEventSnapshot {
|
||||
events: vec![ThreadBufferedEvent::Request(Box::new(request))],
|
||||
};
|
||||
assert!(snapshot_has_pending_interactive_request(&snapshot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_is_notice_matches_warning_notifications() {
|
||||
let warning = ThreadBufferedEvent::Notification(Box::new(ServerNotification::Warning(
|
||||
WarningNotification {
|
||||
thread_id: Some("thread".to_string()),
|
||||
message: "warning".to_string(),
|
||||
code: None,
|
||||
},
|
||||
)));
|
||||
let config_warning = ThreadBufferedEvent::Notification(Box::new(
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "config".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
));
|
||||
assert!(event_is_notice(&warning));
|
||||
assert!(event_is_notice(&config_warning));
|
||||
assert!(!event_is_notice(&delta("item", "text")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omit_completed_agent_deltas_removes_only_replaced_streams() {
|
||||
let mut events = vec![delta("item-1", "draft"), completed_agent("item-1", "final")];
|
||||
events.push(delta("item-2", "still streaming"));
|
||||
|
||||
omit_completed_agent_deltas(&mut events);
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
ThreadBufferedEvent::Notification(notification)
|
||||
if matches!(notification.as_ref(), ServerNotification::ItemCompleted(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
ThreadBufferedEvent::Notification(notification)
|
||||
if matches!(notification.as_ref(), ServerNotification::AgentMessageDelta(delta)
|
||||
if delta.item_id == "item-2")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Session and subagent selection lifecycle for the TUI app.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
|
||||
impl App {
|
||||
/// Resume a canonical App Server thread and refresh the local projection/settings snapshot.
|
||||
///
|
||||
/// Thread identity, queued input and agent-overview replay all move together so callers do
|
||||
/// not accidentally switch only the transport target or only the rendered conversation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn resume_target_session(
|
||||
&mut self,
|
||||
session: &mut AppServerSession,
|
||||
thread_id: String,
|
||||
model: &mut Option<String>,
|
||||
model_provider: &mut Option<String>,
|
||||
effort: &mut Option<String>,
|
||||
permissions: &mut Option<String>,
|
||||
options: &crate::runtime::TuiOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.thread_id.as_deref() == Some(thread_id.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
let response = session.resume_thread(thread_id).await?;
|
||||
let resumed_thread_id = response.thread.id.clone();
|
||||
let snapshot = self.take_thread_event_snapshot(&resumed_thread_id, true);
|
||||
self.bottom_pane.clear();
|
||||
self.hydrate_thread(response.thread);
|
||||
self.set_thread_id(resumed_thread_id.clone());
|
||||
self.restore_thread_input(&resumed_thread_id);
|
||||
self.replay_thread_snapshot(snapshot);
|
||||
*model = Some(response.model);
|
||||
*model_provider = Some(response.model_provider);
|
||||
*effort = response.reasoning_effort;
|
||||
if options.permissions.is_none() {
|
||||
*permissions = session.active_permission_profile().map(str::to_string);
|
||||
}
|
||||
self.set_cwd(PathBuf::from(response.cwd));
|
||||
self.set_settings(
|
||||
model.clone(),
|
||||
model_provider.clone(),
|
||||
effort.clone(),
|
||||
permissions.clone(),
|
||||
);
|
||||
self.refresh_queued_submissions(session).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn open_agent_picker(&mut self) {
|
||||
let picker =
|
||||
AgentPicker::from_navigation(&self.agent_navigation, self.primary_thread_id.as_deref());
|
||||
if picker.is_empty() {
|
||||
self.projection.set_status("no sub-agents available");
|
||||
} else {
|
||||
self.agent_picker = Some(picker);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, ServerRequest,
|
||||
UserInput,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
fn dispatch_connected_input(app: &mut App, event: Event) -> AppAction {
|
||||
app.handle_tui_event(to_tui_event(event), true)
|
||||
}
|
||||
|
||||
fn dispatch_disconnected_input(app: &mut App, event: Event) -> AppAction {
|
||||
app.handle_tui_event(to_tui_event(event), false)
|
||||
}
|
||||
|
||||
fn to_tui_event(event: Event) -> TuiEvent {
|
||||
match event {
|
||||
Event::Key(key) => TuiEvent::Key(key),
|
||||
Event::Paste(text) => TuiEvent::Paste(text),
|
||||
Event::Resize(width, height) => TuiEvent::Resize(ratatui::layout::Size { width, height }),
|
||||
Event::FocusGained => TuiEvent::FocusGained,
|
||||
Event::FocusLost => TuiEvent::FocusLost,
|
||||
_ => TuiEvent::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_bottom_pane_receives_input_before_the_chat_composer() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("draft");
|
||||
app.bottom_pane
|
||||
.enqueue(ServerRequest::ItemCommandExecutionRequestApproval {
|
||||
id: RequestId::Integer(7),
|
||||
params: CommandExecutionRequestApprovalParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "command-1".to_string(),
|
||||
started_at_ms: 1,
|
||||
approval_id: None,
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
command: Some("cargo test".to_string()),
|
||||
cwd: Some("/workspace".to_string()),
|
||||
available_decisions: None,
|
||||
},
|
||||
})
|
||||
.expect("queue approval");
|
||||
|
||||
let ignored = dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert_eq!(ignored, AppAction::None);
|
||||
assert_eq!(app.composer.text(), "draft");
|
||||
|
||||
let response = dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(matches!(
|
||||
response,
|
||||
AppAction::Respond(AppServerResponse::Command {
|
||||
response: app_server_protocol::protocol::v2::CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Accept,
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!app.bottom_pane.is_active());
|
||||
assert_eq!(app.composer.text(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_queues_a_follow_up_without_submitting_the_active_turn() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("follow up");
|
||||
app.projection.apply(
|
||||
app_server_protocol::protocol::v2::ServerNotification::TurnStarted(
|
||||
app_server_protocol::protocol::v2::TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: app_server_protocol::protocol::v2::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
items_view: Default::default(),
|
||||
status: app_server_protocol::protocol::v2::TurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let action = dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
|
||||
);
|
||||
|
||||
assert_eq!(action, AppAction::Queue("follow up".to_string()));
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_interrupts_only_an_active_turn_and_preserves_the_draft() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("keep this draft");
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
|
||||
app.start_turn("turn-1".to_string());
|
||||
assert!(app.active_turn_elapsed(Instant::now()).is_some());
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::Interrupt
|
||||
);
|
||||
assert_eq!(app.composer.text(), "keep this draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_completion_clears_the_active_status_timer() {
|
||||
use app_server_protocol::protocol::v2::{
|
||||
Turn, TurnCompletedNotification, TurnItemsView, TurnStatus,
|
||||
};
|
||||
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("thread-1".to_string());
|
||||
app.start_turn("turn-1".to_string());
|
||||
app.apply_notification(ServerNotification::TurnCompleted(
|
||||
TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
items_view: TurnItemsView::Full,
|
||||
status: TurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: Some(1),
|
||||
completed_at: Some(2),
|
||||
duration_ms: Some(1),
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
assert!(app.projection.active_turn_id().is_none());
|
||||
assert!(app.active_turn_elapsed(Instant::now()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_profile_catalog_is_trimmed_deduplicated_and_used_for_cycles() {
|
||||
let mut app = App::default();
|
||||
app.set_permission_profiles([
|
||||
" custom-read ".to_string(),
|
||||
"custom-write".to_string(),
|
||||
"custom-read".to_string(),
|
||||
"".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
app.permission_profiles,
|
||||
vec!["custom-read".to_string(), "custom-write".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
app.cycle_permission_profile(Some("custom-read"), 1),
|
||||
"custom-write"
|
||||
);
|
||||
assert_eq!(
|
||||
app.cycle_permission_profile(Some("custom-write"), 1),
|
||||
"custom-read"
|
||||
);
|
||||
|
||||
app.set_permission_profiles([" ".to_string(), "custom-read".to_string()]);
|
||||
assert_eq!(app.permission_profiles, vec!["custom-read".to_string()]);
|
||||
app.set_permission_profiles(std::iter::empty::<String>());
|
||||
assert!(app.permission_profiles.is_empty());
|
||||
assert_eq!(
|
||||
app.cycle_permission_profile(Some(":read-only"), 1),
|
||||
":workspace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtab_cycles_server_collaboration_modes_only_when_idle() {
|
||||
let mut app = App::default();
|
||||
app.set_settings(
|
||||
Some("fixture-model".to_string()),
|
||||
Some("fixture-provider".to_string()),
|
||||
Some("medium".to_string()),
|
||||
None,
|
||||
);
|
||||
app.set_collaboration_modes(vec![
|
||||
app_server_protocol::protocol::v2::CollaborationModeMask {
|
||||
name: "Plan".to_string(),
|
||||
mode: Some(agent_protocol::ModeKind::Plan),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some("high".to_string())),
|
||||
},
|
||||
app_server_protocol::protocol::v2::CollaborationModeMask {
|
||||
name: "Default".to_string(),
|
||||
mode: Some(agent_protocol::ModeKind::Default),
|
||||
model: None,
|
||||
reasoning_effort: Some(None),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
app.collaboration_mode.as_ref().map(|mode| mode.mode),
|
||||
Some(agent_protocol::ModeKind::Default)
|
||||
);
|
||||
let plan_mode = agent_protocol::CollaborationMode {
|
||||
mode: agent_protocol::ModeKind::Plan,
|
||||
settings: agent_protocol::CollaborationModeSettings {
|
||||
model: "fixture-model".to_string(),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::ChangeCollaborationMode(plan_mode.clone())
|
||||
);
|
||||
app.collaboration_mode = Some(plan_mode);
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::ChangeCollaborationMode(agent_protocol::CollaborationMode {
|
||||
mode: agent_protocol::ModeKind::Default,
|
||||
settings: agent_protocol::CollaborationModeSettings {
|
||||
model: "fixture-model".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.start_turn("turn-1".to_string());
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_updates_keep_the_active_collaboration_mode_in_sync() {
|
||||
let mut app = App::default();
|
||||
app.collaboration_mode = Some(agent_protocol::CollaborationMode {
|
||||
mode: agent_protocol::ModeKind::Plan,
|
||||
settings: agent_protocol::CollaborationModeSettings {
|
||||
model: "old-model".to_string(),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
developer_instructions: None,
|
||||
},
|
||||
});
|
||||
|
||||
app.set_settings(
|
||||
Some("new-model".to_string()),
|
||||
Some("fixture-provider".to_string()),
|
||||
Some("low".to_string()),
|
||||
None,
|
||||
);
|
||||
|
||||
let mode = app.collaboration_mode.as_ref().expect("active mode");
|
||||
assert_eq!(mode.settings.model, "new-model");
|
||||
assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("low"));
|
||||
|
||||
app.set_settings(
|
||||
Some("newer-model".to_string()),
|
||||
Some("fixture-provider".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mode = app.collaboration_mode.as_ref().expect("active mode");
|
||||
assert_eq!(mode.settings.model, "newer-model");
|
||||
assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_popup_owns_escape_before_active_turn_interruption() {
|
||||
let mut app = App::default();
|
||||
app.start_turn("turn-1".to_string());
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(app.command_popup.is_some());
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(app.command_popup.is_none());
|
||||
assert!(app.projection.active_turn_id().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_search_owns_escape_before_active_turn_interruption() {
|
||||
let mut app = App::default();
|
||||
app.composer.load_history(["previous prompt".to_string()]);
|
||||
app.composer.insert("previous");
|
||||
app.start_turn("turn-1".to_string());
|
||||
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
|
||||
);
|
||||
assert!(app.composer.history_search_active());
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(!app.composer.history_search_active());
|
||||
assert_eq!(app.composer.text(), "previous");
|
||||
assert!(app.projection.active_turn_id().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("draft");
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT,))
|
||||
),
|
||||
AppAction::IncreaseEffort
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::NextPermissions
|
||||
);
|
||||
assert_eq!(app.composer.text(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_shortcut_and_slash_command_do_not_become_turn_input() {
|
||||
let mut app = App::default();
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL,))
|
||||
),
|
||||
AppAction::CopyLastResponse
|
||||
);
|
||||
app.composer.insert("/copy");
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::CopyLastResponse
|
||||
);
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_filters_and_executes_immediate_commands() {
|
||||
let mut app = App::default();
|
||||
for character in ['/', 'm'] {
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.command_popup.as_ref().and_then(CommandPopup::selected),
|
||||
Some(SlashCommand::Model)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::Submit("/model".to_string())
|
||||
);
|
||||
assert!(app.command_popup.is_none());
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes() {
|
||||
let mut app = App::default();
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(app.command_popup.is_some());
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(app.command_popup.is_none());
|
||||
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.command_popup.as_ref().and_then(CommandPopup::selected),
|
||||
Some(SlashCommand::Effort)
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert_eq!(app.composer.text(), "/effort ");
|
||||
assert!(app.command_popup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("real prompt");
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::Submit("real prompt".to_string())
|
||||
);
|
||||
app.set_thread_id("thread-1".to_string());
|
||||
app.set_settings(
|
||||
Some("gpt-5".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("high".to_string()),
|
||||
Some(":workspace".to_string()),
|
||||
);
|
||||
app.composer.insert("/status");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(app.pager_overlay.is_some());
|
||||
assert!(app.composer.is_empty());
|
||||
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(app.composer.is_empty());
|
||||
assert!(app.pager_overlay.is_some());
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
|
||||
);
|
||||
assert!(app.pager_overlay.is_none());
|
||||
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
|
||||
);
|
||||
assert_eq!(app.composer.text(), "real prompt");
|
||||
assert!(app.projection.entries().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("draft");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(app
|
||||
.pager_overlay
|
||||
.as_ref()
|
||||
.is_some_and(PagerOverlay::is_transcript));
|
||||
assert_eq!(app.composer.text(), "draft");
|
||||
assert!(app.projection.entries().is_empty());
|
||||
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
||||
);
|
||||
assert!(app.pager_overlay.is_none());
|
||||
assert_eq!(app.composer.text(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_shortcut_attaches_and_allows_image_only_submission() {
|
||||
let mut app = App::default();
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL,))
|
||||
),
|
||||
AppAction::PasteImage
|
||||
);
|
||||
app.attach_image(PathBuf::from("/tmp/input.png"));
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::Submit(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
app.take_pending_images(),
|
||||
vec![PathBuf::from("/tmp/input.png")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_image_submission_can_restore_pending_attachments() {
|
||||
let mut app = App::default();
|
||||
app.attach_image(PathBuf::from("/tmp/one.png"));
|
||||
app.attach_image(PathBuf::from("/tmp/two.png"));
|
||||
|
||||
let images = app.take_pending_images();
|
||||
assert!(!app.composer.has_pending_images());
|
||||
app.restore_pending_images(images);
|
||||
|
||||
assert_eq!(
|
||||
app.composer.pending_images(),
|
||||
&[PathBuf::from("/tmp/one.png"), PathBuf::from("/tmp/two.png")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_submission_projection_updates_by_id_and_clears_on_thread_change() {
|
||||
let queued = |id: &str, text: &str| QueuedSubmission {
|
||||
id: id.to_string(),
|
||||
input: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
client_user_message_id: format!("client-{id}"),
|
||||
};
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("thread-1".to_string());
|
||||
app.upsert_queued_submission(queued("queue-1", "first"));
|
||||
app.upsert_queued_submission(queued("queue-1", "revised"));
|
||||
app.upsert_queued_submission(queued("queue-2", "second"));
|
||||
|
||||
assert_eq!(app.queued_submissions.len(), 2);
|
||||
assert!(matches!(
|
||||
app.queued_submissions[0].input.as_slice(),
|
||||
[UserInput::Text { text, .. }] if text == "revised"
|
||||
));
|
||||
|
||||
app.set_thread_id("thread-2".to_string());
|
||||
assert!(app.queued_submissions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_up_requests_server_delete_before_restoring_the_last_queued_input() {
|
||||
let submission = QueuedSubmission {
|
||||
id: "queue-1".to_string(),
|
||||
input: vec![
|
||||
UserInput::LocalImage {
|
||||
detail: None,
|
||||
path: "/tmp/queued.png".to_string(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "revise this follow-up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
client_user_message_id: "client-queue-1".to_string(),
|
||||
};
|
||||
let mut app = App::default();
|
||||
app.set_queued_submissions(vec![submission.clone()]);
|
||||
|
||||
let action = dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)),
|
||||
);
|
||||
|
||||
assert_eq!(action, AppAction::EditQueuedSubmission(submission.clone()));
|
||||
assert_eq!(app.queued_submissions, vec![submission.clone()]);
|
||||
assert!(app.composer.is_empty());
|
||||
assert!(!app.composer.has_pending_images());
|
||||
|
||||
assert!(app.restore_queued_submission_for_edit(submission));
|
||||
assert!(app.queued_submissions.is_empty());
|
||||
assert_eq!(app.composer.text(), "revise this follow-up");
|
||||
assert_eq!(
|
||||
app.composer.pending_images(),
|
||||
&[PathBuf::from("/tmp/queued.png")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit() {
|
||||
let remote_image = QueuedSubmission {
|
||||
id: "queue-remote".to_string(),
|
||||
input: vec![UserInput::Image {
|
||||
detail: None,
|
||||
url: "https://example.test/input.png".to_string(),
|
||||
}],
|
||||
client_user_message_id: "client-remote".to_string(),
|
||||
};
|
||||
let mut app = App::default();
|
||||
app.set_queued_submissions(vec![remote_image]);
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
|
||||
app.set_queued_submissions(vec![QueuedSubmission {
|
||||
id: "queue-text".to_string(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "queued".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
client_user_message_id: "client-text".to_string(),
|
||||
}]);
|
||||
app.composer.insert("unsent draft");
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert_eq!(app.composer.text(), "unsent draft");
|
||||
assert_eq!(app.queued_submissions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_input_edits_locally_without_submit_or_queue() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("draft");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_disconnected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_disconnected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert_eq!(app.composer.text(), "draft!");
|
||||
assert!(app.queued_submissions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_paste_and_ctrl_c_are_handled_at_the_app_boundary() {
|
||||
let mut app = App::default();
|
||||
assert_eq!(
|
||||
dispatch_disconnected_input(&mut app, Event::Paste("离线草稿".to_string())),
|
||||
AppAction::None
|
||||
);
|
||||
assert_eq!(app.composer.text(), "离线草稿");
|
||||
assert_eq!(
|
||||
dispatch_disconnected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,))
|
||||
),
|
||||
AppAction::Quit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_picker_owns_input_until_cancelled() {
|
||||
let mut app = App::default();
|
||||
app.resume_picker = Some(PickerState::new(
|
||||
Vec::new(),
|
||||
crate::resume_picker::SessionPickerAction::Resume,
|
||||
crate::resume_picker::SessionStatus::Active,
|
||||
None,
|
||||
true,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::ResumePicker(PickerAction::Reload)
|
||||
);
|
||||
assert!(app.resume_picker.is_some());
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::ResumePicker(PickerAction::Reload)
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(app.resume_picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_right_switches_to_the_next_agent_in_spawn_order() {
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("main".to_string());
|
||||
app.agent_navigation
|
||||
.upsert("agent-1", Some("Robie".to_string()), None, false);
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT,))
|
||||
),
|
||||
AppAction::SwitchThread("agent-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagents_slash_command_opens_the_codex_named_picker() {
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("main".to_string());
|
||||
app.agent_navigation.upsert(
|
||||
"agent-1",
|
||||
Some("Robie".to_string()),
|
||||
Some("worker".to_string()),
|
||||
false,
|
||||
);
|
||||
app.composer.insert("/subagents");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)),
|
||||
),
|
||||
AppAction::None
|
||||
);
|
||||
assert!(app.agents_overview.is_none());
|
||||
assert!(app.agent_picker.is_some());
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_slash_command_opens_the_agents_overview() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("/agents");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
),
|
||||
AppAction::RefreshAgentsOverview
|
||||
);
|
||||
assert!(app.agents_overview.is_some());
|
||||
assert!(app.agent_picker.is_none());
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_slash_command_opens_the_shared_picker_action() {
|
||||
let mut app = App::default();
|
||||
app.composer.insert("/resume");
|
||||
|
||||
assert_eq!(
|
||||
dispatch_connected_input(
|
||||
&mut app,
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,))
|
||||
),
|
||||
AppAction::OpenResumePicker
|
||||
);
|
||||
assert!(app.composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_activity_marks_the_thread_parent_owned_before_resume() {
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("agent-1".to_string());
|
||||
app.apply_notification(ServerNotification::ItemStarted(
|
||||
app_server_protocol::protocol::v2::ItemStartedNotification {
|
||||
item: app_server_protocol::protocol::v2::ThreadItem::SubAgentActivity {
|
||||
id: "activity-1".to_string(),
|
||||
metadata: None,
|
||||
kind: app_server_protocol::protocol::v2::SubAgentActivityKind::Started,
|
||||
agent_thread_id: "agent-1".to_string(),
|
||||
agent_path: "/root/worker".to_string(),
|
||||
},
|
||||
thread_id: "main".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at_ms: 1,
|
||||
},
|
||||
));
|
||||
|
||||
assert!(!app.can_accept_direct_input());
|
||||
assert_eq!(app.projection.status(), "sub-agent thread is parent-owned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_agent_activity_updates_navigation_liveness_and_label() {
|
||||
let mut app = App::default();
|
||||
app.set_thread_id("main".to_string());
|
||||
app.apply_notification(ServerNotification::ItemStarted(
|
||||
app_server_protocol::protocol::v2::ItemStartedNotification {
|
||||
item: app_server_protocol::protocol::v2::ThreadItem::SubAgentActivity {
|
||||
id: "activity-1".to_string(),
|
||||
metadata: None,
|
||||
kind: app_server_protocol::protocol::v2::SubAgentActivityKind::Started,
|
||||
agent_thread_id: "agent-1".to_string(),
|
||||
agent_path: "/root/worker".to_string(),
|
||||
},
|
||||
thread_id: "main".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at_ms: 1,
|
||||
},
|
||||
));
|
||||
|
||||
assert!(
|
||||
app.agent_navigation
|
||||
.get("agent-1")
|
||||
.expect("activity creates picker row")
|
||||
.is_running
|
||||
);
|
||||
assert_eq!(
|
||||
app.agent_navigation
|
||||
.active_agent_label(Some("agent-1"), Some("main")),
|
||||
Some("`/root/worker`".to_string())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Bounded replay buffer for notifications and interactive requests by thread.
|
||||
//!
|
||||
//! The App Server stream is shared by all threads visible to the TUI. When an agent overview or
|
||||
//! a background child thread is active, events for another thread must remain ordered until that
|
||||
//! thread is resumed. This owner keeps the buffer finite and never stores requests it cannot
|
||||
//! replay safely.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use app_server_protocol::protocol::v2::ServerNotification;
|
||||
|
||||
use super::thread_events::{ThreadBufferedEvent, ThreadEventStore};
|
||||
|
||||
/// Maximum number of events retained for one thread.
|
||||
pub(crate) const THREAD_EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
const MAX_COALESCED_AGENT_MESSAGE_DELTA_BYTES: usize = 4 * 1024;
|
||||
const MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES: usize = 256 * 1024;
|
||||
|
||||
impl ThreadEventStore {
|
||||
pub(super) fn push_replay_notification(&mut self, notification: Cow<'_, ServerNotification>) {
|
||||
if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() {
|
||||
if delta.delta.len() > MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES {
|
||||
return;
|
||||
}
|
||||
let can_coalesce = match self.buffer.back() {
|
||||
Some(ThreadBufferedEvent::Notification(previous)) => match previous.as_ref() {
|
||||
ServerNotification::AgentMessageDelta(previous) => {
|
||||
previous.thread_id == delta.thread_id
|
||||
&& previous.turn_id == delta.turn_id
|
||||
&& previous.item_id == delta.item_id
|
||||
&& previous.delta.len().saturating_add(delta.delta.len())
|
||||
<= MAX_COALESCED_AGENT_MESSAGE_DELTA_BYTES
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
if can_coalesce {
|
||||
if let Some(ThreadBufferedEvent::Notification(previous)) = self.buffer.back_mut() {
|
||||
if let ServerNotification::AgentMessageDelta(previous) = previous.as_mut() {
|
||||
previous.delta.push_str(&delta.delta);
|
||||
self.buffered_agent_message_delta_bytes = self
|
||||
.buffered_agent_message_delta_bytes
|
||||
.saturating_add(delta.delta.len());
|
||||
self.evict_overflowing_events();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.push_buffered_event(ThreadBufferedEvent::Notification(Box::new(
|
||||
notification.into_owned(),
|
||||
)));
|
||||
}
|
||||
|
||||
pub(super) fn push_buffered_event(&mut self, event: ThreadBufferedEvent) -> bool {
|
||||
if matches!(event, ThreadBufferedEvent::Request(_)) && self.buffer.len() >= self.capacity {
|
||||
return false;
|
||||
}
|
||||
if let ThreadBufferedEvent::Notification(notification) = &event {
|
||||
if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() {
|
||||
self.buffered_agent_message_delta_bytes = self
|
||||
.buffered_agent_message_delta_bytes
|
||||
.saturating_add(delta.delta.len());
|
||||
}
|
||||
}
|
||||
self.buffer.push_back(event);
|
||||
self.evict_overflowing_events();
|
||||
true
|
||||
}
|
||||
|
||||
fn evict_overflowing_events(&mut self) {
|
||||
while self.buffer.len() > self.capacity
|
||||
|| self.buffered_agent_message_delta_bytes > MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES
|
||||
{
|
||||
let Some(index) = self
|
||||
.buffer
|
||||
.iter()
|
||||
.position(|event| matches!(event, ThreadBufferedEvent::Notification(_)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let Some(ThreadBufferedEvent::Notification(notification)) = self.buffer.remove(index)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() {
|
||||
self.buffered_agent_message_delta_bytes = self
|
||||
.buffered_agent_message_delta_bytes
|
||||
.saturating_sub(delta.delta.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_event_buffer_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,144 @@
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
AgentMessageDeltaNotification, ServerNotification, ServerRequest,
|
||||
ServerRequestResolvedNotification, ThreadClosedNotification, Turn, TurnCompletedNotification,
|
||||
TurnItemsView, TurnStatus,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
|
||||
fn delta(thread_id: &str, turn_id: &str, item_id: &str, text: &str) -> ServerNotification {
|
||||
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: text.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_coalesces_only_adjacent_matching_agent_message_deltas() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
||||
store.push_notification(delta("thread", "turn", "item", "hello"));
|
||||
store.push_notification(delta("thread", "turn", "item", " world"));
|
||||
store.push_notification(delta("thread", "turn", "other", "bar"));
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 2);
|
||||
let ThreadBufferedEvent::Notification(first) = &snapshot.events[0] else {
|
||||
panic!("expected notification");
|
||||
};
|
||||
let ServerNotification::AgentMessageDelta(first) = first.as_ref() else {
|
||||
panic!("expected agent delta");
|
||||
};
|
||||
assert_eq!(first.delta, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_preserves_requests_while_evicting_old_notifications() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
||||
let request = ServerRequest::CurrentTimeRead {
|
||||
id: RequestId::Integer(1),
|
||||
params: app_server_protocol::protocol::v2::CurrentTimeReadParams {
|
||||
thread_id: "thread".to_string(),
|
||||
},
|
||||
};
|
||||
assert!(store.push_request(request));
|
||||
for index in 0..6 {
|
||||
store.push_notification(delta("thread", "turn", &format!("item-{index}"), "x"));
|
||||
}
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 4);
|
||||
assert!(snapshot
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ThreadBufferedEvent::Request(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_tracks_delta_bytes_after_coalescing() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
||||
store.push_notification(delta("thread", "turn", "item", "a"));
|
||||
store.push_notification(delta("thread", "turn", "item", "bc"));
|
||||
assert_eq!(store.buffered_agent_message_delta_bytes, 3);
|
||||
}
|
||||
|
||||
fn user_input_request(id: i64, thread_id: &str) -> ServerRequest {
|
||||
ServerRequest::ItemToolRequestUserInput {
|
||||
id: RequestId::Integer(id),
|
||||
params: app_server_protocol::protocol::v2::ToolRequestUserInputParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: format!("item-{id}"),
|
||||
questions: Vec::new(),
|
||||
auto_resolution_ms: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_does_not_replay_server_resolved_request() {
|
||||
let mut store = ThreadEventStore::new(4);
|
||||
store.push_request(user_input_request(7, "thread"));
|
||||
store.push_notification(ServerNotification::ServerRequestResolved(
|
||||
ServerRequestResolvedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
request_id: RequestId::Integer(7),
|
||||
},
|
||||
));
|
||||
|
||||
assert!(store
|
||||
.snapshot()
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_drops_requests_when_turn_completes_or_thread_closes() {
|
||||
let mut store = ThreadEventStore::new(4);
|
||||
store.push_request(user_input_request(8, "thread"));
|
||||
store.push_notification(ServerNotification::TurnCompleted(
|
||||
TurnCompletedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
turn: Turn {
|
||||
id: "turn".to_string(),
|
||||
items: Vec::new(),
|
||||
items_view: TurnItemsView::Full,
|
||||
status: TurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
));
|
||||
assert!(store
|
||||
.snapshot()
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) }));
|
||||
|
||||
store.push_request(user_input_request(9, "thread"));
|
||||
store.push_notification(ServerNotification::ThreadClosed(ThreadClosedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
}));
|
||||
assert!(store
|
||||
.snapshot()
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_request_capacity_fails_closed_without_evicting_requests() {
|
||||
let mut store = ThreadEventStore::new(1);
|
||||
assert!(store.push_request(user_input_request(10, "thread")));
|
||||
assert!(!store.push_request(user_input_request(11, "thread")));
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 1);
|
||||
assert!(matches!(
|
||||
snapshot.events.first(),
|
||||
Some(ThreadBufferedEvent::Request(request)) if request.id() == &RequestId::Integer(10)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Thread-scoped event projection and agent activity observation.
|
||||
//!
|
||||
//! App Server transport delivery remains in `app_server_events.rs`; this owner applies canonical
|
||||
//! Thread/Turn/Item notifications to the active projection and maintains navigation liveness.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem};
|
||||
|
||||
use super::app_server_event_targets::{
|
||||
server_notification_thread_target, ServerNotificationThreadTarget,
|
||||
};
|
||||
use super::pending_interactive_replay::PendingInteractiveReplayState;
|
||||
use super::replay_filter;
|
||||
use super::thread_event_buffer::THREAD_EVENT_CHANNEL_CAPACITY;
|
||||
use super::App;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ThreadEventSnapshot {
|
||||
pub(super) events: Vec<ThreadBufferedEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ThreadBufferedEvent {
|
||||
Notification(Box<ServerNotification>),
|
||||
Request(Box<ServerRequest>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ThreadEventStore {
|
||||
pub(super) buffer: VecDeque<ThreadBufferedEvent>,
|
||||
pub(super) capacity: usize,
|
||||
pub(super) buffered_agent_message_delta_bytes: usize,
|
||||
pub(super) pending_interactive_replay: PendingInteractiveReplayState,
|
||||
}
|
||||
|
||||
impl ThreadEventStore {
|
||||
pub(super) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: VecDeque::new(),
|
||||
capacity,
|
||||
buffered_agent_message_delta_bytes: 0,
|
||||
pending_interactive_replay: PendingInteractiveReplayState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn push_notification(&mut self, notification: ServerNotification) {
|
||||
self.pending_interactive_replay
|
||||
.note_server_notification(¬ification);
|
||||
if matches!(notification, ServerNotification::ServerRequestResolved(_)) {
|
||||
return;
|
||||
}
|
||||
self.push_replay_notification(Cow::Owned(notification));
|
||||
}
|
||||
|
||||
pub(super) fn push_notification_ref(&mut self, notification: &ServerNotification) {
|
||||
self.pending_interactive_replay
|
||||
.note_server_notification(notification);
|
||||
if matches!(notification, ServerNotification::ServerRequestResolved(_)) {
|
||||
return;
|
||||
}
|
||||
self.push_replay_notification(Cow::Borrowed(notification));
|
||||
}
|
||||
|
||||
pub(super) fn push_request(&mut self, request: ServerRequest) -> bool {
|
||||
self.pending_interactive_replay
|
||||
.note_server_request(&request);
|
||||
if self.push_buffered_event(ThreadBufferedEvent::Request(Box::new(request.clone()))) {
|
||||
true
|
||||
} else {
|
||||
self.pending_interactive_replay
|
||||
.note_evicted_server_request(&request);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh `thread/resume` snapshot supersedes buffered projection events. Interactive
|
||||
/// requests survive because they still need an explicit client response.
|
||||
pub(super) fn rebase_buffer_after_session_refresh(&mut self) {
|
||||
self.buffer.retain(|event| {
|
||||
matches!(event, ThreadBufferedEvent::Request(request)
|
||||
if self.pending_interactive_replay.should_replay_snapshot_request(request))
|
||||
});
|
||||
self.buffered_agent_message_delta_bytes = 0;
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(&self) -> ThreadEventSnapshot {
|
||||
ThreadEventSnapshot {
|
||||
events: self
|
||||
.buffer
|
||||
.iter()
|
||||
.filter(|event| match event {
|
||||
ThreadBufferedEvent::Request(request) => self
|
||||
.pending_interactive_replay
|
||||
.should_replay_snapshot_request(request),
|
||||
ThreadBufferedEvent::Notification(_) => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_snapshot(&mut self) -> ThreadEventSnapshot {
|
||||
self.buffered_agent_message_delta_bytes = 0;
|
||||
let mut snapshot = self.snapshot();
|
||||
replay_filter::omit_completed_agent_deltas(&mut snapshot.events);
|
||||
self.buffer.clear();
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub(super) fn note_outbound_response(&mut self, request_id: &app_server_protocol::RequestId) {
|
||||
self.pending_interactive_replay
|
||||
.note_outbound_response(request_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ThreadEventChannel {
|
||||
pub(super) store: ThreadEventStore,
|
||||
}
|
||||
|
||||
impl ThreadEventChannel {
|
||||
pub(super) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
store: ThreadEventStore::new(capacity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn ensure_thread_channel(&mut self, thread_id: &str) -> &mut ThreadEventChannel {
|
||||
self.thread_event_channels
|
||||
.entry(thread_id.to_string())
|
||||
.or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY))
|
||||
}
|
||||
|
||||
pub(super) fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) {
|
||||
if replay_filter::snapshot_has_pending_interactive_request(&snapshot) {
|
||||
self.pager_overlay = None;
|
||||
}
|
||||
for event in snapshot.events {
|
||||
if replay_filter::event_is_notice(&event) {
|
||||
continue;
|
||||
}
|
||||
self.handle_thread_event_replay(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) {
|
||||
match event {
|
||||
ThreadBufferedEvent::Notification(notification) => {
|
||||
self.apply_notification(*notification)
|
||||
}
|
||||
ThreadBufferedEvent::Request(request) => {
|
||||
if let Err(request) = self.bottom_pane.enqueue(*request) {
|
||||
debug_assert!(
|
||||
false,
|
||||
"buffered request became unsupported: {}",
|
||||
request.method()
|
||||
);
|
||||
} else {
|
||||
self.pager_overlay = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn enqueue_thread_request(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
request: ServerRequest,
|
||||
) -> Result<(), Box<ServerRequest>> {
|
||||
let channel = self.ensure_thread_channel(thread_id);
|
||||
if channel.store.push_request(request.clone()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Box::new(request))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_thread_event_snapshot(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
session_refreshed: bool,
|
||||
) -> ThreadEventSnapshot {
|
||||
let channel = self.ensure_thread_channel(thread_id);
|
||||
if session_refreshed {
|
||||
channel.store.rebase_buffer_after_session_refresh();
|
||||
}
|
||||
channel.store.drain_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_notification(&mut self, notification: ServerNotification) {
|
||||
self.track_agents_overview_notification(¬ification);
|
||||
self.observe_notification(¬ification);
|
||||
let target = server_notification_thread_target(¬ification);
|
||||
if matches!(
|
||||
target,
|
||||
ServerNotificationThreadTarget::Thread(ref thread_id)
|
||||
if self.thread_id.as_deref() != Some(thread_id.as_str())
|
||||
) || matches!(target, ServerNotificationThreadTarget::InvalidThreadId(_))
|
||||
{
|
||||
if let ServerNotificationThreadTarget::Thread(thread_id) = target {
|
||||
self.ensure_thread_channel(&thread_id)
|
||||
.store
|
||||
.push_notification_ref(¬ification);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let previous_turn_id = self.projection.active_turn_id().map(str::to_owned);
|
||||
self.projection.apply(notification);
|
||||
let active_turn_id = self.projection.active_turn_id();
|
||||
if active_turn_id != previous_turn_id.as_deref() {
|
||||
self.active_turn_started_at = active_turn_id.map(|_| Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_outbound_response(
|
||||
&mut self,
|
||||
response: &crate::bottom_pane::AppServerResponse,
|
||||
) {
|
||||
let request_id = match response {
|
||||
crate::bottom_pane::AppServerResponse::Command { id, .. }
|
||||
| crate::bottom_pane::AppServerResponse::FileChange { id, .. }
|
||||
| crate::bottom_pane::AppServerResponse::Permissions { id, .. }
|
||||
| crate::bottom_pane::AppServerResponse::UserInput { id, .. } => id,
|
||||
};
|
||||
for channel in self.thread_event_channels.values_mut() {
|
||||
channel.store.note_outbound_response(request_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_notification(&mut self, notification: &ServerNotification) {
|
||||
match notification {
|
||||
ServerNotification::ThreadStarted(params) => {
|
||||
self.agent_navigation.upsert(
|
||||
params.thread.id.clone(),
|
||||
params.thread.agent_nickname.clone(),
|
||||
params.thread.agent_role.clone(),
|
||||
false,
|
||||
);
|
||||
if params.thread.parent_thread_id.is_some() {
|
||||
self.agent_navigation
|
||||
.mark_parent_owned(params.thread.id.clone());
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadClosed(params) => {
|
||||
self.agent_navigation.mark_closed(¶ms.thread_id);
|
||||
}
|
||||
ServerNotification::ThreadStatusChanged(params) => match ¶ms.status {
|
||||
app_server_protocol::protocol::v2::ThreadStatus::Active { .. } => {
|
||||
self.agent_navigation.mark_running(¶ms.thread_id)
|
||||
}
|
||||
app_server_protocol::protocol::v2::ThreadStatus::Idle
|
||||
| app_server_protocol::protocol::v2::ThreadStatus::SystemError => {
|
||||
self.agent_navigation.mark_stopped(¶ms.thread_id)
|
||||
}
|
||||
app_server_protocol::protocol::v2::ThreadStatus::NotLoaded => {}
|
||||
},
|
||||
ServerNotification::ItemStarted(params) => {
|
||||
self.observe_item(¶ms.item);
|
||||
}
|
||||
ServerNotification::ItemCompleted(params) => {
|
||||
self.observe_item(¶ms.item);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_item(&mut self, item: &ThreadItem) {
|
||||
if let Some(activity) = crate::multi_agents::sub_agent_activity_display(item) {
|
||||
// Activity is emitted on the parent stream before a child thread can be resumed
|
||||
// independently. Treat that observation as authoritative for direct-input policy.
|
||||
self.agent_navigation
|
||||
.mark_parent_owned(activity.thread_id.clone());
|
||||
self.agent_navigation.record_sub_agent_activity(activity);
|
||||
return;
|
||||
}
|
||||
if let ThreadItem::CollabAgentToolCall {
|
||||
tool: app_server_protocol::protocol::v2::CollabAgentTool::SpawnAgent,
|
||||
receiver_thread_ids,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
for thread_id in receiver_thread_ids {
|
||||
self.agent_navigation.mark_parent_owned(thread_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Thread-scoped model, provider, permission and collaboration settings.
|
||||
//!
|
||||
//! Settings are mirrored from the App Server session into the active TUI projection. This module
|
||||
//! owns local normalization and Codex-shaped collaboration mode selection; persistence remains in
|
||||
//! `AppServerSession`.
|
||||
|
||||
use super::*;
|
||||
use crate::collaboration_modes;
|
||||
use crate::settings::{cycle_setting, PERMISSION_PROFILES};
|
||||
|
||||
impl App {
|
||||
pub(crate) fn set_settings(
|
||||
&mut self,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
permissions: Option<String>,
|
||||
) {
|
||||
self.model = model;
|
||||
self.model_provider = model_provider;
|
||||
self.reasoning_effort = reasoning_effort;
|
||||
self.permissions = permissions;
|
||||
if let Some(collaboration_mode) = self.collaboration_mode.as_mut() {
|
||||
if let Some(model) = self.model.as_deref() {
|
||||
collaboration_mode.settings.model = model.to_string();
|
||||
}
|
||||
if let Some(effort) = self.reasoning_effort.as_ref() {
|
||||
collaboration_mode.settings.reasoning_effort = Some(effort.clone());
|
||||
}
|
||||
}
|
||||
self.sync_default_collaboration_mode();
|
||||
}
|
||||
|
||||
pub(crate) fn set_permission_profiles(&mut self, profiles: impl IntoIterator<Item = String>) {
|
||||
let mut next = Vec::new();
|
||||
for profile in profiles {
|
||||
let profile = profile.trim();
|
||||
if profile.is_empty() || next.iter().any(|value| value == profile) {
|
||||
continue;
|
||||
}
|
||||
next.push(profile.to_string());
|
||||
}
|
||||
self.permission_profiles = next;
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_permission_profile(&self, current: Option<&str>, direction: i8) -> String {
|
||||
if self.permission_profiles.is_empty() {
|
||||
return cycle_setting(&PERMISSION_PROFILES, current, direction);
|
||||
}
|
||||
cycle_setting(&self.permission_profiles, current, direction)
|
||||
}
|
||||
|
||||
pub(crate) fn open_model_picker(
|
||||
&mut self,
|
||||
models: Vec<app_server_protocol::protocol::v2::Model>,
|
||||
) {
|
||||
self.model_catalog = ModelCatalog::new(models)
|
||||
.with_collaboration_modes(self.model_catalog.collaboration_modes.clone());
|
||||
let models = self.model_catalog.try_list_models().unwrap_or_default();
|
||||
self.model_picker = Some(ModelPicker::new(models));
|
||||
}
|
||||
|
||||
pub(crate) fn set_collaboration_modes(
|
||||
&mut self,
|
||||
collaboration_modes: Vec<app_server_protocol::protocol::v2::CollaborationModeMask>,
|
||||
) {
|
||||
self.model_catalog.collaboration_modes = collaboration_modes;
|
||||
self.sync_default_collaboration_mode();
|
||||
}
|
||||
|
||||
fn sync_default_collaboration_mode(&mut self) {
|
||||
if self.collaboration_mode.is_some() {
|
||||
return;
|
||||
}
|
||||
let mask = collaboration_modes::default_mode_mask(&self.model_catalog)
|
||||
.or_else(|| collaboration_modes::default_mask(&self.model_catalog));
|
||||
self.collaboration_mode = mask.and_then(|mask| {
|
||||
collaboration_modes::to_mode(
|
||||
&mask,
|
||||
self.model.as_deref(),
|
||||
self.reasoning_effort.as_deref(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn next_collaboration_mode(&self) -> Option<agent_protocol::CollaborationMode> {
|
||||
let current_mask = self.collaboration_mode.as_ref().map(|mode| {
|
||||
app_server_protocol::protocol::v2::CollaborationModeMask {
|
||||
name: String::new(),
|
||||
mode: Some(mode.mode),
|
||||
model: Some(mode.settings.model.clone()),
|
||||
reasoning_effort: Some(mode.settings.reasoning_effort.clone()),
|
||||
}
|
||||
});
|
||||
let mask = collaboration_modes::next_mask(&self.model_catalog, current_mask.as_ref())?;
|
||||
collaboration_modes::to_mode(
|
||||
&mask,
|
||||
self.model.as_deref(),
|
||||
self.reasoning_effort.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn plan_mode(&self) -> Option<agent_protocol::CollaborationMode> {
|
||||
let mask = collaboration_modes::plan_mask(&self.model_catalog)?;
|
||||
collaboration_modes::to_mode(
|
||||
&mask,
|
||||
self.model.as_deref(),
|
||||
self.reasoning_effort.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,31 @@ use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use app_server_client::{
|
||||
ClientSession, RemoteTransportConfig, RequestHandle, SessionEvent, StdioTransportConfig,
|
||||
AppServerEvent, ClientSession, RemoteTransportConfig, RequestHandle, StdioTransportConfig,
|
||||
};
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CurrentTimeReadResponse, METHOD_PERMISSION_PROFILE_LIST, METHOD_PROMPT_HISTORY_APPEND,
|
||||
METHOD_PROMPT_HISTORY_READ, METHOD_THREAD_ARCHIVE, METHOD_THREAD_QUEUE_ADD,
|
||||
METHOD_THREAD_QUEUE_DELETE, METHOD_THREAD_QUEUE_LIST, METHOD_THREAD_READ, METHOD_THREAD_RESUME,
|
||||
METHOD_THREAD_SETTINGS_UPDATE, METHOD_THREAD_START, METHOD_TURN_INTERRUPT, METHOD_TURN_START,
|
||||
METHOD_TURN_STEER, ModelListParams, ModelListResponse, PermissionProfileListParams,
|
||||
CollaborationModeListParams, CollaborationModeListResponse, CollaborationModeMask,
|
||||
CurrentTimeReadResponse, ModelListParams, ModelListResponse, PermissionProfileListParams,
|
||||
PermissionProfileListResponse, PromptHistoryAppendParams, PromptHistoryAppendResponse,
|
||||
PromptHistoryReadParams, PromptHistoryReadResponse, QueuedSubmission, ServerRequest,
|
||||
ThreadForkParams, ThreadForkResponse, ThreadListParams, ThreadListResponse,
|
||||
ThreadQueueAddParams, ThreadQueueAddResponse, ThreadQueueDeleteParams,
|
||||
ThreadQueueDeleteResponse, ThreadQueueListParams, ThreadQueueListResponse, ThreadReadParams,
|
||||
ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, ThreadSettingsUpdateParams,
|
||||
ThreadSettingsUpdateResponse, ThreadStartParams, ThreadStartResponse, ThreadUnarchiveParams,
|
||||
ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, ThreadSetNameParams,
|
||||
ThreadSetNameResponse, ThreadSettingsUpdateParams, ThreadSettingsUpdateResponse,
|
||||
ThreadStartParams, ThreadStartResponse, ThreadStartSource, ThreadUnarchiveParams,
|
||||
ThreadUnarchiveResponse, TurnInterruptParams, TurnInterruptResponse, TurnStartParams,
|
||||
TurnStartResponse, TurnSteerParams, TurnSteerResponse, UserInput,
|
||||
METHOD_COLLABORATION_MODE_LIST, METHOD_PERMISSION_PROFILE_LIST, METHOD_PROMPT_HISTORY_APPEND,
|
||||
METHOD_PROMPT_HISTORY_READ, METHOD_THREAD_ARCHIVE, METHOD_THREAD_QUEUE_ADD,
|
||||
METHOD_THREAD_QUEUE_DELETE, METHOD_THREAD_QUEUE_LIST, METHOD_THREAD_READ, METHOD_THREAD_RESUME,
|
||||
METHOD_THREAD_SETTINGS_UPDATE, METHOD_THREAD_START, METHOD_TURN_INTERRUPT, METHOD_TURN_START,
|
||||
METHOD_TURN_STEER,
|
||||
};
|
||||
use app_server_protocol::{
|
||||
ClientCapabilities, ClientInfo, InitializeParams, JsonRpcError, JsonRpcRequest, RequestId,
|
||||
ClientCapabilities, ClientInfo, InitializeParams, JsonRpcError, RequestId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -87,21 +90,10 @@ impl AppServerSession {
|
||||
cwd: PathBuf,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
) -> Result<String> {
|
||||
let cwd = cwd.to_string_lossy().into_owned();
|
||||
let params = ThreadStartParams {
|
||||
cwd: Some(cwd.clone()),
|
||||
runtime_workspace_roots: Some(vec![cwd]),
|
||||
model,
|
||||
model_provider,
|
||||
experimental_raw_events: false,
|
||||
..ThreadStartParams::default()
|
||||
};
|
||||
let response: ThreadStartResponse = self
|
||||
.request_handle
|
||||
.request(METHOD_THREAD_START, params)
|
||||
.await
|
||||
.context("failed to start App Server thread")?;
|
||||
) -> Result<ThreadStartResponse> {
|
||||
let response = self
|
||||
.start_thread_with_session_start_source(cwd, model, model_provider, None)
|
||||
.await?;
|
||||
let thread_id = response.thread.id.clone();
|
||||
self.session_id = Some(response.thread.session_id.clone());
|
||||
self.thread_id = Some(thread_id.clone());
|
||||
@@ -109,7 +101,33 @@ impl AppServerSession {
|
||||
.active_permission_profile
|
||||
.as_ref()
|
||||
.and_then(permission_profile_id);
|
||||
Ok(thread_id)
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Start a thread without changing the interactive session target.
|
||||
pub(crate) async fn start_thread_with_session_start_source(
|
||||
&self,
|
||||
cwd: PathBuf,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
session_start_source: Option<ThreadStartSource>,
|
||||
) -> Result<ThreadStartResponse> {
|
||||
let cwd = cwd.to_string_lossy().into_owned();
|
||||
self.request_handle
|
||||
.request(
|
||||
METHOD_THREAD_START,
|
||||
ThreadStartParams {
|
||||
cwd: Some(cwd.clone()),
|
||||
runtime_workspace_roots: Some(vec![cwd]),
|
||||
model,
|
||||
model_provider,
|
||||
session_start_source,
|
||||
experimental_raw_events: false,
|
||||
..ThreadStartParams::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to start App Server thread")
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_thread(
|
||||
@@ -176,10 +194,7 @@ impl AppServerSession {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn list_thread_page(
|
||||
&self,
|
||||
params: ThreadListParams,
|
||||
) -> Result<ThreadListResponse> {
|
||||
pub(crate) async fn thread_list(&self, params: ThreadListParams) -> Result<ThreadListResponse> {
|
||||
self.request_handle
|
||||
.request(
|
||||
app_server_protocol::protocol::v2::METHOD_THREAD_LIST,
|
||||
@@ -200,7 +215,7 @@ impl AppServerSession {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn read_thread(
|
||||
pub(crate) async fn thread_read(
|
||||
&self,
|
||||
thread_id: impl Into<String>,
|
||||
include_turns: bool,
|
||||
@@ -218,7 +233,7 @@ impl AppServerSession {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn archive_thread(&self, thread_id: impl Into<String>) -> Result<()> {
|
||||
pub(crate) async fn thread_archive(&self, thread_id: impl Into<String>) -> Result<()> {
|
||||
self.request_handle
|
||||
.request(
|
||||
METHOD_THREAD_ARCHIVE,
|
||||
@@ -231,8 +246,26 @@ impl AppServerSession {
|
||||
.context("failed to archive App Server thread")
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_set_name(
|
||||
&self,
|
||||
thread_id: impl Into<String>,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
self.request_handle
|
||||
.request(
|
||||
app_server_protocol::protocol::v2::METHOD_THREAD_NAME_SET,
|
||||
ThreadSetNameParams {
|
||||
thread_id: thread_id.into(),
|
||||
name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|_: ThreadSetNameResponse| ())
|
||||
.context("failed to set App Server thread name")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn unarchive_thread(
|
||||
pub(crate) async fn thread_unarchive(
|
||||
&self,
|
||||
thread_id: impl Into<String>,
|
||||
) -> Result<ThreadUnarchiveResponse> {
|
||||
@@ -303,6 +336,42 @@ impl AppServerSession {
|
||||
bail!("model list pagination exceeded 16 pages")
|
||||
}
|
||||
|
||||
/// Discover optional collaboration modes from the App Server catalog.
|
||||
///
|
||||
/// A server may legitimately omit this experimental method. Callers treat
|
||||
/// an error as an empty catalog and keep the ordinary model picker usable.
|
||||
pub(crate) async fn list_collaboration_modes(&self) -> Result<Vec<CollaborationModeMask>> {
|
||||
let response: CollaborationModeListResponse = self
|
||||
.request_handle
|
||||
.request(
|
||||
METHOD_COLLABORATION_MODE_LIST,
|
||||
CollaborationModeListParams {},
|
||||
)
|
||||
.await
|
||||
.context("failed to list App Server collaboration modes")?;
|
||||
Ok(response.data)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_collaboration_mode(
|
||||
&self,
|
||||
collaboration_mode: agent_protocol::CollaborationMode,
|
||||
) -> Result<()> {
|
||||
let thread_id = self.thread_id()?.to_string();
|
||||
let _: ThreadSettingsUpdateResponse = self
|
||||
.request_handle
|
||||
.request(
|
||||
METHOD_THREAD_SETTINGS_UPDATE,
|
||||
ThreadSettingsUpdateParams {
|
||||
thread_id,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
..ThreadSettingsUpdateParams::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to update App Server collaboration mode")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_queued_submissions(
|
||||
&self,
|
||||
limit: u32,
|
||||
@@ -433,6 +502,26 @@ impl AppServerSession {
|
||||
Ok(response.turn.id)
|
||||
}
|
||||
|
||||
pub(crate) async fn turn_start(
|
||||
&self,
|
||||
thread_id: impl Into<String>,
|
||||
input: Vec<UserInput>,
|
||||
) -> Result<String> {
|
||||
let response: TurnStartResponse = self
|
||||
.request_handle
|
||||
.request(
|
||||
METHOD_TURN_START,
|
||||
TurnStartParams {
|
||||
thread_id: thread_id.into(),
|
||||
input,
|
||||
..TurnStartParams::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to start background turn")?;
|
||||
Ok(response.turn.id)
|
||||
}
|
||||
|
||||
pub(crate) async fn interrupt(&self, turn_id: &str) -> Result<()> {
|
||||
let thread_id = self.thread_id()?.to_string();
|
||||
let _: TurnInterruptResponse = self
|
||||
@@ -449,6 +538,25 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn turn_interrupt(
|
||||
&self,
|
||||
thread_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
) -> Result<()> {
|
||||
let _: TurnInterruptResponse = self
|
||||
.request_handle
|
||||
.request(
|
||||
METHOD_TURN_INTERRUPT,
|
||||
TurnInterruptParams {
|
||||
thread_id: thread_id.into(),
|
||||
turn_id: turn_id.into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to interrupt background turn")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn steer_turn_input(
|
||||
&self,
|
||||
turn_id: &str,
|
||||
@@ -507,7 +615,7 @@ impl AppServerSession {
|
||||
Ok(response.deleted)
|
||||
}
|
||||
|
||||
pub(crate) async fn next_event(&mut self) -> Option<SessionEvent> {
|
||||
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
||||
self.session.next_event().await
|
||||
}
|
||||
|
||||
@@ -556,20 +664,6 @@ impl AppServerSession {
|
||||
.context("failed to reject unsupported server request")
|
||||
}
|
||||
|
||||
pub(crate) async fn reject_raw_server_request(&self, request: JsonRpcRequest) -> Result<()> {
|
||||
let method = request.method.clone();
|
||||
self.request_handle
|
||||
.reject(
|
||||
request.id,
|
||||
JsonRpcError::new(
|
||||
app_server_protocol::error_codes::METHOD_NOT_FOUND,
|
||||
format!("TUI client does not support server request {method}"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("failed to reject unknown server request")
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(self) -> Result<()> {
|
||||
self.session
|
||||
.shutdown()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use app_server_protocol::RequestId;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams,
|
||||
CommandExecutionRequestApprovalResponse, FileChangeApprovalDecision,
|
||||
@@ -6,6 +5,7 @@ use app_server_protocol::protocol::v2::{
|
||||
PermissionGrantScope, PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse,
|
||||
ServerRequest,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
|
||||
use super::AppServerResponse;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use std::cell::RefMut;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
mod attachment_state;
|
||||
mod draft_state;
|
||||
mod history_search;
|
||||
mod reconnect;
|
||||
|
||||
use self::attachment_state::AttachmentState;
|
||||
use self::draft_state::DraftState;
|
||||
use self::history_search::HistorySearchState;
|
||||
use crate::bottom_pane::textarea::{TextArea, TextAreaState};
|
||||
|
||||
const MAX_HISTORY_ENTRIES: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -18,34 +29,71 @@ pub(crate) enum InputResult {
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct HistorySearchState {
|
||||
query: String,
|
||||
draft: String,
|
||||
selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ChatComposer {
|
||||
text: String,
|
||||
cursor: usize,
|
||||
draft: DraftState,
|
||||
attachments: AttachmentState,
|
||||
history: Vec<String>,
|
||||
history_index: Option<usize>,
|
||||
saved_draft: Option<String>,
|
||||
history_search: Option<HistorySearchState>,
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
pub(crate) fn textarea(&self) -> &TextArea {
|
||||
&self.draft.textarea
|
||||
}
|
||||
|
||||
pub(crate) fn textarea_state_mut(&self) -> RefMut<'_, TextAreaState> {
|
||||
self.draft.textarea_state_mut()
|
||||
}
|
||||
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
self.draft.textarea.text()
|
||||
}
|
||||
|
||||
pub(crate) fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
self.draft.textarea.cursor()
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
self.draft.textarea.desired_height(width)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn cursor_position(&self, width: u16) -> Option<(usize, usize)> {
|
||||
self.draft.textarea.cursor_position(width)
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
self.draft.textarea.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn pending_image_count(&self) -> usize {
|
||||
self.attachments.len()
|
||||
}
|
||||
|
||||
pub(crate) fn pending_images(&self) -> &[std::path::PathBuf] {
|
||||
self.attachments.paths()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_images(&self) -> bool {
|
||||
!self.attachments.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(&mut self, path: std::path::PathBuf) {
|
||||
self.attachments.attach(path);
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_images(&mut self) -> Vec<std::path::PathBuf> {
|
||||
self.attachments.take()
|
||||
}
|
||||
|
||||
pub(crate) fn restore_pending_images(&mut self, images: Vec<std::path::PathBuf>) {
|
||||
self.attachments.restore(images);
|
||||
}
|
||||
|
||||
pub(crate) fn remove_last_pending_image(&mut self) -> bool {
|
||||
self.attachments.remove_last()
|
||||
}
|
||||
|
||||
pub(crate) fn history_search_active(&self) -> bool {
|
||||
@@ -77,21 +125,36 @@ impl ChatComposer {
|
||||
self.history.drain(..keep_from);
|
||||
}
|
||||
self.history_index = None;
|
||||
self.saved_draft = None;
|
||||
self.draft.saved_draft = None;
|
||||
self.history_search = None;
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, value: &str) {
|
||||
self.text.insert_str(self.cursor, value);
|
||||
self.cursor += value.len();
|
||||
self.draft.textarea.insert(value);
|
||||
self.reset_history_navigation();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> InputResult {
|
||||
if matches!(key.kind, KeyEventKind::Release) {
|
||||
return InputResult::None;
|
||||
}
|
||||
if self.history_search.is_some() {
|
||||
return self.handle_history_search_key(key);
|
||||
}
|
||||
|
||||
let editor_key = matches!(key.code, KeyCode::Char('b' | 'f' | 'w' | 'k' | 'u' | 'y'))
|
||||
&& key.modifiers == KeyModifiers::CONTROL
|
||||
|| matches!(key.code, KeyCode::Char('d')) && key.modifiers == KeyModifiers::ALT
|
||||
|| matches!(key.code, KeyCode::Backspace | KeyCode::Delete)
|
||||
&& key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
|
||||
if editor_key {
|
||||
self.draft.textarea.input(key);
|
||||
self.reset_history_navigation();
|
||||
return InputResult::Changed;
|
||||
}
|
||||
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return match key.code {
|
||||
KeyCode::Char('c') => InputResult::Interrupt,
|
||||
@@ -101,11 +164,11 @@ impl ChatComposer {
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.cursor = self.line_start();
|
||||
self.draft.textarea.move_line_start();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
self.cursor = self.line_end();
|
||||
self.draft.textarea.move_line_end();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Char('g') => InputResult::OpenExternalEditor,
|
||||
@@ -133,42 +196,25 @@ impl ChatComposer {
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Enter => self.submit(),
|
||||
KeyCode::Char(ch) => {
|
||||
self.insert(&ch.to_string());
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.remove_previous_grapheme() {
|
||||
KeyCode::Up if !self.draft.textarea.text().contains('\n') => self.history_previous(),
|
||||
KeyCode::Down if !self.draft.textarea.text().contains('\n') => self.history_next(),
|
||||
KeyCode::Char(_)
|
||||
| KeyCode::Backspace
|
||||
| KeyCode::Delete
|
||||
| KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Home
|
||||
| KeyCode::End => {
|
||||
let before = (self.draft.textarea.text().to_string(), self.cursor());
|
||||
self.draft.textarea.input(key);
|
||||
let changed = before.0 != self.draft.textarea.text() || before.1 != self.cursor();
|
||||
if changed {
|
||||
self.reset_history_navigation();
|
||||
InputResult::Changed
|
||||
} else {
|
||||
InputResult::None
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.remove_next_grapheme() {
|
||||
InputResult::Changed
|
||||
} else {
|
||||
InputResult::None
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor = self.previous_grapheme_start();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.cursor = self.next_grapheme_end();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor = self.line_start();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor = self.line_end();
|
||||
InputResult::Changed
|
||||
}
|
||||
KeyCode::Up if !self.text.contains('\n') => self.history_previous(),
|
||||
KeyCode::Down if !self.text.contains('\n') => self.history_next(),
|
||||
_ => InputResult::None,
|
||||
}
|
||||
}
|
||||
@@ -185,27 +231,26 @@ impl ChatComposer {
|
||||
where
|
||||
F: FnOnce(String) -> InputResult,
|
||||
{
|
||||
if self.text.trim().is_empty() {
|
||||
if self.draft.textarea.text().trim().is_empty() {
|
||||
return InputResult::None;
|
||||
}
|
||||
let submitted = std::mem::take(&mut self.text);
|
||||
self.cursor = 0;
|
||||
let submitted = self.draft.textarea.take();
|
||||
self.history.push(submitted.clone());
|
||||
if self.history.len() > MAX_HISTORY_ENTRIES {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history_index = None;
|
||||
self.saved_draft = None;
|
||||
self.draft.saved_draft = None;
|
||||
self.history_search = None;
|
||||
action(submitted)
|
||||
}
|
||||
|
||||
fn start_history_search(&mut self) -> InputResult {
|
||||
self.history_index = None;
|
||||
self.saved_draft = None;
|
||||
self.draft.saved_draft = None;
|
||||
self.history_search = Some(HistorySearchState {
|
||||
query: String::new(),
|
||||
draft: self.text.clone(),
|
||||
draft: self.draft.textarea.text().to_string(),
|
||||
selected_index: None,
|
||||
});
|
||||
InputResult::Changed
|
||||
@@ -302,24 +347,7 @@ impl ChatComposer {
|
||||
return;
|
||||
};
|
||||
let selected_index = search.selected_index;
|
||||
let query = search.query.to_lowercase();
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = selected_index
|
||||
.map(|index| {
|
||||
if older {
|
||||
index.saturating_sub(1)
|
||||
} else {
|
||||
index
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.history.len().saturating_sub(1));
|
||||
let found = (0..=start).rev().find(|index| {
|
||||
self.history
|
||||
.get(*index)
|
||||
.is_some_and(|entry| entry.to_lowercase().contains(&query))
|
||||
});
|
||||
let found = history_search::find_match(&self.history, &search.query, selected_index, older);
|
||||
if let Some(index) = found {
|
||||
if let Some(search) = self.history_search.as_mut() {
|
||||
search.selected_index = Some(index);
|
||||
@@ -339,18 +367,7 @@ impl ChatComposer {
|
||||
let Some(search) = self.history_search.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(current) = search.selected_index else {
|
||||
return;
|
||||
};
|
||||
let query = search.query.to_lowercase();
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
let found = ((current + 1)..self.history.len()).find(|index| {
|
||||
self.history
|
||||
.get(*index)
|
||||
.is_some_and(|entry| entry.to_lowercase().contains(&query))
|
||||
});
|
||||
let found = history_search::find_newer(&self.history, &search.query, search.selected_index);
|
||||
if let Some(index) = found {
|
||||
if let Some(search) = self.history_search.as_mut() {
|
||||
search.selected_index = Some(index);
|
||||
@@ -359,63 +376,12 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_grapheme_start(&self) -> usize {
|
||||
self.text[..self.cursor]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_grapheme_end(&self) -> usize {
|
||||
self.text[self.cursor..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.map(|grapheme| self.cursor + grapheme.len())
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
|
||||
fn remove_previous_grapheme(&mut self) -> bool {
|
||||
if self.cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
let start = self.previous_grapheme_start();
|
||||
self.text.replace_range(start..self.cursor, "");
|
||||
self.cursor = start;
|
||||
self.reset_history_navigation();
|
||||
true
|
||||
}
|
||||
|
||||
fn remove_next_grapheme(&mut self) -> bool {
|
||||
if self.cursor == self.text.len() {
|
||||
return false;
|
||||
}
|
||||
let end = self.next_grapheme_end();
|
||||
self.text.replace_range(self.cursor..end, "");
|
||||
self.reset_history_navigation();
|
||||
true
|
||||
}
|
||||
|
||||
fn line_start(&self) -> usize {
|
||||
self.text[..self.cursor]
|
||||
.rfind('\n')
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn line_end(&self) -> usize {
|
||||
self.text[self.cursor..]
|
||||
.find('\n')
|
||||
.map(|index| self.cursor + index)
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
|
||||
fn history_previous(&mut self) -> InputResult {
|
||||
if self.history.is_empty() {
|
||||
return InputResult::None;
|
||||
}
|
||||
if self.history_index.is_none() {
|
||||
self.saved_draft = Some(self.text.clone());
|
||||
self.draft.saved_draft = Some(self.draft.textarea.text().to_string());
|
||||
}
|
||||
let index = self
|
||||
.history_index
|
||||
@@ -436,20 +402,19 @@ impl ChatComposer {
|
||||
self.replace_text(self.history[next].clone());
|
||||
} else {
|
||||
self.history_index = None;
|
||||
let draft = self.saved_draft.take().unwrap_or_default();
|
||||
let draft = self.draft.saved_draft.take().unwrap_or_default();
|
||||
self.replace_text(draft);
|
||||
}
|
||||
InputResult::Changed
|
||||
}
|
||||
|
||||
fn replace_text(&mut self, text: String) {
|
||||
self.text = text;
|
||||
self.cursor = self.text.len();
|
||||
self.draft.textarea.replace(text);
|
||||
}
|
||||
|
||||
fn reset_history_navigation(&mut self) {
|
||||
self.history_index = None;
|
||||
self.saved_draft = None;
|
||||
self.draft.saved_draft = None;
|
||||
self.history_search = None;
|
||||
}
|
||||
}
|
||||
@@ -514,6 +479,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_word_and_kill_shortcuts_share_textarea_semantics() {
|
||||
let mut composer = ChatComposer::default();
|
||||
composer.insert("alpha beta");
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||
assert_eq!(composer.text(), "alpha ");
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
|
||||
assert_eq!(composer.text(), "alpha beta");
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
assert_eq!(composer.text(), "alpha bet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_release_does_not_insert_or_submit() {
|
||||
let mut composer = ChatComposer::default();
|
||||
let mut release = key(KeyCode::Char('x'));
|
||||
release.kind = KeyEventKind::Release;
|
||||
|
||||
assert_eq!(composer.handle_key_event(release), InputResult::None);
|
||||
assert!(composer.is_empty());
|
||||
|
||||
composer.insert("draft");
|
||||
let mut submit_release = key(KeyCode::Enter);
|
||||
submit_release.kind = KeyEventKind::Release;
|
||||
assert_eq!(composer.handle_key_event(submit_release), InputResult::None);
|
||||
assert_eq!(composer.text(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_queues_non_empty_draft_and_clears_composer() {
|
||||
let mut composer = ChatComposer::default();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Local image attachment bookkeeping for the chat composer.
|
||||
//!
|
||||
//! The attachment state is intentionally owned by `ChatComposer`, matching Codex's ownership
|
||||
//! boundary. Runtime lowering still happens in the App Server session and keeps image parts before
|
||||
//! text parts in the canonical `UserInput` vector.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct AttachmentState {
|
||||
local_images: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AttachmentState {
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.local_images.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.local_images.len()
|
||||
}
|
||||
|
||||
pub(super) fn attach(&mut self, path: PathBuf) {
|
||||
self.local_images.push(path);
|
||||
}
|
||||
|
||||
pub(super) fn take(&mut self) -> Vec<PathBuf> {
|
||||
std::mem::take(&mut self.local_images)
|
||||
}
|
||||
|
||||
pub(super) fn restore(&mut self, images: Vec<PathBuf>) {
|
||||
self.local_images = images;
|
||||
}
|
||||
|
||||
pub(super) fn paths(&self) -> &[PathBuf] {
|
||||
&self.local_images
|
||||
}
|
||||
|
||||
pub(super) fn remove_last(&mut self) -> bool {
|
||||
self.local_images.pop().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::AttachmentState;
|
||||
|
||||
#[test]
|
||||
fn attachments_are_ordered_and_restorable() {
|
||||
let mut state = AttachmentState::default();
|
||||
state.attach(PathBuf::from("one.png"));
|
||||
state.attach(PathBuf::from("two.png"));
|
||||
assert_eq!(
|
||||
state.paths(),
|
||||
&[PathBuf::from("one.png"), PathBuf::from("two.png")]
|
||||
);
|
||||
let taken = state.take();
|
||||
assert!(state.is_empty());
|
||||
state.restore(taken);
|
||||
assert_eq!(state.len(), 2);
|
||||
assert!(state.remove_last());
|
||||
assert_eq!(state.paths(), &[PathBuf::from("one.png")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Editable draft state kept separate from composer control flow.
|
||||
|
||||
use std::cell::{RefCell, RefMut};
|
||||
|
||||
use crate::bottom_pane::textarea::{TextArea, TextAreaState};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct DraftState {
|
||||
pub(super) textarea: TextArea,
|
||||
pub(super) textarea_state: RefCell<TextAreaState>,
|
||||
pub(super) saved_draft: Option<String>,
|
||||
}
|
||||
|
||||
impl DraftState {
|
||||
pub(super) fn textarea_state_mut(&self) -> RefMut<'_, TextAreaState> {
|
||||
self.textarea_state.borrow_mut()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! History-search state and matching rules for `ChatComposer`.
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct HistorySearchState {
|
||||
pub(super) query: String,
|
||||
pub(super) draft: String,
|
||||
pub(super) selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn find_older(
|
||||
history: &[String],
|
||||
query: &str,
|
||||
selected_index: Option<usize>,
|
||||
) -> Option<usize> {
|
||||
let query = query.to_lowercase();
|
||||
if query.is_empty() || history.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let start = selected_index
|
||||
.map(|index| index.saturating_sub(1))
|
||||
.unwrap_or(history.len() - 1);
|
||||
(0..=start).rev().find(|index| {
|
||||
history
|
||||
.get(*index)
|
||||
.is_some_and(|entry| entry.to_lowercase().contains(&query))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_match(
|
||||
history: &[String],
|
||||
query: &str,
|
||||
selected_index: Option<usize>,
|
||||
older: bool,
|
||||
) -> Option<usize> {
|
||||
if older {
|
||||
return find_older(history, query, selected_index);
|
||||
}
|
||||
let query = query.to_lowercase();
|
||||
if query.is_empty() || history.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let start = selected_index
|
||||
.map(|index| {
|
||||
if older {
|
||||
index.saturating_sub(1)
|
||||
} else {
|
||||
index
|
||||
}
|
||||
})
|
||||
.unwrap_or(history.len() - 1);
|
||||
(0..=start).rev().find(|index| {
|
||||
history
|
||||
.get(*index)
|
||||
.is_some_and(|entry| entry.to_lowercase().contains(&query))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_newer(
|
||||
history: &[String],
|
||||
query: &str,
|
||||
selected_index: Option<usize>,
|
||||
) -> Option<usize> {
|
||||
let selected_index = selected_index?;
|
||||
let query = query.to_lowercase();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
((selected_index + 1)..history.len()).find(|index| {
|
||||
history
|
||||
.get(*index)
|
||||
.is_some_and(|entry| entry.to_lowercase().contains(&query))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_match, find_newer, find_older};
|
||||
|
||||
fn history() -> Vec<String> {
|
||||
vec![
|
||||
"git status".to_string(),
|
||||
"cargo test".to_string(),
|
||||
"git diff".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_and_newer_matches_are_case_insensitive_and_bounded() {
|
||||
let history = history();
|
||||
assert_eq!(find_older(&history, "GIT", None), Some(2));
|
||||
assert_eq!(find_older(&history, "GIT", Some(2)), Some(0));
|
||||
assert_eq!(find_match(&history, "git", Some(2), false), Some(2));
|
||||
assert_eq!(find_newer(&history, "git", Some(0)), Some(2));
|
||||
assert_eq!(find_newer(&history, "git", Some(2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_queries_and_missing_selection_do_not_match() {
|
||||
let history = history();
|
||||
assert_eq!(find_older(&history, "", None), None);
|
||||
assert_eq!(find_newer(&history, "git", None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Offline composer editing used while the App Server connection is recovering.
|
||||
//!
|
||||
//! Enter and Tab deliberately leave the draft untouched. The runtime owns reconnect attempts;
|
||||
//! this module only provides the Codex-shaped editing boundary for a composer that is offline.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
|
||||
|
||||
use super::ChatComposer;
|
||||
|
||||
impl ChatComposer {
|
||||
/// Apply a basic editor key while disconnected without submitting or queueing the draft.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
|
||||
if let Some(search) = self.history_search.take() {
|
||||
self.replace_text(search.draft);
|
||||
}
|
||||
self.history_index = None;
|
||||
self.draft.saved_draft = None;
|
||||
|
||||
if matches!(key.code, KeyCode::Enter | KeyCode::Tab)
|
||||
|| !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.draft.textarea.input(key);
|
||||
self.reset_history_navigation();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "reconnect_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::super::ChatComposer;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_edit_preserves_draft_and_cancels_history_preview() {
|
||||
let mut composer = ChatComposer::default();
|
||||
composer.load_history(["history preview".to_string()]);
|
||||
composer.insert("original draft");
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
composer.handle_key_event(key(KeyCode::Char('h')));
|
||||
assert_eq!(composer.text(), "history preview");
|
||||
|
||||
composer.handle_disconnected_key(key(KeyCode::Char('!')));
|
||||
|
||||
assert_eq!(composer.text(), "original draft!");
|
||||
assert!(!composer.history_search_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_enter_and_tab_leave_input_available_for_reconnect() {
|
||||
let mut composer = ChatComposer::default();
|
||||
composer.insert("unsent");
|
||||
|
||||
composer.handle_disconnected_key(key(KeyCode::Enter));
|
||||
assert_eq!(composer.text(), "unsent");
|
||||
composer.handle_disconnected_key(key(KeyCode::Tab));
|
||||
assert_eq!(composer.text(), "unsent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_editing_preserves_graphemes_and_cursor_boundaries() {
|
||||
let mut composer = ChatComposer::default();
|
||||
composer.insert("你👍好");
|
||||
|
||||
composer.handle_disconnected_key(key(KeyCode::Backspace));
|
||||
assert_eq!(composer.text(), "你👍");
|
||||
composer.handle_disconnected_key(key(KeyCode::Backspace));
|
||||
assert_eq!(composer.text(), "你");
|
||||
|
||||
composer.handle_disconnected_key(key(KeyCode::Char('a')));
|
||||
composer.handle_disconnected_key(key(KeyCode::Left));
|
||||
composer.handle_disconnected_key(key(KeyCode::Char('界')));
|
||||
assert_eq!(composer.text(), "你界a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_repeat_key_is_editable_but_never_submits() {
|
||||
let mut composer = ChatComposer::default();
|
||||
composer.insert("draft");
|
||||
let mut repeated = key(KeyCode::Char('!'));
|
||||
repeated.kind = crossterm::event::KeyEventKind::Repeat;
|
||||
|
||||
composer.handle_disconnected_key(repeated);
|
||||
composer.handle_disconnected_key(key(KeyCode::Enter));
|
||||
|
||||
assert_eq!(composer.text(), "draft!");
|
||||
}
|
||||
@@ -2,16 +2,17 @@ mod approval_overlay;
|
||||
mod chat_composer;
|
||||
mod render;
|
||||
mod request_user_input;
|
||||
mod textarea;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use app_server_protocol::RequestId;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecutionApprovalDecision, CommandExecutionRequestApprovalResponse,
|
||||
FileChangeApprovalDecision, FileChangeRequestApprovalResponse, GrantedPermissionProfile,
|
||||
PermissionGrantScope, PermissionsRequestApprovalResponse, ServerRequest,
|
||||
ToolRequestUserInputResponse,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
use crossterm::event::{Event, KeyEvent};
|
||||
|
||||
use approval_overlay::ApprovalOverlay;
|
||||
@@ -154,11 +155,13 @@ mod tests {
|
||||
use super::*;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams,
|
||||
FileChangeApprovalDecision, FileChangeRequestApprovalParams,
|
||||
PermissionsRequestApprovalParams, RequestPermissionProfile, ToolRequestUserInputOption,
|
||||
ToolRequestUserInputParams, ToolRequestUserInputQuestion,
|
||||
DynamicToolCallParams, DynamicToolCallPhase, FileChangeApprovalDecision,
|
||||
FileChangeRequestApprovalParams, PermissionsRequestApprovalParams,
|
||||
RequestPermissionProfile, ToolRequestUserInputOption, ToolRequestUserInputParams,
|
||||
ToolRequestUserInputQuestion,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde_json::json;
|
||||
|
||||
fn key(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||
@@ -226,6 +229,47 @@ mod tests {
|
||||
assert!(!pane.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_requests_return_for_rejection() {
|
||||
let mut pane = BottomPane::default();
|
||||
let dynamic_tool = ServerRequest::DynamicToolCall {
|
||||
id: RequestId::Integer(3),
|
||||
params: DynamicToolCallParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
namespace: None,
|
||||
tool: "browser.open".to_string(),
|
||||
arguments: json!({}),
|
||||
phase: DynamicToolCallPhase::Preflight,
|
||||
approval_token: None,
|
||||
},
|
||||
};
|
||||
assert!(matches!(
|
||||
pane.enqueue(dynamic_tool),
|
||||
Err(request) if matches!(*request, ServerRequest::DynamicToolCall { .. })
|
||||
));
|
||||
|
||||
let mcp_elicitation = serde_json::from_value::<ServerRequest>(json!({
|
||||
"method": "mcpServer/elicitation/request",
|
||||
"id": 4,
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"turnId": "turn-1",
|
||||
"serverName": "form-server",
|
||||
"mode": "form",
|
||||
"message": "Choose a value",
|
||||
"requestedSchema": { "type": "object", "properties": {} }
|
||||
}
|
||||
}))
|
||||
.expect("MCP elicitation request");
|
||||
assert!(matches!(
|
||||
pane.enqueue(mcp_elicitation),
|
||||
Err(request) if matches!(*request, ServerRequest::McpServerElicitationRequest { .. })
|
||||
));
|
||||
assert!(!pane.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_responses_fail_closed_for_every_supported_interaction() {
|
||||
let command =
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::approval_overlay::ApprovalRequest;
|
||||
use super::request_user_input::render as request_user_input_render;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use app_server_protocol::RequestId;
|
||||
use app_server_protocol::protocol::v2::{
|
||||
ToolRequestUserInputAnswer, ToolRequestUserInputParams, ToolRequestUserInputResponse,
|
||||
};
|
||||
use app_server_protocol::RequestId;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
use super::{AppServerResponse, ChatComposer, InputResult};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::Frame;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::line_truncation::line_width;
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
//! Editable UTF-8 text buffer shared by the TUI composer and focused input overlays.
|
||||
//!
|
||||
//! `TextArea` is deliberately independent from submission policy. The parent composer owns
|
||||
//! history, queueing, and attachments while this module owns cursor-safe editing primitives.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{StatefulWidgetRef, WidgetRef};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
mod wrapping;
|
||||
|
||||
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
|
||||
|
||||
fn is_word_separator(ch: char) -> bool {
|
||||
WORD_SEPARATORS.contains(ch)
|
||||
}
|
||||
|
||||
fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
|
||||
let mut pieces = Vec::new();
|
||||
for (segment_start, segment) in run.split_word_bound_indices() {
|
||||
let mut piece_start = 0;
|
||||
let mut chars = segment.char_indices();
|
||||
let Some((_, first_char)) = chars.next() else {
|
||||
continue;
|
||||
};
|
||||
let mut in_separator = is_word_separator(first_char);
|
||||
for (idx, ch) in chars {
|
||||
let is_separator = is_word_separator(ch);
|
||||
if is_separator == in_separator {
|
||||
continue;
|
||||
}
|
||||
pieces.push((segment_start + piece_start, &segment[piece_start..idx]));
|
||||
piece_start = idx;
|
||||
in_separator = is_separator;
|
||||
}
|
||||
pieces.push((segment_start + piece_start, &segment[piece_start..]));
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
fn text_for_display(text: &str) -> Cow<'_, str> {
|
||||
if text.contains('\t') {
|
||||
Cow::Owned(text.replace('\t', " "))
|
||||
} else {
|
||||
Cow::Borrowed(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TextArea {
|
||||
text: String,
|
||||
cursor: usize,
|
||||
kill_buffer: String,
|
||||
}
|
||||
|
||||
/// Viewport state kept outside the editable buffer, matching Codex's stateful textarea widget.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct TextAreaState {
|
||||
/// Index into wrapped lines of the first visible line.
|
||||
scroll: u16,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TextArea {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub(crate) fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn replace(&mut self, text: String) {
|
||||
self.text = text;
|
||||
self.cursor = self.text.len();
|
||||
}
|
||||
|
||||
pub(crate) fn set_text_clearing_elements(&mut self, text: &str) {
|
||||
self.text = text.to_string();
|
||||
self.cursor = self.nearest_char_boundary(self.cursor.min(self.text.len()));
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> String {
|
||||
self.cursor = 0;
|
||||
std::mem::take(&mut self.text)
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, value: &str) {
|
||||
self.text.insert_str(self.cursor, value);
|
||||
self.cursor += value.len();
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, value: &str) {
|
||||
self.insert(value);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str_at(&mut self, pos: usize, value: &str) {
|
||||
let pos = self.nearest_char_boundary(pos.min(self.text.len()));
|
||||
self.text.insert_str(pos, value);
|
||||
if pos <= self.cursor {
|
||||
self.cursor += value.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_range(&mut self, range: Range<usize>, value: &str) {
|
||||
let start = self.nearest_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.nearest_char_boundary(range.end.min(self.text.len()));
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
self.text.replace_range(start..end, value);
|
||||
self.cursor = if self.cursor < start {
|
||||
self.cursor
|
||||
} else if self.cursor <= end {
|
||||
start + value.len()
|
||||
} else {
|
||||
self.cursor.saturating_sub(end - start) + value.len()
|
||||
}
|
||||
.min(self.text.len());
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor(&mut self, pos: usize) {
|
||||
self.cursor = self.nearest_char_boundary(pos.min(self.text.len()));
|
||||
}
|
||||
|
||||
pub(crate) fn move_left(&mut self) {
|
||||
self.cursor = self.previous_grapheme_start();
|
||||
}
|
||||
|
||||
pub(crate) fn move_right(&mut self) {
|
||||
self.cursor = self.next_grapheme_end();
|
||||
}
|
||||
|
||||
pub(crate) fn move_line_start(&mut self) {
|
||||
self.cursor = self.line_start();
|
||||
}
|
||||
|
||||
pub(crate) fn move_line_end(&mut self) {
|
||||
self.cursor = self.line_end();
|
||||
}
|
||||
|
||||
pub(crate) fn remove_previous_grapheme(&mut self) -> bool {
|
||||
if self.cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
let start = self.previous_grapheme_start();
|
||||
self.text.replace_range(start..self.cursor, "");
|
||||
self.cursor = start;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn remove_next_grapheme(&mut self) -> bool {
|
||||
if self.cursor == self.text.len() {
|
||||
return false;
|
||||
}
|
||||
let end = self.next_grapheme_end();
|
||||
self.text.replace_range(self.cursor..end, "");
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply the default Codex editor key semantics without submission policy.
|
||||
///
|
||||
/// `ChatComposer` owns submit/queue/shortcut dispatch. This method is the shared low-level
|
||||
/// editor boundary used by focused overlays and the disconnected composer path.
|
||||
pub(crate) fn input(&mut self, event: KeyEvent) {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
let control = event.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = event.modifiers.contains(KeyModifiers::ALT);
|
||||
match event.code {
|
||||
KeyCode::Char('b') if control => self.move_left(),
|
||||
KeyCode::Char('f') if control => self.move_right(),
|
||||
KeyCode::Char('a') if control => self.move_line_start(),
|
||||
KeyCode::Char('e') if control => self.move_line_end(),
|
||||
KeyCode::Char('w') if control || alt => self.delete_backward_word(),
|
||||
KeyCode::Char('d') if alt => self.delete_forward_word(),
|
||||
KeyCode::Char('u') if control => self.kill_to_beginning_of_line(),
|
||||
KeyCode::Char('k') if control => self.kill_to_end_of_line(),
|
||||
KeyCode::Char('y') if control => self.yank(),
|
||||
KeyCode::Backspace if control || alt => self.delete_backward_word(),
|
||||
KeyCode::Delete if control || alt => self.delete_forward_word(),
|
||||
KeyCode::Backspace => {
|
||||
self.remove_previous_grapheme();
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
self.remove_next_grapheme();
|
||||
}
|
||||
KeyCode::Left => self.move_left(),
|
||||
KeyCode::Right => self.move_right(),
|
||||
KeyCode::Home => self.move_line_start(),
|
||||
KeyCode::End => self.move_line_end(),
|
||||
KeyCode::Enter if !control && !alt => self.insert_str("\n"),
|
||||
KeyCode::Char(ch)
|
||||
if !control
|
||||
&& !alt
|
||||
&& !ch.is_ascii_control()
|
||||
&& (event.modifiers.is_empty() || event.modifiers == KeyModifiers::SHIFT) =>
|
||||
{
|
||||
self.insert_str(&ch.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_backward(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
if !self.remove_previous_grapheme() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_forward(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
if !self.remove_next_grapheme() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_forward_kill(&mut self, n: usize) {
|
||||
if n == 0 || self.cursor >= self.text.len() {
|
||||
return;
|
||||
}
|
||||
let mut target = self.cursor;
|
||||
for _ in 0..n {
|
||||
target = self.next_grapheme_end_at(target);
|
||||
if target >= self.text.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.kill_range(self.cursor..target);
|
||||
}
|
||||
|
||||
pub(crate) fn delete_backward_word(&mut self) {
|
||||
let start = self.beginning_of_previous_word();
|
||||
self.kill_range(start..self.cursor);
|
||||
}
|
||||
|
||||
/// Delete text to the right of the cursor through the next word boundary.
|
||||
pub(crate) fn delete_forward_word(&mut self) {
|
||||
let end = self.end_of_next_word();
|
||||
if end > self.cursor {
|
||||
self.kill_range(self.cursor..end);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill from the cursor to the end of the current logical line.
|
||||
pub(crate) fn kill_to_end_of_line(&mut self) {
|
||||
let eol = self.line_end();
|
||||
let range = if self.cursor == eol {
|
||||
(eol < self.text.len()).then_some(self.cursor..eol + 1)
|
||||
} else {
|
||||
Some(self.cursor..eol)
|
||||
};
|
||||
if let Some(range) = range {
|
||||
self.kill_range(range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill from the beginning of the current logical line through the cursor.
|
||||
pub(crate) fn kill_to_beginning_of_line(&mut self) {
|
||||
let bol = self.line_start();
|
||||
let range = if self.cursor == bol {
|
||||
(bol > 0).then_some(bol - 1..bol)
|
||||
} else {
|
||||
Some(bol..self.cursor)
|
||||
};
|
||||
if let Some(range) = range {
|
||||
self.kill_range(range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the most recently killed text at the cursor.
|
||||
pub(crate) fn yank(&mut self) {
|
||||
if !self.kill_buffer.is_empty() {
|
||||
let text = self.kill_buffer.clone();
|
||||
self.insert_str(&text);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn beginning_of_previous_word(&self) -> usize {
|
||||
let prefix = &self.text[..self.cursor];
|
||||
let Some((first_non_ws_idx, ch)) = prefix
|
||||
.char_indices()
|
||||
.rev()
|
||||
.find(|&(_, ch)| !ch.is_whitespace())
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
let run_start = prefix[..first_non_ws_idx]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.find(|&(_, ch)| ch.is_whitespace())
|
||||
.map_or(0, |(idx, ch)| idx + ch.len_utf8());
|
||||
let run_end = first_non_ws_idx + ch.len_utf8();
|
||||
let pieces = split_word_pieces(&prefix[run_start..run_end]);
|
||||
let mut pieces = pieces.into_iter().rev().peekable();
|
||||
let Some((piece_start, piece)) = pieces.next() else {
|
||||
return run_start;
|
||||
};
|
||||
let mut start = run_start + piece_start;
|
||||
if piece.chars().all(is_word_separator) {
|
||||
while let Some((idx, piece)) = pieces.peek() {
|
||||
if !piece.chars().all(is_word_separator) {
|
||||
break;
|
||||
}
|
||||
start = run_start + *idx;
|
||||
pieces.next();
|
||||
}
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
pub(crate) fn end_of_next_word(&self) -> usize {
|
||||
let suffix = &self.text[self.cursor..];
|
||||
let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
|
||||
return self.text.len();
|
||||
};
|
||||
let run = &suffix[first_non_ws..];
|
||||
let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
|
||||
let mut pieces = split_word_pieces(run).into_iter().peekable();
|
||||
let Some((start, piece)) = pieces.next() else {
|
||||
return self.cursor + first_non_ws;
|
||||
};
|
||||
let word_start = self.cursor + first_non_ws + start;
|
||||
let mut end = word_start + piece.len();
|
||||
if piece.chars().all(is_word_separator) {
|
||||
while let Some((idx, piece)) = pieces.peek() {
|
||||
if !piece.chars().all(is_word_separator) {
|
||||
break;
|
||||
}
|
||||
end = self.cursor + first_non_ws + *idx + piece.len();
|
||||
pieces.next();
|
||||
}
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
fn kill_range(&mut self, range: Range<usize>) {
|
||||
let start = self.nearest_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.nearest_char_boundary(range.end.min(self.text.len()));
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
self.kill_buffer = self.text[start..end].to_string();
|
||||
self.replace_range(start..end, "");
|
||||
}
|
||||
|
||||
pub(crate) fn wrapped_lines(&self, width: u16) -> Vec<std::ops::Range<usize>> {
|
||||
wrapping::wrapped_lines(&self.text, width)
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_position(&self, width: u16) -> Option<(usize, usize)> {
|
||||
let lines = self.wrapped_lines(width);
|
||||
wrapping::cursor_position(&self.text, &lines, width, self.cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.cursor_pos_with_state(area, TextAreaState::default())
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_pos_with_state(
|
||||
&self,
|
||||
area: Rect,
|
||||
state: TextAreaState,
|
||||
) -> Option<(u16, u16)> {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
let (row, column) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor)?;
|
||||
Some((
|
||||
area.x.saturating_add(column as u16),
|
||||
area.y
|
||||
.saturating_add(row.saturating_sub(scroll as usize) as u16)
|
||||
.min(area.bottom().saturating_sub(1)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
self.wrapped_lines(width).len().max(1) as u16
|
||||
}
|
||||
|
||||
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], current: u16) -> u16 {
|
||||
if area.height == 0 || lines.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let total = lines.len() as u16;
|
||||
if total <= area.height {
|
||||
return 0;
|
||||
}
|
||||
let cursor_row = wrapping::cursor_position(&self.text, lines, area.width, self.cursor)
|
||||
.map(|(row, _)| row as u16)
|
||||
.unwrap_or_default();
|
||||
let max_scroll = total.saturating_sub(area.height);
|
||||
let mut scroll = current.min(max_scroll);
|
||||
if cursor_row < scroll {
|
||||
scroll = cursor_row;
|
||||
} else if cursor_row >= scroll.saturating_add(area.height) {
|
||||
scroll = cursor_row
|
||||
.saturating_add(1)
|
||||
.saturating_sub(area.height)
|
||||
.min(max_scroll);
|
||||
}
|
||||
scroll
|
||||
}
|
||||
|
||||
fn nearest_char_boundary(&self, mut pos: usize) -> usize {
|
||||
while pos > 0 && !self.text.is_char_boundary(pos) {
|
||||
pos -= 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
fn render_lines(&self, area: Rect, buf: &mut Buffer, lines: &[Range<usize>], scroll: u16) {
|
||||
let blank = " ".repeat(usize::from(area.width));
|
||||
for row in 0..area.height {
|
||||
buf.set_string(area.x, area.y + row, &blank, Style::default());
|
||||
}
|
||||
let start = usize::from(scroll);
|
||||
let end = (start + usize::from(area.height)).min(lines.len());
|
||||
for (row, range) in lines[start..end].iter().enumerate() {
|
||||
let content_end = range.end.saturating_sub(1).min(self.text.len());
|
||||
let content_start = range.start.min(content_end);
|
||||
let visible =
|
||||
wrapping::visible_prefix(&self.text[content_start..content_end], area.width);
|
||||
buf.set_string(
|
||||
area.x,
|
||||
area.y + row as u16,
|
||||
text_for_display(visible),
|
||||
Style::default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_grapheme_start(&self) -> usize {
|
||||
self.text[..self.cursor]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_grapheme_end(&self) -> usize {
|
||||
self.next_grapheme_end_at(self.cursor)
|
||||
}
|
||||
|
||||
fn next_grapheme_end_at(&self, cursor: usize) -> usize {
|
||||
self.text[cursor..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.map(|grapheme| cursor + grapheme.len())
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
|
||||
fn line_start(&self) -> usize {
|
||||
self.text[..self.cursor]
|
||||
.rfind('\n')
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn line_end(&self) -> usize {
|
||||
self.text[self.cursor..]
|
||||
.find('\n')
|
||||
.map(|index| self.cursor + index)
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &TextArea {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
self.render_lines(area, buf, &lines, 0);
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidgetRef for &TextArea {
|
||||
type State = TextAreaState;
|
||||
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
state.scroll = self.effective_scroll(area, &lines, state.scroll);
|
||||
self.render_lines(area, buf, &lines, state.scroll);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TextArea, TextAreaState};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
|
||||
#[test]
|
||||
fn unicode_cursor_and_grapheme_deletion_match_codex_textarea_semantics() {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.insert("a👩🏽💻界");
|
||||
textarea.move_left();
|
||||
assert!(textarea.remove_previous_grapheme());
|
||||
assert_eq!(textarea.text(), "a界");
|
||||
assert_eq!(textarea.cursor(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_navigation_stays_inside_the_current_logical_line() {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.insert("first\nsecond");
|
||||
textarea.move_line_start();
|
||||
assert_eq!(textarea.cursor(), 6);
|
||||
textarea.move_line_end();
|
||||
assert_eq!(textarea.cursor(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cursor_pos_api_keeps_cursor_visible_in_a_scrolled_viewport() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str("abcdefghij");
|
||||
textarea.set_cursor(textarea.text().len());
|
||||
|
||||
let area = Rect::new(2, 3, 4, 2);
|
||||
let state = TextAreaState::default();
|
||||
let (x, y) = textarea
|
||||
.cursor_pos_with_state(area, state)
|
||||
.expect("cursor position");
|
||||
|
||||
assert_eq!((x, y), (area.x + 2, area.y + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_buffer_replacement_preserves_cursor_only_at_valid_boundaries() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str("ab");
|
||||
textarea.set_cursor(1);
|
||||
textarea.set_text_clearing_elements("cd");
|
||||
|
||||
assert_eq!(textarea.text(), "cd");
|
||||
assert_eq!(textarea.cursor(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_render_clears_rows_outside_the_current_draft() {
|
||||
let area = Rect::new(0, 0, 6, 2);
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str("longer draft");
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
let mut state = TextAreaState::default();
|
||||
StatefulWidgetRef::render_ref(&&textarea, area, &mut buffer, &mut state);
|
||||
|
||||
textarea.set_text_clearing_elements("ok");
|
||||
StatefulWidgetRef::render_ref(&&textarea, area, &mut buffer, &mut state);
|
||||
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "o");
|
||||
assert_eq!(buffer[(2, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(0, 1)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_word_delete_and_line_kill_preserve_unicode_boundaries() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str("alpha 你👍 beta");
|
||||
textarea.set_cursor(textarea.text().len());
|
||||
textarea.delete_backward_word();
|
||||
assert_eq!(textarea.text(), "alpha 你👍 ");
|
||||
textarea.yank();
|
||||
assert_eq!(textarea.text(), "alpha 你👍 beta");
|
||||
|
||||
textarea.set_cursor(9);
|
||||
textarea.kill_to_beginning_of_line();
|
||||
assert_eq!(textarea.text(), "👍 beta");
|
||||
textarea.yank();
|
||||
assert_eq!(textarea.text(), "alpha 你👍 beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_input_dispatches_emacs_style_editor_bindings() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
assert_eq!(textarea.text(), "ab");
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
|
||||
assert_eq!(textarea.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_word_boundaries_handle_cjk_and_separator_runs() {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str("你好::world");
|
||||
textarea.set_cursor(textarea.text().len());
|
||||
assert_eq!(textarea.beginning_of_previous_word(), 8);
|
||||
textarea.delete_backward_word();
|
||||
assert_eq!(textarea.text(), "你好::");
|
||||
textarea.set_cursor(0);
|
||||
assert_eq!(textarea.end_of_next_word(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Grapheme-safe composer wrapping that preserves textwrap's semantic breakpoints.
|
||||
//!
|
||||
//! Source ranges include a sentinel byte for cursor placement. Separators at soft breaks hang off
|
||||
//! the preceding row without changing the source text. Full logical lines reserve an insertion row.
|
||||
|
||||
use super::text_for_display;
|
||||
use crate::width::display_width;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
struct WrappedWord {
|
||||
range: Range<usize>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
fn is_breakable_space(ch: char) -> bool {
|
||||
ch.is_whitespace() && !matches!(ch, '\u{a0}' | '\u{2007}' | '\u{202f}')
|
||||
}
|
||||
|
||||
fn is_hangable_space(ch: char) -> bool {
|
||||
is_breakable_space(ch)
|
||||
&& !matches!(
|
||||
ch,
|
||||
'\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}'
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn visible_prefix(text: &str, width: u16) -> &str {
|
||||
if width == 0 {
|
||||
return "";
|
||||
}
|
||||
if text.len() <= usize::from(width) {
|
||||
return text;
|
||||
}
|
||||
text.grapheme_indices(true)
|
||||
.filter(|(_, grapheme)| grapheme.chars().any(is_hangable_space))
|
||||
.nth(usize::from(width) - 1)
|
||||
.map_or(text, |(start, grapheme)| &text[..start + grapheme.len()])
|
||||
}
|
||||
|
||||
pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec<Range<usize>> {
|
||||
let width = usize::from(width);
|
||||
let options = Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
|
||||
let wrapped_lines = crate::wrapping::wrap_ranges(text, &options);
|
||||
if width == 0 || (wrapped_lines.len() == 1 && text.len() < width) {
|
||||
return wrapped_lines;
|
||||
}
|
||||
|
||||
let mut breakpoints = vec![false; text.len() + 1];
|
||||
let mut words = Vec::new();
|
||||
let mut logical_start = 0;
|
||||
for logical_line in text.split('\n') {
|
||||
let mut word_start = logical_start;
|
||||
for word in options.word_separator.find_words(logical_line) {
|
||||
let word_end = word_start + word.word.len();
|
||||
for breakpoint in options.word_splitter.split_points(word.word) {
|
||||
breakpoints[word_start + breakpoint] = true;
|
||||
}
|
||||
breakpoints[word_end] = true;
|
||||
words.push(WrappedWord {
|
||||
range: word_start..word_end,
|
||||
width: display_width(word.word),
|
||||
});
|
||||
word_start = word_end + word.whitespace.len();
|
||||
}
|
||||
logical_start += logical_line.len() + 1;
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(wrapped_lines.len());
|
||||
let mut line_start = 0;
|
||||
let mut line_width = 0;
|
||||
let mut line_has_text = false;
|
||||
let mut line_active = false;
|
||||
let mut logical_content_end = 0;
|
||||
let mut processed_end = 0;
|
||||
let mut previous_was_whitespace = false;
|
||||
let mut word_index = 0;
|
||||
let mut consumed_word_width = 0;
|
||||
|
||||
for wrapped_line in wrapped_lines {
|
||||
let line_end = wrapped_line.end.saturating_sub(1);
|
||||
let fragment_start = wrapped_line.start.max(processed_end);
|
||||
if fragment_start > line_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
while word_index < words.len() && words[word_index].range.end <= fragment_start {
|
||||
word_index += 1;
|
||||
consumed_word_width = 0;
|
||||
}
|
||||
|
||||
if !line_active {
|
||||
line_start = fragment_start;
|
||||
line_active = true;
|
||||
let logical_line = text[fragment_start..]
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
logical_content_end =
|
||||
fragment_start + logical_line.trim_end_matches(is_hangable_space).len();
|
||||
} else if line_has_text && breakpoints[fragment_start] {
|
||||
let remaining_word_width = words
|
||||
.get(word_index)
|
||||
.filter(|word| word.range.contains(&fragment_start))
|
||||
.map_or(0, |word| word.width.saturating_sub(consumed_word_width));
|
||||
if line_width + remaining_word_width > width {
|
||||
lines.push(line_start..fragment_start + 1);
|
||||
line_start = fragment_start;
|
||||
line_width = 0;
|
||||
line_has_text = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (offset, grapheme) in text[fragment_start..line_end].grapheme_indices(true) {
|
||||
let grapheme_start = fragment_start + offset;
|
||||
while word_index < words.len() && words[word_index].range.end <= grapheme_start {
|
||||
word_index += 1;
|
||||
consumed_word_width = 0;
|
||||
}
|
||||
|
||||
let grapheme_end = grapheme_start + grapheme.len();
|
||||
let is_whitespace = grapheme.chars().all(is_breakable_space);
|
||||
if !is_whitespace && line_has_text && previous_was_whitespace {
|
||||
let word_end = words
|
||||
.get(word_index)
|
||||
.filter(|word| word.range.contains(&grapheme_start))
|
||||
.map_or(line_end, |word| word.range.end.min(line_end));
|
||||
if line_width + display_width(&text[grapheme_start..word_end]) > width {
|
||||
lines.push(line_start..grapheme_start + 1);
|
||||
line_start = grapheme_start;
|
||||
line_width = 0;
|
||||
line_has_text = false;
|
||||
}
|
||||
}
|
||||
|
||||
let grapheme_width = display_width(grapheme);
|
||||
let hanging_space = line_has_text
|
||||
&& grapheme_end < logical_content_end
|
||||
&& grapheme.chars().all(is_hangable_space);
|
||||
if !hanging_space && line_width > 0 && line_width + grapheme_width > width {
|
||||
lines.push(line_start..grapheme_start + 1);
|
||||
line_start = grapheme_start;
|
||||
line_width = 0;
|
||||
line_has_text = false;
|
||||
}
|
||||
line_width += grapheme_width;
|
||||
line_has_text |= !is_whitespace;
|
||||
previous_was_whitespace = is_whitespace;
|
||||
|
||||
if words
|
||||
.get(word_index)
|
||||
.is_some_and(|word| word.range.contains(&grapheme_start))
|
||||
{
|
||||
consumed_word_width += grapheme_width;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(text.as_bytes().get(line_end), None | Some(b'\n')) {
|
||||
lines.push(line_start..line_end + 1);
|
||||
if line_width >= width {
|
||||
lines.push(line_end..line_end + 1);
|
||||
}
|
||||
line_width = 0;
|
||||
line_has_text = false;
|
||||
line_active = false;
|
||||
previous_was_whitespace = false;
|
||||
}
|
||||
processed_end = line_end;
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub(super) fn cursor_position(
|
||||
text: &str,
|
||||
lines: &[Range<usize>],
|
||||
width: u16,
|
||||
pos: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
let row = lines
|
||||
.partition_point(|line| line.start <= pos)
|
||||
.checked_sub(1)?;
|
||||
let before_cursor = visible_prefix(&text[lines[row].start..pos], width);
|
||||
let before_cursor = text_for_display(before_cursor);
|
||||
let col = display_width(before_cursor.as_ref());
|
||||
let width = usize::from(width);
|
||||
if col >= width
|
||||
&& lines
|
||||
.get(row + 1)
|
||||
.is_some_and(|next| next.start == lines[row].end - 1)
|
||||
{
|
||||
Some((row + 1, 0))
|
||||
} else {
|
||||
Some((row, col.min(width.saturating_sub(1))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wrapping_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,30 @@
|
||||
use super::{cursor_position, visible_prefix, wrapped_lines};
|
||||
|
||||
#[test]
|
||||
fn wrapped_lines_keep_utf8_boundaries_and_reserve_cursor_rows() {
|
||||
let text = "你好 世界";
|
||||
let lines = wrapped_lines(text, 5);
|
||||
assert!(!lines.is_empty());
|
||||
assert!(lines.iter().all(|range| text.is_char_boundary(range.start)));
|
||||
assert!(lines
|
||||
.iter()
|
||||
.all(|range| text.is_char_boundary(range.end.saturating_sub(1).min(text.len()))));
|
||||
assert_eq!(
|
||||
cursor_position(text, &lines, 5, text.len()),
|
||||
Some((lines.len() - 1, 4))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_break_spaces_hang_on_the_previous_row() {
|
||||
assert_eq!(visible_prefix("a b c d e f", 5), "a b c d e ");
|
||||
assert_eq!(visible_prefix("hello ", 5), "hello ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_breaking_spaces_stay_inside_the_word() {
|
||||
let text = "one\u{a0}two";
|
||||
let lines = wrapped_lines(text, 5);
|
||||
assert!(lines.len() >= 2);
|
||||
assert!(lines.iter().all(|range| text.is_char_boundary(range.start)));
|
||||
}
|
||||
@@ -463,13 +463,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tmux_requires_clipboard_forwarding_capability() {
|
||||
assert!(
|
||||
tmux_clipboard_copy_ready(
|
||||
|| Ok("external\n".to_string()),
|
||||
|| Ok("193: Ms: (string) \\033]52\n".to_string()),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(tmux_clipboard_copy_ready(
|
||||
|| Ok("external\n".to_string()),
|
||||
|| Ok("193: Ms: (string) \\033]52\n".to_string()),
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(
|
||||
tmux_clipboard_copy_ready(
|
||||
|| Ok("off\n".to_string()),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Codex-shaped collaboration mode discovery and selection helpers.
|
||||
|
||||
use agent_protocol::{CollaborationMode, CollaborationModeSettings, ModeKind};
|
||||
use app_server_protocol::protocol::v2::CollaborationModeMask;
|
||||
|
||||
use crate::model_catalog::ModelCatalog;
|
||||
|
||||
fn filtered_presets(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
|
||||
model_catalog
|
||||
.collaboration_modes
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|mask| mask.mode.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn default_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
|
||||
let presets = filtered_presets(model_catalog);
|
||||
presets
|
||||
.iter()
|
||||
.find(|mask| mask.mode == Some(ModeKind::Default))
|
||||
.cloned()
|
||||
.or_else(|| presets.into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn mask_for_kind(
|
||||
model_catalog: &ModelCatalog,
|
||||
kind: ModeKind,
|
||||
) -> Option<CollaborationModeMask> {
|
||||
filtered_presets(model_catalog)
|
||||
.into_iter()
|
||||
.find(|mask| mask.mode == Some(kind))
|
||||
}
|
||||
|
||||
/// Cycle to the next server-advertised collaboration mode in list order.
|
||||
pub(crate) fn next_mask(
|
||||
model_catalog: &ModelCatalog,
|
||||
current: Option<&CollaborationModeMask>,
|
||||
) -> Option<CollaborationModeMask> {
|
||||
let presets = filtered_presets(model_catalog);
|
||||
if presets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let current_mode = current.and_then(|mask| mask.mode);
|
||||
let next_index = presets
|
||||
.iter()
|
||||
.position(|mask| mask.mode == current_mode)
|
||||
.map_or(0, |index| (index + 1) % presets.len());
|
||||
presets.get(next_index).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
|
||||
mask_for_kind(model_catalog, ModeKind::Default)
|
||||
}
|
||||
|
||||
pub(crate) fn plan_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
|
||||
mask_for_kind(model_catalog, ModeKind::Plan)
|
||||
}
|
||||
|
||||
pub(crate) fn to_mode(
|
||||
mask: &CollaborationModeMask,
|
||||
fallback_model: Option<&str>,
|
||||
fallback_effort: Option<&str>,
|
||||
) -> Option<CollaborationMode> {
|
||||
let mode = mask.mode?;
|
||||
let model = mask.model.as_deref().or(fallback_model)?.to_string();
|
||||
let reasoning_effort = match &mask.reasoning_effort {
|
||||
Some(effort) => effort.clone(),
|
||||
None => fallback_effort.map(str::to_string),
|
||||
};
|
||||
Some(CollaborationMode {
|
||||
mode,
|
||||
settings: CollaborationModeSettings {
|
||||
model,
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mask(name: &str, mode: ModeKind, model: Option<&str>) -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: name.to_string(),
|
||||
mode: Some(mode),
|
||||
model: model.map(str::to_string),
|
||||
reasoning_effort: Some(Some("high".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_selection_prefers_default_and_cycles_in_server_order() {
|
||||
let catalog = ModelCatalog::default().with_collaboration_modes(vec![
|
||||
mask("plan", ModeKind::Plan, Some("planner")),
|
||||
mask("default", ModeKind::Default, Some("default")),
|
||||
]);
|
||||
|
||||
assert_eq!(default_mask(&catalog).unwrap().name, "default");
|
||||
assert_eq!(default_mode_mask(&catalog).unwrap().name, "default");
|
||||
assert_eq!(plan_mask(&catalog).unwrap().name, "plan");
|
||||
assert_eq!(
|
||||
next_mask(&catalog, Some(&mask("plan", ModeKind::Plan, None)))
|
||||
.unwrap()
|
||||
.name,
|
||||
"default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_mode_uses_mask_values_and_fallbacks() {
|
||||
let plan = mask("plan", ModeKind::Plan, None);
|
||||
let mode = to_mode(&plan, Some("fallback-model"), Some("medium")).expect("mode");
|
||||
assert_eq!(mode.mode, ModeKind::Plan);
|
||||
assert_eq!(mode.settings.model, "fallback-model");
|
||||
assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_mode_preserves_an_explicit_effort_clear() {
|
||||
let mask = CollaborationModeMask {
|
||||
name: "default".to_string(),
|
||||
mode: Some(ModeKind::Default),
|
||||
model: None,
|
||||
reasoning_effort: Some(None),
|
||||
};
|
||||
let mode = to_mode(&mask, Some("fallback-model"), Some("high")).expect("mode");
|
||||
assert_eq!(mode.settings.reasoning_effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_has_no_mode() {
|
||||
let catalog = ModelCatalog::default();
|
||||
assert!(default_mask(&catalog).is_none());
|
||||
assert!(next_mask(&catalog, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::locale::Locale;
|
||||
use crate::slash_command::{SlashCommand, command_filter};
|
||||
use crate::slash_command::{command_filter, SlashCommand};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CommandPopupAction {
|
||||
@@ -158,8 +158,8 @@ pub(crate) fn render(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
#[test]
|
||||
fn filters_by_prefix_and_wraps_selection() {
|
||||
@@ -169,8 +169,8 @@ mod tests {
|
||||
popup.handle_event(&Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE,))),
|
||||
CommandPopupAction::Consumed
|
||||
);
|
||||
assert_eq!(popup.selected(), Some(SlashCommand::Copy));
|
||||
assert!(popup.update("/p"));
|
||||
assert_eq!(popup.selected(), Some(SlashCommand::Resume));
|
||||
assert!(popup.update("/per"));
|
||||
assert_eq!(popup.commands(), vec![SlashCommand::Permissions]);
|
||||
assert_eq!(popup.selected(), Some(SlashCommand::Permissions));
|
||||
assert!(!popup.update("/unknown"));
|
||||
@@ -194,9 +194,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_backend_renders_commands_and_localized_descriptions() {
|
||||
let popup = CommandPopup::for_composer("/").expect("popup");
|
||||
let mut terminal = Terminal::new(TestBackend::new(72, 8)).expect("terminal");
|
||||
let mut terminal = Terminal::new(TestBackend::new(72, 10)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, Rect::new(0, 5, 72, 3), &popup, Locale::ZhCn))
|
||||
.draw(|frame| render(frame, Rect::new(0, 8, 72, 2), &popup, Locale::ZhCn))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let text = (0..buffer.area.height)
|
||||
|
||||
@@ -4,7 +4,7 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::highlight::{CodeLineHighlighter, exceeds_highlight_limits};
|
||||
use crate::highlight::{exceeds_highlight_limits, CodeLineHighlighter};
|
||||
use crate::line_truncation::truncate_line_to_width;
|
||||
use crate::width::display_width;
|
||||
|
||||
@@ -226,11 +226,9 @@ fn render_line(line: DiffLine, number_width: usize, width: Option<usize>) -> Vec
|
||||
Span::raw(" ".repeat(number_width + 1)),
|
||||
Span::styled("⋮", Style::default().fg(Color::DarkGray)),
|
||||
]);
|
||||
return vec![
|
||||
width
|
||||
.map(|width| truncate_line_to_width(marker.clone(), width))
|
||||
.unwrap_or(marker),
|
||||
];
|
||||
return vec![width
|
||||
.map(|width| truncate_line_to_width(marker.clone(), width))
|
||||
.unwrap_or(marker)];
|
||||
}
|
||||
let Some(number) = number else {
|
||||
return wrap_plain_line(Line::from(Span::styled(text, style)), width);
|
||||
@@ -667,13 +665,11 @@ mod tests {
|
||||
let inserted = &lines[2];
|
||||
|
||||
assert!(inserted.spans.len() > 3);
|
||||
assert!(
|
||||
inserted
|
||||
.spans
|
||||
.iter()
|
||||
.skip(2)
|
||||
.any(|span| span.content.contains("fn") && span.style.fg.is_some())
|
||||
);
|
||||
assert!(inserted
|
||||
.spans
|
||||
.iter()
|
||||
.skip(2)
|
||||
.any(|span| span.content.contains("fn") && span.style.fg.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -712,17 +708,15 @@ mod tests {
|
||||
content,
|
||||
"pub fn long_name(answer: usize) -> usize { answer + 1 }"
|
||||
);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| {
|
||||
span.content.contains("fn")
|
||||
&& span.style.fg.is_some()
|
||||
&& span.style.fg != Some(Color::Green)
|
||||
})
|
||||
);
|
||||
assert!(lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| {
|
||||
span.content.contains("fn")
|
||||
&& span.style.fg.is_some()
|
||||
&& span.style.fg != Some(Color::Green)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -763,12 +757,10 @@ mod tests {
|
||||
let deleted = lines.last().expect("deleted line");
|
||||
|
||||
assert!(deleted.spans.len() > 3);
|
||||
assert!(
|
||||
deleted
|
||||
.spans
|
||||
.iter()
|
||||
.skip(2)
|
||||
.any(|span| span.content.contains("fn") && span.style.fg.is_some())
|
||||
);
|
||||
assert!(deleted
|
||||
.spans
|
||||
.iter()
|
||||
.skip(2)
|
||||
.any(|span| span.content.contains("fn") && span.style.fg.is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::diff_render;
|
||||
use crate::locale::Locale;
|
||||
use crate::markdown_render;
|
||||
use crate::projection::{EntryKind, EntryStatus, TranscriptEntry};
|
||||
use crate::terminal_hyperlinks::{HyperlinkLine, prefix_hyperlink_lines};
|
||||
use crate::terminal_hyperlinks::{prefix_hyperlink_lines, HyperlinkLine};
|
||||
|
||||
const COMMAND_OUTPUT_HEAD_LINES: usize = 50;
|
||||
const COMMAND_OUTPUT_TAIL_LINES: usize = 50;
|
||||
@@ -72,10 +72,10 @@ pub(crate) fn hyperlink_lines_with_locale(
|
||||
}
|
||||
let mut source = entry.text.lines();
|
||||
let first = source.next().unwrap_or("");
|
||||
let first = if matches!(entry.kind, EntryKind::Tool | EntryKind::System) {
|
||||
locale.detail(first)
|
||||
} else {
|
||||
first.to_string()
|
||||
let first = match entry.kind {
|
||||
EntryKind::Tool | EntryKind::System => locale.detail(first),
|
||||
EntryKind::MultiAgent => locale.multi_agent(first),
|
||||
_ => first.to_string(),
|
||||
};
|
||||
let first = with_status_suffix(&first, entry.status, locale);
|
||||
let mut lines = vec![HyperlinkLine::new(format_line(
|
||||
@@ -287,27 +287,37 @@ mod tests {
|
||||
assert_eq!(mcp[0].spans[0].content.as_ref(), "@ ");
|
||||
assert_eq!(plan[0].spans[0].content.as_ref(), "• ");
|
||||
assert_eq!(multi_agent[0].spans[0].content.as_ref(), "& ");
|
||||
assert!(
|
||||
patch[1]
|
||||
.spans
|
||||
.iter()
|
||||
.any(|span| span.style.fg == Some(Color::Green))
|
||||
);
|
||||
assert!(patch[1]
|
||||
.spans
|
||||
.iter()
|
||||
.any(|span| span.style.fg == Some(Color::Green)));
|
||||
assert_eq!(command[1].spans[1].style.fg, Some(Color::DarkGray));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_titles_are_localized_at_the_render_boundary() {
|
||||
let entry = entry(EntryKind::MultiAgent, "Spawned agent-1");
|
||||
let rendered =
|
||||
hyperlink_lines_with_locale(&entry, Locale::ZhCn, Some(80), Path::new("/workspace"));
|
||||
let text = rendered
|
||||
.iter()
|
||||
.flat_map(|line| line.line.spans.iter())
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("已启动 agent-1"));
|
||||
assert!(!text.contains("Spawned agent-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_statuses_use_stable_checkbox_styles() {
|
||||
let completed = lines(&entry(EntryKind::Plan, "[x] inspect"));
|
||||
let running = lines(&entry(EntryKind::Plan, "[~] test"));
|
||||
let pending = lines(&entry(EntryKind::Plan, "[ ] ship"));
|
||||
|
||||
assert!(
|
||||
completed[0].spans[1]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::CROSSED_OUT)
|
||||
);
|
||||
assert!(completed[0].spans[1]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::CROSSED_OUT));
|
||||
assert_eq!(running[0].spans[1].style.fg, Some(Color::Cyan));
|
||||
assert_eq!(pending[0].spans[1].style.fg, Some(Color::DarkGray));
|
||||
}
|
||||
@@ -345,12 +355,10 @@ mod tests {
|
||||
span.style.fg == Some(Color::DarkGray)
|
||||
&& span.style.add_modifier.contains(Modifier::BOLD)
|
||||
}));
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| span.content == "src/lib.rs" && span.style.fg == Some(Color::Cyan))
|
||||
);
|
||||
assert!(rendered
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| span.content == "src/lib.rs" && span.style.fg == Some(Color::Cyan)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Open the user's configured editor and return the edited draft.
|
||||
@@ -178,11 +178,9 @@ mod tests {
|
||||
.expect_err("non-zero editor must fail");
|
||||
|
||||
assert!(error.to_string().contains("exited with status"));
|
||||
assert!(
|
||||
fs::read_dir(directory.path())
|
||||
.expect("directory listing")
|
||||
.filter_map(Result::ok)
|
||||
.all(|entry| !entry.file_name().to_string_lossy().starts_with("lime-tui-"))
|
||||
);
|
||||
assert!(fs::read_dir(directory.path())
|
||||
.expect("directory listing")
|
||||
.filter_map(Result::ok)
|
||||
.all(|entry| !entry.file_name().to_string_lossy().starts_with("lime-tui-")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,12 +224,10 @@ mod tests {
|
||||
fn unknown_language_falls_back_to_plain_text() {
|
||||
let lines = highlight_code_to_lines("some random text", "xyzlang");
|
||||
assert_eq!(reconstructed(&lines), "some random text");
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.all(|span| span.style == Style::default())
|
||||
);
|
||||
assert!(lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.all(|span| span.style == Style::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -242,12 +240,10 @@ mod tests {
|
||||
#[test]
|
||||
fn crlf_does_not_leak_carriage_returns() {
|
||||
let lines = highlight_code_to_lines("fn main() {\r\n}\r\n", "rust");
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.all(|span| !span.content.contains('\r'))
|
||||
);
|
||||
assert!(lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.all(|span| !span.content.contains('\r')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Key input matching shared by TUI selection surfaces.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// Returns whether an event should be treated as literal text input.
|
||||
pub(crate) fn is_plain_text_key_event(event: KeyEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
..
|
||||
} if !ch.is_ascii_control()
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shifted_characters_are_plain_text() {
|
||||
assert!(is_plain_text_key_event(KeyEvent::new(
|
||||
KeyCode::Char('G'),
|
||||
KeyModifiers::SHIFT,
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_and_alt_characters_are_not_plain_text() {
|
||||
assert!(!is_plain_text_key_event(KeyEvent::new(
|
||||
KeyCode::Char('g'),
|
||||
KeyModifiers::CONTROL,
|
||||
)));
|
||||
assert!(!is_plain_text_key_event(KeyEvent::new(
|
||||
KeyCode::Char('g'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Stable key bindings for the Codex-shaped Agents Overview.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct AgentsKeymap;
|
||||
|
||||
impl AgentsKeymap {
|
||||
pub(crate) fn resume(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 'o')
|
||||
}
|
||||
|
||||
pub(crate) fn search(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 'f')
|
||||
}
|
||||
|
||||
pub(crate) fn new_task(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 'n')
|
||||
}
|
||||
|
||||
pub(crate) fn rename(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 'r')
|
||||
}
|
||||
|
||||
pub(crate) fn stop(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 'x')
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_grouping(self, key: KeyEvent) -> bool {
|
||||
Self::control(key, 's')
|
||||
}
|
||||
|
||||
fn control(key: KeyEvent, character: char) -> bool {
|
||||
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(character)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AgentsKeymap;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn codex_agents_defaults_are_stable() {
|
||||
let keymap = AgentsKeymap;
|
||||
assert!(keymap.resume(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)));
|
||||
assert!(keymap.search(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)));
|
||||
assert!(keymap.new_task(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)));
|
||||
assert!(keymap.rename(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)));
|
||||
assert!(keymap.stop(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)));
|
||||
assert!(keymap.toggle_grouping(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL,)));
|
||||
assert!(!keymap.resume(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,21 @@ mod app_server_session;
|
||||
mod bottom_pane;
|
||||
mod clipboard_copy;
|
||||
mod clipboard_paste;
|
||||
mod collaboration_modes;
|
||||
mod command_popup;
|
||||
mod diff_render;
|
||||
mod entry;
|
||||
mod external_editor;
|
||||
mod highlight;
|
||||
mod key_hint;
|
||||
mod keymap;
|
||||
mod line_truncation;
|
||||
mod locale;
|
||||
mod markdown;
|
||||
mod markdown_render;
|
||||
mod model_catalog;
|
||||
mod model_picker;
|
||||
mod multi_agents;
|
||||
mod pager_overlay;
|
||||
mod pending_input_preview;
|
||||
mod projection;
|
||||
@@ -34,7 +39,8 @@ mod thread_transcript;
|
||||
mod transcript_reflow;
|
||||
mod tui;
|
||||
mod view;
|
||||
mod viewport;
|
||||
mod width;
|
||||
mod wrapping;
|
||||
|
||||
pub use runtime::{ExecOptions, ExecResult, TuiOptions, run_exec, run_resume, run_tui};
|
||||
pub use runtime::{run_exec, run_resume, run_tui, ExecOptions, ExecResult, TuiOptions};
|
||||
|
||||
@@ -156,6 +156,11 @@ impl Locale {
|
||||
(Self::EnUs, SlashCommand::Model) => "choose a model",
|
||||
(Self::JaJp, SlashCommand::Model) => "モデルを選択",
|
||||
(Self::KoKr, SlashCommand::Model) => "모델 선택",
|
||||
(Self::ZhCn, SlashCommand::Plan) => "切换到计划模式",
|
||||
(Self::ZhTw, SlashCommand::Plan) => "切換至計畫模式",
|
||||
(Self::EnUs, SlashCommand::Plan) => "switch to Plan mode",
|
||||
(Self::JaJp, SlashCommand::Plan) => "計画モードに切り替え",
|
||||
(Self::KoKr, SlashCommand::Plan) => "계획 모드로 전환",
|
||||
(Self::ZhCn, SlashCommand::Effort) => "设置推理强度",
|
||||
(Self::ZhTw, SlashCommand::Effort) => "設定推理強度",
|
||||
(Self::EnUs, SlashCommand::Effort) => "set the reasoning effort",
|
||||
@@ -176,6 +181,25 @@ impl Locale {
|
||||
(Self::EnUs, SlashCommand::Copy) => "copy the last response",
|
||||
(Self::JaJp, SlashCommand::Copy) => "最後の応答をコピー",
|
||||
(Self::KoKr, SlashCommand::Copy) => "마지막 응답 복사",
|
||||
(Self::ZhCn, SlashCommand::Agents) => "查看和切换所有活跃 Agent 会话",
|
||||
(Self::ZhTw, SlashCommand::Agents) => "檢視和切換所有活躍 Agent 工作階段",
|
||||
(Self::EnUs, SlashCommand::Agents) => {
|
||||
"view and switch between all active agent sessions"
|
||||
}
|
||||
(Self::JaJp, SlashCommand::Agents) => {
|
||||
"すべてのアクティブな Agent セッションを表示して切り替え"
|
||||
}
|
||||
(Self::KoKr, SlashCommand::Agents) => "모든 활성 에이전트 세션 보기 및 전환",
|
||||
(Self::ZhCn, SlashCommand::MultiAgents) => "切换子 Agent 会话",
|
||||
(Self::ZhTw, SlashCommand::MultiAgents) => "切換子 Agent 工作階段",
|
||||
(Self::EnUs, SlashCommand::MultiAgents) => "switch between sub-agent threads",
|
||||
(Self::JaJp, SlashCommand::MultiAgents) => "サブ Agent スレッドを切り替え",
|
||||
(Self::KoKr, SlashCommand::MultiAgents) => "하위 에이전트 스레드 전환",
|
||||
(Self::ZhCn, SlashCommand::Resume) => "恢复之前的会话",
|
||||
(Self::ZhTw, SlashCommand::Resume) => "恢復先前的工作階段",
|
||||
(Self::EnUs, SlashCommand::Resume) => "resume a previous session",
|
||||
(Self::JaJp, SlashCommand::Resume) => "以前のセッションを再開",
|
||||
(Self::KoKr, SlashCommand::Resume) => "이전 세션 재개",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +341,13 @@ impl Locale {
|
||||
Self::JaJp => "失敗",
|
||||
Self::KoKr => "실패",
|
||||
},
|
||||
"closed" => match self {
|
||||
Self::ZhCn => "已关闭",
|
||||
Self::ZhTw => "已關閉",
|
||||
Self::EnUs => "closed",
|
||||
Self::JaJp => "終了",
|
||||
Self::KoKr => "닫힘",
|
||||
},
|
||||
"interrupted" => match self {
|
||||
Self::ZhCn => "已中断",
|
||||
Self::ZhTw => "已中斷",
|
||||
@@ -324,6 +355,48 @@ impl Locale {
|
||||
Self::JaJp => "中断",
|
||||
Self::KoKr => "중단됨",
|
||||
},
|
||||
"background task started" => match self {
|
||||
Self::ZhCn => "后台任务已启动",
|
||||
Self::ZhTw => "背景工作已啟動",
|
||||
Self::EnUs => "background task started",
|
||||
Self::JaJp => "バックグラウンドタスクを開始しました",
|
||||
Self::KoKr => "백그라운드 작업 시작됨",
|
||||
},
|
||||
"background task failed" => match self {
|
||||
Self::ZhCn => "后台任务失败",
|
||||
Self::ZhTw => "背景工作失敗",
|
||||
Self::EnUs => "background task failed",
|
||||
Self::JaJp => "バックグラウンドタスクに失敗しました",
|
||||
Self::KoKr => "백그라운드 작업 실패",
|
||||
},
|
||||
"background task is idle" => match self {
|
||||
Self::ZhCn => "后台任务未运行",
|
||||
Self::ZhTw => "背景工作未執行",
|
||||
Self::EnUs => "background task is idle",
|
||||
Self::JaJp => "バックグラウンドタスクは待機中です",
|
||||
Self::KoKr => "백그라운드 작업 대기 중",
|
||||
},
|
||||
"stopping background turn" => match self {
|
||||
Self::ZhCn => "正在停止后台回合",
|
||||
Self::ZhTw => "正在停止背景回合",
|
||||
Self::EnUs => "stopping background turn",
|
||||
Self::JaJp => "バックグラウンドターンを停止中",
|
||||
Self::KoKr => "백그라운드 턴 중지 중",
|
||||
},
|
||||
"agent renamed" => match self {
|
||||
Self::ZhCn => "Agent 已改名",
|
||||
Self::ZhTw => "Agent 已改名",
|
||||
Self::EnUs => "agent renamed",
|
||||
Self::JaJp => "Agent の名前を変更しました",
|
||||
Self::KoKr => "에이전트 이름 변경됨",
|
||||
},
|
||||
"resume picker is available from /resume" => match self {
|
||||
Self::ZhCn => "请使用 /resume 打开会话选择器",
|
||||
Self::ZhTw => "請使用 /resume 開啟工作階段選擇器",
|
||||
Self::EnUs => "resume picker is available from /resume",
|
||||
Self::JaJp => "/resume から再開ピッカーを開けます",
|
||||
Self::KoKr => "/resume에서 재개 선택기를 열 수 있음",
|
||||
},
|
||||
"declined" => match self {
|
||||
Self::ZhCn => "已拒绝",
|
||||
Self::ZhTw => "已拒絕",
|
||||
@@ -338,6 +411,55 @@ impl Locale {
|
||||
Self::JaJp => "設定を更新しました",
|
||||
Self::KoKr => "설정이 업데이트됨",
|
||||
},
|
||||
"collaboration mode updated" => match self {
|
||||
Self::ZhCn => "协作模式已更新",
|
||||
Self::ZhTw => "協作模式已更新",
|
||||
Self::EnUs => "collaboration mode updated",
|
||||
Self::JaJp => "コラボレーションモードを更新しました",
|
||||
Self::KoKr => "협업 모드가 업데이트됨",
|
||||
},
|
||||
"plan mode" => match self {
|
||||
Self::ZhCn => "计划模式",
|
||||
Self::ZhTw => "計畫模式",
|
||||
Self::EnUs => "plan mode",
|
||||
Self::JaJp => "計画モード",
|
||||
Self::KoKr => "계획 모드",
|
||||
},
|
||||
"plan mode unavailable on this server" => match self {
|
||||
Self::ZhCn => "此服务器不支持计划模式",
|
||||
Self::ZhTw => "此伺服器不支援計畫模式",
|
||||
Self::EnUs => "plan mode unavailable on this server",
|
||||
Self::JaJp => "このサーバーでは計画モードを利用できません",
|
||||
Self::KoKr => "이 서버에서는 계획 모드를 사용할 수 없음",
|
||||
},
|
||||
"switched agent" => match self {
|
||||
Self::ZhCn => "已切换 Agent",
|
||||
Self::ZhTw => "已切換 Agent",
|
||||
Self::EnUs => "switched agent",
|
||||
Self::JaJp => "Agent を切り替えました",
|
||||
Self::KoKr => "에이전트 전환됨",
|
||||
},
|
||||
"sub-agent thread is parent-owned" => match self {
|
||||
Self::ZhCn => "子 Agent 会话由父会话控制",
|
||||
Self::ZhTw => "子 Agent 工作階段由父工作階段控制",
|
||||
Self::EnUs => "sub-agent thread is parent-owned",
|
||||
Self::JaJp => "サブ Agent スレッドは親が管理します",
|
||||
Self::KoKr => "하위 에이전트 스레드는 부모가 제어함",
|
||||
},
|
||||
"agent switch failed" => match self {
|
||||
Self::ZhCn => "切换 Agent 失败",
|
||||
Self::ZhTw => "切換 Agent 失敗",
|
||||
Self::EnUs => "agent switch failed",
|
||||
Self::JaJp => "Agent の切り替えに失敗しました",
|
||||
Self::KoKr => "에이전트 전환 실패",
|
||||
},
|
||||
"no sub-agents available" => match self {
|
||||
Self::ZhCn => "暂无可用的子 Agent",
|
||||
Self::ZhTw => "目前沒有可用的子 Agent",
|
||||
Self::EnUs => "no sub-agents available",
|
||||
Self::JaJp => "利用可能なサブ Agent がありません",
|
||||
Self::KoKr => "사용 가능한 하위 에이전트가 없음",
|
||||
},
|
||||
"choose model" => match self {
|
||||
Self::ZhCn => "请选择模型",
|
||||
Self::ZhTw => "請選擇模型",
|
||||
@@ -464,6 +586,69 @@ impl Locale {
|
||||
rest.map_or_else(|| label.to_string(), |rest| format!("{label}: {rest}"))
|
||||
}
|
||||
|
||||
pub(crate) fn multi_agent(self, text: &str) -> String {
|
||||
if text == "Finished waiting" {
|
||||
return match self {
|
||||
Self::ZhCn => "等待完成".to_string(),
|
||||
Self::ZhTw => "等待完成".to_string(),
|
||||
Self::EnUs => text.to_string(),
|
||||
Self::JaJp => "待機が完了しました".to_string(),
|
||||
Self::KoKr => "대기 완료".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
const PREFIXES: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("Spawned ", "已启动 ", "已啟動 ", "起動: ", "생성됨 "),
|
||||
(
|
||||
"Sent input to ",
|
||||
"已向其发送输入 ",
|
||||
"已向其傳送輸入 ",
|
||||
"入力送信: ",
|
||||
"입력 전송: ",
|
||||
),
|
||||
(
|
||||
"Resuming ",
|
||||
"正在恢复 ",
|
||||
"正在恢復 ",
|
||||
"再開中: ",
|
||||
"재개 중: ",
|
||||
),
|
||||
("Resumed ", "已恢复 ", "已恢復 ", "再開済み: ", "재개됨 "),
|
||||
(
|
||||
"Waiting for ",
|
||||
"正在等待 ",
|
||||
"正在等待 ",
|
||||
"待機中: ",
|
||||
"대기 중: ",
|
||||
),
|
||||
("Closed ", "已关闭 ", "已關閉 ", "終了: ", "닫힘 "),
|
||||
("Started ", "已开始 ", "已開始 ", "開始: ", "시작됨 "),
|
||||
(
|
||||
"Interacted with ",
|
||||
"已与其交互 ",
|
||||
"已與其互動 ",
|
||||
"操作: ",
|
||||
"상호작용: ",
|
||||
),
|
||||
("Interrupted ", "已中断 ", "已中斷 ", "中断: ", "중단됨 "),
|
||||
];
|
||||
PREFIXES
|
||||
.iter()
|
||||
.find_map(|(prefix, zh_cn, zh_tw, ja, ko)| {
|
||||
text.strip_prefix(prefix).map(|rest| {
|
||||
let label = match self {
|
||||
Self::ZhCn => zh_cn,
|
||||
Self::ZhTw => zh_tw,
|
||||
Self::EnUs => prefix,
|
||||
Self::JaJp => ja,
|
||||
Self::KoKr => ko,
|
||||
};
|
||||
format!("{label}{rest}")
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| text.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn detail(self, detail: &str) -> String {
|
||||
const PREFIXES: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("exit ", "退出 ", "退出 ", "exit ", "종료 "),
|
||||
@@ -726,6 +911,113 @@ impl Locale {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_picker_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "选择 Agent",
|
||||
Self::ZhTw => "選擇 Agent",
|
||||
Self::EnUs => "Choose an agent",
|
||||
Self::JaJp => "Agent を選択",
|
||||
Self::KoKr => "에이전트 선택",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_picker_empty(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "暂无可用的子 Agent。按 Esc 取消",
|
||||
Self::ZhTw => "目前沒有可用的子 Agent。按 Esc 取消",
|
||||
Self::EnUs => "No sub-agents available. Esc cancel",
|
||||
Self::JaJp => "利用可能なサブ Agent がありません。Esc でキャンセル",
|
||||
Self::KoKr => "사용 가능한 하위 에이전트가 없습니다. Esc로 취소",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_picker_footer(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "上下移动 Enter 查看 Esc 取消",
|
||||
Self::ZhTw => "上下移動 Enter 檢視 Esc 取消",
|
||||
Self::EnUs => "Up/Down move Enter view Esc cancel",
|
||||
Self::JaJp => "上下移動 Enter 表示 Esc キャンセル",
|
||||
Self::KoKr => "위/아래 이동 Enter 보기 Esc 취소",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "Agent 控制中心",
|
||||
Self::ZhTw => "Agent 控制中心",
|
||||
Self::EnUs => "Agent command center",
|
||||
Self::JaJp => "Agent コマンドセンター",
|
||||
Self::KoKr => "에이전트 명령 센터",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_footer(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => {
|
||||
"上下移动 · Enter 打开 · / 或 Ctrl+F 搜索 · Ctrl+N 新建 · Ctrl+R 改名 · Ctrl+X 停止 · Ctrl+S 分组 · r 刷新 · Esc 返回"
|
||||
}
|
||||
Self::ZhTw => {
|
||||
"上下移動 · Enter 開啟 · / 或 Ctrl+F 搜尋 · Ctrl+N 新建 · Ctrl+R 改名 · Ctrl+X 停止 · Ctrl+S 分組 · r 重新整理 · Esc 返回"
|
||||
}
|
||||
Self::EnUs => {
|
||||
"Up/Down navigate · Enter open · / or Ctrl+F search · Ctrl+N new · Ctrl+R rename · Ctrl+X stop · Ctrl+S group · r refresh · Esc back"
|
||||
}
|
||||
Self::JaJp => {
|
||||
"上下移動 · Enter 開く · / または Ctrl+F 検索 · Ctrl+N 新規 · Ctrl+R 名前変更 · Ctrl+X 停止 · Ctrl+S グループ · r 更新 · Esc 戻る"
|
||||
}
|
||||
Self::KoKr => {
|
||||
"위/아래 이동 · Enter 열기 · / 또는 Ctrl+F 검색 · Ctrl+N 새 작업 · Ctrl+R 이름 변경 · Ctrl+X 중지 · Ctrl+S 그룹 · r 새로 고침 · Esc 뒤로"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_input_prefix(self, rename: bool) -> &'static str {
|
||||
match (self, rename) {
|
||||
(Self::ZhCn, true) => "改名",
|
||||
(Self::ZhCn, false) => "新建任务",
|
||||
(Self::ZhTw, true) => "改名",
|
||||
(Self::ZhTw, false) => "新建工作",
|
||||
(Self::EnUs, true) => "Rename",
|
||||
(Self::EnUs, false) => "New task",
|
||||
(Self::JaJp, true) => "名前変更",
|
||||
(Self::JaJp, false) => "新しいタスク",
|
||||
(Self::KoKr, true) => "이름 변경",
|
||||
(Self::KoKr, false) => "새 작업",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_search_prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "搜索",
|
||||
Self::ZhTw => "搜尋",
|
||||
Self::EnUs => "Search",
|
||||
Self::JaJp => "検索",
|
||||
Self::KoKr => "검색",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agents_overview_group_label(self, group: &'static str) -> &'static str {
|
||||
match (self, group) {
|
||||
(Self::ZhCn, "need input") => "需要输入",
|
||||
(Self::ZhTw, "need input") => "需要輸入",
|
||||
(Self::JaJp, "need input") => "入力待ち",
|
||||
(Self::KoKr, "need input") => "입력 필요",
|
||||
(Self::ZhCn, "working") => "运行中",
|
||||
(Self::ZhTw, "working") => "執行中",
|
||||
(Self::JaJp, "working") => "実行中",
|
||||
(Self::KoKr, "working") => "작업 중",
|
||||
(Self::ZhCn, "ready") => "就绪",
|
||||
(Self::ZhTw, "ready") => "就緒",
|
||||
(Self::JaJp, "ready") => "準備完了",
|
||||
(Self::KoKr, "ready") => "준비됨",
|
||||
(Self::ZhCn, "finished") => "已完成",
|
||||
(Self::ZhTw, "finished") => "已完成",
|
||||
(Self::JaJp, "finished") => "完了",
|
||||
(Self::KoKr, "finished") => "완료",
|
||||
(_, value) => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resume_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "选择会话",
|
||||
@@ -933,6 +1225,16 @@ impl Locale {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resume_transcript_hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "Ctrl+T 查看完整记录",
|
||||
Self::ZhTw => "Ctrl+T 檢視完整記錄",
|
||||
Self::EnUs => "Ctrl+T view full transcript",
|
||||
Self::JaJp => "Ctrl+T 完全な履歴を表示",
|
||||
Self::KoKr => "Ctrl+T 전체 대화 기록 보기",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn created_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZhCn => "创建时间",
|
||||
@@ -1055,6 +1357,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collaboration_mode_statuses_cover_all_product_locales() {
|
||||
let cases = [
|
||||
(
|
||||
Locale::ZhCn,
|
||||
"协作模式已更新",
|
||||
"计划模式",
|
||||
"此服务器不支持计划模式",
|
||||
),
|
||||
(
|
||||
Locale::ZhTw,
|
||||
"協作模式已更新",
|
||||
"計畫模式",
|
||||
"此伺服器不支援計畫模式",
|
||||
),
|
||||
(
|
||||
Locale::EnUs,
|
||||
"collaboration mode updated",
|
||||
"plan mode",
|
||||
"plan mode unavailable on this server",
|
||||
),
|
||||
(
|
||||
Locale::JaJp,
|
||||
"コラボレーションモードを更新しました",
|
||||
"計画モード",
|
||||
"このサーバーでは計画モードを利用できません",
|
||||
),
|
||||
(
|
||||
Locale::KoKr,
|
||||
"협업 모드가 업데이트됨",
|
||||
"계획 모드",
|
||||
"이 서버에서는 계획 모드를 사용할 수 없음",
|
||||
),
|
||||
];
|
||||
|
||||
for (locale, updated, plan, unavailable) in cases {
|
||||
assert_eq!(locale.status("collaboration mode updated"), updated);
|
||||
assert_eq!(locale.status("plan mode"), plan);
|
||||
assert_eq!(
|
||||
locale.status("plan mode unavailable on this server"),
|
||||
unavailable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_titles_cover_all_product_locales() {
|
||||
let cases = [
|
||||
(
|
||||
Locale::ZhCn,
|
||||
"已启动 agent-1",
|
||||
"等待完成",
|
||||
"已中断 `agent-1`",
|
||||
),
|
||||
(
|
||||
Locale::ZhTw,
|
||||
"已啟動 agent-1",
|
||||
"等待完成",
|
||||
"已中斷 `agent-1`",
|
||||
),
|
||||
(
|
||||
Locale::EnUs,
|
||||
"Spawned agent-1",
|
||||
"Finished waiting",
|
||||
"Interrupted `agent-1`",
|
||||
),
|
||||
(
|
||||
Locale::JaJp,
|
||||
"起動: agent-1",
|
||||
"待機が完了しました",
|
||||
"中断: `agent-1`",
|
||||
),
|
||||
(
|
||||
Locale::KoKr,
|
||||
"생성됨 agent-1",
|
||||
"대기 완료",
|
||||
"중단됨 `agent-1`",
|
||||
),
|
||||
];
|
||||
for (locale, spawned, waiting, interrupted) in cases {
|
||||
assert_eq!(locale.multi_agent("Spawned agent-1"), spawned);
|
||||
assert_eq!(locale.multi_agent("Finished waiting"), waiting);
|
||||
assert_eq!(locale.multi_agent("Interrupted `agent-1`"), interrupted);
|
||||
}
|
||||
assert_eq!(Locale::EnUs.multi_agent("unknown event"), "unknown event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_omission_markers_cover_all_product_locales() {
|
||||
let line_markers = [
|
||||
@@ -1080,6 +1469,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_controls_cover_all_product_locales() {
|
||||
for locale in [
|
||||
Locale::ZhCn,
|
||||
Locale::ZhTw,
|
||||
Locale::EnUs,
|
||||
Locale::JaJp,
|
||||
Locale::KoKr,
|
||||
] {
|
||||
assert!(!locale.agents_overview_title().is_empty());
|
||||
assert!(!locale.agents_overview_footer().is_empty());
|
||||
assert!(!locale.agents_overview_search_prefix().is_empty());
|
||||
assert!(!locale.agents_overview_group_label("need input").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overview_task_statuses_cover_all_product_locales() {
|
||||
for locale in [
|
||||
Locale::ZhCn,
|
||||
Locale::ZhTw,
|
||||
Locale::EnUs,
|
||||
Locale::JaJp,
|
||||
Locale::KoKr,
|
||||
] {
|
||||
assert!(!locale.agents_overview_input_prefix(false).is_empty());
|
||||
assert!(!locale.agents_overview_input_prefix(true).is_empty());
|
||||
assert!(!locale
|
||||
.status("background task started: thread-1")
|
||||
.is_empty());
|
||||
assert!(!locale.status("background task failed: offline").is_empty());
|
||||
assert!(!locale.status("agent renamed").is_empty());
|
||||
assert!(!locale
|
||||
.status("resume picker is available from /resume")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_clipboard_statuses_cover_all_product_locales() {
|
||||
let cases = [
|
||||
@@ -1265,6 +1692,7 @@ mod tests {
|
||||
assert!(!locale.resume_density_label(false).is_empty());
|
||||
assert!(!locale.resume_density_label(true).is_empty());
|
||||
assert!(!locale.resume_expand_hint().is_empty());
|
||||
assert!(!locale.resume_transcript_hint().is_empty());
|
||||
assert!(!locale.created_label().is_empty());
|
||||
assert!(!locale.updated_label().is_empty());
|
||||
assert!(!locale.resume_transcript_loading().is_empty());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user