Release v1.118.0

This commit is contained in:
coso
2026-08-01 00:06:26 +08:00
parent 66752a2772
commit d0971ea086
197 changed files with 16361 additions and 1566 deletions
+2
View File
@@ -69,6 +69,8 @@ internal/exec-plans/*
!internal/exec-plans/right-surface-implementation-progress.md
!internal/exec-plans/agent-app-host-v3-implementation.md
!internal/exec-plans/windows-runtime-model-route-repair-plan.md
!internal/exec-plans/windows-model-catalog-and-updater-reliability.md
!internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md
!internal/exec-plans/agent-app-uninstall-current-ui-progress.md
!internal/exec-plans/writing-v2-workflow-completion-plan.md
!internal/exec-plans/workflow-standardization-plan.md
+22 -20
View File
@@ -1,41 +1,43 @@
## Lime v1.117.0
## Lime v1.118.0
<sub>The Simplified Chinese release notes are the primary version. This English page is a companion for international readers.</sub>
### New Features
- Completed the `currentTime/read`, `item/permissions/requestApproval`, and `item/tool/call` host capabilities, keeping clock reads, permission escalation, and desktop dynamic-tool calls on the same Electron Host, App Server JSON-RPC, and RuntimeCore product chain.
- Promoted dynamic-tool calls to canonical Thread Items with namespace, tool, raw arguments, ordered text/image/audio output, status, and duration, preserving the lifecycle across session restoration.
- Completed the six AgentControl tools, typed wait states, child-agent activity projection, and cold-restart restoration for Multi-Agent conversations; parent-owned child threads now explicitly reject direct input.
- Added canonical execution recovery for known Lime Hub models through the bundled registry and canonical Agnes 2.5 Flash/Pro catalog entries, while unknown models remain fail-closed.
- Skill catalogs now refresh automatically when Skills are created, changed, or removed under default roots, or after successful local Lime Skill mutations. Composer always reloads the current App Server `skill/list`, without requiring a manual refresh or app restart.
- Agent plans now use the typed `turn/plan/updated` notification, showing the same checklist during a live Turn and after cold restoration. Failed or malformed snapshots cannot replace the latest valid plan.
- Agent errors now follow a typed retry and terminal chain: retrying, final failure, and the authoritative Turn terminal have distinct states and can be restored from the canonical read model.
- MCP OAuth completion now reaches the Renderer through a direct App Server v2 typed notification, keeping the login window, MCP management state, and event identity aligned.
### Fixes
- Fixed clean-environment resolution of `@limecloud/app-server-client/browser`, image-task fixtures using an invalid Chat model, model-list generation baseline drift, and incorrect JSON-RPC codes for invalid model parameters.
- Fixed model selectors exposing inferred-only models that RuntimeCore cannot execute, live provider metadata being overwritten by local hints, and persisted Windows Lime Hub models failing on the first Turn.
- Fixed media previews depending on transient chunk/completed notifications and Renderer live-drain helpers; every ranged response now validates offset, length, digest, and size before advancing progress.
- Fixed boundary cases in initial-session and child-agent navigation, canonical Item reads, event sequencing, and pending-interaction restoration.
- Fixed explicitly configured Provider models on Windows being hidden from the chat model selector when authoritative capability metadata was unavailable. Inferred-only, non-executable models remain fail closed.
- Fixed Electron updater sessions treating equal, older, invalid, or missing versions as installable updates. Only a strictly newer semantic version can enter download and installation.
- Fixed update failures, up-to-date results, and available updates sharing the same message, and aligned download, retry, and restart states across the sidebar, About page, and update notification window.
- Fixed inconsistent presentation of non-executable Provider routes between submit failures and runtime events, and missing global toast hosts on standalone pages.
- Fixed the Provider setup flow showing “Finish adding” before a configuration had been persisted and returning to the wrong model context after a successful save.
### Improvements and Refactors
- Migrated `configWarning` from v0 DTOs and schemas to one typed v2 owner; the Renderer only projects decoded global warnings and shows deduplicated messages across all five locales.
- Removed the v0 config-warning and TextPosition/TextRange schemas, media transient-notification consumers, the old protocol facade, and duplicate preview synchronization modules without production compatibility wrappers.
- Tightened permission-profile, sandbox-command, dynamic-tool routing, provider-history, and read-model boundaries; reserved names, schema collisions, identity mismatches, duplicate responses, and late responses all fail closed.
- Switched Skill installation locations to the authoritative backend `localDirectoryPath`, using Electron `openPath` for directories and `showItemInFolder` for files.
- Consolidated `skills/changed`, `error`, `turn/plan/updated`, and `mcpServer/oauthLogin/completed` into the App Server v2 protocol, schemas, generated client, and Renderer typed event bus without adding a production mock or second event owner.
- Unified Skill catalog cache invalidation in `lime-skills`; default roots use recursive watchers and throttled notifications, while reconnect and remount still perform an active current-catalog read.
- Kept the canonical `update_plan` tool item as the restoration source without generating a duplicate Plan Item, tool card, or decision surface.
- Moved updater version comparison and session transitions into a focused pure owner and tightened the `available -> downloaded -> installing/restarting` state flow.
### Tests and Quality
- Added Rust, Electron, protocol, and Renderer regressions for host capabilities, dynamic tools, permission profiles, current time, AgentControl, parent-owned threads, v2 config warnings, and model routing.
- Added real Electron Gate B evidence covering preload/IPC, `app_server_handle_json_lines`, App Server, RuntimeCore, canonical read models, visible GUI state, and cold-restart identity.
- Strengthened guards for the app-server-client browser subpath, generated protocols, five-locale resources, legacy surfaces, and the current bridge.
- Added Rust, Electron, protocol, Renderer, and script regressions for Skills refresh, typed errors, plan updates, MCP OAuth, Provider model catalogs, and updater semantics.
- Expanded the current Agent fixture with typed-error success/failure, live and restored plan updates, Skills runtime refresh, and real Electron/preload/IPC/App Server/read-model identity checks.
- Audited the core user flow across startup, Provider/model selection, Agent chat, stop-and-continue, history restoration, About, and updater states, with zero production mock fallbacks.
### Documentation
- Updated architecture sources for host capabilities, the canonical DynamicTool payload, v2 config warnings, media transient retirement, and Lime Hub capability provenance.
- Updated Refactor v2 V2-04/V2-05 progress, Gate B evidence, the Windows runtime model-routing plan, and the Soul output-surface roadmap.
- Updated architecture and execution sources for App Server v2 notifications, Skill catalog invalidation, MCP OAuth, typed error/plan recovery, and updater/model-catalog behavior.
- Recorded the Windows model-catalog and updater reliability work, core user-flow E2E audit, and the current Refactor v2 V2-05 status.
### Other
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.117.0`.
- Bumped the root app, CLI npm package, Rust workspace, and lockfile versions to `1.118.0`.
- The Windows Squirrel packaged Gate B for N-1 discovery, download, restart, and installation still requires a real Windows host; macOS Electron evidence does not replace that platform check.
**Full changes**: `v1.116.0` -> `v1.117.0`
**Full changes**: `v1.117.0` -> `v1.118.0`
+22 -20
View File
@@ -1,39 +1,41 @@
## Lime v1.117.0
## Lime v1.118.0
### 新功能
- 完成 `currentTime/read``item/permissions/requestApproval``item/tool/call` 三项 Host capability:系统时间读取、权限提升和桌面动态工具调用都经 Electron Host、App Server JSON-RPC 与 RuntimeCore 的同一条产品链闭环
- 动态工具调用升级为 canonical Thread Item,完整保留 namespace、tool、原始参数、有序文本/图片/音频输出、状态和耗时;恢复会话后仍能可靠呈现工具生命周期
- Multi-Agent 对话补齐六项 AgentControl 工具、typed wait state、子 Agent 活动投影和冷重启恢复;parent-owned 子线程现在明确禁止直接输入
- Lime Hub 可通过 bundled canonical registry 恢复已知模型的执行能力,并新增 Agnes 2.5 Flash/Pro canonical catalog;未知模型仍保持 fail-closed
- Skill 目录现在会在默认目录新增、修改或删除 Skill,以及 Lime 本地 Skill 管理成功后自动刷新;Composer 始终重新读取 App Server 的 current `skill/list`,无需手动刷新或重启应用
- Agent 执行计划接入 typed `turn/plan/updated` 通知,实时对话与冷恢复会展示同一份 checklist;失败或畸形的计划快照不会覆盖最近一次有效状态
- Agent 错误升级为 typed retry/terminal 链路:重试中、最终失败与权威 Turn terminal 各自有明确状态,并可从 canonical read model 恢复
- MCP OAuth 登录完成态改由 App Server v2 typed notification 直接投影到 Renderer,统一登录窗口、MCP 管理状态与事件 identity
### 修复
- 修复干净环境无法解析 `@limecloud/app-server-client/browser`、图片任务 fixture 使用错误 Chat 模型、模型列表 generation 基线漂移,以及非法模型参数返回错误 JSON-RPC code 的问题
- 修复模型选择器展示 RuntimeCore 无法执行的 inferred-only 模型、live provider metadata 被本地 hint 覆盖,以及 Windows Lime Hub 已保存模型无法进入首回合的问题
- 修复媒体预览依赖 transient chunk/completed 通知和 Renderer live-drain helper 的双轨行为;所有分段读取现在先校验 offset、length、digest 与大小再更新进度
- 修复初始会话和子 Agent 导航、canonical Item 读取、事件序列门控与待处理交互恢复中的边界问题。
- 修复 Windows 用户已显式配置的 Provider 模型因缺少权威 capability 来源而不出现在聊天模型选择器中的问题;仅靠启发式推断且不可执行的模型继续 fail closed
- 修复 Electron updater 将同版本、旧版本、无效版本或缺失版本误报为可安装更新的问题;只有严格更高的语义版本进入下载和安装流程
- 修复更新失败、已是最新和发现新版本共用提示的问题,并统一侧边栏、关于页与更新通知页的下载、重试和重启安装状态
- 修复 Provider 路由不可执行错误在发送入口和 runtime 事件链中出现不一致提示,以及部分独立页面缺少全局 toast 宿主的问题。
- 修复 Provider 添加流程在尚未保存配置时显示“完成添加”,并在已保存后正确返回可用模型上下文。
### 优化与重构
-`configWarning` 从 v0 DTO/schema 迁移到唯一的 typed v2 ownerRenderer 只投影解码后的全局告警,并提供五语言去重提示
- 删除 v0 配置告警、TextPosition/TextRange schema、媒体 transient notification 消费者、旧协议 facade 与重复预览同步模块,不保留生产兼容包装
- 收紧请求权限 profile、沙箱命令、动态工具路由、provider history 与 read model 的类型边界;保留名、schema 冲突、身份错配、重复或迟到响应全部 fail-closed
- Skill 安装目录改用后端权威 `localDirectoryPath`Electron 对目录使用 `openPath`、对文件使用 `showItemInFolder`
-`skills/changed``error``turn/plan/updated``mcpServer/oauthLogin/completed` 收敛到 App Server v2 protocol、schema、generated client 和 Renderer typed event bus,不新增生产 mock 或第二事件 owner
- Skill catalog cache 统一由 `lime-skills` 失效;默认 Skill roots 使用递归 watcher 与节流通知,重连和 remount 仍主动读取 current catalog
- canonical `update_plan` 工具项继续作为恢复事实源,但不再生成重复 Plan Item、工具卡或决策面板
- updater 的版本比较与会话状态拆到独立纯 owner,收紧 `available -> downloaded -> installing/restarting` 状态转换
### 测试与质量
- 新增 Host capability、动态工具、权限 profile、current time、AgentControl、parent-owned thread、配置告警 v2 与模型路由的 Rust、Electron、协议Renderer 回归。
- 补充真实 Electron Gate B 证据,覆盖 preload/IPC、`app_server_handle_json_lines`、App Server、RuntimeCore、canonical read model、GUI 可见状态和冷重启 identity。
- 加强 app-server-client browser 子路径、协议生成物、五语言资源、legacy surface 和 current bridge 的守卫
- 新增 Skills 自动刷新、typed error、执行计划、MCP OAuth、Provider 模型目录与 updater 语义的 Rust、Electron、协议Renderer 和脚本回归。
- 扩展 current Agent fixture,覆盖 typed error 成功/失败、计划实时更新与冷恢复、Skills runtime 自动刷新,以及真实 Electron/preload/IPC/App Server/read model identity。
- 核心用户流程审计覆盖启动、Provider/模型选择、Agent 对话、停止后继续、历史恢复、关于页与更新状态;生产 mock fallback 命中为零
### 文档
- 更新 Host capability、DynamicTool canonical payload、配置告警 v2、媒体 transient retirement 与 Lime Hub capability provenance 的架构事实源。
- 更新 Refactor v2 V2-04/V2-05 进度、Gate B evidence、Windows runtime 模型路由计划和 Soul 输出面路线图
- 更新 App Server v2 notification、Skills catalog invalidation、MCP OAuth、typed error/plan recovery 与 updater/模型目录的架构和执行事实源。
- 记录 Windows 模型目录与更新可靠性、核心用户主流程 E2E 审计,以及 Refactor v2 V2-05 当前完成情况
### 其他
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.117.0`
- 将根应用、CLI npm 包、Rust workspace 与锁文件版本统一提升到 `1.118.0`
- Windows Squirrel 从 N-1 版本发现、下载、重启安装的 packaged Gate B 仍需 Windows 实机验收;macOS Electron 证据不替代该平台验证。
**完整变更**: `v1.116.0` -> `v1.117.0`
**完整变更**: `v1.117.0` -> `v1.118.0`
+101 -31
View File
@@ -74,6 +74,17 @@ vi.mock("./electronRuntime", () => ({
import { ElectronUpdateHost } from "./updateHost";
function emitDownloadedUpdate(releaseName: string | undefined): void {
autoUpdaterEmit(
"update-downloaded",
{},
"release notes",
releaseName,
new Date("2026-07-17T00:00:00.000Z"),
"https://updates.limecloud.com/Lime-update.zip",
);
}
describe("ElectronUpdateHost", () => {
beforeEach(() => {
appState.isPackaged = false;
@@ -196,22 +207,18 @@ describe("ElectronUpdateHost", () => {
});
const host = new ElectronUpdateHost(vi.fn());
await expect(host.invoke("check_for_updates")).resolves.toEqual(
expect.objectContaining({ hasUpdate: true }),
);
const checkSession = host.invoke("check_for_updates");
await Promise.resolve();
expect(checkForUpdatesMock).toHaveBeenCalledTimes(1);
await expect(host.invoke("get_update_install_session")).resolves.toEqual(
expect.objectContaining({ stage: "downloading" }),
);
const installSession = host.invoke("start_update_install_session");
autoUpdaterEmit(
"update-downloaded",
{},
"release notes",
"1.61.0",
new Date("2026-07-17T00:00:00.000Z"),
"https://updates.limecloud.com/Lime-1.61.0.zip",
emitDownloadedUpdate("1.61.0");
await expect(checkSession).resolves.toEqual(
expect.objectContaining({ hasUpdate: true, latest: "1.61.0" }),
);
await expect(installSession).resolves.toEqual(
@@ -236,12 +243,14 @@ describe("ElectronUpdateHost", () => {
});
const host = new ElectronUpdateHost(vi.fn());
await expect(
host.invoke("check_for_updates", { automatic: true }),
).resolves.toEqual(expect.objectContaining({ hasUpdate: true }));
await expect(
host.invoke("check_for_updates", { automatic: true }),
).resolves.toEqual(expect.objectContaining({ hasUpdate: true }));
const firstCheck = host.invoke("check_for_updates", { automatic: true });
const secondCheck = host.invoke("check_for_updates", { automatic: true });
emitDownloadedUpdate("1.61.0");
await expect(Promise.all([firstCheck, secondCheck])).resolves.toEqual([
expect.objectContaining({ hasUpdate: true }),
expect.objectContaining({ hasUpdate: true }),
]);
expect(checkForUpdatesMock).toHaveBeenCalledTimes(1);
});
@@ -255,28 +264,89 @@ describe("ElectronUpdateHost", () => {
});
const host = new ElectronUpdateHost(vi.fn());
await host.invoke("check_for_updates");
const checkSession = host.invoke("check_for_updates");
const firstInstall = host.invoke("start_update_install_session");
const secondInstall = host.invoke("start_update_install_session");
autoUpdaterEmit(
"update-downloaded",
{},
"release notes",
"1.61.0",
new Date("2026-07-17T00:00:00.000Z"),
"https://updates.limecloud.com/Lime-1.61.0.zip",
);
emitDownloadedUpdate("1.61.0");
await expect(Promise.all([firstInstall, secondInstall])).resolves.toEqual(
[
expect.objectContaining({ stage: "restarting" }),
expect.objectContaining({ stage: "restarting" }),
],
);
await expect(
Promise.all([checkSession, firstInstall, secondInstall]),
).resolves.toEqual([
expect.objectContaining({ hasUpdate: true }),
expect.objectContaining({ stage: "restarting" }),
expect.objectContaining({ stage: "restarting" }),
]);
await vi.advanceTimersByTimeAsync(250);
expect(quitAndInstallMock).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it.each(["1.60.0", "v1.60.0", "1.59.0"])(
"候选版本 %s 不高于当前版本时不得进入安装",
async (candidateVersion) => {
vi.useFakeTimers();
try {
appState.isPackaged = true;
checkForUpdatesMock.mockImplementation(() => {
autoUpdaterEmit("update-available");
});
const host = new ElectronUpdateHost(vi.fn());
const checkSession = host.invoke("check_for_updates");
const installSession = host.invoke("start_update_install_session");
emitDownloadedUpdate(candidateVersion);
await expect(checkSession).resolves.toEqual(
expect.objectContaining({
hasUpdate: false,
latest: candidateVersion.replace(/^v/i, ""),
}),
);
await expect(installSession).resolves.toEqual(
expect.objectContaining({ stage: "up_to_date" }),
);
await vi.advanceTimersByTimeAsync(250);
expect(quitAndInstallMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
},
);
it.each([undefined, "latest"])(
"候选版本 %s 无效时应终止更新且不得安装",
async (candidateVersion) => {
vi.useFakeTimers();
try {
appState.isPackaged = true;
checkForUpdatesMock.mockImplementation(() => {
autoUpdaterEmit("update-available");
});
const host = new ElectronUpdateHost(vi.fn());
const checkSession = host.invoke("check_for_updates");
const installSession = host.invoke("start_update_install_session");
emitDownloadedUpdate(candidateVersion);
await expect(checkSession).resolves.toEqual(
expect.objectContaining({
hasUpdate: false,
error: "更新清单未提供有效的候选版本",
}),
);
await expect(installSession).resolves.toEqual(
expect.objectContaining({
stage: "failed",
latestVersion: null,
}),
);
await vi.advanceTimersByTimeAsync(250);
expect(quitAndInstallMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
},
);
});
+139 -145
View File
@@ -1,4 +1,9 @@
import { app, autoUpdater } from "./electronRuntime";
import {
assessUpdateCandidate,
normalizeUpdateVersion,
type UpdateCandidateAssessment,
} from "./updateVersion";
type UpdateInstallStage =
| "idle"
@@ -84,7 +89,6 @@ export class ElectronUpdateHost {
readonly #windowController: UpdateWindowController;
#configured = false;
#checkPromise: Promise<UpdateInfo | null> | null = null;
#downloadPromise: Promise<UpdateInfo> | null = null;
#session: UpdateInstallSession;
#latestInfo: UpdateInfo | null = null;
@@ -156,27 +160,40 @@ export class ElectronUpdateHost {
};
}
this.#configure();
if (this.#hasPendingUpdate()) {
if (this.#hasConfirmedUpdate()) {
return this.#currentVersionInfo();
}
this.#setSession({
stage: "checking",
message: "正在检查更新",
isActive: true,
});
if (!this.#checkPromise) {
this.#setSession({
stage: "checking",
latestVersion: null,
downloadUrl: null,
error: null,
message: "正在检查更新",
isActive: true,
completedAt: null,
});
}
try {
const info = await this.#checkForUpdatesOnce();
this.#latestInfo = info;
const hasUpdate = Boolean(info && info.version !== app.getVersion());
const assessment = this.#assessUpdateInfo(info);
if (assessment.status === "invalid") {
throw new Error("更新清单未提供有效的候选版本");
}
const hasUpdate = assessment.status === "newer";
this.#setSession({
stage: hasUpdate ? "downloading" : "up_to_date",
latestVersion: info?.version ?? null,
message: hasUpdate ? "发现新版本,正在下载更新" : "当前已是最新版本",
isActive: hasUpdate,
completedAt: hasUpdate ? null : Date.now(),
stage: hasUpdate ? "completed" : "up_to_date",
latestVersion: assessment.version,
downloadUrl: hasUpdate ? this.#downloadUrl(info) : null,
percent: hasUpdate ? 1 : 0,
error: null,
message: hasUpdate ? "更新已下载,准备安装" : "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
});
return this.#versionInfo(info, hasUpdate);
return this.#versionInfo(info);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.#setSession({
@@ -204,13 +221,6 @@ export class ElectronUpdateHost {
}
this.#configure();
if (this.#session.stage === "downloading") {
return {
success: true,
message: "更新下载已开始",
filePath: null,
};
}
if (this.#session.stage === "completed") {
return {
success: true,
@@ -226,50 +236,19 @@ export class ElectronUpdateHost {
};
}
this.#setSession({
stage: "checking",
latestVersion: this.#latestInfo?.version ?? null,
message: "正在检查并下载更新",
isActive: true,
});
try {
const info = await this.#checkForUpdatesOnce();
if (!info) {
this.#setSession({
stage: "up_to_date",
message: "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
});
return {
success: false,
message: "当前已是最新版本",
filePath: null,
};
}
this.#latestInfo = info;
this.#setSession({
stage: "downloading",
latestVersion: info.version ?? null,
message: "更新下载已开始",
isActive: true,
});
const versionInfo = await this.checkForUpdates();
if (versionInfo.hasUpdate) {
return {
success: true,
message: "更新下载已开始",
message: "更新下载,准备安装",
filePath: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.#setSession({
stage: "failed",
error: message,
message,
isActive: false,
completedAt: Date.now(),
});
return { success: false, message };
}
return {
success: false,
message: versionInfo.error || "当前已是最新版本",
filePath: null,
};
}
async startInstallSession(): Promise<UpdateInstallSession> {
@@ -292,30 +271,48 @@ export class ElectronUpdateHost {
return this.#session;
}
if (this.#session.stage !== "completed") {
if (this.#session.stage !== "downloading") {
if (!this.#checkPromise) {
this.#setSession({
stage: "checking",
latestVersion: null,
downloadUrl: null,
error: null,
message: "正在检查并下载更新",
isActive: true,
completedAt: null,
});
const info = await this.#checkForUpdatesOnce();
if (!info) {
this.#setSession({
stage: "up_to_date",
message: "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
});
return this.#session;
}
}
await this.#waitForDownloadedUpdate();
const info = await this.#checkForUpdatesOnce();
this.#latestInfo = info;
const assessment = this.#assessUpdateInfo(info);
if (assessment.status === "invalid") {
throw new Error("更新清单未提供有效的候选版本");
}
if (assessment.status !== "newer") {
this.#setSession({
stage: "up_to_date",
latestVersion: assessment.version,
downloadUrl: null,
percent: 0,
error: null,
message: "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
});
return this.#session;
}
}
if (this.#isInstallingUpdate()) {
return this.#session;
}
const installAssessment = this.#assessUpdateInfo(this.#latestInfo);
if (installAssessment.status !== "newer") {
throw new Error("更新版本未通过安装前校验");
}
this.#setSession({
stage: "restarting",
latestVersion: installAssessment.version,
error: null,
message: "正在重启并安装更新",
isActive: true,
canCloseWindow: false,
@@ -350,18 +347,25 @@ export class ElectronUpdateHost {
),
});
autoUpdater.on("update-available", () => {
this.#latestInfo = {};
this.#latestInfo = null;
this.#setSession({
stage: "downloading",
latestVersion: null,
downloadUrl: null,
message: "发现新版本,正在下载更新",
error: null,
message: "正在下载并验证更新",
isActive: true,
completedAt: null,
});
});
autoUpdater.on("update-not-available", () => {
this.#latestInfo = null;
this.#setSession({
stage: "up_to_date",
latestVersion: null,
downloadUrl: null,
percent: 0,
error: null,
message: "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
@@ -376,16 +380,7 @@ export class ElectronUpdateHost {
releaseDate,
updateURL,
);
this.#latestInfo = info;
this.#setSession({
stage: "completed",
latestVersion: info.version ?? null,
downloadUrl: updateURL || null,
percent: 1,
message: "更新已下载,准备安装",
isActive: false,
completedAt: Date.now(),
});
this.#applyDownloadedUpdate(info);
},
);
autoUpdater.on("error", (error) => {
@@ -419,12 +414,14 @@ export class ElectronUpdateHost {
return app.isPackaged;
}
#versionInfo(info: UpdateInfo | null, hasUpdate: boolean): VersionInfo {
#versionInfo(info: UpdateInfo | null): VersionInfo {
const assessment = this.#assessUpdateInfo(info);
const hasUpdate = assessment.status === "newer";
return {
current: app.getVersion(),
latest: info?.version ?? null,
latest: assessment.version,
hasUpdate,
downloadUrl: this.#downloadUrl(info),
downloadUrl: hasUpdate ? this.#downloadUrl(info) : null,
releaseNotes:
typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
releaseNotesUrl: null,
@@ -434,22 +431,15 @@ export class ElectronUpdateHost {
}
#currentVersionInfo(): VersionInfo {
const hasUpdate = Boolean(
this.#latestInfo && this.#latestInfo.version !== app.getVersion(),
);
return this.#versionInfo(this.#latestInfo, hasUpdate);
return this.#versionInfo(this.#latestInfo);
}
#downloadUrl(info: UpdateInfo | null): string | null {
return info?.updateURL ?? null;
}
#hasPendingUpdate(): boolean {
return (
this.#session.stage === "downloading" ||
this.#session.stage === "completed" ||
this.#isInstallingUpdate()
);
#hasConfirmedUpdate(): boolean {
return this.#session.stage === "completed" || this.#isInstallingUpdate();
}
#isInstallingUpdate(): boolean {
@@ -464,48 +454,6 @@ export class ElectronUpdateHost {
return await this.#checkPromise;
}
const promise = new Promise<UpdateInfo | null>((resolve, reject) => {
const cleanup = () => {
autoUpdater.removeListener("update-available", onAvailable);
autoUpdater.removeListener("update-not-available", onNotAvailable);
autoUpdater.removeListener("error", onError);
};
const onAvailable = () => {
cleanup();
resolve(this.#latestInfo ?? {});
};
const onNotAvailable = () => {
cleanup();
resolve(null);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
autoUpdater.once("update-available", onAvailable);
autoUpdater.once("update-not-available", onNotAvailable);
autoUpdater.once("error", onError);
try {
autoUpdater.checkForUpdates();
} catch (error) {
cleanup();
reject(error);
}
}).finally(() => {
this.#checkPromise = null;
});
this.#checkPromise = promise;
return await promise;
}
async #waitForDownloadedUpdate(): Promise<UpdateInfo> {
if (this.#session.stage === "completed" && this.#latestInfo) {
return this.#latestInfo;
}
if (this.#downloadPromise) {
return await this.#downloadPromise;
}
const promise = new Promise<UpdateInfo>((resolve, reject) => {
const cleanup = () => {
autoUpdater.removeListener("update-downloaded", onDownloaded);
autoUpdater.removeListener("update-not-available", onNotAvailable);
@@ -525,28 +473,74 @@ export class ElectronUpdateHost {
releaseDate,
updateURL,
);
this.#latestInfo = info;
if (this.#assessUpdateInfo(info).status === "invalid") {
reject(new Error("更新清单未提供有效的候选版本"));
return;
}
resolve(info);
};
const onNotAvailable = () => {
cleanup();
reject(new Error("当前已是最新版本"));
resolve(null);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
autoUpdater.once("update-downloaded", onDownloaded);
autoUpdater.once("update-not-available", onNotAvailable);
autoUpdater.once("error", onError);
try {
autoUpdater.checkForUpdates();
} catch (error) {
cleanup();
reject(error);
}
}).finally(() => {
this.#downloadPromise = null;
this.#checkPromise = null;
});
this.#downloadPromise = promise;
this.#checkPromise = promise;
return await promise;
}
#assessUpdateInfo(info: UpdateInfo | null): UpdateCandidateAssessment {
if (!info) {
return { status: "not_newer", version: null };
}
return assessUpdateCandidate(info.version, app.getVersion());
}
#applyDownloadedUpdate(info: UpdateInfo): void {
const assessment = this.#assessUpdateInfo(info);
if (assessment.status === "invalid") {
this.#latestInfo = null;
this.#setSession({
stage: "failed",
latestVersion: null,
downloadUrl: null,
percent: 0,
error: "更新清单未提供有效的候选版本",
message: "更新清单未提供有效的候选版本",
isActive: false,
completedAt: Date.now(),
});
return;
}
this.#latestInfo = info;
const hasUpdate = assessment.status === "newer";
this.#setSession({
stage: hasUpdate ? "completed" : "up_to_date",
latestVersion: assessment.version,
downloadUrl: hasUpdate ? this.#downloadUrl(info) : null,
percent: hasUpdate ? 1 : 0,
error: null,
message: hasUpdate ? "更新已下载,准备安装" : "当前已是最新版本",
isActive: false,
completedAt: Date.now(),
});
}
#setSession(update: Partial<UpdateInstallSession>): void {
this.#session = {
...this.#session,
@@ -657,7 +651,7 @@ function buildUpdateInfo(
releaseName,
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : undefined,
updateURL,
version: releaseName,
version: normalizeUpdateVersion(releaseName) ?? undefined,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { assessUpdateCandidate, normalizeUpdateVersion } from "./updateVersion";
describe("updateVersion", () => {
it.each([
["1.117.0", "1.117.0"],
["v1.117.0", "1.117.0"],
["1.116.0", "1.117.0"],
])("候选版本 %s 相对当前版本 %s 不应视为更新", (candidate, current) => {
expect(assessUpdateCandidate(candidate, current)).toEqual({
status: "not_newer",
version: candidate.replace(/^v/i, ""),
});
});
it("只应把严格更高版本视为可安装更新", () => {
expect(assessUpdateCandidate("1.118.0", "1.117.0")).toEqual({
status: "newer",
version: "1.118.0",
});
});
it.each([undefined, "", "latest", "1.117", "1.117.0-01"])(
"无效候选版本 %s 应 fail closed",
(candidate) => {
expect(assessUpdateCandidate(candidate, "1.117.0")).toEqual({
status: "invalid",
version: null,
});
},
);
it("应遵循 SemVer 预发布版本优先级并忽略构建元数据", () => {
expect(assessUpdateCandidate("1.118.0-rc.1", "1.118.0-beta.9").status).toBe(
"newer",
);
expect(assessUpdateCandidate("1.118.0", "1.118.0-rc.1").status).toBe(
"newer",
);
expect(
assessUpdateCandidate("1.118.0+build.2", "1.118.0+build.1").status,
).toBe("not_newer");
expect(normalizeUpdateVersion("V1.118.0-rc.1+build.2")).toBe(
"1.118.0-rc.1+build.2",
);
});
});
+127
View File
@@ -0,0 +1,127 @@
interface ParsedUpdateVersion {
core: [string, string, string];
normalized: string;
prerelease: string[];
}
export type UpdateCandidateStatus = "invalid" | "newer" | "not_newer";
export interface UpdateCandidateAssessment {
status: UpdateCandidateStatus;
version: string | null;
}
const UPDATE_VERSION_PATTERN =
/^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/i;
function parseUpdateVersion(
value: string | null | undefined,
): ParsedUpdateVersion | null {
const match = UPDATE_VERSION_PATTERN.exec(value?.trim() ?? "");
if (!match) {
return null;
}
const prerelease = match[4]?.split(".") ?? [];
if (
prerelease.some(
(identifier) =>
/^\d+$/.test(identifier) &&
identifier.length > 1 &&
identifier.startsWith("0"),
)
) {
return null;
}
const core: [string, string, string] = [match[1], match[2], match[3]];
const prereleaseSuffix =
prerelease.length > 0 ? `-${prerelease.join(".")}` : "";
const buildSuffix = match[5] ? `+${match[5]}` : "";
return {
core,
normalized: `${core.join(".")}${prereleaseSuffix}${buildSuffix}`,
prerelease,
};
}
function compareNumericIdentifiers(left: string, right: string): number {
if (left.length !== right.length) {
return left.length > right.length ? 1 : -1;
}
if (left === right) {
return 0;
}
return left > right ? 1 : -1;
}
function comparePrerelease(left: string[], right: string[]): number {
if (left.length === 0 || right.length === 0) {
if (left.length === right.length) {
return 0;
}
return left.length === 0 ? 1 : -1;
}
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const leftIdentifier = left[index];
const rightIdentifier = right[index];
if (leftIdentifier === undefined || rightIdentifier === undefined) {
return leftIdentifier === undefined ? -1 : 1;
}
if (leftIdentifier === rightIdentifier) {
continue;
}
const leftNumeric = /^\d+$/.test(leftIdentifier);
const rightNumeric = /^\d+$/.test(rightIdentifier);
if (leftNumeric && rightNumeric) {
return compareNumericIdentifiers(leftIdentifier, rightIdentifier);
}
if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1;
}
return leftIdentifier > rightIdentifier ? 1 : -1;
}
return 0;
}
function compareParsedVersions(
left: ParsedUpdateVersion,
right: ParsedUpdateVersion,
): number {
for (let index = 0; index < left.core.length; index += 1) {
const result = compareNumericIdentifiers(
left.core[index],
right.core[index],
);
if (result !== 0) {
return result;
}
}
return comparePrerelease(left.prerelease, right.prerelease);
}
export function normalizeUpdateVersion(
value: string | null | undefined,
): string | null {
return parseUpdateVersion(value)?.normalized ?? null;
}
export function assessUpdateCandidate(
candidateVersion: string | null | undefined,
currentVersion: string | null | undefined,
): UpdateCandidateAssessment {
const candidate = parseUpdateVersion(candidateVersion);
const current = parseUpdateVersion(currentVersion);
if (!candidate || !current) {
return { status: "invalid", version: null };
}
return {
status:
compareParsedVersions(candidate, current) > 0 ? "newer" : "not_newer",
version: candidate.normalized,
};
}
+36
View File
@@ -1699,3 +1699,39 @@ Architecture impact: major because a live notification and recovery surface move
to the v2 protocol/projector/Renderer boundary. The product direction remains Electron Desktop Host -> App Server
JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection -> GUI. Architecture diagram updated: this section and the App
Server notification boundary above. Responsible developer confirmation: root, 2026-07-31.
## 28. Skills Changed Catalog Invalidation Owner
Skill catalog invalidation uses one current catalog owner and one typed transient notification path:
```text
default Skill roots create / modify / remove
or successful Lime Skill catalog mutation
-> invalidate lime-skills executable snapshot cache
-> App Server v2 skills/changed {}
-> app_server_handle_json_lines notification drain
-> Renderer typed notification bus
-> current skill/list
-> Composer Skill catalog projection and GUI refresh
```
The App Server watches only default Skill roots. Existing roots are recursive watches; roots created after startup are
reconciled and watched. Filesystem create, modify and remove events invalidate the executable snapshot and broadcast at
most once per ten-second throttle window. Successful Lime catalog mutations invalidate the same cache and attach the
same typed notification at the processor boundary; failed mutations and mutations for other apps do not notify.
`skill/list` is the sole executable catalog read for Composer. The Renderer API reuses the existing typed executable
Skill decoder and applies a pure GUI projection. `skillManagement/list` retains its management-center semantics and does
not become a second Composer catalog owner. Reconnect or remount performs a fresh list independently of notification
delivery. `skills/changed` carries the strict empty object only and is a process-level transient invalidation; it does not
create a Thread/Turn/Item, durable event, persistence record or replay requirement.
The v2 protocol/schema/generated client, App Server watcher and successful mutation producer, `lime-skills` cache
invalidation, Renderer typed event bus and current `skill/list` refresh are `current`. No `compat` path exists.
`skills/extraRoots/set`, a second catalog/read model, durable notification replay and production mock fallback were not
added and are `dead / forbidden-to-restore` as alternate implementations.
Architecture impact: major because this adds a public notification producer and GUI invalidation boundary while keeping
the product direction unchanged: Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore/domain owner -> GUI.
Architecture diagram updated: this section and the Skill catalog path above. Responsible developer confirmation: root,
2026-07-31.
+15 -15
View File
@@ -18,13 +18,13 @@ Renderer typed gateway
## Owner 判定
| 需求 | 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 |
| 需求 | 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 |
禁止为业务调用新增第二个 Electron 后端、renderer mock fallback、临时 DevBridge 命令或 legacy wrapper。生产失败必须显式失败;mock 仅在测试夹具中显式注入。
@@ -43,13 +43,13 @@ Renderer typed gateway
## 验证入口
| 风险 | 最低验证 |
| --- | --- |
| Typed client / protocol | `npm run test:contracts` |
| Rust domain | `npm run test:rust:related -- <paths...>` |
| Desktop bridge | `npm run test:contracts` + `npm run verify:gui-smoke` |
| Agent 主链 | `npm run smoke:agent-runtime-current-fixture` |
| 真实桌面闭环 | Gate B Electron fixture / GUI smoke |
| 风险 | 最低验证 |
| ----------------------- | ----------------------------------------------------- |
| Typed client / protocol | `npm run test:contracts` |
| Rust domain | `npm run test:rust:related -- <paths...>` |
| Desktop bridge | `npm run test:contracts` + `npm run verify:gui-smoke` |
| Agent 主链 | `npm run smoke:agent-runtime-current-fixture` |
| 真实桌面闭环 | Gate B Electron fixture / GUI smoke |
具体质量选择见 [quality-workflow.md](quality-workflow.md)。
@@ -59,7 +59,7 @@ MCP 管理、发现和调用只允许走:
`src/lib/api/mcp.ts -> AppServerClient.request(...) -> app_server_handle_json_lines -> App Server JSON-RPC -> lime-rs/crates/mcp`
current method 为 `mcpServer/list``mcpServerStatus/list``mcpServer/create``mcpServer/update``mcpServer/delete``mcpServer/enabled/set``mcpServer/importFromApp``mcpServer/syncAllToLive``mcpServer/oauth/login``mcpServer/start``mcpServer/stop``mcpTool/list``mcpTool/listForContext``mcpTool/search``mcpTool/call``mcpTool/callWithCaller``mcpPrompt/list``mcpPrompt/get``mcpResource/list``mcpResource/read``mcpResource/subscribe``mcpResource/unsubscribe`。事件 `mcp:resources_updated``mcp:resource_updated` 必须经真实 MCP manager / Desktop Host event bridge 投影;浏览器模式不得静默退回 mock event fallback。
current method 为 `mcpServer/list``mcpServerStatus/list``mcpServer/create``mcpServer/update``mcpServer/delete``mcpServer/enabled/set``mcpServer/importFromApp``mcpServer/syncAllToLive``mcpServer/oauth/login``mcpServer/oauthLogin/completed``mcpServer/start``mcpServer/stop``mcpTool/list``mcpTool/listForContext``mcpTool/search``mcpTool/call``mcpTool/callWithCaller``mcpPrompt/list``mcpPrompt/get``mcpResource/list``mcpResource/read``mcpResource/subscribe``mcpResource/unsubscribe`OAuth 完成态只允许由 App Server v2 typed notification `mcpServer/oauthLogin/completed` 投影给 Renderer,不再经过 Desktop event。事件 `mcp:resources_updated``mcp:resource_updated` 必须经真实 MCP manager / Desktop Host event bridge 投影;浏览器模式不得静默退回 mock event fallback。
live evidence 仅通过 `smoke:mcp-current -- --allow-live-provider` 显式开启,且需要 `LIME_MCP_LIVE_SERVER_URL`。该 URL 不得包含 username、password、query 或 hash;认证只能引用环境变量名,不允许 inline secret。`network-invoke.json` 仅可记录脱敏的 host、环境变量名、header 名、范围和工具/资源摘要。
+4 -2
View File
@@ -20,7 +20,7 @@ src/lib/api/
src/hooks/
├── useMcp.ts # MCP runtime 状态与事件刷新
└── useMcpEvents.ts # Desktop Host MCP event bridge
└── useMcpEvents.ts # MCP Desktop events 与 App Server typed notification bridge
src/components/mcp/
├── McpPage.tsx # 设置页入口
@@ -54,6 +54,7 @@ Current MCP method 固定为:
- `mcpServer/importFromApp`
- `mcpServer/syncAllToLive`
- `mcpServer/oauth/login`
- `mcpServer/oauthLogin/completed`
- `mcpServer/start`
- `mcpServer/stop`
- `mcpTool/list`
@@ -80,7 +81,7 @@ Current MCP method 固定为:
- `stdio``streamable_http` 都由 `lime-rs/crates/mcp` 管理。
- HTTP header 只允许来自配置中的安全字段或环境变量引用;inline secret、非法 header、缺失 env var 必须 fail closed。
- OAuth 登录走 `mcpServer/oauth/login`,使用系统浏览器 current 网关打开授权 URLcallback 完成后通过 `mcp:oauth_completed` 刷新前端
- OAuth 登录走 `mcpServer/oauth/login`,使用系统浏览器 current 网关打开授权 URLcallback 成功或失败后由 App Server 发布 `mcpServer/oauthLogin/completed` typed notificationRenderer 经 typed event bus 刷新 server/tool 状态。旧 `mcp:oauth_completed` Desktop event 为 `dead / deleted / forbidden-to-restore`
- OAuth token store 使用 app data runtime 下的 versioned credential envelope,按 server name + URL 隔离。
- 显式 `oauth.client_id` / `oauth_resource` 目前仍按 unsupported fail-closed,不能伪装成可用。
@@ -119,6 +120,7 @@ MCP smoke
npm run smoke:mcp-current
npm run smoke:mcp-current -- --allow-write-fixture
npm run smoke:mcp-current -- --allow-oauth-fixture
npm run smoke:mcp-oauth-notification-electron-fixture
npm run smoke:mcp-current -- --allow-live-provider
```
+2
View File
@@ -51,6 +51,8 @@
- 旧 Tauri wrapper 清理入口已合并到生产命令 current 迁移计划;历史 quick cleanup 队列和机械 inventory 文件已删除,不再作为 current 执行计划入口。
- Provider 模型能力 taxonomy 进度日志:`internal/exec-plans/provider-model-taxonomy-progress.md`
- Windows Lime Hub 模型路由修复:`internal/exec-plans/windows-runtime-model-route-repair-plan.md`
- Windows 模型目录与更新可靠性闭环:`internal/exec-plans/windows-model-catalog-and-updater-reliability.md`
- Lime 核心用户主流程 E2E 审计:`internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md`
- MCP 现代化进度:`internal/exec-plans/mcp-modernization-progress.md`
- Right Surface 统一承载实施进度:`internal/exec-plans/right-surface-implementation-progress.md`
- Soul Style 输出面收敛计划:`internal/exec-plans/soul-style-output-surface-convergence-plan.md`
@@ -0,0 +1,193 @@
# Lime 核心用户主流程 E2E 审计
状态:本轮范围完成;Windows packaged updater 待实机验收
日期:2026-07-31
## 目标与用户闭环
普通桌面用户从启动 Lime 开始,完成 Provider / 模型确认,创建对话并完成一轮响应,再验证停止后继续、历史恢复、关键设置与更新状态。完成标准不是页面可打开,而是 GUI 状态、Electron IPC、App Server current JSON-RPC 与 read model identity 一致,生产 mock fallback 命中为零。
## 范围
- 启动、首页加载、主导航、控制台基线。
- `设置 -> AI 服务商` 的 Provider 列表、显式模型可见性与返回聊天后的模型选择。
- Claw 新会话、用户消息、流式 assistant 输出与真实 terminal。
- 停止、输入框立即恢复、同一会话继续下一轮。
- 最近对话 / 历史详情恢复与 Electron 重启后的 read model。
- `设置 -> 关于` 的当前版本与更新状态语义。
不做范围:不修改真实用户 Provider 凭证;不保存配置、API key、完整 prompt 或 provider 响应;不把 external fixture 当作 live Provider;不宣称 macOS Gate B 能替代 Windows Squirrel packaged 验收;不覆盖社媒内容、素材、浏览器自动化、MCP、Agent Apps 和所有 `@` 命令的产品矩阵。
## 写集
- 本执行记录。
- `src/components/api-key-provider/ModelAddPanel.tsx`
- `src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx`
- `src/RootRouter.tsx``src/RootRouter.test.tsx`
- `src/components/ui/sonner.test.tsx`
- `src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts` 及其测试。
- `src/components/agent/chat/hooks/agentStreamSubmitFailure.ts`
- `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 及其测试。
- `src/i18n/resources/{zh-CN,zh-TW,en-US,ja-JP,ko-KR}/settings.json`
- 发现其它缺陷后,只扩展到最接近根因的 current owner、定向测试与五语言资源,并在修改前登记。
避让:`internal/aiprompts/architecture.md``internal/exec-plans/refactor-v2-implementation.md`、App Server protocol / Skills runtime 并行脏区,以及当前未归属本任务的工作树改动。
## Agent Verification Contract
### 基本信息
```text
改动名称:Lime 核心用户主流程 E2E 审计
执行计划文件:internal/exec-plans/core-user-flow-e2e-audit-2026-07-31.md
负责人:Codex
预算标签:budget:normal
风险等级:P0
影响模块:Electron Desktop Host、App Server current session、Claw GUI、Provider / model GUI、历史恢复、Updater GUI
不做范围:live Provider、真实用户配置写入、Windows packaged installer
```
### Current 主链
```text
前端入口:Claw 首页、模型选择器、设置页、最近对话
前端网关:src/lib/api/* typed gateway / AppServerClient
Electron Desktop Host bridgepreload contextBridge -> electron-ipc -> app_server_handle_json_lines
App Server methodagentSession/* current session surface 及对应 read/list;宿主更新能力不进入业务 JSON-RPC
RuntimeCore / service ownerRuntimeCore external fixture backendUpdater 归 electron/updateHost.ts
read modelThread / Turn / Item projection 与 current session read model
runtime eventmessage.delta、turn.completed、turn.canceled 及 current terminal projection
Evidence Pack 字段:method、transport、status、sessionId、turnId、scenario marker、mock/console error 计数
GUI surface:Claw 输入框、消息列表、停止按钮、最近对话、AI 服务商、关于
```
### Happy Path
```text
用户输入 / Agent 输入:隔离 fixture 中发送无敏感信息的场景 marker
预期 runtime eventsturn started -> message delta -> turn completed;取消场景为 turn canceled
预期 tool calls:基础聊天无需工具;不以工具调用作为完成条件
预期 approval / sandbox:无审批;fixture 不写用户工作区
预期 artifact:无
预期 evidenceElectron/preload/IPC/App Server/runtime/read model/GUI identity 一致
预期 GUI 状态:消息可见、terminal 后输入框恢复、历史可重新打开、Provider 显式模型可见、更新状态不误报
失败时应停在哪一层:按 Renderer、Electron bridge、App Server、runtime/read model、平台 updater 精确分类
```
### Evidence Layers
| Layer | 本次是否需要 | 证据路径 / 计划路径 | 不需要的原因 |
| --- | --- | --- | --- |
| deterministic-smoke | 是 | `smoke:agent-runtime-current-fixture` 与 Claw fixture summaries | - |
| gui-trace | 是 | 真实 Electron fixture / CDP,记录脱敏 trace 摘要 | - |
| runtime-transcript | 是 | fixture summary 中的 terminal 与 read model identity | - |
| release-artifact | 否 | - | 本轮不制作或安装发行包 |
### 必跑命令
```bash
# C0
npm run test:contracts
# C1
npm run smoke:agent-runtime-current-fixture
npm run smoke:claw-chat-current-fixture
npm run smoke:claw-chat-current-fixture -- --scenario cancel
npm run smoke:claw-chat-current-fixture -- --scenario cancel-then-continue
npm run smoke:agent-session-history-electron-fixture
# C2
npm run verify:gui-smoke
LIME_ELECTRON_REMOTE_DEBUGGING_PORT=9223 npm run electron:dev
npm run bridge:health -- --timeout-ms 120000
```
未跑:C3 / C4 qcloop、live Provider、Windows packaged updater。
原因:用户未要求发送数据到真实 Provider,本机不是 Windows packaged 环境。
风险:不能证明具体线上 Provider 的回答质量、网络稳定性或 Windows 安装器闭环。
后续触发条件:明确授权 live Provider,或提供 Windows N-1 packaged 测试机。
### Agent QC 场景映射
```text
P0: 启动与主链可用、消息可见、真实 terminal、取消、取消后继续、历史恢复
P1: Provider / 模型可见性、设置导航、更新状态语义
P2: 非主流程页面视觉与扩展能力
```
选择依据:本轮验证用户最基本的可用闭环;允许 deterministic sidecar / external fixture,但 official evidence 只声明到对应 Gate,不冒充 live Provider 或 packaged Windows。
### Supervisor Rubric
本轮不使用 LLM Supervisor。确定性断言只判断:主流程能否完成、identity 是否一致、mock / console / invoke error 是否为零。
### 回写规则
```text
失败类型:产品阻塞、体验误导、信息泄露、桥接缺口、测试缺口、环境噪音
回写资产:最接近根因的单元 / 组件测试、fixture smoke、Playwright trace 或 GUI flow
关闭条件:修复后同一场景复测通过,且不恢复 compat / mock fallback
```
### 完成标准
```text
主线目标是否完成:是;已完成 controlled current 主链与 macOS Electron Gate B,未扩张到 live Provider 或 Windows packaged claim
已跑验证:Provider / updater / typed error 定向回归、contracts、App Server protocol check、current runtime 聚合 fixture、typed error success/failure Gate B、GUI smoke、版本一致性
未跑验证及原因:live Provider 与 Windows packaged,见上文
是否存在 token / Provider / GUI owner 风险:不调用 live Provider;真实 Electron CDP 独占本轮窗口
是否可进入 release evidence:否,缺 Windows packaged 与 live Provider claim
下一刀:在 Windows N-1 Squirrel 安装包上完成发现、下载、重启安装与最终版本确认
```
### 架构图确认
```text
架构影响:当前为只读 E2E 审计,尚未改变 owner、协议、read model 或交付门禁
架构图已更新:不适用;若修复触达架构敏感路径,将重新分类
责任开发者确认:不适用;当前无重大架构变更
```
## 验收矩阵
| 场景 | Proof level | 状态 | 证据 / 问题 |
| --- | --- | --- | --- |
| 启动、首页与导航 | Gate B | 通过 | `verify:gui-smoke` 21/21;首页长短输入 current Electron fixture 通过 |
| Provider 与显式模型 | Gate A + Gate B bridge | 通过,Windows 平台待补 | Provider / ModelSelector 68/68Provider hooks / storage / context / integration 26/26;不声明 live Provider |
| 新会话、stream、terminal | Current fixture + Gate B | 通过 | 聚合 current fixture 通过;typed error success/failure 均为 Gate B controlled fixture |
| 停止后继续 | Current fixture + Gate B | 通过 | cancel、cancel-then-continue 与 active steer 聚合场景通过 |
| 历史恢复与重启 | Gate B fixture | 通过 | history/cache hydration、Plan history hydrate 与 Coding Workbench 恢复通过 |
| 关于与更新状态 | Gate A + host unit | 通过,Windows packaged 待补 | updater / About / UpdateNotification / Sidebar 51/51;版本 `1.117.0`Squirrel N-1 实机未验收 |
## 问题台账
| 严重度 | 分类 | 场景 | 状态 | 根因 / 修复 |
| --- | --- | --- | --- | --- |
| P0 | 桥接缺口 / 构建阻塞 | 任何需要 source 重建 App Server sidecar 的 Electron 主流程 | 已解决 | typed `error` notification 的 Rust 关联项歧义已消除;`cargo check -p app-server-protocol`、host 重建与 GUI smoke 通过 |
| P0 | 桥接缺口 / contract 漂移 | Electron host fixture 重建 | 已解决 | generated TypeScript notification union、client 与 fixture 已同步;`test:contracts` 和聚合 Electron fixture 通过 |
| P0 | 产品阻塞 / 体验误导 | 自定义 Provider 添加 | 已修复 | 未保存态改为“取消”;保存成功但连接测试失败时,“完成添加”仍激活已持久化 Provider |
| P0 | 产品阻塞 | 发送失败反馈 | 已修复并回归 | 恢复全局 Toastertyped error success/failure Electron fixture 均无 console / invoke error |
| P0 | 信息泄露 / 体验误导 | 不可执行模型路由发送 | 已修复并回归 | 用户只看到五语言 Provider 不可用提示,内部路由错误保留在诊断边界 |
| P0 | 测试缺口 | typed retry GUI 等待 | 已修复 | fixture 原先只在用户消息分组内查 assistant 状态且错误要求输入框禁用;改用全局 `data-status` 语义状态并验证 active steer 输入可用 |
| P0 | 测试缺口 | typed retry trace 聚合 | 已修复 | external backend 在 retry 后漏发 `provider.first_text_delta.received`;补齐 Provider checkpoint,不放宽 Provider / App Server 分离断言 |
| P0 | 测试缺口 | typed retry read model | 已修复 | 删除过时的 `detail.thread_read` 私有状态读取,统一使用 canonical `turns[]` ownercompleted 等待同时要求真实 terminal |
| P0 | 测试缺口 | typed retry failure 中间态 | 已修复 | 不再等待不会进入 `thread/read` 的内部错误 detail;改为证明 prompt 保留且 read model 在 `turn.failed` 前仍为非终态 |
| P1 | 可维护性 | Electron fixture 编排文件超过 1000 行 | 已登记退出条件 | `backend-script.mjs``smoke.mjs``smoke.test.mjs` 不再继续承接下一种 backend 场景;新增场景前先把 typed error backend 生成片段和 assertion guard 拆到独立领域模块,聚合入口只保留编排 |
## 执行日志
- 2026-07-31:建立 `budget:normal / P0` 审计合同;本机 `1420 / 3030 / 9223` 均无监听,从干净环境开始。
- 2026-07-31`npm run smoke:agent-runtime-current-fixture` 通过;覆盖首页首发、stream terminal、停止后继续、历史 hydrate、审批、计划、Skills / MCP、媒体与工作台,明确 `liveProviderUsed=false`
- 2026-07-31:随后单独运行 `npm run smoke:claw-chat-current-fixture` 时,检测到 13:14 更新的并行 App Server protocol source 比 13:03 的 sidecar 新,重建在 `envelopes.rs:753` 失败;错误为 `ambiguous associated item`。旧聚合证据有效但不能替代当前 source build,新的 Gate B 暂停。
- 2026-07-31:测试进程临时设置 `RUSTFLAGS=-Aambiguous-associated-items` 后,`cargo check -p app-server-protocol` 通过,但继续重建 host 时 `packages/app-server-client``error` notification 未进入生成 method union 而失败。临时 lint 参数只用于继续诊断,不关闭默认构建阻塞。
- 2026-07-31:隔离 Electron CDP 复现 Provider 静默丢失:填写 `E2E Local Provider / e2e-explicit-model` 后点击“完成添加”,页面回到“还没有启用模型”,console / invoke error 均为 0;源码确认该按钮直接绑定 `onCancel`
- 2026-07-31:继续复现保存失败分支:本机不可达 endpoint 的配置成功持久化,设置列表显示 `e2e-explicit-model` 且标为默认,但返回首页仍只显示 `gpt-5.2-pro`;根因为完成动作未调用 `onActivated`,导致 persisted provider 与 active provider 分裂。
- 2026-07-31:沿不可执行 Provider 真实发送:`agentSession/turn/start``electron-ipc -> app_server_handle_json_lines` 返回 `runtime model route is not executable`;输入恢复,但无可见 toast。源码确认生产唯一 Toaster 挂载在发布改动中被删除。
- 2026-07-31:恢复 `RootRouter` 的 Toaster 挂载,覆盖主应用、Browser Runtime、Resource Manager 和开发 smoke 窗口;新增真实 Sonner DOM 回归。发送提交与流式提交失败统一使用 `resolveAgentRuntimeSubmitErrorMessage`,将不可执行路由隐藏为 Provider 暂不可用五语言文案。
- 2026-07-31:定向 Vitest 通过:`RootRouter.test.tsx` 4/4、`sonner.test.tsx` 1/1、`agentRuntimeErrorPresentation.test.ts` 14/14、`useWorkspaceSendActions.test.tsx` 155/155。
- 2026-07-31:修复 typed error fixture 的 DOM 作用域、`data-status`、Provider checkpoint、canonical read model 状态读取与 pre-terminal 等待合同;fixture guard 81/81 通过。
- 2026-07-31typed error / runtime status 最终定向回归 93/93 通过;全工作树 `git diff --check` 通过,fixture 进程无残留。
- 2026-07-31`typed-error-retry-success``typed-error-retry-failure` 真实 Electron controlled Gate B 均通过;两者均命中 Electron IPC、App Server current JSON-RPC、Provider/App Server trace 分离、同一 turn identityconsole / invoke error 为零。
- 2026-07-31`npm run smoke:agent-runtime-current-fixture` 完整通过;覆盖首页、历史恢复、停止继续、审批、active steer、Plan、Skills、MCP、媒体、代码工作台、typed error success/failure 与内容工厂,`liveProviderUsed=false`
- 2026-07-31`npm run verify:gui-smoke` 通过,21/21 assertionsevidence`.lime/qc/project-gates/standalone-shell-01-20260731103342-78558/shell-01-electron-smoke/summary.json`
- 2026-07-31:本轮 controlled 主流程完成。Windows packaged Squirrel 从 N-1 到新版本的发现、下载、重启安装与最终版本确认仍是唯一发布平台阻塞,完成度 92%。
@@ -9,6 +9,7 @@
## 分类
- `current``lime-rs/crates/mcp`、App Server `mcp*` JSON-RPC methods、前端 `src/lib/api/mcp.ts`
- `current`OAuth completion 只由 App Server `mcpServer/oauthLogin/completed` typed notification 投影给 Renderer
- `current`runtime 工具命名 `mcp__<server>__<tool>`extension surface key 继续使用 `mcp__<server>`
- `current``ToolSearchBridgeTool` 消费 MCP inventory / search 结果
- `test-only`retired MCP facade 只允许停留在 contract forbidden snippet、smoke 观测黑名单与负向测试文本中
@@ -47,11 +48,11 @@
- App Server protocol schema、schema fixtures、npm client generated types、package client 测试与 MCP API fail-closed 测试已同步 `mcpServer/oauth/login`
- MCP 管理面已消费 `runtime_status.auth_status.action_plan`:动态 OAuth 缺授权时显示登录入口,调用 `mcpServer/oauth/login` 并用系统浏览器打开 `authorizationUrl`;显式 `client_id/oauth_resource` unsupported 状态只显示不可用提示,不提供假登录入口;已授权状态显示为“已授权”。
- MCP OAuth 登录入口已补五语言 `settings` 文案与 `McpPanel` 组件回归,覆盖登录打开、unsupported fail-closed 和已授权状态。
- OAuth callback 完成后会发出 `mcp:oauth_completed` 事件,前端 `useMcp` 监听事件后自动刷新 server/tool 列表,`McpPanel` 给出“授权已完成,状态已刷新”的五语言 toast。
- 参考 Codex 的 `mcpServer/oauth/login -> async completion notification` 设计后,补齐本地回环 OAuth provider completion 回归:测试真实跑 rmcp metadata discovery、dynamic client registration、authorization redirect、callback、token exchange、持久 token store 与 `mcp:oauth_completed` 事件发射
- OAuth callback 完成后由 App Server 发出 `mcpServer/oauthLogin/completed` typed notification,前端 `useMcp` 经 typed event bus 自动刷新 server/tool 列表,`McpPanel` 给出“授权已完成,状态已刷新”的五语言 toast。
- 参考 Codex 的 `mcpServer/oauth/login -> async completion notification` 设计后,补齐本地回环 OAuth provider completion 回归:测试真实跑 rmcp metadata discovery、dynamic client registration、authorization redirect、callback、token exchange、持久 token store 与 App Server typed notification 投影
- 动态 OAuth 登录现在复用 streamable HTTP 的 `http_headers` / `env_http_headers` 构造路径;metadata discovery、dynamic client registration、持久凭据恢复和授权后的 MCP HTTP transport 使用同一组默认 headers,避免 OAuth 登录链路与普通 streamable HTTP 链路分叉。
- 动态 OAuth scopes 对齐 Codex 解析优先级:登录参数 scopes > server 配置 scopes > provider discovery `scopes_supported` > 空;discovery scopes 会去重、trim 并过滤空值,只在用户未显式传入且 server 未配置 scopes 时启用。
- OAuth callback 支持标准 provider error 回调(如 `error=invalid_scope&error_description=...`),不再等到 5 分钟超时;后台立即通过既有 `mcp:server_error` 事件把失败投影给 GUI。
- OAuth callback 支持标准 provider error 回调(如 `error=invalid_scope&error_description=...`),不再等到 5 分钟超时;App Server 立即通过 `mcpServer/oauthLogin/completed { success: false, error }` 把失败投影给 GUI。
- OAuth 回归测试已拆到 `lime-rs/crates/mcp/src/oauth_tests.rs``oauth.rs` 保持在代码体量边界内;`manager.rs` 巨型文件未继续追加新逻辑。
- MCP manager 已按职责拆分为 `manager/lifecycle.rs``manager/tools.rs``manager/prompts.rs``manager/resources.rs``manager/tests/*``manager.rs` 从 3000+ 行收敛到状态、连接池、缓存与事件 facade,当前最大子模块约 533 行,避免后续 transport / 工具 / prompt / resource 逻辑继续堆回中心文件。
- MCP GUI OAuth 登录打开授权页已从 `window.open` 收敛到 `openExternalUrlWithSystemBrowser` current 外链网关,避免 Electron 下绕过 Desktop Host 的浏览器打开链路;组件回归覆盖授权 URL 通过系统浏览器网关打开且不回退 `window.open`
@@ -310,10 +311,10 @@
- `git diff --check -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"`
- `rg -n "[ \t]+$" -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"`:无命中。
- `rg -n "^(<<<<<<<|=======|>>>>>>>)" -- "scripts/mcp/lib/current-smoke-core.mjs" "scripts/mcp/lib/current-smoke-transport.mjs" "scripts/mcp/current-smoke.test.mjs" "scripts/mcp/lib/contract-guards.mjs" "internal/exec-plans/mcp-modernization-progress.md"`:无命中。
- MCP Desktop Host event bridge 监听已从 `src/hooks/useMcp.ts` 拆到 `src/hooks/useMcpEvents.ts`事件 payload 类型、`safeListen` 注册、`mcp:server_started/stopped/error/tools_updated/resources_updated/resource_updated/oauth_completed` 处理集中在事件模块`useMcp.ts` 只保留状态、action 与初始化编排,文件体量从约 553 行降到约 420 行
- `scripts/mcp/lib/contract-guards.mjs` 已纳入 `src/hooks/useMcpEvents.ts`,强制 MCP runtime events 继续通过 `safeListen` current bridge 注册,并禁止 `defaultMocks``mockPriorityCommands``invokeMockOnly``explicitMockFallback``listenExplicitMock` 回流到事件监听边界
- MCP event 监听已从 `src/hooks/useMcp.ts` 拆到 `src/hooks/useMcpEvents.ts``mcp:server_started/stopped/error/tools_updated/resources_updated/resource_updated` 继续通过 Desktop `safeListen`OAuth completion 改为同步订阅 App Server typed event bus`useMcp.ts` 只保留状态、action 与初始化编排。
- `scripts/mcp/lib/contract-guards.mjs` 已纳入 `src/hooks/useMcpEvents.ts`,强制剩余 MCP Desktop runtime events 继续通过 `safeListen` current bridge、OAuth completion 只走 typed notification,并禁止 legacy OAuth event 与生产 mock fallback 回流
- `npx eslint "src/hooks/useMcp.ts" "src/hooks/useMcpEvents.ts" "src/hooks/useMcp.test.tsx" --max-warnings 0`
- `npx vitest run "src/hooks/useMcp.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`6 passed,覆盖 OAuth completion、resources updated、resource updated 与订阅 current API 透传。
- `npx vitest run "src/hooks/useMcp.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`9 passed,覆盖 typed OAuth success/failure、Desktop listener 阻塞时同步订阅、resources updated、resource updated 与订阅 current API 透传。
- `node --check "scripts/mcp/lib/contract-guards.mjs" && node "scripts/check-app-server-client-contract.mjs"`:通过,281 checksMCP event bridge 守卫已纳入 App Server client contract guard。
- `npm run test:contracts`:通过,含 protocol types、App Server client contract、command contracts、mock priority commands=0、script governance、electron release workflow guard、harness cleanup 与 docs boundary。
- MCP 前端 API response guard 已从 `src/lib/api/mcp.ts` 拆到 `src/lib/api/mcpResponseGuards.ts``mcp.ts` 保留 `AppServerClient.request(...)`、App Server method 常量和 `mcpApi` 编排;response guard 模块集中校验 server list、lifecycle empty result、OAuth login、tool result、prompt result、resource content 与 resource templates,文件体量从约 512 行降到约 367 行。
@@ -410,13 +411,16 @@
- 2026-06-22:清理 `smoke:agent-runtime-tool-execution -- --batch runtime-introspection-tools` 的旧 ToolSearch 参数,移除 `caller / limit / include_deferred / include_schema`,只保留 current schema `query / max_results`;复跑该 smoke 通过,证明 ToolSearch 与 SendUserMessage 均完成。
- 已验证:`cargo fmt --all``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend_tool_inventory`3 passed);`npm run smoke:agent-runtime-tool-execution -- --batch runtime-introspection-tools --output /tmp/lime-runtime-introspection-smoke.json --timeout-ms 180000``cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-mcp streamable_http --lib`13 passed);`npx vitest run "src/components/mcp/McpPage.test.tsx" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`4 passed);五语言 `settings.json` JSON parse`npx eslint "src/components/mcp/mcpPageModel.ts" "src/components/mcp/McpPageEditor.tsx" "src/components/mcp/McpPage.test.tsx" --max-warnings 0``npm run smoke:mcp-current -- --allow-write-fixture --timeout-ms 120000`Context7 `--allow-live-provider` + `env_http_headers` live smoke`npx vitest run "scripts/mcp/current-smoke.test.mjs" --silent=passed-only --disableConsoleIntercept --testTimeout=30000`6 passed);`npm run test:contracts``npm run verify:gui-smoke``git diff --check -- "lime-rs/crates/app-server/src/runtime_backend/tool_inventory.rs" "lime-rs/crates/app-server/src/runtime_backend/tests/tool_inventory.rs" "lime-rs/crates/mcp/src/streamable_http.rs" "scripts/agent-runtime/tool-execution-smoke.mjs" "src/components/mcp/mcpPageModel.ts" "src/components/mcp/McpPageEditor.tsx" "src/components/mcp/McpPage.test.tsx" "src/i18n/resources/zh-CN/settings.json" "src/i18n/resources/zh-TW/settings.json" "src/i18n/resources/en-US/settings.json" "src/i18n/resources/ja-JP/settings.json" "src/i18n/resources/ko-KR/settings.json"`
- 2026-07-31OAuth completion 已从 `mcp:oauth_completed` Desktop event 迁到 App Server `mcpServer/oauthLogin/completed` typed notification。Rust MCP 只返回 completion handleApp Server 投影成功/失败;Renderer 在任何异步 Desktop listener 前同步订阅 typed event bus,并自动刷新 status/tools。旧事件字符串受 contract guard 禁止回流 `electron``lime-rs``packages``src`
- 2026-07-31`npm run smoke:mcp-oauth-notification-electron-fixture` 专用 Gate B 通过。证据 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json``ok=true`:真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server event drain、本地 OAuth provider callback 与 GUI 状态/toast 全链命中;自动刷新 `mcpServer/oauth/login``mcpServerStatus/list``mcpTool/list``mockFallbackHitCount=0``failedInvokeCount=0`、console/page/invoke errors 为 0。
## 当前缺口
- OAuth、动态授权与用户 approval 已具备 GUI 登录入口、completion 自动刷新闭环、本地回环 OAuth provider completion 证据,以及 `smoke:mcp-current -- --allow-oauth-fixture` 的 Electron `open_external_url` 网关证据;下一刀应补真实第三方 provider 账号流的 live-gated / 手工 E2E 证据。
- OAuth、动态授权与用户 approval 已具备 GUI 登录入口、typed completion 自动刷新闭环、本地回环 OAuth provider completion 证据`smoke:mcp-current -- --allow-oauth-fixture` 的 Electron `open_external_url` 网关证据,以及 `smoke:mcp-oauth-notification-electron-fixture` 的专用真实 Electron Gate B;下一刀应补真实第三方 provider 账号流的 live-gated / 手工 E2E 证据。
- 显式 `oauth.client_id` / `oauth_resource` 仍按 unsupported fail-closed 处理,后续需要等 connector 能无损支持后再打开。
- rmcp 0.12 的 `exchange_code_for_token` 内部仍新建 reqwest clientLime 目前能保证 OAuth discovery / dynamic registration / authorized MCP requests 复用自定义 headers,但不能无损保证 token endpoint 也接收自定义 headers。后续若要支持需要 upstream connector seam 或本地 OAuth flow 封装,不应靠假支持打开。
- rmcp 0.12 的 token exchange 内部自建 client 也意味着本地 loopback OAuth provider 在带系统代理的开发机上需要测试进程级 `NO_PROXY` 保护;当前保护只放在 Rust fixture 测试里,不修改生产进程环境。
- MCP GUI 管理面仍缺真实第三方 OAuth provider 账号流 smoke;当前本地 OAuth fixture 已证明系统浏览器 current 网关、provider redirect、callback completion 与状态回流闭环。
- MCP GUI 管理面仍缺真实第三方 OAuth provider 账号流 smoke;当前本地 OAuth fixture 已证明系统浏览器 current 网关、provider redirect、callback completion、App Server typed notification、自动 status/tool refresh 与 GUI 状态/toast 回流闭环。
- MCP `structuredContent` control-plane、runtime/read model/evidence projection、基础 Agent Chat 展示回归与真实 Electron fixture 可见证据已覆盖;下一刀若继续提升完整度,应补 Playwright 人工交互式证据或真实第三方 MCP provider 工具结果的 live-gated E2E。
- `smoke:mcp-current -- --allow-live-provider` 已对 Context7 远程 provider 跑通 `resolve-library-id``query-docs` 两条真实 tool E2E,且 `isError=true` 已 fail-closed`smoke:mcp-config-electron-fixture` 已补真实 Electron 设置页创建 Context7 配置并经 `app_server_handle_json_lines -> mcpServer/list` 验证落库的 GUI 证据;`smoke:mcp-context7-live-electron-fixture` 已补真实 Electron 启动 / ToolSearch / tool call 远程 Context7 live 入口。剩余缺口是在用户本机显式设置真实 `CONTEXT7_API_KEY` 后复跑同一 live fixture,确认 header 注入语义;当前本机未设置时仍可验证 Context7 公共工具链路。
- Agent Runtime / Claw fixture 拆分后,current 聚合回归、WebTools rendering、Skills Runtime、Expert Skills Runtime 与 `cancel-then-continue` 已恢复到绿色;下一刀应继续补用户截图中更细的 Codex 桌面 UI 视觉一致性断言,例如未完成时不得折叠、搜索 / 思考 / final Markdown 的分段快照和完成前状态稳定性,而不是继续扩张单个 fixture 文件。
@@ -442,7 +446,7 @@
- `npm run verify:local` 本轮尝试已通过 `verify:app-version`、i18n resource check、i18n unused、全仓 eslint 与变更文件 i18n hardcoded scan;进入全量 `tsc --noEmit` 后因同一工作区已有多个并行 `verify:local` / `tsc --noEmit` 高 CPU 进程抢资源,长时间无输出,已主动终止本轮额外验证会话(退出码 130)。本轮 MCP 定向校验、contract 与 GUI smoke 已通过。
- 历史上 `npm run test:contracts` 曾被非 MCP `useAgentChat.ts` 脏改动阻塞;当前复跑已通过,MCP current smoke structuredContent 守卫已进入同一 contract 入口。
- 本轮尝试 `npm run smoke:mcp-current -- --allow-oauth-fixture --timeout-ms 30000`,当前环境没有运行 `npm run electron:dev` / DevBridgehealth check 直接 `fetch failed`;该 smoke 未能作为 GUI 证据使用,需在 Electron DevBridge 运行后复跑
- 历史尝试 `npm run smoke:mcp-current -- --allow-oauth-fixture --timeout-ms 30000` 时因未运行 `npm run electron:dev` / DevBridge `fetch failed`,当时不能作为 GUI 证据;该阻塞现已由不依赖 DevBridge 的 `npm run smoke:mcp-oauth-notification-electron-fixture` 专用真实 Electron Gate B 关闭,旧失败只保留为历史记录
- `scripts/mcp/current-smoke.mjs` 超 1000 行阻塞已收口,当前入口约 390 行,core 约 409 行,transport 约 340 行,fixture writer 约 157 行;后续继续扩 `smoke:mcp-current` 必须优先复用 `scripts/mcp/lib/current-smoke-core.mjs` / `current-smoke-transport.mjs` / `current-smoke-fixture.mjs` / `live-provider-smoke.mjs` 或继续按职责拆子模块,不得把 transport / fixture / assertion helper 长回入口。MCP runtime current contract 已从 `scripts/check-app-server-client-contract.mjs` 拆到 `scripts/mcp/lib/contract-guards.mjs`;根 contract 脚本当前约 8447 行,仍超过非生成代码体量边界,后续应继续按领域把非 MCP contract guard 拆出,不要把新领域规则继续追加到根脚本。
- Agent stream handler 超 1000 行阻塞已在 2026-06-22 收口:`agentStreamRuntimeHandler.ts` 当前约 986 行,后续不得再把 runtime lifecycle / terminal / timer 逻辑追加回中心 handler。剩余巨型 facade 是 `src/components/agent/chat/AgentChatWorkspace.tsx`,当前约 5589 行;下一刀如果继续治理 Agent Chat 工作区,应先按 session 列表 / task file runtime / right surface orchestration 分块拆分,并保持 `smoke:agent-runtime-current-fixture`、WebTools rendering fixture 与 `verify:gui-smoke` 绿色。
- 本轮曾并行复用 `/tmp/lime-target-mcp-resource-templates` 触发 cargo incremental / rmeta 写入竞争(`No such file or directory (os error 2)`);已改用独立 `CARGO_TARGET_DIR="/tmp/lime-target-appserver-mcp-resource-templates"` 复跑 App Server MCP fixture 并通过。
@@ -1987,7 +1987,7 @@ OPEN_REF:继续按 EVENT-PROJECTIONS 推进其余 planned notification、Windo
验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 通过。npm run test:contracts 通过,含 839 schema definitions、831 generated types、0 generation failure、289 app-server-client checks 及 command/harness/modality/scripts/release/docs guards。Rust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。
产品证据:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false;它证明 current Agent/Electron fixture 主链无回归,但没有普通 warning 专项 DOM 断言,因此不冒充 warning 专项 Gate B。通用 npm run verify:gui-smoke 通过 21/21 assertions,证据为 .lime/qc/project-gates/standalone-shell-01-20260730191439-55464/shell-01-electron-smoke/summary.json该 Gate B-F 证明 Electron/preload/IPC/app_server_handle_json_lines/App Server/GUI shell 与零 mock fallback,不宣称实际命中 warning method。
产品证据:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false;它证明 current Agent/Electron fixture 主链无回归,但没有普通 warning 专项 DOM 断言,因此不冒充 warning 专项 Gate B。fresh npm run verify:local 通过 i18n、lint、typecheck、112/112 Vitest、contracts、Rust changed-scope、Electron renderer/host build 与真实 GUI smoke;后者通过 21/21 assertions,证据为 .lime/qc/project-gates/standalone-shell-01-20260730194114-24572/shell-01-electron-smoke/summary.json该 Gate B-F 证明 Electron/preload/IPC/app_server_handle_json_lines/App Server/GUI shell 与零 mock fallback,不宣称实际命中 warning method。
治理与格式:cargo fmt --manifest-path lime-rs/Cargo.toml --all --check、定向 Prettier、git diff --check 与 npm run governance:legacy-report 通过;治理扫描为零引用候选 0、分类漂移 0、边界违规 0。
@@ -1998,6 +1998,108 @@ OPEN_REF:继续按 EVENT-PROJECTIONS 推进其余 planned notification、Windo
OPEN_REF:下一刀审计 skills/changed 的真实 Skill catalog producer、GUI refresh consumer 与 recovery 语义;只有形成 typed producer -> GUI refresh -> 风险匹配 Gate B 的垂直链才转 current。v2 总体完成度保守估算约 91%,仍非 release-ready。
```
### 9.72 V2-05 skills/changed catalog invalidation closure
```text
状态:completed2026-07-31);只关闭 `skills/changed` catalog invalidation 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。
主目标:按 Codex `skills/changed` 的严格空对象、进程级瞬时失效语义,在唯一产品链内形成默认 Skill root / 成功本地 mutation -> App Server typed notification -> Renderer 重新读取 current Skill catalog -> GUI 可见更新;不进入 Thread/Turn/Item、持久化或恢复重放,不新增 `skills/extraRoots/set`、第二 catalog owner 或生产 mock fallback。
本轮窄写集:app-server-protocol v2 notification/method/schema/generated clientlime-skills snapshot cache invalidationApp Server Skill mutation producer/default-root watcher/public JSON-RPC testsapp-server-client typed decoderComposer Skill hook、current catalog projection 与 focused testsSkills runtime Gate B fixtureEVENT-PROJECTIONS、architecture、refactor v2 实施计划与本执行计划。共享工作树其它 provider、warning、guardian、realtime、Windows host capability 与 recovery 热区未做无关改写。
退出条件:`skills/changed` 只接受 `{}` 且进入 protocol catalog/schema/generated client;默认 root 在启动时存在、启动后首次创建、修改和删除时均可失效并按 10 秒节流;成功 Lime catalog mutation 发通知,失败及其他 app mutation 不发;Renderer 只消费 typed event bus 并重新调用 current `skill/list`,重连仍主动 listRust/TS/contracts/governance 通过;风险匹配 Gate B 证明真实 Electron/preload/IPC/App Server notification drain、重新 list、GUI catalog 更新与零 mock fallback。
实现事实:v2 protocol、schema、generated client 与 typed decoder 只接受严格空对象 `skills/changed {}`。App Server 启动默认 Skill root watcher;已存在 root 的创建/修改/删除和启动后首次出现均失效 `lime-skills` snapshot cache 并按 10 秒节流广播 typed notification。成功的 Lime catalog mutation 在 processor 边界失效同一 cache 并附带 notification;失败 mutation 和其他 app mutation 不发。没有新增 `skills/extraRoots/set`
Renderer 事实:Composer `useLimeSkills` 的初始加载与 notification refresh 统一调用 current `skill/list``skillsApi.getRuntimeCatalog()` 复用 `skillExecutionApi.listExecutableSkills()` 的既有 decoder,再经纯 projection 映射 GUI shape。管理中心 `skillManagement/list` 保持原语义,不成为 Composer catalog owner。Renderer 只消费 typed event bus;命中后写 console-only `skillsChanged.received { method, count }` marker 并自动重读;重连/重新挂载仍主动 list。该事件是进程级瞬时失效,不进入 Thread/Turn/Item、持久化或恢复重放。
验证:纯 projection、API、Hook 与 fixture guard 共 4 files / 99 tests 通过;npm run typecheck、npm run test:contracts840 schema definitions、832 generated types、0 generation failures、290 app-server-client checks)、npm run governance:legacy-report、Prettier、MJS node --check、cargo fmt --all --check 与 git diff --check 通过。npm run test:related 因仓库既有 runner 将 electron/ 目录当文件读取而报 EISDIR,未记为通过;本轮精确 Vitest 已覆盖相关前端回归。Rust related 因 Cargo.lock 自动扩大到 workspace lib;首次运行只暴露 protocol::v2::tests::typed_v2_envelope_schema_names_are_stable 的稳定清单漏项,补入 skills/changed 后 app-server-protocol 95/95 通过;workspace lib 最终汇总为 32 个 suite、4670 passed、5 ignored、0 failed。
产品证据:npm run smoke:claw-chat-current-fixture -- --scenario skills-runtime 通过,proof level 为 Gate B controlled fixture。真实 Electron/preload/IPC/app_server_handle_json_lines/App Server 链先完成初始 skill/list;隔离 HOME 默认 root 新建 notification-refresh/SKILL.md 后,typed skills/changed marker 为 1skill/list 自动由 1 次增至 2 次且 transport=electron-ipcGUI 新 Skill 可见,手动刷新点击为 0console/page/actionable errors 与 failed assertions 均为 0。证据位于 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json。
整合复验:npm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=false,包含 skills/changed、Inputbar rich restore、Skills Runtime 三入口及其余 current Agent/Electron fixture。首次聚合运行暴露的 Inputbar 失败属于 fixture DOM 定位合同:夹具用展示名 Capability Report 大小写敏感匹配 current catalog 的 capability-report,候选已可见但 target.click 从未执行;生产 CharacterMention 与 catalog identity 无缺陷。夹具现统一折叠大小写、空格、连字符和下划线,并由 source guard 锁定候选与 @capability-report badge 的同一等价规则。独立 inputbar-rich-restore Gate B 随后通过,text/image/path/skill 在 output-free cancel 后全部恢复,console/page error 与 mock fallback 为 0;证据为 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-inputbar-rich-restore-regression-summary.json。
通用 npm run verify:gui-smoke 通过,真实 Electron renderer、App Server sidecar、Claw shell reload 与 memory settings 均就绪;证据为 .lime/qc/project-gates/standalone-shell-01-20260731043417-63564/shell-01-electron-smoke/summary.json。fresh npm run verify:local 通过版本一致性和 i18n unused 后,在全仓 lint 被非本切片脏文件 src/components/input-kit/ModelSelector.tsx:432 的既有 react-hooks/exhaustive-deps warning 阻断,故未记为通过;本切片未改该文件。后续独立 npm run typecheck、contracts、99 项精确测试、GUI smoke、Rust fmt 与 Gate B 均明确通过。
架构确认:本切片把 Skill catalog mutation/filesystem invalidation、App Server typed notification 与 Renderer current list refresh 收敛到单一产品链,已同步 internal/aiprompts/architecture.md 第 28 节。责任开发者确认:root2026-07-31。
治理分类:v2 skills/changed protocol/schema、App Server watcher/mutation producer、lime-skills cache invalidation、typed Renderer event bus 与 current skill/list GUI refresh 为 current;无 compatskillManagement/list 仅保留管理中心读取语义;持久化/replay、skills/extraRoots/set、第二 catalog owner 与生产 mock fallback 均未新增。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 92%,仍非 release-ready。
```
### 9.73 V2-05 typed error retry/terminal closure
```text
状态:completed2026-07-31);只关闭 typed error retry/terminal 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。
主目标:在唯一产品链内证明 error(willRetry=true) -> turn/completed(completed) 与 error(true)* -> error(false) -> turn/completed(failed)typed error 负责重试/错误可见性,权威 turn/completed 独占 Turn terminallive、durable recovery、read model 和 GUI 使用同一 identity,生产 mock fallback 为零。
本轮窄写集:app-server-protocol v2 error notification/schema/generated clientApp Server error projector 与 canonical read-model runtime notification mergeapp-server-client typed decoderagent-runtime-client signal/lifecycle 分流;Renderer strict typed error controller/presentationClaw typed-error external fixture、assertion/evidence guardsEVENT-PROJECTIONS、refactor v2 实施计划与本执行计划。保留共享工作树其它 updater、provider、guardian、host capability 与 planned recovery 改动,不做无关改写。
退出条件:typed error 从 protocol/schema/generated client 到 Renderer strict decoder 一致;willRetry=true 不结束 TurnwillRetry=false 也必须等待权威 turn/completeddurable runtime.error 可由 canonical full/limited read model 恢复且不抢占后续 failed terminalSDK signal 不污染 lifecycle sequence;定向 Rust/TS、contracts、Agent fixture 与 GUI smoke 通过;success/failure 两条 Gate B 证明真实 Electron/preload/IPC/app_server_handle_json_lines/App Server/RuntimeCore/read model/GUI、同一 identity 和零生产 mock fallback。
实现事实:App Server 把 durable runtime.error 投影成 v2 typed errorRenderer 只接受 typed event bus。willRetry=true 显示重试状态,后续 completed terminal 清理并完成;一个或多个 retry error 后的 willRetry=false 显示最终错误,仍由 failed terminal 决定 Turn。canonical read model 的 runtime notification merge 同时恢复 warning 与 errorruntime error 只形成可见错误证据,Turn 在权威 terminal 前保持 running。没有新增第二 persistence、第二 terminal reducer 或 raw agentSession/event wrapper。
SDK/fixtureagent-runtime-client 将 typed error 作为独立 signal,不送入 lifecycle sequence verifier。common assertion 将 provider first-text/provider-wait 与 App Server message delta、server timestamp、Renderer client output/paint、current trace method、W3C、GUI/read model/identity 分开;typed-error 场景只豁免不存在的 provider 首文本证据。新增回归锁定普通文本流仍要求 provider tracetyped-error 缺 App Server、Renderer 或 current trace 证据仍失败。
验证:typed-error Renderer/fixture 6 files / 94 tests、packages/app-server-client 8 files / 98 tests、packages/agent-runtime-client 22 tests 通过;Rust canonical runtime error recovery 与 runtime_error_does_not_preempt_a_later_turn_failed_terminal 两项定向回归分别通过。npm run test:contracts 通过,app-server-client contract 为 292 checksnpm run smoke:agent-runtime-current-fixture 完整通过且 liveProviderUsed=falsenpm run verify:gui-smoke 21/21 assertions 通过,证据为 .lime/qc/project-gates/standalone-shell-01-20260731104641-30858/shell-01-electron-smoke/summary.json。
Gate Bsuccess 场景观察 error(willRetry=true) -> turn/completed(completed),证据 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-success-gateb-02-summary.jsonfailure 场景观察 error(true)* -> error(false) -> pending read model -> turn/completed(failed) -> GUI/read-model/backend terminal,证据 .lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-failure-gateb-03-summary.json。两者 ok=true、proofLevel=Gate B controlled fixture;聚合回归 evidence 为同目录的 claw-chat-current-fixture-typed-error-retry-success-regression-summary.json 与 claw-chat-current-fixture-typed-error-retry-failure-regression-summary.json,均 ok=true。
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只补齐既有 notification/projector/read-model/Renderer 边界的 typed contract 与恢复语义;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root2026-07-31。
治理分类:v2 typed error protocol/projector、durable runtime error recovery、strict Renderer signal 与权威 Turn terminal ownership 为 current;无 compatraw error wrapper、provider trace 对 current 主链证据的错误替代和由 error 抢占 Turn terminal 的第二状态机为 dead/forbidden-to-restore;其余 V2-05 planned notification、host capability 与全面 recovery 仍为开放项。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 93%,仍非 release-ready。
```
### 9.74 V2-05 `turn/plan/updated` canonical checklist 与 cold recovery closure
```text
状态:completed2026-07-31);只关闭 `turn/plan/updated` canonical checklist 与恢复垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。
主目标:在唯一产品链内把 canonical `update_plan` 的结构化输出派生为 typed `turn.plan.updated`,让实时和 canonical cold read 都显示同一份执行 checklist;保留 canonical `update_plan` 工具项用于恢复,但不生成 Plan ThreadItem、不展示 Plan UI/decision panel/tool card,也不新增 Message synthesis 或 renderer mock fallback。
本轮窄写集:App Server canonical `ToolOutput.structured_content``turn.plan.updated` 的 producerRenderer strict notification parser`appServerCanonicalThreadProjection` 的 update_plan cold recovery 与定向测试;App Server client contract guardEVENT-PROJECTIONS、v2 实施计划与本执行计划。保留共享工作树其它 provider、warning、skills/changed、typed error、host capability 与 planned recovery 热区,不做无关改写。
退出条件:实时通知只接受 typed `turn.plan.updated`;冷读从成功 `update_plan` 的最新有效快照恢复;真实 App Server wire `[ { name: "plan", value: "[...]" } ]` 可解析;空数组可清空旧 checklist,失败/非法快照不覆盖;read model 只保留 canonical `update_plan` tool item;真实 Electron Gate B 证明实时与 reload 可见、Plan Item/Plan UI/decision panel/tool card 为零、同一 identity 与零 invoke/console error。
实现事实:App Server 从 canonical `ToolOutput.structured_content` 派生 `turn.plan.updated`Renderer parser 严格消费 `turn_plan_updated``canonicalTurnPlanItems` 扫描 completed/successful `update_plan` tool item,按最新有效快照投影 `{ content, status }`,同时支持对象参数与真实 wire 参数数组。Malformed JSON、重复/缺失 `plan` 参数、非允许 status、失败或非 completed tool 均 fail closed;没有 Plan ThreadItem 或第二 checklist owner。
验证:`appServerCanonicalThreadProjection.test.ts` 10/10`npm run test:contracts` 通过,app-server-client contract 为 292 checks`npm run typecheck``npm run smoke:agent-runtime-current-fixture``npm run verify:gui-smoke` 通过。Gate B 证据 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json``scenario=turn-plan-update``proofLevel=Gate B controlled fixture``ok=true``turn.plan.updated` `methodCount=1`,事件 drain `messageCount=28``planIndex=16``terminalIndex=27`;实时和 reload checklist 各有 2 项,`updatePlanToolCount=1``planItemCount=0``visiblePlanBlockCount=0``visiblePlanDecisionCount=0``visibleUpdatePlanToolRowCount=0`Electron/preload/IPC/App Server/read model/GUI identity 一致,invoke/page/console error 均为 0。
治理分类:typed `turn.plan.updated` producer/parser、canonical `update_plan` recovery 与 run-control checklist 为 current;无 compat`update_plan` 场景下的 Plan ThreadItem、Message/tool card checklist synthesis、Plan UI/decision panel/tool card 和 renderer mock fallback 为 dead/deleted/forbidden-to-restore;独立 Plan mode 的现役 UI 不在本切片范围内。
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 94%,仍非 release-ready。
```
### 9.75 V2-05 MCP OAuth typed completion notification closure
```text
状态:completed2026-07-31);只关闭 mcpServer/oauthLogin/completed 垂直切片,V2-05 与 v2 总体仍为 in-progress,不是 release-ready。
主目标:把 MCP OAuth callback completion 从旧 Desktop event 收敛到 App Server v2 typed notification,使成功/失败、Renderer 自动刷新与 GUI 可见状态走唯一产品链;不保留 mcp:oauth_completed compat、不新增第二 OAuth 状态 owner或生产 mock fallback。
本轮窄写集:lime-mcp OAuth completion handleApp Server MCP notification producerapp-server-protocol v2 notification/schema/generated clientapp-server-client strict decoderRenderer useMcp typed event subscription 与回归;MCP OAuth 专用 Electron fixturecontract guardMCP/V2 文档与本执行计划。保留共享工作树其他 V2-05 notification、host capability、recovery 与版本改动,不做无关重写。
退出条件:callback success/failure 都产生 mcpServer/oauthLogin/completedname 非空、threadId 必填可空、success 必填、error 可选可空且 decoder fail closedRenderer 在任何 await 前同步订阅 typed event bus,完成后自动刷新 server/tool;生产路径无 mcp:oauth_completedRust/TS/contracts/governance/scripts 通过;真实 Electron Gate B 证明 preload/IPC、app_server_handle_json_lines、App Server event drain、OAuth callback、GUI 授权状态/toast 与零 mock fallback。
实现事实:lime-mcp 返回 McpOAuthLoginHandle completion future,不再发 legacy OAuth event。App Server 等待 handle 后把 success/error 投影为 ServerNotification::McpServerOauthLoginCompletedprotocol、schema、generated TypeScript 与 app-server-client strict decoder 使用同一 payload。Renderer setupMcpEventListeners 在串行 Desktop safeListen 前同步注册 App Server notification,避免快速 callback 竞态;成功/失败都刷新 mcpServerStatus/list 与 mcpTool/list,成功显示 completion toast,失败显示 typed error。
验证:lime-mcp OAuth 22 passed、app-server-protocol v2 59 passed、App Server MCP processor 4 passed、app-server-client 101 passed、useMcp 9 passed。npm run test:contracts 通过,包含 847 schema definitions、839 generated protocol types、0 generation failures、292 app-server-client checks,以及 command/scripts/docs/legacy guards。
Gate Bnpm run smoke:mcp-oauth-notification-electron-fixture 通过,proofLevel=Gate B、ok=true。真实 Electron/preload/IPC、app_server_handle_json_lines、App Server OAuth login/event drain、本地 provider redirect/callback/token exchange 与 Renderer GUI 全部命中;oauthRequiredVisible=true、oauthAuthorizedVisible=true、completionToastVisible=trueauthorizeRequestCount=1、tokenRequestCount=1、scope=fixture.readappServerHandleJsonLinesHitCount=3、appServerDrainEventsHitCount=4、openExternalUrlHitCount=1、electronIpcHitCount=8、mockFallbackHitCount=0、failedInvokeCount=0console/page/invoke errors 为 0。自动刷新 methods 为 mcpServer/oauth/login、mcpServerStatus/list、mcpTool/list。证据:.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json 与同目录 PNG。
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只把既有 OAuth completion 从 legacy Desktop event 迁到 App Server v2 typed notification,并补齐 Renderer/Gate B;无需改写 internal/aiprompts/architecture.md。责任开发者确认:root2026-07-31。
治理分类:typed protocol/schema、App Server completion producer、strict client decoder、Renderer typed event bus、自动刷新与专用 Gate B 为 current;无 compatmcp:oauth_completed 为 dead/deleted/forbidden-to-restore。
OPEN_REF:下一刀回到 EVENT-PROJECTIONS.md,继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recoveryguardianWarning 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。
```
## 10. 完成定义
v2 实施不能因为“几个进程都有改动”而标记完成。每个 slice 必须同时具备:
@@ -0,0 +1,58 @@
# Lime v1.118.0 发布执行计划
状态:completed
日期:2026-08-01
目标版本:`1.118.0`
目标 tag`v1.118.0`
## 主目标
`v1.117.0` 后当前工作树中的 Skills catalog 自动失效、typed error、`turn/plan/updated`、MCP OAuth 完成通知、Windows Provider 模型目录与 updater 可靠性,以及核心用户流程修复作为同一个 release candidate,完成版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端复核。
## 当前阶段与下一刀
- 当前阶段:`v1.118.0` release candidate、版本事实源、双语 release notes、发布门禁、release commit、tag、`main`/tag 推送与远端引用复核均已完成。
- 下一刀:无;后续增量进入下一版本,不改写已发布的 `v1.118.0` tag。
## Release Candidate
- 基线:`v1.117.0`
- 最终候选盘点:167 个 tracked diff 文件、30 个未跟踪文件,tracked diff 为 12,614 行新增、1,563 行删除。
- `release metadata`:根 `package.json``packages/lime-cli-npm/package.json``lime-rs/Cargo.toml``lime-rs/Cargo.lock`、双语 release notes 与本计划。
- `candidate changes`:当前工作树全部产品、协议、文档、测试、schema、生成物和脚本改动;重点包括 Skills catalog invalidation、typed error retry/terminal、canonical plan checklist、MCP OAuth typed notification、Provider model catalog 和 updater 状态修复。
- `excluded changes`:无。初始盘点未发现缓存、构建产物、凭证、个人临时文件或与候选无关的大文件。
- 并行避让:本轮除 release metadata、本计划和 Rust 门禁暴露的一处过时 fixture 外,其余候选文件只读验证,不覆盖已有业务改动。门禁期间新增的 MCP 架构/进度文档属于同一 current notification 候选,已纳入最终盘点与后续验证。
## 写集与退出条件
- 所有版本事实源同步为 `1.118.0`release notes 只保留当前版本单页。
- 必跑 `npm run verify:app-version``npm run typecheck``npm run test:contracts``npm run test:rust:changed``npm run smoke:agent-runtime-current-fixture``npm run verify:gui-smoke``git diff --check`
- 协议、App Server、Electron、GUI、脚本与重大架构均有变化;门禁失败必须定位修复或记录明确阻塞,不能静默降级。
- 重大架构变更已同步 `internal/aiprompts/architecture.md`,并包含责任开发者确认。
- Git 写操作已获得明确确认;完整 release candidate 由同一 release commit 承载,`v1.118.0` tag、远端 `main` 与远端 tag 指向同一发布对象。
## 验证记录
- `npm run verify:app-version`:版本更新后与最终复跑均通过,根应用、CLI npm 包、Rust workspace 与锁文件一致为 `1.118.0`
- `npm run typecheck`:通过,覆盖 renderer 与 Node TypeScript 配置。
- `npm run test:contracts`:通过;839 个 generated protocol types、0 generation failure、292 项 App Server client contract,以及 command、harness、modality、scripts、Electron release workflow 和 docs boundary guard 全部通过。
- `npm run test:rust:changed`:因 workspace 版本和 lockfile 变化扩大为 `cargo test --lib --workspace`。首次运行发现 `maps_item_and_terminal_lifecycle_to_direct_v2` 仍使用旧 `turn.failed` fixture;更新为当前 `error -> turn/completed` contract 后,定向测试 1/1 与最终 workspace 全部 suite 通过,关键结果包括 App Server 1650/1650、protocol 98/98、MCP 151/1510 failed;既有联网 embedding 测试按规则 ignored。
- `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all --check``git diff --check`:通过。
- `npm run smoke:agent-runtime-current-fixture`:通过;覆盖 history/cache hydration、停止继续、approval、Skills catalog 自动刷新、typed error retry success/failure、Plan 历史恢复、MCP structuredContent、media、Coding Workbench 与 Article Editor`liveProviderUsed=false`
- `npm run verify:gui-smoke`:通过;App Server 报告 `version=1.118.0`,真实 Electron Gate B 摘要位于 `.lime/qc/project-gates/standalone-shell-01-20260731155713-55975/shell-01-electron-smoke/summary.json`
- `npm run smoke:mcp-oauth-notification-electron-fixture`:通过;真实 Electron、外部浏览器回调与 App Server v2 notification 闭环命中 `mcpServer/oauthLogin/completed`,证据位于 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json`
- 工作树稳定性:2026-08-01 00:01:15 与 00:01:34 两次采样的 status、tracked diff、untracked content 三组 SHA-256 完全一致;最终候选保持 167 个 tracked diff 文件与 30 个未跟踪文件。本地 `HEAD``origin/main` 与远端 `main` 均为 `66752a277`,目标 tag 仍不存在。
- Staged 边界:197 个候选文件全部暂存,摘要为 16,356 行新增、1,563 行删除;未暂存与未跟踪文件均为 0,`git diff --cached --check` 通过。
- 发布结果:release commit、`v1.118.0` tag、`main`/tag 推送与远端引用复核完成;发布后工作树 clean。
- 平台限制:Windows Squirrel packaged Gate B 尚未执行;macOS Electron fixture 不能替代 Windows 实机证据。
## 分类
- `current`Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item -> GUI 产品链;typed v2 notifications、Skill catalog invalidation、canonical checklist/error recovery、Provider 模型目录和 Electron updater。
- `compat`:无新增。
- `deprecated`Refactor v2 尚未迁完的 V2-05 notification、host capability 与 recovery surface,只允许继续迁出。
- `dead / deleted / forbidden-to-restore`raw notification wrapper、第二 catalog/checklist/error owner、Provider inferred-only 可执行假设、updater 伪版本判断与生产 mock fallback。
## 完成度
- 当前完成度:100%。候选范围、发布元数据、发布门禁、staged 复核、commit、tag、push 与远端复核均已完成。
@@ -0,0 +1,67 @@
# Windows 模型目录与更新可靠性闭环
状态:实现完成;Windows Gate B 待实机验收
日期:2026-07-31
## 目标
1. Windows 用户添加并启用 Provider 模型后,聊天模型选择器必须展示该显式模型。
2. 更新入口只在确认候选版本严格高于当前版本后出现。
3. 更新失败、已是最新和发现新版本使用不同状态,不再把失败或同版本误报成新版本。
## 用户闭环
用户从设置中的 Provider 添加模型和凭证,回到聊天输入区打开模型选择器,能看到并选择刚添加的模型。应用后台检查更新时不打扰用户;只有已确认的新版本可安装时才显示更新入口,失败时提供准确状态和可恢复动作。
## 写集
- `src/components/input-kit/ModelSelector.tsx` 及定向测试
- `src/components/agent/chat/utils/providerModelCompatibility.ts` 及定向测试
- `electron/updateHost.ts` 及定向测试
- `src/components/app-sidebar/AppUpdateEntry.tsx` 及定向测试
- `src/pages/update-notification.tsx` 及定向测试
- `src/i18n/resources/*/common.json` 的更新状态文案
- 本执行记录
避让:App Server protocol、Skills runtime、`internal/aiprompts/architecture.md` 与已有脏执行计划。
## 根因证据
- 模型:Provider 显式模型会被前端临时投影为无权威能力来源的元数据,`ModelSelector` 又把该来源当成路由不可执行并直接过滤,形成“顶部保留旧选择、候选列表为空”。
- 更新:`update-available` 事件没有版本参数,旧逻辑以 `undefined !== currentVersion` 判断有更新;通知页又固定渲染“发现新版本”,未按 `failed / up_to_date / completed` 分支。
## 退出条件
- Provider 显式模型可见,未显式声明且仅靠启发式推断的目录项仍 fail closed。
- 同版本、旧版本、无版本和无效版本均不会产生可安装更新入口。
- 只有严格更高版本进入已下载/可安装状态;失败通知不显示“发现新版本”。
- 定向单测、`typecheck``test:contracts``verify:app-version``verify:gui-smoke` 完成或记录明确阻塞。
## 验证记录
- 模型定向回归:`providerModelCompatibility.test.ts``ModelSelector.test.tsx`48/48 通过。
- 更新与模型联合定向回归:7 个测试文件,99/99 通过。
- Electron 版本与宿主定向回归:23/23 通过。
- `npm run typecheck`:通过。
- `npm run typecheck:electron`:通过。
- `npm run verify:app-version`:通过,当前版本 `1.117.0`
- `npm run test:contracts`:通过。
- `npm run verify:gui-smoke`:通过;Electron、preload/IPC、App Server JSON-RPC 与用户可见工作台主路径 smoke 证据:`.lime/qc/project-gates/standalone-shell-01-20260731050343-95334/shell-01-electron-smoke/summary.json`
- 2026-07-31 最终 GUI smoke21/21 assertions 通过,console / invoke / preload / page error 均为 0mock fallback 与 legacy command 命中为 0;证据:`.lime/qc/project-gates/standalone-shell-01-20260731103342-78558/shell-01-electron-smoke/summary.json`
- updater / About / UpdateNotification / Sidebar 定向回归:51/51 通过;`npm run typecheck:electron``npm run verify:app-version` 通过,版本 `1.117.0`
- `npm run smoke:agent-runtime-current-fixture`:通过;Provider 模型选择后的 current Agent 主链、历史、停止继续、typed error success/failure 与 read model terminal 未出现回归,`liveProviderUsed=false`
- `npm run verify:local`:通过;前端 112/112 个智能测试批次、Rust workspace 测试和内含 GUI smoke 全部通过。
- 线上 Windows x64 `RELEASES` 清单:本机到 `updates.limecloud.com` 连续三次 TLS 握手失败,未取得服务端内容。
- 待实机验收:Windows Squirrel 从 N-1 版本发现更新、下载、重启安装并确认最终版本的 Gate B 闭环。
- 当前完成度:92%;实现、macOS Electron Gate B 与跨层回归已完成,剩余 8% 为 Windows packaged 平台验收,不能由 macOS fixture 代替。
## 状态分类
- currentProvider 配置 -> 模型目录 -> 模型选择器;Electron `autoUpdater` -> 更新会话 -> GUI。
- compat:无新增。
- deprecated:无新增。
- dead / deleted:删除 `undefined !== currentVersion` 伪版本判断,以及版本未知时的用户可见更新入口。
## 架构确认
未改变 Electron Desktop Host、preload/IPC 或 App Server 边界,不属于重大架构变更,无需更新架构图。
+6 -6
View File
@@ -13,20 +13,20 @@
出口代码:TL 时间线,TP Turn 面板,PI pending interactionHS Header/状态区,GN 应用通知,DX 仅开发诊断。
实施快照:direct `item/started -> item/commandExecution/outputDelta* -> item/completed` 通过 typed adapter 和共享 reducerproduction `thread/resume` 安装同一 replay reducer,后续 live notification 继续复用。completed snapshot 权威覆盖 delta 草稿,输出限制为 256 KiB。unknown Item 与 unknown/known-unprojected notification drift recorder 已接线,但 recorder 只提供 fail-visible 诊断,不能把 72 notification 中的 planned surface 标记完成。
实施快照:direct `item/started -> item/commandExecution/outputDelta* -> item/completed` 通过 typed adapter 和共享 reducerproduction `thread/resume` 安装同一 replay reducer,后续 live notification 继续复用。completed snapshot 权威覆盖 delta 草稿,输出限制为 256 KiB。`turn.plan.updated` 由 canonical `update_plan``ToolOutput.structured_content` 派生,实时与 canonical cold read 共用 checklist 投影;`update_plan` 工具项保留在 read model,但不生成 `ThreadItem.plan` 或 Plan UI。unknown Item 与 unknown/known-unprojected notification drift recorder 已接线,但 recorder 只提供 fail-visible 诊断,不能把 72 notification 中的 planned surface 标记完成。
## 1. Thread、Turn 与 Hook
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------- | -------- | -------- | ----------------------------------------------------- |
| 1 | error | TP/HS | planned | thread/turn 错误;willRetry 只显示重试中 |
| 1 | error | TP/HS | current | typed live/durabletrue 重试,false 等权威 Turn |
| 2 | thread/started | HS | current | 建立 Thread metadata,不把空 turns 当完整 history |
| 3 | thread/status/changed | HS/PI | current | notLoaded、idle、systemError、active 与 waiting flags |
| 4 | thread/archived | GN/HS | current | 侧栏归档,当前页只读 |
| 5 | thread/deleted | GN | current | 当前页已删除态,不删除 Project |
| 6 | thread/unarchived | GN/HS | current | 恢复可见/可操作状态 |
| 7 | thread/closed | HS | current | 清除 live spinner 和 pending interaction |
| 8 | skills/changed | GN | planned | 刷新 Composer Skill catalog |
| 8 | skills/changed | GN | current | 瞬时失效并重新读取 Composer current Skill catalog |
| 9 | thread/name/updated | HS | current | Header 与侧栏名称 |
| 10 | thread/goal/updated | HS | current | goal/阶段入口,不映射 Rust ProductionPlan |
| 11 | thread/goal/cleared | HS | current | 清除当前 goal indicator |
@@ -39,7 +39,7 @@
| 18 | turn/completed | TL/TP | current | 权威 Turn 终态并清理 pending |
| 19 | hook/completed | TL | planned | Hook status、duration、entries 与阻断结果 |
| 20 | turn/diff/updated | TP | planned | 替换聚合 Diff snapshot |
| 21 | turn/plan/updated | TP | planned | 执行 checklist,不等同 plan Item |
| 21 | turn/plan/updated | TP | current | canonical update_plan checklist,实时/冷恢复一致 |
## 2. Item 生命周期、流与进程
@@ -67,7 +67,7 @@
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------------ | -------- | ---------------------- | ---------------------------------------- |
| 39 | mcpServer/oauthLogin/completed | GN/PI | planned | 结束 OAuth 等待并刷新 MCP status |
| 39 | mcpServer/oauthLogin/completed | GN/PI | current | 结束 OAuth 等待并刷新 MCP status |
| 40 | mcpServer/startupStatus/updated | HS/GN | planned | starting/ready/failed,无假 MCP fallback |
| 41 | account/updated | GN | product-scope-excluded | 不写入对话 history |
| 42 | account/rateLimits/updated | HS/GN | product-scope-excluded | 若未来纳入,独立产品范围变更 |
@@ -167,7 +167,7 @@ command/file approval、requestUserInput、MCP elicitation 与 permission approv
## 9. 非 Item 投影与状态终结
Turn plan 是执行 checklist,不是 ThreadItem.plan 或 Rust ProductionPlan。Turn diff 是本 Turn 聚合 Diff,不能取代 fileChange Item。Header 只显示当前 model、reasoning、permission/sandbox、environment 与 active flags 的用户级摘要;底层 route、provider、credential 和 capability 明细进入受控运行详情。
Turn plan 是 canonical `update_plan`执行 checklist,不是 ThreadItem.plan 或 Rust ProductionPlan;实时 `turn.plan.updated` 与 cold read 从同一结构化快照恢复,非法快照不覆盖上一份有效计划。Turn diff 是本 Turn 聚合 Diff,不能取代 fileChange Item。Header 只显示当前 model、reasoning、permission/sandbox、environment 与 active flags 的用户级摘要;底层 route、provider、credential 和 capability 明细进入受控运行详情。
| 触发 | 必须终结的 UI |
| --------------------------- | ----------------------------------------------------- |
+90 -10
View File
@@ -4,9 +4,9 @@
主目标:以 Codex 的 Thread / Turn / Item 生命周期和渲染语义替换 Lime 当前把 canonical Item 二次压缩为 Message、tool_call、extension 的对话渲染路径,同时保持 model-provider 的 Grok 多模型控制面和 OpenCode 多模态/多协议 lowering 不变。
当前阶段:V2-05 notification、host capability 与 recoveryV2-00、V2-01、V2-02、V2-03、V2-04 已关闭,direct TurnTimeline、production replay、session history、长列表性能、MCP elicitation、Multi-Agent、三项 product-scope reverse requestmedia read v2 Gate B 已通过
当前阶段:V2-05 notification、host capability 与 recoveryV2-00、V2-01、V2-02、V2-03、V2-04 已关闭,direct TurnTimeline、production replay、session history、长列表性能、MCP elicitation、Multi-Agent、三项 product-scope reverse requestmedia read v2`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist 与 `mcpServer/oauthLogin/completed` Gate B 已通过
下一刀:V2-05 已关闭 media transient bypass、`configWarning` owner 迁移thread-scoped `warning` typed/recovery 链;继续审计 `skills/changed` 等具备真实 producer 的 notification`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。不重复改写已关闭的 V2-04 owner,也不恢复 Message synthesis、extension fallback、v0 media/config owner、旧 Team 工具第二 pending store。
下一刀:V2-05 已关闭 media transient bypass、`configWarning` owner 迁移thread-scoped `warning` typed/recovery`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist 与 MCP OAuth typed completion 链;回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。不重复改写已关闭的 V2-04 owner,也不恢复 Message synthesis、extension fallback、v0 media/config owner、旧 MCP OAuth Desktop event、旧 Team 工具第二 pending store、第二 Skill catalog owner、Plan ThreadItem 或由 error 抢占 Turn terminal 的旁路状态机
## 1. 约束与非目标
@@ -252,14 +252,14 @@ Gate B 最少场景:Agent Markdown、Reasoning、Plan、Search、Shell output
## 5. 分类与完成度
| 分类 | v2 裁决 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| current | App Server v2 主链、ConversationProjection、direct TurnTimeline、bounded restored Turn window、canonical long-message preview、production replay、统一 PendingInteraction、thread-scoped media read、typed `configWarning` producer/GUI、thread-scoped typed `warning` 与 durable recovery、六工具 AgentControl、canonical SubAgent activity、parent-owned child direct-input policy、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed DynamicToolCall、model-provider 多模型/多模态 owner |
| compat | 不保留长期生产 compat;仅可存在一次性迁移测试夹具 |
| deprecated | fileChange outputDelta、thread/compacted 与尚未完成 current producer 的旧 notification 裁决;只允许迁出 |
| dead / deleted | canonical Item -> Message tool/agent/reasoning 合成、首帧无界历史挂载、canonical 长正文绕过 preview、unknown Item null drop、ContextCompaction hide、通用 extension fallback、重复 pending store、v0 media/config notification owner、media transient notification/raw subscription/live-drain 旁路、裸旧 Team 工具、raw output 状态推断与生产 mock fallback |
| 分类 | v2 裁决 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| current | App Server v2 主链、ConversationProjection、direct TurnTimeline、bounded restored Turn window、canonical long-message preview、production replay、统一 PendingInteraction、thread-scoped media read、typed `configWarning` producer/GUI、thread-scoped typed `warning` 与 durable recovery、typed `skills/changed` catalog invalidation 与 GUI 自动重读、typed `error` live/durable recovery 与 Turn terminal ownership、`turn/plan/updated` canonical checklist 与 cold recovery、typed `mcpServer/oauthLogin/completed` 与 GUI 自动刷新、六工具 AgentControl、canonical SubAgent activity、parent-owned child direct-input policy、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed DynamicToolCall、model-provider 多模型/多模态 owner |
| compat | 不保留长期生产 compat;仅可存在一次性迁移测试夹具 |
| deprecated | fileChange outputDelta、thread/compacted 与尚未完成 current producer 的旧 notification 裁决;只允许迁出 |
| dead / deleted | canonical Item -> Message tool/agent/reasoning 合成、首帧无界历史挂载、canonical 长正文绕过 preview、unknown Item null drop、ContextCompaction hide、通用 extension fallback、重复 pending store、v0 media/config notification owner、media transient notification/raw subscription/live-drain 旁路、旧 MCP OAuth Desktop event、裸旧 Team 工具、raw output 状态推断与生产 mock fallback |
计划完成度:保守估算 91%。该数字按 V2-00 至 V2-05 六个阶段退出条件与剩余风险折算:V2-00、V2-01、V2-02、V2-03、V2-04 已关闭,V2-05 的 media transient bypass、`configWarning` typed ownerthread-scoped `warning` typed/recovery 链已关闭;其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。
计划完成度:保守估算 95%。该数字按 V2-00 至 V2-05 六个阶段退出条件与剩余风险折算:V2-00、V2-01、V2-02、V2-03、V2-04 已关闭,V2-05 的 media transient bypass、`configWarning` typed ownerthread-scoped `warning` typed/recovery`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` canonical checklist 与 MCP OAuth typed completion 链已关闭;其余 planned notification、host capability 与全面 recovery 仍未完成。该估算不表示 v2 已可整体交付或 release-ready。
## 6. 执行台账
@@ -631,10 +631,90 @@ Gate B 证据:
- full canonical 冷读与 history-limit projection summary 都从同一 durable warning event 派生历史 warning item,并保留 `code`;没有新增第二 persistence 或 canonical Item lifecycle 变体。
- 实时与恢复都只接受精确 message/code shape;畸形 message/code、旧 alias、缺失或不匹配的 thread/turn identity 均 fail closed。raw `agentSession/event` 包装的 `runtime.warning` 被拒绝。`guardianWarning` 因没有 Guardian runtime producer 继续保持 planned。
验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 均通过。`npm run test:contracts` 通过 289 项 app-server-client checks 及全部附属 guardsRust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`;该聚合 smoke 没有普通 warning 专项 DOM 断言,不冒充 warning 专项 Gate B。通用 `npm run verify:gui-smoke` 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260730191439-55464/shell-01-electron-smoke/summary.json``cargo fmt --all --check`、定向 Prettier、`git diff --check``npm run governance:legacy-report` 通过,治理结果为零引用候选 0、分类漂移 0、边界违规 0。
验证:protocol v2 50/50、schema fixture 1/1、App Server warning 12/12、projection-summary recovery 1/1、Renderer 3 files / 61 tests、generated TypeScript drift check 均通过。`npm run test:contracts` 通过 289 项 app-server-client checks 及全部附属 guardsRust related 覆盖 19 个 scoped packages、18 个 lib 目标,共 3597 项(3593 passed、4 个既有环境/live 测试 ignored、0 failed)。`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`;该聚合 smoke 没有普通 warning 专项 DOM 断言,不冒充 warning 专项 Gate B。fresh `npm run verify:local` 通过 i18n、lint、typecheck、112/112 Vitest、contracts、Rust changed-scope、Electron renderer/host build 与真实 GUI smoke;后者 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260730194114-24572/shell-01-electron-smoke/summary.json``cargo fmt --all --check`、定向 Prettier、`git diff --check``npm run governance:legacy-report` 通过,治理结果为零引用候选 0、分类漂移 0、边界违规 0。
治理分类:v2 `warning` protocol/projector、thread-scoped Renderer toast 与 durable recovery 为 `current`;无 `compat`raw warning wrapper 为 `dead / forbidden-to-restore``guardianWarning``planned``v2_notifications.rs` 已超过 1000 行且主要由 inline tests 构成,本刀只增加路由并把业务 projector 放入独立 `warning.rs`;后续触碰 inline test 区前应迁出专用测试模块。
架构确认:typed notification/recovery owner 已同步 `internal/aiprompts/architecture.md` 第 27 节;唯一产品链保持不变。责任开发者确认:root,2026-07-31。
下一刀:审计 `skills/changed` 与其真实 Skill catalog producer/consumer;只有能形成 typed producer -> GUI refresh -> Gate B 的垂直切片才改为 current。v2 总体完成度保守估算约 91%。
### 2026-07-31V2-05 `skills/changed` catalog invalidation
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- v2 protocol、schema、generated client 与 typed decoder 只接受 `skills/changed {}`;默认 Skill roots 的创建、修改、删除以及启动后首次出现都经 10 秒节流 watcher 失效 `lime-skills` snapshot cache 并广播通知。
- 成功的 Lime catalog mutation 在 App Server processor 边界失效同一 cache 并附带 typed notification;失败 mutation 和其他 app 的 mutation 不发通知。
- Composer `useLimeSkills` 的自动加载与 notification refresh 统一读取 current `skill/list`;管理中心 `skillManagement/list` 语义不变,`skillsApi.getRuntimeCatalog()` 复用既有 executable Skill decoder 与纯 catalog projection,不新增第二 catalog owner。
- Renderer 只订阅 typed event bus;命中后记录 console-only `skillsChanged.received` marker 并自动重读,重连/重新挂载仍主动 list。该 notification 是进程级瞬时失效,不写入 Thread/Turn/Item、持久化或 recovery replay。
验证:纯 projection、API、Hook 与 fixture guard 共 4 files / 99 tests 通过;`npm run typecheck``npm run test:contracts`840 schema definitions、832 generated types、0 generation failures、290 app-server-client checks)、`npm run governance:legacy-report`、Prettier、MJS `node --check``cargo fmt --all --check``git diff --check` 通过。Rust workspace lib 汇总为 32 个 suite、4670 passed、5 ignored、0 failed。`npm run test:related` 因仓库既有 runner 把 `electron/` 目录当文件读取而报 `EISDIR`,未记为通过,已由精确 Vitest 覆盖本轮前端回归。
产品证据:`npm run smoke:claw-chat-current-fixture -- --scenario skills-runtime` 通过,proof level 为 `Gate B controlled fixture`。真实 Electron/preload/IPC/App Server 链先产生初始 `skill/list`,随后隔离 HOME 默认 root 新建 `notification-refresh/SKILL.md`typed `skills/changed` marker 为 1`skill/list` 自动由 1 次增至 2 次且 transport 为 `electron-ipc`,GUI 新 Skill 可见,手动刷新点击为 0console/page/actionable errors 与 failed assertions 均为 0。证据:`.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json`
整合复验:聚合 `npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false`,包含 `skills/changed`、Inputbar rich restore、Skills Runtime 三入口及其余 current Agent/Electron fixture。首次聚合运行暴露的 Inputbar 失败属于 fixture DOM 定位合同:夹具用展示名 `Capability Report` 大小写敏感匹配 current catalog 的 `capability-report`,因此候选已可见但未点击;生产 CharacterMention 与 catalog identity 无缺陷。夹具现统一折叠大小写、空格、连字符和下划线,并用 source guard 锁定候选与 `@capability-report` badge 的同一等价规则。独立 `inputbar-rich-restore` Gate B 随后通过,text/image/path/skill 在 output-free cancel 后全部恢复,console/page error 与 mock fallback 为 0;证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-inputbar-rich-restore-regression-summary.json`
通用 `npm run verify:gui-smoke` 通过,真实 Electron renderer、App Server sidecar、Claw shell reload 与 memory settings 均就绪;证据为 `.lime/qc/project-gates/standalone-shell-01-20260731043417-63564/shell-01-electron-smoke/summary.json`。fresh `npm run verify:local` 通过版本一致性和 i18n unused 后,在全仓 lint 被非本切片脏文件 `src/components/input-kit/ModelSelector.tsx:432` 的既有 `react-hooks/exhaustive-deps` warning 阻断,因此未记为通过;本切片未改该文件或越界修复。后续独立 `npm run typecheck`、contracts、99 项精确测试、GUI smoke、Rust fmt 与 Gate B 均已取得明确通过结果。
治理分类:v2 `skills/changed` protocol/schema、App Server watcher/mutation producer、`lime-skills` cache invalidation、typed Renderer event bus 与 current `skill/list` GUI refresh 为 `current`;无 `compat``skillManagement/list` 仅保留管理中心原语义,不是 Composer catalog owner;持久化/replay、`skills/extraRoots/set`、第二 catalog owner 与生产 mock fallback 均未新增。
架构确认:catalog invalidation owner 已同步 `internal/aiprompts/architecture.md` 第 28 节;唯一产品链保持不变。责任开发者确认:root,2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 92%。
### 2026-07-31V2-05 typed `error` retry/terminal closure
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- v2 protocol、schema、generated client 与 strict Renderer decoder 承接 typed `error`App Server projector 将 durable `runtime.error` 投影到同一 thread/turn identityraw `agentSession/event` 不再是 GUI owner。
- `willRetry=true` 只进入可见重试状态,后续权威 `turn/completed(completed)` 清理提示并完成 Turn;一个或多个 retry error 后的 `willRetry=false` 只显示 terminal error,最终失败仍由权威 `turn/completed(failed)` 决定。
- canonical full/limited read model 统一合入 durable runtime warning 与 error。恢复出的 runtime error 保留可见错误证据,但 Turn 保持 running,直到 durable Turn terminal 到达;没有新增第二 persistence、第二 terminal reducer 或 error 合成终态。
- SDK 将 typed error 放在独立 signal 通道,不送入只接受 lifecycle sequence 的 verifier。Gate B assertion 把 provider first-text trace 与 App Server message delta、Renderer output/paint、current trace method、W3C 和 identity 证据拆分;typed-error 场景只豁免本来不会发生的 provider 首文本证据,不能豁免 current 主链证据。
验证:typed-error Renderer/fixture 共 6 files / 94 tests、`packages/app-server-client` 8 files / 98 tests、`packages/agent-runtime-client` 22 tests 通过;Rust canonical runtime error recovery 与 `runtime_error_does_not_preempt_a_later_turn_failed_terminal` 两项定向回归分别通过。`npm run test:contracts` 通过,app-server-client contract 为 292 checks`npm run smoke:agent-runtime-current-fixture` 完整通过且 `liveProviderUsed=false``npm run verify:gui-smoke` 21/21 assertions 通过,证据为 `.lime/qc/project-gates/standalone-shell-01-20260731104641-30858/shell-01-electron-smoke/summary.json`
产品证据:success 场景经真实 Electron、preload/IPC、`app_server_handle_json_lines`、App Server、RuntimeCore、read model 与 GUI 观察 `error(willRetry=true) -> turn/completed(completed)`,同一 identity、零生产 mock fallback,证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-success-gateb-02-summary.json`。failure 场景经同一链观察 `error(true)* -> error(false) -> turn/completed(failed)`pending read model、GUI failed 与最终 read-model/后台 terminal 一致,证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-typed-error-retry-failure-gateb-03-summary.json`。两者 `ok=true`、proof level 均为 `Gate B controlled fixture`;聚合回归证据分别为 `claw-chat-current-fixture-typed-error-retry-success-regression-summary.json``claw-chat-current-fixture-typed-error-retry-failure-regression-summary.json`
治理分类:v2 typed `error` protocol/projector、durable runtime error recovery、strict Renderer signal 与权威 Turn terminal ownership 为 `current`;无 `compat`raw error wrapper、provider trace 对 current 主链证据的错误替代和由 error 抢占 Turn terminal 的第二状态机为 `dead / forbidden-to-restore`;其余 V2-05 planned notification、host capability 与全面 recovery 仍为开放项。
架构确认:本切片没有改变 public owner、唯一产品链或依赖方向,只补齐既有 notification/projector/read-model/Renderer 边界的 typed contract 与恢复语义;无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 93%。
### 2026-07-31V2-05 `turn/plan/updated` canonical checklist 与恢复
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- App Server 从 canonical `update_plan` `ToolOutput.structured_content` 派生 typed `turn.plan.updated`;Renderer 严格解析该通知并把实时快照投影到唯一 run-control checklist owner。
- canonical cold read 从成功的 `update_plan` 工具项恢复最新有效计划,兼容 App Server 真实 wire 的 `[ { name: "plan", value: "[...]" } ]` 参数形状;空数组可以清空旧 checklist,失败或非法快照不会覆盖上一份有效计划。
- read model 保留 canonical `update_plan` 工具项以支持恢复,但不生成 `ThreadItem.plan`,不从 Message/tool card 合成 checklist,也不展示 Plan UI、decision panel 或 `update_plan` 工具行。
验证:`appServerCanonicalThreadProjection.test.ts` 10/10`npm run test:contracts` 通过,app-server-client contract 为 292 checks`npm run typecheck``npm run smoke:agent-runtime-current-fixture``npm run verify:gui-smoke` 均通过。Gate B 证据为 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json``scenario=turn-plan-update``proofLevel=Gate B controlled fixture``methodCount=1`、通知位于事件序列 `planIndex=16``turn/completed` 位于 `terminalIndex=27`;实时与 reload 后均可见 2 项 checklistcanonical read model `updatePlanToolCount=1``planItemCount=0`Plan block/decision/update_plan tool row 均为 0Electron IPC、App Server、read model 与 GUI identity 一致,invoke/page/console error 为 0。
治理分类:`turn/plan/updated` typed producer、strict Renderer parser、canonical `update_plan` cold recovery 与 run-control checklist 为 `current`;无 `compat``update_plan` 场景下的 Plan ThreadItem、Message/tool card 合成、Plan UI/decision panel/tool card 和 renderer mock fallback 均为 `dead / forbidden-to-restore`,独立 Plan mode 的现役 UI 不在本切片范围内。本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`
下一刀:回到 `EVENT-PROJECTIONS.md` 审计下一项具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 94%。
### 2026-07-31V2-05 MCP OAuth typed completion notification
状态:该垂直切片 `completed`V2-05 与 v2 总体仍为 `in-progress`,不得标记 release-ready。
本轮实现:
- Rust MCP OAuth 返回 `McpOAuthLoginHandle` completion futureApp Server 等待 callback 成功或失败后发布 `mcpServer/oauthLogin/completed`payload 固定为必填 `name`、必填可空 `threadId``success` 与可选 `error`
- protocol v2、schema、generated TypeScript 与 app-server-client strict decoder 使用同一 typed contract。Renderer 在任何异步 Desktop listener 注册前同步订阅 App Server event bus,避免快速 callback 被排空,并在完成后自动刷新 `mcpServerStatus/list``mcpTool/list`
- 生产路径已删除 `mcp:oauth_completed`contract guard 禁止该字符串回流 `electron``lime-rs``packages``src`。没有新增 compat wrapper、第二 OAuth 状态 owner或生产 mock fallback。
验证:`lime-mcp` OAuth 22 passed、app-server-protocol v2 59 passed、App Server MCP processor 4 passed、app-server-client 101 passed、`src/hooks/useMcp.test.tsx` 9 passed`npm run test:contracts` 通过,包含 847 schema definitions、839 generated protocol types、0 generation failures 与 292 app-server-client checks。
产品证据:`npm run smoke:mcp-oauth-notification-electron-fixture` 通过,proof level 为 `Gate B`。真实 Electron/preload/IPC、`app_server_handle_json_lines`、App Server OAuth login/event drain、本地 OAuth provider callback 与 Renderer GUI 链全部命中;授权前后状态和 completion toast 均可见,`mcpServer/oauth/login``mcpServerStatus/list``mcpTool/list` 自动刷新成立,`openExternalUrlHitCount=1``electronIpcHitCount=8``mockFallbackHitCount=0``failedInvokeCount=0`console/page/invoke errors 均为 0。证据为 `.lime/qc/mcp-oauth-notification/mcp-oauth-notification-fixture-summary.json` 与同目录 PNG。
治理分类:typed protocol/schema、App Server completion producer、strict client decoder、Renderer typed event bus 与自动刷新为 `current`;无 `compat``mcp:oauth_completed``dead / deleted / forbidden-to-restore`。本切片没有改变 public owner、唯一产品链或依赖方向,无需改写 `internal/aiprompts/architecture.md`。责任开发者确认:root2026-07-31。
下一刀:回到 `EVENT-PROJECTIONS.md` 继续审计具备真实 producer/consumer 的 planned notification、host capability 或 recovery`guardianWarning` 在 Guardian runtime producer 落地前保持 planned。v2 总体完成度保守估算约 95%,仍非 release-ready。
+31 -30
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-protocol"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"schemars",
"serde",
@@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "agent-runtime"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -181,7 +181,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-server"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -209,6 +209,7 @@ dependencies = [
"lime-services",
"lime-skills",
"model-provider",
"notify",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
@@ -241,7 +242,7 @@ dependencies = [
[[package]]
name = "app-server-client"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -252,7 +253,7 @@ dependencies = [
[[package]]
name = "app-server-daemon"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"app-server-protocol",
"app-server-transport",
@@ -264,7 +265,7 @@ dependencies = [
[[package]]
name = "app-server-protocol"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"schemars",
@@ -274,7 +275,7 @@ dependencies = [
[[package]]
name = "app-server-test-client"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"anyhow",
"app-server-client",
@@ -285,7 +286,7 @@ dependencies = [
[[package]]
name = "app-server-transport"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"app-server-protocol",
"axum",
@@ -1355,7 +1356,7 @@ dependencies = [
[[package]]
name = "document-preview"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"flate2",
"tempfile",
@@ -2618,7 +2619,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"agent-runtime",
@@ -2651,7 +2652,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"chrono",
"futures",
@@ -2668,7 +2669,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"clap",
"lime-core",
@@ -2680,7 +2681,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2696,7 +2697,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"async-trait",
@@ -2752,7 +2753,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"aes",
"async-trait",
@@ -2780,7 +2781,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"chrono",
"dashmap",
@@ -2802,7 +2803,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"chrono",
"hex",
@@ -2815,7 +2816,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"async-trait",
"axum",
@@ -2841,7 +2842,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -2860,7 +2861,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"async-trait",
"lime-core",
@@ -2900,7 +2901,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"async-stream",
@@ -2943,7 +2944,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"axum",
"futures",
@@ -2958,7 +2959,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3001,7 +3002,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -3022,7 +3023,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"axum",
"chrono",
@@ -3231,7 +3232,7 @@ dependencies = [
[[package]]
name = "model-provider"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"anyhow",
@@ -3901,7 +3902,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "patch-apply"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"tempfile",
"thiserror 1.0.69",
@@ -4660,7 +4661,7 @@ dependencies = [
[[package]]
name = "runtime-core"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
@@ -5408,7 +5409,7 @@ dependencies = [
[[package]]
name = "thread-store"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"chrono",
@@ -5686,7 +5687,7 @@ dependencies = [
[[package]]
name = "tool-runtime"
version = "1.117.0"
version = "1.118.0"
dependencies = [
"agent-protocol",
"app-server-protocol",
+1 -1
View File
@@ -6,7 +6,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.117.0"
version = "1.118.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
File diff suppressed because it is too large Load Diff
@@ -218,6 +218,10 @@
"kind": "request",
"method": "discordChannel/probe"
},
{
"kind": "notification",
"method": "error"
},
{
"kind": "request",
"method": "evidence/export"
@@ -522,6 +526,10 @@
"kind": "request",
"method": "mcpServer/oauth/login"
},
{
"kind": "notification",
"method": "mcpServer/oauthLogin/completed"
},
{
"kind": "request",
"method": "mcpServer/start"
@@ -994,6 +1002,10 @@
"kind": "request",
"method": "skillRepository/save"
},
{
"kind": "notification",
"method": "skills/changed"
},
{
"kind": "request",
"method": "soulStylePack/install"
@@ -1186,6 +1198,10 @@
"kind": "request",
"method": "turn/interrupt"
},
{
"kind": "notification",
"method": "turn/plan/updated"
},
{
"kind": "request",
"method": "turn/start"
@@ -2190,6 +2206,7 @@
"ArtifactWriteSidecar",
"ClientRequest",
"ClientResponse",
"CodexErrorInfo",
"CollabAgentState",
"CollabAgentStatus",
"CollabAgentTool",
@@ -2212,6 +2229,7 @@
"DynamicToolNamespaceSpec",
"DynamicToolNamespaceTool",
"DynamicToolSpec",
"ErrorNotification",
"FileChangeApprovalDecision",
"FileChangePatchUpdatedNotification",
"FileChangeRequestApprovalParams",
@@ -2232,6 +2250,7 @@
"McpServerElicitationAction",
"McpServerElicitationRequestParams",
"McpServerElicitationRequestResponse",
"McpServerOauthLoginCompletedNotification",
"McpToolCallAppContext",
"McpToolCallError",
"McpToolCallProgressNotification",
@@ -2254,6 +2273,7 @@
"ModelUpgradeInfo",
"ModelVerification",
"ModelVerificationNotification",
"NonSteerableTurnKind",
"PatchApplyStatus",
"PatchChangeKind",
"PermissionGrantScope",
@@ -2268,6 +2288,7 @@
"ServerNotification",
"ServerRequest",
"ServerRequestResolvedNotification",
"SkillsChangedNotification",
"SleepItem",
"SortDirection",
"SubAgentActivityKind",
@@ -2379,6 +2400,9 @@
"TurnInterruptParams",
"TurnInterruptResponse",
"TurnItemsView",
"TurnPlanStep",
"TurnPlanStepStatus",
"TurnPlanUpdatedNotification",
"TurnStartParams",
"TurnStartResponse",
"TurnStartedNotification",
@@ -0,0 +1,144 @@
{
"$defs": {
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
],
"title": "CodexErrorInfo"
}
@@ -0,0 +1,198 @@
{
"$defs": {
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
"null",
"string"
]
},
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Reports a transient retry or terminal error for the current turn.",
"properties": {
"error": {
"$ref": "#/$defs/TurnError"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"willRetry": {
"description": "True only when App Server has already decided to retry this turn.",
"type": "boolean"
}
},
"required": [
"error",
"threadId",
"turnId",
"willRetry"
],
"title": "ErrorNotification",
"type": "object"
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"error": {
"type": [
"null",
"string"
]
},
"name": {
"type": "string"
},
"success": {
"type": "boolean"
},
"threadId": {
"type": [
"null",
"string"
]
}
},
"required": [
"name",
"success",
"threadId"
],
"title": "McpServerOauthLoginCompletedNotification",
"type": "object"
}
@@ -0,0 +1,9 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"enum": [
"compact",
"review"
],
"title": "NonSteerableTurnKind",
"type": "string"
}
@@ -44,6 +44,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -363,6 +496,32 @@
],
"type": "string"
},
"ErrorNotification": {
"additionalProperties": false,
"description": "Reports a transient retry or terminal error for the current turn.",
"properties": {
"error": {
"$ref": "#/$defs/TurnError"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"willRetry": {
"description": "True only when App Server has already decided to retry this turn.",
"type": "boolean"
}
},
"required": [
"error",
"threadId",
"turnId",
"willRetry"
],
"type": "object"
},
"FileChangePatchUpdatedNotification": {
"properties": {
"changes": {
@@ -534,6 +693,35 @@
],
"type": "object"
},
"McpServerOauthLoginCompletedNotification": {
"additionalProperties": false,
"properties": {
"error": {
"type": [
"null",
"string"
]
},
"name": {
"type": "string"
},
"success": {
"type": "boolean"
},
"threadId": {
"type": [
"null",
"string"
]
}
},
"required": [
"name",
"success",
"threadId"
],
"type": "object"
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
@@ -792,6 +980,13 @@
],
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -978,6 +1173,11 @@
],
"type": "object"
},
"SkillsChangedNotification": {
"additionalProperties": false,
"description": "Signals that the process-wide Skill catalog must be read again.",
"type": "object"
},
"SleepItem": {
"properties": {
"durationMs": {
@@ -2249,6 +2449,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -2256,7 +2457,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -2274,6 +2484,61 @@
],
"type": "string"
},
"TurnPlanStep": {
"additionalProperties": false,
"properties": {
"status": {
"$ref": "#/$defs/TurnPlanStepStatus"
},
"step": {
"type": "string"
}
},
"required": [
"status",
"step"
],
"type": "object"
},
"TurnPlanStepStatus": {
"enum": [
"completed",
"inProgress",
"pending"
],
"type": "string"
},
"TurnPlanUpdatedNotification": {
"additionalProperties": false,
"description": "Reports the latest turn-scoped execution checklist.",
"properties": {
"explanation": {
"default": null,
"type": [
"null",
"string"
]
},
"plan": {
"items": {
"$ref": "#/$defs/TurnPlanStep"
},
"type": "array"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"plan",
"threadId",
"turnId"
],
"type": "object"
},
"TurnStartedNotification": {
"description": "Notification payloads are kept beside the request contract so the v2\nprojection can use the same Thread/Turn identity without a second DTO.",
"properties": {
@@ -2499,6 +2764,54 @@
],
"type": "object"
},
{
"properties": {
"method": {
"const": "error",
"type": "string"
},
"params": {
"$ref": "#/$defs/ErrorNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
"const": "skills/changed",
"type": "string"
},
"params": {
"$ref": "#/$defs/SkillsChangedNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
"const": "mcpServer/oauthLogin/completed",
"type": "string"
},
"params": {
"$ref": "#/$defs/McpServerOauthLoginCompletedNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
@@ -2643,6 +2956,22 @@
],
"type": "object"
},
{
"properties": {
"method": {
"const": "turn/plan/updated",
"type": "string"
},
"params": {
"$ref": "#/$defs/TurnPlanUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"type": "object"
},
{
"properties": {
"method": {
@@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Signals that the process-wide Skill catalog must be read again.",
"title": "SkillsChangedNotification",
"type": "object"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1261,6 +1401,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1268,7 +1409,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -465,6 +598,13 @@
}
]
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1426,6 +1566,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1433,7 +1574,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -465,6 +598,13 @@
}
]
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1426,6 +1566,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1433,7 +1574,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1417,6 +1557,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1424,7 +1565,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -465,6 +598,13 @@
}
]
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1426,6 +1566,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1433,7 +1574,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1164,6 +1304,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1171,7 +1312,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -441,6 +574,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1402,6 +1542,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1409,7 +1550,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1107,6 +1247,7 @@
]
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1114,7 +1255,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1164,6 +1304,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1171,7 +1312,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -1,5 +1,148 @@
{
"$defs": {
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -7,7 +150,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -0,0 +1,28 @@
{
"$defs": {
"TurnPlanStepStatus": {
"enum": [
"completed",
"inProgress",
"pending"
],
"type": "string"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"status": {
"$ref": "#/$defs/TurnPlanStepStatus"
},
"step": {
"type": "string"
}
},
"required": [
"status",
"step"
],
"title": "TurnPlanStep",
"type": "object"
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"enum": [
"completed",
"inProgress",
"pending"
],
"title": "TurnPlanStepStatus",
"type": "string"
}
@@ -0,0 +1,59 @@
{
"$defs": {
"TurnPlanStep": {
"additionalProperties": false,
"properties": {
"status": {
"$ref": "#/$defs/TurnPlanStepStatus"
},
"step": {
"type": "string"
}
},
"required": [
"status",
"step"
],
"type": "object"
},
"TurnPlanStepStatus": {
"enum": [
"completed",
"inProgress",
"pending"
],
"type": "string"
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Reports the latest turn-scoped execution checklist.",
"properties": {
"explanation": {
"default": null,
"type": [
"null",
"string"
]
},
"plan": {
"items": {
"$ref": "#/$defs/TurnPlanStep"
},
"type": "array"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"plan",
"threadId",
"turnId"
],
"title": "TurnPlanUpdatedNotification",
"type": "object"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1164,6 +1304,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1171,7 +1312,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1164,6 +1304,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1171,7 +1312,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -21,6 +21,139 @@
],
"type": "object"
},
"CodexErrorInfo": {
"description": "Codex-compatible error classification exposed on the v2 wire.",
"oneOf": [
{
"enum": [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/$defs/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"type": "object"
}
]
},
"CollabAgentState": {
"properties": {
"message": {
@@ -418,6 +551,13 @@
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"compact",
"review"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"completed",
@@ -1164,6 +1304,7 @@
"type": "object"
},
"TurnError": {
"additionalProperties": false,
"properties": {
"additionalDetails": {
"type": [
@@ -1171,7 +1312,16 @@
"string"
]
},
"errorInfo": true,
"codexErrorInfo": {
"anyOf": [
{
"$ref": "#/$defs/CodexErrorInfo"
},
{
"type": "null"
}
]
},
"message": {
"type": "string"
}
@@ -262,12 +262,56 @@ pub struct Turn {
pub duration_ms: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum NonSteerableTurnKind {
Review,
Compact,
}
/// Codex-compatible error classification exposed on the v2 wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum CodexErrorInfo {
ContextWindowExceeded,
SessionBudgetExceeded,
UsageLimitExceeded,
ServerOverloaded,
CyberPolicy,
HttpConnectionFailed {
#[serde(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
ResponseStreamConnectionFailed {
#[serde(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
InternalServerError,
Unauthorized,
BadRequest,
ThreadRollbackFailed,
SandboxError,
ResponseStreamDisconnected {
#[serde(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
ResponseTooManyFailedAttempts {
#[serde(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
ActiveTurnNotSteerable {
#[serde(rename = "turnKind")]
turn_kind: NonSteerableTurnKind,
},
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TurnError {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_info: Option<Value>,
pub codex_error_info: Option<CodexErrorInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_details: Option<String>,
}
@@ -1,14 +1,15 @@
use super::{
AgentMessageDeltaNotification, ArtifactWriteParams, ArtifactWriteResponse,
CommandExecutionOutputDeltaNotification, CommandExecutionRequestApprovalParams,
ConfigWarningNotification, CurrentTimeReadParams, DynamicToolCallParams,
ConfigWarningNotification, CurrentTimeReadParams, DynamicToolCallParams, ErrorNotification,
FileChangePatchUpdatedNotification, FileChangeRequestApprovalParams, ItemCompletedNotification,
ItemStartedNotification, McpServerElicitationRequestParams, McpToolCallProgressNotification,
MediaReadParams, MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification,
ItemStartedNotification, McpServerElicitationRequestParams,
McpServerOauthLoginCompletedNotification, McpToolCallProgressNotification, MediaReadParams,
MediaReadResponse, Method, ModelListParams, ModelListUpdatedNotification,
ModelReroutedNotification, ModelSafetyBufferingUpdatedNotification,
ModelVerificationNotification, PermissionsRequestApprovalParams, PlanDeltaNotification,
ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification, ServerRequestResolvedNotification,
ReasoningTextDeltaNotification, ServerRequestResolvedNotification, SkillsChangedNotification,
ThreadApproveGuardianDeniedActionParams, ThreadApproveGuardianDeniedActionResponse,
ThreadArchiveParams, ThreadArchiveResponse, ThreadArchivedNotification,
ThreadBackgroundTerminalsCleanParams, ThreadBackgroundTerminalsCleanResponse,
@@ -33,19 +34,21 @@ use super::{
ThreadTokenUsageUpdatedNotification, ThreadTurnsListParams, ThreadTurnsListResponse,
ThreadUnarchiveParams, ThreadUnarchiveResponse, ThreadUnarchivedNotification,
ThreadUnsubscribeParams, ThreadUnsubscribeResponse, ToolRequestUserInputParams,
TurnCompletedNotification, TurnInterruptParams, TurnInterruptResponse, TurnStartParams,
TurnStartResponse, TurnStartedNotification, TurnSteerParams, TurnSteerResponse,
WarningNotification, METHOD_COMMAND_EXECUTION_OUTPUT_DELTA, METHOD_CONFIG_WARNING,
METHOD_CURRENT_TIME_READ, METHOD_FILE_CHANGE_PATCH_UPDATED,
METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL, METHOD_ITEM_FILE_CHANGE_REQUEST_APPROVAL,
METHOD_ITEM_PERMISSIONS_REQUEST_APPROVAL, METHOD_ITEM_TOOL_CALL,
METHOD_ITEM_TOOL_REQUEST_USER_INPUT, METHOD_MCP_SERVER_ELICITATION_REQUEST,
TurnCompletedNotification, TurnInterruptParams, TurnInterruptResponse,
TurnPlanUpdatedNotification, TurnStartParams, TurnStartResponse, TurnStartedNotification,
TurnSteerParams, TurnSteerResponse, WarningNotification, METHOD_COMMAND_EXECUTION_OUTPUT_DELTA,
METHOD_CONFIG_WARNING, METHOD_CURRENT_TIME_READ, METHOD_ERROR,
METHOD_FILE_CHANGE_PATCH_UPDATED, METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL,
METHOD_ITEM_FILE_CHANGE_REQUEST_APPROVAL, METHOD_ITEM_PERMISSIONS_REQUEST_APPROVAL,
METHOD_ITEM_TOOL_CALL, METHOD_ITEM_TOOL_REQUEST_USER_INPUT,
METHOD_MCP_SERVER_ELICITATION_REQUEST, METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED,
METHOD_MCP_TOOL_CALL_PROGRESS, METHOD_MODEL_LIST_UPDATED, METHOD_MODEL_REROUTED,
METHOD_MODEL_SAFETY_BUFFERING_UPDATED, METHOD_MODEL_VERIFICATION, METHOD_PLAN_DELTA,
METHOD_REASONING_SUMMARY_PART_ADDED, METHOD_REASONING_SUMMARY_TEXT_DELTA,
METHOD_REASONING_TEXT_DELTA, METHOD_SERVER_REQUEST_RESOLVED, METHOD_THREAD_CLOSED,
METHOD_THREAD_GOAL_CLEARED, METHOD_THREAD_GOAL_UPDATED, METHOD_THREAD_NAME_UPDATED,
METHOD_THREAD_STATUS_CHANGED, METHOD_THREAD_TOKEN_USAGE_UPDATED, METHOD_WARNING,
METHOD_REASONING_TEXT_DELTA, METHOD_SERVER_REQUEST_RESOLVED, METHOD_SKILLS_CHANGED,
METHOD_THREAD_CLOSED, METHOD_THREAD_GOAL_CLEARED, METHOD_THREAD_GOAL_UPDATED,
METHOD_THREAD_NAME_UPDATED, METHOD_THREAD_STATUS_CHANGED, METHOD_THREAD_TOKEN_USAGE_UPDATED,
METHOD_TURN_PLAN_UPDATED, METHOD_WARNING,
};
use crate::{JsonRpcNotification, JsonRpcRequest, RequestId};
use schemars::JsonSchema;
@@ -645,6 +648,12 @@ pub enum ServerNotification {
ConfigWarning(ConfigWarningNotification),
#[serde(rename = "warning")]
Warning(WarningNotification),
#[serde(rename = "error")]
Error(ErrorNotification),
#[serde(rename = "skills/changed")]
SkillsChanged(SkillsChangedNotification),
#[serde(rename = "mcpServer/oauthLogin/completed")]
McpServerOauthLoginCompleted(McpServerOauthLoginCompletedNotification),
#[serde(rename = "thread/started")]
ThreadStarted(ThreadStartedNotification),
#[serde(rename = "thread/archived")]
@@ -663,6 +672,8 @@ pub enum ServerNotification {
TurnStarted(TurnStartedNotification),
#[serde(rename = "turn/completed")]
TurnCompleted(TurnCompletedNotification),
#[serde(rename = "turn/plan/updated")]
TurnPlanUpdated(TurnPlanUpdatedNotification),
#[serde(rename = "item/started")]
ItemStarted(ItemStartedNotification),
#[serde(rename = "item/completed")]
@@ -708,6 +719,9 @@ impl ServerNotification {
match self {
Self::ConfigWarning(_) => METHOD_CONFIG_WARNING,
Self::Warning(_) => METHOD_WARNING,
Self::Error(_) => METHOD_ERROR,
Self::SkillsChanged(_) => METHOD_SKILLS_CHANGED,
Self::McpServerOauthLoginCompleted(_) => METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED,
Self::ThreadStarted(_) => "thread/started",
Self::ThreadArchived(_) => "thread/archived",
Self::ThreadDeleted(_) => "thread/deleted",
@@ -717,6 +731,7 @@ impl ServerNotification {
Self::ThreadStatusChanged(_) => METHOD_THREAD_STATUS_CHANGED,
Self::TurnStarted(_) => "turn/started",
Self::TurnCompleted(_) => "turn/completed",
Self::TurnPlanUpdated(_) => METHOD_TURN_PLAN_UPDATED,
Self::ItemStarted(_) => "item/started",
Self::ItemCompleted(_) => "item/completed",
Self::AgentMessageDelta(_) => "item/agentMessage/delta",
@@ -743,7 +758,7 @@ impl ServerNotification {
impl TryFrom<JsonRpcNotification> for ServerNotification {
type Error = String;
fn try_from(notification: JsonRpcNotification) -> Result<Self, Self::Error> {
fn try_from(notification: JsonRpcNotification) -> Result<Self, String> {
let params = notification.params.unwrap_or_else(|| serde_json::json!({}));
match notification.method.as_str() {
METHOD_CONFIG_WARNING => serde_json::from_value(params)
@@ -752,6 +767,15 @@ impl TryFrom<JsonRpcNotification> for ServerNotification {
METHOD_WARNING => serde_json::from_value(params)
.map(Self::Warning)
.map_err(|error| error.to_string()),
METHOD_ERROR => serde_json::from_value(params)
.map(Self::Error)
.map_err(|error| error.to_string()),
METHOD_SKILLS_CHANGED => serde_json::from_value(params)
.map(Self::SkillsChanged)
.map_err(|error| error.to_string()),
METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED => serde_json::from_value(params)
.map(Self::McpServerOauthLoginCompleted)
.map_err(|error| error.to_string()),
"thread/started" => serde_json::from_value(params)
.map(Self::ThreadStarted)
.map_err(|error| error.to_string()),
@@ -779,6 +803,9 @@ impl TryFrom<JsonRpcNotification> for ServerNotification {
"turn/completed" => serde_json::from_value(params)
.map(Self::TurnCompleted)
.map_err(|error| error.to_string()),
METHOD_TURN_PLAN_UPDATED => serde_json::from_value(params)
.map(Self::TurnPlanUpdated)
.map_err(|error| error.to_string()),
"item/started" => serde_json::from_value(params)
.map(Self::ItemStarted)
.map_err(|error| error.to_string()),
@@ -848,6 +875,13 @@ impl From<ServerNotification> for JsonRpcNotification {
jsonrpc_notification(METHOD_CONFIG_WARNING, params)
}
ServerNotification::Warning(params) => jsonrpc_notification(METHOD_WARNING, params),
ServerNotification::Error(params) => jsonrpc_notification(METHOD_ERROR, params),
ServerNotification::SkillsChanged(params) => {
jsonrpc_notification(METHOD_SKILLS_CHANGED, params)
}
ServerNotification::McpServerOauthLoginCompleted(params) => {
jsonrpc_notification(METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED, params)
}
ServerNotification::ThreadStarted(params) => {
jsonrpc_notification("thread/started", params)
}
@@ -873,6 +907,9 @@ impl From<ServerNotification> for JsonRpcNotification {
ServerNotification::TurnCompleted(params) => {
jsonrpc_notification("turn/completed", params)
}
ServerNotification::TurnPlanUpdated(params) => {
jsonrpc_notification(METHOD_TURN_PLAN_UPDATED, params)
}
ServerNotification::ItemStarted(params) => jsonrpc_notification("item/started", params),
ServerNotification::ItemCompleted(params) => {
jsonrpc_notification("item/completed", params)
@@ -28,6 +28,21 @@ pub struct McpToolCallProgressNotification {
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct McpServerOauthLoginCompletedNotification {
pub name: String,
#[schemars(schema_with = "nullable_string_schema")]
pub thread_id: Option<String>,
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<Option<String>>()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct McpServerElicitationRequestParams {
@@ -238,4 +253,47 @@ mod tests {
.is_err()
);
}
#[test]
fn oauth_login_completed_matches_codex_wire_and_fails_closed() {
let success = McpServerOauthLoginCompletedNotification {
name: "remote-docs".to_string(),
thread_id: None,
success: true,
error: None,
};
assert_eq!(
serde_json::to_value(success).expect("serialize OAuth completion"),
json!({
"name": "remote-docs",
"threadId": null,
"success": true
})
);
assert!(
serde_json::from_value::<McpServerOauthLoginCompletedNotification>(json!({
"name": "remote-docs",
"threadId": null,
"success": false,
"error": "scope rejected",
"serverName": "legacy-name"
}))
.is_err()
);
let schema = serde_json::to_value(schemars::schema_for!(
McpServerOauthLoginCompletedNotification
))
.expect("serialize OAuth completion schema");
let required = schema["required"]
.as_array()
.expect("OAuth completion schema required fields");
assert!(required.iter().any(|field| field == "threadId"));
let thread_id_types = schema["properties"]["threadId"]["type"]
.as_array()
.expect("OAuth completion threadId must be nullable");
assert!(thread_id_types.iter().any(|value| value == "null"));
assert!(thread_id_types.iter().any(|value| value == "string"));
}
}
@@ -48,6 +48,7 @@ pub const METHOD_THREAD_NAME_UPDATED: &str = "thread/name/updated";
pub const METHOD_THREAD_STATUS_CHANGED: &str = "thread/status/changed";
pub const METHOD_TURN_STARTED: &str = "turn/started";
pub const METHOD_TURN_COMPLETED: &str = "turn/completed";
pub const METHOD_TURN_PLAN_UPDATED: &str = "turn/plan/updated";
pub const METHOD_ITEM_STARTED: &str = "item/started";
pub const METHOD_ITEM_COMPLETED: &str = "item/completed";
pub const METHOD_AGENT_MESSAGE_DELTA: &str = "item/agentMessage/delta";
@@ -68,6 +69,9 @@ pub const METHOD_THREAD_GOAL_CLEARED: &str = "thread/goal/cleared";
pub const METHOD_SERVER_REQUEST_RESOLVED: &str = "serverRequest/resolved";
pub const METHOD_CONFIG_WARNING: &str = "configWarning";
pub const METHOD_WARNING: &str = "warning";
pub const METHOD_ERROR: &str = "error";
pub const METHOD_SKILLS_CHANGED: &str = "skills/changed";
pub const METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED: &str = "mcpServer/oauthLogin/completed";
pub const METHOD_CURRENT_TIME_READ: &str = "currentTime/read";
pub const METHOD_MCP_SERVER_ELICITATION_REQUEST: &str = "mcpServer/elicitation/request";
pub const METHOD_ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL: &str =
@@ -287,6 +291,9 @@ pub const METHODS: &[&str] = &[
pub const NOTIFICATION_METHODS: &[&str] = &[
METHOD_CONFIG_WARNING,
METHOD_WARNING,
METHOD_ERROR,
METHOD_SKILLS_CHANGED,
METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED,
METHOD_THREAD_STARTED,
METHOD_THREAD_ARCHIVED,
METHOD_THREAD_DELETED,
@@ -296,6 +303,7 @@ pub const NOTIFICATION_METHODS: &[&str] = &[
METHOD_THREAD_STATUS_CHANGED,
METHOD_TURN_STARTED,
METHOD_TURN_COMPLETED,
METHOD_TURN_PLAN_UPDATED,
METHOD_ITEM_STARTED,
METHOD_ITEM_COMPLETED,
METHOD_AGENT_MESSAGE_DELTA,
@@ -1,7 +1,50 @@
use super::TurnError;
use crate::RequestId;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Signals that the process-wide Skill catalog must be read again.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillsChangedNotification {}
/// Reports a transient retry or terminal error for the current turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ErrorNotification {
pub error: TurnError,
/// True only when App Server has already decided to retry this turn.
pub will_retry: bool,
pub thread_id: String,
pub turn_id: String,
}
/// Reports the latest turn-scoped execution checklist.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TurnPlanUpdatedNotification {
pub thread_id: String,
pub turn_id: String,
#[serde(default)]
pub explanation: Option<String>,
pub plan: Vec<TurnPlanStep>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TurnPlanStep {
pub step: String,
pub status: TurnPlanStepStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum TurnPlanStepStatus {
Pending,
InProgress,
Completed,
}
/// A user-visible runtime warning projected from the current event stream.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
@@ -7,6 +7,9 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"TextRange",
"ConfigWarningNotification",
"WarningNotification",
"ErrorNotification",
"SkillsChangedNotification",
"McpServerOauthLoginCompletedNotification",
"CurrentTimeReadParams",
"CurrentTimeReadResponse",
"AdditionalNetworkPermissions",
@@ -72,6 +75,8 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"Thread",
"GitInfo",
"Turn",
"CodexErrorInfo",
"NonSteerableTurnKind",
"TurnError",
"ThreadItem",
"HookPromptFragment",
@@ -197,6 +202,9 @@ pub const V2_SCHEMA_TYPE_NAMES: &[&str] = &[
"ThreadClosedNotification",
"TurnStartedNotification",
"TurnCompletedNotification",
"TurnPlanUpdatedNotification",
"TurnPlanStep",
"TurnPlanStepStatus",
"ItemStartedNotification",
"ItemCompletedNotification",
"AgentMessageDeltaNotification",
@@ -550,6 +550,121 @@ fn warning_notification_round_trips_thread_scoped_lime_shape() {
.is_err());
}
#[test]
fn error_notification_round_trips_exact_codex_shape() {
let expected = json!({
"method": "error",
"params": {
"error": {
"message": "provider stream timed out",
"codexErrorInfo": {
"responseStreamDisconnected": {
"httpStatusCode": null
}
},
"additionalDetails": "request id: req-1"
},
"willRetry": false,
"threadId": "thread-1",
"turnId": "turn-1"
}
});
let notification: ServerNotification =
serde_json::from_value(expected.clone()).expect("decode error notification");
assert_eq!(notification.method(), METHOD_ERROR);
let raw: crate::JsonRpcNotification = notification.clone().into();
assert_eq!(raw.method, METHOD_ERROR);
assert_eq!(
ServerNotification::try_from(raw).expect("decode JSON-RPC error notification"),
notification
);
assert_eq!(
serde_json::to_value(notification).expect("encode error notification"),
expected
);
assert!(NOTIFICATION_METHODS.contains(&METHOD_ERROR));
for malformed in [
json!({
"method": "error",
"params": {
"error": { "message": "failed" },
"threadId": "thread-1",
"turnId": "turn-1"
}
}),
json!({
"method": "error",
"params": {
"error": { "message": "failed" },
"willRetry": "false",
"threadId": "thread-1",
"turnId": "turn-1"
}
}),
json!({
"method": "error",
"params": {
"error": { "message": "failed", "errorInfo": "legacy" },
"willRetry": false,
"threadId": "thread-1",
"turnId": "turn-1"
}
}),
json!({
"method": "error",
"params": {
"error": { "message": "failed", "additionalDetails": 42 },
"willRetry": false,
"threadId": "thread-1",
"turnId": "turn-1"
}
}),
json!({
"method": "error",
"params": {
"error": { "message": "failed" },
"willRetry": false,
"threadId": "thread-1",
"turnId": "turn-1",
"retryable": true
}
}),
] {
assert!(serde_json::from_value::<ServerNotification>(malformed).is_err());
}
}
#[test]
fn skills_changed_notification_round_trips_empty_invalidation_shape() {
let expected = json!({
"method": "skills/changed",
"params": {}
});
let notification: ServerNotification =
serde_json::from_value(expected.clone()).expect("decode skills changed notification");
assert_eq!(notification.method(), METHOD_SKILLS_CHANGED);
let raw: crate::JsonRpcNotification = notification.clone().into();
assert_eq!(raw.method, METHOD_SKILLS_CHANGED);
assert_eq!(raw.params, Some(json!({})));
assert_eq!(
ServerNotification::try_from(raw).expect("decode JSON-RPC skills changed notification"),
notification
);
assert_eq!(
serde_json::to_value(notification).expect("encode skills changed notification"),
expected
);
assert!(NOTIFICATION_METHODS.contains(&METHOD_SKILLS_CHANGED));
assert!(serde_json::from_value::<ServerNotification>(json!({
"method": "skills/changed",
"params": { "path": "/private/skill" }
}))
.is_err());
}
#[test]
fn thread_search_occurrences_round_trips_exact_codex_shape() {
let expected = json!({
@@ -1113,6 +1228,41 @@ fn plan_delta_notification_round_trips_codex_shape() {
assert!(NOTIFICATION_METHODS.contains(&METHOD_PLAN_DELTA));
}
#[test]
fn turn_plan_updated_notification_round_trips_codex_shape_and_rejects_unknown_fields() {
let expected = json!({
"method": "turn/plan/updated",
"params": {
"threadId": "thread_1",
"turnId": "turn_2",
"explanation": "继续执行",
"plan": [
{ "step": "读取现状", "status": "completed" },
{ "step": "补齐主链", "status": "inProgress" }
]
}
});
let notification: ServerNotification =
serde_json::from_value(expected.clone()).expect("decode turn plan update");
assert_eq!(notification.method(), METHOD_TURN_PLAN_UPDATED);
let raw: crate::JsonRpcNotification = notification.clone().into();
assert_eq!(raw.method, METHOD_TURN_PLAN_UPDATED);
assert_eq!(
ServerNotification::try_from(raw).expect("decode JSON-RPC turn plan update"),
notification
);
assert_eq!(
serde_json::to_value(notification).expect("encode turn plan update"),
expected
);
assert!(NOTIFICATION_METHODS.contains(&METHOD_TURN_PLAN_UPDATED));
let mut malformed = expected;
malformed["params"]["plan"][0]["unexpected"] = json!(true);
assert!(serde_json::from_value::<ServerNotification>(malformed).is_err());
}
#[test]
fn command_execution_output_delta_notification_round_trips_codex_shape() {
let expected = json!({
@@ -2119,6 +2269,9 @@ fn typed_v2_envelope_schema_names_are_stable() {
[
"configWarning",
"warning",
"error",
"skills/changed",
"mcpServer/oauthLogin/completed",
"thread/started",
"thread/archived",
"thread/deleted",
@@ -2128,6 +2281,7 @@ fn typed_v2_envelope_schema_names_are_stable() {
"thread/status/changed",
"turn/started",
"turn/completed",
"turn/plan/updated",
"item/started",
"item/completed",
"item/agentMessage/delta",
@@ -748,6 +748,11 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::TextRange>("TextRange"),
typed_schema::<v2::ConfigWarningNotification>("ConfigWarningNotification"),
typed_schema::<v2::WarningNotification>("WarningNotification"),
typed_schema::<v2::ErrorNotification>("ErrorNotification"),
typed_schema::<v2::SkillsChangedNotification>("SkillsChangedNotification"),
typed_schema::<v2::McpServerOauthLoginCompletedNotification>(
"McpServerOauthLoginCompletedNotification",
),
typed_schema::<v2::CurrentTimeReadParams>("CurrentTimeReadParams"),
typed_schema::<v2::CurrentTimeReadResponse>("CurrentTimeReadResponse"),
typed_schema::<v2::AdditionalNetworkPermissions>("AdditionalNetworkPermissions"),
@@ -823,6 +828,8 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::Thread>("Thread"),
typed_schema::<v2::GitInfo>("GitInfo"),
typed_schema::<v2::Turn>("Turn"),
typed_schema::<v2::CodexErrorInfo>("CodexErrorInfo"),
typed_schema::<v2::NonSteerableTurnKind>("NonSteerableTurnKind"),
typed_schema::<v2::TurnError>("TurnError"),
typed_schema::<v2::ThreadItem>("ThreadItem"),
typed_schema::<v2::HookPromptFragment>("HookPromptFragment"),
@@ -972,6 +979,9 @@ pub(super) fn v2_schemas() -> Vec<GeneratedJsonSchema> {
typed_schema::<v2::ThreadClosedNotification>("ThreadClosedNotification"),
typed_schema::<v2::TurnStartedNotification>("TurnStartedNotification"),
typed_schema::<v2::TurnCompletedNotification>("TurnCompletedNotification"),
typed_schema::<v2::TurnPlanUpdatedNotification>("TurnPlanUpdatedNotification"),
typed_schema::<v2::TurnPlanStep>("TurnPlanStep"),
typed_schema::<v2::TurnPlanStepStatus>("TurnPlanStepStatus"),
typed_schema::<v2::ItemStartedNotification>("ItemStartedNotification"),
typed_schema::<v2::ItemCompletedNotification>("ItemCompletedNotification"),
typed_schema::<v2::AgentMessageDeltaNotification>("AgentMessageDeltaNotification"),
+1
View File
@@ -37,6 +37,7 @@ lime-browser-runtime.workspace = true
lime-media-runtime.workspace = true
lime-mcp.workspace = true
model-provider.workspace = true
notify.workspace = true
runtime-core.workspace = true
lime-skills.workspace = true
lime-services = { workspace = true, features = ["local-sensevoice"] }
+9
View File
@@ -35,6 +35,7 @@ mod runtime_backend;
mod runtime_factory;
mod server_request;
mod skill_registry;
mod skills_watcher;
mod thread_listener;
#[cfg(test)]
mod thread_listener_tests;
@@ -295,6 +296,7 @@ pub struct AppServer {
server_requests: server_request::ServerRequestRouter,
current_time_requests: current_time::CurrentTimeRequestRouter,
mcp_elicitation_requests: mcp_elicitation::ElicitationRequestSource,
_skills_watcher: Option<Arc<skills_watcher::SkillsWatcher>>,
}
#[derive(Clone)]
@@ -345,6 +347,7 @@ impl AppServer {
};
let interrupt_router = server_requests.clone();
let notification_bridge = interrupt_bridge.clone();
let skills_watcher_bridge = notification_bridge.clone();
let turn_interrupt_hook: processor::TurnInterruptHook =
Arc::new(move |thread_id, turn_id| {
let bridge = interrupt_bridge.clone();
@@ -364,6 +367,11 @@ impl AppServer {
.await;
})
});
let skills_watcher = if cfg!(test) {
None
} else {
skills_watcher::SkillsWatcher::start_default(skills_watcher_bridge)
};
Self {
processor: RequestProcessor::new_with_thread_states(runtime, thread_states.clone())
.with_turn_interrupt_hook(turn_interrupt_hook)
@@ -380,6 +388,7 @@ impl AppServer {
server_requests,
current_time_requests,
mcp_elicitation_requests: mcp_elicitation::ElicitationRequestSource::default(),
_skills_watcher: skills_watcher,
}
}
@@ -1,5 +1,5 @@
use super::super::*;
use app_server_protocol::{McpServerOauthLoginParams, McpServerOauthLoginResponse};
use app_server_protocol::McpServerOauthLoginParams;
use async_trait::async_trait;
#[async_trait]
@@ -76,7 +76,7 @@ impl McpAppDataSource for LocalAppDataSource {
async fn login_mcp_server_oauth(
&self,
params: McpServerOauthLoginParams,
) -> Result<McpServerOauthLoginResponse, RuntimeCoreError> {
) -> Result<lime_mcp::McpOAuthLoginHandle, RuntimeCoreError> {
mcp::login_mcp_server_oauth(&self.db, &self.mcp_manager, params).await
}
@@ -20,7 +20,6 @@ use app_server_protocol::McpServerImportFromAppResponse;
use app_server_protocol::McpServerLifecycleResponse;
use app_server_protocol::McpServerListResponse;
use app_server_protocol::McpServerOauthLoginParams;
use app_server_protocol::McpServerOauthLoginResponse;
use app_server_protocol::McpServerStartParams;
use app_server_protocol::McpServerStatusListResponse;
use app_server_protocol::McpServerStopParams;
@@ -165,7 +164,7 @@ pub(crate) async fn login_mcp_server_oauth(
db: &DbConnection,
manager: &McpManagerState,
params: McpServerOauthLoginParams,
) -> Result<McpServerOauthLoginResponse, RuntimeCoreError> {
) -> Result<lime_mcp::McpOAuthLoginHandle, RuntimeCoreError> {
let server = McpService::get_all(db)
.map_err(data_error)?
.into_iter()
@@ -184,10 +183,6 @@ pub(crate) async fn login_mcp_server_oauth(
)
.await
.map_err(mcp_error)
.map(|response| McpServerOauthLoginResponse {
authorization_url: response.authorization_url,
state: response.state,
})
}
pub(crate) async fn list_mcp_tools(
@@ -18,6 +18,7 @@ use app_server_protocol::METHOD_MCP_SERVER_STATUS_LIST;
use app_server_protocol::METHOD_MCP_SERVER_STOP;
use app_server_protocol::METHOD_MCP_TOOL_CALL;
use app_server_protocol::METHOD_MCP_TOOL_LIST;
use app_server_protocol::METHOD_SKILL_CACHE_REFRESH;
use app_server_protocol::SERVER_NAME;
use app_server_transport::decode_message;
use lime_core::database::schema::create_tables;
@@ -82,6 +83,55 @@ fn setup_data_source() -> LocalAppDataSource {
}
}
#[tokio::test]
async fn successful_skill_catalog_mutation_emits_typed_invalidation() {
let server = AppServer::with_runtime(
RuntimeCore::with_backend(Arc::new(MockBackend))
.with_app_data_source(Arc::new(setup_data_source())),
);
let init = app_server_request(
&server,
1,
METHOD_INITIALIZE,
json!({
"clientInfo": { "name": "skills-changed-test", "version": "test" },
"capabilities": {}
}),
)
.await;
assert_eq!(
init.pointer("/result/serverInfo/name"),
Some(&json!(SERVER_NAME))
);
app_server_notification(&server, METHOD_INITIALIZED, json!({})).await;
let line = json!({
"jsonrpc": "2.0",
"id": 2,
"method": METHOD_SKILL_CACHE_REFRESH,
"params": {}
})
.to_string();
let responses = server
.handle_json_line(&line)
.await
.expect("handle Skill catalog mutation");
assert_eq!(responses.len(), 2, "{responses:?}");
let response: Value = serde_json::from_str(&responses[0]).expect("decode response");
let notification = decode_message(&responses[1]).expect("decode notification");
assert_eq!(response["result"], json!({ "success": true }));
assert_eq!(
notification,
app_server_protocol::JsonRpcMessage::Notification(
app_server_protocol::protocol::v2::ServerNotification::SkillsChanged(
app_server_protocol::protocol::v2::SkillsChangedNotification {},
)
.into(),
)
);
}
#[tokio::test]
async fn diagnostics_use_injected_agent_log_root() {
let temp = tempfile::tempdir().expect("tempdir");
+29 -3
View File
@@ -1,12 +1,16 @@
//! mcp domain handlers for the App Server processor.
use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch};
use app_server_protocol::protocol::v2::{
McpServerOauthLoginCompletedNotification, ServerNotification,
};
use app_server_protocol::{
JsonRpcError, McpPromptGetParams, McpResourceReadParams, McpResourceSubscribeParams,
McpResourceUnsubscribeParams, McpServerCreateParams, McpServerDeleteParams,
McpServerEnabledSetParams, McpServerImportFromAppParams, McpServerOauthLoginParams,
McpServerStartParams, McpServerStopParams, McpServerUpdateParams, McpToolCallParams,
McpToolCallWithCallerParams, McpToolListForContextParams, McpToolSearchParams,
McpServerOauthLoginResponse, McpServerStartParams, McpServerStopParams, McpServerUpdateParams,
McpToolCallParams, McpToolCallWithCallerParams, McpToolListForContextParams,
McpToolSearchParams,
};
impl RequestProcessor {
@@ -148,11 +152,33 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: McpServerOauthLoginParams = parse_params(params)?;
let response = self
let server_name = params.name.clone();
let handle = self
.runtime
.login_mcp_server_oauth(params)
.await
.map_err(to_jsonrpc_error)?;
let response = McpServerOauthLoginResponse {
authorization_url: handle.authorization_url.clone(),
state: handle.state.clone(),
};
let processor = self.clone();
tokio::spawn(async move {
let (success, error) = match handle.wait().await {
Ok(()) => (true, None),
Err(error) => (false, Some(error.to_string())),
};
processor
.publish_server_notification(ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: server_name,
thread_id: None,
success,
error,
},
))
.await;
});
dispatch_result(response)
}
@@ -1,6 +1,9 @@
//! skill domain handlers for the App Server processor.
use super::{dispatch_result, parse_params, to_jsonrpc_error, RequestProcessor, RpcDispatch};
use app_server_protocol::protocol::v2::{
ServerNotification as V2ServerNotification, SkillsChangedNotification,
};
use app_server_protocol::{
JsonRpcError, SkillDownloadInstallParams, SkillLocalDetailInspectParams,
SkillLocalImportParams, SkillLocalInspectParams, SkillLocalRenameParams,
@@ -10,6 +13,7 @@ use app_server_protocol::{
SkillRemoteInspectParams, SkillRepositoryDeleteParams, SkillRepositorySaveParams,
SkillScaffoldCreateParams,
};
use serde::Serialize;
impl RequestProcessor {
pub(super) async fn handle_skill_list_impl(&self) -> Result<RpcDispatch, JsonRpcError> {
@@ -52,12 +56,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillManagementInstallParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.install_management_skill(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_management_uninstall_impl(
@@ -66,12 +71,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillManagementUninstallParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.uninstall_management_skill(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_repository_list_impl(
@@ -97,7 +103,7 @@ impl RequestProcessor {
.save_skill_repository(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, true)
}
pub(super) async fn handle_skill_repository_delete_impl(
@@ -111,7 +117,7 @@ impl RequestProcessor {
.delete_skill_repository(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, true)
}
pub(super) async fn handle_skill_cache_refresh_impl(
@@ -123,7 +129,7 @@ impl RequestProcessor {
.refresh_skill_cache()
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, true)
}
pub(super) async fn handle_skill_installed_directories_list_impl(
@@ -186,12 +192,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillScaffoldCreateParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.create_skill_scaffold(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_local_import_impl(
@@ -200,12 +207,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillLocalImportParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.import_local_skill(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_local_rename_impl(
@@ -214,12 +222,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillLocalRenameParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.rename_local_skill(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_remote_inspect_impl(
@@ -242,12 +251,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillPackageLocalInstallParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.install_local_skill_package(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_package_local_replace_impl(
@@ -256,12 +266,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillPackageLocalReplaceParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.replace_local_skill_package(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_package_export_impl(
@@ -284,12 +295,13 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillMarketplaceInstallParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.install_marketplace_skill(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
pub(super) async fn handle_skill_download_install_impl(
@@ -298,11 +310,30 @@ impl RequestProcessor {
) -> Result<RpcDispatch, JsonRpcError> {
self.ensure_initialized()?;
let params: SkillDownloadInstallParams = parse_params(params)?;
let changes_lime_catalog = is_lime_app(&params.app);
let response = self
.runtime
.install_skill_from_download_url(params)
.await
.map_err(to_jsonrpc_error)?;
dispatch_result(response)
dispatch_skill_catalog_mutation(response, changes_lime_catalog)
}
}
fn dispatch_skill_catalog_mutation(
response: impl Serialize,
changes_lime_catalog: bool,
) -> Result<RpcDispatch, JsonRpcError> {
let dispatch = dispatch_result(response)?;
if !changes_lime_catalog {
return Ok(dispatch);
}
lime_skills::invalidate_agent_skill_snapshot_cache();
Ok(dispatch.with_notification(
V2ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(),
))
}
fn is_lime_app(app: &str) -> bool {
app.trim().eq_ignore_ascii_case("lime")
}
@@ -50,6 +50,44 @@ fn event_notifications_jsonrpc_emits_direct_agent_message_delta() {
);
}
#[test]
fn event_notifications_jsonrpc_emits_direct_turn_plan_update() {
let message = single_event_notification(AgentEvent {
event_id: "evt_plan_1".to_string(),
sequence: 2,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "turn.plan.updated".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({
"explanation": "继续执行",
"plan": [
{ "step": "读取现状", "status": "completed" },
{ "step": "补齐主链", "status": "in_progress" }
]
}),
})
.expect("notification");
let JsonRpcMessage::Notification(notification) = message else {
panic!("expected notification");
};
assert_eq!(notification.method, "turn/plan/updated");
assert_eq!(
notification.params.expect("params"),
json!({
"threadId": "thread_1",
"turnId": "turn_1",
"explanation": "继续执行",
"plan": [
{ "step": "读取现状", "status": "completed" },
{ "step": "补齐主链", "status": "inProgress" }
]
})
);
}
#[test]
fn event_notifications_jsonrpc_emits_typed_warning_with_localization_code() {
let message = single_event_notification(AgentEvent {
@@ -128,9 +166,215 @@ fn warning_alias_does_not_activate_typed_notification() {
assert_eq!(notification.method, "agentSession/event");
}
fn failed_turn_event(event_id: &str, message: &str) -> AgentEvent {
AgentEvent {
event_id: event_id.to_string(),
sequence: 9,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "turn.failed".to_string(),
timestamp: "2026-07-05T00:00:01Z".to_string(),
payload: json!({
"message": message,
"turn": {
"sessionId": "sess_1",
"threadId": "thread_1",
"turnId": "turn_1",
"status": "failed",
"createdAtMs": 100,
"updatedAtMs": 120,
"startedAtMs": 100,
"completedAtMs": 120,
"error": {"message": message}
}
}),
}
}
#[test]
fn event_notifications_jsonrpc_lowers_turn_failed_to_direct_completion() {
let message = single_event_notification(AgentEvent {
fn event_notifications_project_retry_and_terminal_errors_with_exact_semantics() {
let mut projector = V2NotificationProjector::default();
let retry = project_event_notifications_jsonrpc(
&mut projector,
AgentEvent {
event_id: "evt_retry".to_string(),
sequence: 7,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "plugin_worker.retry".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({
"message": "provider stream reconnecting",
"errorCode": "PLUGIN_WORKER_RETRYABLE_FAILURE",
"retryable": false
}),
},
)
.expect("retry notification");
assert_eq!(retry.len(), 1);
let JsonRpcMessage::Notification(retry) = &retry[0] else {
panic!("expected retry notification");
};
assert_eq!(retry.method, "error");
assert_eq!(retry.params.as_ref().expect("params")["willRetry"], true);
let terminal = project_event_notifications_jsonrpc(
&mut projector,
failed_turn_event("evt_failed_after_retry", "retry budget exhausted"),
)
.expect("terminal notifications");
let terminal = terminal
.into_iter()
.map(|message| match message {
JsonRpcMessage::Notification(notification) => notification,
other => panic!("expected notification, got {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
terminal
.iter()
.map(|notification| notification.method.as_str())
.collect::<Vec<_>>(),
["error", "turn/completed"]
);
assert_eq!(
terminal[0].params.as_ref().expect("error params")["willRetry"],
false
);
assert_eq!(
terminal[1].params.as_ref().expect("completion params")["turn"]["status"],
"failed"
);
}
#[test]
fn runtime_error_is_terminal_even_when_legacy_retryable_is_true_and_is_not_duplicated() {
let mut projector = V2NotificationProjector::default();
let runtime_error = project_event_notifications_jsonrpc(
&mut projector,
AgentEvent {
event_id: "evt_runtime_error".to_string(),
sequence: 8,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({
"message": "retry budget exhausted",
"errorCode": "PLUGIN_WORKER_RETRYABLE_FAILURE",
"retryable": true
}),
},
)
.expect("runtime error notification");
let JsonRpcMessage::Notification(runtime_error) = &runtime_error[0] else {
panic!("expected runtime error notification");
};
let params = runtime_error.params.as_ref().expect("runtime error params");
assert_eq!(runtime_error.method, "error");
assert_eq!(params["willRetry"], false);
assert_eq!(params["error"]["codexErrorInfo"], "other");
let completion = project_event_notifications_jsonrpc(
&mut projector,
failed_turn_event("evt_failed_after_runtime_error", "retry budget exhausted"),
)
.expect("completion after terminal error");
assert_eq!(completion.len(), 1);
let JsonRpcMessage::Notification(completion) = &completion[0] else {
panic!("expected turn completion");
};
assert_eq!(completion.method, "turn/completed");
}
#[test]
fn turn_failed_without_runtime_error_emits_error_before_completion() {
let messages = event_notifications_jsonrpc(failed_turn_event(
"evt_failed_without_error",
"provider stream timed out",
))
.expect("failed turn notifications");
let methods = messages
.iter()
.map(|message| match message {
JsonRpcMessage::Notification(notification) => notification.method.as_str(),
other => panic!("expected notification, got {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(methods, ["error", "turn/completed"]);
}
#[test]
fn malformed_typed_error_sources_fail_closed() {
for (index, event) in [
AgentEvent {
event_id: "missing_thread".to_string(),
sequence: 1,
session_id: "sess_1".to_string(),
thread_id: None,
turn_id: Some("turn_1".to_string()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({"message": "failed"}),
},
AgentEvent {
event_id: "missing_turn".to_string(),
sequence: 1,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: None,
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({"message": "failed"}),
},
AgentEvent {
event_id: "missing_message".to_string(),
sequence: 1,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({}),
},
AgentEvent {
event_id: "invalid_retry".to_string(),
sequence: 1,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({"message": "failed", "willRetry": "false"}),
},
AgentEvent {
event_id: "invalid_details".to_string(),
sequence: 1,
session_id: "sess_1".to_string(),
thread_id: Some("thread_1".to_string()),
turn_id: Some("turn_1".to_string()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-05T00:00:00Z".to_string(),
payload: json!({"message": "failed", "additionalDetails": 42}),
},
]
.into_iter()
.enumerate()
{
let error = match event_notifications_jsonrpc(event) {
Ok(messages) => panic!("case {index} projected {messages:?}"),
Err(error) => error,
};
assert_eq!(error.code, app_server_protocol::error_codes::RUNTIME_ERROR);
}
}
#[test]
fn event_notifications_jsonrpc_lowers_turn_failed_to_error_then_completion() {
let messages = event_notifications_jsonrpc(AgentEvent {
event_id: "evt_failed".to_string(),
sequence: 2,
session_id: "sess_1".to_string(),
@@ -153,13 +397,14 @@ fn event_notifications_jsonrpc_lowers_turn_failed_to_direct_completion() {
}
}),
})
.expect("notification");
.expect("notifications");
let JsonRpcMessage::Notification(notification) = message else {
assert_eq!(messages.len(), 2);
let JsonRpcMessage::Notification(notification) = &messages[1] else {
panic!("expected notification");
};
assert_eq!(notification.method, "turn/completed");
let params = notification.params.expect("params");
let params = notification.params.as_ref().expect("params");
assert_eq!(params["threadId"], "thread_1");
assert_eq!(params["turn"]["id"], "turn_1");
assert_eq!(params["turn"]["status"], "failed");
@@ -1,18 +1,99 @@
//! mcp request processor tests.
use super::super::*;
use super::tests_support::initialize_processor;
use app_server_protocol::{
ClientCapabilities, JsonRpcMessage, RequestId, METHOD_INITIALIZE, METHOD_INITIALIZED,
METHOD_MCP_PROMPT_GET, METHOD_MCP_PROMPT_LIST, METHOD_MCP_RESOURCE_LIST,
METHOD_MCP_RESOURCE_READ, METHOD_MCP_RESOURCE_SUBSCRIBE, METHOD_MCP_RESOURCE_UNSUBSCRIBE,
METHOD_MCP_SERVER_CREATE, METHOD_MCP_SERVER_DELETE, METHOD_MCP_SERVER_ENABLED_SET,
METHOD_MCP_SERVER_IMPORT_FROM_APP, METHOD_MCP_SERVER_LIST, METHOD_MCP_SERVER_START,
METHOD_MCP_SERVER_STATUS_LIST, METHOD_MCP_SERVER_STOP, METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE,
METHOD_MCP_SERVER_UPDATE, METHOD_MCP_TOOL_CALL, METHOD_MCP_TOOL_CALL_WITH_CALLER,
METHOD_MCP_TOOL_LIST,
METHOD_MCP_SERVER_IMPORT_FROM_APP, METHOD_MCP_SERVER_LIST, METHOD_MCP_SERVER_OAUTH_LOGIN,
METHOD_MCP_SERVER_START, METHOD_MCP_SERVER_STATUS_LIST, METHOD_MCP_SERVER_STOP,
METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE, METHOD_MCP_SERVER_UPDATE, METHOD_MCP_TOOL_CALL,
METHOD_MCP_TOOL_CALL_WITH_CALLER, METHOD_MCP_TOOL_LIST,
};
use async_trait::async_trait;
use serde_json::json;
#[derive(Default)]
struct OAuthTestDataSource {
login: std::sync::Mutex<Option<lime_mcp::McpOAuthLoginHandle>>,
}
impl crate::SessionAppDataSource for OAuthTestDataSource {}
impl crate::WorkspaceAppDataSource for OAuthTestDataSource {}
impl crate::SkillAppDataSource for OAuthTestDataSource {}
impl crate::WorkspaceSkillBindingAppDataSource for OAuthTestDataSource {}
impl crate::GatewayAppDataSource for OAuthTestDataSource {}
impl crate::MediaAppDataSource for OAuthTestDataSource {}
impl crate::VoiceAppDataSource for OAuthTestDataSource {}
impl crate::PluginDataSource for OAuthTestDataSource {}
impl crate::KnowledgeAppDataSource for OAuthTestDataSource {}
impl crate::AutomationOverviewAppDataSource for OAuthTestDataSource {}
impl crate::AutomationManagementAppDataSource for OAuthTestDataSource {}
impl crate::MemoryAppDataSource for OAuthTestDataSource {}
impl crate::DiagnosticsAppDataSource for OAuthTestDataSource {}
impl crate::UsageStatsAppDataSource for OAuthTestDataSource {}
impl crate::ModelProviderAppDataSource for OAuthTestDataSource {}
impl crate::ConnectAppDataSource for OAuthTestDataSource {}
impl crate::RightSurfaceAppDataSource for OAuthTestDataSource {}
#[async_trait]
impl crate::McpAppDataSource for OAuthTestDataSource {
async fn login_mcp_server_oauth(
&self,
_params: app_server_protocol::McpServerOauthLoginParams,
) -> Result<lime_mcp::McpOAuthLoginHandle, RuntimeCoreError> {
self.login
.lock()
.expect("OAuth test login mutex poisoned")
.take()
.ok_or_else(|| RuntimeCoreError::Backend("OAuth test login missing".to_string()))
}
}
fn oauth_test_data_source() -> (
Arc<OAuthTestDataSource>,
tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (completion_tx, completion_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
let handle = lime_mcp::McpOAuthLoginHandle::new(
"https://auth.example/authorize",
"pending",
async move {
completion_rx
.await
.map_err(|error| lime_mcp::McpError::ProtocolError(error.to_string()))?
.map_err(lime_mcp::McpError::ConfigError)
},
);
(
Arc::new(OAuthTestDataSource {
login: std::sync::Mutex::new(Some(handle)),
}),
completion_tx,
)
}
async fn start_oauth_test() -> (
RequestProcessor,
tokio::sync::oneshot::Sender<Result<(), String>>,
tokio::sync::mpsc::UnboundedReceiver<app_server_protocol::protocol::v2::ServerNotification>,
) {
let (data_source, completion_tx) = oauth_test_data_source();
let runtime = RuntimeCore::default().with_app_data_source(data_source);
let (notification_tx, notification_rx) = tokio::sync::mpsc::unbounded_channel();
let hook: ServerNotificationHook = Arc::new(move |notification| {
let notification_tx = notification_tx.clone();
Box::pin(async move {
let _ = notification_tx.send(notification);
})
});
let processor = RequestProcessor::new(runtime).with_server_notification_hook(hook);
initialize_processor(&processor).await;
(processor, completion_tx, notification_rx)
}
#[tokio::test]
async fn mcp_list_methods_require_initialized_and_return_current_empty_state() {
let processor = RequestProcessor::new(RuntimeCore::default());
@@ -232,3 +313,79 @@ async fn mcp_runtime_methods_require_initialized_and_fail_closed_without_manager
}
}
}
#[tokio::test]
async fn mcp_oauth_login_returns_before_typed_success_notification() {
let (processor, completion_tx, mut notification_rx) = start_oauth_test().await;
let messages = processor
.handle_request(JsonRpcRequest::new(
RequestId::Integer(30),
METHOD_MCP_SERVER_OAUTH_LOGIN,
Some(json!({ "name": "remote-docs" })),
))
.await
.expect("OAuth login response");
let [JsonRpcMessage::Response(response)] = messages.as_slice() else {
panic!("expected OAuth login response, got {messages:?}");
};
assert_eq!(
response.result["authorizationUrl"],
"https://auth.example/authorize"
);
assert_eq!(response.result["state"], "pending");
completion_tx.send(Ok(())).expect("complete OAuth login");
let notification =
tokio::time::timeout(std::time::Duration::from_secs(1), notification_rx.recv())
.await
.expect("OAuth success notification timeout")
.expect("OAuth success notification channel closed");
assert_eq!(
notification,
app_server_protocol::protocol::v2::ServerNotification::McpServerOauthLoginCompleted(
app_server_protocol::protocol::v2::McpServerOauthLoginCompletedNotification {
name: "remote-docs".to_string(),
thread_id: None,
success: true,
error: None,
}
)
);
}
#[tokio::test]
async fn mcp_oauth_login_publishes_typed_failure_notification() {
let (processor, completion_tx, mut notification_rx) = start_oauth_test().await;
let messages = processor
.handle_request(JsonRpcRequest::new(
RequestId::Integer(31),
METHOD_MCP_SERVER_OAUTH_LOGIN,
Some(json!({ "name": "remote-docs" })),
))
.await
.expect("OAuth login response");
assert!(matches!(messages.as_slice(), [JsonRpcMessage::Response(_)]));
completion_tx
.send(Err("scope rejected".to_string()))
.expect("fail OAuth login");
let notification =
tokio::time::timeout(std::time::Duration::from_secs(1), notification_rx.recv())
.await
.expect("OAuth failure notification timeout")
.expect("OAuth failure notification channel closed");
let app_server_protocol::protocol::v2::ServerNotification::McpServerOauthLoginCompleted(
notification,
) = notification
else {
panic!("expected OAuth completion notification");
};
assert_eq!(notification.name, "remote-docs");
assert_eq!(notification.thread_id, None);
assert!(!notification.success);
assert!(notification
.error
.as_deref()
.is_some_and(|error| error.contains("scope rejected")));
}
@@ -296,7 +296,9 @@ fn project_turn(turn: canonical::Turn) -> Result<v2::Turn, JsonRpcError> {
status: project_turn_status(turn.status),
error: turn.error.map(|error| v2::TurnError {
message: error.message,
error_info: error.code.map(Value::String),
codex_error_info: crate::processor::v2_notifications::error::codex_error_info_from_code(
error.code.as_deref(),
),
additional_details: error.details,
}),
started_at: turn.started_at_ms.map(millis_to_seconds),
@@ -6,10 +6,12 @@ use serde_json::Value;
use std::collections::HashSet;
mod command;
pub(crate) mod error;
mod file_change;
mod mcp;
mod plan;
mod thread_status;
mod turn_plan;
mod warning;
enum EventProjection {
@@ -31,6 +33,7 @@ pub(crate) struct V2NotificationProjector {
completed_file_change_item_ids: HashSet<String>,
started_mcp_item_ids: HashSet<String>,
completed_mcp_item_ids: HashSet<String>,
terminal_error_turn_ids: HashSet<String>,
thread_status: thread_status::ThreadStatusProjector,
}
@@ -54,12 +57,12 @@ impl V2NotificationProjector {
"turn.completed" => {
return self.project_turn_completed_with_usage(event, v2::TurnStatus::Completed)
}
"turn.failed" => {
return self.project_turn_completed_with_usage(event, v2::TurnStatus::Failed)
}
"turn.failed" => return self.project_turn_failed(event),
"turn.canceled" => {
return self.project_turn_completed_with_usage(event, v2::TurnStatus::Interrupted)
}
"plugin_worker.retry" => return self.project_error(event, Some(true)),
"runtime.error" => return self.project_error(event, None),
"action.required" => {
return EventProjection::Direct(
self.thread_status
@@ -120,6 +123,7 @@ impl V2NotificationProjector {
event,
)
}
"turn.plan.updated" => return turn_plan::project(event),
"tool.progress" => {
return mcp::project_progress(
&self.started_mcp_item_ids,
@@ -233,6 +237,42 @@ impl V2NotificationProjector {
EventProjection::Direct(notifications)
}
fn project_error(
&mut self,
event: &AgentEvent,
forced_will_retry: Option<bool>,
) -> EventProjection {
let Some(projected) = error::project(event, forced_will_retry) else {
return EventProjection::Reject(projection_error(event));
};
if !projected.will_retry
&& !self
.terminal_error_turn_ids
.insert(projected.turn_id.clone())
{
return EventProjection::Direct(Vec::new());
}
EventProjection::Direct(vec![projected.notification.into()])
}
fn project_turn_failed(&mut self, event: &AgentEvent) -> EventProjection {
let Some(turn_id) = required_event_id(event.turn_id.as_deref()) else {
return EventProjection::Reject(projection_error(event));
};
let completion = self.project_turn_completed_with_usage(event, v2::TurnStatus::Failed);
let EventProjection::Direct(mut notifications) = completion else {
return completion;
};
if self.terminal_error_turn_ids.remove(&turn_id) {
return EventProjection::Direct(notifications);
}
let Some(projected) = error::project(event, Some(false)) else {
return EventProjection::Reject(projection_error(event));
};
notifications.insert(0, projected.notification.into());
EventProjection::Direct(notifications)
}
fn project_item(&mut self, event: &AgentEvent, completed: bool) -> Option<ServerNotification> {
let thread_id = required_event_id(event.thread_id.as_deref())?;
let turn_id = required_event_id(event.turn_id.as_deref())?;
@@ -880,35 +920,44 @@ mod tests {
(
"turn.completed",
json!({"turn": canonical_turn("completed")}),
"turn/completed",
vec!["turn/completed"],
),
(
"turn.failed",
json!({"turn": canonical_turn("failed")}),
"turn/completed",
json!({
"message": "provider failed",
"turn": canonical_turn("failed")
}),
vec!["error", "turn/completed"],
),
(
"turn.canceled",
json!({"turn": canonical_turn("interrupted")}),
"turn/completed",
vec!["turn/completed"],
),
(
"item.started",
json!({"item": canonical_item("inProgress")}),
"item/started",
vec!["item/started"],
),
(
"item.completed",
json!({"item": canonical_item("completed")}),
"item/completed",
vec!["item/completed"],
),
];
for (event_type, payload, method) in cases {
for (event_type, payload, methods) in cases {
let mut projector = V2NotificationProjector::default();
let notifications = projector
.project(event(event_type, payload))
.expect("direct lifecycle");
assert_eq!(notifications[0].method, method);
assert_eq!(
notifications
.iter()
.map(|notification| notification.method.as_str())
.collect::<Vec<_>>(),
methods
);
}
}
@@ -0,0 +1,167 @@
use app_server_protocol::protocol::v2::{
CodexErrorInfo, ErrorNotification, ServerNotification, TurnError,
};
use app_server_protocol::AgentEvent;
use serde_json::Value;
pub(super) struct ProjectedError {
pub(super) notification: ServerNotification,
pub(super) turn_id: String,
pub(super) will_retry: bool,
}
pub(super) fn project(
event: &AgentEvent,
forced_will_retry: Option<bool>,
) -> Option<ProjectedError> {
let thread_id = required_id(event.thread_id.as_deref())?;
let turn_id = required_id(event.turn_id.as_deref())?;
let message = error_message(&event.payload)?;
let additional_details = optional_string(
&event.payload,
&["additionalDetails", "additional_details", "details"],
)
.or_else(|| nested_turn_error_string(&event.payload, "additionalDetails"))
.or_else(|| nested_turn_error_string(&event.payload, "details"));
if has_invalid_optional_string(
&event.payload,
&["additionalDetails", "additional_details", "details"],
) {
return None;
}
let will_retry = match forced_will_retry {
Some(will_retry) => will_retry,
None => explicit_will_retry(&event.payload)?,
};
let code = payload_string(&event.payload, &["errorCode", "error_code", "code"])
.or_else(|| nested_turn_error_string(&event.payload, "code"));
Some(ProjectedError {
notification: ServerNotification::Error(ErrorNotification {
error: TurnError {
message,
codex_error_info: codex_error_info_from_code(code.as_deref()),
additional_details,
},
will_retry,
thread_id,
turn_id: turn_id.clone(),
}),
turn_id,
will_retry,
})
}
pub(crate) fn codex_error_info_from_code(code: Option<&str>) -> Option<CodexErrorInfo> {
let code = code?;
let normalized = code
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect::<String>();
Some(match normalized.as_str() {
"contextwindowexceeded" => CodexErrorInfo::ContextWindowExceeded,
"sessionbudgetexceeded" => CodexErrorInfo::SessionBudgetExceeded,
"usagelimitexceeded" => CodexErrorInfo::UsageLimitExceeded,
"serveroverloaded" => CodexErrorInfo::ServerOverloaded,
"cyberpolicy" => CodexErrorInfo::CyberPolicy,
"httpconnectionfailed" => CodexErrorInfo::HttpConnectionFailed {
http_status_code: None,
},
"responsestreamconnectionfailed" => CodexErrorInfo::ResponseStreamConnectionFailed {
http_status_code: None,
},
"internalservererror" => CodexErrorInfo::InternalServerError,
"unauthorized" => CodexErrorInfo::Unauthorized,
"badrequest" => CodexErrorInfo::BadRequest,
"threadrollbackfailed" => CodexErrorInfo::ThreadRollbackFailed,
"sandboxerror" => CodexErrorInfo::SandboxError,
"responsestreamdisconnected" => CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: None,
},
"responsetoomanyfailedattempts" => CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: None,
},
_ => CodexErrorInfo::Other,
})
}
fn error_message(payload: &Value) -> Option<String> {
payload_string(payload, &["message", "errorMessage", "error_message"])
.or_else(|| payload_string(payload, &["error"]))
.or_else(|| nested_error_string(payload, "message"))
.or_else(|| nested_turn_error_string(payload, "message"))
}
fn explicit_will_retry(payload: &Value) -> Option<bool> {
let mut result = None;
for key in ["willRetry", "will_retry"] {
let Some(value) = payload.get(key) else {
continue;
};
let value = value.as_bool()?;
if result.is_some_and(|current| current != value) {
return None;
}
result = Some(value);
}
Some(result.unwrap_or(false))
}
fn required_id(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn payload_string(payload: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
payload
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
fn optional_string(payload: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| match payload.get(key) {
Some(Value::String(value)) => Some(value.clone()),
_ => None,
})
}
fn has_invalid_optional_string(payload: &Value, keys: &[&str]) -> bool {
keys.iter().any(|key| {
!matches!(
payload.get(key),
None | Some(Value::Null) | Some(Value::String(_))
)
})
}
fn nested_error_string(payload: &Value, key: &str) -> Option<String> {
payload
.get("error")
.and_then(Value::as_object)
.and_then(|error| error.get(key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn nested_turn_error_string(payload: &Value, key: &str) -> Option<String> {
payload
.get("turn")
.and_then(Value::as_object)
.and_then(|turn| turn.get("error"))
.and_then(Value::as_object)
.and_then(|error| error.get(key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
@@ -0,0 +1,97 @@
use super::{projection_error, required_event_id, EventProjection};
use app_server_protocol::protocol::v2::{
self, ServerNotification, TurnPlanStep, TurnPlanStepStatus,
};
use app_server_protocol::AgentEvent;
use serde_json::Value;
use tool_runtime::update_plan::{PlanStep, PlanStepStatus};
pub(super) fn project(event: &AgentEvent) -> EventProjection {
let Some(thread_id) = required_event_id(event.thread_id.as_deref()) else {
return EventProjection::Reject(projection_error(event));
};
let Some(turn_id) = required_event_id(event.turn_id.as_deref()) else {
return EventProjection::Reject(projection_error(event));
};
let Some(plan_value) = event.payload.get("plan") else {
return EventProjection::Reject(projection_error(event));
};
let Ok(plan) = serde_json::from_value::<Vec<PlanStep>>(plan_value.clone()) else {
return EventProjection::Reject(projection_error(event));
};
let explanation = match event.payload.get("explanation") {
None | Some(Value::Null) => None,
Some(Value::String(value)) => Some(value.clone()),
Some(_) => return EventProjection::Reject(projection_error(event)),
};
EventProjection::Direct(vec![ServerNotification::TurnPlanUpdated(
v2::TurnPlanUpdatedNotification {
thread_id,
turn_id,
explanation,
plan: plan.into_iter().map(project_step).collect(),
},
)
.into()])
}
fn project_step(step: PlanStep) -> TurnPlanStep {
TurnPlanStep {
step: step.step,
status: match step.status {
PlanStepStatus::Pending => TurnPlanStepStatus::Pending,
PlanStepStatus::InProgress => TurnPlanStepStatus::InProgress,
PlanStepStatus::Completed => TurnPlanStepStatus::Completed,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn event(payload: Value) -> AgentEvent {
AgentEvent {
event_id: "evt-plan-1".to_string(),
sequence: 3,
session_id: "session-plan-1".to_string(),
thread_id: Some("thread-plan-1".to_string()),
turn_id: Some("turn-plan-1".to_string()),
event_type: "turn.plan.updated".to_string(),
timestamp: "2026-07-31T00:00:00.000Z".to_string(),
payload,
}
}
#[test]
fn projects_update_plan_checklist_without_plan_item_lifecycle() {
let EventProjection::Direct(notifications) = project(&event(json!({
"explanation": "继续实现",
"plan": [
{ "step": "读现状", "status": "completed" },
{ "step": "补主链", "status": "in_progress" }
]
}))) else {
panic!("turn plan update should project directly");
};
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].method, "turn/plan/updated");
let params = notifications[0].params.as_ref().expect("params");
assert_eq!(params["threadId"], "thread-plan-1");
assert_eq!(params["turnId"], "turn-plan-1");
assert_eq!(params["plan"][1]["status"], "inProgress");
}
#[test]
fn rejects_invalid_update_plan_status() {
assert!(matches!(
project(&event(json!({
"plan": [{ "step": "补主链", "status": "running" }]
}))),
EventProjection::Reject(_)
));
}
}
@@ -3,7 +3,7 @@ use super::NoopAppDataSource;
use super::RuntimeCoreError;
use app_server_protocol::*;
use async_trait::async_trait;
use lime_mcp::McpRuntimeServerSpec;
use lime_mcp::{McpOAuthLoginHandle, McpRuntimeServerSpec};
#[async_trait]
pub trait McpAppDataSource: Send + Sync {
@@ -79,7 +79,7 @@ pub trait McpAppDataSource: Send + Sync {
async fn login_mcp_server_oauth(
&self,
_params: McpServerOauthLoginParams,
) -> Result<McpServerOauthLoginResponse, RuntimeCoreError> {
) -> Result<McpOAuthLoginHandle, RuntimeCoreError> {
Err(requires_current("mcpServer/oauth/login"))
}
@@ -229,7 +229,7 @@ pub(in crate::runtime) async fn projection_load_context(
.collect(),
};
let mut detail = if stored.events.is_empty() && params.history_limit.is_some() {
let (mut usage_events, warning_events): (Vec<_>, Vec<_>) = projection_support_events
let (mut usage_events, notification_events): (Vec<_>, Vec<_>) = projection_support_events
.into_iter()
.partition(is_turn_completed_usage_event);
usage_events.extend(
@@ -243,7 +243,7 @@ pub(in crate::runtime) async fn projection_load_context(
&projection,
params,
&usage_events,
&warning_events,
&notification_events,
projection_store,
)
.await?
@@ -288,7 +288,7 @@ async fn projection_summary_detail(
projection: &ProjectionReadSession,
params: &AgentSessionReadParams,
usage_events: &[AgentEvent],
warning_events: &[AgentEvent],
notification_events: &[AgentEvent],
projection_store: &super::ProjectionStore,
) -> Result<serde_json::Value, RuntimeCoreError> {
let messages = projection.messages.clone();
@@ -300,10 +300,10 @@ async fn projection_summary_detail(
let canonical_items = read_model::canonical_items_from_thread_store(projection_store, stored)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let mut warning_stored = stored.clone();
warning_stored.events = warning_events.to_vec();
let items = Value::Array(read_model::merge_runtime_warning_items(
&warning_stored,
let mut notification_stored = stored.clone();
notification_stored.events = notification_events.to_vec();
let items = Value::Array(read_model::merge_runtime_notification_items(
&notification_stored,
canonical_items,
));
let thread_items = items.clone();
+1 -1
View File
@@ -72,7 +72,7 @@ impl RuntimeCore {
pub async fn login_mcp_server_oauth(
&self,
params: McpServerOauthLoginParams,
) -> Result<McpServerOauthLoginResponse, RuntimeCoreError> {
) -> Result<lime_mcp::McpOAuthLoginHandle, RuntimeCoreError> {
self.app_data_source.login_mcp_server_oauth(params).await
}
@@ -48,13 +48,14 @@ use thread_store::{ListItemsParams, PageRequest, ThreadStore, ThreadStoreResult}
pub(super) use canonical_items::canonical_item_to_agent_detail;
pub(super) use messages::runtime_session_messages;
pub(super) fn merge_runtime_warning_items(
pub(super) fn merge_runtime_notification_items(
stored: &StoredSession,
mut items: Vec<serde_json::Value>,
) -> Vec<serde_json::Value> {
// Warning notifications are durable runtime events, not canonical ThreadItem
// variants. Re-derive them beside canonical items for every cold-read path.
// Runtime notifications are durable events, not canonical ThreadItem variants.
// Re-derive them beside canonical items for every cold-read path.
items.extend(runtime_warning_items_from_events(stored));
items.extend(runtime_error_items_from_events(stored));
sort_read_detail_items(&mut items);
items
}
@@ -151,7 +152,7 @@ fn runtime_session_read_detail_with_item_source(
sort_read_detail_items(&mut items);
items
},
|items| merge_runtime_warning_items(stored, items.to_vec()),
|items| merge_runtime_notification_items(stored, items.to_vec()),
);
if canonical_items.is_some() {
apply_canonical_item_views(&mut thread_read, &items);
@@ -85,6 +85,47 @@ fn canonical_read_keeps_runtime_warning_message_and_localization_code() {
assert_eq!(detail["thread_read"]["thread_items"], detail["items"]);
}
#[test]
fn canonical_read_keeps_runtime_error_without_terminalizing_the_turn() {
let mut stored = stored_running_session("2026-07-31T00:00:00Z", "2026-07-31T00:00:01Z");
stored.events.push(AgentEvent {
event_id: "event-read-model-error".to_string(),
sequence: 2,
session_id: stored.session.session_id.clone(),
thread_id: Some(stored.session.thread_id.clone()),
turn_id: Some(stored.turns[0].turn_id.clone()),
event_type: "runtime.error".to_string(),
timestamp: "2026-07-31T00:00:02Z".to_string(),
payload: json!({
"errorCode": "server_overloaded",
"message": "服务暂时不可用,等待权威终态。",
"willRetry": false
}),
});
let detail = runtime_session_read_detail_with_item_source(
&stored,
ReadDetailOptions::default(),
&[],
Some(&[]),
);
let error = detail["items"]
.as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "error"))
.expect("error item");
assert_eq!(error["thread_id"], stored.session.thread_id);
assert_eq!(error["turn_id"], stored.turns[0].turn_id);
assert_eq!(error["message"], "服务暂时不可用,等待权威终态。");
assert_eq!(detail["turns"][0]["status"], "running");
assert_eq!(
detail["thread_read"]["diagnostics"]["latest_turn_status"],
"running"
);
assert!(detail["turns"][0]["completed_at"].is_null());
assert_eq!(detail["thread_read"]["thread_items"], detail["items"]);
}
#[test]
fn canonical_read_rejects_malformed_runtime_warning_shapes_and_identity() {
let mut stored = stored_running_session("2026-07-31T00:00:00Z", "2026-07-31T00:00:01Z");
@@ -1,8 +1,6 @@
use crate::RuntimeEvent;
use agent_protocol::ToolOutput;
use serde_json::{json, Value};
use std::collections::HashMap;
const UPDATE_PLAN_ACK: &str = "Plan updated";
pub fn plan_delta_event(text: impl Into<String>, revision_id: impl Into<String>) -> RuntimeEvent {
let text = text.into();
@@ -68,52 +66,31 @@ pub fn proposed_plan_final_event(
event
}
pub fn plan_final_event_from_update_plan_result(
pub fn turn_plan_updated_event_from_update_plan_result(
tool_id: &str,
output: &str,
metadata: Option<&HashMap<String, Value>>,
output: &ToolOutput,
) -> Option<RuntimeEvent> {
if output.trim() != UPDATE_PLAN_ACK {
if output.text.as_deref()?.trim() != tool_runtime::update_plan::PLAN_UPDATED_MESSAGE {
return None;
}
let metadata = metadata?;
let plan = metadata.get("plan")?.clone();
let text = plan_text_from_plan_value(&plan)?;
let revision_id = format!("update_plan:{tool_id}");
let mut event = plan_final_event(text, revision_id, Some(plan));
if let Some(payload_object) = event.payload.as_object_mut() {
payload_object.insert("toolCallId".to_string(), Value::String(tool_id.to_string()));
payload_object.insert(
"source".to_string(),
Value::String("update_plan".to_string()),
);
if let Some(explanation) = metadata.get("explanation") {
if !explanation.is_null() {
payload_object.insert("explanation".to_string(), explanation.clone());
}
}
}
Some(event)
}
fn plan_text_from_plan_value(plan: &Value) -> Option<String> {
let lines = plan
.as_array()?
.iter()
.filter_map(|item| {
let step = item.get("step")?.as_str()?.trim();
if step.is_empty() {
return None;
}
let status = item
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending");
let marker = if status == "completed" { "[x]" } else { "[ ]" };
Some(format!("- {marker} {step}"))
})
.collect::<Vec<_>>();
(!lines.is_empty()).then(|| lines.join("\n"))
let structured_content = output.structured_content.as_ref()?.as_object()?;
let plan = serde_json::from_value::<Vec<tool_runtime::update_plan::PlanStep>>(
structured_content.get("plan")?.clone(),
)
.ok()?;
let explanation = match structured_content.get("explanation") {
None | Some(Value::Null) => None,
Some(Value::String(explanation)) => Some(explanation.clone()),
Some(_) => return None,
};
Some(RuntimeEvent::new(
"turn.plan.updated",
json!({
"toolCallId": tool_id,
"explanation": explanation,
"plan": plan,
}),
))
}
fn plan_value_from_markdown_text(text: &str) -> Option<Value> {
@@ -190,29 +167,41 @@ mod tests {
}
#[test]
fn builds_update_plan_final_event_from_tool_metadata() {
let event = plan_final_event_from_update_plan_result(
fn builds_turn_plan_updated_event_from_canonical_tool_output() {
let event = turn_plan_updated_event_from_update_plan_result(
"tool-plan-1",
"Plan updated",
Some(&HashMap::from([
(
"plan".to_string(),
json!([
&ToolOutput {
text: Some("Plan updated".to_string()),
structured_content: Some(json!({
"tool_family": "update_plan",
"explanation": "继续实现",
"plan": [
{ "step": "读现状", "status": "completed" },
{ "step": "补主链", "status": "in_progress" }
]),
),
("explanation".to_string(), json!("继续实现")),
])),
]
})),
..ToolOutput::default()
},
)
.expect("update_plan result should become plan.final");
.expect("update_plan result should become turn.plan.updated");
assert_eq!(event.event_type, "plan.final");
assert_eq!(event.payload["revisionId"], "update_plan:tool-plan-1");
assert_eq!(event.event_type, "turn.plan.updated");
assert_eq!(event.payload["toolCallId"], "tool-plan-1");
assert_eq!(event.payload["source"], "update_plan");
assert_eq!(event.payload["explanation"], "继续实现");
assert_eq!(event.payload["text"], "- [x] 读现状\n- [ ] 补主链");
assert_eq!(event.payload["plan"][1]["status"], "in_progress");
}
#[test]
fn ignores_update_plan_ack_without_canonical_structured_output() {
let event = turn_plan_updated_event_from_update_plan_result(
"tool-plan-1",
&ToolOutput {
text: Some("Plan updated".to_string()),
structured_content: None,
..ToolOutput::default()
},
);
assert!(event.is_none());
}
}
@@ -174,19 +174,13 @@ fn update_plan_event_from_completed_tool(event: &RuntimeAgentEvent) -> Option<Ru
};
let tool = canonical_tool_item(event)?;
let output = tool.output?;
if !tool.item.status.is_terminal() || output.error.is_some() {
if tool.name != tool_runtime::update_plan::UPDATE_PLAN_NAME
|| !tool.item.status.is_terminal()
|| output.error.is_some()
{
return None;
}
let metadata = tool
.item
.metadata
.as_object()
.map(|metadata| metadata.clone().into_iter().collect());
plan_events::plan_final_event_from_update_plan_result(
tool.call_id,
output.text.as_deref().unwrap_or_default(),
metadata.as_ref(),
)
plan_events::turn_plan_updated_event_from_update_plan_result(tool.call_id, output)
}
fn runtime_event_type_for_agent_event(event: &RuntimeAgentEvent, raw_type: &str) -> &'static str {
@@ -208,6 +202,7 @@ fn provider_stream_runtime_event_type(runtime_event_kind: &str) -> &'static str
struct CanonicalToolItem<'a> {
item: &'a ThreadItem,
call_id: &'a str,
name: &'a str,
output: Option<&'a ToolOutput>,
}
@@ -219,7 +214,10 @@ fn canonical_tool_item(event: &RuntimeAgentEvent) -> Option<CanonicalToolItem<'_
_ => return None,
};
let ThreadItemPayload::Tool {
call_id, output, ..
call_id,
name,
output,
..
} = &item.payload
else {
return None;
@@ -227,6 +225,7 @@ fn canonical_tool_item(event: &RuntimeAgentEvent) -> Option<CanonicalToolItem<'_
Some(CanonicalToolItem {
item,
call_id,
name,
output: output.as_ref(),
})
}
@@ -717,7 +716,7 @@ mod tests {
}
#[test]
fn canonical_update_plan_completion_emits_plan_final_fact() {
fn canonical_update_plan_completion_emits_turn_plan_updated_fact() {
let events = runtime_events_from_agent_event(&canonical_tool_event(
"tool-plan",
"update_plan",
@@ -726,18 +725,16 @@ mod tests {
success: true,
output: "Plan updated".to_string(),
error: None,
structured_content: None,
structured_content: Some(json!({
"tool_family": "update_plan",
"explanation": "开始实现",
"plan": [
{ "step": "读现状", "status": "completed" },
{ "step": "打通主链", "status": "in_progress" }
]
})),
images: None,
metadata: Some(HashMap::from([
(
"plan".to_string(),
json!([
{ "step": "读现状", "status": "completed" },
{ "step": "打通主链", "status": "in_progress" }
]),
),
("explanation".to_string(), json!("开始实现")),
])),
metadata: None,
}),
))
.expect("update_plan item completion should emit");
@@ -747,9 +744,34 @@ mod tests {
.iter()
.map(|event| event.event_type.as_str())
.collect::<Vec<_>>(),
vec!["item.completed", "plan.final"]
vec!["item.completed", "turn.plan.updated"]
);
assert_eq!(events[1].payload["revisionId"], "update_plan:tool-plan");
assert_eq!(events[1].payload["toolCallId"], "tool-plan");
assert_eq!(events[1].payload["explanation"], "开始实现");
assert_eq!(events[1].payload["plan"][1]["status"], "in_progress");
}
#[test]
fn non_update_plan_completion_does_not_emit_turn_plan_updated_fact() {
let events = runtime_events_from_agent_event(&canonical_tool_event(
"tool-other",
"other_tool",
Some(json!({})),
Some(AgentToolResult {
success: true,
output: "Plan updated".to_string(),
error: None,
structured_content: Some(json!({
"plan": [{ "step": "不应投影", "status": "completed" }]
})),
images: None,
metadata: None,
}),
))
.expect("other tool item completion should emit");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "item.completed");
}
#[test]
@@ -0,0 +1,296 @@
use crate::{AppServerEventBridge, JsonRpcMessage};
use app_server_protocol::protocol::v2::{ServerNotification, SkillsChangedNotification};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
pub(crate) struct SkillsWatcher {
_watcher: Arc<Mutex<RecommendedWatcher>>,
_watched_roots: Arc<Mutex<HashSet<PathBuf>>>,
reconcile_task: tokio::task::JoinHandle<()>,
}
impl Drop for SkillsWatcher {
fn drop(&mut self) {
self.reconcile_task.abort();
}
}
impl SkillsWatcher {
pub(crate) fn start_default(bridge: AppServerEventBridge) -> Option<Arc<Self>> {
Self::start(
bridge,
lime_skills::default_agent_skill_roots()
.into_iter()
.map(|root| root.path),
WATCHER_THROTTLE_INTERVAL,
)
}
fn start(
bridge: AppServerEventBridge,
roots: impl IntoIterator<Item = PathBuf>,
throttle_interval: Duration,
) -> Option<Arc<Self>> {
let handle = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle,
Err(error) => {
tracing::warn!(%error, "skills watcher skipped without Tokio runtime");
return None;
}
};
let roots = roots.into_iter().collect::<HashSet<_>>();
if roots.is_empty() {
tracing::warn!("skills watcher has no default Skill roots");
return None;
}
let last_notification = Arc::new(Mutex::new(None::<Instant>));
let callback_last_notification = last_notification.clone();
let callback_bridge = bridge.clone();
let callback_handle = handle.clone();
let watcher = match notify::recommended_watcher(move |result: notify::Result<Event>| {
let event = match result {
Ok(event) if is_catalog_change(&event.kind) => event,
Ok(_) => return,
Err(error) => {
tracing::warn!(%error, "skills watcher received an error");
return;
}
};
if event.paths.is_empty() {
return;
}
notify_catalog_changed(
&callback_bridge,
&callback_handle,
&callback_last_notification,
throttle_interval,
);
}) {
Ok(watcher) => watcher,
Err(error) => {
tracing::warn!(%error, "failed to initialize skills watcher");
return None;
}
};
let watcher = Arc::new(Mutex::new(watcher));
let watched_roots = Arc::new(Mutex::new(HashSet::new()));
reconcile_watches(&watcher, &watched_roots, &roots);
let reconcile_watcher = watcher.clone();
let reconcile_watched_roots = watched_roots.clone();
let reconcile_last_notification = last_notification.clone();
let reconcile_handle = handle.clone();
let reconcile_task = handle.spawn(async move {
let mut interval = tokio::time::interval(throttle_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
if reconcile_watches(&reconcile_watcher, &reconcile_watched_roots, &roots) {
notify_catalog_changed(
&bridge,
&reconcile_handle,
&reconcile_last_notification,
throttle_interval,
);
}
}
});
Some(Arc::new(Self {
_watcher: watcher,
_watched_roots: watched_roots,
reconcile_task,
}))
}
}
fn reconcile_watches(
watcher: &Mutex<RecommendedWatcher>,
watched_roots: &Mutex<HashSet<PathBuf>>,
roots: &HashSet<PathBuf>,
) -> bool {
let existing_roots = roots
.iter()
.filter(|root| root.is_dir())
.map(|root| std::fs::canonicalize(root).unwrap_or_else(|_| root.clone()))
.collect::<HashSet<_>>();
let mut watcher = watcher.lock().unwrap_or_else(|error| error.into_inner());
let mut watched_roots = watched_roots
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut changed = false;
for removed in watched_roots
.difference(&existing_roots)
.cloned()
.collect::<Vec<_>>()
{
if let Err(error) = watcher.unwatch(&removed) {
tracing::debug!(path = %removed.display(), %error, "Skill root was already unwatched");
}
watched_roots.remove(&removed);
changed = true;
}
for added in existing_roots
.difference(&watched_roots)
.cloned()
.collect::<Vec<_>>()
{
match watcher.watch(&added, RecursiveMode::Recursive) {
Ok(()) => {
watched_roots.insert(added);
changed = true;
}
Err(error) => {
tracing::warn!(path = %added.display(), %error, "failed to watch Skill root");
}
}
}
changed
}
fn notify_catalog_changed(
bridge: &AppServerEventBridge,
handle: &tokio::runtime::Handle,
last_notification: &Mutex<Option<Instant>>,
throttle_interval: Duration,
) {
let now = Instant::now();
let mut last = last_notification
.lock()
.unwrap_or_else(|error| error.into_inner());
if last.is_some_and(|last| now.duration_since(last) < throttle_interval) {
return;
}
*last = Some(now);
drop(last);
lime_skills::invalidate_agent_skill_snapshot_cache();
let bridge = bridge.clone();
handle.spawn(async move {
bridge
.broadcast_message(JsonRpcMessage::Notification(
ServerNotification::SkillsChanged(SkillsChangedNotification {}).into(),
))
.await;
});
}
fn is_catalog_change(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppServer;
use std::fs;
#[test]
fn only_catalog_mutations_trigger_invalidation() {
assert!(is_catalog_change(&EventKind::Create(
notify::event::CreateKind::File
)));
assert!(is_catalog_change(&EventKind::Modify(
notify::event::ModifyKind::Data(notify::event::DataChange::Content)
)));
assert!(is_catalog_change(&EventKind::Remove(
notify::event::RemoveKind::Folder
)));
assert!(!is_catalog_change(&EventKind::Access(
notify::event::AccessKind::Read
)));
}
#[tokio::test]
async fn watched_skill_file_change_broadcasts_typed_invalidation() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("skills");
let skill_dir = root.join("writer");
fs::create_dir_all(&skill_dir).expect("create Skill directory");
let skill_file = skill_dir.join("SKILL.md");
fs::write(&skill_file, "---\nname: writer\ndescription: before\n---\n")
.expect("write Skill fixture");
let server = AppServer::new();
let mut outbound = server.subscribe_outbound_messages();
let _watcher =
SkillsWatcher::start(server.event_bridge(), [root], Duration::from_millis(20))
.expect("start Skill watcher");
fs::write(&skill_file, "---\nname: writer\ndescription: after\n---\n")
.expect("update Skill fixture");
let notification = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let message = outbound.recv().await.expect("watcher notification");
if matches!(
&message,
JsonRpcMessage::Notification(notification)
if notification.method == "skills/changed"
) {
break message;
}
}
})
.await
.expect("wait for skills/changed");
assert_eq!(
notification,
JsonRpcMessage::Notification(
ServerNotification::SkillsChanged(SkillsChangedNotification {}).into()
)
);
}
#[tokio::test]
async fn skill_root_created_after_start_broadcasts_typed_invalidation() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("skills");
let server = AppServer::new();
let mut outbound = server.subscribe_outbound_messages();
let _watcher = SkillsWatcher::start(
server.event_bridge(),
[root.clone()],
Duration::from_millis(20),
)
.expect("start Skill watcher before root exists");
let skill_dir = root.join("writer");
fs::create_dir_all(&skill_dir).expect("create Skill root and directory");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: writer\ndescription: created later\n---\n",
)
.expect("write Skill fixture");
let notification = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let message = outbound.recv().await.expect("watcher notification");
if matches!(
&message,
JsonRpcMessage::Notification(notification)
if notification.method == "skills/changed"
) {
break message;
}
}
})
.await
.expect("wait for root creation skills/changed");
assert_eq!(
notification,
JsonRpcMessage::Notification(
ServerNotification::SkillsChanged(SkillsChangedNotification {}).into()
)
);
}
}
-6
View File
@@ -41,9 +41,3 @@ pub struct McpResourceUpdatedPayload {
pub server_name: String,
pub uri: String,
}
/// OAuth 授权完成事件
#[derive(Debug, Clone, Serialize)]
pub struct McpOAuthCompletedPayload {
pub server_name: String,
}
+3 -4
View File
@@ -31,13 +31,12 @@ pub use elicitation::{
ElicitationRequestRouter, ElicitationResponse, ElicitationRouterError,
};
pub use events::{
McpOAuthCompletedPayload, McpResourceUpdatedPayload, McpResourcesUpdatedPayload,
McpServerErrorPayload, McpServerStartedPayload, McpServerStoppedPayload,
McpToolsUpdatedPayload,
McpResourceUpdatedPayload, McpResourcesUpdatedPayload, McpServerErrorPayload,
McpServerStartedPayload, McpServerStoppedPayload, McpToolsUpdatedPayload,
};
pub use extension_surface::{build_runtime_extension_surface, runtime_extension_name};
pub use manager::{McpBridgeSnapshot, McpClientManager, McpRuntimeOwner, McpRuntimeServerSpec};
pub use oauth::{McpOAuthLoginParams, McpOAuthLoginResponse, McpOAuthRegistry};
pub use oauth::{McpOAuthLoginHandle, McpOAuthLoginParams, McpOAuthRegistry};
pub use tool_converter::ToolConverter;
pub use types::{
McpContent, McpError, McpManagerState, McpPromptArgument, McpPromptDefinition,
+3 -3
View File
@@ -38,7 +38,7 @@ use tracing::{debug, info, warn};
use crate::client::McpClientWrapper;
use crate::environment::McpEnvironmentRegistry;
use crate::events::*;
use crate::oauth::{McpOAuthLoginResponse, McpOAuthRegistry};
use crate::oauth::{McpOAuthLoginHandle, McpOAuthRegistry};
use crate::types::*;
mod lifecycle;
@@ -373,9 +373,9 @@ impl McpClientManager {
config: &McpServerConfig,
scopes: Option<Vec<String>>,
timeout_secs: Option<u64>,
) -> Result<McpOAuthLoginResponse, McpError> {
) -> Result<McpOAuthLoginHandle, McpError> {
self.oauth_registry
.start_login(name, config, scopes, timeout_secs, self.emitter.clone())
.start_login(name, config, scopes, timeout_secs)
.await
}
+43 -64
View File
@@ -1,4 +1,3 @@
use crate::events::{McpOAuthCompletedPayload, McpServerErrorPayload};
use crate::oauth_store::PersistentCredentialStore;
use crate::streamable_http::build_default_headers;
use crate::types::{McpError, McpServerConfig, McpServerTransport};
@@ -7,11 +6,12 @@ use axum::http::StatusCode;
use axum::response::{Html, IntoResponse};
use axum::routing::get;
use axum::Router;
use lime_core::DynEmitter;
use rmcp::transport::auth::{AuthorizationManager, CredentialStore, OAuthState, StoredCredentials};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{oneshot, Mutex};
@@ -28,11 +28,38 @@ pub struct McpOAuthLoginParams {
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct McpOAuthLoginResponse {
pub struct McpOAuthLoginHandle {
pub authorization_url: String,
pub state: String,
completion: Pin<Box<dyn Future<Output = Result<(), McpError>> + Send + 'static>>,
}
impl std::fmt::Debug for McpOAuthLoginHandle {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("McpOAuthLoginHandle")
.field("authorization_url", &self.authorization_url)
.field("state", &self.state)
.finish_non_exhaustive()
}
}
impl McpOAuthLoginHandle {
pub fn new(
authorization_url: impl Into<String>,
state: impl Into<String>,
completion: impl Future<Output = Result<(), McpError>> + Send + 'static,
) -> Self {
Self {
authorization_url: authorization_url.into(),
state: state.into(),
completion: Box::pin(completion),
}
}
pub async fn wait(self) -> Result<(), McpError> {
self.completion.await
}
}
/// OAuth 凭据根只能由组合根注入。未注入时所有凭据操作 fail closed
@@ -59,8 +86,7 @@ impl McpOAuthRegistry {
config: &McpServerConfig,
scopes: Option<Vec<String>>,
timeout_secs: Option<u64>,
emitter: Option<DynEmitter>,
) -> Result<McpOAuthLoginResponse, McpError> {
) -> Result<McpOAuthLoginHandle, McpError> {
let (url, bearer_token_env_var, http_headers, env_http_headers) = match &config.transport {
McpServerTransport::StreamableHttp {
url,
@@ -133,36 +159,30 @@ impl McpOAuthRegistry {
let wait_secs = timeout_secs
.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS)
.clamp(30, 900);
let server_name_for_error = server_name.clone();
let emitter_for_error = emitter.clone();
tokio::spawn(async move {
if let Err(error) = complete_oauth_login(
let completion = async move {
let result = complete_oauth_login(
auth_state,
store,
callback_rx,
wait_secs,
server_name.clone(),
emitter,
)
.await
{
.await;
if let Err(error) = &result {
tracing::warn!(
server_name = %server_name,
error = %error,
"MCP OAuth login did not complete"
);
emit_oauth_error(
emitter_for_error,
&server_name_for_error,
&error.to_string(),
);
}
});
result
};
Ok(McpOAuthLoginResponse {
Ok(McpOAuthLoginHandle::new(
authorization_url,
state: "pending".to_string(),
})
"pending",
completion,
))
}
pub async fn authorized_manager_for(
@@ -360,7 +380,6 @@ async fn complete_oauth_login(
callback_rx: oneshot::Receiver<OAuthCallbackResult>,
timeout_secs: u64,
server_name: String,
emitter: Option<DynEmitter>,
) -> Result<(), McpError> {
let login_timeout = Duration::from_secs(timeout_secs);
let started_at = Instant::now();
@@ -410,49 +429,9 @@ async fn complete_oauth_login(
})
.await
.map_err(|_| McpError::Timeout)??;
emit_oauth_completed(emitter, &server_name);
Ok(())
}
fn emit_oauth_completed(emitter: Option<DynEmitter>, server_name: &str) {
let Some(emitter) = emitter else {
return;
};
let payload = McpOAuthCompletedPayload {
server_name: server_name.to_string(),
};
let Ok(value) = serde_json::to_value(&payload) else {
return;
};
if let Err(error) = emitter.emit_event("mcp:oauth_completed", &value) {
tracing::warn!(
server_name = %server_name,
error = %error,
"MCP OAuth completion event failed"
);
}
}
fn emit_oauth_error(emitter: Option<DynEmitter>, server_name: &str, error: &str) {
let Some(emitter) = emitter else {
return;
};
let payload = McpServerErrorPayload {
server_name: server_name.to_string(),
error: error.to_string(),
};
let Ok(value) = serde_json::to_value(&payload) else {
return;
};
if let Err(error) = emitter.emit_event("mcp:server_error", &value) {
tracing::warn!(
server_name = %server_name,
error = %error,
"MCP OAuth error event failed"
);
}
}
async fn discover_supported_scopes(
url: &str,
client: reqwest::Client,
+29 -141
View File
@@ -6,28 +6,11 @@ use axum::http::{header::LOCATION, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use lime_core::EventEmit;
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone, Default)]
struct RecordingEmitter {
events: Arc<Mutex<Vec<(String, Value)>>>,
}
impl EventEmit for RecordingEmitter {
fn emit_event(&self, event: &str, payload: &Value) -> Result<(), String> {
self.events
.lock()
.expect("events lock")
.push((event.to_string(), payload.clone()));
Ok(())
}
}
fn oauth_config_with_explicit_client_id() -> McpServerConfig {
McpServerConfig {
transport: McpServerTransport::StreamableHttp {
@@ -143,28 +126,6 @@ fn require_test_header(state: &TestOAuthProviderState, headers: &HeaderMap) {
assert_eq!(actual, expected);
}
async fn wait_for_event(
events: Arc<Mutex<Vec<(String, Value)>>>,
event_name: &str,
server_name: &str,
) {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let has_event = events
.lock()
.expect("events lock")
.iter()
.any(|(name, payload)| name == event_name && payload["server_name"] == server_name);
if has_event {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("event should be emitted");
}
fn oauth_browser_client() -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
@@ -300,37 +261,6 @@ fn merge_loopback_no_proxy_hosts_returns_none_when_complete() {
);
}
#[test]
fn emit_oauth_completed_sends_server_event() {
let emitter = RecordingEmitter::default();
let events = emitter.events.clone();
emit_oauth_completed(Some(DynEmitter::new(emitter)), "remote-docs");
let events = events.lock().expect("events lock");
assert_eq!(events.len(), 1);
assert_eq!(events[0].0, "mcp:oauth_completed");
assert_eq!(events[0].1["server_name"], "remote-docs");
}
#[test]
fn emit_oauth_error_sends_server_error_event() {
let emitter = RecordingEmitter::default();
let events = emitter.events.clone();
emit_oauth_error(
Some(DynEmitter::new(emitter)),
"remote-docs",
"invalid_scope",
);
let events = events.lock().expect("events lock");
assert_eq!(events.len(), 1);
assert_eq!(events[0].0, "mcp:server_error");
assert_eq!(events[0].1["server_name"], "remote-docs");
assert_eq!(events[0].1["error"], "invalid_scope");
}
#[tokio::test]
async fn credential_lookup_fails_closed_without_injected_store_root() {
let registry = McpOAuthRegistry::new();
@@ -355,22 +285,13 @@ async fn start_login_completes_against_local_oauth_provider_and_persists_token()
let temp = tempfile::tempdir().expect("tempdir");
let store_root = temp.path().join("oauth-store");
let registry = McpOAuthRegistry::new_in(&store_root);
let emitter = RecordingEmitter::default();
let events = emitter.events.clone();
let login = registry
.start_login(
"remote-docs",
&config,
None,
Some(30),
Some(DynEmitter::new(emitter)),
)
.start_login("remote-docs", &config, None, Some(30))
.await
.expect("start OAuth login");
let response = oauth_browser_client()
.get(login.authorization_url)
.get(login.authorization_url.clone())
.send()
.await
.expect("visit authorization URL");
@@ -379,29 +300,12 @@ async fn start_login_completes_against_local_oauth_provider_and_persists_token()
"callback page should be successful"
);
let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let has_credentials = store
.has_credentials()
.await
.expect("credential store should be readable");
let has_event = events
.lock()
.expect("events lock")
.iter()
.any(|(name, payload)| {
name == "mcp:oauth_completed" && payload["server_name"] == "remote-docs"
});
if has_credentials && has_event {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("OAuth completion should persist token and emit event");
tokio::time::timeout(Duration::from_secs(5), login.wait())
.await
.expect("OAuth completion should finish")
.expect("OAuth completion should succeed");
let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url);
let credentials = store
.load()
.await
@@ -427,12 +331,12 @@ async fn start_login_uses_discovered_scopes_when_config_has_no_scopes() {
let registry = McpOAuthRegistry::new_in(temp.path().join("oauth-store"));
let login = registry
.start_login("remote-docs", &config, None, Some(30), None)
.start_login("remote-docs", &config, None, Some(30))
.await
.expect("start OAuth login");
let response = oauth_browser_client()
.get(login.authorization_url)
.get(login.authorization_url.clone())
.send()
.await
.expect("visit authorization URL");
@@ -450,6 +354,7 @@ async fn start_login_uses_discovered_scopes_when_config_has_no_scopes() {
.and_then(|query| query.get("scope"))
.expect("authorize URL should include discovered scopes");
assert_eq!(scope, "search.read search.write");
login.wait().await.expect("OAuth completion should succeed");
}
#[tokio::test]
@@ -478,12 +383,12 @@ async fn start_login_applies_configured_headers_to_oauth_requests() {
let registry = McpOAuthRegistry::new_in(&store_root);
let login = registry
.start_login("remote-docs", &config, None, Some(30), None)
.start_login("remote-docs", &config, None, Some(30))
.await
.expect("start OAuth login");
let response = oauth_browser_client()
.get(login.authorization_url)
.get(login.authorization_url.clone())
.send()
.await
.expect("visit authorization URL");
@@ -492,41 +397,27 @@ async fn start_login_applies_configured_headers_to_oauth_requests() {
"callback page should be successful"
);
tokio::time::timeout(Duration::from_secs(5), login.wait())
.await
.expect("OAuth completion should finish")
.expect("OAuth completion should succeed");
let store = PersistentCredentialStore::new_in(&store_root, "remote-docs", &server_url);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if store
.has_credentials()
.await
.expect("credential store should be readable")
{
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("OAuth completion should persist token");
assert!(store
.has_credentials()
.await
.expect("credential store should be readable"));
}
#[tokio::test]
async fn oauth_callback_provider_error_emits_server_error_without_timeout() {
async fn oauth_callback_provider_error_returns_without_timeout() {
let provider = spawn_test_oauth_provider().await;
let server_url = format!("{}/mcp", provider.base_url);
let config = dynamic_oauth_config(server_url);
let temp = tempfile::tempdir().expect("tempdir");
let registry = McpOAuthRegistry::new_in(temp.path().join("oauth-store"));
let emitter = RecordingEmitter::default();
let events = emitter.events.clone();
let login = registry
.start_login(
"remote-docs",
&config,
None,
Some(30),
Some(DynEmitter::new(emitter)),
)
.start_login("remote-docs", &config, None, Some(30))
.await
.expect("start OAuth login");
let mut callback_url =
@@ -559,13 +450,10 @@ async fn oauth_callback_provider_error_emits_server_error_without_timeout() {
.expect("visit callback URL");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
wait_for_event(events.clone(), "mcp:server_error", "remote-docs").await;
let events = events.lock().expect("events lock");
let error = events
.iter()
.find(|(name, _)| name == "mcp:server_error")
.map(|(_, payload)| payload["error"].as_str().unwrap_or_default())
.expect("server error should be emitted");
assert!(error.contains("invalid_scope"));
assert!(error.contains("scope rejected"));
let error = tokio::time::timeout(Duration::from_secs(5), login.wait())
.await
.expect("OAuth failure should return without waiting for timeout")
.expect_err("provider error should fail OAuth completion");
assert!(error.to_string().contains("invalid_scope"));
assert!(error.to_string().contains("scope rejected"));
}
@@ -210,6 +210,13 @@ fn snapshot_cache() -> &'static Mutex<Option<SnapshotCacheEntry>> {
CACHE.get_or_init(|| Mutex::new(None))
}
pub fn invalidate_agent_skill_snapshot_cache() {
let mut guard = snapshot_cache()
.lock()
.unwrap_or_else(|error| error.into_inner());
*guard = None;
}
fn cached_snapshot(key: &str) -> Option<AgentSkillSnapshot> {
let guard = snapshot_cache()
.lock()
+4 -3
View File
@@ -47,9 +47,10 @@ pub use agent_selection::{
pub use agent_snapshot::{
agent_skill_roots_for_workspace, build_agent_skill_snapshot,
build_agent_skill_snapshot_from_roots, build_agent_skill_snapshot_from_workspace,
default_agent_skill_roots, AgentSkillAuthority, AgentSkillDependencies, AgentSkillInterface,
AgentSkillMetadata, AgentSkillPolicy, AgentSkillRoot, AgentSkillScope, AgentSkillSnapshot,
AgentSkillSnapshotOptions, AgentSkillSource, AgentSkillToolDependency,
default_agent_skill_roots, invalidate_agent_skill_snapshot_cache, AgentSkillAuthority,
AgentSkillDependencies, AgentSkillInterface, AgentSkillMetadata, AgentSkillPolicy,
AgentSkillRoot, AgentSkillScope, AgentSkillSnapshot, AgentSkillSnapshotOptions,
AgentSkillSource, AgentSkillToolDependency,
};
pub use execution_callback::{
events, ExecutionCallback, ExecutionCompletePayload, StepCompletePayload, StepErrorPayload,
+2 -1
View File
@@ -5,7 +5,7 @@
"workspaces": [
"packages/*"
],
"version": "1.117.0",
"version": "1.118.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
@@ -220,6 +220,7 @@
"smoke:local-history-import-visual-audit": "node scripts/electron/local-history-import-visual-audit-smoke.mjs",
"smoke:local-history-import-real-sample-visual-audit": "node scripts/electron/local-history-import-real-sample-visual-audit-smoke.mjs",
"smoke:mcp-config-electron-fixture": "node scripts/electron/mcp-config-fixture-smoke.mjs",
"smoke:mcp-oauth-notification-electron-fixture": "npm run build:renderer:electron && node scripts/electron/mcp-oauth-notification-fixture-smoke.mjs",
"smoke:settings-about-electron-fixture": "node scripts/electron/settings-about-fixture-smoke.mjs",
"smoke:settings-stats-electron-fixture": "node scripts/electron/settings-stats-fixture-smoke.mjs",
"smoke:settings-environment-electron-fixture": "node scripts/electron/settings-environment-fixture-smoke.mjs",
+4 -3
View File
@@ -86,6 +86,7 @@ const thread = await runtime.readThread({
| `readThread(params, options?)` | `thread/read` | Thread read model | 供 projection hydration / repair 使用,建议 `includeTurns: true`。 |
| `exportEvidence(params, options?)` | `evidence/export` | evidence export response | 缺 surface 时 fail closed,不伪造空 evidence。 |
| `subscribeLifecycleEvents(listener)` | direct v2 notifications | unsubscribe handle | 分发 Thread / Turn / Item lifecycle 与 message delta。 |
| `subscribeSignalEvents(listener)` | direct v2 `error` | unsubscribe handle | 分发 retry/terminal error signal,不进入 lifecycle verifier。 |
| `dispatchEvent(message)` | local event router | boolean | 用于现有 gateway 把 JSON-RPC notification 喂给 client。 |
| `nextEvent(timeoutMs?)` | gateway event source | notification | 优先 gateway `nextEvent`,其次 `drainEvents`。 |
@@ -108,7 +109,7 @@ client 只传递 lifecycle intent 和 facts,不维护 tool 状态机、subagen
## Browser-Safe Session Gateway
Renderer 宿主如果已经有自己的 App Server gateway,应从 browser-safe 子路径导入 session gateway 适配器,避免把根入口中的 Node 侧 sidecar / stdio client 打进前端包。该适配器仍返回标准 `AgentRuntimeClient`,覆盖 turn lifecycle、`readThread``exportEvidence``subscribeLifecycleEvents``dispatchEvent``nextEvent`;宿主缺少 evidence 或 event source 时会 fail closed,不会静默回退 mock。
Renderer 宿主如果已经有自己的 App Server gateway,应从 browser-safe 子路径导入 session gateway 适配器,避免把根入口中的 Node 侧 sidecar / stdio client 打进前端包。该适配器仍返回标准 `AgentRuntimeClient`,覆盖 turn lifecycle、`readThread``exportEvidence``subscribeLifecycleEvents``subscribeSignalEvents``dispatchEvent``nextEvent`;宿主缺少 evidence 或 event source 时会 fail closed,不会静默回退 mock。
`createAgentRuntimeClientFromSessionGateway(...)` 只适配现有 session gateway
不会创建第二套 transport、lifecycle state 或兼容协议。
@@ -154,7 +155,7 @@ const runtime: AgentRuntimeLifecycleClient =
| Root entry | 适合 Node / host owner,复用 `@limecloud/app-server-client``AppServerConnection`。 |
| `./sessionGateway` | browser-safe,适合 renderer 复用已有 App Server gateway。 |
| Electron / Desktop Host | 只能由宿主 gateway 封装,不能在本包直接 import bridge helper。 |
| Event source | `nextEvent(timeoutMs?)``drainEvents(limit?)`接受 direct v2 Thread / Turn / Item notificationmessage delta。 |
| Event source | `nextEvent(timeoutMs?)``drainEvents(limit?)`,接受 direct v2 lifecycle notificationmessage delta 与 typed `error` signal。 |
| Mock / fixture | 只允许测试显式传入,不允许 production transport fallback。 |
如果 gateway 方法来自 class instance,必须在宿主里包成闭包,避免丢失 `this`。本包不会替宿主绑定私有 client 实例。
@@ -200,7 +201,7 @@ Product App business context
最小 runtime client conformance
- lifecycle`startTurn -> steerTurn / readThread -> cancelTurn / respondAction` 均委托 gateway。
- events`subscribeLifecycleEvents``dispatchEvent``nextEvent` 只把 direct v2 Thread / Turn / Item 与 message delta入 lifecycle pipelineraw channel 一律拒绝。
- events`subscribeLifecycleEvents` 只接收 direct v2 Thread / Turn / Item 与 message delta`subscribeSignalEvents` 独立接收 typed `error`,不进入 lifecycle pipelineraw channel 一律拒绝。
- evidence`exportEvidence` 有实现时委托,没有实现时 fail closed。
- errorstransport error 原样传播,不切 mock。
- bundle`@limecloud/agent-runtime-client/sessionGateway` dist 不包含 Node builtin 或 sidecar/stdio 模块;只允许依赖 browser-safe App Server entry。
@@ -5,6 +5,8 @@ export {
type AgentRuntimeLifecycleEventListener,
type AgentRuntimeLifecycleNotification,
type AgentRuntimeNotification,
type AgentRuntimeSignalEventListener,
type AgentRuntimeSignalNotification,
type AgentSessionActionRespondParams,
type AgentSessionActionRespondResponse,
type ThreadReadParams,
@@ -6,6 +6,8 @@ import {
type AgentRuntimeLifecycleEventListener,
type AgentRuntimeLifecycleNotification,
type AgentRuntimeNotification,
type AgentRuntimeSignalEventListener,
type AgentRuntimeSignalNotification,
type JsonRpcError,
type AgentSessionActionRespondParams,
type AgentSessionActionRespondResponse,
@@ -31,7 +33,8 @@ import {
type JsonRpcMessage,
type RequestId,
type StructuredOutputContract,
serverNotification,
agentRuntimeLifecycleNotification,
agentRuntimeSignalNotification,
} from "@limecloud/app-server-client";
export type { StructuredOutputContract };
@@ -57,6 +60,7 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
readonly connection: AppServerConnection;
readonly #base: BaseAppServerAgentRuntimeClient;
readonly #lifecycleListeners = new Set<AgentRuntimeLifecycleEventListener>();
readonly #signalListeners = new Set<AgentRuntimeSignalEventListener>();
readonly #eventPipeline: AgentRuntimeEventPipeline;
readonly #pendingNextEvents: AgentRuntimeNotification[] = [];
@@ -158,8 +162,24 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
};
}
subscribeSignalEvents(
listener: AgentRuntimeSignalEventListener,
): AgentRuntimeClientSubscription {
this.#signalListeners.add(listener);
return {
unsubscribe: () => {
this.#signalListeners.delete(listener);
},
};
}
async dispatchEvent(message: JsonRpcMessage): Promise<boolean> {
const lifecycle = serverNotification(message);
const signal = agentRuntimeSignalNotification(message);
if (signal) {
await this.#dispatchSignal(signal);
return true;
}
const lifecycle = agentRuntimeLifecycleNotification(message);
if (lifecycle) {
const result = await this.#dispatchLifecycle(lifecycle);
return result.accepted;
@@ -167,6 +187,14 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
return false;
}
async #dispatchSignal(
notification: AgentRuntimeSignalNotification,
): Promise<void> {
for (const listener of this.#signalListeners) {
await listener(notification, notification);
}
}
async #dispatchLifecycle(
notification: AgentRuntimeLifecycleNotification,
): Promise<AgentRuntimeEventPipelineResult> {
@@ -189,7 +217,12 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
return pending;
}
const notification = await this.connection.nextNotification(timeoutMs);
const lifecycle = serverNotification(notification);
const signal = agentRuntimeSignalNotification(notification);
if (signal) {
await this.#dispatchSignal(signal);
return signal;
}
const lifecycle = agentRuntimeLifecycleNotification(notification);
if (lifecycle) {
const result = await this.#dispatchLifecycle(lifecycle);
if (result.accepted) {
@@ -1,9 +1,12 @@
import {
serverNotification,
agentRuntimeLifecycleNotification,
agentRuntimeSignalNotification,
AgentRuntimeClient,
AgentRuntimeClientSubscription,
AgentRuntimeLifecycleEventListener,
AgentRuntimeNotification,
AgentRuntimeSignalEventListener,
AgentRuntimeSignalNotification,
AgentSessionActionRespondParams,
AgentSessionActionRespondResponse,
ThreadReadParams,
@@ -169,6 +172,9 @@ export function createAgentRuntimeClientFromSessionGateway(
subscribeLifecycleEvents(listener) {
return eventRouter.subscribeLifecycle(listener);
},
subscribeSignalEvents(listener) {
return eventRouter.subscribeSignal(listener);
},
async dispatchEvent(message) {
const result = await eventRouter.dispatch(message);
return result.accepted;
@@ -264,6 +270,7 @@ async function nextDrainedAgentRuntimeEvent(
class AgentRuntimeGatewayEventRouter {
readonly #lifecycleListeners = new Set<AgentRuntimeLifecycleEventListener>();
readonly #signalListeners = new Set<AgentRuntimeSignalEventListener>();
readonly #eventPipeline: AgentRuntimeEventPipeline;
readonly #pendingNextEvents: AgentRuntimeNotification[] = [];
@@ -287,10 +294,28 @@ class AgentRuntimeGatewayEventRouter {
};
}
subscribeSignal(
listener: AgentRuntimeSignalEventListener,
): AgentRuntimeClientSubscription {
this.#signalListeners.add(listener);
return {
unsubscribe: () => {
this.#signalListeners.delete(listener);
},
};
}
async dispatch(
message: JsonRpcMessage,
): Promise<AgentRuntimeGatewayDispatchResult> {
const lifecycle = serverNotification(message);
const signal = agentRuntimeSignalNotification(message);
if (signal) {
for (const listener of this.#signalListeners) {
await listener(signal, signal);
}
return acceptedSignalResult(signal);
}
const lifecycle = agentRuntimeLifecycleNotification(message);
if (!lifecycle) {
return { accepted: false, reason: "dropped" };
}
@@ -321,4 +346,22 @@ class AgentRuntimeGatewayEventRouter {
}
}
type AgentRuntimeGatewayDispatchResult = AgentRuntimeEventPipelineResult;
type AgentRuntimeSignalDispatchResult = {
accepted: true;
notification: AgentRuntimeSignalNotification;
notifications: [AgentRuntimeSignalNotification];
};
type AgentRuntimeGatewayDispatchResult =
| AgentRuntimeEventPipelineResult
| AgentRuntimeSignalDispatchResult;
function acceptedSignalResult(
signal: AgentRuntimeSignalNotification,
): AgentRuntimeSignalDispatchResult {
return {
accepted: true,
notification: signal,
notifications: [signal],
};
}
@@ -263,13 +263,20 @@ test("malformed direct lifecycle notifications fail closed with explicit mapper
test("root client dispatches direct lifecycle and delta only to lifecycle listeners", async () => {
const runtime = createRuntimeClient();
const lifecycle = [];
const signals = [];
runtime.subscribeLifecycleEvents((event) => lifecycle.push(event));
runtime.subscribeSignalEvents((event) => signals.push(event));
for (const notification of directToolLifecycle()) {
assert.equal(await runtime.dispatchEvent(notification), true);
}
const delta = agentMessageDeltaNotification();
assert.equal(await runtime.dispatchEvent(delta), true);
assert.equal(await runtime.dispatchEvent(errorNotification(true)), true);
assert.equal(
await runtime.dispatchEvent(turnPlanUpdatedNotification()),
true,
);
assert.equal(
await runtime.dispatchEvent(rawNotification("turn.completed")),
false,
@@ -285,6 +292,10 @@ test("root client dispatches direct lifecycle and delta only to lifecycle listen
"item/agentMessage/delta",
],
);
assert.deepEqual(signals, [
errorNotification(true),
turnPlanUpdatedNotification(),
]);
});
test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle", async () => {
@@ -292,7 +303,9 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle"
createMinimalSessionGateway(),
);
const lifecycle = [];
const signals = [];
runtime.subscribeLifecycleEvents((event) => lifecycle.push(event));
runtime.subscribeSignalEvents((event) => signals.push(event));
assert.equal(
await runtime.dispatchEvent(turnNotification("inProgress")),
@@ -307,6 +320,11 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle"
await runtime.dispatchEvent(mcpToolCallProgressNotification()),
true,
);
assert.equal(await runtime.dispatchEvent(errorNotification(false)), true);
assert.equal(
await runtime.dispatchEvent(turnPlanUpdatedNotification()),
true,
);
assert.equal(
await runtime.dispatchEvent(rawNotification("turn.started")),
false,
@@ -321,6 +339,10 @@ test("session gateway dispatches direct lifecycle and rejects wrapper lifecycle"
"item/mcpToolCall/progress",
],
);
assert.deepEqual(signals, [
errorNotification(false),
turnPlanUpdatedNotification(),
]);
});
test("disabling sequence verification does not restore raw lifecycle", async () => {
@@ -369,6 +391,28 @@ test("nextEvent consumes direct notifications from gateway sources", async () =>
const drainedEvent = await drainedRuntime.nextEvent();
assert.equal(drainedEvent.method, "turn/started");
const directSignal = errorNotification(true);
const signalRuntime = createAgentRuntimeClientFromSessionGateway({
...createMinimalSessionGateway(),
async nextEvent() {
return directSignal;
},
});
const signals = [];
signalRuntime.subscribeSignalEvents((event) => signals.push(event));
const signalEvent = await signalRuntime.nextEvent();
assert.equal(signalEvent.method, "error");
assert.deepEqual(signals, [directSignal]);
const drainedSignalRuntime = createAgentRuntimeClientFromSessionGateway({
...createMinimalSessionGateway(),
async drainEvents() {
return [{ method: "log/list", params: {} }, errorNotification(false)];
},
});
assert.equal((await drainedSignalRuntime.nextEvent()).method, "error");
});
test("sequence verifier fails closed for orphan direct tool completion", async () => {
@@ -788,6 +832,35 @@ function agentMessageDeltaNotification(overrides = {}) {
};
}
function errorNotification(willRetry, overrides = {}) {
return {
method: "error",
params: {
error: { message: "provider stream reconnecting" },
threadId: "thread-1",
turnId: "turn-1",
willRetry,
...overrides,
},
};
}
function turnPlanUpdatedNotification(overrides = {}) {
return {
method: "turn/plan/updated",
params: {
explanation: "继续执行",
plan: [
{ step: "读现状", status: "completed" },
{ step: "补主链", status: "inProgress" },
],
threadId: "thread-1",
turnId: "turn-1",
...overrides,
},
};
}
function commandOutputDeltaNotification(overrides = {}) {
return {
method: "item/commandExecution/outputDelta",
@@ -50,13 +50,25 @@ export type AgentRuntimeLifecycleNotification = Extract<
}
>;
export type AgentRuntimeNotification = AgentRuntimeLifecycleNotification;
export type AgentRuntimeSignalNotification = Extract<
ServerNotification,
{ method: "error" | "turn/plan/updated" }
>;
export type AgentRuntimeNotification =
| AgentRuntimeLifecycleNotification
| AgentRuntimeSignalNotification;
export type AgentRuntimeLifecycleEventListener = (
event: AgentRuntimeLifecycleNotification,
notification: AgentRuntimeLifecycleNotification,
) => void | Promise<void>;
export type AgentRuntimeSignalEventListener = (
event: AgentRuntimeSignalNotification,
notification: AgentRuntimeSignalNotification,
) => void | Promise<void>;
export type AgentRuntimeClientOptions = {
request?: AppServerRequestOptions;
};
@@ -109,12 +121,16 @@ export interface AgentRuntimeClient {
subscribeLifecycleEvents(
listener: AgentRuntimeLifecycleEventListener,
): AgentRuntimeClientSubscription;
subscribeSignalEvents(
listener: AgentRuntimeSignalEventListener,
): AgentRuntimeClientSubscription;
dispatchEvent(message: JsonRpcMessage): Promise<boolean>;
nextEvent(timeoutMs?: number): Promise<AgentRuntimeNotification>;
}
export class AppServerAgentEventRouter {
#lifecycleListeners = new Set<AgentRuntimeLifecycleEventListener>();
#signalListeners = new Set<AgentRuntimeSignalEventListener>();
subscribeLifecycle(listener: AgentRuntimeLifecycleEventListener): () => void {
this.#lifecycleListeners.add(listener);
@@ -123,7 +139,21 @@ export class AppServerAgentEventRouter {
};
}
subscribeSignal(listener: AgentRuntimeSignalEventListener): () => void {
this.#signalListeners.add(listener);
return () => {
this.#signalListeners.delete(listener);
};
}
async dispatch(message: JsonRpcMessage): Promise<boolean> {
const signal = agentRuntimeSignalNotification(message);
if (signal) {
for (const listener of this.#signalListeners) {
await listener(signal, signal);
}
return true;
}
const lifecycle = agentRuntimeLifecycleNotification(message);
if (lifecycle) {
for (const listener of this.#lifecycleListeners) {
@@ -254,6 +284,13 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
return { unsubscribe };
}
subscribeSignalEvents(
listener: AgentRuntimeSignalEventListener,
): AgentRuntimeClientSubscription {
const unsubscribe = this.eventRouter.subscribeSignal(listener);
return { unsubscribe };
}
async dispatchEvent(message: JsonRpcMessage): Promise<boolean> {
return await this.eventRouter.dispatch(message);
}
@@ -261,6 +298,11 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
async nextEvent(timeoutMs?: number): Promise<AgentRuntimeNotification> {
for (;;) {
const notification = await this.connection.nextNotification(timeoutMs);
const signal = agentRuntimeSignalNotification(notification);
if (signal) {
await this.dispatchEvent(signal);
return signal;
}
const lifecycle = agentRuntimeLifecycleNotification(notification);
if (lifecycle) {
await this.dispatchEvent(lifecycle);
@@ -273,7 +315,28 @@ export class AppServerAgentRuntimeClient implements AgentRuntimeClient {
export function agentRuntimeLifecycleNotification(
message: JsonRpcMessage,
): AgentRuntimeLifecycleNotification | undefined {
return serverNotification(message);
const notification = serverNotification(message);
return notification && !isAgentRuntimeSignalNotification(notification)
? notification
: undefined;
}
export function agentRuntimeSignalNotification(
message: JsonRpcMessage,
): AgentRuntimeSignalNotification | undefined {
const notification = serverNotification(message);
return notification && isAgentRuntimeSignalNotification(notification)
? notification
: undefined;
}
function isAgentRuntimeSignalNotification(
notification: ServerNotification,
): notification is AgentRuntimeSignalNotification {
return (
notification.method === "error" ||
notification.method === "turn/plan/updated"
);
}
export function createAgentRuntimeClient(
@@ -77,6 +77,7 @@ export const METHOD_DIAGNOSTICS_TRACE_READ = "diagnostics/trace/read";
export const METHOD_DIAGNOSTICS_WINDOWS_STARTUP_READ =
"diagnostics/windowsStartup/read";
export const METHOD_DISCORD_CHANNEL_PROBE = "discordChannel/probe";
export const METHOD_ERROR = "error";
export const METHOD_EVIDENCE_EXPORT = "evidence/export";
export const METHOD_EXECUTION_PROCESS_DRAIN_OUTPUT =
"executionProcess/drainOutput";
@@ -176,6 +177,8 @@ export const METHOD_MCP_SERVER_ENABLED_SET = "mcpServer/enabled/set";
export const METHOD_MCP_SERVER_IMPORT_FROM_APP = "mcpServer/importFromApp";
export const METHOD_MCP_SERVER_LIST = "mcpServer/list";
export const METHOD_MCP_SERVER_OAUTH_LOGIN = "mcpServer/oauth/login";
export const METHOD_MCP_SERVER_OAUTH_LOGIN_COMPLETED =
"mcpServer/oauthLogin/completed";
export const METHOD_MCP_SERVER_START = "mcpServer/start";
export const METHOD_MCP_SERVER_STOP = "mcpServer/stop";
export const METHOD_MCP_SERVER_SYNC_ALL_TO_LIVE = "mcpServer/syncAllToLive";
@@ -310,6 +313,7 @@ export const METHOD_SKILL_REMOTE_INSPECT = "skillRemote/inspect";
export const METHOD_SKILL_REPOSITORY_DELETE = "skillRepository/delete";
export const METHOD_SKILL_REPOSITORY_LIST = "skillRepository/list";
export const METHOD_SKILL_REPOSITORY_SAVE = "skillRepository/save";
export const METHOD_SKILLS_CHANGED = "skills/changed";
export const METHOD_SOUL_STYLE_PACK_INSTALL = "soulStylePack/install";
export const METHOD_SOUL_STYLE_PACK_LIST = "soulStylePack/list";
export const METHOD_SOUL_STYLE_PACK_STATUS_SET = "soulStylePack/status/set";
@@ -364,6 +368,7 @@ export const METHOD_THREAD_UNARCHIVED = "thread/unarchived";
export const METHOD_THREAD_UNSUBSCRIBE = "thread/unsubscribe";
export const METHOD_TURN_COMPLETED = "turn/completed";
export const METHOD_TURN_INTERRUPT = "turn/interrupt";
export const METHOD_TURN_PLAN_UPDATED = "turn/plan/updated";
export const METHOD_TURN_START = "turn/start";
export const METHOD_TURN_STARTED = "turn/started";
export const METHOD_TURN_STEER = "turn/steer";
@@ -639,6 +644,10 @@ export const GENERATED_APP_SERVER_METHODS = [
kind: "request",
method: "discordChannel/probe",
},
{
kind: "notification",
method: "error",
},
{
kind: "request",
method: "evidence/export",
@@ -943,6 +952,10 @@ export const GENERATED_APP_SERVER_METHODS = [
kind: "request",
method: "mcpServer/oauth/login",
},
{
kind: "notification",
method: "mcpServer/oauthLogin/completed",
},
{
kind: "request",
method: "mcpServer/start",
@@ -1415,6 +1428,10 @@ export const GENERATED_APP_SERVER_METHODS = [
kind: "request",
method: "skillRepository/save",
},
{
kind: "notification",
method: "skills/changed",
},
{
kind: "request",
method: "soulStylePack/install",
@@ -1607,6 +1624,10 @@ export const GENERATED_APP_SERVER_METHODS = [
kind: "request",
method: "turn/interrupt",
},
{
kind: "notification",
method: "turn/plan/updated",
},
{
kind: "request",
method: "turn/start",
@@ -4604,6 +4625,44 @@ export interface ClientResponse {
result: unknown;
}
export type CodexErrorInfo =
| "badRequest"
| "contextWindowExceeded"
| "cyberPolicy"
| "internalServerError"
| "other"
| "sandboxError"
| "serverOverloaded"
| "sessionBudgetExceeded"
| "threadRollbackFailed"
| "unauthorized"
| "usageLimitExceeded"
| {
httpConnectionFailed: {
httpStatusCode?: number | null;
};
}
| {
responseStreamConnectionFailed: {
httpStatusCode?: number | null;
};
}
| {
responseStreamDisconnected: {
httpStatusCode?: number | null;
};
}
| {
responseTooManyFailedAttempts: {
httpStatusCode?: number | null;
};
}
| {
activeTurnNotSteerable: {
turnKind: NonSteerableTurnKind;
};
};
export interface CollabAgentState {
message?: null | string;
status: CollabAgentStatus;
@@ -5145,6 +5204,13 @@ export type EndpointKind =
| "openai_compatible"
| "provider_base_url";
export interface ErrorNotification {
error: TurnError;
threadId: string;
turnId: string;
willRetry: boolean;
}
export interface EvidenceExportParams {
includeArtifacts?: boolean | null;
includeEvents?: boolean | null;
@@ -5948,6 +6014,13 @@ export interface McpServerListResponse {
servers?: unknown[];
}
export interface McpServerOauthLoginCompletedNotification {
error?: null | string;
name: string;
success: boolean;
threadId: null | string;
}
export interface McpServerOauthLoginParams {
name: string;
scopes?: string[] | null;
@@ -6897,6 +6970,8 @@ export interface ModelVerificationNotification {
verifications: ModelVerification[];
}
export type NonSteerableTurnKind = "compact" | "review";
export interface OpenDeepLinkPayload {
action?: null | string;
kind: string;
@@ -7724,6 +7799,18 @@ export type ServerNotification =
method: "warning";
params: WarningNotification;
}
| {
method: "error";
params: ErrorNotification;
}
| {
method: "skills/changed";
params: SkillsChangedNotification;
}
| {
method: "mcpServer/oauthLogin/completed";
params: McpServerOauthLoginCompletedNotification;
}
| {
method: "thread/started";
params: ThreadStartedNotification;
@@ -7760,6 +7847,10 @@ export type ServerNotification =
method: "turn/completed";
params: TurnCompletedNotification;
}
| {
method: "turn/plan/updated";
params: TurnPlanUpdatedNotification;
}
| {
method: "item/started";
params: ItemStartedNotification;
@@ -8204,6 +8295,8 @@ export interface SkillWorkflowStep {
name: string;
}
export type SkillsChangedNotification = Record<string, never>;
export interface SleepItem {
durationMs?: number | null;
id: string;
@@ -9096,7 +9189,7 @@ export interface TurnEnvironmentParams {
export interface TurnError {
additionalDetails?: null | string;
errorInfo?: unknown;
codexErrorInfo?: CodexErrorInfo | null;
message: string;
}
@@ -9109,6 +9202,20 @@ export type TurnInterruptResponse = Record<string, unknown>;
export type TurnItemsView = "full" | "notLoaded" | "summary";
export interface TurnPlanStep {
status: TurnPlanStepStatus;
step: string;
}
export type TurnPlanStepStatus = "completed" | "inProgress" | "pending";
export interface TurnPlanUpdatedNotification {
explanation?: null | string;
plan: TurnPlanStep[];
threadId: string;
turnId: string;
}
export interface TurnStartParams {
additionalContext?: null | Record<string, unknown>;
approvalPolicy?: unknown;
@@ -8,6 +8,8 @@ export type RuntimeServerNotification = Extract<
ServerNotification,
{
method:
| "error"
| "turn/plan/updated"
| "thread/started"
| "turn/started"
| "turn/completed"
@@ -30,6 +32,16 @@ export type ModelListUpdatedServerNotification = Extract<
{ method: "model/list/updated" }
>;
export type SkillsChangedServerNotification = Extract<
ServerNotification,
{ method: "skills/changed" }
>;
export type McpServerOauthLoginCompletedServerNotification = Extract<
ServerNotification,
{ method: "mcpServer/oauthLogin/completed" }
>;
export type ServerNotificationFor<
Method extends RuntimeServerNotification["method"],
> = Extract<RuntimeServerNotification, { method: Method }>;
@@ -42,6 +54,14 @@ export function serverNotification(
}
switch (message.method) {
case "error":
return hasErrorNotification(message.params)
? (message as ServerNotificationFor<"error">)
: undefined;
case "turn/plan/updated":
return hasTurnPlanUpdatedNotification(message.params)
? (message as ServerNotificationFor<"turn/plan/updated">)
: undefined;
case "thread/started":
return hasEntityId(record(message.params)?.thread)
? (message as ServerNotificationFor<"thread/started">)
@@ -146,12 +166,65 @@ export function isModelListUpdatedNotification(
return modelListUpdatedServerNotification(message) !== undefined;
}
export function skillsChangedServerNotification(
message: JsonRpcMessage,
): SkillsChangedServerNotification | undefined {
if (!isJsonRpcNotification(message) || message.method !== "skills/changed") {
return undefined;
}
const params = record(message.params);
if (!params || Object.keys(params).length !== 0) {
return undefined;
}
return message as SkillsChangedServerNotification;
}
export function isSkillsChangedNotification(
message: JsonRpcMessage,
): message is SkillsChangedServerNotification {
return skillsChangedServerNotification(message) !== undefined;
}
export function mcpServerOauthLoginCompletedServerNotification(
message: JsonRpcMessage,
): McpServerOauthLoginCompletedServerNotification | undefined {
if (
!isJsonRpcNotification(message) ||
message.method !== "mcpServer/oauthLogin/completed"
) {
return undefined;
}
const params = record(message.params);
if (
!hasOnlyKeys(params, ["error", "name", "success", "threadId"]) ||
!hasString(params, "name") ||
typeof params?.success !== "boolean" ||
!hasRequiredNullableString(params, "threadId") ||
!hasOptionalString(params, "error")
) {
return undefined;
}
return message as McpServerOauthLoginCompletedServerNotification;
}
export function isThreadStartedNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"thread/started"> {
return serverNotification(message)?.method === "thread/started";
}
export function isErrorNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"error"> {
return serverNotification(message)?.method === "error";
}
export function isTurnPlanUpdatedNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"turn/plan/updated"> {
return serverNotification(message)?.method === "turn/plan/updated";
}
export function isTurnStartedNotification(
message: JsonRpcMessage,
): message is ServerNotificationFor<"turn/started"> {
@@ -246,6 +319,133 @@ function hasTurnNotification(value: unknown, statuses: string[]): boolean {
);
}
function hasErrorNotification(value: unknown): boolean {
const params = record(value);
const error = record(params?.error);
const additionalDetails = error?.additionalDetails;
const codexErrorInfo = error?.codexErrorInfo;
return (
hasOnlyKeys(params, ["error", "threadId", "turnId", "willRetry"]) &&
hasOnlyKeys(error, ["additionalDetails", "codexErrorInfo", "message"]) &&
hasString(params, "threadId") &&
hasString(params, "turnId") &&
hasString(error, "message") &&
typeof params?.willRetry === "boolean" &&
hasCodexErrorInfo(codexErrorInfo) &&
(additionalDetails === undefined ||
additionalDetails === null ||
typeof additionalDetails === "string")
);
}
function hasTurnPlanUpdatedNotification(value: unknown): boolean {
const params = record(value);
if (
!hasOnlyKeys(params, ["explanation", "plan", "threadId", "turnId"]) ||
!hasString(params, "threadId") ||
!hasString(params, "turnId") ||
!Array.isArray(params?.plan) ||
(params.explanation !== undefined &&
params.explanation !== null &&
typeof params.explanation !== "string")
) {
return false;
}
return params.plan.every((value) => {
const step = record(value);
return (
hasOnlyKeys(step, ["status", "step"]) &&
hasString(step, "step") &&
(step?.status === "pending" ||
step?.status === "inProgress" ||
step?.status === "completed")
);
});
}
function hasOnlyKeys(
value: Record<string, unknown> | undefined,
allowedKeys: string[],
): boolean {
return Boolean(
value && Object.keys(value).every((key) => allowedKeys.includes(key)),
);
}
function hasRequiredNullableString(
value: Record<string, unknown> | undefined,
key: string,
): boolean {
return Boolean(
value &&
Object.prototype.hasOwnProperty.call(value, key) &&
(value[key] === null || hasString(value, key)),
);
}
function hasOptionalString(
value: Record<string, unknown> | undefined,
key: string,
): boolean {
return Boolean(
value &&
(!Object.prototype.hasOwnProperty.call(value, key) ||
typeof value[key] === "string"),
);
}
function hasCodexErrorInfo(value: unknown): boolean {
if (value === undefined || value === null) {
return true;
}
if (typeof value === "string") {
return [
"badRequest",
"contextWindowExceeded",
"cyberPolicy",
"internalServerError",
"other",
"sandboxError",
"serverOverloaded",
"sessionBudgetExceeded",
"threadRollbackFailed",
"unauthorized",
"usageLimitExceeded",
].includes(value);
}
const variant = record(value);
if (!variant || Object.keys(variant).length !== 1) {
return false;
}
const variantName = Object.keys(variant)[0];
const details = record(variant[variantName]);
if (!details) {
return false;
}
if (variantName === "activeTurnNotSteerable") {
return details.turnKind === "review" || details.turnKind === "compact";
}
if (
![
"httpConnectionFailed",
"responseStreamConnectionFailed",
"responseStreamDisconnected",
"responseTooManyFailedAttempts",
].includes(variantName)
) {
return false;
}
const status = details.httpStatusCode;
return (
status === undefined ||
status === null ||
(Number.isInteger(status) &&
(status as number) >= 0 &&
(status as number) <= 65_535)
);
}
function hasItemNotification(
value: unknown,
timestampKey: "startedAtMs" | "completedAtMs",
@@ -3479,6 +3479,37 @@ test("agent runtime client facade subscribes to direct lifecycle notifications",
assert.equal(received[0].message.method, "turn/started");
});
test("agent runtime client facade returns typed errors through the signal channel", async () => {
const notification = {
method: "error",
params: {
error: { message: "provider stream reconnecting" },
threadId: "thread_external",
turnId: "turn_external",
willRetry: true,
},
};
const runtime = new AppServerAgentRuntimeClient(
new AppServerConnection({
send() {},
async nextMessage() {
return notification;
},
}),
);
const signals = [];
const subscription = runtime.subscribeSignalEvents((event, message) => {
signals.push({ event, message });
});
assert.equal(await runtime.dispatchEvent(notification), true);
assert.equal((await runtime.nextEvent()).method, "error");
subscription.unsubscribe();
assert.equal(signals.length, 2);
assert.equal(signals[0].event.params.willRetry, true);
assert.equal(signals[0].message.method, "error");
});
test("agent runtime client facade subscribes to direct item lifecycle", async () => {
const notification = {
method: "item/started",
@@ -4628,6 +4659,53 @@ test("routes direct lifecycle notifications without wrapper projection", async (
]);
});
test("routes typed error notifications through the signal router", async () => {
const notification = {
method: "error",
params: {
error: { message: "provider stream reconnecting" },
threadId: "thread_external",
turnId: "turn_external",
willRetry: true,
},
};
const router = new AppServerAgentEventRouter();
const signals = [];
const unsubscribeSignal = router.subscribeSignal((event) => {
signals.push(event);
});
assert.equal(agentRuntimeLifecycleNotification(notification), undefined);
assert.equal(await router.dispatch(notification), true);
unsubscribeSignal();
assert.deepEqual(signals, [notification]);
});
test("routes turn plan updates only through the signal router", async () => {
const notification = {
method: "turn/plan/updated",
params: {
explanation: "继续执行",
plan: [
{ step: "读现状", status: "completed" },
{ step: "补主链", status: "inProgress" },
],
threadId: "thread_external",
turnId: "turn_external",
},
};
const router = new AppServerAgentEventRouter();
const lifecycle = [];
const signals = [];
router.subscribeLifecycle((event) => lifecycle.push(event));
router.subscribeSignal((event) => signals.push(event));
assert.equal(agentRuntimeLifecycleNotification(notification), undefined);
assert.equal(await router.dispatch(notification), true);
assert.deepEqual(lifecycle, []);
assert.deepEqual(signals, [notification]);
});
test("connection buffers request responses read by idle notification loop", async () => {
const sent = [];
const waiters = [];
@@ -3,6 +3,7 @@ import { test } from "vitest";
import {
isAgentMessageDeltaNotification,
isCommandExecutionOutputDeltaNotification,
isErrorNotification,
isFileChangePatchUpdatedNotification,
isItemCompletedNotification,
isItemStartedNotification,
@@ -13,19 +14,121 @@ import {
isReasoningSummaryTextDeltaNotification,
isReasoningTextDeltaNotification,
isServerNotification,
isSkillsChangedNotification,
isThreadStartedNotification,
isThreadSettingsUpdatedNotification,
isTurnPlanUpdatedNotification,
isTurnCompletedNotification,
isTurnStartedNotification,
modelListUpdatedServerNotification,
mcpServerOauthLoginCompletedServerNotification,
serverNotification,
skillsChangedServerNotification,
} from "../dist/index.js";
const threadId = "thread-1";
const turnId = "turn-1";
test("recognizes strict skills/changed catalog invalidation", () => {
const notification = { method: "skills/changed", params: {} };
assert.deepEqual(skillsChangedServerNotification(notification), notification);
assert.equal(isSkillsChangedNotification(notification), true);
for (const malformed of [
{ method: "skills/changed" },
{ method: "skills/changed", params: null },
{ method: "skills/changed", params: { path: "/private/skill" } },
]) {
assert.equal(skillsChangedServerNotification(malformed), undefined);
assert.equal(isSkillsChangedNotification(malformed), false);
}
});
test("recognizes strict mcpServer/oauthLogin/completed notifications", () => {
const success = {
method: "mcpServer/oauthLogin/completed",
params: {
name: "remote-docs",
threadId: null,
success: true,
},
};
const failure = {
method: "mcpServer/oauthLogin/completed",
params: {
name: "remote-docs",
threadId: "thread-1",
success: false,
error: "scope rejected",
},
};
assert.deepEqual(
mcpServerOauthLoginCompletedServerNotification(success),
success,
);
assert.deepEqual(
mcpServerOauthLoginCompletedServerNotification(failure),
failure,
);
for (const malformed of [
{ method: "mcpServer/oauthLogin/completed" },
{
method: "mcpServer/oauthLogin/completed",
params: { name: "remote-docs", success: true },
},
{
method: "mcpServer/oauthLogin/completed",
params: { name: "", threadId: null, success: true },
},
{
method: "mcpServer/oauthLogin/completed",
params: { name: "remote-docs", threadId: 7, success: true },
},
{
method: "mcpServer/oauthLogin/completed",
params: {
name: "remote-docs",
threadId: null,
success: false,
error: null,
},
},
{
method: "mcpServer/oauthLogin/completed",
params: {
name: "remote-docs",
threadId: null,
success: true,
serverName: "legacy",
},
},
]) {
assert.equal(
mcpServerOauthLoginCompletedServerNotification(malformed),
undefined,
);
}
});
test("recognizes native v2 lifecycle and reasoning notifications", () => {
const notifications = [
{
method: "error",
params: {
error: {
message: "provider stream reconnecting",
codexErrorInfo: {
responseStreamDisconnected: { httpStatusCode: null },
},
additionalDetails: null,
},
threadId,
turnId,
willRetry: true,
},
},
{
method: "thread/started",
params: { thread: { id: threadId } },
@@ -44,6 +147,18 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
turn: { id: turnId, status: "completed" },
},
},
{
method: "turn/plan/updated",
params: {
explanation: "继续执行",
plan: [
{ step: "读取现状", status: "completed" },
{ step: "补齐主链", status: "inProgress" },
],
threadId,
turnId,
},
},
{
method: "item/started",
params: {
@@ -130,28 +245,30 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
];
assert.equal(notifications.every(isServerNotification), true);
assert.equal(isThreadStartedNotification(notifications[0]), true);
assert.equal(isTurnStartedNotification(notifications[1]), true);
assert.equal(isTurnCompletedNotification(notifications[2]), true);
assert.equal(isItemStartedNotification(notifications[3]), true);
assert.equal(isItemCompletedNotification(notifications[4]), true);
assert.equal(isAgentMessageDeltaNotification(notifications[5]), true);
assert.equal(isErrorNotification(notifications[0]), true);
assert.equal(isThreadStartedNotification(notifications[1]), true);
assert.equal(isTurnStartedNotification(notifications[2]), true);
assert.equal(isTurnCompletedNotification(notifications[3]), true);
assert.equal(isTurnPlanUpdatedNotification(notifications[4]), true);
assert.equal(isItemStartedNotification(notifications[5]), true);
assert.equal(isItemCompletedNotification(notifications[6]), true);
assert.equal(isAgentMessageDeltaNotification(notifications[7]), true);
assert.equal(
isCommandExecutionOutputDeltaNotification(notifications[6]),
isCommandExecutionOutputDeltaNotification(notifications[8]),
true,
);
assert.equal(isFileChangePatchUpdatedNotification(notifications[7]), true);
assert.equal(isPlanDeltaNotification(notifications[8]), true);
assert.equal(isMcpToolCallProgressNotification(notifications[9]), true);
assert.equal(isFileChangePatchUpdatedNotification(notifications[9]), true);
assert.equal(isPlanDeltaNotification(notifications[10]), true);
assert.equal(isMcpToolCallProgressNotification(notifications[11]), true);
assert.equal(
isReasoningSummaryTextDeltaNotification(notifications[10]),
isReasoningSummaryTextDeltaNotification(notifications[12]),
true,
);
assert.equal(
isReasoningSummaryPartAddedNotification(notifications[11]),
isReasoningSummaryPartAddedNotification(notifications[13]),
true,
);
assert.equal(isReasoningTextDeltaNotification(notifications[12]), true);
assert.equal(isReasoningTextDeltaNotification(notifications[14]), true);
assert.equal(
isThreadSettingsUpdatedNotification({
method: "thread/settings/updated",
@@ -168,6 +285,87 @@ test("recognizes native v2 lifecycle and reasoning notifications", () => {
);
});
test("fails closed for malformed turn plan updates", () => {
const malformed = [
{
method: "turn/plan/updated",
params: {
plan: [{ step: "补齐主链", status: "running" }],
threadId,
turnId,
},
},
{
method: "turn/plan/updated",
params: {
plan: [{ step: "补齐主链", status: "inProgress", extra: true }],
threadId,
turnId,
},
},
{
method: "turn/plan/updated",
params: {
plan: [{ step: "", status: "completed" }],
threadId,
turnId,
},
},
];
assert.equal(
malformed.every((message) => !isTurnPlanUpdatedNotification(message)),
true,
);
});
test("fails closed for malformed typed error notifications", () => {
const malformed = [
{ error: { message: "failed" }, threadId, turnId },
{ error: { message: "failed" }, threadId, turnId, willRetry: "false" },
{ error: { message: "" }, threadId, turnId, willRetry: false },
{ error: { message: "failed" }, threadId: "", turnId, willRetry: false },
{ error: { message: "failed" }, threadId, turnId: "", willRetry: false },
{
error: { message: "failed", additionalDetails: 42 },
threadId,
turnId,
willRetry: false,
},
{
error: {
message: "failed",
codexErrorInfo: "responseStreamDisconnected",
},
threadId,
turnId,
willRetry: false,
},
{
error: { message: "failed", legacyCode: "retryable" },
threadId,
turnId,
willRetry: false,
},
{
error: { message: "failed" },
retryable: true,
threadId,
turnId,
willRetry: false,
},
].map((params) => ({ method: "error", params }));
assert.equal(
malformed.every((message) => !isServerNotification(message)),
true,
);
assert.equal(
malformed.every((message) => !isErrorNotification(message)),
true,
);
});
test("recognizes typed model list update notifications", () => {
const providerUpdate = {
method: "model/list/updated",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.117.0",
"version": "1.118.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
@@ -41,6 +41,10 @@ import {
TERMINAL_STALE_GUARD_FIRST_PROMPT,
TERMINAL_STALE_GUARD_SCENARIO,
TERMINAL_STALE_GUARD_SECOND_PROMPT,
TYPED_ERROR_RETRY_FAILURE_PROMPT,
TYPED_ERROR_RETRY_FAILURE_SCENARIO,
TYPED_ERROR_RETRY_SUCCESS_PROMPT,
TYPED_ERROR_RETRY_SUCCESS_SCENARIO,
WEB_TOOLS_RENDERING_PROMPT,
} from "./claw-chat-current-fixture-constants.mjs";
import { EXPERT_PANEL_SKILLS_RUNTIME_UI_SKILL_REF } from "./claw-chat-current-fixture-expert-actions.mjs";
@@ -113,6 +117,12 @@ export function resolveGateBExpectedIdentity({
}
function resolveScenarioTurnStart(summary, options) {
if (
options?.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO ||
options?.scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO
) {
return summary?.typedErrorTurnStart ?? null;
}
if (options?.scenario === "skills-runtime") {
return summary?.manualEnableSkillsRuntimeTurnStart?.backend ?? null;
}
@@ -370,6 +380,12 @@ export function buildAssertionContext({
entry.kind === "turnStart" &&
entry.inputText === TERMINAL_STALE_GUARD_SECOND_PROMPT,
);
const typedErrorTurnStart = backendLedger.find(
(entry) =>
entry.kind === "turnStart" &&
(entry.inputText === TYPED_ERROR_RETRY_SUCCESS_PROMPT ||
entry.inputText === TYPED_ERROR_RETRY_FAILURE_PROMPT),
);
const mcpStructuredContentTurnStart = backendLedger.find(
(entry) =>
entry.kind === "turnStart" &&
@@ -451,6 +467,12 @@ export function buildAssertionContext({
options.scenario === TERMINAL_FAILED_AFTER_ANSWER_SCENARIO;
const isTerminalStaleGuardScenario =
options.scenario === TERMINAL_STALE_GUARD_SCENARIO;
const isTypedErrorRetrySuccessScenario =
options.scenario === TYPED_ERROR_RETRY_SUCCESS_SCENARIO;
const isTypedErrorRetryFailureScenario =
options.scenario === TYPED_ERROR_RETRY_FAILURE_SCENARIO;
const isTypedErrorRetryScenario =
isTypedErrorRetrySuccessScenario || isTypedErrorRetryFailureScenario;
const isMcpStructuredContentScenario =
options.scenario === "mcp-structured-content";
const isMediaReferenceScenario =
@@ -502,19 +524,21 @@ export function buildAssertionContext({
? terminalCanceledAfterAnswerTurnStart?.runtimeRequest
: isTerminalFailedAfterAnswerScenario
? terminalFailedAfterAnswerTurnStart?.runtimeRequest
: isMcpStructuredContentScenario
? mcpStructuredContentTurnStart?.runtimeRequest
: isMediaReferenceScenario
? mediaReferenceTurnStart?.runtimeRequest
: isSkillsRuntimeScenario
? skillsRuntimeTurnStart?.runtimeRequest
: isAnyExpertSkillsRuntimeScenario
? expertRuntimeTurnStartForAssertions?.runtimeRequest
: isContentFactoryArticleWorkspaceScenario
? {}
: isHomeHotpathScenario
? homeHotpathTurnStart?.runtimeRequest
: newsTurnStart?.runtimeRequest) ??
: isTypedErrorRetryScenario
? typedErrorTurnStart?.runtimeRequest
: isMcpStructuredContentScenario
? mcpStructuredContentTurnStart?.runtimeRequest
: isMediaReferenceScenario
? mediaReferenceTurnStart?.runtimeRequest
: isSkillsRuntimeScenario
? skillsRuntimeTurnStart?.runtimeRequest
: isAnyExpertSkillsRuntimeScenario
? expertRuntimeTurnStartForAssertions?.runtimeRequest
: isContentFactoryArticleWorkspaceScenario
? {}
: isHomeHotpathScenario
? homeHotpathTurnStart?.runtimeRequest
: newsTurnStart?.runtimeRequest) ??
{};
const hasCancelPhase = isCancelOnlyScenario || isCancelThenContinueScenario;
const goalHarness = readHarnessMetadataFromTurnStart(goalTurnStart);
@@ -588,38 +612,43 @@ export function buildAssertionContext({
: isTerminalFailedAfterAnswerScenario
? terminalFailedAfterAnswerTurnStart?.inputText ===
TERMINAL_FAILED_AFTER_ANSWER_PROMPT
: isTerminalStaleGuardScenario
? terminalStaleGuardFirstTurnStart?.inputText ===
TERMINAL_STALE_GUARD_FIRST_PROMPT &&
terminalStaleGuardSecondTurnStart?.inputText ===
TERMINAL_STALE_GUARD_SECOND_PROMPT
: isMcpStructuredContentScenario
? mcpStructuredContentTurnStart?.inputText ===
MCP_STRUCTURED_CONTENT_PROMPT
: isMediaReferenceScenario
? mediaReferenceTurnStart?.inputText ===
MEDIA_REFERENCE_PROMPT
: isSkillsRuntimeScenario
? skillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_PROMPT &&
explicitSkillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_EXPLICIT_PROMPT &&
manualEnableSkillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_MANUAL_ENABLE_PROMPT
: isAnyExpertSkillsRuntimeScenario
? isExpertPanelSkillsRuntimeScenario
? expertPanelSkillsRuntimeTurnStart?.inputText ===
EXPERT_SKILLS_RUNTIME_PANEL_PROMPT
: expertSkillsRuntimeTurnStart?.inputText?.includes(
EXPERT_SKILLS_RUNTIME_PROMPT,
) === true
: isContentFactoryArticleWorkspaceScenario
? true
: isSoulStyleScenario
? newsTraceTurnStart?.inputText ===
NEWS_PROMPT
: newsTurnStart?.inputText ===
NEWS_PROMPT;
: isTypedErrorRetryScenario
? typedErrorTurnStart?.inputText ===
(isTypedErrorRetrySuccessScenario
? TYPED_ERROR_RETRY_SUCCESS_PROMPT
: TYPED_ERROR_RETRY_FAILURE_PROMPT)
: isTerminalStaleGuardScenario
? terminalStaleGuardFirstTurnStart?.inputText ===
TERMINAL_STALE_GUARD_FIRST_PROMPT &&
terminalStaleGuardSecondTurnStart?.inputText ===
TERMINAL_STALE_GUARD_SECOND_PROMPT
: isMcpStructuredContentScenario
? mcpStructuredContentTurnStart?.inputText ===
MCP_STRUCTURED_CONTENT_PROMPT
: isMediaReferenceScenario
? mediaReferenceTurnStart?.inputText ===
MEDIA_REFERENCE_PROMPT
: isSkillsRuntimeScenario
? skillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_PROMPT &&
explicitSkillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_EXPLICIT_PROMPT &&
manualEnableSkillsRuntimeTurnStart?.inputText ===
SKILLS_RUNTIME_MANUAL_ENABLE_PROMPT
: isAnyExpertSkillsRuntimeScenario
? isExpertPanelSkillsRuntimeScenario
? expertPanelSkillsRuntimeTurnStart?.inputText ===
EXPERT_SKILLS_RUNTIME_PANEL_PROMPT
: expertSkillsRuntimeTurnStart?.inputText?.includes(
EXPERT_SKILLS_RUNTIME_PROMPT,
) === true
: isContentFactoryArticleWorkspaceScenario
? true
: isSoulStyleScenario
? newsTraceTurnStart?.inputText ===
NEWS_PROMPT
: newsTurnStart?.inputText ===
NEWS_PROMPT;
return {
backendLedger,
traceMessages,
@@ -656,6 +685,7 @@ export function buildAssertionContext({
terminalFailedAfterAnswerTurnStart,
terminalStaleGuardFirstTurnStart,
terminalStaleGuardSecondTurnStart,
typedErrorTurnStart,
mcpStructuredContentTurnStart,
mediaReferenceTurnStart,
skillsRuntimeTurnStart,
@@ -686,6 +716,9 @@ export function buildAssertionContext({
isTerminalCanceledAfterAnswerScenario,
isTerminalFailedAfterAnswerScenario,
isTerminalStaleGuardScenario,
isTypedErrorRetrySuccessScenario,
isTypedErrorRetryFailureScenario,
isTypedErrorRetryScenario,
isMcpStructuredContentScenario,
isMediaReferenceScenario,
isSkillsRuntimeScenario,
@@ -5,13 +5,25 @@ import { buildNotApplicableAssertions } from "./claw-chat-current-fixture-not-ap
import { buildScenarioAssertions } from "./claw-chat-current-fixture-scenario-assertions.mjs";
import { assert } from "./claw-chat-current-fixture-utils.mjs";
import { buildGateBContractAssertions } from "./claw-chat-current-fixture-gate-b-contract.mjs";
import {
buildTurnPlanUpdateScenarioAssertions,
TURN_PLAN_UPDATE_SCENARIO,
} from "./claw-chat-current-fixture-turn-plan-update.mjs";
export function buildFixtureAssertionReport(input) {
const backendSummary = summarizeBackendLedger(input.backendLedger);
const context = buildAssertionContext(input);
const commonAssertions = buildCommonAssertions(context);
const scenarioAssertions = buildScenarioAssertions(context);
const notApplicableAssertions = buildNotApplicableAssertions(context);
const isTurnPlanUpdateScenario =
input.options?.scenario === TURN_PLAN_UPDATE_SCENARIO;
const commonAssertions = isTurnPlanUpdateScenario
? {}
: buildCommonAssertions(context);
const scenarioAssertions = isTurnPlanUpdateScenario
? buildTurnPlanUpdateScenarioAssertions(context)
: buildScenarioAssertions(context);
const notApplicableAssertions = isTurnPlanUpdateScenario
? []
: buildNotApplicableAssertions(context);
const gateBContractAssertions = buildGateBContractAssertions(
context.gateBContract,
);
@@ -13,6 +13,11 @@ import {
NEWS_PROMPT,
TEXT_PROVIDER_FIXTURE_API_KEY,
TEXT_FIXTURE_PROVIDER_NAME,
TURN_PLAN_UPDATE_DONE_TEXT,
TURN_PLAN_UPDATE_EXPLANATION,
TURN_PLAN_UPDATE_PROMPT,
TURN_PLAN_UPDATE_STEPS,
TURN_PLAN_UPDATE_TOOL_CALL_ID,
} from "./claw-chat-current-fixture-constants.mjs";
import { writeFixtureBackend } from "./claw-chat-current-fixture-backend-script.mjs";
import {
@@ -87,6 +92,10 @@ export function createTempRuntimeEnv() {
const backendPath = path.join(tempRoot, "claw-chat-backend.mjs");
const backendLedgerPath = path.join(tempRoot, "claw-chat-backend.jsonl");
const cancelSignalPath = path.join(tempRoot, "claw-chat-cancel.signal");
const typedErrorSignalPath = path.join(
tempRoot,
"claw-chat-typed-error.signal",
);
const mediaReferenceSourcePath = path.join(
tempRoot,
"fixture-media-reference.png",
@@ -116,6 +125,7 @@ export function createTempRuntimeEnv() {
writeFixtureConfig(homeConfigPath);
writeFixtureConfig(macConfigPath);
fs.writeFileSync(backendLedgerPath, "");
fs.writeFileSync(typedErrorSignalPath, "");
fs.writeFileSync(
mediaReferenceSourcePath,
Buffer.from(IMAGE_PROVIDER_FIXTURE_DATA_URL.split(",")[1] || "", "base64"),
@@ -129,6 +139,7 @@ export function createTempRuntimeEnv() {
backendPath,
backendLedgerPath,
cancelSignalPath,
typedErrorSignalPath,
mediaReferenceSourcePath,
configPath,
macConfigPath,
@@ -266,6 +277,12 @@ export async function startImageProviderFixtureServer() {
function fixtureTextForChatRequest(body) {
const serialized = typeof body === "string" ? body : JSON.stringify(body);
if (
serialized.includes(TURN_PLAN_UPDATE_PROMPT) &&
serialized.includes("Plan updated")
) {
return `执行清单已更新,主链继续运行。\n${TURN_PLAN_UPDATE_DONE_TEXT}`;
}
if (serialized.includes(ACTIVE_STEER_INPUT)) {
return `${ACTIVE_STEER_FINAL_TEXT}\n${ACTIVE_STEER_DONE_TEXT}`;
}
@@ -347,6 +364,7 @@ function summarizeChatCompletionRequestBody(body, expectedSoulStyle) {
parsed = {};
}
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
const tools = Array.isArray(parsed.tools) ? parsed.tools : [];
const serialized = body || "";
return {
stream: parsed.stream ?? null,
@@ -355,6 +373,22 @@ function summarizeChatCompletionRequestBody(body, expectedSoulStyle) {
responseFormatType:
parsed.response_format?.type ?? parsed.responseFormat?.type ?? null,
toolChoice: parsed.tool_choice ?? parsed.toolChoice ?? null,
toolNames: tools
.map((tool) => tool?.function?.name ?? tool?.name ?? null)
.filter(Boolean),
bodyIncludesUpdatePlanToolDefinition: tools.some(
(tool) => (tool?.function?.name ?? tool?.name) === "update_plan",
),
bodyIncludesTurnPlanUpdatePrompt: messages.some(
(message) =>
message?.role === "user" &&
readChatContentText(message?.content).includes(TURN_PLAN_UPDATE_PROMPT),
),
bodyIncludesPlanUpdatedToolResult: messages.some(
(message) =>
message?.role === "tool" &&
readChatContentText(message?.content).includes("Plan updated"),
),
soulMarkers: summarizeSoulPromptMarkers(serialized, expectedSoulStyle),
bodyIncludesPresentationContract:
serialized.includes("image_task_presentation.v1") ||
@@ -444,7 +478,45 @@ function openaiChatCompletionSseBody(content) {
return `data: ${firstChunk}\n\ndata: ${finalChunk}\n\ndata: ${usageChunk}\n\ndata: [DONE]\n\n`;
}
function openaiModelsBody() {
function openaiToolCallSseBody({ name, callId, argumentsValue }) {
const toolChunk = JSON.stringify({
id: "chatcmpl-claw-text-fixture",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: FIXTURE_MODEL,
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: callId,
type: "function",
function: {
name,
arguments: JSON.stringify(argumentsValue),
},
},
],
},
finish_reason: null,
},
],
});
const finalChunk = JSON.stringify({
id: "chatcmpl-claw-text-fixture",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: FIXTURE_MODEL,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
});
const usageChunk = JSON.stringify(openaiChatCompletionUsageChunk());
return `data: ${toolChunk}\n\ndata: ${finalChunk}\n\ndata: ${usageChunk}\n\ndata: [DONE]\n\n`;
}
function openaiModelsBody({ supportsTools = false } = {}) {
return {
object: "list",
data: [
@@ -456,10 +528,13 @@ function openaiModelsBody() {
input_modalities: ["text", "image"],
output_modalities: ["text"],
task_families: ["chat", "vision_understanding"],
runtime_features: ["streaming"],
runtime_features: supportsTools
? ["streaming", "tool_calling"]
: ["streaming"],
capabilities: {
vision: true,
streaming: true,
tools: supportsTools,
},
},
],
@@ -469,6 +544,7 @@ function openaiModelsBody() {
export async function startTextProviderFixtureServer({
soulStyleExpectation = null,
activeSteerDelayMs = 0,
turnPlanUpdate = false,
} = {}) {
const requests = [];
const expectedAuthorization = `Bearer ${TEXT_PROVIDER_FIXTURE_API_KEY}`;
@@ -512,7 +588,9 @@ export async function startTextProviderFixtureServer({
["/models", "/v1/models"].includes(pathname)
) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(openaiModelsBody()));
response.end(
JSON.stringify(openaiModelsBody({ supportsTools: turnPlanUpdate })),
);
return;
}
if (
@@ -530,6 +608,44 @@ export async function startTextProviderFixtureServer({
} catch {
parsedBody = {};
}
const messages = Array.isArray(parsedBody.messages)
? parsedBody.messages
: [];
const hasTurnPlanPrompt = messages.some(
(message) =>
message?.role === "user" &&
readChatContentText(message?.content).includes(
TURN_PLAN_UPDATE_PROMPT,
),
);
const hasPlanUpdatedToolResult = messages.some(
(message) =>
message?.role === "tool" &&
readChatContentText(message?.content).includes("Plan updated"),
);
if (
turnPlanUpdate &&
parsedBody.stream !== false &&
hasTurnPlanPrompt &&
!hasPlanUpdatedToolResult
) {
const responseBody = openaiToolCallSseBody({
argumentsValue: {
explanation: TURN_PLAN_UPDATE_EXPLANATION,
plan: TURN_PLAN_UPDATE_STEPS,
},
callId: TURN_PLAN_UPDATE_TOOL_CALL_ID,
name: "update_plan",
});
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "close",
"content-length": Buffer.byteLength(responseBody),
});
response.end(responseBody);
return;
}
const content = fixtureTextForChatRequest(body);
if (parsedBody.stream === false) {
response.writeHead(200, { "content-type": "application/json" });

Some files were not shown because too many files have changed in this diff Show More