mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 07:16:36 +08:00
Release v1.158.0
This commit is contained in:
@@ -60,6 +60,7 @@ internal/exec-plans/*
|
||||
!internal/exec-plans/README.md
|
||||
!internal/exec-plans/rea-codex-desktop-analysis-plan.md
|
||||
!internal/exec-plans/codex-gui-alignment-current-plan.md
|
||||
!internal/exec-plans/codex-gui-alignment-history-20261015.md
|
||||
!internal/exec-plans/codex-desktop-selective-goose-reference-plan.md
|
||||
!internal/exec-plans/codex-desktop-selective-goose-reference-evidence.json
|
||||
!internal/exec-plans/tui-codex-snapshot-inventory.json
|
||||
@@ -272,11 +273,17 @@ internal/research/**
|
||||
!internal/research/codex-desktop-rea/
|
||||
!internal/research/codex-desktop-rea/report.md
|
||||
!internal/research/codex-desktop-rea/evidence-index.json
|
||||
!internal/research/codex-desktop-rea/structure-alignment-ledger.md
|
||||
!internal/research/codex-desktop-rea/navigation-composer-audit.md
|
||||
!internal/research/codex-desktop-rea/queue-steer-audit.md
|
||||
!internal/research/codex-desktop-rea/main-composer-audit.md
|
||||
!internal/research/codex-desktop-rea/composer-draft-persistence-audit.md
|
||||
!internal/research/codex-desktop-rea/new-conversation-navigation-audit.md
|
||||
!internal/research/codex-desktop-rea/settings-navigation-audit.md
|
||||
!internal/research/codex-desktop-rea/marks-voice-audit.md
|
||||
!internal/research/codex-desktop-rea/composer-marks-css-audit.md
|
||||
!internal/research/codex-desktop-rea/composer-code-nodeview-audit.md
|
||||
!internal/research/codex-desktop-rea/composer-code-highlight-audit.md
|
||||
!internal/research/codex-desktop-rea/professional-files-audit.md
|
||||
!internal/research/codex-desktop-rea/user-message-collapse-audit.md
|
||||
!internal/research/agent/
|
||||
|
||||
+15
-20
@@ -1,41 +1,36 @@
|
||||
## Lime v1.157.0
|
||||
## Lime v1.158.0
|
||||
|
||||
Simplified Chinese release notes are the primary version; this English companion summarizes the same release.
|
||||
|
||||
### Features
|
||||
|
||||
- Use one structured composer for skill, character, and capability mentions, preserving image, path, and rich input data through submission and restoration.
|
||||
- Support links and code blocks with Markdown submission and preserved reference ranges; add dictation cancellation and isolate late results after conversation switches or IME composition.
|
||||
- Preview, edit, delete, reorder, and start queued messages while idle; keep pending input separate from the public conversation history.
|
||||
- Show pending approvals in the conversation with actions and status tied to each request.
|
||||
- Reuse conversation changes and diff views in the Files panel, with file selection and navigation to the corresponding change.
|
||||
- Persist composer drafts across restarts, keeping home, workspace, and conversation scopes isolated while restoring rich text and resource references.
|
||||
- Add code block language selection, search, automatic detection, and syntax highlighting while preserving structured editing and Markdown submission.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prevent late notifications and conversation switches from applying queue state, title edits, model settings, or working directories to the wrong conversation.
|
||||
- Correct browser tab and right panel state during restoration, closure, and switching.
|
||||
- Preserve scroll position when expanding, collapsing, or copying long user messages, and stop copy feedback updates after unmount.
|
||||
- Correct header toolbar overflow and message/composer placement in compact windows.
|
||||
- Prevent incorrect draft bindings, stale content restoration, and accidental clearing of new edits during new conversation navigation, first sends, and conversation switches.
|
||||
- Settle input using runtime acceptance: preserve rejected drafts, restore interrupted input, and prevent late receipts from overwriting newer drafts.
|
||||
- Navigate skill trial submissions to the actual conversation and restore the editor to an editable state after sending.
|
||||
|
||||
### Improvements and Refactoring
|
||||
|
||||
- Mount conversation Turns by viewport, retaining independent history pagination, dynamic height measurement, and reading position.
|
||||
- Consolidate project, branch, and run location controls, structured submission, and input restoration around existing owners.
|
||||
- Keep queue, approval, file, and settings behavior on the shared App Server and remove replaced components and duplicate projections.
|
||||
- Consolidate desktop media and clipboard permissions while preserving main window and browser tab boundaries.
|
||||
- Consolidate draft coordination, desktop persistence, and sidebar draft projections; remove replaced draft events, duplicate state, and navigation implementations.
|
||||
- Use one settings section definition for menus, loading, and categorization; return to the conversation that opened settings while keeping Home navigation separate, and share notification lifecycle handling across media settings.
|
||||
- Keep conversation history on the shared App Server and draft persistence on the existing Electron Host.
|
||||
|
||||
### Tests and Quality
|
||||
|
||||
- Extend regressions for queue history, search and cold recovery, approval responses, structured input, scrolling, copy lifecycle, and browser tab restoration.
|
||||
- Update real Electron current fixtures, interaction selectors, and build asset checks, separating source, artifact, and platform evidence.
|
||||
- Update UI text and regression coverage across five languages.
|
||||
- Extend coverage for draft saving, cold recovery, first sends, stop restoration, navigation, and input acceptance.
|
||||
- Strengthen real Electron fixture teardown, build freshness, and resource byte verification.
|
||||
- Update code block and settings text and regressions across five languages.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update GUI design, architecture, quality workflows, Codex Desktop research evidence, and alignment execution records.
|
||||
- Update GUI architecture, draft and code block research, settings navigation audits, and structural alignment records, distinguishing delivered behavior from remaining work.
|
||||
|
||||
### Other
|
||||
|
||||
- Align the desktop app, CLI npm package, and Rust workspace at `1.157.0`.
|
||||
- Align the desktop app, CLI npm package, and Rust workspace at `1.158.0`.
|
||||
|
||||
**Full Changelog**: `v1.156.0` -> `v1.157.0`
|
||||
**Full Changelog**: `v1.157.0` -> `v1.158.0`
|
||||
|
||||
+15
-20
@@ -1,39 +1,34 @@
|
||||
## Lime v1.157.0
|
||||
## Lime v1.158.0
|
||||
|
||||
### 新功能
|
||||
|
||||
- 输入框采用统一结构化编辑器,支持技能、角色和能力引用,并保留图片、路径与富文本输入的提交和恢复信息。
|
||||
- 支持链接和代码块输入,按 Markdown 提交并保留引用位置;听写增加取消操作,隔离会话切换和输入法组字后的迟到结果。
|
||||
- 排队消息支持展开预览、编辑、删除、重排与空闲时启动;待执行输入与公开对话历史分开呈现。
|
||||
- 待处理审批集中展示在消息区,提供对应请求的操作与状态反馈。
|
||||
- Files 面板复用会话变更与 diff 视图,支持选择文件并定位到对应变更。
|
||||
- 输入框草稿持久化保存,支持首页及不同工作区、会话间隔离,并在重启后恢复富文本和资源引用。
|
||||
- 代码块增加语言选择、搜索、自动识别与语法高亮,保留结构化编辑和 Markdown 提交。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复会话切换和迟到通知导致队列、标题编辑、模型与工作目录设置落入错误会话的问题。
|
||||
- 修复浏览器标签与右侧面板恢复、关闭及切换时的状态串扰。
|
||||
- 修复长用户消息展开、折叠与复制时的滚动跳动,以及复制反馈在卸载后继续更新的问题。
|
||||
- 修复紧凑窗口中页头工具栏越界和消息、输入框布局错位。
|
||||
- 修复新建会话、首次发送和会话切换时草稿误绑定、旧内容回流或新编辑被清空的问题。
|
||||
- 按运行时实际接受结果处理输入:提交被拒绝时保留草稿,停止后恢复原输入,迟到回执不覆盖新草稿。
|
||||
- 修复技能试用等入口提交后未切换到实际会话,以及发送后编辑器恢复可编辑状态的问题。
|
||||
|
||||
### 优化与重构
|
||||
|
||||
- 长对话使用按视口挂载和卸载的 Turn 列表,保留独立历史分页、动态高度测量和阅读位置。
|
||||
- 统一输入框项目、分支与运行位置工具栏,收敛结构化提交和输入恢复链路。
|
||||
- GUI 队列、审批、文件与设置继续复用 App Server 和现有业务 owner,移除被替代的旧组件与重复投影。
|
||||
- 收敛桌面媒体与剪贴板权限处理,保持主窗口和浏览器标签的权限边界。
|
||||
- 统一草稿协调、桌面持久化和侧栏草稿投影,移除旧草稿事件、重复状态与被替代的导航实现。
|
||||
- 设置页面使用统一章节定义,收敛菜单、加载和分类逻辑;页头返回恢复进入设置前的会话,首页入口保持独立,并复用媒体设置提示生命周期。
|
||||
- 保持 GUI 与共享 App Server 的业务边界,草稿持久化复用 Electron Host,不建立第二套会话历史。
|
||||
|
||||
### 测试与质量
|
||||
|
||||
- 扩充队列公共历史、搜索与冷恢复、审批响应、结构化输入、列表滚动、复制生命周期及浏览器标签恢复回归。
|
||||
- 更新真实 Electron current fixture、交互定位和构建资源检查,区分源码、产物及平台验证证据。
|
||||
- 同步五语言界面文案与回归覆盖。
|
||||
- 扩充草稿保存、冷恢复、首次发送、停止恢复、导航和输入接受结果的回归覆盖。
|
||||
- 增强真实 Electron fixture 的退出检查、构建新鲜度和资源字节验证。
|
||||
- 同步代码块与设置的五语言文案及回归覆盖。
|
||||
|
||||
### 文档
|
||||
|
||||
- 更新 GUI 设计、架构与质量事实源,以及 Codex Desktop 分析证据和对齐执行账本。
|
||||
- 更新 GUI 架构、草稿与代码块分析、设置导航审计和结构对齐账本,区分已交付能力与后续范围。
|
||||
|
||||
### 其他
|
||||
|
||||
- 桌面应用、CLI npm 包与 Rust workspace 版本统一为 `1.157.0`。
|
||||
- 桌面应用、CLI npm 包与 Rust workspace 版本统一为 `1.158.0`。
|
||||
|
||||
**完整变更**: `v1.156.0` -> `v1.157.0`
|
||||
**完整变更**: `v1.157.0` -> `v1.158.0`
|
||||
|
||||
@@ -13,6 +13,8 @@ export const ELECTRON_HOST_COMMANDS = [
|
||||
"app_server_handle_json_lines",
|
||||
"app_server_drain_events",
|
||||
"app_server_host_diagnostics",
|
||||
"persisted_atom_read",
|
||||
"persisted_atom_update",
|
||||
"cloud_session_credential_set",
|
||||
"cloud_session_credential_status",
|
||||
"cloud_session_credential_delete",
|
||||
|
||||
+14
-4
@@ -4,7 +4,6 @@ import {
|
||||
IPC_DEEP_LINK_GET_URLS_CHANNEL,
|
||||
IPC_DIALOG_OPEN_CHANNEL,
|
||||
IPC_DIALOG_SAVE_CHANNEL,
|
||||
IPC_EMIT_CHANNEL,
|
||||
IPC_GLOBAL_SHORTCUT_COMMAND_CHANNEL,
|
||||
IPC_INVOKE_CHANNEL,
|
||||
IPC_SHELL_OPEN_CHANNEL,
|
||||
@@ -27,6 +26,8 @@ import { ElectronBrowserTabHost, isBrowserTabCommand } from "./browserTabHost";
|
||||
import { resolveBrowserTabOwnerWindow } from "./browserTabSnapshot";
|
||||
import { ElectronDevHttpBridge } from "./devHttpBridge";
|
||||
import { ElectronHostCommands } from "./hostCommands";
|
||||
import { PersistedAtomHost } from "./persistedAtomHost";
|
||||
import { registerRendererEventHandler } from "./rendererEventPolicy";
|
||||
import { ElectronSecureCredentialStore } from "./secureCredentialStore";
|
||||
import {
|
||||
buildMainWindowChromeOptions,
|
||||
@@ -152,6 +153,10 @@ const hostCommands = new ElectronHostCommands(
|
||||
safeStorage,
|
||||
),
|
||||
);
|
||||
const persistedAtomHost = new PersistedAtomHost(
|
||||
desktopStorageRoots.appDataRoot,
|
||||
broadcast,
|
||||
);
|
||||
const updateHost = new ElectronUpdateHost(broadcast, {
|
||||
open: openUpdateNotificationWindow,
|
||||
close: closeUpdateNotificationWindow,
|
||||
@@ -1064,9 +1069,7 @@ function registerIpcHandlers(): void {
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on(IPC_EMIT_CHANNEL, (_event, event: string, payload?: unknown) => {
|
||||
broadcast(event, payload);
|
||||
});
|
||||
registerRendererEventHandler(ipcMain, broadcast);
|
||||
|
||||
ipcMain.handle(IPC_DIALOG_OPEN_CHANNEL, async (_event, options) => {
|
||||
const normalizedOptions = normalizeOpenDialogOptions(options);
|
||||
@@ -1142,6 +1145,12 @@ async function handleHostInvoke(
|
||||
: {},
|
||||
);
|
||||
}
|
||||
if (command === "persisted_atom_read") {
|
||||
return persistedAtomHost.sendPersistedAtomState(args);
|
||||
}
|
||||
if (command === "persisted_atom_update") {
|
||||
return persistedAtomHost.updatePersistedAtomState(args);
|
||||
}
|
||||
if (command === "sync_tray_model_shortcuts") {
|
||||
syncTrayModelShortcuts(args);
|
||||
return null;
|
||||
@@ -1543,6 +1552,7 @@ app.on("before-quit", () => {
|
||||
devHttpBridge?.stop();
|
||||
devHttpBridge = null;
|
||||
hostCommands.dispose();
|
||||
persistedAtomHost.dispose();
|
||||
void appServerHost.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import process from "node:process";
|
||||
import {
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PersistedAtomEnvelope } from "../src/lib/api/persistedAtomTypes";
|
||||
import {
|
||||
PersistedAtomFile,
|
||||
persistedAtomFileIO,
|
||||
type PersistedAtomFileIO,
|
||||
} from "./persistedAtomFile";
|
||||
import { scopeKey } from "./persistedAtomRecords";
|
||||
|
||||
const roots: string[] = [];
|
||||
const limits = {
|
||||
maxDraftBytes: 2_048,
|
||||
maxFileBytes: 8_192,
|
||||
maxEntries: 10,
|
||||
maxBindings: 10,
|
||||
};
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "atom-file-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
function data(text = "original", revision = 1): PersistedAtomEnvelope {
|
||||
const originScopeKey = scopeKey({
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
});
|
||||
return {
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: revision,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
bindings: { [originScopeKey]: "d" },
|
||||
entries: {
|
||||
d: {
|
||||
draftId: "d",
|
||||
scopeVersion: `v${revision}`,
|
||||
originScopeKey,
|
||||
updatedAt: revision,
|
||||
state: "draft",
|
||||
draft: { text },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
function failure(code: string): Error {
|
||||
return Object.assign(
|
||||
new Error("secret user content and attachment data must never escape"),
|
||||
{ code },
|
||||
);
|
||||
}
|
||||
|
||||
describe("persisted atom fixed-file atomic IO", () => {
|
||||
it("uses fixed names, private file mode, valid backup and no retained temporary file", async () => {
|
||||
const root = await createRoot(),
|
||||
file = new PersistedAtomFile(root, limits);
|
||||
await file.save(data());
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")),
|
||||
).toEqual(data());
|
||||
expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe(
|
||||
await readFile(path.join(root, "host-state.json"), "utf8"),
|
||||
);
|
||||
expect((await file.load()).envelope).toEqual(data());
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
"host-state.json",
|
||||
"host-state.json.bak",
|
||||
]);
|
||||
if (process.platform !== "win32")
|
||||
expect(
|
||||
(await stat(path.join(root, "host-state.json"))).mode & 0o777,
|
||||
).toBe(0o600);
|
||||
});
|
||||
|
||||
it.each(["corrupt", "missing"])(
|
||||
"recovers backup when primary is %s",
|
||||
async (mode) => {
|
||||
const root = await createRoot(),
|
||||
file = new PersistedAtomFile(root, limits);
|
||||
await file.save(data());
|
||||
if (mode === "corrupt")
|
||||
await writeFile(path.join(root, "host-state.json"), "{broken");
|
||||
else await rm(path.join(root, "host-state.json"));
|
||||
expect(await file.load()).toEqual({ envelope: data(), recovered: true });
|
||||
},
|
||||
);
|
||||
|
||||
it("treats only two missing files as an empty initial profile", async () => {
|
||||
const root = await createRoot(),
|
||||
file = new PersistedAtomFile(root, limits);
|
||||
expect(await file.load()).toEqual({ envelope: null, recovered: false });
|
||||
await writeFile(path.join(root, "host-state.json"), "bad");
|
||||
await expect(file.load()).rejects.toMatchObject({
|
||||
diagnostic: { phase: "load", code: "INVALID_STATE" },
|
||||
});
|
||||
expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe(
|
||||
"bad",
|
||||
);
|
||||
expect(await readdir(root)).toEqual(["host-state.json"]);
|
||||
});
|
||||
|
||||
it("both corrupt files remain unchanged after a rejected load", async () => {
|
||||
const root = await createRoot();
|
||||
await writeFile(path.join(root, "host-state.json"), "bad-main");
|
||||
await writeFile(path.join(root, "host-state.json.bak"), "bad-backup");
|
||||
await expect(
|
||||
new PersistedAtomFile(root, limits).load(),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: { phase: "load", code: "INVALID_STATE" },
|
||||
});
|
||||
expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe(
|
||||
"bad-main",
|
||||
);
|
||||
expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe(
|
||||
"bad-backup",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["write", "rename"])(
|
||||
"preserves old primary/backup on %s failure and cleans only the new temporary",
|
||||
async (stage) => {
|
||||
const root = await createRoot();
|
||||
await new PersistedAtomFile(root, limits).save(data());
|
||||
const removeTemporary = vi.fn(persistedAtomFileIO.removeTemporary);
|
||||
const io: PersistedAtomFileIO = {
|
||||
...persistedAtomFileIO,
|
||||
removeTemporary,
|
||||
...(stage === "write"
|
||||
? {
|
||||
writeFile: async () => {
|
||||
throw failure("ENOSPC");
|
||||
},
|
||||
}
|
||||
: {
|
||||
rename: async () => {
|
||||
throw failure("EIO");
|
||||
},
|
||||
}),
|
||||
};
|
||||
await expect(
|
||||
new PersistedAtomFile(root, limits, { io }).save(data("new", 2)),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: {
|
||||
phase: "primary",
|
||||
code: stage === "write" ? "ENOSPC" : "EIO",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")),
|
||||
).toEqual(data());
|
||||
expect(
|
||||
JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json.bak"), "utf8"),
|
||||
),
|
||||
).toEqual(data());
|
||||
expect(removeTemporary).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
removeTemporary.mock.calls.every(([file]) => file.endsWith(".tmp")),
|
||||
).toBe(true);
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
"host-state.json",
|
||||
"host-state.json.bak",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"retries Windows %s without deleting the destination",
|
||||
async (code) => {
|
||||
const root = await createRoot();
|
||||
await new PersistedAtomFile(root, limits).save(data());
|
||||
let attempts = 0;
|
||||
const rename = vi.fn(async (from: string, to: string) => {
|
||||
attempts += 1;
|
||||
if (attempts <= 3) throw failure(code);
|
||||
await persistedAtomFileIO.rename(from, to);
|
||||
});
|
||||
const removeTemporary = vi.fn(persistedAtomFileIO.removeTemporary),
|
||||
wait = vi.fn(async () => undefined);
|
||||
await new PersistedAtomFile(root, limits, {
|
||||
platform: "win32",
|
||||
wait,
|
||||
io: { ...persistedAtomFileIO, rename, removeTemporary },
|
||||
}).save(data("new", 2));
|
||||
expect(wait.mock.calls).toEqual([[100], [200], [400]]);
|
||||
expect(rename).toHaveBeenCalledTimes(5);
|
||||
expect(removeTemporary).not.toHaveBeenCalled();
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")),
|
||||
).toEqual(data("new", 2));
|
||||
},
|
||||
);
|
||||
|
||||
it("limits Windows retries and leaves the old durable snapshot intact", async () => {
|
||||
const root = await createRoot();
|
||||
await new PersistedAtomFile(root, limits).save(data());
|
||||
const rename = vi.fn(async () => {
|
||||
throw failure("EPERM");
|
||||
}),
|
||||
wait = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
new PersistedAtomFile(root, limits, {
|
||||
platform: "win32",
|
||||
wait,
|
||||
io: { ...persistedAtomFileIO, rename },
|
||||
}).save(data("new", 2)),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: { phase: "primary", code: "EPERM" },
|
||||
});
|
||||
expect(rename).toHaveBeenCalledTimes(4);
|
||||
expect(wait).toHaveBeenCalledTimes(3);
|
||||
expect((await new PersistedAtomFile(root, limits).load()).envelope).toEqual(
|
||||
data(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: "darwin" as const, code: "EPERM" },
|
||||
{ platform: "win32" as const, code: "EEXIST" },
|
||||
])(
|
||||
"does not retry an unsupported replacement error",
|
||||
async ({ platform, code }) => {
|
||||
const root = await createRoot(),
|
||||
rename = vi.fn(async () => {
|
||||
throw failure(code);
|
||||
}),
|
||||
wait = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
new PersistedAtomFile(root, limits, {
|
||||
platform,
|
||||
wait,
|
||||
io: { ...persistedAtomFileIO, rename },
|
||||
}).save(data()),
|
||||
).rejects.toMatchObject({ diagnostic: { code } });
|
||||
expect(rename).toHaveBeenCalledTimes(1);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("backup replacement failure preserves old backup and reports partial persistence", async () => {
|
||||
const root = await createRoot();
|
||||
await new PersistedAtomFile(root, limits).save(data());
|
||||
const io = {
|
||||
...persistedAtomFileIO,
|
||||
rename: async (from: string, to: string) => {
|
||||
if (to.endsWith(".bak")) throw failure("EIO");
|
||||
await persistedAtomFileIO.rename(from, to);
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
new PersistedAtomFile(root, limits, { io }).save(data("new", 2)),
|
||||
).rejects.toMatchObject({ diagnostic: { phase: "backup", code: "EIO" } });
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")),
|
||||
).toEqual(data("new", 2));
|
||||
expect(
|
||||
JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json.bak"), "utf8"),
|
||||
),
|
||||
).toEqual(data());
|
||||
});
|
||||
|
||||
it("rejects oversize UTF8 file without truncation or any write", async () => {
|
||||
const root = await createRoot();
|
||||
const bytes = Buffer.byteLength(JSON.stringify(data("中文")), "utf8");
|
||||
const exact = new PersistedAtomFile(root, {
|
||||
...limits,
|
||||
maxFileBytes: bytes,
|
||||
});
|
||||
await exact.save(data("中文"));
|
||||
await expect(
|
||||
new PersistedAtomFile(root, { ...limits, maxFileBytes: bytes - 1 }).save(
|
||||
data("中文"),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: { phase: "serialize", code: "CAPACITY_EXCEEDED" },
|
||||
});
|
||||
await expect(
|
||||
new PersistedAtomFile(root, {
|
||||
...limits,
|
||||
maxFileBytes: bytes - 1,
|
||||
}).load(),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: { phase: "load", code: "CAPACITY_EXCEEDED" },
|
||||
});
|
||||
expect((await exact.load()).envelope).toEqual(data("中文"));
|
||||
});
|
||||
|
||||
it("filters unknown IO codes and never exposes exception messages", async () => {
|
||||
const root = await createRoot();
|
||||
const file = new PersistedAtomFile(root, limits, {
|
||||
io: {
|
||||
...persistedAtomFileIO,
|
||||
readFile: async () => {
|
||||
throw failure("private payload");
|
||||
},
|
||||
},
|
||||
});
|
||||
const error = await file.load().catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({
|
||||
message: "persisted atom file operation failed",
|
||||
diagnostic: { phase: "load", code: "IO_ERROR" },
|
||||
});
|
||||
expect(JSON.stringify(error)).not.toContain("private payload");
|
||||
expect(JSON.stringify(error)).not.toContain("secret user content");
|
||||
});
|
||||
|
||||
it("bounds native file reads and accepts exactly the declared byte budget", async () => {
|
||||
const root = await createRoot(),
|
||||
file = path.join(root, "large.json");
|
||||
const bytes = Buffer.alloc(1_024, 0x61);
|
||||
await writeFile(file, bytes);
|
||||
await expect(persistedAtomFileIO.readFile(file, 1_024)).resolves.toEqual(
|
||||
bytes,
|
||||
);
|
||||
await expect(
|
||||
persistedAtomFileIO.readFile(file, 1_023),
|
||||
).rejects.toMatchObject({
|
||||
diagnostic: { phase: "load", code: "CAPACITY_EXCEEDED" },
|
||||
});
|
||||
expect(await readFile(file)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("rejects relative/empty roots rather than choosing an arbitrary transport path", () => {
|
||||
expect(() => new PersistedAtomFile("relative", limits)).toThrow(
|
||||
"resolved AppDataRoot",
|
||||
);
|
||||
expect(() => new PersistedAtomFile(" ", limits)).toThrow(
|
||||
"resolved AppDataRoot",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import process from "node:process";
|
||||
import { TextDecoder } from "node:util";
|
||||
import * as fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
PersistedAtomDiagnostic,
|
||||
PersistedAtomEnvelope,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import { parseEnvelope } from "./persistedAtomRecords";
|
||||
import type { PersistedAtomLimits } from "../src/lib/api/persistedAtomProtocol";
|
||||
|
||||
export interface PersistedAtomFileIO {
|
||||
readFile(file: string, maxBytes: number): Promise<Buffer>;
|
||||
mkdir(directory: string): Promise<void>;
|
||||
writeFile(file: string, data: string): Promise<void>;
|
||||
rename(from: string, to: string): Promise<void>;
|
||||
removeTemporary(file: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const persistedAtomFileIO: PersistedAtomFileIO = {
|
||||
readFile: async (file, maxBytes) => {
|
||||
const handle = await fs.open(file, "r");
|
||||
try {
|
||||
if ((await handle.stat()).size > maxBytes)
|
||||
throw new PersistedAtomFileError("load", "CAPACITY_EXCEEDED");
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const chunk = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, maxBytes - total + 1),
|
||||
);
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
||||
if (!bytesRead) break;
|
||||
total += bytesRead;
|
||||
if (total > maxBytes)
|
||||
throw new PersistedAtomFileError("load", "CAPACITY_EXCEEDED");
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
mkdir: async (directory) => {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
},
|
||||
writeFile: (file, data) =>
|
||||
fs.writeFile(file, data, { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
||||
rename: (from, to) => fs.rename(from, to),
|
||||
removeTemporary: (file) => fs.rm(file, { force: true }),
|
||||
};
|
||||
|
||||
const SAFE_IO_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"EBUSY",
|
||||
"ENOSPC",
|
||||
"EIO",
|
||||
"EEXIST",
|
||||
"ETIMEDOUT",
|
||||
]);
|
||||
|
||||
export class PersistedAtomFileError extends Error {
|
||||
readonly diagnostic: PersistedAtomDiagnostic;
|
||||
constructor(phase: PersistedAtomDiagnostic["phase"], code: string) {
|
||||
super("persisted atom file operation failed");
|
||||
this.diagnostic = { phase, code };
|
||||
}
|
||||
}
|
||||
|
||||
function ioCode(error: unknown): string {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? error.code
|
||||
: undefined;
|
||||
return typeof code === "string" && SAFE_IO_CODES.has(code)
|
||||
? code
|
||||
: "IO_ERROR";
|
||||
}
|
||||
|
||||
export interface PersistedAtomFileOptions {
|
||||
readonly io?: PersistedAtomFileIO;
|
||||
readonly platform?: typeof process.platform;
|
||||
readonly wait?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
type FileRead =
|
||||
| { state: "valid"; envelope: PersistedAtomEnvelope }
|
||||
| { state: "missing" }
|
||||
| { state: "invalid"; code: string };
|
||||
|
||||
export class PersistedAtomFile {
|
||||
readonly #root: string;
|
||||
readonly #io: PersistedAtomFileIO;
|
||||
readonly #platform: typeof process.platform;
|
||||
readonly #wait: (milliseconds: number) => Promise<void>;
|
||||
readonly #limits: PersistedAtomLimits;
|
||||
|
||||
constructor(
|
||||
appDataRoot: string,
|
||||
limits: PersistedAtomLimits,
|
||||
options: PersistedAtomFileOptions = {},
|
||||
) {
|
||||
if (!appDataRoot.trim() || !path.isAbsolute(appDataRoot))
|
||||
throw new Error("persisted atom requires the resolved AppDataRoot");
|
||||
this.#root = path.resolve(appDataRoot);
|
||||
this.#io = options.io ?? persistedAtomFileIO;
|
||||
this.#platform = options.platform ?? process.platform;
|
||||
this.#wait =
|
||||
options.wait ??
|
||||
((milliseconds) =>
|
||||
new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
||||
this.#limits = limits;
|
||||
}
|
||||
|
||||
async load(): Promise<{
|
||||
envelope: PersistedAtomEnvelope | null;
|
||||
recovered: boolean;
|
||||
}> {
|
||||
const primary = await this.#read(path.join(this.#root, "host-state.json"));
|
||||
if (primary.state === "valid")
|
||||
return { envelope: primary.envelope, recovered: false };
|
||||
const backup = await this.#read(
|
||||
path.join(this.#root, "host-state.json.bak"),
|
||||
);
|
||||
if (backup.state === "valid")
|
||||
return { envelope: backup.envelope, recovered: true };
|
||||
if (primary.state === "missing" && backup.state === "missing")
|
||||
return { envelope: null, recovered: false };
|
||||
throw new PersistedAtomFileError(
|
||||
"load",
|
||||
primary.state === "invalid"
|
||||
? primary.code
|
||||
: backup.state === "invalid"
|
||||
? backup.code
|
||||
: "INVALID_STATE",
|
||||
);
|
||||
}
|
||||
|
||||
async save(envelope: PersistedAtomEnvelope): Promise<void> {
|
||||
let data: string;
|
||||
try {
|
||||
data = JSON.stringify(envelope);
|
||||
if (Buffer.byteLength(data, "utf8") > this.#limits.maxFileBytes)
|
||||
throw new PersistedAtomFileError("serialize", "CAPACITY_EXCEEDED");
|
||||
} catch (error) {
|
||||
if (error instanceof PersistedAtomFileError) throw error;
|
||||
throw new PersistedAtomFileError("serialize", "INVALID_JSON");
|
||||
}
|
||||
try {
|
||||
await this.#io.mkdir(this.#root);
|
||||
} catch (error) {
|
||||
throw new PersistedAtomFileError("primary", ioCode(error));
|
||||
}
|
||||
await this.#replace(
|
||||
path.join(this.#root, "host-state.json"),
|
||||
data,
|
||||
"primary",
|
||||
);
|
||||
await this.#replace(
|
||||
path.join(this.#root, "host-state.json.bak"),
|
||||
data,
|
||||
"backup",
|
||||
);
|
||||
}
|
||||
|
||||
async #read(file: string): Promise<FileRead> {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await this.#io.readFile(file, this.#limits.maxFileBytes);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof PersistedAtomFileError &&
|
||||
error.diagnostic.code === "CAPACITY_EXCEEDED"
|
||||
? "CAPACITY_EXCEEDED"
|
||||
: ioCode(error);
|
||||
return code === "ENOENT"
|
||||
? { state: "missing" }
|
||||
: { state: "invalid", code };
|
||||
}
|
||||
if (buffer.byteLength > this.#limits.maxFileBytes)
|
||||
return { state: "invalid", code: "CAPACITY_EXCEEDED" };
|
||||
try {
|
||||
return {
|
||||
state: "valid",
|
||||
envelope: parseEnvelope(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)),
|
||||
this.#limits,
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return { state: "invalid", code: "INVALID_STATE" };
|
||||
}
|
||||
}
|
||||
|
||||
async #replace(
|
||||
file: string,
|
||||
data: string,
|
||||
phase: "primary" | "backup",
|
||||
): Promise<void> {
|
||||
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let renamed = false;
|
||||
try {
|
||||
await this.#io.writeFile(temporary, data);
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await this.#io.rename(temporary, file);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
this.#platform !== "win32" ||
|
||||
!["EPERM", "EACCES", "EBUSY"].includes(ioCode(error)) ||
|
||||
attempt >= 3
|
||||
)
|
||||
throw error;
|
||||
await this.#wait(100 * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
renamed = true;
|
||||
} catch (error) {
|
||||
throw new PersistedAtomFileError(phase, ioCode(error));
|
||||
} finally {
|
||||
if (!renamed)
|
||||
await this.#io.removeTemporary(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PersistedAtomUpdateEvent } from "../src/lib/api/persistedAtomTypes";
|
||||
import { PersistedAtomHost } from "./persistedAtomHost";
|
||||
|
||||
const roots: string[] = [],
|
||||
hosts: { host: PersistedAtomHost; root: string }[] = [];
|
||||
const scope = {
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
} as const;
|
||||
const request = {
|
||||
key: "composer-drafts-v1",
|
||||
mutation: {
|
||||
op: "put",
|
||||
scope,
|
||||
draftId: "d1",
|
||||
expectedVersion: null,
|
||||
nextVersion: "v1",
|
||||
draft: {
|
||||
text: "fixture private content",
|
||||
attachments: [{ data: "c2VjcmV0", mediaType: "image/png" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
async function createHost() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "atom-host-"));
|
||||
roots.push(root);
|
||||
const events: { name: string; payload: unknown }[] = [];
|
||||
const host = new PersistedAtomHost(root, (name, payload) =>
|
||||
events.push({ name, payload }),
|
||||
);
|
||||
hosts.push({ host, root });
|
||||
await host.ready;
|
||||
return { root, host, events };
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const { host, root } of hosts.splice(0)) {
|
||||
const revision = host.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: scope },
|
||||
}).storeRevision;
|
||||
host.dispose();
|
||||
if (revision > 0)
|
||||
await vi.waitFor(async () => {
|
||||
const backup = JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json.bak"), "utf8"),
|
||||
);
|
||||
expect(backup.storeRevision).toBeGreaterThanOrEqual(revision);
|
||||
});
|
||||
}
|
||||
// Disposal is intentionally not an acknowledged Quit protocol; ensure test IO settled before cleanup.
|
||||
await Promise.all(
|
||||
roots.splice(0).map(async (root) => {
|
||||
await vi.waitFor(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("persisted atom isolated Host command boundary", () => {
|
||||
it("accepts only the known atom and reports complete scope projection", async () => {
|
||||
const { host } = await createHost();
|
||||
expect(host.updatePersistedAtomState(request)).toMatchObject({
|
||||
outcome: "applied",
|
||||
storeRevision: 1,
|
||||
});
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: scope },
|
||||
}),
|
||||
).toMatchObject({
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: {
|
||||
scope,
|
||||
entry: {
|
||||
draftId: "d1",
|
||||
scopeVersion: "v1",
|
||||
draft: request.mutation.draft,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: "other-atom", mutation: request.mutation },
|
||||
{ ...request, path: "/arbitrary/host-state.json" },
|
||||
{ ...request, budget: Number.MAX_SAFE_INTEGER },
|
||||
{ key: "composer-drafts-v1", mutation: { op: "replace", value: {} } },
|
||||
{ mutation: request.mutation },
|
||||
])(
|
||||
"refuses unknown keys, paths, caller budgets and full replacements",
|
||||
async (input) => {
|
||||
const { host, events } = await createHost();
|
||||
const count = events.length;
|
||||
expect(host.updatePersistedAtomState(input)).toMatchObject({
|
||||
outcome: "invalid",
|
||||
changed: false,
|
||||
storeRevision: 0,
|
||||
});
|
||||
expect(events).toHaveLength(count);
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: scope },
|
||||
}),
|
||||
).toMatchObject({
|
||||
data: { queryKind: "scope", view: { entry: null } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("validates read fields and scope with explicit errors", async () => {
|
||||
const { host } = await createHost();
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: "other",
|
||||
query: { kind: "scope", scope: scope },
|
||||
}),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
});
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: { ...scope, kind: "cloud" } },
|
||||
}),
|
||||
).toMatchObject({ outcome: "invalid", reason: "invalid_request" });
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: scope },
|
||||
path: "x",
|
||||
}),
|
||||
).toMatchObject({ outcome: "invalid", data: null });
|
||||
});
|
||||
|
||||
it("serves workspace index through the same read command without another owner or event", async () => {
|
||||
const { host, events } = await createHost();
|
||||
host.updatePersistedAtomState(request);
|
||||
const count = events.length;
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: request.key,
|
||||
query: { kind: "workspace-index", workspaceId: null },
|
||||
}),
|
||||
).toMatchObject({
|
||||
outcome: "ready",
|
||||
storeRevision: 1,
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId: null,
|
||||
items: [{ scope, identity: { draftId: "d1", scopeVersion: "v1" } }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
host.sendPersistedAtomState({
|
||||
key: request.key,
|
||||
query: { kind: "workspace-index", workspaceId: "other" },
|
||||
}),
|
||||
).toMatchObject({ data: { items: [] } });
|
||||
expect(events).toHaveLength(count);
|
||||
});
|
||||
|
||||
it("rejects removed scope-only requests and nested query getters without invoking them", async () => {
|
||||
const { host } = await createHost();
|
||||
expect(
|
||||
host.sendPersistedAtomState({ key: request.key, scope }),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
data: null,
|
||||
});
|
||||
let called = false;
|
||||
const query = Object.defineProperty(
|
||||
{ kind: "workspace-index" },
|
||||
"workspaceId",
|
||||
{
|
||||
enumerable: true,
|
||||
get() {
|
||||
called = true;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
host.sendPersistedAtomState({ key: request.key, query }),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
data: null,
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("emits safe revision/persistence notifications without content or runtime fields", async () => {
|
||||
const { host, events } = await createHost();
|
||||
host.updatePersistedAtomState(request);
|
||||
const event = events.at(-1);
|
||||
expect(event?.name).toBe("persisted-atom-update");
|
||||
expect(event?.payload).toMatchObject({
|
||||
key: "composer-drafts-v1",
|
||||
event: "persisted-atom-update",
|
||||
storeRevision: 1,
|
||||
persistence: { state: "pending", savedRevision: 0 },
|
||||
} satisfies Partial<PersistedAtomUpdateEvent>);
|
||||
expect(JSON.stringify(events)).not.toContain("fixture private content");
|
||||
expect(JSON.stringify(events)).not.toContain("c2VjcmV0");
|
||||
expect(event?.payload).not.toHaveProperty("threadId");
|
||||
expect(event?.payload).not.toHaveProperty("entry");
|
||||
});
|
||||
|
||||
it("normal lifecycle disposal initiates best-effort save without a promise or renderer ACK", async () => {
|
||||
const { host, root, events } = await createHost();
|
||||
host.updatePersistedAtomState(request);
|
||||
const count = events.length;
|
||||
expect(host.dispose()).toBeUndefined();
|
||||
expect(host.updatePersistedAtomState(request)).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
reason: "closed",
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const primary = JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json"), "utf8"),
|
||||
);
|
||||
const backup = JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json.bak"), "utf8"),
|
||||
);
|
||||
expect(primary.storeRevision).toBe(1);
|
||||
expect(backup).toEqual(primary);
|
||||
});
|
||||
expect(events).toHaveLength(count);
|
||||
const restored = new PersistedAtomHost(root);
|
||||
hosts.push({ host: restored, root });
|
||||
await restored.ready;
|
||||
expect(
|
||||
restored.sendPersistedAtomState({
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "scope", scope: scope },
|
||||
}),
|
||||
).toMatchObject({
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: { entry: { draft: request.mutation.draft } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { parsePersistedAtomReadRequest } from "../src/lib/api/persistedAtomProtocol";
|
||||
import type {
|
||||
PersistedAtomMetadata,
|
||||
PersistedAtomReadResult,
|
||||
PersistedAtomUpdateEvent,
|
||||
PersistedAtomUpdateResult,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import { COMPOSER_ATOM_KEY, exactRecord } from "./persistedAtomRecords";
|
||||
import { PersistedAtomStore } from "./persistedAtomStore";
|
||||
|
||||
type HostEventEmitter = (event: string, payload?: unknown) => void;
|
||||
|
||||
/** Host UI persistence only. It owns no runtime, session or send readiness. */
|
||||
export class PersistedAtomHost {
|
||||
readonly #store: PersistedAtomStore;
|
||||
readonly #unsubscribe: () => void;
|
||||
readonly #emit: HostEventEmitter;
|
||||
readonly ready: Promise<void>;
|
||||
|
||||
constructor(appDataRoot: string, emit: HostEventEmitter = () => undefined) {
|
||||
this.#store = new PersistedAtomStore(appDataRoot);
|
||||
this.#emit = emit;
|
||||
this.ready = this.#store.ready;
|
||||
this.#unsubscribe = this.#store.subscribe((metadata) =>
|
||||
this.#broadcastPersistedAtomUpdate(metadata),
|
||||
);
|
||||
}
|
||||
|
||||
sendPersistedAtomState(args: unknown): PersistedAtomReadResult {
|
||||
try {
|
||||
const request = parsePersistedAtomReadRequest(args);
|
||||
return this.#store.sendPersistedAtomState(request.query);
|
||||
} catch {
|
||||
return {
|
||||
...this.#store.metadata(),
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updatePersistedAtomState(args: unknown): PersistedAtomUpdateResult {
|
||||
try {
|
||||
const request = exactRecord(args, ["key", "mutation"]);
|
||||
if (request.key !== COMPOSER_ATOM_KEY)
|
||||
throw new Error("invalid atom key");
|
||||
return this.#store.updatePersistedAtomState(request.mutation);
|
||||
} catch {
|
||||
return {
|
||||
...this.#store.metadata(),
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
changed: false,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#unsubscribe();
|
||||
this.#store.dispose();
|
||||
}
|
||||
|
||||
#broadcastPersistedAtomUpdate(metadata: PersistedAtomMetadata): void {
|
||||
const event: PersistedAtomUpdateEvent = {
|
||||
...metadata,
|
||||
event: "persisted-atom-update",
|
||||
};
|
||||
this.#emit(event.event, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
PERSISTED_ATOM_LIMITS,
|
||||
parsePersistedAtomReadResult,
|
||||
} from "../src/lib/api/persistedAtomProtocol";
|
||||
import type {
|
||||
DraftScope,
|
||||
PersistedAtomEnvelope,
|
||||
PersistedDraftEntry,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import { projectWorkspaceDraftIndex, scopeKey } from "./persistedAtomRecords";
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
entry,
|
||||
home,
|
||||
identity,
|
||||
local,
|
||||
put,
|
||||
} from "./persistedAtomStore.test-support";
|
||||
import { PersistedAtomStore } from "./persistedAtomStore";
|
||||
|
||||
afterEach(cleanup);
|
||||
const scope = (id: string, workspaceId: string | null = "w"): DraftScope => ({
|
||||
workspaceId,
|
||||
kind: "draft",
|
||||
id,
|
||||
});
|
||||
function index(store: PersistedAtomStore, workspaceId: string | null = "w") {
|
||||
const query = { kind: "workspace-index", workspaceId } as const;
|
||||
const result = parsePersistedAtomReadResult(
|
||||
store.sendPersistedAtomState(query),
|
||||
{
|
||||
key: "composer-drafts-v1",
|
||||
query,
|
||||
},
|
||||
);
|
||||
if (result.outcome !== "ready" || result.data.queryKind !== "workspace-index")
|
||||
throw new Error("expected ready workspace index");
|
||||
return result;
|
||||
}
|
||||
function items(store: PersistedAtomStore, workspaceId: string | null = "w") {
|
||||
const result = index(store, workspaceId);
|
||||
if (result.data.queryKind !== "workspace-index")
|
||||
throw new Error("wrong query");
|
||||
return result.data.items;
|
||||
}
|
||||
|
||||
describe("same-owner workspace draft index", () => {
|
||||
it("isolates exact workspaces including null and returns empty metadata discovery", async () => {
|
||||
const { store } = await createStore();
|
||||
const scopes = [
|
||||
home,
|
||||
scope("same"),
|
||||
scope("same", "other"),
|
||||
scope("same", null),
|
||||
local,
|
||||
];
|
||||
for (const [i, value] of scopes.entries())
|
||||
expect(store.updatePersistedAtomState(put(value, `d${i}`)).outcome).toBe(
|
||||
"applied",
|
||||
);
|
||||
expect(items(store).map((item) => item.scope)).toEqual([
|
||||
scope("same"),
|
||||
local,
|
||||
]);
|
||||
expect(items(store, null).map((item) => item.scope)).toEqual([
|
||||
scope("same", null),
|
||||
home,
|
||||
]);
|
||||
expect(items(store, "other").map((item) => item.scope)).toEqual([
|
||||
scope("same", "other"),
|
||||
]);
|
||||
expect(items(store, "missing")).toEqual([]);
|
||||
expect(
|
||||
store.sendPersistedAtomState({ kind: "scope", scope: scope("missing") }),
|
||||
).toMatchObject({
|
||||
outcome: "ready",
|
||||
data: { queryKind: "scope", view: { entry: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current promoted binding and removes consumed records without exposing payload", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState({
|
||||
op: "promote",
|
||||
source: home,
|
||||
target: local,
|
||||
identity,
|
||||
expectedTarget: null,
|
||||
sessionId: local.id,
|
||||
threadId: "canonical-thread",
|
||||
});
|
||||
expect(items(store, null)).toEqual([]);
|
||||
expect(items(store)).toEqual([
|
||||
{
|
||||
scope: local,
|
||||
identity,
|
||||
updatedAt: 1_000,
|
||||
sessionId: local.id,
|
||||
threadId: "canonical-thread",
|
||||
},
|
||||
]);
|
||||
expect(entry(store, local)?.originScopeKey).toBe(scopeKey(home));
|
||||
expect(JSON.stringify(items(store))).not.toMatch(
|
||||
/rich 中文|attachments|mediaType|editorDocument|inputCapabilityRoute|originScopeKey/,
|
||||
);
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "consume", identity }).outcome,
|
||||
).toBe("applied");
|
||||
expect(items(store)).toEqual([]);
|
||||
expect(items(store, null)).toEqual([]);
|
||||
expect(store.updatePersistedAtomState(put()).reason).toBe("retired_draft");
|
||||
});
|
||||
|
||||
it("preserves the single binding invariant across rejected aliases and repeated promotions", async () => {
|
||||
const { store } = await createStore();
|
||||
const a = scope("a"),
|
||||
b = scope("b"),
|
||||
c = scope("c");
|
||||
store.updatePersistedAtomState(put(a));
|
||||
expect(store.updatePersistedAtomState(put(b)).outcome).toBe("conflict");
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(b, "d1", "v2", "v1")).outcome,
|
||||
).toBe("conflict");
|
||||
const promote = (source: DraftScope, target: DraftScope) =>
|
||||
store.updatePersistedAtomState({
|
||||
op: "promote",
|
||||
source,
|
||||
target,
|
||||
identity,
|
||||
expectedTarget: null,
|
||||
});
|
||||
expect(promote(a, b).outcome).toBe("applied");
|
||||
expect(promote(a, b).changed).toBe(false);
|
||||
expect(promote(b, c).outcome).toBe("applied");
|
||||
expect(items(store).map((item) => item.scope)).toEqual([c]);
|
||||
expect(entry(store, a)).toBeNull();
|
||||
expect(entry(store, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("orders by newest time then canonical scope key without environment-sensitive collation", async () => {
|
||||
let time = 10;
|
||||
const { store } = await createStore({ now: () => time });
|
||||
for (const id of ["z", "ä", "Z", "a", "😀", "\ue000"])
|
||||
store.updatePersistedAtomState(put(scope(id), `d-${id}`));
|
||||
time = 11;
|
||||
store.updatePersistedAtomState(put(scope("newest"), "d-newest"));
|
||||
expect(items(store).map((item) => item.scope.id)).toEqual([
|
||||
"newest",
|
||||
"Z",
|
||||
"a",
|
||||
"z",
|
||||
"ä",
|
||||
"\ue000",
|
||||
"😀",
|
||||
]);
|
||||
expect(items(store)).toEqual(items(store));
|
||||
});
|
||||
|
||||
it("does not read/write disk, broadcast, change metadata or update timestamps while reading", async () => {
|
||||
const now = vi.fn(() => 1_000);
|
||||
const calls: string[] = [];
|
||||
const { store } = await createStore({
|
||||
now,
|
||||
io: {
|
||||
readFile: async () => {
|
||||
calls.push("read");
|
||||
throw { code: "ENOENT" };
|
||||
},
|
||||
mkdir: async () => {
|
||||
calls.push("mkdir");
|
||||
},
|
||||
writeFile: async () => {
|
||||
calls.push("write");
|
||||
},
|
||||
rename: async () => {
|
||||
calls.push("rename");
|
||||
},
|
||||
removeTemporary: async () => {
|
||||
calls.push("remove");
|
||||
},
|
||||
},
|
||||
});
|
||||
store.updatePersistedAtomState(put(scope("a")));
|
||||
await store.flush();
|
||||
const metadata = store.metadata(),
|
||||
body = entry(store, scope("a"));
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
const baselineCalls = [...calls],
|
||||
baselineTimeCalls = now.mock.calls.length;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
expect(index(store).storeRevision).toBe(metadata.storeRevision);
|
||||
expect(items(store)[0].updatedAt).toBe(1_000);
|
||||
expect(store.metadata()).toEqual(metadata);
|
||||
expect(entry(store, scope("a"))).toEqual(body);
|
||||
}
|
||||
expect(calls).toEqual(baselineCalls);
|
||||
expect(now).toHaveBeenCalledTimes(baselineTimeCalls);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
// Projection objects cannot mutate the store owner.
|
||||
const projection = items(store)[0];
|
||||
Object.assign(projection.scope, { id: "mutated" });
|
||||
Object.assign(projection.identity, { scopeVersion: "mutated" });
|
||||
expect(items(store)[0].scope.id).toBe("a");
|
||||
expect(items(store)[0].identity.scopeVersion).toBe("v1");
|
||||
});
|
||||
|
||||
it("returns the complete small-budget index without read capacity truncation", async () => {
|
||||
const { store } = await createStore({
|
||||
limits: { ...PERSISTED_ATOM_LIMITS, maxEntries: 3, maxBindings: 3 },
|
||||
});
|
||||
for (let i = 0; i < 3; i += 1)
|
||||
store.updatePersistedAtomState(put(scope(`s${i}`), `d${i}`));
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(scope("over"), "over")).reason,
|
||||
).toBe("capacity_exceeded");
|
||||
expect(items(store)).toHaveLength(3);
|
||||
expect(index(store).storeRevision).toBe(3);
|
||||
});
|
||||
|
||||
it("returns all 10,000 validated bindings from one synchronous loaded-store snapshot", async () => {
|
||||
const entries: Record<string, PersistedDraftEntry> = {},
|
||||
bindings: Record<string, string> = {};
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
const current = scope(`s${i}`),
|
||||
draftId = `d${i}`;
|
||||
const key = scopeKey(current);
|
||||
entries[draftId] = {
|
||||
draftId,
|
||||
scopeVersion: "v1",
|
||||
originScopeKey: key,
|
||||
updatedAt: i,
|
||||
state: "draft",
|
||||
draft: {},
|
||||
};
|
||||
bindings[key] = draftId;
|
||||
}
|
||||
const envelope: PersistedAtomEnvelope = {
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: 10_000,
|
||||
atoms: { "composer-drafts-v1": { entries, bindings } },
|
||||
};
|
||||
const buffer = Buffer.from(JSON.stringify(envelope));
|
||||
const { store } = await createStore({
|
||||
limits: PERSISTED_ATOM_LIMITS,
|
||||
io: {
|
||||
readFile: async () => buffer,
|
||||
mkdir: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
rename: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
removeTemporary: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = index(store);
|
||||
expect(result.storeRevision).toBe(10_000);
|
||||
if (result.data.queryKind !== "workspace-index")
|
||||
throw new Error("wrong query");
|
||||
expect(result.data.items).toHaveLength(10_000);
|
||||
expect(result.data.items[0].identity.draftId).toBe("d9999");
|
||||
expect(result.data.items.at(-1)?.identity.draftId).toBe("d0");
|
||||
expect(store.metadata()).toMatchObject({
|
||||
storeRevision: 10_000,
|
||||
persistence: { state: "saved", savedRevision: 10_000 },
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("rejects old bare scopes and malformed queries and distinguishes closed from empty", async () => {
|
||||
const { store } = await createStore();
|
||||
for (const query of [
|
||||
home,
|
||||
{ kind: "workspace-index", workspaceId: undefined },
|
||||
{ kind: "workspace-index", workspaceId: null, path: "/tmp/private" },
|
||||
{ kind: "scope", scope: home, extra: true },
|
||||
])
|
||||
expect(store.sendPersistedAtomState(query)).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
data: null,
|
||||
});
|
||||
store.dispose();
|
||||
expect(
|
||||
store.sendPersistedAtomState({
|
||||
kind: "workspace-index",
|
||||
workspaceId: null,
|
||||
}),
|
||||
).toMatchObject({ outcome: "unavailable", reason: "closed", data: null });
|
||||
});
|
||||
|
||||
it("reports failed load as unavailable instead of an empty index that could discard saved state", async () => {
|
||||
const { store } = await createStore({
|
||||
io: {
|
||||
readFile: async () => {
|
||||
throw { code: "EACCES" };
|
||||
},
|
||||
mkdir: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
rename: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
removeTemporary: async () => {
|
||||
throw new Error("unexpected write");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
store.sendPersistedAtomState({
|
||||
kind: "workspace-index",
|
||||
workspaceId: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
data: null,
|
||||
persistence: {
|
||||
state: "unavailable",
|
||||
diagnostic: { phase: "load", code: "EACCES" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects only valid live current bindings and excludes orphans, tombstones and malformed bindings", () => {
|
||||
const current = scope("current"),
|
||||
old = scope("origin", null);
|
||||
const live: PersistedDraftEntry = {
|
||||
...identity,
|
||||
originScopeKey: scopeKey(old),
|
||||
updatedAt: 2,
|
||||
state: "draft",
|
||||
draft: { text: "private" },
|
||||
};
|
||||
const { draft: _draft, ...base } = live;
|
||||
const consumed: PersistedDraftEntry = {
|
||||
...base,
|
||||
draftId: "consumed",
|
||||
state: "consumed",
|
||||
consumption: "clear",
|
||||
consumedAt: 3,
|
||||
};
|
||||
const entries = new Map<string, PersistedDraftEntry>([
|
||||
["d1", live],
|
||||
["orphan", { ...live, draftId: "orphan" }],
|
||||
["consumed", consumed],
|
||||
["bad-local", { ...live, draftId: "bad-local", sessionId: "wrong" }],
|
||||
["mismatch", { ...live, draftId: "different" }],
|
||||
]);
|
||||
const bindings = new Map([
|
||||
[scopeKey(current), "d1"],
|
||||
[scopeKey(scope("dangling")), "missing"],
|
||||
[scopeKey(scope("consumed")), "consumed"],
|
||||
[scopeKey(local), "bad-local"],
|
||||
[scopeKey(scope("mismatch")), "mismatch"],
|
||||
["malformed", "d1"],
|
||||
[JSON.stringify(["w", "draft", "current"]) + " ", "d1"],
|
||||
]);
|
||||
expect(projectWorkspaceDraftIndex(bindings, entries, "w")).toEqual([
|
||||
{ scope: current, identity, updatedAt: 2 },
|
||||
]);
|
||||
expect(projectWorkspaceDraftIndex(bindings, entries, null)).toEqual([]);
|
||||
expect(entries.get("d1")).toBe(live);
|
||||
expect(bindings.size).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cloneDraft,
|
||||
parseEnvelope,
|
||||
parseMutation,
|
||||
parseScope,
|
||||
scopeKey,
|
||||
} from "./persistedAtomRecords";
|
||||
import type { PersistedAtomEnvelope } from "../src/lib/api/persistedAtomTypes";
|
||||
|
||||
const limits = {
|
||||
maxDraftBytes: 2_048,
|
||||
maxFileBytes: 8_192,
|
||||
maxEntries: 10,
|
||||
maxBindings: 10,
|
||||
};
|
||||
const home = {
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
} as const;
|
||||
function envelope(): PersistedAtomEnvelope {
|
||||
return {
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: 1,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
bindings: { [scopeKey(home)]: "draft-1" },
|
||||
entries: {
|
||||
"draft-1": {
|
||||
state: "draft",
|
||||
draftId: "draft-1",
|
||||
scopeVersion: "v1",
|
||||
originScopeKey: scopeKey(home),
|
||||
updatedAt: 1,
|
||||
draft: {
|
||||
text: "中文😀",
|
||||
editorDocument: { type: "doc", content: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("persisted atom JSON/identity boundary", () => {
|
||||
it("keeps structured namespaces distinct and preserves the local session id", () => {
|
||||
expect(
|
||||
parseScope({ workspaceId: "w", kind: "local", id: "session-1" }),
|
||||
).toEqual({ workspaceId: "w", kind: "local", id: "session-1" });
|
||||
expect(
|
||||
scopeKey({ workspaceId: "a|b", kind: "draft", id: "c" }),
|
||||
).not.toEqual(scopeKey({ workspaceId: "a", kind: "draft", id: "b|c" }));
|
||||
expect(scopeKey(home)).not.toEqual(
|
||||
scopeKey({ ...home, workspaceId: "null" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...home, id: "other-home" },
|
||||
{ ...home, kind: "remote" },
|
||||
{ ...home, workspaceId: undefined },
|
||||
{ ...home, extra: "ignored" },
|
||||
{ ...home, id: "" },
|
||||
{ ...home, id: "x\u0000y" },
|
||||
])("rejects invalid scope rather than changing identity", (scope) => {
|
||||
expect(() => parseScope(scope)).toThrow("invalid_scope");
|
||||
});
|
||||
|
||||
it("measures complete UTF8 JSON including escaped text and clones the input", () => {
|
||||
const value = { text: '中文😀\n"', nested: { selection: [0, 2] } };
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
expect(cloneDraft(value, bytes)).toEqual(value);
|
||||
expect(() => cloneDraft(value, bytes - 1)).toThrow("capacity_exceeded");
|
||||
const copy = cloneDraft(value, bytes);
|
||||
value.nested.selection[0] = 4;
|
||||
expect(copy).not.toEqual(value);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
BigInt(1),
|
||||
NaN,
|
||||
Infinity,
|
||||
() => "x",
|
||||
new Date(0),
|
||||
new Map(),
|
||||
Symbol("x"),
|
||||
])("rejects values JSON would lose", (item) => {
|
||||
expect(() => cloneDraft({ item }, limits.maxDraftBytes)).toThrow(
|
||||
"invalid_payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not execute accessors or toJSON, or accept cyclic/sparse data", () => {
|
||||
let called = false;
|
||||
const accessor = Object.defineProperty({}, "text", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
called = true;
|
||||
return "secret";
|
||||
},
|
||||
});
|
||||
expect(() => cloneDraft(accessor, limits.maxDraftBytes)).toThrow(
|
||||
"invalid_payload",
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
expect(() =>
|
||||
cloneDraft({ toJSON: () => ({ lost: true }) }, limits.maxDraftBytes),
|
||||
).toThrow("invalid_payload");
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(() => cloneDraft(circular, limits.maxDraftBytes)).toThrow(
|
||||
"invalid_payload",
|
||||
);
|
||||
expect(() => cloneDraft({ list: Array(2) }, limits.maxDraftBytes)).toThrow(
|
||||
"invalid_payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves ordinary special property names without prototype mutation", () => {
|
||||
const value = JSON.parse(
|
||||
'{"__proto__":{"text":"x"},"constructor":"field"}',
|
||||
);
|
||||
expect(cloneDraft(value, limits.maxDraftBytes)).toEqual(value);
|
||||
expect(Object.getPrototypeOf(cloneDraft(value, limits.maxDraftBytes))).toBe(
|
||||
Object.prototype,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown operation fields and unchanged version tokens", () => {
|
||||
const put = {
|
||||
op: "put",
|
||||
scope: home,
|
||||
draftId: "d",
|
||||
expectedVersion: null,
|
||||
nextVersion: "v1",
|
||||
draft: {},
|
||||
};
|
||||
expect(parseMutation(put, limits)).toEqual(put);
|
||||
expect(() =>
|
||||
parseMutation({ ...put, arbitraryPath: "/tmp/x" }, limits),
|
||||
).toThrow("invalid_request");
|
||||
expect(() =>
|
||||
parseMutation({ ...put, expectedVersion: "v1" }, limits),
|
||||
).toThrow("version_conflict");
|
||||
expect(() => parseMutation({ op: "replace", atom: {} }, limits)).toThrow(
|
||||
"invalid_request",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a valid rich document and a payload-free tombstone", () => {
|
||||
expect(parseEnvelope(envelope(), limits)).toEqual(envelope());
|
||||
const data = envelope(),
|
||||
atom = data.atoms["composer-drafts-v1"];
|
||||
const consumed = {
|
||||
draftId: "old",
|
||||
scopeVersion: "v-old",
|
||||
originScopeKey: scopeKey(home),
|
||||
state: "consumed",
|
||||
consumption: "accepted",
|
||||
updatedAt: 2,
|
||||
consumedAt: 2,
|
||||
};
|
||||
expect(
|
||||
parseEnvelope(
|
||||
{
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...atom,
|
||||
entries: { ...atom.entries, old: consumed },
|
||||
},
|
||||
},
|
||||
},
|
||||
limits,
|
||||
).atoms["composer-drafts-v1"].entries.old,
|
||||
).toEqual(consumed);
|
||||
expect(() =>
|
||||
parseEnvelope(
|
||||
{
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...atom,
|
||||
entries: {
|
||||
...atom.entries,
|
||||
old: { ...consumed, draft: { text: "sent" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
limits,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
(data: PersistedAtomEnvelope) => ({ ...data, version: 2 }),
|
||||
(data: PersistedAtomEnvelope) => ({
|
||||
...data,
|
||||
atoms: { ...data.atoms, other: {} },
|
||||
}),
|
||||
(data: PersistedAtomEnvelope) => ({ ...data, storeRevision: -1 }),
|
||||
(data: PersistedAtomEnvelope) => ({
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...data.atoms["composer-drafts-v1"],
|
||||
bindings: { [scopeKey(home)]: "absent" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
(data: PersistedAtomEnvelope) => ({
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...data.atoms["composer-drafts-v1"],
|
||||
bindings: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(data: PersistedAtomEnvelope) => ({
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...data.atoms["composer-drafts-v1"],
|
||||
bindings: {
|
||||
[scopeKey(home)]: "draft-1",
|
||||
[scopeKey({ workspaceId: "w", kind: "draft", id: "tab" })]:
|
||||
"draft-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])("fails closed for inconsistent/future durable envelopes", (change) => {
|
||||
expect(() => parseEnvelope(change(envelope()), limits)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a local binding whose session metadata is a canonical thread id", () => {
|
||||
const data = envelope();
|
||||
expect(() =>
|
||||
parseEnvelope(
|
||||
{
|
||||
...data,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
...data.atoms["composer-drafts-v1"],
|
||||
bindings: {
|
||||
[scopeKey({
|
||||
workspaceId: "w",
|
||||
kind: "local",
|
||||
id: "session-1",
|
||||
})]: "draft-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
limits,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
assertPersistedDraftPayload,
|
||||
type PersistedAtomLimits,
|
||||
} from "../src/lib/api/persistedAtomProtocol";
|
||||
import type {
|
||||
DraftIdentity,
|
||||
DraftScope,
|
||||
JsonObject,
|
||||
PersistedAtomEnvelope,
|
||||
PersistedAtomMutation,
|
||||
PersistedAtomReason,
|
||||
PersistedDraftEntry,
|
||||
PersistedDraftIndexItem,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
|
||||
export const COMPOSER_ATOM_KEY = "composer-drafts-v1" as const;
|
||||
|
||||
export class AtomValidationError extends Error {
|
||||
constructor(readonly reason: PersistedAtomReason) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalid(reason: PersistedAtomReason): never {
|
||||
throw new AtomValidationError(reason);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
if (
|
||||
Object.getOwnPropertySymbols(value).length ||
|
||||
Object.values(Object.getOwnPropertyDescriptors(value)).some(
|
||||
(descriptor) => !descriptor.enumerable || !("value" in descriptor),
|
||||
)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function exactRecord(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): Record<string, unknown> {
|
||||
const object = record(value);
|
||||
const descriptors = Object.getOwnPropertyDescriptors(object);
|
||||
if (
|
||||
Object.getOwnPropertySymbols(object).length ||
|
||||
required.some((key) => !Object.hasOwn(object, key)) ||
|
||||
Object.keys(descriptors).some(
|
||||
(key) =>
|
||||
(!required.includes(key) && !optional.includes(key)) ||
|
||||
!descriptors[key].enumerable ||
|
||||
!("value" in descriptors[key]),
|
||||
)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
return object;
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, maxLength = 256): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value.trim() ||
|
||||
value.length > maxLength ||
|
||||
[...value].some((character) => character.charCodeAt(0) < 32)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseScope(value: unknown): DraftScope {
|
||||
try {
|
||||
const scope = exactRecord(value, ["workspaceId", "kind", "id"]);
|
||||
if (
|
||||
typeof scope.kind !== "string" ||
|
||||
!["home", "draft", "local"].includes(scope.kind)
|
||||
)
|
||||
invalid("invalid_scope");
|
||||
const id = identifier(scope.id, 8_192);
|
||||
if (scope.kind === "home" && id !== "new-conversation")
|
||||
invalid("invalid_scope");
|
||||
return {
|
||||
workspaceId:
|
||||
scope.workspaceId === null
|
||||
? null
|
||||
: identifier(scope.workspaceId, 4_096),
|
||||
kind: scope.kind as DraftScope["kind"],
|
||||
id,
|
||||
};
|
||||
} catch {
|
||||
return invalid("invalid_scope");
|
||||
}
|
||||
}
|
||||
|
||||
export function scopeKey(scope: DraftScope): string {
|
||||
return JSON.stringify([scope.workspaceId, scope.kind, scope.id]);
|
||||
}
|
||||
|
||||
function compareScopeKeys(left: string, right: string): number {
|
||||
let a = 0,
|
||||
b = 0;
|
||||
while (a < left.length && b < right.length) {
|
||||
const x = left.codePointAt(a)!,
|
||||
y = right.codePointAt(b)!;
|
||||
if (x !== y) return x - y;
|
||||
a += x > 0xffff ? 2 : 1;
|
||||
b += y > 0xffff ? 2 : 1;
|
||||
}
|
||||
return a < left.length ? 1 : b < right.length ? -1 : 0;
|
||||
}
|
||||
|
||||
/** Read-only projection from the store owner; no parallel index or cache. */
|
||||
export function projectWorkspaceDraftIndex(
|
||||
bindings: ReadonlyMap<string, string>,
|
||||
entries: ReadonlyMap<string, PersistedDraftEntry>,
|
||||
workspaceId: string | null,
|
||||
): readonly PersistedDraftIndexItem[] {
|
||||
const items: PersistedDraftIndexItem[] = [];
|
||||
for (const [key, draftId] of bindings) {
|
||||
const entry = entries.get(draftId);
|
||||
if (!entry || entry.state !== "draft" || entry.draftId !== draftId)
|
||||
continue;
|
||||
let scope: DraftScope;
|
||||
try {
|
||||
const parts: unknown = JSON.parse(key);
|
||||
if (!Array.isArray(parts) || parts.length !== 3) continue;
|
||||
scope = parseScope({
|
||||
workspaceId: parts[0],
|
||||
kind: parts[1],
|
||||
id: parts[2],
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
scopeKey(scope) !== key ||
|
||||
scope.workspaceId !== workspaceId ||
|
||||
(scope.kind === "local" && entry.sessionId !== scope.id)
|
||||
)
|
||||
continue;
|
||||
items.push({
|
||||
scope,
|
||||
identity: { draftId: entry.draftId, scopeVersion: entry.scopeVersion },
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.sessionId !== undefined ? { sessionId: entry.sessionId } : {}),
|
||||
...(entry.threadId !== undefined ? { threadId: entry.threadId } : {}),
|
||||
});
|
||||
}
|
||||
return items.sort((left, right) => {
|
||||
if (left.updatedAt !== right.updatedAt)
|
||||
return right.updatedAt - left.updatedAt;
|
||||
return compareScopeKeys(scopeKey(left.scope), scopeKey(right.scope));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseIdentity(value: unknown): DraftIdentity {
|
||||
const identity = exactRecord(value, ["draftId", "scopeVersion"]);
|
||||
return {
|
||||
draftId: identifier(identity.draftId),
|
||||
scopeVersion: identifier(identity.scopeVersion),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject lossy JSON values, accessors and cycles before serialization. */
|
||||
export function cloneDraft(value: unknown, maxBytes: number): JsonObject {
|
||||
try {
|
||||
assertPersistedDraftPayload(value);
|
||||
} catch {
|
||||
return invalid("invalid_payload");
|
||||
}
|
||||
const json = JSON.stringify(value);
|
||||
if (Buffer.byteLength(json, "utf8") > maxBytes) invalid("capacity_exceeded");
|
||||
return JSON.parse(json) as JsonObject;
|
||||
}
|
||||
|
||||
export function parseMutation(
|
||||
value: unknown,
|
||||
limits: PersistedAtomLimits,
|
||||
): PersistedAtomMutation {
|
||||
const op = record(value).op;
|
||||
if (op === "put") {
|
||||
const update = exactRecord(value, [
|
||||
"op",
|
||||
"scope",
|
||||
"draftId",
|
||||
"expectedVersion",
|
||||
"nextVersion",
|
||||
"draft",
|
||||
]);
|
||||
const expectedVersion =
|
||||
update.expectedVersion === null
|
||||
? null
|
||||
: identifier(update.expectedVersion);
|
||||
const nextVersion = identifier(update.nextVersion);
|
||||
if (expectedVersion === nextVersion) invalid("version_conflict");
|
||||
return {
|
||||
op,
|
||||
scope: parseScope(update.scope),
|
||||
draftId: identifier(update.draftId),
|
||||
expectedVersion,
|
||||
nextVersion,
|
||||
draft: cloneDraft(update.draft, limits.maxDraftBytes),
|
||||
};
|
||||
}
|
||||
if (op === "promote") {
|
||||
const update = exactRecord(
|
||||
value,
|
||||
["op", "source", "target", "identity", "expectedTarget"],
|
||||
["sessionId", "threadId"],
|
||||
);
|
||||
return {
|
||||
op,
|
||||
source: parseScope(update.source),
|
||||
target: parseScope(update.target),
|
||||
identity: parseIdentity(update.identity),
|
||||
expectedTarget:
|
||||
update.expectedTarget === null
|
||||
? null
|
||||
: parseIdentity(update.expectedTarget),
|
||||
...(update.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: identifier(update.sessionId) }),
|
||||
...(update.threadId === undefined
|
||||
? {}
|
||||
: { threadId: identifier(update.threadId) }),
|
||||
};
|
||||
}
|
||||
if (op === "consume" || op === "clear") {
|
||||
const update = exactRecord(value, ["op", "identity"]);
|
||||
return { op, identity: parseIdentity(update.identity) };
|
||||
}
|
||||
return invalid("invalid_request");
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
||||
invalid("invalid_request");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEntry(
|
||||
value: unknown,
|
||||
limits: PersistedAtomLimits,
|
||||
): PersistedDraftEntry {
|
||||
const state = record(value).state;
|
||||
const base = [
|
||||
"draftId",
|
||||
"scopeVersion",
|
||||
"originScopeKey",
|
||||
"state",
|
||||
"updatedAt",
|
||||
];
|
||||
const entry = exactRecord(
|
||||
value,
|
||||
[
|
||||
...base,
|
||||
...(state === "draft" ? ["draft"] : ["consumption", "consumedAt"]),
|
||||
],
|
||||
["sessionId", "threadId"],
|
||||
);
|
||||
let origin: unknown;
|
||||
try {
|
||||
origin = JSON.parse(identifier(entry.originScopeKey, 16_384));
|
||||
} catch {
|
||||
return invalid("invalid_request");
|
||||
}
|
||||
if (!Array.isArray(origin) || origin.length !== 3) invalid("invalid_request");
|
||||
const originScope = parseScope({
|
||||
workspaceId: origin[0],
|
||||
kind: origin[1],
|
||||
id: origin[2],
|
||||
});
|
||||
if (scopeKey(originScope) !== entry.originScopeKey)
|
||||
invalid("invalid_request");
|
||||
const metadata = {
|
||||
draftId: identifier(entry.draftId),
|
||||
scopeVersion: identifier(entry.scopeVersion),
|
||||
originScopeKey: entry.originScopeKey as string,
|
||||
updatedAt: timestamp(entry.updatedAt),
|
||||
...(entry.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: identifier(entry.sessionId) }),
|
||||
...(entry.threadId === undefined
|
||||
? {}
|
||||
: { threadId: identifier(entry.threadId) }),
|
||||
};
|
||||
if (state === "draft")
|
||||
return {
|
||||
...metadata,
|
||||
state,
|
||||
draft: cloneDraft(entry.draft, limits.maxDraftBytes),
|
||||
};
|
||||
if (
|
||||
state !== "consumed" ||
|
||||
typeof entry.consumption !== "string" ||
|
||||
!["accepted", "clear"].includes(entry.consumption)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
return {
|
||||
...metadata,
|
||||
state,
|
||||
consumption: entry.consumption as "accepted" | "clear",
|
||||
consumedAt: timestamp(entry.consumedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEnvelope(
|
||||
value: unknown,
|
||||
limits: PersistedAtomLimits,
|
||||
): PersistedAtomEnvelope {
|
||||
const envelope = exactRecord(value, [
|
||||
"format",
|
||||
"version",
|
||||
"storeRevision",
|
||||
"atoms",
|
||||
]);
|
||||
if (
|
||||
envelope.format !== "electron-persisted-atom-state" ||
|
||||
envelope.version !== 1
|
||||
)
|
||||
invalid("invalid_request");
|
||||
const atoms = exactRecord(envelope.atoms, [COMPOSER_ATOM_KEY]);
|
||||
const atom = exactRecord(atoms[COMPOSER_ATOM_KEY], ["bindings", "entries"]);
|
||||
const rawEntries = record(atom.entries);
|
||||
const rawBindings = record(atom.bindings);
|
||||
if (
|
||||
Object.keys(rawEntries).length > limits.maxEntries ||
|
||||
Object.keys(rawBindings).length > limits.maxBindings
|
||||
)
|
||||
invalid("capacity_exceeded");
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(rawEntries).map(([id, item]) => {
|
||||
const entry = parseEntry(item, limits);
|
||||
if (id !== entry.draftId) invalid("invalid_request");
|
||||
return [id, entry];
|
||||
}),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const bindings = Object.fromEntries(
|
||||
Object.entries(rawBindings).map(([key, id]) => {
|
||||
let parts: unknown;
|
||||
try {
|
||||
parts = JSON.parse(key);
|
||||
} catch {
|
||||
return invalid("invalid_request");
|
||||
}
|
||||
if (!Array.isArray(parts) || parts.length !== 3)
|
||||
invalid("invalid_request");
|
||||
const scope = parseScope({
|
||||
workspaceId: parts[0],
|
||||
kind: parts[1],
|
||||
id: parts[2],
|
||||
});
|
||||
const identity = identifier(id);
|
||||
if (
|
||||
scopeKey(scope) !== key ||
|
||||
!Object.hasOwn(entries, identity) ||
|
||||
entries[identity].state !== "draft" ||
|
||||
seen.has(identity)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
if (scope.kind === "local" && entries[identity].sessionId !== scope.id)
|
||||
invalid("invalid_request");
|
||||
seen.add(identity);
|
||||
return [key, identity];
|
||||
}),
|
||||
);
|
||||
if (
|
||||
Object.values(entries).some(
|
||||
(entry) => entry.state === "draft" && !seen.has(entry.draftId),
|
||||
)
|
||||
)
|
||||
invalid("invalid_request");
|
||||
return {
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: timestamp(envelope.storeRevision),
|
||||
atoms: { [COMPOSER_ATOM_KEY]: { entries, bindings } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PersistedAtomUpdateResult } from "../src/lib/api/persistedAtomTypes";
|
||||
import {
|
||||
persistedAtomFileIO,
|
||||
type PersistedAtomFileIO,
|
||||
} from "./persistedAtomFile";
|
||||
import { PersistedAtomStore } from "./persistedAtomStore";
|
||||
import {
|
||||
cleanup,
|
||||
createRoot,
|
||||
createStore,
|
||||
deferred,
|
||||
entry,
|
||||
home,
|
||||
identity,
|
||||
limits,
|
||||
local,
|
||||
promotion,
|
||||
put,
|
||||
trackStore,
|
||||
} from "./persistedAtomStore.test-support";
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("persisted atom save queue and recovery", () => {
|
||||
it("reserves tombstone overhead before accepting a minimal draft at capacity", async () => {
|
||||
const minimal = {
|
||||
op: "put",
|
||||
scope: { workspaceId: null, kind: "draft", id: "x" },
|
||||
draftId: "d1",
|
||||
expectedVersion: null,
|
||||
nextVersion: "v1",
|
||||
draft: {},
|
||||
} as const;
|
||||
const baseline = await createStore();
|
||||
expect(baseline.store.updatePersistedAtomState(minimal).outcome).toBe(
|
||||
"applied",
|
||||
);
|
||||
await baseline.store.flush();
|
||||
const actualBytes = (
|
||||
await readFile(path.join(baseline.root, "host-state.json"))
|
||||
).byteLength;
|
||||
const narrow = await createStore({
|
||||
limits: { ...limits, maxFileBytes: actualBytes },
|
||||
});
|
||||
expect(narrow.store.updatePersistedAtomState(minimal)).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "capacity_exceeded",
|
||||
storeRevision: 0,
|
||||
});
|
||||
const bounded = await createStore({
|
||||
limits: { ...limits, maxFileBytes: actualBytes + 128 },
|
||||
});
|
||||
expect(bounded.store.updatePersistedAtomState(minimal).outcome).toBe(
|
||||
"applied",
|
||||
);
|
||||
expect(
|
||||
bounded.store.updatePersistedAtomState({ op: "consume", identity }),
|
||||
).toMatchObject({ outcome: "applied", entry: { state: "consumed" } });
|
||||
await bounded.store.flush();
|
||||
expect(
|
||||
(await readFile(path.join(bounded.root, "host-state.json"))).byteLength,
|
||||
).toBeLessThanOrEqual(actualBytes + 128);
|
||||
});
|
||||
|
||||
it("malformed UTF8 durable files fail closed without replacing invalid bytes", async () => {
|
||||
const root = await createRoot();
|
||||
const broken = Buffer.from([
|
||||
0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d,
|
||||
]);
|
||||
await writeFile(path.join(root, "host-state.json"), broken);
|
||||
const store = new PersistedAtomStore(root, { limits });
|
||||
trackStore(store);
|
||||
await store.ready;
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: {
|
||||
state: "unavailable",
|
||||
diagnostic: { code: "INVALID_STATE" },
|
||||
},
|
||||
});
|
||||
expect(store.updatePersistedAtomState(put()).outcome).toBe("unavailable");
|
||||
await store.flush();
|
||||
expect(await readFile(path.join(root, "host-state.json"))).toEqual(broken);
|
||||
});
|
||||
it("coalesces edits over 500 ms and writes only the latest full snapshot", async () => {
|
||||
vi.useFakeTimers();
|
||||
const rename = vi.fn(persistedAtomFileIO.rename);
|
||||
const { store } = await createStore({
|
||||
io: { ...persistedAtomFileIO, rename },
|
||||
});
|
||||
store.updatePersistedAtomState(put());
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "latest"));
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await store.flush();
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(store.metadata()).toMatchObject({
|
||||
storeRevision: 2,
|
||||
persistence: { state: "saved", savedRevision: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a late saved revision cannot mark a newer in-memory edit saved", async () => {
|
||||
const started = deferred<void>(),
|
||||
release = deferred<void>();
|
||||
let first = true;
|
||||
const io: PersistedAtomFileIO = {
|
||||
...persistedAtomFileIO,
|
||||
rename: async (from, to) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await persistedAtomFileIO.rename(from, to);
|
||||
},
|
||||
};
|
||||
const { store, root } = await createStore({ io });
|
||||
store.updatePersistedAtomState(put());
|
||||
const saving = store.flush();
|
||||
await started.promise;
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "d1", "v2", "v1", "newer pending"),
|
||||
);
|
||||
release.resolve();
|
||||
await saving;
|
||||
expect(store.metadata()).toMatchObject({
|
||||
storeRevision: 2,
|
||||
persistence: { state: "pending", savedRevision: 1 },
|
||||
});
|
||||
const persisted = JSON.parse(
|
||||
await readFile(path.join(root, "host-state.json"), "utf8"),
|
||||
);
|
||||
expect(persisted.storeRevision).toBe(1);
|
||||
expect(entry(store)).toMatchObject({ scopeVersion: "v2" });
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent flushes without overlapping or rolling the durable revision back", async () => {
|
||||
const started = deferred<void>(),
|
||||
release = deferred<void>();
|
||||
let first = true,
|
||||
active = 0,
|
||||
maximum = 0;
|
||||
const io: PersistedAtomFileIO = {
|
||||
...persistedAtomFileIO,
|
||||
writeFile: async (file, text) => {
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
if (first) {
|
||||
first = false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await persistedAtomFileIO.writeFile(file, text);
|
||||
active -= 1;
|
||||
},
|
||||
};
|
||||
const { store } = await createStore({ io });
|
||||
store.updatePersistedAtomState(put());
|
||||
const oldSave = store.flush();
|
||||
await started.promise;
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1"));
|
||||
const newSave = store.flush();
|
||||
release.resolve();
|
||||
await Promise.all([oldSave, newSave]);
|
||||
expect(maximum).toBe(1);
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps latest memory/old durable files after IO failure, without gating further mutations", async () => {
|
||||
let fail = false;
|
||||
const io = {
|
||||
...persistedAtomFileIO,
|
||||
rename: async (from: string, to: string) => {
|
||||
if (fail)
|
||||
throw Object.assign(new Error("draft text attachment secret"), {
|
||||
code: "ENOSPC",
|
||||
});
|
||||
await persistedAtomFileIO.rename(from, to);
|
||||
},
|
||||
};
|
||||
const { store, root } = await createStore({ io });
|
||||
store.updatePersistedAtomState(put());
|
||||
await store.flush();
|
||||
fail = true;
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "new memory"));
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: {
|
||||
state: "error",
|
||||
savedRevision: 1,
|
||||
diagnostic: { phase: "primary", code: "ENOSPC" },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(store.metadata())).not.toContain("attachment secret");
|
||||
expect(entry(store)).toMatchObject({ scopeVersion: "v2" });
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8"))
|
||||
.storeRevision,
|
||||
).toBe(1);
|
||||
const accepted: PersistedAtomUpdateResult = store.updatePersistedAtomState({
|
||||
op: "consume",
|
||||
identity: { ...identity, scopeVersion: "v2" },
|
||||
});
|
||||
expect(accepted).toMatchObject({
|
||||
outcome: "applied",
|
||||
entry: { state: "consumed" },
|
||||
});
|
||||
fail = false;
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("partial backup failure is an error rather than a false saved receipt", async () => {
|
||||
let failBackup = false;
|
||||
const io = {
|
||||
...persistedAtomFileIO,
|
||||
rename: async (from: string, to: string) => {
|
||||
if (failBackup && to.endsWith(".bak"))
|
||||
throw Object.assign(new Error("private"), { code: "EIO" });
|
||||
await persistedAtomFileIO.rename(from, to);
|
||||
},
|
||||
};
|
||||
const { store, root } = await createStore({ io });
|
||||
store.updatePersistedAtomState(put());
|
||||
await store.flush();
|
||||
failBackup = true;
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1"));
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: {
|
||||
state: "error",
|
||||
savedRevision: 1,
|
||||
diagnostic: { phase: "backup", code: "EIO" },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json.bak"), "utf8"))
|
||||
.storeRevision,
|
||||
).toBe(1);
|
||||
failBackup = false;
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a new owner restores durable rich records and non-resurrectable tombstones", async () => {
|
||||
const { store, root } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(promotion());
|
||||
store.updatePersistedAtomState(put(home, "new-home", "new-version"));
|
||||
store.updatePersistedAtomState({ op: "consume", identity });
|
||||
await store.flush();
|
||||
const restored = new PersistedAtomStore(root, { limits });
|
||||
trackStore(restored);
|
||||
await restored.ready;
|
||||
expect(entry(restored, local)).toBeNull();
|
||||
expect(entry(restored)).toEqual(entry(store));
|
||||
expect(restored.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "retired_draft",
|
||||
});
|
||||
expect(restored.metadata()).toMatchObject({
|
||||
storeRevision: 4,
|
||||
persistence: { state: "saved", savedRevision: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs a corrupt primary from valid backup without losing the record", async () => {
|
||||
const { store, root } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
await store.flush();
|
||||
await writeFile(path.join(root, "host-state.json"), "corrupt");
|
||||
const restored = new PersistedAtomStore(root, { limits });
|
||||
trackStore(restored);
|
||||
await restored.ready;
|
||||
expect(entry(restored)).toEqual(entry(store));
|
||||
expect(restored.metadata()).toMatchObject({
|
||||
persistence: { state: "pending", savedRevision: 1 },
|
||||
});
|
||||
await restored.flush();
|
||||
expect(restored.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 1 },
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8"))
|
||||
.storeRevision,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps corrupt durable files unavailable and never overwrites them with empty state", async () => {
|
||||
const root = await createRoot();
|
||||
await writeFile(
|
||||
path.join(root, "host-state.json"),
|
||||
"private-broken-primary",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "host-state.json.bak"),
|
||||
"private-broken-backup",
|
||||
);
|
||||
const store = new PersistedAtomStore(root, { limits });
|
||||
trackStore(store);
|
||||
await store.ready;
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
changed: false,
|
||||
});
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: {
|
||||
state: "unavailable",
|
||||
savedRevision: null,
|
||||
diagnostic: { code: "INVALID_STATE" },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(store.metadata())).not.toContain("private-broken");
|
||||
expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe(
|
||||
"private-broken-primary",
|
||||
);
|
||||
expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe(
|
||||
"private-broken-backup",
|
||||
);
|
||||
});
|
||||
|
||||
it("dispose is synchronous best effort and closed stores refuse new mutations", async () => {
|
||||
const { store, root } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
expect(store.dispose()).toBeUndefined();
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1")),
|
||||
).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
reason: "closed",
|
||||
});
|
||||
await store.flush();
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8"))
|
||||
.storeRevision,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { vi } from "vitest";
|
||||
import type {
|
||||
DraftScope,
|
||||
PersistedAtomMutation,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import { PersistedAtomStore } from "./persistedAtomStore";
|
||||
|
||||
const roots: string[] = [],
|
||||
stores: PersistedAtomStore[] = [];
|
||||
export const limits = {
|
||||
maxDraftBytes: 2_048,
|
||||
maxFileBytes: 16_384,
|
||||
maxEntries: 20,
|
||||
maxBindings: 20,
|
||||
};
|
||||
export const home: DraftScope = {
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
};
|
||||
export const local: DraftScope = {
|
||||
workspaceId: "w",
|
||||
kind: "local",
|
||||
id: "session-1",
|
||||
};
|
||||
export const identity = { draftId: "d1", scopeVersion: "v1" };
|
||||
|
||||
export function put(
|
||||
scope: DraftScope = home,
|
||||
draftId = "d1",
|
||||
nextVersion = "v1",
|
||||
expectedVersion: string | null = null,
|
||||
text = "rich 中文😀",
|
||||
): PersistedAtomMutation {
|
||||
return {
|
||||
op: "put",
|
||||
scope,
|
||||
draftId,
|
||||
nextVersion,
|
||||
expectedVersion,
|
||||
draft: {
|
||||
text,
|
||||
editorDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
marks: [
|
||||
{ type: "link", attrs: { href: "https://example.test" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
selectionStart: 0,
|
||||
selectionEnd: text.length,
|
||||
attachments: [
|
||||
{ data: "aW1hZ2U=", mediaType: "image/png", sourcePath: "fixture.png" },
|
||||
],
|
||||
annotations: [{ id: "annotation-1" }],
|
||||
pendingPastes: [{ text: "folded", placeholder: "paste-1" }],
|
||||
inputCapabilityRoute: { route: "image" },
|
||||
},
|
||||
};
|
||||
}
|
||||
export function promotion(
|
||||
source: DraftScope = home,
|
||||
target: DraftScope = local,
|
||||
): PersistedAtomMutation {
|
||||
return { op: "promote", source, target, identity, expectedTarget: null };
|
||||
}
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
export function trackStore(store: PersistedAtomStore): void {
|
||||
stores.push(store);
|
||||
}
|
||||
export async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "atom-store-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
export async function createStore(
|
||||
options: ConstructorParameters<typeof PersistedAtomStore>[1] = {},
|
||||
) {
|
||||
const root = await createRoot(),
|
||||
store = new PersistedAtomStore(root, {
|
||||
limits,
|
||||
now: () => 1_000,
|
||||
...options,
|
||||
});
|
||||
trackStore(store);
|
||||
await store.ready;
|
||||
return { root, store };
|
||||
}
|
||||
export function entry(store: PersistedAtomStore, scope: DraftScope = home) {
|
||||
const result = store.sendPersistedAtomState({ kind: "scope", scope });
|
||||
if (result.outcome !== "ready" || result.data.queryKind !== "scope")
|
||||
return null;
|
||||
return result.data.view.entry;
|
||||
}
|
||||
export async function cleanup(): Promise<void> {
|
||||
vi.useRealTimers();
|
||||
for (const store of stores.splice(0)) {
|
||||
store.dispose();
|
||||
await store.flush();
|
||||
}
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DraftScope,
|
||||
PersistedAtomMetadata,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import { persistedAtomFileIO } from "./persistedAtomFile";
|
||||
import { PersistedAtomStore } from "./persistedAtomStore";
|
||||
import {
|
||||
cleanup,
|
||||
createRoot,
|
||||
createStore,
|
||||
deferred,
|
||||
entry,
|
||||
home,
|
||||
identity,
|
||||
limits,
|
||||
local,
|
||||
promotion,
|
||||
put,
|
||||
trackStore,
|
||||
} from "./persistedAtomStore.test-support";
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("persisted atom Main CAS and draft lifecycle", () => {
|
||||
it("returns explicit loading unavailability without accepting or awaiting a put", async () => {
|
||||
const root = await createRoot(),
|
||||
gate = deferred<Buffer>();
|
||||
const store = new PersistedAtomStore(root, {
|
||||
limits,
|
||||
io: { ...persistedAtomFileIO, readFile: () => gate.promise },
|
||||
});
|
||||
trackStore(store);
|
||||
expect(
|
||||
store.sendPersistedAtomState({ kind: "scope", scope: home }),
|
||||
).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
persistence: { state: "loading" },
|
||||
data: null,
|
||||
});
|
||||
const result = store.updatePersistedAtomState(put());
|
||||
expect(result).not.toBeInstanceOf(Promise);
|
||||
expect(result).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
changed: false,
|
||||
storeRevision: 0,
|
||||
});
|
||||
gate.resolve(Buffer.from("invalid"));
|
||||
await store.ready;
|
||||
expect(
|
||||
store.sendPersistedAtomState({ kind: "scope", scope: home }),
|
||||
).toMatchObject({
|
||||
outcome: "unavailable",
|
||||
persistence: { state: "unavailable" },
|
||||
});
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a complete rich draft synchronously before durable IO", async () => {
|
||||
const { root, store } = await createStore();
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: true,
|
||||
storeRevision: 1,
|
||||
persistence: { state: "pending", savedRevision: 0 },
|
||||
});
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
const draft = put();
|
||||
expect(entry(store)?.state).toBe("draft");
|
||||
expect(entry(store)).toMatchObject({
|
||||
draftId: "d1",
|
||||
scopeVersion: "v1",
|
||||
draft: "draft" in draft ? draft.draft : null,
|
||||
});
|
||||
await store.flush();
|
||||
expect(store.metadata()).toMatchObject({
|
||||
persistence: { state: "saved", savedRevision: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses exact expected versions and does not rebase a stale writer", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "new edit")),
|
||||
).toMatchObject({ outcome: "applied", storeRevision: 2 });
|
||||
expect(
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "d1", "stale", "v1", "stale overwrite"),
|
||||
),
|
||||
).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "version_conflict",
|
||||
changed: false,
|
||||
storeRevision: 2,
|
||||
});
|
||||
expect(entry(store)).toMatchObject({
|
||||
scopeVersion: "v2",
|
||||
draft: { text: "new edit" },
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "other", "v1")),
|
||||
).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "identity_mismatch",
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "d1", "v2", "v2")),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "version_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates home/draft/session and workspace namespaces", async () => {
|
||||
const { store } = await createStore();
|
||||
const scopes: DraftScope[] = [
|
||||
home,
|
||||
{ ...home, workspaceId: "w" },
|
||||
{ workspaceId: "w", kind: "draft", id: "tab-1" },
|
||||
local,
|
||||
];
|
||||
scopes.forEach((scope, index) =>
|
||||
expect(
|
||||
store.updatePersistedAtomState(
|
||||
put(scope, `draft-${index}`, `v-${index}`, null, `scope ${index}`),
|
||||
).outcome,
|
||||
).toBe("applied"),
|
||||
);
|
||||
scopes.forEach((scope, index) =>
|
||||
expect(entry(store, scope)).toMatchObject({
|
||||
draftId: `draft-${index}`,
|
||||
draft: { text: `scope ${index}` },
|
||||
}),
|
||||
);
|
||||
expect(entry(store, local)).toMatchObject({ sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "Home without tab", scope: home },
|
||||
{
|
||||
name: "direct draft",
|
||||
scope: {
|
||||
workspaceId: "w",
|
||||
kind: "draft",
|
||||
id: "tab-direct",
|
||||
} as DraftScope,
|
||||
},
|
||||
{
|
||||
name: "materialized draft",
|
||||
scope: {
|
||||
workspaceId: "w",
|
||||
kind: "draft",
|
||||
id: "tab-materialized",
|
||||
} as DraftScope,
|
||||
},
|
||||
])(
|
||||
"promotes $name without changing logical draft/version/payload",
|
||||
async ({ scope }) => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put(scope));
|
||||
const before = entry(store, scope);
|
||||
const result = store.updatePersistedAtomState(promotion(scope));
|
||||
expect(result).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: true,
|
||||
storeRevision: 2,
|
||||
});
|
||||
expect(entry(store, scope)).toBeNull();
|
||||
expect(entry(store, local)).toMatchObject({
|
||||
draftId: before?.draftId,
|
||||
scopeVersion: before?.scopeVersion,
|
||||
originScopeKey: before?.originScopeKey,
|
||||
sessionId: "session-1",
|
||||
draft: before?.state === "draft" ? before.draft : null,
|
||||
});
|
||||
expect(store.updatePersistedAtomState(promotion(scope))).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: false,
|
||||
storeRevision: 2,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps both source and occupied target intact when promotion conflicts", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(
|
||||
put(local, "target", "target-v1", null, "target text"),
|
||||
);
|
||||
const beforeSource = entry(store),
|
||||
beforeTarget = entry(store, local);
|
||||
expect(store.updatePersistedAtomState(promotion())).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "target_conflict",
|
||||
storeRevision: 2,
|
||||
});
|
||||
expect(entry(store)).toEqual(beforeSource);
|
||||
expect(entry(store, local)).toEqual(beforeTarget);
|
||||
});
|
||||
|
||||
it("does not overwrite a target even if the caller supplies its expected identity", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(put(local, "target", "target-v1"));
|
||||
expect(
|
||||
store.updatePersistedAtomState({
|
||||
...promotion(),
|
||||
expectedTarget: { draftId: "target", scopeVersion: "target-v1" },
|
||||
}),
|
||||
).toMatchObject({ outcome: "conflict", reason: "target_conflict" });
|
||||
expect(entry(store)).toMatchObject(identity);
|
||||
expect(entry(store, local)).toMatchObject({ draftId: "target" });
|
||||
});
|
||||
|
||||
it("adds canonical thread metadata after promotion, preserving a reused Home binding", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(promotion());
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "new-home", "new-version", null, "new Home"),
|
||||
);
|
||||
expect(
|
||||
store.updatePersistedAtomState({
|
||||
...promotion(),
|
||||
threadId: "canonical-thread-1",
|
||||
}),
|
||||
).toMatchObject({ outcome: "applied", changed: true });
|
||||
expect(entry(store, local)).toMatchObject({
|
||||
...identity,
|
||||
sessionId: "session-1",
|
||||
threadId: "canonical-thread-1",
|
||||
});
|
||||
expect(entry(store)).toMatchObject({
|
||||
draftId: "new-home",
|
||||
draft: { text: "new Home" },
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState({
|
||||
...promotion(local, local),
|
||||
expectedTarget: identity,
|
||||
threadId: "other-thread",
|
||||
}),
|
||||
).toMatchObject({ outcome: "conflict", reason: "identity_mismatch" });
|
||||
});
|
||||
|
||||
it("rejects a promotion with mismatched session metadata or stale source version", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
expect(
|
||||
store.updatePersistedAtomState({
|
||||
...promotion(),
|
||||
sessionId: "canonical-thread",
|
||||
}),
|
||||
).toMatchObject({ outcome: "conflict", reason: "identity_mismatch" });
|
||||
expect(
|
||||
store.updatePersistedAtomState({
|
||||
...promotion(),
|
||||
identity: { ...identity, scopeVersion: "wrong" },
|
||||
}),
|
||||
).toMatchObject({ outcome: "conflict", reason: "version_conflict" });
|
||||
expect(entry(store)).toMatchObject(identity);
|
||||
expect(entry(store, local)).toBeNull();
|
||||
});
|
||||
|
||||
it("consumes the exact promoted token, releases its binding and drops all payload", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(promotion());
|
||||
const result = store.updatePersistedAtomState({ op: "consume", identity });
|
||||
expect(result).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: true,
|
||||
entry: { ...identity, state: "consumed", consumption: "accepted" },
|
||||
});
|
||||
expect(result.entry).not.toHaveProperty("draft");
|
||||
expect(entry(store, local)).toBeNull();
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "consume", identity }),
|
||||
).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: false,
|
||||
storeRevision: 3,
|
||||
});
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "retired_draft",
|
||||
storeRevision: 3,
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState(
|
||||
put(local, "d1", "late", "v1", "late write"),
|
||||
),
|
||||
).toMatchObject({ outcome: "conflict", reason: "retired_draft" });
|
||||
});
|
||||
|
||||
it("accepted/clear of an old version leaves newer same-scope edits untouched", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "d1", "v2", "v1", "edited after send"),
|
||||
);
|
||||
for (const op of ["consume", "clear"])
|
||||
expect(store.updatePersistedAtomState({ op, identity })).toMatchObject({
|
||||
outcome: "conflict",
|
||||
changed: false,
|
||||
reason: "version_conflict",
|
||||
});
|
||||
expect(entry(store)).toMatchObject({
|
||||
scopeVersion: "v2",
|
||||
draft: { text: "edited after send" },
|
||||
});
|
||||
});
|
||||
|
||||
it("old accepted replay cannot clear a newly reused Home draft id", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState({ op: "consume", identity });
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "new-home", "new-version", null, "next draft"),
|
||||
);
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "consume", identity }),
|
||||
).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: false,
|
||||
});
|
||||
expect(entry(store)).toMatchObject({
|
||||
draftId: "new-home",
|
||||
draft: { text: "next draft" },
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit clear creates the same non-resurrectable minimal tombstone", async () => {
|
||||
const { store } = await createStore();
|
||||
store.updatePersistedAtomState(put());
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "clear", identity }),
|
||||
).toMatchObject({
|
||||
outcome: "applied",
|
||||
entry: { state: "consumed", consumption: "clear" },
|
||||
});
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "retired_draft",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not evict tombstones when the entry budget is full", async () => {
|
||||
const { store } = await createStore({
|
||||
limits: { ...limits, maxEntries: 1 },
|
||||
});
|
||||
store.updatePersistedAtomState(put());
|
||||
store.updatePersistedAtomState({ op: "consume", identity });
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "new-home", "new-version")),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
reason: "capacity_exceeded",
|
||||
storeRevision: 2,
|
||||
});
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "conflict",
|
||||
reason: "retired_draft",
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "consume", identity }),
|
||||
).toMatchObject({
|
||||
outcome: "applied",
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces draft UTF8 and aggregate file budgets without changing an old draft", async () => {
|
||||
const { store } = await createStore({
|
||||
limits: { ...limits, maxDraftBytes: 800, maxFileBytes: 1_500 },
|
||||
});
|
||||
expect(
|
||||
store.updatePersistedAtomState(put(home, "d1", "v1", null, "x")),
|
||||
).toMatchObject({
|
||||
outcome: "applied",
|
||||
});
|
||||
const before = entry(store);
|
||||
expect(
|
||||
store.updatePersistedAtomState(
|
||||
put(home, "d1", "v2", "v1", "中文".repeat(200)),
|
||||
),
|
||||
).toMatchObject({ outcome: "invalid", reason: "capacity_exceeded" });
|
||||
expect(entry(store)).toEqual(before);
|
||||
expect(
|
||||
store.updatePersistedAtomState(
|
||||
put(local, "d2", "v-other", null, "中文".repeat(25)),
|
||||
),
|
||||
).toMatchObject({ outcome: "invalid", reason: "capacity_exceeded" });
|
||||
expect(entry(store, local)).toBeNull();
|
||||
expect(entry(store)).toEqual(before);
|
||||
});
|
||||
|
||||
it("input/read/result objects cannot mutate the Main-owned record", async () => {
|
||||
const { store } = await createStore();
|
||||
const input = put(),
|
||||
result = store.updatePersistedAtomState(input);
|
||||
if (input.op === "put")
|
||||
(input.draft as { text: string }).text = "external mutation";
|
||||
const read = entry(store);
|
||||
if (read?.state === "draft")
|
||||
(read.draft as { text: string }).text = "read mutation";
|
||||
if (result.entry?.state === "draft")
|
||||
(result.entry.draft as { text: string }).text = "result mutation";
|
||||
expect(entry(store)).toMatchObject({ draft: { text: "rich 中文😀" } });
|
||||
});
|
||||
|
||||
it("notification failures cannot roll back a committed record and events contain no draft", async () => {
|
||||
const { store } = await createStore(),
|
||||
events: PersistedAtomMetadata[] = [];
|
||||
store.subscribe(() => {
|
||||
throw new Error("listener failure");
|
||||
});
|
||||
store.subscribe((event) => events.push(event));
|
||||
expect(store.updatePersistedAtomState(put())).toMatchObject({
|
||||
outcome: "applied",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).not.toHaveProperty("entry");
|
||||
expect(events[0]).not.toHaveProperty("draft");
|
||||
expect(JSON.stringify(events)).not.toContain("rich 中文");
|
||||
});
|
||||
|
||||
it("invalid/accessor requests have no side effects or getter execution", async () => {
|
||||
const { store } = await createStore();
|
||||
let called = false;
|
||||
const request = Object.defineProperty({}, "op", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
called = true;
|
||||
return "consume";
|
||||
},
|
||||
});
|
||||
expect(store.updatePersistedAtomState(request)).toMatchObject({
|
||||
outcome: "invalid",
|
||||
changed: false,
|
||||
storeRevision: 0,
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(
|
||||
store.updatePersistedAtomState({ op: "replace", data: {} }),
|
||||
).toMatchObject({
|
||||
outcome: "invalid",
|
||||
storeRevision: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import {
|
||||
PERSISTED_ATOM_LIMITS,
|
||||
parsePersistedAtomReadQuery,
|
||||
type PersistedAtomLimits,
|
||||
} from "../src/lib/api/persistedAtomProtocol";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type {
|
||||
DraftIdentity,
|
||||
PersistedAtomEnvelope,
|
||||
PersistedAtomMetadata,
|
||||
PersistedAtomMutation,
|
||||
PersistedAtomPersistence,
|
||||
PersistedAtomReadResult,
|
||||
PersistedAtomReadQuery,
|
||||
PersistedAtomReason,
|
||||
PersistedAtomUpdateResult,
|
||||
PersistedDraftEntry,
|
||||
} from "../src/lib/api/persistedAtomTypes";
|
||||
import {
|
||||
PersistedAtomFile,
|
||||
PersistedAtomFileError,
|
||||
type PersistedAtomFileOptions,
|
||||
} from "./persistedAtomFile";
|
||||
import {
|
||||
AtomValidationError,
|
||||
COMPOSER_ATOM_KEY,
|
||||
parseMutation,
|
||||
projectWorkspaceDraftIndex,
|
||||
scopeKey,
|
||||
} from "./persistedAtomRecords";
|
||||
|
||||
interface PersistedAtomStoreOptions extends PersistedAtomFileOptions {
|
||||
readonly limits?: PersistedAtomLimits;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
type MutationPlan = {
|
||||
entry: PersistedDraftEntry;
|
||||
detach: readonly string[];
|
||||
bind?: string;
|
||||
};
|
||||
|
||||
function identityMatches(
|
||||
entry: PersistedDraftEntry | undefined,
|
||||
identity: DraftIdentity,
|
||||
): boolean {
|
||||
return (
|
||||
entry?.draftId === identity.draftId &&
|
||||
entry.scopeVersion === identity.scopeVersion
|
||||
);
|
||||
}
|
||||
|
||||
function detached<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export class PersistedAtomStore {
|
||||
readonly ready: Promise<void>;
|
||||
readonly #file: PersistedAtomFile;
|
||||
readonly #limits: PersistedAtomLimits;
|
||||
readonly #now: () => number;
|
||||
readonly #entries = new Map<string, PersistedDraftEntry>();
|
||||
readonly #bindings = new Map<string, string>();
|
||||
readonly #listeners = new Set<(metadata: PersistedAtomMetadata) => void>();
|
||||
#storeRevision = 0;
|
||||
#persistence: PersistedAtomPersistence = {
|
||||
state: "loading",
|
||||
savedRevision: null,
|
||||
};
|
||||
#available = false;
|
||||
#closed = false;
|
||||
#repairPending = false;
|
||||
#timer: ReturnType<typeof setTimeout> | undefined;
|
||||
#diskQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(appDataRoot: string, options: PersistedAtomStoreOptions = {}) {
|
||||
this.#limits = Object.freeze({
|
||||
...(options.limits ?? PERSISTED_ATOM_LIMITS),
|
||||
});
|
||||
if (
|
||||
Object.values(this.#limits).some(
|
||||
(limit) => !Number.isSafeInteger(limit) || limit <= 0,
|
||||
)
|
||||
)
|
||||
throw new Error("invalid persisted atom budget");
|
||||
this.#now = options.now ?? Date.now;
|
||||
this.#file = new PersistedAtomFile(appDataRoot, this.#limits, options);
|
||||
this.ready = this.#load();
|
||||
}
|
||||
|
||||
metadata(): PersistedAtomMetadata {
|
||||
return {
|
||||
key: COMPOSER_ATOM_KEY,
|
||||
storeRevision: this.#storeRevision,
|
||||
persistence: detached(this.#persistence),
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(listener: (metadata: PersistedAtomMetadata) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => {
|
||||
this.#listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
sendPersistedAtomState(query: unknown): PersistedAtomReadResult {
|
||||
let parsed: PersistedAtomReadQuery;
|
||||
try {
|
||||
parsed = parsePersistedAtomReadQuery(query);
|
||||
} catch {
|
||||
return {
|
||||
...this.metadata(),
|
||||
outcome: "invalid",
|
||||
reason: "invalid_request",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
if (!this.#available || this.#closed)
|
||||
return {
|
||||
...this.metadata(),
|
||||
outcome: "unavailable",
|
||||
...(this.#closed ? { reason: "closed" as const } : {}),
|
||||
data: null,
|
||||
};
|
||||
if (parsed.kind === "workspace-index")
|
||||
return {
|
||||
...this.metadata(),
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: parsed.kind,
|
||||
workspaceId: parsed.workspaceId,
|
||||
items: projectWorkspaceDraftIndex(
|
||||
this.#bindings,
|
||||
this.#entries,
|
||||
parsed.workspaceId,
|
||||
),
|
||||
},
|
||||
};
|
||||
const id = this.#bindings.get(scopeKey(parsed.scope));
|
||||
return {
|
||||
...this.metadata(),
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: parsed.kind,
|
||||
view: {
|
||||
scope: parsed.scope,
|
||||
entry: id ? detached(this.#entries.get(id) ?? null) : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** All CAS checks and Map commits are synchronous; disk IO is separate. */
|
||||
updatePersistedAtomState(value: unknown): PersistedAtomUpdateResult {
|
||||
if (!this.#available || this.#closed)
|
||||
return this.#result(
|
||||
"unavailable",
|
||||
null,
|
||||
this.#closed ? "closed" : undefined,
|
||||
);
|
||||
let mutation: PersistedAtomMutation;
|
||||
try {
|
||||
mutation = parseMutation(value, this.#limits);
|
||||
} catch (error) {
|
||||
return this.#result(
|
||||
"invalid",
|
||||
null,
|
||||
error instanceof AtomValidationError ? error.reason : "invalid_request",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
mutation.op === "put" ? mutation.draftId : mutation.identity.draftId;
|
||||
const current = this.#entries.get(id);
|
||||
const plan =
|
||||
mutation.op === "put"
|
||||
? this.#put(mutation, current)
|
||||
: mutation.op === "promote"
|
||||
? this.#promote(mutation, current)
|
||||
: this.#consume(mutation, current);
|
||||
if ("outcome" in plan) return plan;
|
||||
if (!this.#fits(plan))
|
||||
return this.#result("invalid", current ?? null, "capacity_exceeded");
|
||||
if (this.#storeRevision === Number.MAX_SAFE_INTEGER)
|
||||
return this.#result("invalid", current ?? null, "capacity_exceeded");
|
||||
for (const key of plan.detach) this.#bindings.delete(key);
|
||||
if (plan.bind) this.#bindings.set(plan.bind, plan.entry.draftId);
|
||||
this.#entries.set(plan.entry.draftId, plan.entry);
|
||||
this.#storeRevision += 1;
|
||||
this.#persistence = {
|
||||
state: "pending",
|
||||
savedRevision: this.#persistence.savedRevision,
|
||||
};
|
||||
this.#broadcastPersistedAtomUpdate();
|
||||
this.#schedule();
|
||||
return this.#result("applied", plan.entry, undefined, true);
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (this.#timer) clearTimeout(this.#timer);
|
||||
this.#timer = undefined;
|
||||
if (!this.#available) return this.#diskQueue;
|
||||
const snapshot = this.#snapshot();
|
||||
this.#diskQueue = this.#diskQueue.then(async () => {
|
||||
if (
|
||||
!this.#repairPending &&
|
||||
this.#persistence.savedRevision !== null &&
|
||||
this.#persistence.savedRevision >= snapshot.storeRevision
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await this.#file.save(snapshot);
|
||||
this.#repairPending = false;
|
||||
this.#persistence = {
|
||||
state:
|
||||
snapshot.storeRevision === this.#storeRevision
|
||||
? "saved"
|
||||
: "pending",
|
||||
savedRevision: snapshot.storeRevision,
|
||||
};
|
||||
} catch (error) {
|
||||
this.#persistence = {
|
||||
state: "error",
|
||||
savedRevision: this.#persistence.savedRevision,
|
||||
diagnostic:
|
||||
error instanceof PersistedAtomFileError
|
||||
? error.diagnostic
|
||||
: { phase: "serialize", code: "INVALID_JSON" },
|
||||
};
|
||||
}
|
||||
this.#broadcastPersistedAtomUpdate();
|
||||
});
|
||||
return this.#diskQueue;
|
||||
}
|
||||
|
||||
/** Best effort only. No Quit handshake or sender waits on this operation. */
|
||||
dispose(): void {
|
||||
this.#closed = true;
|
||||
this.#listeners.clear();
|
||||
void this.flush();
|
||||
}
|
||||
|
||||
#put(
|
||||
mutation: Extract<PersistedAtomMutation, { op: "put" }>,
|
||||
current: PersistedDraftEntry | undefined,
|
||||
): MutationPlan | PersistedAtomUpdateResult {
|
||||
const key = scopeKey(mutation.scope);
|
||||
const bound = this.#bindings.get(key);
|
||||
if (current?.state === "consumed")
|
||||
return this.#result("conflict", current, "retired_draft");
|
||||
if (mutation.expectedVersion === null) {
|
||||
if (current || bound)
|
||||
return this.#result("conflict", current ?? null, "identity_mismatch");
|
||||
} else {
|
||||
if (!current || bound !== mutation.draftId)
|
||||
return this.#result("conflict", current ?? null, "identity_mismatch");
|
||||
if (current.scopeVersion !== mutation.expectedVersion)
|
||||
return this.#result("conflict", current, "version_conflict");
|
||||
}
|
||||
return {
|
||||
entry: {
|
||||
draftId: mutation.draftId,
|
||||
scopeVersion: mutation.nextVersion,
|
||||
originScopeKey: current?.originScopeKey ?? key,
|
||||
updatedAt: this.#now(),
|
||||
...(current?.sessionId
|
||||
? { sessionId: current.sessionId }
|
||||
: mutation.scope.kind === "local"
|
||||
? { sessionId: mutation.scope.id }
|
||||
: {}),
|
||||
...(current?.threadId ? { threadId: current.threadId } : {}),
|
||||
state: "draft",
|
||||
draft: mutation.draft,
|
||||
},
|
||||
detach: [],
|
||||
bind: key,
|
||||
};
|
||||
}
|
||||
|
||||
#promote(
|
||||
mutation: Extract<PersistedAtomMutation, { op: "promote" }>,
|
||||
current: PersistedDraftEntry | undefined,
|
||||
): MutationPlan | PersistedAtomUpdateResult {
|
||||
if (
|
||||
!identityMatches(current, mutation.identity) ||
|
||||
current?.state !== "draft"
|
||||
)
|
||||
return this.#result("conflict", current ?? null, "version_conflict");
|
||||
const source = scopeKey(mutation.source),
|
||||
target = scopeKey(mutation.target);
|
||||
const sourceId = this.#bindings.get(source),
|
||||
targetId = this.#bindings.get(target);
|
||||
const targetEntry = targetId ? this.#entries.get(targetId) : undefined;
|
||||
if (targetId && targetId !== current.draftId)
|
||||
return this.#result("conflict", current, "target_conflict");
|
||||
if (
|
||||
mutation.expectedTarget &&
|
||||
!identityMatches(targetEntry, mutation.expectedTarget)
|
||||
)
|
||||
return this.#result("conflict", current, "target_conflict");
|
||||
if (sourceId !== current.draftId && targetId !== current.draftId) {
|
||||
return this.#result("conflict", current, "identity_mismatch");
|
||||
}
|
||||
const sessionId =
|
||||
mutation.sessionId ??
|
||||
(mutation.target.kind === "local"
|
||||
? mutation.target.id
|
||||
: current.sessionId);
|
||||
if (
|
||||
(mutation.target.kind === "local" && sessionId !== mutation.target.id) ||
|
||||
(current.sessionId && sessionId !== current.sessionId) ||
|
||||
(current.threadId &&
|
||||
mutation.threadId &&
|
||||
current.threadId !== mutation.threadId)
|
||||
)
|
||||
return this.#result("conflict", current, "identity_mismatch");
|
||||
const threadId = mutation.threadId ?? current.threadId;
|
||||
if (
|
||||
targetId === current.draftId &&
|
||||
current.sessionId === sessionId &&
|
||||
current.threadId === threadId
|
||||
)
|
||||
return this.#result("applied", current);
|
||||
return {
|
||||
entry: {
|
||||
...current,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
updatedAt: this.#now(),
|
||||
},
|
||||
detach: source === target || sourceId !== current.draftId ? [] : [source],
|
||||
bind: target,
|
||||
};
|
||||
}
|
||||
|
||||
#consume(
|
||||
mutation: Extract<PersistedAtomMutation, { op: "consume" | "clear" }>,
|
||||
current: PersistedDraftEntry | undefined,
|
||||
): MutationPlan | PersistedAtomUpdateResult {
|
||||
if (!identityMatches(current, mutation.identity))
|
||||
return this.#result("conflict", current ?? null, "version_conflict");
|
||||
if (current?.state === "consumed") return this.#result("applied", current);
|
||||
if (!current) return this.#result("conflict", null, "identity_mismatch");
|
||||
const { draft: _draft, ...metadata } = current;
|
||||
return {
|
||||
entry: {
|
||||
...metadata,
|
||||
state: "consumed",
|
||||
consumption: mutation.op === "consume" ? "accepted" : "clear",
|
||||
consumedAt: this.#now(),
|
||||
updatedAt: this.#now(),
|
||||
},
|
||||
detach: [...this.#bindings]
|
||||
.filter(([, id]) => id === current.draftId)
|
||||
.map(([key]) => key),
|
||||
};
|
||||
}
|
||||
|
||||
#fits(plan: MutationPlan): boolean {
|
||||
const snapshot = this.#snapshot();
|
||||
const atom = snapshot.atoms[COMPOSER_ATOM_KEY];
|
||||
const bindings = { ...atom.bindings },
|
||||
entries = { ...atom.entries, [plan.entry.draftId]: plan.entry };
|
||||
for (const key of plan.detach) delete bindings[key];
|
||||
if (plan.bind) bindings[plan.bind] = plan.entry.draftId;
|
||||
if (
|
||||
Object.keys(entries).length > this.#limits.maxEntries ||
|
||||
Object.keys(bindings).length > this.#limits.maxBindings
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const reservedEntries = Object.fromEntries(
|
||||
Object.entries(entries).map(([id, entry]) => {
|
||||
if (entry.state === "consumed") return [id, entry];
|
||||
const { draft: _draft, ...metadata } = entry;
|
||||
const tombstone: PersistedDraftEntry = {
|
||||
...metadata,
|
||||
state: "consumed",
|
||||
consumption: "accepted",
|
||||
updatedAt: Number.MAX_SAFE_INTEGER,
|
||||
consumedAt: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
return [
|
||||
id,
|
||||
Buffer.byteLength(JSON.stringify(tombstone), "utf8") >
|
||||
Buffer.byteLength(JSON.stringify(entry), "utf8")
|
||||
? tombstone
|
||||
: entry,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return (
|
||||
Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
...snapshot,
|
||||
storeRevision: Number.MAX_SAFE_INTEGER,
|
||||
atoms: {
|
||||
[COMPOSER_ATOM_KEY]: { bindings, entries: reservedEntries },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
) <= this.#limits.maxFileBytes
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#result(
|
||||
outcome: PersistedAtomUpdateResult["outcome"],
|
||||
entry: PersistedDraftEntry | null,
|
||||
reason?: PersistedAtomReason,
|
||||
changed = false,
|
||||
): PersistedAtomUpdateResult {
|
||||
return {
|
||||
...this.metadata(),
|
||||
outcome,
|
||||
changed,
|
||||
...(reason ? { reason } : {}),
|
||||
entry: entry ? detached(entry) : null,
|
||||
};
|
||||
}
|
||||
|
||||
#snapshot(): PersistedAtomEnvelope {
|
||||
return {
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: this.#storeRevision,
|
||||
atoms: {
|
||||
[COMPOSER_ATOM_KEY]: {
|
||||
bindings: Object.fromEntries(this.#bindings),
|
||||
entries: Object.fromEntries(this.#entries),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
try {
|
||||
const { envelope, recovered } = await this.#file.load();
|
||||
if (envelope) {
|
||||
const atom = envelope.atoms[COMPOSER_ATOM_KEY];
|
||||
for (const [id, entry] of Object.entries(atom.entries))
|
||||
this.#entries.set(id, entry);
|
||||
for (const [key, id] of Object.entries(atom.bindings))
|
||||
this.#bindings.set(key, id);
|
||||
this.#storeRevision = envelope.storeRevision;
|
||||
}
|
||||
this.#available = true;
|
||||
this.#repairPending = recovered;
|
||||
this.#persistence = {
|
||||
state: recovered ? "pending" : "saved",
|
||||
savedRevision: this.#storeRevision,
|
||||
};
|
||||
if (recovered && !this.#closed) this.#schedule();
|
||||
} catch (error) {
|
||||
this.#persistence = {
|
||||
state: "unavailable",
|
||||
savedRevision: null,
|
||||
diagnostic:
|
||||
error instanceof PersistedAtomFileError
|
||||
? error.diagnostic
|
||||
: { phase: "load", code: "INVALID_STATE" },
|
||||
};
|
||||
}
|
||||
this.#broadcastPersistedAtomUpdate();
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (this.#closed) return;
|
||||
if (this.#timer) clearTimeout(this.#timer);
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#timer = undefined;
|
||||
void this.flush();
|
||||
}, 500);
|
||||
this.#timer.unref?.();
|
||||
}
|
||||
|
||||
#broadcastPersistedAtomUpdate(): void {
|
||||
for (const listener of this.#listeners) {
|
||||
try {
|
||||
listener(this.metadata());
|
||||
} catch {
|
||||
/* Notification failure cannot roll back a committed atom. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,98 @@ describe("electron/preload", () => {
|
||||
exposedGlobals.clear();
|
||||
});
|
||||
|
||||
it("Host persistence commands cross the same invoke boundary", async () => {
|
||||
const api = await loadPreloadApi();
|
||||
expect(api.supportsCommand("persisted_atom_read")).toBe(true);
|
||||
expect(api.supportsCommand("persisted_atom_update")).toBe(true);
|
||||
const args = {
|
||||
key: "composer-drafts-v1",
|
||||
query: {
|
||||
kind: "scope",
|
||||
scope: { workspaceId: null, kind: "home", id: "new-conversation" },
|
||||
},
|
||||
};
|
||||
const result = {
|
||||
key: "composer-drafts-v1",
|
||||
storeRevision: 0,
|
||||
persistence: { state: "saved", savedRevision: 0 },
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: { scope: args.query.scope, entry: null },
|
||||
},
|
||||
};
|
||||
ipcInvoke.mockResolvedValueOnce({ ok: true, result });
|
||||
await expect(api.invoke("persisted_atom_read", args)).resolves.toEqual(
|
||||
result,
|
||||
);
|
||||
expect(ipcInvoke).toHaveBeenCalledWith(
|
||||
IPC_INVOKE_CHANNEL,
|
||||
"persisted_atom_read",
|
||||
args,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["workspace", null])(
|
||||
"workspace-index query and ready data cross the same public IPC",
|
||||
async (workspaceId) => {
|
||||
const api = await loadPreloadApi();
|
||||
const args = {
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "workspace-index", workspaceId },
|
||||
};
|
||||
const result = {
|
||||
key: "composer-drafts-v1",
|
||||
storeRevision: 0,
|
||||
persistence: { state: "saved", savedRevision: 0 },
|
||||
outcome: "ready",
|
||||
data: { queryKind: "workspace-index", workspaceId, items: [] },
|
||||
};
|
||||
ipcInvoke.mockResolvedValueOnce({ ok: true, result });
|
||||
await expect(api.invoke("persisted_atom_read", args)).resolves.toEqual(
|
||||
result,
|
||||
);
|
||||
expect(ipcInvoke).toHaveBeenCalledExactlyOnceWith(
|
||||
IPC_INVOKE_CHANNEL,
|
||||
"persisted_atom_read",
|
||||
args,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preload rejects renderer persistence notifications before sending IPC", async () => {
|
||||
const api = await loadPreloadApi();
|
||||
await expect(
|
||||
api.emit("persisted-atom-update", { state: "saved" }),
|
||||
).rejects.toThrow("Renderer event is not allowed");
|
||||
expect(() => api.send("persisted-atom-update", { state: "saved" })).toThrow(
|
||||
"Renderer event is not allowed",
|
||||
);
|
||||
expect(ipcSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("real Main persistence notifications remain observable and removable", async () => {
|
||||
const api = await loadPreloadApi();
|
||||
const handler = vi.fn();
|
||||
const stop = api.listen("persisted-atom-update", handler);
|
||||
const listener = ipcOn.mock.calls[0][1];
|
||||
const payload = {
|
||||
key: "composer-drafts-v1",
|
||||
storeRevision: 1,
|
||||
persistence: { state: "saved", savedRevision: 1 },
|
||||
};
|
||||
listener({}, { event: "persisted-atom-update", payload });
|
||||
expect(handler).toHaveBeenCalledExactlyOnceWith({
|
||||
event: "persisted-atom-update",
|
||||
payload,
|
||||
});
|
||||
stop();
|
||||
expect(ipcRemoveListener).toHaveBeenCalledWith(
|
||||
"evt:persisted-atom-update",
|
||||
listener,
|
||||
);
|
||||
});
|
||||
|
||||
it("暴露真实 Electron bridge 标记并用 host command 白名单判断支持命令", async () => {
|
||||
const api = await loadPreloadApi();
|
||||
|
||||
|
||||
+13
-3
@@ -1,3 +1,4 @@
|
||||
import { canBroadcastRendererEvent } from "./rendererEventPolicy";
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
ELECTRON_HOST_COMMANDS,
|
||||
@@ -62,9 +63,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
};
|
||||
},
|
||||
emit: async (event: string, payload?: unknown) => {
|
||||
if (!canBroadcastRendererEvent(event))
|
||||
throw new Error("Renderer event is not allowed");
|
||||
ipcRenderer.send(IPC_EMIT_CHANNEL, event, payload);
|
||||
},
|
||||
send: (event: string, payload?: unknown) => {
|
||||
if (!canBroadcastRendererEvent(event))
|
||||
throw new Error("Renderer event is not allowed");
|
||||
ipcRenderer.send(IPC_EMIT_CHANNEL, event, payload);
|
||||
},
|
||||
convertFileSrc: (filePath: string, protocol = "file") => {
|
||||
@@ -77,8 +82,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
return `${protocol}://${encodeURIComponent(filePath)}`;
|
||||
},
|
||||
dialog: {
|
||||
open: (options?: unknown) => ipcRenderer.invoke(IPC_DIALOG_OPEN_CHANNEL, options),
|
||||
save: (options?: unknown) => ipcRenderer.invoke(IPC_DIALOG_SAVE_CHANNEL, options),
|
||||
open: (options?: unknown) =>
|
||||
ipcRenderer.invoke(IPC_DIALOG_OPEN_CHANNEL, options),
|
||||
save: (options?: unknown) =>
|
||||
ipcRenderer.invoke(IPC_DIALOG_SAVE_CHANNEL, options),
|
||||
},
|
||||
shell: {
|
||||
open: (target: string, openWith?: string) =>
|
||||
@@ -154,6 +161,9 @@ function normalizeEvent(event: string, payload: unknown): unknown {
|
||||
return { event, payload };
|
||||
}
|
||||
|
||||
function invokeWindow<T = void>(command: string, ...args: unknown[]): Promise<T> {
|
||||
function invokeWindow<T = void>(
|
||||
command: string,
|
||||
...args: unknown[]
|
||||
): Promise<T> {
|
||||
return ipcRenderer.invoke(IPC_WINDOW_COMMAND_CHANNEL, command, ...args);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IPC_EMIT_CHANNEL } from "./ipcChannels";
|
||||
import {
|
||||
canBroadcastRendererEvent,
|
||||
registerRendererEventHandler,
|
||||
} from "./rendererEventPolicy";
|
||||
|
||||
describe("renderer event broadcast authority", () => {
|
||||
it("allows existing renderer events and rejects Main persistence notifications", () => {
|
||||
expect(canBroadcastRendererEvent("config-changed")).toBe(true);
|
||||
expect(canBroadcastRendererEvent("persisted-atom-update")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not coerce malformed IPC names or execute their getters", () => {
|
||||
const coerce = vi.fn(() => "config-changed");
|
||||
for (const value of [null, undefined, 42, [], { toString: coerce }])
|
||||
expect(canBroadcastRendererEvent(value)).toBe(false);
|
||||
expect(coerce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects forged saved acknowledgements at the Main IPC handler", () => {
|
||||
const on = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
registerRendererEventHandler({ on }, broadcast);
|
||||
expect(on).toHaveBeenCalledWith(IPC_EMIT_CHANNEL, expect.any(Function));
|
||||
const handler = on.mock.calls[0][1];
|
||||
handler({ sender: { id: 42 } }, "persisted-atom-update", {
|
||||
persistence: { state: "saved" },
|
||||
});
|
||||
handler({ sender: { id: 42 } }, null, "malformed");
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
handler({ sender: { id: 42 } }, "config-changed", { theme: "dark" });
|
||||
expect(broadcast).toHaveBeenCalledExactlyOnceWith("config-changed", {
|
||||
theme: "dark",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IPC_EMIT_CHANNEL } from "./ipcChannels";
|
||||
|
||||
/** Persisted-state acknowledgements are emitted only by their Main owner. */
|
||||
export function canBroadcastRendererEvent(event: unknown): event is string {
|
||||
return typeof event === "string" && event !== "persisted-atom-update";
|
||||
}
|
||||
|
||||
type RendererEventListener = (
|
||||
sender: unknown,
|
||||
event: unknown,
|
||||
payload?: unknown,
|
||||
) => void;
|
||||
|
||||
export function registerRendererEventHandler(
|
||||
ipc: { on(channel: string, listener: RendererEventListener): unknown },
|
||||
broadcast: (event: string, payload?: unknown) => void,
|
||||
): void {
|
||||
ipc.on(IPC_EMIT_CHANNEL, (_sender, event, payload) => {
|
||||
if (canBroadcastRendererEvent(event)) broadcast(event, payload);
|
||||
});
|
||||
}
|
||||
@@ -473,6 +473,14 @@ const agentRuntimeCommandSelectors = [
|
||||
"agent runtime 命令只允许集中放在 `src/lib/api/agentRuntime` 网关目录;旧 agent_/agent_ 命令已废弃,禁止在业务模块直接扩散。",
|
||||
}));
|
||||
|
||||
const persistedAtomGatewayCommandSelectors = (
|
||||
agentCommandCatalog.persistedAtomCommands ?? []
|
||||
).map((command) => ({
|
||||
selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`,
|
||||
message:
|
||||
"桌面草稿持久化只允许通过 src/lib/api/persistedAtoms.ts 网关,禁止在业务层散落裸 invoke。",
|
||||
}));
|
||||
|
||||
const projectGatewayCommandSelectors = [
|
||||
"workspace_create",
|
||||
"workspace_get_projects_root",
|
||||
@@ -1028,6 +1036,7 @@ export default [
|
||||
"error",
|
||||
...generalChatCompatCommandSelectors,
|
||||
...agentRuntimeCommandSelectors,
|
||||
...persistedAtomGatewayCommandSelectors,
|
||||
...projectGatewayCommandSelectors,
|
||||
...materialGatewayCommandSelectors,
|
||||
...templateGatewayCommandSelectors,
|
||||
@@ -1139,6 +1148,7 @@ export default [
|
||||
"src/lib/api/serverTools.ts",
|
||||
"src/lib/api/voiceTools.ts",
|
||||
"src/lib/api/appServerTransport.ts",
|
||||
"src/lib/api/persistedAtoms.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
@@ -1148,6 +1158,7 @@ export default [
|
||||
files: [
|
||||
"src/lib/desktop-host/core.test.ts",
|
||||
"src/lib/dev-bridge/safeInvoke.test.ts",
|
||||
"src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
|
||||
@@ -1128,6 +1128,92 @@ Responsible developer confirmation: root, 2026-09-01. Window orchestration owner
|
||||
2026-09-01. Native helper protocol/readiness owner/data flow updated: root, 2026-09-02.
|
||||
Apple Events authorization owner/data flow updated: root, 2026-09-01.
|
||||
|
||||
### 4.3 Desktop persisted atom
|
||||
|
||||
未发送的桌面 UI 草稿保存归 `PersistedAtomHost` / `PersistedAtomStore` / `PersistedAtomFile`,固定使用统一 `AppDataRoot/host-state.json`。它不保存 canonical transcript、Thread/Turn/Item、队列或 runtime 状态;业务持久化仍由 App Server 与 thread-store 承接。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Gateway[Renderer typed persistedAtoms gateway] --> Bridge[Current safeInvoke / preload IPC]
|
||||
Bridge --> Main[Main: lifecycle and delegation]
|
||||
Main --> Host[PersistedAtomHost]
|
||||
Host --> Store[PersistedAtomStore: synchronous CAS and bindings]
|
||||
Store --> File[PersistedAtomFile: serialized IO and atomic rename]
|
||||
File --> Disk[AppDataRoot / host-state.json and .bak]
|
||||
Store --> Metadata[Revision and persistence metadata]
|
||||
Metadata --> Host
|
||||
Host --> Event[Main persisted-atom-update notification]
|
||||
Event --> Gateway
|
||||
```
|
||||
|
||||
`persisted_atom_read` / `persisted_atom_update` 只经 `src/lib/api/persistedAtoms.ts` 的唯一 typed gateway。types、JSON transport parser 与预算归 `src/lib/api/persistedAtomTypes.ts` / `persistedAtomProtocol.ts`;Main 不引入 PM/React。Host 方法名对齐可确认的 `sendPersistedAtomState`、`updatePersistedAtomState` 与 `broadcastPersistedAtomUpdate`,新增类名是 Lime 的领域命名,不冒充安装包原开发源码名称。
|
||||
|
||||
唯一 atom 为 `composer-drafts-v1`。put/promote/consume/clear 在同一 Store 中执行 draftId + opaque scopeVersion CAS;promotion 不改变版本,consume/clear 保留无正文 tombstone,拒绝迟到 put。record/binding 预算各为10,000,单 draft128MiB、完整UTF8 JSON256MiB,整次拒绝保旧,不截断或自动驱逐。应用 revision 与 savedRevision 分开,500ms 合并进入串行落盘;主文件原子替换后再更新 backup,primary 成功而 backup 失败时可能是新 primary / 旧 backup。无效文件恢复失败保持 unavailable,不覆盖为空。
|
||||
|
||||
`rendererEventPolicy` 是 Main handler 与 preload 的同一事件策略,禁止 Renderer 伪造 `persisted-atom-update`。current safeInvoke/HTTP 诊断复用 `invokeDiagnostics`,草稿命令只记录固定 key/op/error 分类,不回显正文、media、scope 或原始错误。原始 transport 错误仍向调用者传播;native notification 订阅失败显式失败。
|
||||
|
||||
Main 只负责组装、命令委托与 dispose;不在大文件添加保存、CAS 或 schema 业务。退出 best effort,不建立 Quit 握手或发送 checkpoint。当前 read 使用唯一 query/data 判别合同:scope 查询或 workspace-index 查询。索引仅投影当前 live binding 的 identity/scope/updatedAt/sessionId?/threadId?,workspace 精确匹配(含null)、updatedAt降序与canonical scope codepoint升序,不返回正文或media,不推进revision,不截断10,000预算内结果。实际编辑目标按scope重读并继续同一CAS。旧scope-only/top-level view已删除,gateway/fixture/preload/tests/guard成套迁移。公开 IPC 的正常重启证据只证明原生边界;GUI 富草稿恢复与 acceptance 的验证在 current 执行计划独立记录。
|
||||
|
||||
Architecture impact: major; Desktop UI persistence owner and IPC boundary added. Architecture diagram confirmation: root, 2026-10-14; Host/gateway/parser/file/event/diagnostic ownership and separation from the canonical App Server chain checked. Product verification remains recorded in the current execution plan.
|
||||
|
||||
Renderer 未发正文的唯一 owner 是 `ComposerController`;`ComposerDraftCoordinator` 只协调 scope、250ms 发布队列、CAS 与接受后消费,codec 复用原 schema/selection/resources。唯一 React owner `useWorkspaceComposerDraftRuntime` 创建/解绑 coordinator,并在目标实际提交后调用 `bindCommitted`。公开编辑目标只有稳定 home/new-conversation 与 canonical local/session,workspace 精确匹配(含 null);原生 metadata index 没有第二个 renderer 草稿发现或标签 owner。编辑门禁同时要求真实 binding ready 和可见目标与绑定 scope 完全一致;首次读取被替代、失败或模式不匹配不能靠 await/finally 开放旧正文。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Surface[Home / Inputbar] --> Controller[ComposerController]
|
||||
Controller --> Coordinator[ComposerDraftCoordinator]
|
||||
Navigation[Committed Home / canonical session target] --> ReactOwner[useWorkspaceComposerDraftRuntime]
|
||||
ReactOwner --> Coordinator
|
||||
Coordinator --> Gateway[Typed persistedAtoms gateway]
|
||||
Gateway --> Host[PersistedAtomHost / Store / File]
|
||||
Surface --> Send[Existing send / queue owners]
|
||||
Send --> AppServer[Canonical App Server JSON-RPC]
|
||||
AppServer --> Receipt[RuntimeSubmissionResult and captured acceptance]
|
||||
Receipt --> Promotion[Captured source promotion]
|
||||
Promotion --> Coordinator
|
||||
```
|
||||
|
||||
发送捕获原 receipt、draftId/version 与 scope,业务请求不等待保存。仅 `runtime-accepted` / `queue-accepted` 触发 promotion 后消费;`local-handled` / `rejected` / `unknown` 保留草稿。旧接受可消费旧存储,但可见 Controller 清理必须仍匹配 generation、scope 和 revision。导航前仅发布旧 scope;成功并匹配启动时 UI epoch 与真实 session/workspace 后提交新目标。同次 topic hydration 的私有 onHydrated 生命周期在 source-current 检查后、自身 scope 提交前更新 presentation,不用 epoch 加一豁免。opaque restore token 只在 renderer 进程内透传,不进入协议、日志、metric 或 transcript。
|
||||
|
||||
责任 root 于 2026-10-15 确认上述更新:Desktop 保存仅管理 UI 草稿,App Server 仍唯一拥有 Thread/Turn/Item 与业务接受事实。随机草稿索引、预热与 materialized 首发路径已删除;binding.ready 是唯一就绪事实,不建立第二个 ready authority。正式组合回归、真实 Electron 富草稿冷恢复及默认门禁按 current 执行计划记录,基础 Host receipt 不能替代它们。
|
||||
|
||||
程序化初始输入(含 Skills 工作台试用)没有 Composer 草稿 settlement;CommandWiring 在既有 sendMessage 收到真实 accepted 后,以捕获的 navigation epoch 提交同一可见 workspace 的 topic,仍由现有 navigation owner 等待真实 session 再绑定。该路径不 consume Home 草稿;Composer 自身发送继续走原 captured identity promotion/consume。非接受、迟到或不可见回执不能越过 ready/scope 门禁。此为既有4.3链路的接线修复,非新增 authority;root,2026-10-15确认。
|
||||
|
||||
### 4.4 Desktop 设置章节
|
||||
|
||||
`src/components/settings-v2/sections.tsx` 是 current 设置章节的唯一登记 owner,派生导航、可用性、预加载与内容渲染。公开入口直接导出已确认的 `SettingsPage`;category hook 只做翻译分组,layout 只组装容器和 Suspense。Home 保持同步入口,由 layout 注入 render factory,避免 Home/category/sections 循环依赖。业务页继续拥有各自的编辑与保存逻辑。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Route[Existing PageParams] --> Page[SettingsPage]
|
||||
Sections[settingsSections] --> Category[Translated navigation groups]
|
||||
Sections --> Page
|
||||
Category --> Navigation[SettingsNavigation]
|
||||
Navigation --> Page
|
||||
Page --> Business[Existing settings page owners]
|
||||
```
|
||||
|
||||
`SettingsPageV2` / `SettingsLayoutV2`、重复章节名单及无消费者 `SETTINGS_GROUPS` / `ExecutionTracker` 已删除。既有 Experimental 只委托 Developer;providerView/focus 与 executionPolicyFocus 保真。内部章节的 canonical 路由、返回上下文、内容搜索与 dirty authority 尚待后续切片,不新增平行导航或业务 runtime。责任 root 于 2026-10-15 确认此图;正式回归和 GUI 状态见 current 执行计划。
|
||||
|
||||
### 4.5 Desktop 公开新建会话
|
||||
|
||||
`useAppNavigation.startNewConversation` 是唯一公开动作,typed intent 明确保留 null/project identity 与 Home 恢复或 New fresh 意图。AppPageContent 只在真实 route/request 提交后交付请求;requested preview 不能清稿。root draft runtime 提交稳定 Home scope 与单调 epoch,同一 Coordinator 在有效 hydration 后执行受保护的 identity CAS,只有 applied 才清空 Controller。冲突、不可用、再次导航或新编辑均保稿;fresh 请求不进入 reload restore,避免冷启动重放。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Public[Home / Search New / Project New] --> Action[startNewConversation]
|
||||
Action --> Route[Existing App navigation commit]
|
||||
Route --> Root[Root draft runtime and exact scope]
|
||||
Root --> Coordinator[ComposerDraftCoordinator]
|
||||
Coordinator --> CAS[Existing persisted atom clear CAS]
|
||||
CAS -->|applied and unchanged| Controller[Controller clear]
|
||||
Controller --> Send[Existing typed first submission]
|
||||
Send --> Receipt[App Server canonical acceptance]
|
||||
Receipt --> Local[Canonical local scope and source consume]
|
||||
```
|
||||
|
||||
cancelable 新建事件、随机 draftTabs/discovery、新页 owner、1ms 输入预热、materialized 首发和 general 第二层 strip 已直接删除;canonical session、Canvas、image workbench 与后台 queue 继续走既有 owner。Home `freshDraft=false`、New `true` 是 Lime 的明确决策;REA 确认公开导出名与静态职责,普通 Codex sidebar 的运行时 fresh 行为仍 unknown。责任 root 于 2026-10-15 确认此图,正式回归和真实 Gate 状态见 current 执行计划。
|
||||
|
||||
## 5. TypeScript Package 规范
|
||||
|
||||
| Package | 职责 |
|
||||
@@ -1202,6 +1288,14 @@ flowchart TD
|
||||
Chat[Conversation ComposerEditor] -->|Transaction| Controller
|
||||
Controller -->|state / revision / snapshot| Home
|
||||
Controller -->|state / revision / snapshot| Chat
|
||||
Controller --> Registry[CodeBlockNodeViewRegistry]
|
||||
Registry --> NodeView[CodeBlockNodeView: toolbar container + PM code contentDOM]
|
||||
Registry --> Portal[React CodeBlockToolbar Portal]
|
||||
Portal -->|language attrs Transaction| Controller
|
||||
Controller --> Highlight[PM highlight plugin: DecorationSet]
|
||||
Highlight --> NodeView
|
||||
Controller --> Auto[PM Auto plugin: generation + document identity]
|
||||
Auto -->|no-history attrs Transaction| Controller
|
||||
Controller --> Receipt[Submit receipt: revision + draft + submission]
|
||||
Receipt -->|draft: original JSON + selection + resources| Restore[Restore / local rich history]
|
||||
Restore --> Controller
|
||||
@@ -1215,9 +1309,15 @@ flowchart TD
|
||||
|
||||
Workspace 的 `useAgentChatWorkspaceLocalDisplayState` 持有同一个 Controller,原 `input` string store 被 snapshot 派生值替换;`setInput` 仅为 text adapter。首页 materialization 保留该 owner 与 revision,显式换会话/new-chat/back-home 使用 clear 重置完整草稿并推进 revision;clear/restore 即使内容相同也强制新生命周期,普通同值 text update 保持无更新。附件 adapter 直接消费 Controller resources,能力选择仅把 UI 候选详情映射到 Controller 的 canonical route,禁止新 surface 的空镜像覆盖原资源。路径侧栏的 `usePathReferences` 同步读写同一 Controller 并从 snapshot 派生,不保留 path useState 或 passive effect 镜像。资源恢复由原始 draft 一次更新;历史回放不再 clear/add 资源导致导航游标失效。
|
||||
|
||||
真实接受沿 `turn/start|turn/steer response -> runtimeAcceptance -> raw send -> typed observer / RuntimeSubmissionResult -> surface settlement` 传播。`RuntimeAcceptanceReceipt` 的 session/thread 在异步前捕获,turn取真实响应;接受observer先于UI发布/统计/refresh结算,后处理失败不能撤销已接受事实。外层只接受 `runtime-accepted` 或真实 `queue-accepted`,`local-handled`、`rejected`、`unknown` 不消费草稿。queue receipt是实际QueuedSubmission与请求前canonical context,不伪造成turn/start或由boolean/void猜测接受。该类型合同属于Renderer既有send owner,未新增AppServer wire字段。Architecture diagram confirmation: root, 2026-10-14; acceptance receipt flow and canonical ownership checked.
|
||||
|
||||
`draft` 保留折叠 paste、原始 JSON、link/code 属性、UTF16 选择区、图片、路径和能力供恢复;`submission` 将链接与代码块序列化为 Markdown、展开 paste 并携带 typed TextElement/inputMentions,唯一决定发送内容。代码 fence 长度为正文最长连续反引号数加一且至少三个,正文空白原样保留。literal link 只有在完整安全链接的 label/href/title 均匹配时用于草稿重放;编辑清除旧 literal/occurrence/autolink 元数据。ProseMirror position、显示文本 UTF16 offset 与最终 Markdown 发送文本 UTF8 byteRange 分别映射,不混用。annotation 更新通过 PM Transaction 保留富文档与资源;HTML/Markdown paste 进入同一 Controller,code 中 Enter/ShiftEnter 插入文本换行。`composerDraft`/`composerSubmission` 显式贯通 start/steer/queue;无法映射的 textElements 在 IPC 前拒绝,不能猜 range 或退回纯文本。Controller 不拥有 Thread/Turn/Queue 事实;workspace 依据 canonical read model 解析 intent 并在真实接受结果结束前持有发送锁。失败保留完整 draft;成功只在 receipt revision 未变化时 commit,迟到结果不清新草稿。提交瞬间直接捕获当前 canonical 文档,不把旧 React 闭包的资源回灌;能力初始化与恢复请求按同 Controller identity 去重,跨 surface 重挂载不重放旧请求。
|
||||
|
||||
Queue mutation 进入 Codex v2 `thread/queue/*`,`thread/queue/changed` 或显式 refresh 只触发 canonical queue 重读;Renderer 不建第二 queue facade、乐观删除或 runtime。现有 `@tiptap/pm` 提供 schema/state/view/history/keymap,不新增依赖或 public JSON-RPC 字段。Architecture impact: major; Renderer document/transaction and cross-surface draft owner converged. Architecture diagram updated: Composer flow above. Responsible developer confirmation: root, 2026-10-14; directory, ownership, transaction/submission/restoration flow and protocol boundary checked. Runtime verification remains tracked in the current execution plan.
|
||||
Queue mutation 进入 Codex v2 `thread/queue/*`,`thread/queue/changed` 或显式 refresh 只触发 canonical queue 重读;Renderer 不建第二 queue facade、乐观删除或 runtime。现有 `@tiptap/pm` 提供 schema/state/view/history/keymap;代码块增加直接依赖 `lowlight 3.3.0`、`highlight.js 11.11.1`、`jsonc-parser 3.3.1`,不新增 public JSON-RPC 字段。Architecture impact: major; Renderer document/transaction and cross-surface draft owner converged. Architecture diagram updated: Composer flow above. Responsible developer confirmation: root, 2026-10-14; directory, ownership, transaction/submission/restoration flow and protocol boundary checked. Runtime verification remains tracked in the current execution plan.
|
||||
|
||||
代码块的唯一视图工厂归 Controller 持有的 `CodeBlockNodeViewRegistry`;Editor 创建 NodeView,React 仅向不可编辑 toolbar container 建 Portal。正文和高亮 span 均由 PM contentDOM/Decoration 管理,不用 `innerHTML` 或第二份 React document 替换正文。`composerState` 只装配 EditorState/plugins 和投影资源,不持有另一个状态 store;文档输入/配置类型统一归 `types`,旧 Controller type export 已直接迁净。有效 rich/code/plain 配置决定 NodeView 与两个独立 plugin 的装配,外部注入 Controller 不被 hook 强加 defaults。
|
||||
|
||||
高亮按位置和 `Node.eq` 缓存、映射移动块,顶层 HAST scope 转为 UTF16 inline Decoration。raw parser 的 53 grammar/5 alias 与 GUI 41 项目录/21 alias 分开,Auto subset 为 39 项。Auto 延迟150ms、按 Node identity缓存,并在异步结果前复核 generation/同 doc/未销毁;结果写 attrs但不进入 undo history。纯 Decoration/meta 更新 state/View,保留同一 draft snapshot/revision与receipt;真实 attrs/selection/正文改变才更新草稿。clear/restore 同值也创建 fresh plugins,旧 View 异步结果失效;View 卸载注销 entries,但不永久销毁可重挂载的 Controller registry。旧 schema `toDOM` 仅保留 serialization/clipboard 责任;主 GUI 及 fixture 已直接迁到 NodeView DOM,无 compat 视图。Architecture diagram confirmation: root, 2026-10-14; registry、插件、Portal、资源、receipt、生命周期与既有 App Server 边界逐项确认。正式产品验证在 current 计划独立记录。
|
||||
|
||||
Composer 纯文本历史只允许沿 `Composer -> typed AppServerClient -> app_server_handle_json_lines -> App Server promptHistory/read|append -> RuntimeCore PromptHistoryStore` 流动;Electron 仅转发 JSON-RPC,Renderer 不得以 `localStorage`、SQLite 或旧 Desktop facade 建立第二事实源。`PromptHistoryStore` 使用受控数据根下的 append-only `prompt_history.jsonl`、文件锁和 4 MiB 保留上限,坏行跳过但保留 offset;macOS/Linux 的 `logId` 使用 inode,Windows 使用文件 creation time,用于分页 cursor 的快照一致性。读取返回 newest-first bounded pages,客户端在 Composer 内按 oldest-first 合并;异步读取完成时只合并服务端快照与本地新提交的边界重叠,不覆盖已产生的 draft/history。只追加成功提交的正文和 canonical Thread identity:公开 append 为 `{threadId,text}`,entry 返回 `threadId`,内部 JSONL 的 `session_id` 字段记录同一个 Thread ID。TUI 选定 Skill 元素可经 `mention_codec` 编码为 path link,再从同一 history owner 恢复;附件、普通路径引用与大粘贴 payload 仍为会话内 rich state,不把正文 codec 冒充完整附件持久化。`promptHistory/read|append` 是稳定 v2 方法,不要求 `initialize.capabilities.experimentalApi`。Architecture impact: major; persistent history owner and cross-platform identity semantics fixed. Responsible developer confirmation: root, 2026-09-01.
|
||||
|
||||
|
||||
@@ -48,6 +48,25 @@ Electron `safeStorage` 加密后落在统一 `AppDataRoot/credentials`,状态
|
||||
query、Debug 或日志;safeStorage 不可用时必须 fail closed。该 owner 只是 Cloud
|
||||
transport foundation,不会启用默认远端 App Server,也不替代 App Server JSON-RPC。
|
||||
|
||||
## Desktop persisted atom
|
||||
|
||||
桌面未发送 UI 草稿只允许:
|
||||
|
||||
```text
|
||||
Renderer -> src/lib/api/persistedAtoms.ts
|
||||
-> current safeInvoke -> preload / persisted_atom_read|persisted_atom_update
|
||||
-> Main delegate -> PersistedAtomHost -> PersistedAtomStore -> PersistedAtomFile
|
||||
-> fixed AppDataRoot/host-state.json
|
||||
```
|
||||
|
||||
read 为 `{key:"composer-drafts-v1",query}`,query 判别为 `{kind:"scope",scope}` 或 `{kind:"workspace-index",workspaceId}`;ready 结果分别为 `data:{queryKind:"scope",view}` 或 `data:{queryKind:"workspace-index",workspaceId,items}`,invalid/unavailable 为 data:null。update 为 `{key,mutation}`,仅支持 put/promote/consume/clear。read/update 结果与 `persisted-atom-update` notification 必须经过同一纯 JSON parser。notification 仅含 revision / persistence metadata,无草稿正文;Renderer emit/send 与 Main IPC handler 拒绝伪造该事件。旧 scope-only/top-level view 已直接删除。索引精确匹配 workspace(含null),只发现当前 live binding,返回 scope/identity/updatedAt/sessionId?/threadId?,不读正文、不改变 revision、不截断。选中后重新读scope并执行原CAS。
|
||||
|
||||
`PersistedAtomStore` 为唯一 CAS/binding owner,`PersistedAtomFile` 为唯一原子保存/backup owner。typed gateway 之外禁止裸 invoke/listen;catalog、preload 白名单、实际 handler、ESLint restriction 与 command guard 同批同步。safeInvoke 与 HTTP 诊断由同一 `invokeDiagnostics` 承接,保存参数和任意原始错误正文不进入 trace/console。
|
||||
|
||||
该能力属于 Desktop UI 保存,不能承接业务 session、canonical transcript、队列、App Server runtime 或 Provider 请求;不新增 JSON-RPC method、CLI/TUI 草稿后端或生产 mock。Host ready 仅证明加载完成,savedRevision 才证明该 revision 已落盘;发送和退出不增加持久 checkpoint 或等待握手。真实正常重启仅证明 Host/IPC/file 基础能力,GUI 富草稿恢复仍需另验。
|
||||
|
||||
GUI send 的本地返回统一为 `RuntimeSubmissionResult`:`runtime-accepted` / `queue-accepted` / `local-handled` / `rejected` / `unknown`。start/steer 的接受从真实 response 捕获,queue 从真实 QueuedSubmission 投影;后处理失败不能撤销接受。composer capture、restore token 与导航 epoch 都是 renderer 本地能力,不增加 App Server wire 字段,也不以 void/boolean 或 dispatched 猜测接受。TaskCenter 请求保留原 capture,发送驱动的 materialization 禁止提前 promotion;接受后由唯一 React owner 使用 receipt 的 sessionId/threadId 先 promotion 再 consume。
|
||||
|
||||
## TUI/CLI 与 Cloud transport 边界
|
||||
|
||||
TUI 推理显示只消费同一协议的 summary/summaryIndex;原始 content 默认隐藏。摘要
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- 路线图主线:`internal/roadmap/`
|
||||
- Runtime 当前对齐入口:`internal/aiprompts/architecture.md` 与 `internal/exec-plans/codex-alignment-v1-coordination-plan.md`
|
||||
- Refactor v2 全项目 Gate A/B 验收计划:`internal/exec-plans/project-gate-a-b-acceptance-plan.md`
|
||||
- 当前发布执行计划:`internal/exec-plans/release-v1.148.0-plan.md`
|
||||
- 当前发布执行计划:`internal/exec-plans/release-v1.158.0-plan.md`
|
||||
- 已结束版本的发布过程不保留在 active tree;结果以 Git tag、GitHub Release、Release Notes 与 Git history 为准。
|
||||
- Codex App GUI 历史基线及领域计划索引:`internal/exec-plans/codex-app-gui-alignment-plan.md`;GUI current 实施统一见下方全面对齐计划。
|
||||
- Codex Desktop 跨平台底层对比与对齐计划:`internal/exec-plans/codex-desktop-platform-parity-plan.md`
|
||||
|
||||
@@ -1,632 +1,113 @@
|
||||
# Codex Desktop 与 Lime GUI 全面对齐
|
||||
|
||||
状态:active / partial。校准日期:2026-10-14。
|
||||
状态:active / partial。校准日期:2026-10-15。
|
||||
|
||||
## 主目标与参考
|
||||
## 目标与事实源
|
||||
|
||||
使用 REA 分析指定 Codex Desktop 安装包,直接对齐可确认的 UI、交互、代码结构、目录职责、函数、类型和类;同时迁移或删除被替代的代码、文档、测试、fixture、snapshot、护栏和白名单。只保留 Lime 主题、多模型支持、首页 Banner 三项差异,不为旧规则、旧测试或未知实现新增保留项。
|
||||
使用 REA 对齐可确认的 GUI、交互、目录职责、函数、类型、类与调用链,同时直接迁移或删除旧代码、文档、测试、fixture、snapshot 和冲突护栏。仅保留 Lime 主题、多模型支持、首页 Banner,分别继续走 token/skin、catalog/provider 和 Home presentation owner。
|
||||
|
||||
- GUI 参考:`/Applications/ChatGPT.app`,Bundle ID `com.openai.codex`,版本 `26.1007.30912`,build `20156`。
|
||||
- 参考安装包:`/Applications/ChatGPT.app`,实际为 Codex Desktop,Bundle ID `com.openai.codex`,版本 `26.1007.30912`,build `20156`。
|
||||
- ASAR SHA-256:`75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。
|
||||
- REA 安装:`~/.local/bin/rea` 6.4.0;独立 Node 24.21.0。原始 Evidence 与提取包位于 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/`。
|
||||
- 共享业务语义:`/Users/coso/Documents/dev/rust/codex` current 源码,已核对 revision `a9b0815167c27ab13719dd12d13b63e02b70d221`;下次实现前复核。
|
||||
- 2026-10-14 复核公开仓库 HEAD 已为 `31434401fa3cd111866acdc1bba2a73b561f53d5`。安装包 CLI manifest 为 `0.162.0-alpha.17.2`;Desktop Host 与公开 TUI 均有 App Server 路径,精确构建等价仍 unknown,不能将最新 checkout 当作已安装二进制源码。
|
||||
- [研究报告](../research/codex-desktop-rea/report.md) 与 [证据索引](../research/codex-desktop-rea/evidence-index.json) 供复用,不能当作 Lime 产品已实现证据。
|
||||
- REA:6.4.0,独立 Node 24.21.0;当前安装、注册与 Evidence 身份见[索引](../research/codex-desktop-rea/evidence-index.json)。
|
||||
- 公开 runtime 源码:`/Users/coso/Documents/dev/rust/codex`,最近已核 HEAD `31434401fa3cd111866acdc1bba2a73b561f53d5`;安装包附带 CLI 为 `0.162.0-alpha.17.2`,精确构建等价 unknown。公开仓库不含 Desktop 前端。
|
||||
- 唯一业务链:`Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`。GUI、CLI/TUI 共享协议、runtime、工具和持久化 owner。
|
||||
- 研究入口:[差距报告](../research/codex-desktop-rea/report.md)、[结构与命名账本](../research/codex-desktop-rea/structure-alignment-ledger.md)、[证据索引](../research/codex-desktop-rea/evidence-index.json)。
|
||||
|
||||
本页仅维护当前进度、写集、退出条件与下一刀。已执行合同、每轮失败和原身份保存在[历史快照](codex-gui-alignment-history-20261015.md)及领域审计/收据,历史清单不再作为 current 指令。2026-10-14 用户删除旧 `.lime/` 后缺失的证据不能恢复为有效原件;本轮重新建立的 freeze/packet 独立记录。
|
||||
|
||||
## 当前阶段与写集
|
||||
|
||||
当前阶段:审批、Files/diff、时间线、队列动作身份、Header、设置通知、ComposerUtilityBar、公共 pending Queue 与 Browser tab 恢复均已迁移并取得各自定向与真实 Gate B。结构化主 Composer 的既有切片已关闭。长 UserMessage 折叠已补 canonical 去重、原生复制权限、动态测量、scrollTop clamp 与手动阅读视口竞争的先红回归;前轮默认门禁126/126批及7个Rust crate3179/3179均通过。rich第五轮69/69、long-list第六轮/冷启动与live-tail65/65通过;resize/reflow页头64px/46px越界及consumer placement已修复,40项诊断/几何、57项页头回归、真实fixed-5三尺寸66/66及标准typecheck通过。新默认门禁85批发现复制反馈卸载泄漏;已修复,五文件69/69、lint/typecheck、默认127/127批及fresh复制Gate69/69通过。长UserMessage合同100%关闭,证据按renderer版本分别保存。基础link/code已迁入实际input-kit,22红/8绿后20文件224/224通过;dictation接线及单路径清理已实施,72项回归通过。本批新Electron和默认门禁正在关闭,persistent draft、Code NodeView和完整产品账本仍未完成。旧默认绿不覆盖本批新产品写集。
|
||||
主目标仍为全面对齐。Composer 富输入、草稿恢复、新建导航和 Skills 接线的产品验证已关闭;最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭;下一刀为设置 canonical 路由与返回,设置产品源码尚未改动。
|
||||
|
||||
规则写集:两个 skill、设计 skill UI metadata、skill 索引、`design-language.md`、`quality-workflow.md`、Verification Contract 的 current method 示例、`.gitignore` 精确白名单与本计划。设计 skill 原先被忽略,加入版本控制准入,避免规则仅保存在本机。
|
||||
本轮产品行为沿用已采用的 current owner:非 Composer accepted 回执进入既有可见 topic commit;Agent 文案复用 createI18n 五语言表。epoch、scope、binding.ready、revision、CAS、accepted 消费和新编辑保护保持有效。没有新增业务 runtime、read model、cache authority 或 compat。
|
||||
|
||||
后续产品写集在每个切片实现前登记。避让已有 App Server/agent/tool-runtime、命令 catalog/executor、五语言 `agent.json` 与未知未跟踪改动;不提交、推送、重置或创建分支。
|
||||
本轮测试写集:
|
||||
|
||||
规则更新不改变实际 owner、依赖方向或门禁实现;架构影响为非重大。后续若改变目录/owner/协议或数据流,必须同步 `architecture.md` 并填写责任开发者确认,不能沿用本次非重大判断。
|
||||
- current fixture 退出护栏:`claw-chat-current-fixture-smoke.mjs` 仅接入独立 teardown helper 与相邻行为测试,`current-fixture-regression-smoke.mjs` 纳入该回归,复用既有 `captureMainProcessExit/quitMainProcessNormally` owner。只有真实 Main code0/signalnull 和全部资源释放后打印 pass;15秒正常退出期限不放宽,强制回收只允许失败后且永不计入正常退出。巨型 smoke 入口仅替换 finally 接线,不继续堆叠收尾业务;后续仍须拆分原入口职责。
|
||||
- `useAgentRuntimeSyncEffects.test.tsx`:旧 turn/start 回执迁 `turn.id/inProgress`,hook 与通知共享 canonical thread identity。
|
||||
- `useWorkspaceSendStructured.unit.test.tsx`:boolean/void 夹具迁完整 typed accepted/rejected,保 dispatch lock、富稿与新编辑保护。
|
||||
- `useWorkspaceResetRuntime.unit.test.tsx`、`newConversationPublicNavigation.integration.test.tsx`:覆盖根导航委托及编辑后的富稿保存/恢复;重复 `workspaceComposerReset.integration.test.tsx` 删除。
|
||||
- `Inputbar/index.test.tsx`:六个成功、一个拒绝和一个延迟接受夹具迁 typed result,原 recent usage/恢复/元数据断言保留。既有大测试文件只做夹具窄改;不再增加业务分支,职责拆分另刀。
|
||||
- 本计划、历史快照、研究报告/审计/索引及 `.gitignore` 精确文档准入。
|
||||
|
||||
## 对齐账本
|
||||
沿用此前认领的翻译键清理、CommandWiring 与文案资源修复;不覆盖未知 release plan、`local-conversation-page-a83a1af2c24b.js`、`tsconfig.node.tsbuildinfo` 或媒体设置实现。没有提交、推送、重置或创建分支。
|
||||
|
||||
| 能力 | 参考 | Lime current owner / 待办 | 清理与验证状态 |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 设计与质量规则 | 用户三项差异、current 架构与 package scripts | 两个 skill 与领域事实源 | 完成;validator、引用、脚本与文档守卫通过 |
|
||||
| 长历史与流式时间线 | `virtualized-turn-list`、`thread-virtualizer` 包模块 | `threadVirtualizer` / `VirtualizedTurnList`;canonical paging 与 DOM range 分开 | 已直接替换;29 文件 / 318 tests;真实滚动与冷恢复 Gate B 通过 |
|
||||
| 待处理审批 | pending approval 包组件及 Codex server request/decision 合同 | `PendingRequestItemPanel` → `ApprovalCard`,复用现有 approval authority | 已替换;146 首轮 tests +追加回归通过,Gate B 待聚合 |
|
||||
| Files → diff | review tree、file-diff 与 side panel 包模块 | `ReviewThreadSidePanelTab` → 同一 changeView / ChangesPanel / RightSurface | 已替换;真实四文件 diff/选择/定位 Gate B 通过,整体视觉仍待核对 |
|
||||
| 导航、页头、Composer、队列 | 对应 Desktop 模块与 shared Thread/Turn/Item | 已有 sidebar/header/composer/queue owner | 已有能力须重新核对行为与命名,不能仅按旧计划判完成 |
|
||||
| Queue mutation / Header identity | `QueuedMessageList` confirmed export、Thread scoped actions | 旧 ThreadQueueStatus 已迁净;public pending visibility 与既有 typed queue/store | 内部Item/媒体/动作回归通过;public history/paging74、搜索17、rollout恢复23;新版真实pending Gate B通过,旧 transcript Gate已失效 |
|
||||
| ComposerUtilityBar | REA confirmed export、project/run-location/branch 分工 | 共享 utility + keyed mutation actions,删除旧组件/未消费透传 | 12文件343 tests;真实两项目选择、branch create/checkout、冷重启与Git readback Gate B通过;staged worktree/remote待办 |
|
||||
| 结构化主 Composer | REA 主模块、initial Controller/keymap/View | 唯一 EditorState + EditorView;原始 draft / typed submission;共享 workspace scope | 当前切片关闭:rich68/68、聚合22/22、完整默认门禁通过;marks/persistent/voice 未关闭 |
|
||||
| 长用户正文折叠 | REA collapsible-user-message-content、20行自然高度合同 | 两入口共享正文显示组件;现有Markdown、row测量与滚动owner | 已验收;guard39/39、默认127/127批、rich69/69、long-list/冷启动、live-tail65/65及resize66/66通过;证据分版本 |
|
||||
| Thread 设置通知消费 | shared `thread/settings/updated` canonical settings | EventBus → scoped adapter → 现有 preference/threadRead cache → model/header | 120 定向 tests + production consumer 8;idle model/cwd Gate B 通过,已迁 known_projected;不声明 active/future Turn 执行 |
|
||||
| 右侧面、Browser、terminal、artifact、subagent | 已提取 side-panel 与 surface 模块 | 单一 RightSurfaceHost/registry、scoped local state与既有native owner | scoped生命周期73、完整tab恢复82、actual sender41项测试通过;双tab/primary关闭不复活的真实Gate B通过;其他surface继续核对 |
|
||||
| 设置、Skills、MCP、插件、自动化 | 对应包模块与公开协议 owner | 既有设置与 App Server current owner | 待完整清单与行为/结构核对 |
|
||||
| diff/editor、Office/专业文件、原生操作、环境/remote | 对应包与平台证据 | 既有 artifact、document-preview、Desktop Host、environment、transport owner | 待查证/实现;不自动排除,不复制业务后端 |
|
||||
## 已采用切片与证据边界
|
||||
|
||||
每行继续拆成具体“上游符号 → current 符号 → 调用者 → 行为 → 删除项 → 测试/护栏 → 证据”的映射。压缩局部变量与无 source map 的开发源码名称记 unknown。
|
||||
| 范围 | current owner / 迁移 | 已取得证据与剩余范围 |
|
||||
| ----------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 设计/质量规则 | 两个 Lime skill 与领域事实源 | 已按三项保留差异、结构命名和风险门禁更新;不代表产品全面完成 |
|
||||
| 长历史/用户正文 | threadVirtualizer、VirtualizedTurnList、共享正文显示 | 原身份真实滚动、动态测量、冷恢复和长正文 Gate 已过;不是完整视觉等价 |
|
||||
| 审批/Queue | PendingRequestItemPanel、ApprovalCard、QueuedMessageList | 同一 server request/decision/queue authority;pending、history/paging/search、图片真实读取有对应证据;自动审查及全部变体待核 |
|
||||
| Files/diff/右侧面 | ReviewThreadSidePanelTab、ChangesPanel、RightSurfaceHost | 四文件 diff/定位与 Browser 双 tab/关闭不复活已验收;其他 editor/surface 完整行为待核 |
|
||||
| Composer/NodeView | ComposerController、EditorView、NodeView registry/toolbar/plugins | 单一正文、结构化输入、语言/高亮与 rich Gate 已采用;原类名未知的项目不搬压缩名 |
|
||||
| 持久草稿/新建导航 | Coordinator、Host persisted atom Store/File、useAppNavigation | packet21 三入口首发、rich Stop、Home 冷恢复、三工作区两正常冷恢复四合同通过,均绑定原源码/产物 |
|
||||
| Skills accepted | CommandWiring -> existing visible topic commit | packet22 三路径及 current22通过;最终新构建结果另记 |
|
||||
| 设置第一切片 | 单一 sections、公开 SettingsPage | 九源正式采用,重复名单/SETTINGS_GROUPS/ExecutionTracker/V2 aliases 删除;15章节/24首屏/157原生读通过,路由/返回/search/dirty待交付 |
|
||||
| Thread settings | scoped EventBus/adapter/既有 cache | idle model/cwd 已验收;不推导 active/future Turn 执行 |
|
||||
|
||||
## 验证合同
|
||||
完整映射、调用者、删除项与 Evidence ID 由结构账本和领域审计持有;上表不按同名文件数计算完成度。
|
||||
|
||||
规则更新:`budget:tight`,仅格式、链接、命令存在性、相关文档守卫与 diff 检查。无产品代码变化时不启动 Electron 或全量 Rust,也不形成产品/release evidence。
|
||||
## 当前验证状态
|
||||
|
||||
产品切片:实现前按模板补齐入口、网关、IPC、public method、domain owner、read model、事件、GUI、happy path、失败终态与测试资产。仍沿唯一 `Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection` 主链。
|
||||
| 验证 | 结果 | 身份或限制 |
|
||||
| -------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| packet21 四项产品合同 | 4/4通过 | first-send8、rich Stop、Home7、local17;7885源/2093产物零漂移;旧正常退出超时根因仍unknown |
|
||||
| packet22 current 聚合第4轮 | 22/22通过 | 定向31/34/3/294,7885源/2198产物零漂移;不能覆盖后续修改 |
|
||||
| 五语言废弃设置键清理 | 42/42通过 | 只删无消费者键,loader/type tests迁current键 |
|
||||
| CommandWiring稳定函数引用 | 61/61、严格lint通过 | 原窄lint曾exit0带warning,纠正收据保留;后续标准全src lint零warning |
|
||||
| 文案资源热路径 | 81/81通过 | 先3过1行为红;复用既有资源;historyWindow原14项和5秒期限未改,默认89批147/147通过 |
|
||||
| 本轮旧夹具迁移 | 60/60、24/24、16/16、113/113通过 | 分别为通知、Workspace发送、导航覆盖归并、Inputbar;原默认失败日志保留 |
|
||||
| 默认前置检查 | 通过 | 第4轮版本、五语言结构/unused、全src零warning lint、112源文案扫描、标准typecheck |
|
||||
| 前端默认续跑 | 132/132批通过 | 1761历史登记路径,现存1756、已删5;原passed批保留,修改的早期owner另跑定向;不是所有批都在本轮重新执行 |
|
||||
| contracts / legacy | 通过 | 标准test:contracts、legacy report;无边界违规 |
|
||||
| 新标准GUI构建/smoke | 24/24通过、标准wrapper exit0 | run codex-composer-final-shell-20261015-1;freshness全ready,2303目录产物 |
|
||||
| 退出护栏与原审批隔离 | 110/110、66/66通过 | 6项新行为回归;Main34547正常code0/signalnull,327ms,未强制回收。仅fixture变化,不能推导原挂起根因 |
|
||||
| 新产物完整current22 | 第7轮22/22、入口exit0 | 四组31/34/3/300;21个Claw正常Main回执、强制回收0;Coding仅保留既有子进程证据。7886源/2303产物和产物路径集零漂移;67份本轮证据独立冻结,旧性能/退出失败根因仍unknown |
|
||||
|
||||
默认顺序:定向行为测试 → 受影响 current fixture → 对应 Electron fixture/Gate B → GUI smoke → 默认本地门禁。协议/bridge 变更追加 contracts;Windows 未实跑不写 Platform 通过。外部 fixture 绕过的 provider/runtime 层要明确。
|
||||
默认验证绑定 freeze30 的7884路径,SHA `727cc715a2b6af374ec59d991f8ac436927df53465cf40be48203f4c3be038bd`;退出护栏另建 freeze31,共7886路径,仅4项fixture变化(2修改、2新增),产品与2303产物不变、freshness全ready,无需重建。新聚合第7轮已exit0、22/22通过;packet31位于 `.lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-31.json`,SHA `8dcf4bddbb89e8b6d57f8502f6ec06e18718a14dfc00e8b317fa349fd660e57e`。新测试由定向及聚合执行,不冒充已进入旧续跑状态。默认组合最终收据:`.lime/qc/composer-persistence/default-combined-current-20261015/final-receipt.json`;前置收据:`.lime/qc/composer-persistence/default-combined-current-20261015/pre-gui-receipt.json`。
|
||||
|
||||
## 完成标准与限制
|
||||
默认按已过前置检查 + 原生test:resume + contracts + 标准GUI关闭组合步骤;原verify:local wrappers均保留exit1,不改写成单次wrapper绿。续跑不按源码摘要作全量缓存,修改过的owner已单独回归。fixture是受控backend,不能冒充live provider、Windows、系统IME/mic或安装包等价。
|
||||
|
||||
每个切片必须同时关闭行为、结构/命名、调用者迁移、旧资产清理和风险匹配验证。全面对齐要逐项关闭完整账本,不能用三个切片或同名文件数量替代。
|
||||
## 本切片 Verification Contract 与退出条件
|
||||
|
||||
已知限制:此前 Computer Use 拒绝操作 `com.openai.codex`;不绕过限制。可继续静态 REA 与 Lime 真实 GUI 验证,Codex 实时窗口效果记 not-run。REA 三文件 timeline 与六文件 surfaces 的结构覆盖完整,但语义仍有 unknown,不宣称全包完整分析。
|
||||
负责人root。风险P1,非live。入口为Home/Inputbar/Skills试用/根新建动作,经真实Electron、preload/IPC、`app_server_handle_json_lines`、current JSON-RPC、runtime/read model到同一canonical thread/turn/item与可见状态。草稿UI保存只走Controller -> Coordinator -> Host Store/File,业务历史仍归App Server。
|
||||
|
||||
下一刀:完成当前长UserMessage整体折叠的真实两入口/clipboard/焦点/长历史锚点与统一门禁;随后按完整账本继续scoped persistent draft等能力。Queue active-turn Steer/交付状态、专业文件、原生工具和 remote 继续保留在全面账本中。pending Queue 诊断 export / unload 另立合同,不由 public read 通过推导完成。
|
||||
- Happy path:非Composer接受提交后切到实际可见会话,编辑器恢复可编辑;富输入、资源字节、hidden paste、selection和接受消费可追溯;Home/local冷恢复隔离。
|
||||
- 失败终态:拒绝保稿、接受后的新编辑不被消费、迟到旧turn不能夺取新scope、pending discard通知不能重绑旧稿;严格身份/readiness/CAS守卫不放宽。
|
||||
- 测试/护栏:按current方法迁fixture与过期断言;无超时放宽、skip、生产mock fallback或手改续跑状态。旧实现只在负向guard/历史Evidence出现。
|
||||
- 最终构建后重核全部声明源码、dist、dist-electron和client dist的regular非symlink产物;保留旧chunk的目录manifest不声称全部被加载。
|
||||
- 标准GUI及新current22均已退出成功,前后源码/产物零漂移。第7轮manifest位于 `.lime/qc/composer-persistence/current-aggregate-7-complete/manifest.json`,SHA `69340c52a05833c450ad6f0d8667add345fa61d262b0aec3a6d8485674c9973c`;summary及其显式ledger/截图和log共67份独立冻结,排除旧前缀遗留失败截图,旧manifest不覆盖。
|
||||
- 更新索引/报告/本计划,格式、文档边界、脚本治理与diff检查通过,才关闭本验证切片。
|
||||
|
||||
## 首刀:审批卡 Verification Contract
|
||||
架构:持久草稿/新建导航/设置单一章节的重大图变更已由root于2026-10-14/15确认,见[architecture 4.3/4.4/4.5](../aiprompts/architecture.md)。本轮仅既有owner接线、资源复用和测试/文档整理,非重大;不新增authority或依赖方向。
|
||||
|
||||
- 名称:Pending Request Item Panel / Approval Card 对齐。负责人:root,2026-10-13。
|
||||
- 预算:`budget:normal`;风险 P1;不调用 live Provider。
|
||||
- 参考:包内 `pending-request-item-panel-770855267df2.js`,SHA-256 `65c883ee4c9b507b6fe86e9edd73ac0eb2e085730dfbb1f72d0761ccde22be69`;通用卡位于 `app-initial-6350d04672d0.js`。module-derived 文件名与原始压缩符号分别记录,不能声称已恢复开发源码名称。
|
||||
- 写集:新 `components/ApprovalCard.tsx`、`PendingRequestItemPanel.tsx` 与对应测试/纯投影;删除 `Inputbar/components/InputbarApprovalPrompt` 源码与测试;迁移 PendingInteractionLayer、Workspace inputbar、相关 DOM 定位器;补五语言 `agentInputbar.json`;迁移审批 GUI fixture/断言/脚本测试;更新旧审批 PRD 与执行记录。public request 的 cwd/grantRoot 只补到现有 pending projection,不改 schema 或业务 authority。
|
||||
- 避让:已脏 Rust approval/runtime 与 `agent.json`,不接管其实现。
|
||||
- 前端入口:PendingInteractionLayer / Workspace Composer accessory → PendingRequestItemPanel → ApprovalCard。
|
||||
- 网关与 Host:既有 pending interaction controller → AppServer server request dispatcher → Electron preload/IPC → `app_server_handle_json_lines`。
|
||||
- App Server:`item/commandExecution/requestApproval`、`item/fileChange/requestApproval` reverse request 与同 id response;不调用已退役私有 action method。
|
||||
- Owner:审批 authority 仍归 App Server / tool-runtime;卡片只展示请求并提交现有 decision。
|
||||
- Read model / event:同一 thread/turn/item pending interaction,response 后清除;terminal 使用 current `turn/completed` 与 canonical read model。
|
||||
- Happy path:显示完整原因、命令预览、目录与已知网络/授权范围;长命令可展开;仅显示服务端允许的决定;提交期间禁止重复响应,失败可重试;响应成功后恢复现有运行状态。
|
||||
- 失败边界:没有 submit owner 或请求已提交时按钮禁用;未知授权能力不生成“Always allow”或“Allow similar commands”假动作。
|
||||
- 证据:component/unit 与五语言回归、current fixture、对应 approval Electron fixture、GUI smoke 与默认本地门禁;GUI evidence 需同 identity 的 IPC/request/response/read model/可见状态,不能只看卡片截图。
|
||||
- 必跑:定向组件/投影/fixture tests;`npm run test:contracts`;`npm run smoke:agent-runtime-current-fixture`;`npm run verify:gui-smoke`;`npm run verify:local`。失败按 owner 修复或明确记录外部脏写集阻塞,不恢复旧断言。
|
||||
- 架构影响:非重大;既有 projection/response owner、协议与依赖方向保持一致,拆分仅为 Renderer presentation;架构图无需改变。
|
||||
- 退出条件:旧 compact-only 规则、源文件和测试不再作为 current 入口;新卡行为与支持的决定完成定向验证。file diff 与 canonical item_id 接线、policy/network amendment 和自动审查专用详情仍是呈现缺口;协议已有字段不能被记成协议缺失。不宣称整个审批域完全对齐。
|
||||
## 下一刀:设置canonical章节与返回
|
||||
|
||||
## Files/diff Verification Contract
|
||||
前一验证切片已关闭;设置第二切片现进入实施,详见[设置审计](../research/codex-desktop-rea/settings-navigation-audit.md)。
|
||||
|
||||
- 负责人:lime_gui_comparison;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。
|
||||
- 参考:REA `ev_7e96cf795451f834a7bddc70daacbc950320070656781f6347f1cf9c759e4299`;经 ASAR SHA 校验的 review tree/side pane 补证。`ReviewThreadSidePanelTab` 是确认导出;压缩局部名不作开发源码事实。
|
||||
- 写集:Files renderer、workspaceFiles、既有 ChangesPanel/DiffList/ReviewState、右侧 composition 所需接线及对应测试;文档、架构、fixture 门禁归 root。避让 Rust、命令目录、审批和 agent.json。
|
||||
- 入口与事实源:统一 RightSurfaceHost 的 Files → 同一 canonical coding changeView → 既有 CanvasWorkbenchChangesPanel;Git/read/full-file 仍走 existing frontend gateway → Electron IPC → App Server current methods,不新建 diff 或文件读取 owner。
|
||||
- 接线:会话 coding projection 只计算一次,分别供 Canvas 与 Files 消费。若使用 Context,只传只读 projection,不增加业务状态或 runtime。
|
||||
- Happy path:打开 Files 显示真实 patch/diff;点击文件选择并定位相应 diff;再次点击仍定位;打开原文件是显式动作;已有 inline/split、baseline、full-file 复用。
|
||||
- 失败边界:Git/full-file 失败可重试;线程/目录切换清空局部缓存;迟到异步结果不得写回新 identity;无协议能力不伪装 comparison base。
|
||||
- 旧资产:删除 patch→path-only 列表与解析 helper;迁移旧“选择会启动外部应用”测试;保留唯一 RightSurface owner 守卫并同步接线。
|
||||
- 验证:定向行为测试、typecheck、current fixture、`smoke:code-artifact-workbench-electron-fixture -- --scenario gui-coding-input` 加 Files→diff 断言、GUI smoke、verify:local。
|
||||
- 架构影响:共享 renderer projection 新增消费者,更新 architecture 图。责任开发者图确认:root,2026-10-14;已复核 Scene 只读 Context 直接传同次 changeView / root / loadFilePreview,Canvas 与 Files 共用 review owner,无第二数据 store。
|
||||
本刀认领:`useAppNavigation`及相邻测试、`types/page.ts`、App/AppPageContent及接线测试、AgentChatPage/contract/既有workspace surface回调、SettingsPage/SettingsNavigation及测试、五语言settings资源与类型/加载回归、settings-about/project-gate-settings-a/tool-surface导航消费者、对应文档。现有脏内容只作窄增量,不回退他人修改;release plan、媒体设置实现、参考解包chunk与tsbuildinfo继续避让。
|
||||
|
||||
## 长历史 Verification Contract
|
||||
Verification Contract:root,budget:normal,P1。`SettingsPage -> useAppNavigation -> AppPageContent`是唯一导航owner链;实际会话位置仅沿现有workspace surface投影发布session/workspace,由导航requestId拒绝迟到报告,不新建runtime/read model/history。返回只保存最小可恢复Agent参数,去除发送、新建、启动等一次性intent。章节选择直接消费/回写canonical SettingsPageParams,删除initial-props/local-selection双轨;页头返回与rail Home分开。真实Electron合同为Home首发生成会话后进入设置切章返回、已有会话切章返回;要求preload/IPC、current thread读取、同一session/workspace、可见会话与零重复发送/production mock。直达无来处、非Agent来处及快速导航以owner/component回归补足。先定向与五语言/type/lint,再标准GUI smoke及真实Electron场景;新产品变化开启本刀默认本地验证,历史通过结果不冒充新源码缓存。无live/Windows/参考实时观察;dirty/search后置。不需要LLM judge或release artifact。
|
||||
|
||||
- 负责人:lime_desktop_capabilities;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。
|
||||
- 参考:既有 timeline REA 与 `thread-virtualizer` / `virtualized-turn-list` 包内纯几何、viewport range、高度测量、锚点和导航;导出为压缩 alias,语义命名与原始绑定分开记录。
|
||||
- 写集:MessageList、旧 RenderWindow owner、scroll controller、timeline/telemetry/historical chrome/constants、相关纯 projection 与测试/样式;不修改 Files 或 conversation composition。
|
||||
- 入口与事实源:App Server thread/read 与 canonical paging → 同一 Thread/Turn/Item projection → viewport turn list。保留后端分页 owner,不增加 history store。
|
||||
- Happy path:离屏 turn 真正卸载;稳定 key 与动态高度;窗口按 viewport/overscan 改变;prepend/流式高度变化保持阅读锚点;用户在底部时尾随;focused/current item 导航先挂载目标再定位。
|
||||
- 失败边界:迟到测量不污染新线程;ResizeObserver/RAF/事件清理;键盘、触摸、滚轮与滚动条操作能停止尾随;离屏数量不等于未加载历史或 hydration pending。
|
||||
- 旧资产:删除 idle 渐进挂载与 Expand All 全量 DOM 路径;保留 canonical Load More;同步迁移初始窗口固定 10/36 的测试与性能 fixture,不保留双裁剪 owner。
|
||||
- 验证:纯几何与 component/hook 行为、长历史真实 Electron fixture、current fixture、GUI smoke、verify:local。实际性能数字只有跑过才落库。
|
||||
- 架构影响:renderer window owner 直接替换,更新 architecture 历史窗口段落及图;责任开发者图确认:root,2026-10-14。MessageList 传全部已加载 canonical entries,唯一 virtualizer 持有 DOM/测量,后端 paging 保持独立。
|
||||
架构影响:既有导航owner内增加返回位置与显示投影接线,跨层业务边界不变;同步architecture 4.4的数据流,由root于2026-10-15确认后记录实际验证,不提前宣称完成。
|
||||
|
||||
## 验证记录
|
||||
- Owner:useAppNavigation既有requested/committed/requestId持有设置参数与进入上下文;SettingsPage消费canonical章节,内部点击回写同一PageParams;不新增第二history。
|
||||
- 入口接线:App/AppPageContent -> SettingsPage/SettingsNavigation;providerView/providerFocus/executionPolicyFocus与Experimental委托语义保真,离开章节不重放旧焦点。
|
||||
- 返回与Home分开:页头返回恢复进入设置前实际可见会话/workspace,rail Home仍进入首页。不能仅保存旧根params,因为Home首发后params可能尚无真实会话,并可能含一次性发送/newConversationRequest。
|
||||
- 先沿既有onSessionChange投影携带真实workspace/session;它仅是导航显示位置,不建立新业务read model。剔除初始发送、fresh与其他一次性intent。
|
||||
- 窄写集:useAppNavigation、App/AppPageContent、既有session投影回调、SettingsPage/SettingsNavigation、PageParams及定向测试;五语言返回文案;settings-about/project-gate-settings-a/tool-surface消费者按新返回语义迁移。实施前核对各文件最新脏写集。
|
||||
- 必须覆盖Home首发后进入设置再返回同一会话、已有会话切章再返回、直达设置fallback Home、快速导航/迟到投影不夺取返回目标。
|
||||
- 验证:owner/component接线回归、五语言稳定断言、contracts按边界触发、标准GUI及真实Electron两条返回路径;同一identity的bridge/read model/可见状态和零production mock。
|
||||
- Memory/Web Search dirty owner已只读确认;其他表单与内容搜索继续查实际owner,不能复制未提取registry/隐藏章节或新造全局dirty业务状态。
|
||||
|
||||
- 规则:两个 skill validator、链接/脚本存在性、docs boundary、12 个 current-docs guard 通过。
|
||||
- 首轮审批定向验证:6 文件 / 146 tests 通过;contracts 与 typecheck 通过。
|
||||
- 追加:file grantRoot request/response 回归与 dead surface 物理恢复守卫通过;键盘菜单 Escape 焦点回归已修复并通过,PendingRequestItemPanel 13 tests 通过。
|
||||
- Files:9 文件 / 92 tests、package projection 42 tests,移除无消费者菜单后 3 文件 / 26 tests;typecheck、lint 通过。真实 Electron `gui-coding-input` 四个 canonical 文件的 diff 方向、选中与头部可见全部通过;删除项禁用外部打开,rename 仍按 source path 选择、显式打开 destination。选择导致 external open 为 0,console/page errors 为 0。证据:`.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-files-final-summary.json`。
|
||||
- 长历史:29 文件 / 318 tests、typecheck、lint 通过。真实 Gate B 与冷重启恢复通过,fixture seed 240 Turn / 720 Item;实际已加载 14 entries,首帧挂载 3 Turn / 9 Item,滚到已加载页首挂载 8 Turn 并卸载原尾部行,然后返回尾部。首帧文本 2,068 字;first paint 38ms / cold 97ms,compute max 0ms / cold 1ms。预算仍为 ≤12 Turn / ≤36 Item、paint ≤8,000ms、compute ≤500ms;不把 seed 总数当成全量加载性能。证据:`.lime/qc/gui-evidence/agent-session-history-electron-fixture/codex-gui-alignment-history-final-summary.json`,console/page errors 为 0。
|
||||
- Gate 迁移:Files helper 8 tests + smoke guard 8 tests;viewport oracle 9 tests;current-rules 与 viewport 合计 20 tests;五语言 loader 10 tests。旧 current-rules 的 skill 重复模板断言迁为权威引用/主链/证据要求;旧全历史文案断言迁为分页文案。没有降低 DOM/性能预算或移除权限/mock 护栏。
|
||||
- 默认本地门禁已通过五语言完整性/unused、lint、硬编码扫描与 typecheck,Vitest 续跑到批次 91/121;contentParts final body case 在全量负载下5秒超时,独立case与整文件14tests均通过,未扩大timeout或更改产品逻辑掩盖。待写集冻结后resume。current-rules、loader与Codex async源码anchor等过期断言已迁移,保留有效权限/mock/Reasoning护栏。本轮新改动后current fixture、GUI smoke与受影响Rust门禁待收敛。
|
||||
- 写集例外:仅精确移除五语言 `agent.json` 中六个孤立旧 Files/菜单键,保留其他并行编辑;`agentMessageList.json` 删除三个旧尾窗键。新增 loader test 与 current-rules test 用于迁移过期护栏。
|
||||
- 真实桌面发现 `thread/settings/updated` 原先只有 typed guard / package oracle。完成生产 consumer、现有 cache 接线和真实 Electron Gate B 后,迁为 `known_projected`;不通过静默白名单掩盖缺口。
|
||||
- REA 审批:1 文件、23,095 AST nodes、199 findings、0 parse failures;结构 complete、语义 unknown。Wakaru source recovery 因 Linux x64 引擎不适用于当前 host 而未执行;不影响静态分析,不声称恢复完整源码。
|
||||
## 分类、完成度与未验证范围
|
||||
|
||||
规则更新完成度 100%;全面 GUI 对齐仍为 partial,不从三项首批切片推导全产品百分比。
|
||||
- current:表中既有owner、当前协议fixture与新build验证链。
|
||||
- compat:仅已存在的Experimental -> Developer设置委托;本轮新增0。
|
||||
- deprecated:本轮没有新增延期双轨。
|
||||
- dead/deleted:旧组件/名单/V2 aliases、general草稿事件双轨、布尔接受夹具、重复reset integration、finally前打印pass及吞掉资源退出失败的旧fixture路径;有效风险由current语义回归承接,禁止恢复旧实现迁就测试。
|
||||
|
||||
## 下一批:队列呈现与动作隔离 Verification Contract
|
||||
前端续跑为132/132批(100%仅该历史测试发现集合),新增退出回归单独及聚合通过;默认组合、新current22、后验摘要及最终文档检查已关闭(本验证合同100%,不代表产品对齐100%);最终收据 `.lime/qc/composer-persistence/fixture-teardown-final-20261015/receipt.json`。整体GUI仍partial:设置完整路由/返回/search/dirty、专业文件排版与批注、原生应用操作、remote产品入口、完整符号覆盖、OS输入及Windows未完成;全产品分母尚未完整枚举,不给伪百分比。
|
||||
|
||||
- 负责人:lime_gui_comparison;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。
|
||||
- 参考:[导航/Composer 审计](../research/codex-desktop-rea/navigation-composer-audit.md);已确认 shipped export `QueuedMessageList`。只迁移同义列表职责,不把压缩 alias 作为函数名。
|
||||
- 写集:`ThreadQueueStatus` 直接迁为 `QueuedMessageList`、纯输入编辑 helper、既有 queue action UI 与 tests、Scene import/callsite、`queuedTurnCurrentBoundary` 的 current path 和 dead 回流守卫;独立修复 `ThreadWorkspaceHeader` 重命名 session identity 及测试。root 拥有执行计划、架构、五语言文案和 Electron mutation gate。
|
||||
- 既有主链:QueuedMessageList → current threadQueueActions → typed client → Electron preload/IPC → `app_server_handle_json_lines` → `thread/queue/update|delete|reorder` → 既有 App Server queue/store → scoped notification/read hook → 同一 canonical queue projection。禁止新建 queue store 或在 renderer 删除后再 steer。
|
||||
- 行为:线程切换清空旧编辑与错误;旧 action 的 then/catch/finally 不影响新 identity;重复提交 fail closed;互斥队列变更不会被另一个请求提前解锁;保存失败保留草稿。多 text block 编辑无重复,非文本与 client message identity 保留;未改文本保留原始 textElements,修改后不沿用失效 ranges。
|
||||
- Header:编辑绑定开始编辑时的 session;切换取消旧草稿,旧 blur/key handler 不能调用新会话 rename。
|
||||
- 暂停/sending/outcome-unknown 尚无 canonical DTO,保持显式 gap,不伪造状态。当前 `thread/queue/start` 只支持 idle,不把它改文案冒充 active-turn Steer;该协议/事务切片另立合同。
|
||||
- 验证:跨线程迟到成功/失败、重复与并发、富输入保存和失败恢复、键盘 preview、header session 切换;scoped reader 和 boundary guard;真实 Electron mutation 需覆盖 update/reorder/delete 的 typed request、canonical queue refresh、正文唯一,单独报告,不冒充 live runtime。
|
||||
- 架构:业务 owner/协议不变,仅同义 renderer 直接迁移;同步图和当前入口,不保留 alias/compat。退出条件是旧组件与其正向测试删除、调用者/护栏迁移、定向与对应 Gate B 通过。
|
||||
|
||||
## 下一批:设置通知消费 Verification Contract
|
||||
|
||||
- 负责人:lime_desktop_capabilities;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。
|
||||
- 已确认缺口:typed client 已解析 `thread/settings/updated`,projection 中现有 oracle 没有生产 consumer;`agentRuntimeAdapter` 丢弃 RPC response.notifications,`useAgentContext` 以 ACK 后的请求选择更新 UI。warning 间接触发 detail refresh 不算通知消费。
|
||||
- 写集:既有 settings adapter、EventBus/typed notification route、session/read-cache 与 useAgentContext 的最小接线和回归;不改 Rust、协议、Files、MessageList、queue、命令 catalog 或外部脏 owner。先复核生产订阅/identity 设计,再实现;root 拥有 Electron settings oracle、执行计划和架构。
|
||||
- 主链:既有 settings RPC → App Server canonical settings → typed `thread/settings/updated`(response 与后续 drain)→ 现有 event/同步 owner → 同一 thread/session read cache → useAgentContext/header/model/cwd projection。ACK 仅结束提交,不以请求值替代服务端有效值;没有第二设置 store。
|
||||
- 回归:服务端 canonical model/provider/effort/serviceTier/cwd、inactive thread cache、切换迟到隔离、响应与 drain 重复幂等、失败恢复、active turn 不变而 future turn 使用新设置、通知不写 transcript/Item。对于当前不支持显示的字段不合成 UI 行为。
|
||||
- 门禁:真实 production consumer 定向测试、既有 settings live-update oracle/typed contracts、真实 Electron model/cwd/header 证据;通过后才能把 method 从 known_unprojected 移到 PROJECTED。不可先加白名单消警告。退出条件按实际可消费字段逐项确认,不把 oracle 导出数当接线。
|
||||
|
||||
## 第二批实施与桌面验收(2026-10-14)
|
||||
|
||||
- Queue/Header:旧组件与正向测试物理删除,无 alias/compat。thread-keyed 实例、mounted/request token、同步整队 mutation 锁隔离 A→B→A 迟到 success/error/finally;保存失败保留草稿。纯输入 helper 保留未改 blocks/ranges,修改后合为一段 text,保留非文本顺序,清除失效 ranges。Header 绑定编辑开始的 session/token/callback。6 文件 / 36 tests、renderer typecheck、窄写集 lint 通过。
|
||||
- Queue Gate B:真实 GUI 保存、上移、删除和 preview 焦点均经 current IPC,canonical list 同一 identity 读回。seed 内联图片入库后成为 `sidecar://media/input-<sha256>.png`,oracle 先校验 seed bytes 的哈希基线,再精确比较 update/read-back 全部非文本输入;附件丢失、替换和空基线均 fail closed。原 raw dataURI 比较是过期验收基线,未为通过而移除附件要求。GUI list 可见先于 trace 完成,采证改为等待生产同线程 list,未补 fake request。证据 `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-final-summary.json`:零 invoke/console/page/mock error;不启动 Turn,不证明 active Steer、拖拽或发送状态。
|
||||
- Settings:EventBus 消耗 response/drain 成对镜像且保留 A→B→A,scoped adapter/cache/context/header 接线不建第二 store。ACK 不替代 canonical 设置,串行选择和 future-send wait 有定向回归;`useAgentContext` 已拆至 765 行。10 文件 / 120 tests、既有 oracle 8、最后 production consumer 8、renderer typecheck/lint 通过。真实 Gate B 后迁 `known_projected`,新增 drift/privacy/typed passthrough 回归。
|
||||
- Settings Gate B:真实 GUI model selection 与同线程 raw cwd RPC,旁路观察原 Host IPC handler 输出,不额外 drain、不注入 consumer。当前 stdio ACK 与 notification 分离,原要求 response.decoded 含通知是 oracle 错误;验收依据生产 drain 中 canonical model/provider/cwd 通知与可见 header,以及 `thread/resume|read` 读回。证据 `.lime/qc/gui-evidence/thread-settings-electron-gate-b/codex-gui-alignment-settings-final-summary.json`:零新 Turn、transcript 通知和 invoke/console/page error。只证明 idle model/cwd,不声明 active/future Turn 执行、live Provider、Windows 或 packaged。
|
||||
- 架构图:Queue/Header 与 settings 数据流已更新;责任开发者 root,2026-10-14 confirmed。业务 owner、协议和依赖方向未新增双轨。实际 App Server sidecar 使用显式 binary override,SHA 记录到最终 Gate summary;未建立与当前外部脏 Rust 树的精确构建等价,不用 mtime 冒充编译证明。
|
||||
- 分类:新增/迁移 presentation、通知 consumer、fixture/oracle 为 current;旧审批、Files、尾窗与 ThreadQueueStatus owner 为 dead/deleted/forbidden-to-restore;本批没有 compat/deprecated。现有队列 store、runtime、thread paging/read model 是 current,不因删除 UI 旧入口而删除业务 owner。
|
||||
- 完成度:规则更新100%;Queue/Header的mutation list/identity/结构/清理已通过既有验收,设置idle model/cwd切片通过。随后补齐内部canonical Item/媒体一致性,并由公开源码纠正“pending进入主时间线”的错误验收合同;旧timeline-final summary不再作为最终pending证据。完整产品仍partial,全面账本未穷举到稳定分母,不给无依据的全产品百分比。
|
||||
- current fixture 聚合已通过:22 个真实 Electron 子场景,涵盖审批 allow/decline/cancel/full-access、首页热路径、Files/coding recovery、图片意图、停止续聊、富草稿恢复、active steer、Plan/Skills/MCP/media/Expert/文章产物;绕过模型的 external/unavailable fixture 不是 live provider。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-gui-alignment-current-fixture-final.log`,各场景 summary 位于 `.lime/qc/gui-evidence/` 对应目录。不能用聚合通过覆盖上述新发现或完整视觉等价。
|
||||
- 前批contracts、规则/dead surface/queue/settings Gate guard、窄写集ESLint与legacy report通过;零引用、分类漂移、边界违规均为0。本轮新增public history/native tab合同后需重验受影响边界,旧75批记录已由上方91批进度取代。
|
||||
|
||||
## Queue canonical Item 与主时间线一致性 Verification Contract
|
||||
|
||||
- 触发:真实 mutation 截图显示旧 rich 文本与已删除 sentinel。`queue.added` 自动注入 message.created 生成 canonical User Item,`queue.updated` 没有更新该 Item;reorder 的 removed/added 事务会移除后重建 Turn,却不重建 Item。renderer queue.changed 只刷新 list,普通 read-model merge 保留删除项。PNG fixture 的 IDAT CRC 无效已替换,并在浏览器 Image.decode 验证 seed。
|
||||
- 负责人:Rust materializer 与独立回归由 lime_desktop_capabilities,root 拥有 renderer read refresh、Gate、计划与架构;P1,`budget:normal`,无 live Provider。
|
||||
- 窄写集:`runtime/thread_item_projection/materializer.rs`、`materializer/lowering.rs`、`materializer/lifecycle.rs` 与该领域新独立 Queue regression module/必要 test module 注册;root 的现有 session refresh/projection/helper 与 scoped queue.changed hook、既有 SyncEffects 一处接入、对应定向测试;既有 Queue Gate 增强。避让外部脏 approval/runtime/backend owner,不修改 public v2 schema 或新建 queue/history store。
|
||||
- 主链:Queue typed mutation → App Server 既有 queue events → 唯一 materializer → 既有 ThreadStore canonical User Item → queue.changed → scoped existing session read refresh → 同一 canonical Thread/Turn/Item 主线。编辑使用稳定 user item/client identity、完整 typed input(含 sidecar)覆盖快照,不重新 append message.created 或完成生命周期。
|
||||
- 行为:add/edit/reorder 保留一份准确 User Item;delete 清 Turn/Item;重启和 thread/read 一致。frontend 自动读回更新文本/顺序和删除,不直接从queue list patch历史;读取为完整 read model才允许replace,分页detail不能伪装完整快照,保留 active streaming/未确认 optimistic 边界。
|
||||
- 失败与竞争:读取失败不伪造删除成功;订阅session/thread/workspace generation、A→B→A和迟到read隔离;连续通知串行且末次不能丢失。v2 Turn 未携带内部queue状态是既有协议gap,不依靠当前不可命中的isQueuedTurn判断;另立协议合同,不随本刀扩schema。
|
||||
- 验证:独立 materializer + canonical store add→edit→reorder→delete/restart 回归、受影响Rust crate、frontend scoped read/projection/hook 回归、contracts、增强真实Electron Gate B(raw thread/read正文/Items + GUI更新/删项 + canonical image decode)、默认本地门禁。退出条件是上述同一identity证据通过;原queue list Gate通过不关闭新问题。
|
||||
- 架构影响:恢复既有唯一projection owner的一致性,不新建业务owner;更新architecture现有Queue/read flow并由root复核图。canonical内部queue状态与v2传输的完整对齐仍待独立关闭。
|
||||
- 测试迁移:`ChatModelSelector.integration.test.tsx` 补canonical设置通知和隔离catalog,保留跨session恢复的模型与thread/settings cache断言,4 tests通过;不会恢复ACK请求值提交逻辑。
|
||||
|
||||
## ComposerUtilityBar 结构与动作隔离 Verification Contract
|
||||
|
||||
- 负责人:lime_gui_comparison;root 记录,2026-10-14。P1,`budget:normal`,无 live Provider。
|
||||
- 参考:focused REA `ev_a12d13494c92f390f31b4493b08cc34bd42867413b8d6fcfbe597601fad22a18` / exact export view `ev_d226fcac113fff14952965ae9465453d279c1f1431cdef57be2026d60b9e06d9`;证据限制见 navigation-composer-audit。只迁 project/run-location/branch/local context 职责,不迁整个 model/permissions/editor。
|
||||
- 窄写集:`InputbarProjectContextBar` 源文件/独立动作hook/对应测试,直接迁 `ComposerUtilityBar` 与首页、ChatNavbar、EmptyState类型、Inputbar.index、既有workspace透传的必要引用;仅清理实际未消费的props。root 拥有docs、架构、dead surface catalog/guard和Electron验收。避让Queue/read model、审批、Rust、共享command owner和不相关脏修改。
|
||||
- 合同:现有project/Git gateway → current Electron IPC/App Server与domain owner不变;所有mutation绑定projectId/rootPath/workspace generation/request identity。同步互斥,A→B→A旧success/error/finally不能改新视图或导航;失败可重试,正确当前项目动作仍刷新/导航;单一owner同时服务home与navbar。
|
||||
- 清理:旧组件、copy/project类型与测试路径直接迁;不建alias/compat。动作职责拆成hook,renderer文件保持小于800行;主题使用既有token。不合成cloud/remote/environment能力、不以Local文案冒充readiness,现有worktree立即创建与上游staged mode差异另立跨层合同。
|
||||
- 验证:既有home/navbar/Inputbar/Git及workspace调用者测试、mutation隔离/同步重复/失败恢复/键盘与主题回归,renderer typecheck/lint、受影响Electron场景、GUI smoke、本地续跑;必要文案同步五语言。结构迁移不等于完整worktree/run target行为等价。
|
||||
- 架构影响:非重大,沿用唯一project/Git owner及composer controller;root 最终复核引用图。退出条件按结构、命名、调用者、已确认动作语义、清理与对应测试分别记录,未知能力仍在全面账本。
|
||||
- 巨型既有session hook例外:本轮仅接入已拆分的Queue read helper,新增业务保持在独立helper/hook;不向`useAgentSession`继续堆叠状态机。退出条件是该helper测试/接线证据通过并冻结最小接线,后续迁移另立owner拆分合同。
|
||||
|
||||
## Queue 输入图片真实读取补充合同
|
||||
|
||||
- 触发:增强Gate已观察主时间线正文更新,但图片仍显示unavailable;合法seed已由浏览器decode通过。输入writer生成既有`outputRefs`对象,`session_media_refs`只识别嵌套`sidecarRef`,需查明同一canonical input URI的真实`media/read`结果。
|
||||
- 负责人:lime_desktop_capabilities窄写`runtime/session_media_refs.rs`及`session_media_reader.rs`该领域独立回归/必要注册;root拥有Gate和docs。避让外部脏runtime/backend、协议与input writer;先真实RPC或Rust集成复现,再修复已经存在的reference lowering,禁止绕过session ownership/sha/bytes/range/cancel校验或读取任意路径。
|
||||
- 合同:typed input media writer → 既有outputRefs → media/read唯一reference owner → sidecar verified bytes → MessageImageAttachments。不新增media metadata key、平行store或renderer raw文件读取;保留跨Thread拒绝、篡改哈希/大小失败、未知URI失败和请求取消。
|
||||
- 验证:真实queue/add与合法inlinePNG后的media/read,在edit/reorder及冷恢复后保留同一URI/bytes;负向scope/sha/read保护、受影响AppServer tests和新binary SHA、增强Electron image.decode、contracts与本地门禁。此发现继续阻止Queue完整验收,原列表和正文通过不覆盖图片。
|
||||
|
||||
## ComposerUtilityBar Electron 验收补充合同
|
||||
|
||||
- root 窄写集:既有 `scripts/electron/lib/` 内独立 utility Gate 与 oracle、scripts 导航、legacy catalog、执行计划和架构。生产组件及动作 hook 由既有 worker 冻结后再构建。
|
||||
- 使用隔离 app data、两个真实 Project 与临时 Git 仓库;初始对象用 Git plumbing 生成测试基线,不提交或切换 Lime 源码仓库。setup 可从 current IPC 建 Project/写本地选择基线,但成功证据必须来自 GUI 项目选择、分支创建/切换、真实 IPC 与 Git/readback 同一 root。
|
||||
- 验证 home UtilityBar project/root identity、分支菜单/搜索/create/checkout、reload 后恢复;全部 Git mutation 必须匹配当前 workspace root,禁用 mock fallback。错误/重试和 A→B→A 由组件回归覆盖,不用受控成功场景冒充故障恢复实跑。
|
||||
- 不启动 Turn、不调用模型;不把立即创建 worktree 证明为上游 staged composerMode 等价,不包含 cloud/remote/environment。
|
||||
- 实跑补充:首页把 `useWorkspaceOpenedProjectsRuntime.enabled` 永久绑定到 auxiliary deferral,导致已打开项目虽存在于侧栏/存储,UtilityBar picker 始终为空。root 窄写补 `useAgentChatWorkspaceEntryRuntime` 的这一处接线;只恢复现有 opened IDs 的 workspace/read,不开放 memory/model 等其他 auxiliary owner。真实 Gate 使用 workspace/ensure 基线,项目必须经 GUI 选择,不能拿 Project 目录实体替代 Workspace 实体。
|
||||
|
||||
## 未消费 Queue 的 public history 与运行状态合同
|
||||
|
||||
- 触发:增强 Gate 的正文/附件一致性已通过,但截图发现没有启动的 Queue 仍被显示为运行中。公开 Codex `ext/queue` 的 enqueue 只写独立 queued_items;start/admission 后才创建执行 Turn。public v2 Turn 不携带 queue 状态,不新增猜测 enum 或 renderer 私有 wire 字段。
|
||||
- 保留 Lime 内部 current durable queue materializer、稳定 User Item、媒体与冷恢复 owner;尚未消费的内部 Queued Turn 不属于 public transcript,Queue 列表继续由 `thread/queue/list` 唯一供给。内部有事实不等于可向 public history 投影。
|
||||
- 窄写集:既有 RuntimeCore thread read/turns/items/history pagination owner 与独立 regression module、必要 public projection 过滤及 processor tests;不修改 schema、agent loop、queue scheduler、shared runtime/approval/backend 外部热区。root 接管 frontend 公共 projection 中不可命中的私有 queue 字段假设、Electron oracle、docs/架构。
|
||||
- 失败边界:queued 不能触发 orphan interruption、terminal-projection-pending 或 active 状态;真实 active/terminal/drain 语义保持。过滤必须覆盖 read/list/resume、turn/item paging 与指定 queued Turn read,不泄漏 Item;不能在截断后的单页简单丢条目。使用既有 opaque cursor,补足可见 page 或明确 EOF,重复 cursor fail closed。
|
||||
- 验证:混合 queue/history、全 queued 页、反向 paging、同库/生产分库冷恢复、queue admission 后可见、真实 active 仍 active;pending Gate 改断言 public history 未含 queued正文、header/Composer idle、队列 edit/reorder/delete 保真。图片由真实 media/read 解码验证,不能为通过测试保留错误的未启动 transcript。
|
||||
- REA 精确缩略补证:同一 Queue chunk UTF16 offset 11048 有真实 `img`,src 来自 submission context 第一张 image preview,size-6;不是完整 gallery。root 窄写 `QueuedMessageList`、`queuedMessageInput` 的首图转换、既有 `MessageImageAttachments` 的 thumbnail presentation 及定向回归;复用同一 media/read/abort owner,不建第二图片读取链。Queue Gate 改在真实 row 解码缩略,公共历史必须为空。
|
||||
|
||||
## Right Surface 切换与关闭合同
|
||||
|
||||
- 触发:同线程 Browser → Files 仅切换选中项,却使 Browser 从 openSurfaces 消失。select 与 close 必须分别表达;不从 active pane 卸载猜测 native Browser 已销毁。
|
||||
- 参考:已保留 REA surfaces 模块及确认导出 `LocalConversationBackgroundTerminalTab`、`LocalConversationSubagentsPanelTab`、`ReviewThreadSidePanelTab`;通用 host/artifact 压缩局部名仍 unknown,不凭猜测迁名。
|
||||
- 窄写集:既有 `useWorkspaceRightSurfaceLocalStateRuntime`、`useWorkspaceRightSurfaceProjectionRuntime`、`useWorkspaceRightSurfaceCoordinatorRuntime`、`useWorkspaceRightSurfaceActionRuntime` 与相邻独立 projection/action 回归;不修改 queue、Composer、runtime、native/browser host 或 shared registry 协议。worker 避让并保留前轮 Files/diff 修改。
|
||||
- 同线程显式打开 surface 集合属于局部显示 owner;select 不删除其他 tab,close 才删除;thread/workspace epoch 切换必须隔离旧集合与迟到动作。resource availability 仍由现有 registry/definitions 判断,不新增 browser/artifact/subagent store。
|
||||
- 验证:open Browser → Files → Browser、close 选中/非选中 tab、无资源 surface、线程 A→B→A;定向测试与真实 Electron 相同 thread tab/资源 identity,禁止 mock fallback。Gate 证明 tab 生命周期,不冒充 native Browser 进程跨线程持久化。
|
||||
|
||||
## Pending Queue 搜索可见性合同
|
||||
|
||||
- 触发:只读追踪确认 canonical User Item 已进入 `thread/search` 的 snippet 与 `thread/searchOccurrences` 的 occurrence/turn cursor;搜索还透传 raw Active 状态。pending Queue 属于独立列表,搜索执行历史不能把它返回为 transcript 结果。
|
||||
- 负责人:lime_gui_comparison;root 记录,2026-10-14。窄写集为既有 thread-store canonical search owner 与相邻独立 regression module/注册、RuntimeCore public search 包装的最小有效状态投影及必要 processor/JSONL 回归。保留 external 脏写集、现有 visibility 与 opaque cursor,不改 schema、queue scheduler、export、rollout rebuild 或全局 active helper。
|
||||
- 合同:已执行 transcript 匹配、排序、分页与已有 title/path 搜索继续由当前 owner;仅 pending 文本不能命中 snippet/occurrence。Thread overview 状态取同一 execution state 规范化。queue admission 后该 input 成为正常可搜索历史;同库/分库冷恢复保持语义,不合成游标或由 renderer 二次过滤。
|
||||
- 验证:interrupt 基线 + 唯一 pending 字符串负向、混合执行/queue 同词、分页 EOF/双向、admission 后正向、真实 JSONL 与 thread-store 定向。不把诊断 export 中显式 pending/queued 字段定义为泄漏;其 transcript 与队列区分另立合同。rollout rebuild 与 idle unload 为待复现风险,未更改。
|
||||
- 真实 pending Queue Gate 前置条件校准:sidebar `thread/resume` 会唤醒 idle queue,直接 add 到全新 Thread 的旧 fixture 会 admission 第一条。改用 current external 阻塞 backend 启动唯一基线 Turn,再真实 interrupt 确认终态,随后添加 pending Queue;只含已执行基线历史且 idle,pending 不出现在 transcript。该 fixture 仍绕过 live Provider,不证明跨 unload/restart 暂停持久化。
|
||||
- 大文件例外:code-artifact fixture 只接入独立 lifecycle helper 的 import/call,业务与 oracle 位于 `scripts/electron/lib/`,不继续向原巨型入口堆叠;退出条件为相同 Thread Gate 完成并冻结接线。
|
||||
|
||||
## Browser live tab 恢复合同
|
||||
|
||||
- 触发:同线程 Browser→Files→Browser 卸载/重建 BrowserWorkspace,仅 mount primary,丢失完整 native tabs 的投影;primary存在时第二tab无法恢复可见性,primary已关闭时会复活primary。BrowserTabHost是既有live tabs/selected/URL/native view唯一authority;App Server继续只分配canonical browser session/thread identity。
|
||||
- 负责人:lime_desktop_capabilities;root记录,2026-10-14。生产窄写集为BrowserTabHost、ipcChannels、browserTab typed API、BrowserWorkspace及必要独立hydration helper;相邻host/API/component/IPC合同tests。根治理catalog与既有mock/fixture若当前native命令已纳入必须同步,不建生产fallback。
|
||||
- 新增host-only只读`browser_tab_list`:从实际IPC sender派生window,按window/ownerWebContents/browserSession/thread返回完整live snapshot;已知跨scope资源拒绝,不将身份不符伪装empty。沿既有preload白名单及main native dispatch,不增加Rust业务runtime或猜测上游符号;名称为本地领域命名,未声称REA确认上游导出。
|
||||
- renderer在同一canonical identity下hydrate,非空恢复host selected与既有setBounds,只在确认真正empty时创建primary。snapshot失败、scope混入、无selected中间态不能bootstrap;event/snapshot/selected/close交错不让旧快照覆盖新事实;origin+单调generation隔离ABA与迟到bounds。
|
||||
- 验证:双idle tab选第二→切换保持列表/URL/view/webContents;关闭primary→切换不得复活;跨window/session/thread/伪造owner/mixed/duplicate snapshot拒绝;empty、失败、late event/snapshot/bounds和ABA;定向host/API/renderer、`test:contracts`、真实Electron双tabGate、GUI smoke与本地门禁。
|
||||
- 大文件处理:BrowserTabHost既有超过1000行,新增snapshot参数解析/校验/列表拼装进入该领域独立模块,主类仅注册和窄委托;记录既有文件拆分风险,退出条件为不向主类继续追加业务算法。BrowserWorkspace接近800行,hydration逻辑拆独立hook/helper,并直接替换旧bootstrap effect,不保留双轨。
|
||||
- 不证明native跨线程或重启持久化,不推导Codex运行等价;本刀修复已确认本地生命周期缺口。root最终复核并更新架构图;command/bridge变更不通过时不能进入产品证据。
|
||||
|
||||
## 本轮冻结与证据记录(2026-10-14)
|
||||
|
||||
- pending public read/list/resume/paging:11文件,74/74 tests、rustfmt与窄diff通过。定向JSONL含queue6、v2 thread19、resume replay2。日志`.lime/qc/validation/codex-queue-public-history/`。分页读取Summary pending IDs的长历史成本尚未benchmark。
|
||||
- pending搜索:7文件,store6 + JSONL11 = 17/17 tests、rustfmt与窄diff通过。fixture显式`sourceKinds:["appServer"]`,先正向证明已执行内容可命中,再断言pending词不命中,避免默认CLI/VSCode scope导致负向空跑;默认产品scope不变。日志`.lime/qc/validation/codex-queue-search/`;最终build SHA`1188f12c7150afbe6a0c666eb57c3e109ed9c8682b698aa23667a2faa26778a3`,Gate prepare后需重新记录实际binary。
|
||||
- Right Surface局部标签:6生产 + 2新增tests + 1迁移guard,73/73 tests、renderer typecheck、窄lint/diff通过。日志`/tmp/lime-right-surface-tests-final.log`。旧availability自动造open tabs与plugin-only epoch reset删除,未猜测unknown上游名称。
|
||||
- Browser live tab:8生产文件与相邻测试/fixture写集冻结,11文件82/82 tests;renderer/Electron typecheck、20文件窄lint/diff及`test:contracts`通过。BrowserWorkspace从802降为689行;原primary-only恢复effect删除,无compat。新增snapshot/selection算法进入独立helper;主Host只注册/委托与修正visibility。日志`/tmp/lime-browser-hydration-{tests-final,renderer-tsc-final,electron-tsc-final,lint-final,contracts}.log`;当时 native 双tab Gate 待 Rust 冻结;随后通过,见下方最终 Browser / Right Surface Gate B。
|
||||
- root Gate oracle:4文件56/56 tests通过,日志`/tmp/lime-pending-and-surface-oracle-final.log`;标准`npm run typecheck`通过,日志`/tmp/lime-codex-alignment-standard-typecheck-final.log`。首次误用Node test runner被Vitest拒绝,改用实际Vitest入口重验,未修改产品或放宽断言。
|
||||
- Composer真实Gate B通过:`.lime/qc/gui-evidence/composer-utility-electron-gate-b/codex-gui-alignment-composer-summary.json`。两项目A→B→A GUI选择、branch create/checkout、Electron/AppServer冷重启后GUI恢复与Git readback;真实IPC4次、mock/invoke/console/page error为0,实际binary SHA`1c10f38b5d4927d32c01d4c92032fd7c95b09aa8325b96606e6bfad12fe1bcbf`。跨重启分别采集真实trace后合并,未补fake request。
|
||||
- Gate失败已修原因:pending旧fixture等待marker正文,sidebarresume实际admission第一条,已迁interrupt基线;RightSurface初版request只填session,但GUI按workspace ID/root查询,已补同workspace scope。当时两项均待最终冻结后重跑;随后新版最终 Gate 已通过,失败结果不计成功证据。一次Gate freshness发现搜索半写集并开始prepare,已中止exit130,无剩余prepare/cargo进程,不将其产物作为最终证据。
|
||||
- 新oracle:真实host/身份/资源/EOF/图片读/选择关闭负向回归通过;Browser Gate已增强双tab与关闭primary后恢复,必须出现真实native snapshot IPC,不把单primary保持等同完整tab恢复。
|
||||
- 分类:以上新增owner/helper/client/fixtures为current;旧入口直接dead/deleted/forbidden-to-restore;无新增compat/deprecated。规则100%,Composer本合同100%,public pending与RightSurface本合同的真实Gate/默认门禁尚未关闭;全产品仍partial。
|
||||
|
||||
## Queue rollout 重建恢复合同
|
||||
|
||||
- 触发:projection_rebuild按TurnStatus将pending Queued/InProgress写成running。真实runtime queue/add写rollout,保留原DB/event log,并以同一rollout root初始化全新同库DB:canonical完整快照相等,但projected Turn.status=Running、冷queue/list由1变0。复现日志`.lime/qc/validation/codex-queue-rollout-rebuild/reproduction.log`;修复限定现有重建owner,不改变watermark或全局状态机。
|
||||
- 负责人:lime_gui_comparison,root记录,2026-10-14。先用临时数据与既有真实rollout写入/空DB重建路径复现pending queue/list/read/resume;避免删除用户DB。窄写集为现有canonical_thread_store/projection_rebuild及该领域独立queue regression与必要注册;若没有复现,不增加生产补丁并记录证据。禁止改scheduler/unload/export/schema/普通admitted与terminal恢复语义。
|
||||
- 若复现:重建必须保留canonical Turn.queue事实、稳定User Item/client/input/媒体与pending projection;唯一当前owner直接修复,不恢复第二journal/store。验证同库/分库、pending+executed混合、admission后可见、public history/search排除pending及既有completed/active/repair回归。
|
||||
- 退出条件为实际复现分类与定向测试通过,冻结新binary后再跑最终Gate;诊断export与idle unload仍独立待核对。本合同不扩大为全workspace测试。
|
||||
|
||||
## 完整 Composer 只读补证
|
||||
|
||||
- 负责人:lime_desktop_capabilities;与root Gate收敛独立。无生产、测试、fixture写集,不触发build freshness。
|
||||
- 先复用指定ASAR与现有REA Evidence,定位主editor而非UtilityBar/BrowserSkills/Sketch;仅当明确seed未被分析时补focused REA,不重跑全包、不执行Codex或绕过Computer Use拒绝。
|
||||
- 返回真实模块/可确认导出、草稿/IME/附件/提交数据流与Lime input-kit/Inputbar调用链,记录Evidence ID、SHA/UTF16 anchor、观察/推断/unknown及最小可实施缺口。root随后落入本账本,不由静态补证标记产品完成。
|
||||
|
||||
## Browser actual sender dispatch 复核
|
||||
|
||||
- root 复核发现 `main.ts` 的 `currentWindow(event)` 会在未知 sender 时 fallback 到 mainWindow,Host 仅看到 window 而失去实际 sender。窄写集为 snapshot 领域 sender resolver / 相邻真实 Host 测试,以及 main browser-tab dispatch 的委托接线;不改 hydration 或其他 Host 命令。
|
||||
- BrowserTab 命令只能使用 `BrowserWindow.fromWebContents(actualSender)` 且 `window.webContents.id == actualSender.id`;未知或 child WebContents 直接拒绝,不返回 empty、不回退 mainWindow。主文件超过1000行的例外仅限 import / 委托接线,算法位于已有领域独立模块。
|
||||
- 定向验证 exact sender、unknown sender、child sender 经真实Host list的成功/拒绝;Electron typecheck、窄lint、contracts,再进入新版双tabGate。
|
||||
|
||||
## rollout 与 sender 最终冻结
|
||||
|
||||
- rollout:生产只在现有 projection_rebuild 的 Turn.queue 分支保留 queued;独立 queue regression/注册在同领域。新增及既有状态3 + repair/rebuild13 + queue JSONL7 = 23/23通过,rustfmt/diff通过。worker 当时 rebuild binary SHA `1d93af0f9d52a9e7ae2add71d5556fe42c44aa17148254c270d603e44fae4478`(不作为实际 Gate prepare 后摘要);日志 `.lime/qc/validation/codex-queue-rollout-rebuild/`,原失败仍保留。worker domain测试显式MockBackend,真实Electron Gate另用external fixture,不冒充live Provider。
|
||||
- sender:root新增 actual sender 接线3项回归;受影响5文件41tests、Electron typecheck、root窄lint与最终contracts全部通过。日志 `/tmp/lime-browser-sender-tests-final.log`、`/tmp/lime-browser-sender-electron-typecheck-final.log`、`/tmp/lime-codex-alignment-root-lint-final.log`、`/tmp/lime-codex-alignment-contracts-final.log`。
|
||||
- 所有产品与测试写集已冻结,root顺序跑新版pending Queue与双tab Right Surface Gate。两个worker只做完整Composer/Queue activeSteer只读补证,不影响build freshness。
|
||||
- Queue activeSteer只读补证由lime_gui_comparison负责:追踪同ASAR Steer callback的外部owner、消费与steer事务/失败顺序、expectedTurnId/client identity及交付状态;对照Codex current ext/queue与Lime既有steer/session actor,不用renderer delete→steer,不写产品/测试/文档。返回sources/Evidence/unknown与最小正确后续合同。
|
||||
|
||||
## 最终 pending Queue Gate B
|
||||
|
||||
- `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-pending-final-summary.json` 的 `ok=true`:rich input/client identity、update/reorder/delete、独立preview/首图真实decode、public read/paging/search pending排除、idle与正向执行搜索基线全部通过,mock/invoke/console/page error均0。
|
||||
- 实际postprepare App Server SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。初始worker build SHA与prepare后binary不同,最终证据按实际值记录;不推导未知脏树的精确源码等价。
|
||||
- 首次新Gate的search数据全部符合合同,但直接preload读取并不生成safeInvoke trace,oracle错误要求该trace。诊断证明baseline=1/idle、pending=0/EOF、occurrence=0/EOF;已将直接调用取证迁到真实发送params及匹配request/response ID的`ipcObservation`,不制造trace、不补fake request、不放宽搜索断言。
|
||||
- 共享观察predicate位于现有mcp-config-fixture-evidence;直接call在真实preload返回成功后记录observation。Queue/RightSurface分别校验准确method、identity与输入,同步负向unknown/mismatched response/mock回归。4文件61tests通过,日志`/tmp/lime-preload-proof-tests-final.log`;最后统一门禁继续验证。
|
||||
|
||||
## 最终 Browser / Right Surface Gate B
|
||||
|
||||
- `.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-surface-final-summary.json` 的 `ok=true`:真实GUI新建/选择第二tab,Browser→Files→Browser恢复完整tabs/selected/session/tab/webContents及真实native URL;关闭primary后再次切换不复活,最后分别关闭Browser/Files。canonical请求、native mount/list均为真实preload/IPC观察;mock/invoke error0。
|
||||
- 同场景保留原四文件Files diff/选择/定位回归,external file open为0;不把native跨线程、Electron重启或Codex实时GUI列为已验证。
|
||||
- 直接preload的workspaceRightSurface/request与Queue search复用同一实际发送/响应观察predicate;native mount/list继续要求safeInvoke真实IPC trace,不能拿测试构造trace补证。当前fixture22聚合进行中。
|
||||
- 后续只读Queue activeSteer报告已落 `internal/research/codex-desktop-rea/queue-steer-audit.md` 与证据索引;前端实际steer成功后delete、unknown对账、有限TTL锁已确认,持久幂等未知。Lime日志失败/终态竞争风险仍为待复现,不按静态风险直接扩大生产写集。
|
||||
|
||||
## Composer 附件异步隔离 Verification Contract
|
||||
|
||||
- 负责人:lime_desktop_capabilities;root 登记,2026-10-14。预算 `budget:normal`,风险 P1;上一批 current fixture 22 场景通过且退出码 0 后开启本切片。
|
||||
- 参考:REA 主 Composer `ev_18100ea823ab540f529ba756499161870e17f824267ed48a0665d3fb48cd0bb4`,原成员 SHA 与 UTF16 anchors 见 main-composer-audit。上游文件附件捕获 generation 并在完成时检查;只支持生命周期隔离原则,不声称与本地图片 hook 精确同义。
|
||||
- 窄写集:`Inputbar/hooks/useImageAttachments.ts` 与相邻测试、`components/useEmptyStateAttachments.ts` 与新相邻行为测试,以及 `components/useAttachmentGeneration.ts` 小型共享 lifecycle helper。不迁附件状态 owner、不修改 runtime/store/协议/五语言文案;不夹写 root 的 BaseComposer、文档与 Gate。
|
||||
- Current 主链:首页/会话读取附件 → 当前草稿 owner → 既有 submit → preload/IPC → App Server `turn/start` 或 queue method → canonical Item/media readback。generation 只隔离本地读取;不建立第二套业务后端。
|
||||
- Happy path:同代读取正常接收并遵守既有容量、格式、路径与文案合同;clear/replace/unmount 后旧 success/error 均不改变新草稿或发旧 toast;下一代仍正常读取。先以 deferred FileReader 复现旧行为,再修复;不从 hook 测试推导跨线程 scope 或跨重启附件持久化已完成。
|
||||
- Evidence:确定性 hook/DOM 回归必需;GUI 使用受影响真实输入 fixture 验证发送与 Item/media 消费;不跑 live Provider,不形成 packaged/Windows 证据。失败在本地草稿 owner 停止,既有发送失败保留草稿规则仍有效。
|
||||
- 必跑:两个 hook 及输入消费者定向测试、renderer typecheck、窄 lint;冻结后受影响 Electron 输入 fixture、`verify:gui-smoke`、`verify:local`(Vitest resume)。无 public boundary 变化,不为该切片重复 contracts。
|
||||
- 场景:P1 首页/会话 rich input 与发送恢复。Supervisor/LLM judge 不适用;回写 deferred lifecycle tests,关闭条件为迟到 success/error 不回流且同代正向接收。
|
||||
- 架构:非重大,现有附件 owner 内提取共享生命周期原语,目录职责/协议/read model/依赖方向不变;全面 Composer 编辑器结构仍待对齐。
|
||||
|
||||
## Composer IME 候选确认 Verification Contract
|
||||
|
||||
- 负责人:root,2026-10-14。预算 `budget:normal`,风险 P1。
|
||||
- 参考:REA initial 六声明 `ev_38693c282fb15af95ea4b8f1caa292ed199fee587ea26d709b126123c3fb8153`;原 initial SHA `604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f`,cBc UTF16 10282434 检查 event/view composing/keyCode229,uHc 10371337 的 compositionEnd 只更新编辑状态。
|
||||
- 窄写集:`input-kit/BaseComposer.tsx` 与相邻测试、`Inputbar/inputbarSendPayload.ts` 与发送测试的 trigger union、既有 Electron 输入 fixture 中独立 IME oracle、研究/规则/执行文档。附件由既有 worker 独占;避让外部 Rust/command/approval 脏写集与未知根 JS。
|
||||
- Current 主链:BaseComposer DOM 编辑 → 既有 InputbarSend → current gateway/preload/IPC → App Server `turn/start` → canonical projection。候选确认不得发出 submit 请求;另一次普通 Enter 或显式按钮仍走既有主链。
|
||||
- Happy path:compositionstart 内部状态、native isComposing、Process、keyCode229 均保护编辑;compositionend 不安排发送;普通 Enter、Shift+Enter、button/stop 与历史回放保持各自合同。删除 pendingImeSendRef、旧 composition-send 和 ime trigger 类型,不保留 compat;将旧自动发送正向测试迁为负向及随后显式提交正向。
|
||||
- Evidence:DOM 确定性回归证明下一次 Enter 和显式按钮正常提交;真实 Electron rich-input fixture 以合成 composition/keydown 事件验证无提交请求及 canonical 空历史,随后真实按钮提交并读回同 Thread 的 Item/完整富输入。合成事件不冒充系统 IME 候选窗口或 Codex 实时 GUI 证据;不跑 live Provider。
|
||||
- 必跑:BaseComposer、ComposerController、InputbarSend/输入消费者定向测试;renderer typecheck、窄 lint;冻结后受影响输入 fixture、GUI smoke、默认本地门禁续跑。P1 多语言输入/意外提交;Supervisor 不适用,失败回写 owner tests 与 Gate oracle。
|
||||
- 架构:非重大,现有编辑事件与 metadata 合同内收敛,无 public protocol/read model/业务 owner 变化。完整富文本编辑器与 scoped persistent draft 仍单独待办。
|
||||
|
||||
## Composer 附件与 IME 冻结记录(2026-10-14)
|
||||
|
||||
- 附件:3生产/2测试文件,deferred FileReader 在旧实现中复现12项生命周期失败,修复后两个hook21/21通过;5个输入消费者172/172通过。只隔离回调,不中止FileReader;卸载采用layout cleanup同步失效。日志 `/tmp/lime-attachment-generation-{repro,hooks,consumers,lint}.log`。
|
||||
- IME:旧pendingImeSendRef、compositionEnd RAF submit、ime trigger类型及旧正向断言已删除;BaseComposer/Controller/InputbarSend 3文件41/41、IME与受影响oracle 6文件157/157通过。未将重叠的InputbarSend用例重复合计。
|
||||
- 标准typecheck、root窄lint、12项current/dead回流守卫通过;legacy report的零引用/分类漂移/边界违规均为0。新两个研究报告原被research全局ignore覆盖,已加入精确白名单;不纳入原始应用源码或Evidence大包。
|
||||
- 真实输入Gate:`.lime/qc/gui-evidence/claw-chat-current-fixture/codex-gui-alignment-composer-ime-final-summary.json` 的ok=true、exit0;四类IME信号无submit请求/无canonical Turn,随后真实按钮提交,backend富输入/取消、GUI文字/图片/路径/Skill恢复及read model interrupted均通过。mock/legacy/console/page error为0;合成DOM事件不证明系统候选窗口或Codex实时GUI。
|
||||
- 实际App Server路径为`lime-rs/target/debug/app-server`,SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`;pendingQueue/Browser最终Gate也使用prepare后的此摘要。worker此前重建摘要不替代实际Gate摘要,不推导脏树精确源码构建等价。
|
||||
- 默认门禁 `verify:local` 已退出0:五语言/unused、lint、硬编码扫描、标准typecheck、Vitest 91→121的31批3553项续跑(状态121/121 passed)、contracts、7个受影响Rust crate的3179项测试以及真实Electron GUI smoke全部通过。shell evidence为`.lime/qc/project-gates/standalone-shell-01-20261013203631-5833/shell-01-electron-smoke/summary.json`,含冷reload、3个窗口尺寸、memory设置和真实App Server初始化。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-composer-local-final.log`。
|
||||
- 本切片current,无新增compat/deprecated;被替代IME行为为dead/deleted/forbidden-to-restore。完整富文本结构、text_elements发送/恢复、持久草稿与全面账本仍未关闭。
|
||||
- 两项风险切片完成度100%;该百分比仅指上述合同,不作为全面GUI百分比或release/Windows证据。
|
||||
|
||||
## 结构化 Composer / EditorView Verification Contract
|
||||
|
||||
- 负责人:root协调,lime_gui_comparison负责input-kit文档/状态/serializer/view,lime_desktop_capabilities负责typed submission传输;GUI消费者与测试/fixture分属窄写集,在接口冻结后开启。预算`budget:normal`,风险P1。前一冻结写集的默认门禁已全部通过,不复用为本切片产品证据。
|
||||
- 参考:main-composer-audit的REA/ASAR identity;观察到ProseMirror EditorView、controller、serializer、keymap、submit-ready、destroy职责。开发源码类名unknown,不妨碍领域结构直接迁移;只确认style/诊断的名称不冒称export。
|
||||
- Current主链:同一首页/会话ComposerEditor → ComposerController唯一EditorState/document → 同次submit receipt → typed ComposerSubmission → start/steer/queue gateway → preload/IPC → App Server现有UserInput/text_elements → canonical Thread/Turn/Item;恢复只读receipt.draft,发送只读receipt.submission。
|
||||
- 冻结内部接口:ComposerDocument.editorDocument为严格JSON;display text/selection/mention/textElements/pending paste从唯一PM文档派生。ComposerSubmission为展开paste的text、typed TextElement(最终文本UTF8 byteRange)、typed skill/mention、attachments/path/capability。receipt保留revision与原始完整draft,新增submission;不以展开文本覆盖折叠JSON。SendMessageOptions显式传composerDraft/composerSubmission,不从metadata反推。
|
||||
- 文档owner:Controller持有EditorState,View只引用该state并回交Transaction;applyTransaction消费全部appended transaction后原子更新revision/snapshot。PM positions、display UTF16和最终UTF8分别映射;不建第二React/TipTap document store。已有`tiptap/pm`足够,不安装/升级依赖,不新增Rust/JSON-RPC字段。
|
||||
- 窄写集A:input-kit的Controller/types/hook/exports;新schema/serialization/positions/keymap/ComposerEditor及相邻行为tests。避让BaseComposer、产品adapters、wire与fixtures。
|
||||
- 窄写集B:InputbarSend及snapshot→HandleSendOptions/SendMessageOptions→preparation/execution→buildTurnInput的明确透传、queue的统一serializer与stream恢复draft构造;相邻typed/fixture tests。避让input-kit、BaseComposer、InputbarController/EmptyState/CharacterMention与外部Rust/command脏写集。
|
||||
- root写集:BaseComposer、InputbarCore/ComposerSection/index/styles、首页/EmptyStateComposerPanel、useInputbarController/HintRoutes/Dictation、CharacterMention及相邻tests/必要领域helper。主textarea/textareaRef/textareaProps/value/selectionStart/setSelectionRange直接迁移为ComposerEditorHandle,不模拟HTMLTextAreaElement。CharacterMention已超过1000行,只拆领域接线,不继续堆业务。
|
||||
- fixture写集在独立worker登记:仅主输入选择器迁`[data-testid="composer-editor"][role="textbox"]`,普通设置表单textarea保留;40余脚本的value setter/selection/disabled/读取与shared helper同步,DOM操作走真实EditorView事务,不调用测试侧第二业务API或生产mock。
|
||||
- Happy path:输入、mention/file/skill/plugin atom、长paste折叠/展开、selection、换行/撤销、图片/路径和完整history/draft恢复;start/steer/queue消费同一submission且保留typed ranges。确定失败保留完整draft,旧receipt与旧附件读取不覆盖新代。先复现text_elements/restore丢失,再迁移对应旧正向测试与护栏。
|
||||
- 键盘合同:IME guard先于mention/hint,检查event/View composing、Process/229;compositionEnd只编辑。hint消费后才history/submit;Shift+Enter换行,普通Enter/button按原canSend/defer/pointer去重/stop合同。paste图片/路径owner先消费,否则统一CRLF/CR→LF、大pasteatom;不允许HTML绕过schema。
|
||||
- 必跑:Controller/serialization/positions/View事务行为,输入消费者、mention/dictation、typed wire、queue/restore与fixture oracle定向测试;typecheck/lint/i18n;冻结后真实rich-input/IME/active-steer/home输入Gate、current fixture、contracts、GUI smoke与默认本地门禁。source-string或synthetic事件不冒充系统IME/Windows、跨重启或live Provider。
|
||||
- Evidence:deterministic与GUI trace/canonical readback必需,release-artifact与live/eval不需要;P1输入/错误恢复,Supervisor不适用。故障回写最近owner及产品Gate,禁止为旧selector恢复textarea。完整marks/code-block/链接编辑、voice、跨线程/跨重启persistent rich draft与Queue durable active handoff继续独立登记。
|
||||
- 架构影响:重大,Renderer document/transaction owner与typed输入数据流收敛;实施同刀更新architecture中的Composer图,root在冻结时核对state、snapshot、wire、恢复与测试门禁并确认。未确认/未取得新Gate不标记本切片完成。
|
||||
|
||||
## 结构化 Composer 实施与迁移记录(2026-10-14)
|
||||
|
||||
- A:schema/serializer/positions/keymap/Controller/View 的 5 文件48/48;7个消费者149/149;第二批 helper/五语言主输入命名7文件68/68(新增helper18)。各窄lint/diff通过。以上测试集有重叠,不合计成唯一总数。
|
||||
- B:typed submission→start/steer/queue 显式传输,完整 restoreDraft 深拷,精确 textElements remap、真实接受结果透传与失败重抛;20文件316/316及31文件窄lint通过。旧失败复现4/4保留;不以传输单测代替跨 surface 验收。
|
||||
- root:BaseComposer、首页/会话/mention/dictation迁至真实Editor;CharacterMention 1408行拆为主组件、选择hook、types和纯helper。root上一聚合3文件104/104通过,之后追加 shared owner/history 接线需 fresh 验证。IME guard先于hint/mention,compositionEnd只编辑;View卸载销毁,不创建textarea适配器。
|
||||
- fixture:首批72文件迁移,71 mjs语法检查、smokeChecks窄lint、14文件220/220与current-entrypoints16/16。追加完整typed input/same client User Item/media字节/active Steer/独立IME/唯一可见editor oracle:4文件84/84、受影响smoke9/9;未重复220基线。history Gate在同rich场景追加,冻结结果待记。
|
||||
- dead/deleted:`InputbarPluginBadge.tsx`、`SkillBadge.tsx`及3个旧Plugin文本helpers;catalog为retired-composer-reference-sidecars禁止回流,调用者/tests直接迁。旧主textarea领域名称、styles/ref/props及五语言core.textarea key均直接替换,无新增compat/deprecated。普通设置表单textarea不属于被退役主输入。
|
||||
- root文档写集:architecture Composer state/transaction/receipt/wire/restoration图、main-composer-audit、design-language、scripts README与本计划。重大架构确认:root,2026-10-14;已核对目录归属、数据流、依赖方向、既有协议边界与验证门禁;实际Gate尚待新冻结产物。
|
||||
- 1000行例外:EmptyState约1130行,本次仅注入共享Controller与既有资源/历史接线,不继续堆业务算法。能力route UI派生已拆独立hook;退出条件是后续首页能力/推荐逻辑改动先拆领域owner,当前不为行数引入第二store或接口包装。
|
||||
|
||||
## 首页/会话共享草稿与接受锁 Verification Contract
|
||||
|
||||
- 负责人:B负责既有workspace local display owner/scene与transport;root负责surface props、能力route与资源消费者;A负责Controller hook注入和附件generation。风险P1,budget:normal;沿用上节唯一业务主链,不新增JSON-RPC/Rust字段/依赖。
|
||||
- 实际缺口:request发布即切首页→聊天,局部Controller和图片丢失;failure只保留string。`return executeSendPlan` 未await导致finally提前释放prepare锁;迟到success能清新surface draft,queue可重复add。先失败复现:6项新断言均失败,日志`/tmp/lime-composer-scope-repro.log`。
|
||||
- 最小修复:用workspace Controller snapshot替换原input string store,同实例注入Home与Inputbar;materialization preserveInput延续scope,显式导航/new-chat/back-home完整reset并同步清canonical路径。附件直接消费Controller resources,不建空挂载镜像;能力canonical route在同owner,UI候选详情不是第二事实源。
|
||||
- 接受前同scope互斥;start/steer/queue与local confirmation等待真实返回后finally释放。确定false/reject完整草稿保留,success只有相同revision可commit;外部编辑/restore/reset或surface remount的旧receipt不清新输入,结束后正常重试/下一条仍可发送。
|
||||
- 必跑:shared hook mount/unmount与严格JSON/资源、deferred true/false/reject、新revision、explicit scope reset与same-scope hydration、materializer preserveInput、queue重复/失败重试;输入消费者history ArrowUp/Down回归;typecheck/lint,冻结后同新版Gate与默认门禁。
|
||||
- Evidence边界:Gate键盘history检查真实可见token/附件/path/capability与无额外input;hidden paste payload恢复由Controller行为test证明,初始wire另证明完整展开正文。不复制paste原文到DOM attribute以伪造可观察性,不把分层证据提升成不存在的history payload readback。
|
||||
|
||||
### 共享草稿补充护栏与新冻结
|
||||
|
||||
- A第三批9文件:external Controller不建shadow;两个附件hook直接canonical派生;generation对精确own写入豁免、外部同步重入/reset失效。clear/restore每次force新state/revision,普通同值setText/replaceDocument仍no-op。修前6红/38绿,修后6文件72/72与窄lint/diff通过。
|
||||
- B第三批19文件:shared workspace owner、显式scope reset、首页materialization preserveInput、await acceptance与统一start/steer/queue pending锁;9文件89/89、追加Reset2/2、19文件lint通过。
|
||||
- B第四批7文件:usePathReferences删除useState,同步canonical写与snapshot派生;空scope使用clear;useInputbarSend提交瞬间capture canonical资源/route,不回灌旧React props。path/scope旧行为3 fail、send旧closure3 fail;修后3文件26/26及7文件lint/diff通过。
|
||||
- root移除两surface路径与附件镜像;历史/中断恢复不再clear/add同owner资源,UI直接canonical派生。能力初始化signature与恢复requestId按Controller identity一次消费,避免重挂载回放旧预选/恢复覆盖用户编辑。Home发送同样读取当前文档,restoreDraft严格保留receipt原始route,不从fallback候选重建。
|
||||
- 新root失败归因:force restore后非稳定effect重复执行导致maximum-depth,已在同Controller恢复request owner加once消费;builtin fallback候选不能改原receipt route,恢复源回到完整原稿。失败日志`/tmp/lime-composer-root-canonical-tests.log`保留,不列成功证据。
|
||||
- history fixture追加5文件:真实accepted/clear→ArrowUp富草稿→ArrowDown空草稿;可信键盘事件、同session焦点、DOM结构/token identity/PNG字节/path identity/已挂载Skill、canonical与submit trace无新增。77/77新oracle、既有rich合同1/1与syntax/diff通过。真实Gate仍待统一build。
|
||||
|
||||
### 结构化 Composer 冻结后验证(持续更新)
|
||||
|
||||
- root五核心文件212/212、新增恢复请求重放回归后的Inputbar 92/92通过;标准renderer/node typecheck与窄lint通过。guards273/273、contracts、legacy report通过,后者零候选/分类漂移/违规。测试集有重叠,不合计为唯一总数。
|
||||
- 删除SkillBadge后唯一unused的`skills.inputBadge.remove`已从五语言`agentSkills.json`清理;i18n完整性与unused检查均exit0。第一次`verify:local`在该unused键失败,尚未形成新编辑器默认门禁成功证据。
|
||||
- GUI续测事实源移除旧主textarea选择器,改为唯一可见同session的ComposerEditor与真实EditorView编辑;普通表单与TUI textarea不在清理范围。
|
||||
- 首次新版rich Gate exit1,证据`.lime/qc/composer-alignment-review/rich/rich-summary.json`:prompt、大paste atom与Skill已插入,无console/page/invoke错误;expected `/var/.../SKILL.md`与App Server canonical `/private/var/.../SKILL.md`别名导致严格身份断言失败。夹具将解析实际文件realpath作为同一expected identity,保留错文件/错身份失败,不放宽为suffix检查。该失败不记成功;五语言清理后重新prepare并冻结再取产品证据。
|
||||
- 追加Skill identity:公开机器name是目录名`capability-report`,frontmatter `Capability Report`是display name;夹具明确机器name/token id/label,DOM/wire/canonical/history共用真实文件identity。同文件别名通过,同名同内容其他文件/missing/wrong identity失败。8脚本冻结,102/102新定向、既有rich合同1/1、syntax/format/diff通过;失败predicate回放保留。生产和App Server未改。
|
||||
- 新build/prepare exit0,实际二进制与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。新版active Steer、home-hotpath、home-hotpath-greeting均exit0(`.lime/qc/composer-alignment-review/{steer,home,greeting}`);Steer为runtime+本地provider fixture,typed request/client User Item/同Turn终态保真且无新turn/start或queue;home为external backend,绕过provider执行层。零console/page错误,不冒充live。
|
||||
- `verify:local`第二次在`InputbarCore.test`迁移后未使用的两个editor工具import处失败,已精确删除并清理旧textarea局部名/描述。后续默认门禁停在22/125:模型设置fixture缺少canonical通知、失败发送测试仍期待resolve,以及Node24原生fetch与jsdom AbortSignal混用。已分别修正合法空ACK/显式canonical通知、reject断言、Host测试的Node环境;完整useAgentChat 187/187、Host 49/49及窄lint通过。生产行为和真实HTTP/PNG缓存断言未放宽,下一次从失败批次resume。
|
||||
- 图片上传fixture已从错误Core祖先查找迁为实际添加图片按钮→filechooser,并严格核对chooser input属于同session唯一Editor所在Surface。53/53定向及rich合同1/1通过,媒体字节和identity oracle保持严格。
|
||||
- rich真实历史恢复暴露P1产品问题:CharacterMention将恢复的不可编辑atom标签解析为新提及,捕获方向键后阻断ArrowDown。只读诊断确认Controller历史游标和PM/display文末选区正确,诊断结果不作成功Gate。最小修复只从现有PM文档投影解析候选,并拒绝候选范围与atom重叠,不建第二状态owner、不改serialization或paste源文。
|
||||
- 新增五类atom的真实EditorView ArrowUp/Down组件回归与atom后新输入`@`/`/`正向;修前4项失败(text_element另改实际展示内容后覆盖),修后7/7,相关21文件162/162。renderer typecheck与四文件lint通过。修前/修后日志分别为`/tmp/lime-composer-history-atom-{repro,fixed}.log`;聚合为`/tmp/lime-composer-history-related.log`。
|
||||
- 键盘Gate observer修正capture+queueMicrotask提前读取问题,改同Editor后注册的bubble listener同步记录defaultPrevented。55/55脚本定向含6项事件时序回归,trusted/focused/session/严格消费与canonical不变;日志`/tmp/lime-composer-history-keyboard-tests.log`。夹具修复不能替代真实产品Gate。
|
||||
- 新增跨atom且含emoji的查询边界回归,history组件8/8;全部提及选择动作同样使用atom-aware解析,renderer typecheck与五文件lint通过。fresh rich第一次已证明history键消费、完整可见原稿/资源及返回空稿,但发送接受证据predicate仍失败,`.lime/qc/composer-alignment-review/rich-final/rich-summary.json`保留exit1,不计成功。
|
||||
- 修复真实质量缺口:Vitest resume只加载旧files会遗漏新增回归,并重跑失败批次之后已passed的批次。既有runner现复用同一collect/live过滤/buildBatches,追加新pending,保留旧批次/开始时间/失败起点并跳过passed。7项真实CLI+fake Vitest行为回归,runner19/19、相关30/30通过;只读preview原125批保留,新增126包含图片上传和CharacterMention.history测试,未修改默认状态。日志`/tmp/lime-vitest-resume-discovery-{baseline,targeted,related,preview}.log`。默认门禁现重新resume,新测试将实际进入执行。
|
||||
- 该次resume从22运行到34/126,在过期Skills静态guard失败:同session选择器已迁入真实shared helper,但guard的源集合未包含它。仅补`composer-editor-dom.mjs`进入真实依赖集合,原断言保留;完整guard82/82、窄lint通过。日志`/tmp/lime-composer-fixture-selector-guard-fixed.log`。生产mock/唯一owner/identity护栏未放宽。
|
||||
- 独立review发现P2:send preparation在结构化原稿分支以dispatch备用route覆盖receipt.draft原始route,造成失败恢复丢选择。仅保留原稿route,发送仍用当前dispatch route,非结构化fallback不变。新增3红复现(缺备用、冲突、原稿无route)后preparation8/8及相关14/14通过,原稿JSON/paste与发送/恢复route分开断言;日志`/tmp/lime-composer-receipt-route-{repro,related}.log`。此生产补丁重新开启冻结,需fresh renderer及产品Gate。
|
||||
- fresh rich发现发送接受oracle混淆原稿与最终dispatch文本:当前installed_skill route在正文前加已确认的`/capability-report `;真实wire/backend完整正文相同,client/canonical/keyboard均通过。期望现在由fixture机器名与展开原稿独立生成,wrong/missing/double前缀、尾缀、截断、status/client、多文本/多request均失败,最终wire的UTF8范围精确匹配;history/rich115/115及窄检查通过。正式rich已执行历史与cancel闭环,但common旧断言仍期待纯PROMPT,正在迁到完整prepared原稿精确恢复;该次exit1不记整体成功。
|
||||
- 默认门禁第二次续跑至43/126,旧Agent API fixture仍期待不存在的零queue count并把pending queue伪造成canonical Turn。测试迁为真实thread/read合同,独立queue owner保留;不恢复renderer排队历史过滤,不合成零值。Agent API与canonical projection42/42、lint通过;日志`/tmp/lime-canonical-session-fixture-fixed.log`。下一次从43续跑。
|
||||
- 旧textarea回流护栏的扫描范围遗漏新owner,现只迁该条catalog至完整input-kit与Home EmptyState,普通表单/TUI继续合法。隔离源树真实CLI复现4项漏检后,Editor/Controller/adapter/Home恢复旧路均exit1,真实current/普通表单/TUI源码exit0。相关core/summary/catalog234/234、真实全仓report零违规、lint/JSON/diff通过;日志`/tmp/lime-composer-legacy-guard-{repro,related,lint}.log`。其他catalog并行改动原样保留,无新增compat。
|
||||
- 生产写集再次冻结,重新prepare后取fresh rich/IME/history、active Steer/home、current fixture、GUI smoke与默认门禁证据。未修改依赖、锁文件、Rust或公共JSON-RPC字段;完整marks/link/code-block、persistent draft与voice仍独立待办。前轮成功不覆盖本次atom-aware trigger改动。
|
||||
|
||||
- 最终fresh rich Gate exit0:`.lime/qc/composer-alignment-review/rich-complete-final/rich-summary.json`,68/68,history11项与发送接受14项全部通过。历史导航前后完整输入request各1,无额外start/steer/queue;DOM原稿、Skill/paste identity、图片字节、路径及能力选择保真,ArrowDown回空稿;typed/full canonical/media readback13项通过。same-session唯一可见可用Editor、send-ready、cancel后无正文;console/page/invoke/actionable errors与mock fallback均0。App Server与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。external controlled fixture绕过live provider/runtime prompt lowering;键盘真实,IME/paste为synthetic DOM事件,hidden paste源文恢复由Controller tests证明。脚本134/134与场景合同1/1通过;`/tmp/lime-composer-rich-complete-final.log`为实际进程日志。
|
||||
- 默认门禁第三次续跑至52/126,external controlled pending Queue Gate未登记进后端边界精确白名单。只在`appServerRuntimeBoundary.testSupport.ts`追加既有`thread-queue-gate-b.mjs`及相邻测试两条精确路径,未放宽生产默认runtime或目录范围。backend boundary与Queue Gate tests7/7、窄lint通过;日志`/tmp/lime-external-fixture-registration-{fixed,lint}.log`。新版默认门禁继续从失败批次resume。
|
||||
|
||||
- 默认门禁第四次续跑至72/126,inputRestoreGuard漏登记统一`composerDraftRestore`,且旧UI直写字符串扫描已无实际命中。迁为两UI只能委托同一helper、Controller.restoreDraft只能在helper写入的非空owner断言;仅增加helper精确路径,不扩大目录。guard与首页/会话恢复行为157/157、窄lint通过,日志`/tmp/lime-composer-restore-owner-guard-{fixed,lint}.log`。产品写集保持冻结,继续resume。
|
||||
|
||||
- 默认门禁第五次续跑至73/126,workspaceBoundaryGuard仍强制旧input useState。迁为共享Controller hook、snapshot派生、canonical setText/clear合同,并用regex禁止旧独立input store回流;不改变布局/Banner或runtime truth边界。guard与共享hook/显式reset19/19、窄lint通过;日志`/tmp/lime-composer-workspace-owner-guard-{fixed,lint}.log`。
|
||||
|
||||
- 剩余未通过批次中的Composer source/contract护栏完成只读复扫;风险相关5文件16/16,未发现其他旧textarea/badge或独立生产input/resources正向要求,日志`/tmp/lime-composer-remaining-source-guards.log`。行为夹具的useState不是生产owner回流,不误删。旧codex-app GUI文档与执行计划导航已明确迁为历史基线,current实施入口唯一;未接管global/CLI/TUI并行进度。
|
||||
|
||||
- 默认门禁第六次续跑已通过前端126/126批(包含resume追加的新回归文件)与contracts;当前继续执行工作树受影响的7个Rust crate和GUI smoke。日志`/tmp/lime-composer-local-resume-final-6.log`尚未退出,不记完整默认门禁成功。
|
||||
|
||||
- 默认门禁第六次exit101:agent-runtime211/211,App Server1855通过/1失败,`json_lines_loop_streams_turn_failed_after_partial_external_backend_events`等待JSONL输出超时。未改产品终态、超时或断言;正在从仓库Rust测试入口定向复现并区分调度/fixture与runtime缺陷。首次直接cargo遗漏仓库V8 resolver,下载404,日志`/tmp/lime-app-server-partial-failure-targeted.log`不作该测试结果;已改用`test:rust:unit`,GUI smoke尚未执行。
|
||||
|
||||
- 正规单项Rust入口1/1通过,日志`/tmp/lime-app-server-partial-failure-owned-targeted.log`;整包超时原因仍未知,不以单项替代整包。一次直接test artifact诊断缺少Cargo dylib环境而dyld退出,`/tmp/lime-app-server-partial-failure-built-artifact.log`仅为setup失败。新版聚合首次在首场景初始化前因raw target/debug/app-server不存在而exit1,`/tmp/lime-composer-current-fixture-final.log`与unknown-item失败summary留存,不计产品通过。Desktop冻结复制品仍存在且SHA仍为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`,后续以它作为明确APP_SERVER_BIN,不重建或伪造raw路径。默认门禁第七次启用明确Node24与backtrace继续验证;产品/超时/断言未变。
|
||||
|
||||
- 默认门禁第七次fresh前端125/125、contracts与7个Rust crate共3179/3179通过:agent-runtime211、App Server1856、lime-agent278、lime-mcp174、lime-scheduler36、lime-server119、tool-runtime505。前次partial external JSONL失败项本轮整包通过,原一次等待超时原因仍unknown;未提高timeout或修改assert。GUI smoke开始合法重建sidecar,最终需要重新记录实际产物SHA与exitcode。
|
||||
- raw binary只读bounded调查未找到明确删除consumer:fixture仅清lock、assets清dist目的且禁止source/dest相同、V8/sherpa清下载缓存与临时解压、Rust destructive调用所读48文件只清测试资源;本地Rust入口为cargo test --lib且没有cargo clean。无系统调用或进程追踪,Cargo/并行进程归因unknown,不按mtime推断。本轮没有保留该调查的产品或文档改动。
|
||||
|
||||
- 完整默认门禁第七次exit0,日志`/tmp/lime-composer-local-resume-final-7.log`。fresh前端125批共12220个通过test instances、1671个报告通过文件;与前次resume的126批分母不同,不混用。contracts与Rust3179通过后GUI smoke24/24,证据`.lime/qc/project-gates/standalone-shell-01-20261013234813-36535/shell-01-electron-smoke/summary.json`:真实Electron/preload/IPC/current App Server、reload与3个viewport通过,所有console/page/invoke/preload/renderer/trace/legacy/mock错误计数0。GUI smoke合法重建raw与Desktop复制品后,两者实际SHA均为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;这不是前轮rich的旧SHA,后续场景必须记录自己的产物。新版聚合以明确Desktop `APP_SERVER_BIN`串行执行,日志`/tmp/lime-composer-current-fixture-final-2.log`,目前未退出,不提前记22场景通过。
|
||||
|
||||
### 下一刀补证:scoped persistent draft
|
||||
|
||||
- 独立只读REA与Lime存储/导航调查完成,无产品实施;[保存审计](../research/codex-desktop-rea/composer-draft-persistence-audit.md)与Evidence索引记录`ev_6ac3324c94242434149d9b820ee6cecdc0165021adadac0bdca91254ab787c29`,37 inert声明、structural complete/semantic unknown、parse失败0。
|
||||
- 上游两个key保存PM JSON/prompt/plainTextMode;document change合并发布、删除立即发布、dispose retained、pagehide/reload flush及main persisted atom同步有静态anchors。client/canonical local双写与home/cloud scope明确;真实重启/耐久性/完整资源保存unknown。
|
||||
- Lime目前没有keyed Controller草稿或持久store;TaskCenterDraftTab仅导航metadata,transcript cache与promptHistory不是未发草稿owner。两条首发materialization和无draft首页分支都需转绑定;sessionId和canonical threadId须显式区分,不可用restore改变待接受receipt revision。
|
||||
- 候选实施必须先登记新Verification Contract、唯一scope/I-O owner与架构图;不借用provider UI state、不预建runtime、不在现行Editor Gate期间夹入产品变更。已有存储封装quota删除重试与无成功结果需先修合同,跨重启附件不得用预览图或内存结果冒充。
|
||||
|
||||
### 下一刀补证:整条 UserMessage 折叠
|
||||
|
||||
- [用户消息补证](../research/codex-desktop-rea/user-message-collapse-audit.md)与索引已落仓;两个 parent Evidence 均为选定范围 structural complete / semantic unknown、parse failures 0,不运行目标。普通正文消费完整text字段,默认20行实测高度折叠,不能据此制造逐paste chip。
|
||||
- 名称只确认member stem `collapsible-user-message-content`、压缩绑定`v as t`与exports n/t;候选`CollapsibleUserMessageContent`是同职责领域命名,不声称恢复原开发符号。
|
||||
- current canonical用户正文在`ConversationTurnTimeline`直接走Markdown,`MessageUserBody`承接message_group另一入口;实现必须覆盖两者。复用MarkdownRenderer、VirtualizedTurnList的现有ResizeObserver与scrollController.holdViewport;不新增高度cache、消息store、协议或runtime。
|
||||
- 候选最窄写集为新折叠组件/相邻tests、两正文入口、MessageList/MessageListItem的callback透传与五语言agentMessageList.json;实现前另填Verification Contract。复制/编辑继续使用完整原文,图片/Skill标签留在正文折叠之外。代码块裁剪、隐藏交互焦点、内容/宽度重测与find reveal按可确认合同逐项验收,不能以字符数估计高度替代。
|
||||
- 当前只读准备完成,产品尚未实施;需在结构化Composer新版聚合/默认门禁关闭之后开启。参考窗口表现及未穷尽producer/Markdown分支继续unknown。
|
||||
|
||||
### 正常 UserMessage inline 引用追加补证
|
||||
|
||||
- 同摘要成员的普通exact AST闭合正常Markdown link token→Skill/plugin/app专用consumer链;新REA只核对primary Hd身份项1/1963,返回形状仍unknown,不能当完整模块语义。审计与索引保存4个成员SHA、12 exports、10 imports、30声明范围与37 anchors。
|
||||
- Skill按规范化href/path或无href时名称唯一匹配catalog才绑定身份;保留label/href/copy属性与条件侧栏导航。plugin/app有独立URI/host/metadata consumer。裸$skill、producer→wire→history、复制回贴与真实点击仍未闭合。
|
||||
- 旧“正文下层Skill/mention未知”已由这条静态consumer补证局部推进;原typed sidecar不直接进入字符串的观察仍成立。Lime已有Composer atom、typed wire和单个route标签,不足以证明正文内联身份回放已对齐;产品迁移继续独立待办,不改wire或伪装任意label。
|
||||
|
||||
## 长 UserMessage 折叠 Verification Contract
|
||||
|
||||
- 负责人:root;登记:2026-10-14。预算 `budget:normal`,风险 P1。开启条件已满足:结构化 Composer 的新版 current fixture 聚合与完整默认门禁均 exit0;当前切片已开启。
|
||||
- 参考:[整条用户消息审计](../research/codex-desktop-rea/user-message-collapse-audit.md)的两个 REA parent 与同摘要精确 AST。确认 member stem、20 行实测阈值和整条正文折叠;原开发 export、精确裁剪样式与目标应用真实交互仍 unknown。`CollapsibleUserMessageContent` 为本地职责命名,不写成 confirmed upstream export。
|
||||
- 产品窄写集:新正文折叠组件及相邻行为测试;`ConversationTurnTimeline` 与 `MessageUserBody` 两条正文入口;`MessageList`/`MessageListItem` 只透传既有 `scrollController.holdViewport` 与 focused Item/reveal token;五语言 `agentMessageList.json`。如 installed Skill 分支正文需拆分,只改 `MessageListUserContent` 中同一正文接点,标签和图片留在折叠外。不修改 Markdown wire、Composer、runtime、store、协议、未知 Rust/approval/command 改动。
|
||||
- Current 主链:既有 typed submit 或历史读取 → Electron preload/IPC `app_server_handle_json_lines` → App Server `turn/start`、`turn/interrupt`、`thread/read` → 同一 canonical Thread/Turn/Item → `canonical_turn` 或 `message_group` → 现有 Markdown/纯正文 renderer → 显示折叠。折叠只拥有局部显示与焦点状态;没有新的 gateway、runtime event、tool、approval 或 artifact。
|
||||
- Happy path:全量正文布局高度严格超过 `20 * lineHeight + 1px` 才显示初始收起和切换按钮;短正文无按钮。正文/字体/宽度变化重测并尊重用户展开选择。折叠不裁掉源字符串,不改变 typed ranges/媒体/引用身份;复制与现有编辑仍传全文。
|
||||
- 焦点与列表:按钮支持真实 Tab、Enter、Space 与准确 `aria-expanded`;被遮挡的互动元素不可聚焦,可见互动元素保留可达。收起时若焦点即将被隐藏则回到切换按钮。使用现有 virtual row ResizeObserver/anchor 算法,切换前 holdViewport;不加第二高度 cache。现有指定 Item 导航与浏览器 find reveal 的支持范围需由实现和证据明确,不能默认宣称全部覆盖。
|
||||
- 定向回归:严格阈值上下 1px、软换行、代码块跨边界、正文/宽度重测、展开偏好、内容身份更新、observer 清理、隐藏/可见链接焦点、全文复制/编辑、两个入口与五语言。护栏验证两入口共享单一正文 owner,不保留字符数截断或平行用户正文实现。
|
||||
- Electron 窄写集:rich 场景只接独立 collapse helper;launcher/scenario-flow 仅传测试侧 clipboard reader;相邻 oracle/tests;session-history long-list fixture/oracle/runner补非尾部 canonical 长正文、冷启动、焦点与相邻锚点。全新 helper 进入既有 `scripts/agent-runtime/` 或 `scripts/electron/lib/`,不得新建脚本领域或产品 IPC。
|
||||
- Gate B:每条可见 bubble 要关联 ancestor `data-render-entry-kind`、exact Turn/Item、唯一 user bubble;canonical 另核 `data-direct-segment-kind=message`。rich 历史68/68未采该入口,且 itemId 为 null,不能据旧 readback推断其 renderer。真实点击/键盘后采 ARIA、实际几何与尾部;两状态用 Electron clipboard精确比对全文,仅落长度/hash/布尔证据。若 rich 实际走纯正文,只证明该入口;链接焦点由 canonical long-list补证。
|
||||
- 锚点与业务不变:真实 wheel进入距底大于64px旧历史,切换前后比较 surviving first-visible Turn key与viewport-relative top,后继row offset跟随实际高度且不重叠。完整trace禁止新增 `turn/start`、`turn/steer`、`thread/queue/add/update/start`;再次thread/read核正文/content parts/text elements/client/Item/Turn一致。缺trace、错入口/Item、只改ARIA、截断copy、隐藏链接可聚焦、锚点漂移或相邻重叠均失败。
|
||||
- 必跑:受影响 component/projection/oracle tests、renderer typecheck、窄lint/i18n、legacy report;冻结后rich与long-list真实Electron场景、current fixture、GUI smoke与默认本地门禁。未改public boundary时不重复contracts;无live Provider、Windows/packaged或参考应用实时证据。
|
||||
- 架构影响:非重大,renderer在两条既有正文路径复用单一局部显示组件,原投影/持久化/测量/滚动owner与依赖方向不变。实施结束复核实际调用图,若引入跨层owner改变则重新登记并更新architecture图。
|
||||
- 退出条件:两个入口与所有调用者迁移、有效旧测试/fixture/护栏同步、定向和真实证据通过。Skill/plugin/app专用inline consumer、persistent draft、marks/voice与其他全面账本不在本切片完成分母。当前产品/新Gate完成度0%。
|
||||
|
||||
### Composer 切片关闭与长正文实施记录
|
||||
|
||||
- 新版聚合exit0,22/22实际Electron场景成功,所有声明assertions为true;证据快照`.lime/qc/composer-alignment-review/current-fixture-complete-final/aggregate-summary.json`及各场景summary,日志`/tmp/lime-composer-current-fixture-final-2.log`。实际backend SHA仍为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;21个Claw场景明确mock/legacy计数0,Coding场景缺独立mock计数字段,不伪填0。全部console/page errors0、noInvokeErrors true;runtime场景使用本地provider fixture,其余external绕过执行层,liveProviderUsed=false。聚合通过不扩大各子场景合同。
|
||||
- 结构化Composer当前实施合同(唯一Controller/EditorView、typed submit、共享surface草稿、接受锁、atom历史与receipt恢复)100%关闭;marks/link/code-block、跨重启scoped草稿、voice及任意正文inline引用consumer仍在待办。全产品不提供无明确分母的百分比。
|
||||
- root认领两正文入口、tagged正文、MessageList/Item回调/准确identity接线、五locale及相邻integration/纯identity回归。组件归lime_gui_comparison,rich归composer_fixture_migration,long-list归lime_desktop_capabilities;产品开启前草稿只在/tmp,未夹写冻结产物。
|
||||
- 新共享组件从自然full root读取实际lineHeight;内层RO、同高度异步descendant变化MO、隐藏互动inert/原值恢复、焦点preventScroll、递增reveal去重。MO缺口先红复现后修复;组件11/11与窄lint通过。用户展开选择只在当前实例保留,虚拟卸载后重挂与冷重启默认重置;尚未声称跨卸载选择持久化。
|
||||
- 两入口的复制/编辑保留现有完整字符串,tag/image在折叠外。用户bubble identity纯转换移入`messageItemIdentity`,精确Item/client匹配并去重;同Turn多User未匹配或重复client时fail closed,不取第一项。根写集78/78(含组件11、identity6、integration11和既有消费者50)与标准typecheck通过,日志`/tmp/lime-user-message-collapse-{related,typecheck-final,root-lint}.log`。首次误用不存在的Vitest配置为setup失败;首次integration两用例被canonical投影吞并,改为纯identity测试与合法local正文reveal,不恢复错误renderer路径。
|
||||
- 新脚本治理通过:`/tmp/lime-user-message-collapse-scripts.log`;未新增根脚本或一级领域目录。rich 首命令因误传 `--output-dir` 在参数解析阶段退出,未启动 Electron,不算产品失败;改为 `--evidence-dir` 后真实 Electron 已启动,但共享 trace oracle 错误要求所有 current request 显式携带 `jsonrpc: "2.0"`,合法 sender 的缺省版本被拒绝。日志 `/tmp/lime-user-message-collapse-rich-gate-2.log`;失败证据 `.lime/qc/user-message-collapse/rich-final/user-message-collapse-rich-final-summary.json`。仅修复 oracle 对合法协议形状的识别,保留错误显式版本、截断/饱和、解析遗漏和输入 mutation 的负向门禁;真实 rich 与 long-list 尚未通过。
|
||||
- 缺省版本修复已冻结,current envelope 与既有 oracle/parser 合计243/243通过;实际 schema fixture 明确 `sendsJsonRpcVersionField=false`。历史 rich trace 达240条,不能凭过滤后的 request 数绕过完整性要求;增加仅测试侧的连续 append 观察,保留原 native Storage 调用语义,并以同一 observer run identity 校验 baseline/final 完整前缀;原 ring 模式饱和仍失败。长历史首跑的原滚动/分页 oracle通过,但新 probe224不在实际已加载227–240区间,故退出1;改为已加载非尾部 probe并保留前后邻居、真实 wheel 与冷启动合同,不能延长 timeout 或模拟滚动/历史请求。日志 `/tmp/lime-user-message-collapse-long-list-gate-1.log`;失败目录 `.lime/qc/user-message-collapse/long-list-final/`。
|
||||
|
||||
- 连续 collector/rich 写集于 `2026-10-14T00:44:42Z` 冻结,6套286/286通过;manifest `/tmp/lime-user-message-collapse-rich/continuous-collector-write-set.json`。collector仅测试侧连续观察 native Storage,独立重算每次 append 的前后 ring/hash,seed不算accepted input,hook丢失/非法写入/reset/restore失败拒绝通过;raw ledger只驻内存,证据仅留模式/run identity/count/hash及实际 `nativeRestored`。
|
||||
- long-list 写集已冻结,5套本体186/186与共享collector69/69(初次teardown错误修复后复跑)通过;9文件lint/格式通过。probe统一232,实际检查末页声明和mounted canonical tail,再以真实wheel验证231/232/233;正文UTF16 9682、UTF8 17673,SHA-256 `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`。runner最小消费pure白名单失败extractor,不落正文。产品15文件摘要存 `.lime/qc/user-message-collapse/product-write-set.json`,raw与Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新的真实Gate正在重跑,不能将以上unit/integration计作Electron通过。
|
||||
|
||||
- 新rich真实运行仍exit1:连续collector32733次append、`complete=true/nativeRestored=true`,但初始collapsed几何未达合同;console/page errors为0。日志 `/tmp/lime-user-message-collapse-rich-gate-3.log`;证据 `.lime/qc/user-message-collapse/rich-continuous-final-1/`。新增safe失败extractor21项与既有测试合计277/277通过,只补取证,不放宽identity/几何/timeout。
|
||||
- 新long-list真实运行exit1:进入已加载probe时工作区触发React #185更新循环,frame消失,故报wheel scope不匹配。safe诊断记录进入前准确session、14-entry末页、canonical tail240与连续trace无mutation/错误、native恢复;不能将scope消失误记夹具通过。证据 `.lime/qc/user-message-collapse/long-list-continuous-final-1/`;开发构建 `.lime/qc/user-message-collapse/long-list-debug-1/` 复现Maximum update depth exceeded。当前定位虚拟行重挂时full→collapsed高度与同步range/anchor的反馈,需完整堆栈与先红回归确认,尚未修产品。default/current-fixture/GUI smoke本切片均未开启。
|
||||
|
||||
- 开发构建第二次复现的完整stack `.lime/qc/user-message-collapse/long-list-debug-2/` 指向 `CollapsibleUserMessageContent.measure` 的 `setMeasurement` 与同步layout effect。组件owner认领原两文件,候选最小修为dispatch前同值ref guard、同步测量不依赖每次父render的新children对象;真实DOM变更继续由RO/MO重测,inert/reveal/20行合同保持。定向先红回归需区分重复测量触发路径与真正#185复现,真实dev及fresh packaged Gate负责关闭bug;不提前写已修复。
|
||||
|
||||
### 长正文动态测量反馈修复(2026-10-14,进行中)
|
||||
|
||||
- 组件最小修已冻结:dispatch 前使用 measurement ref 拒绝同值,同步 effect 不再依赖父层新建的 children JSX;真实子树变化仍由 MO/RO 测量。组件与现有列表/接线 31/31,通过不代表真实更新循环已修,dev Electron 仍失败。
|
||||
- 新父层回归 `VirtualizedTurnList.measurement.component.test.tsx` 已直接先红复现 Maximum update depth exceeded:阅读锚点 turn-10 顶部 -40px,上方 overscan 中 turn-8 实际行高从 1200px 降至 224px,经父 RO 测量后 mounts=18/unmounts=18。真实 React、现有折叠组件和虚拟列表,仅为 jsdom 注入几何/RO;断言 DOM identity、锚点与挂载次数,不复制保锚算法。root 复跑失败日志 `/tmp/lime-user-message-collapse-parent-red-root.log`。
|
||||
- 测试侧被动 native-full-rect-call 采样已冻结,三文件窄写集:long-list input helper、geometry 白名单、新相邻 input test;4套78/78和 lint/格式/语法通过。最多300条、WeakMap mountId、错误时同步冻结、退出恢复 native;计数仅针对有界窗口,completeHistoryClaim=false,不宣称所有调用均由组件 measure 发起。
|
||||
- root 下一生产窄写集为既有 `VirtualizedTurnList.tsx` 的 render range 接点及上述相邻回归;不改共享协议、runtime、scroll owner 或增加高度 store。候选为 layout 变化时先复用 preserveThreadAnchor 求与新 layout 一致的 range top,既有 layout effect 继续提交实际滚动;等待 dev 被动采样再实施。所有 frontend/test 写入在真实运行期间冻结,避免 Vite reload 污染证据。
|
||||
- 本轮未关闭项仍为 fresh rich、long-list/cold restart、current fixture、GUI smoke 与 verify:local。旧 15 文件源码摘要已因组件修复失效,收口时重新生成。
|
||||
- 原生 dev 采样失败证据 `.lime/qc/user-message-collapse/long-list-native-debug-1/`:176次调用、172个mount、10个identity重挂,dimensionChangedMountCount=0/oscillatingMountCount=0;probe232反复新mount且自然正文8301.109375px保持不变。采样窗口不证明完整运行历史,摘要samples另受安全白名单有界输出限制;native已恢复,错误明确为Maximum update depth exceeded。
|
||||
- root最小修已落在原range接点,仅对pending layout使用既有preserveThreadAnchor或新tail top选窗口,不在render更新旧基准或滚动。先红1项修后通过,相关列表/几何/组件24/24、两个consumer/identity17/17及四文件lint通过。dev真实复跑进行中,仍未声明产品bug关闭。
|
||||
- 修后dev `.lime/qc/user-message-collapse/long-list-native-fixed-1/` 到达collapsed-copy:console/page errors均0;29次native调用、12个mount、remountedIdentityCount=0。因测试用无用户手势navigator.clipboard.writeText设置sentinel被权限拒绝而exit1,不能算完整Gate通过,也尚未证明产品copy成功。标准typecheck exit0;下一步把测试sentinel与finally恢复接到Electron main clipboard,真实按钮与全文readback仍为产品oracle,不修改权限或增加renderer fallback。
|
||||
- 新pending导航竞态回归已冻结:同一probe收缩期间目标DOM/位置稳定;真实owner的RAF提交后target top=10px且请求完成,后续用户滚75px旧请求不回拉,第二次导航仍对齐。5文件36/36、相邻lint/格式通过,无sleep或timeout放宽;测试与先前结果重叠,不相加为唯一总数。fresh packaged renderer已开始构建。
|
||||
- 同步更正GUI E2E规则中强制显式jsonrpc版本的过期断言,按生成schema与current sender接受缺省或显式2.0,仍拒绝错误版本;不改变协议、客户端或production authority。
|
||||
- fresh renderer构建exit0(33.98s),复用已新鲜的Host/sidecar;源码17文件与三个宿主产物摘要 `.lime/qc/user-message-collapse/product-write-set-after-anchor-fix.json`,renderer全文件摘要 `renderer-artifact-after-anchor-fix.json`。snapshot复核无漂移,raw/Desktop sidecar仍同SHA。legacy report零候选/分类漂移/违规、脚本治理、父接点格式、文档护栏12/12均通过。
|
||||
- long-list main clipboard接线三文件已冻结,14/14定向与lint/格式/语法通过;writer只设置sentinel和恢复,真实产品按钮产生正文,shared main readback继续核完整hash/length。该定向是controlled callback接线证据,不计实际OS/Electron复制成功。
|
||||
- rich/shared七文件于2026-10-14T02:20:41.624357Z冻结,6套319/319与lint/格式/语法通过,manifest `/tmp/lime-user-message-collapse-rich/host-clipboard-write-set.json`;缺回调、读失败/nontext、sentinel未交付、截断、按钮失败、恢复抛错/读失败/不匹配和双重异常都拒绝成功。旧helper page参数与renderer写剪贴板已删除;finally写回后另从main验证恢复。root核对manifest无漂移,fresh packaged long-list已启动。
|
||||
- fresh packaged long-list第二轮exit1,`.lime/qc/user-message-collapse/long-list-packaged-2/`:准确collapsed几何与邻居挂载通过,console/page errors0,随后真实产品按钮copy未满足全文hash/length。main sentinel准备已成功,不能把该失败再归为fixture写入权限;具体是clipboard rejection、错误正文还是交互反馈尚待安全观察确认。末尾scope仍正确,用户wheel引发的有界重挂不等于之前同步#185。新fresh rich正串行执行;所有product/scripts写集在运行期间冻结。
|
||||
- fresh rich第二轮exit1,`.lime/qc/user-message-collapse/rich-packaged-2/`:同一canonical Item/client同时出现canonical_turn正文(collapsed/root1)与message_group空壳(root0/26×14px);严格唯一identity拒绝通过,不能放宽matcher只取正文。源码确认canonical已经消费同一user image;纯projection复现为inputCapabilityRoute单独保留导致残留空bubble,并非图片未消费。capabilities认领projection与相邻ownership/userCollapse三文件,只迁移canonical已拥有user的route残留,不动其他richsurface或无canonical本地消息;media_reference重复候选单独登记。
|
||||
- Clipboard原生观察收敛为测试侧sharedhelper及相邻observation、long-list安全失败字段,不新增Host callback/权限/reader API。原promise/this/args/同步异常保持,侧链记录安全错误enum且清理恢复;观察引入rejection handler的微任务边界要注明。实际write错误确认前不改产品权限。
|
||||
- route残留三文件已冻结:projection只在exact canonical user-owned residual清inputCapabilityRoute,其他富surface与无canonical local消息保持;先红3fail,修后ownership/projection/userCollapse/directTimeline四文件53/53、三文件lint/格式/diff通过。新集成证明同client/Item只有1bubble/1正文/1图片/1copy,两个typed标签与完整copy/图片preview身份不丢。未改directTimeline既有脏补丁。此前17文件源码快照已因projection与userCollapse变化失效,后续重新取新的摘要和renderer构建。
|
||||
- 同边界独立待办:collectCanonicalMediaKeys未覆盖user image parts,可能产生同引用residual media_reference;尚未修改,不由route修复计作关闭。Clipboard补证已记录于user-message-collapse-audit:main存在readText/writeText宿主服务,但普通UserMessage完整caller链unknown,不把sandbox的权限策略移植到主窗口。
|
||||
- route修复后的renderer构建与标准typecheck均exit0,日志分别为`/tmp/lime-user-message-collapse-renderer-after-route-build.log`和`/tmp/lime-user-message-collapse-typecheck-after-route.log`。测试侧原生clipboard observer正在最终冻结:保持native this/全部参数/同步throw/原Promise,安全proof区分实际仍为sentinel、全文不匹配及权限/焦点错误;观察侧链会增加rejection handler和微任务,不冒称完全无影响。待真实错误证明后再决定产品权限或fixture焦点的最小修复,当前未修改主窗口permission owner。
|
||||
- clipboard观察11文件已最终冻结,8套258/258、lint/格式/11项Node语法与窄diff通过;manifest `/tmp/lime-user-message-collapse-rich/passive-clipboard-write-set.json`,SHA-256 `2082c12413842eacd391acaad8654454f3b243e0a549d0e65d1abeaee61761ed`,root复核全部11项无漂移。rich聚合oracle失败时未带成功返回phase proof是已登记的诊断限制,不影响long-list本次取证。fresh源码19文件与renderer3190文件快照分别为`.lime/qc/user-message-collapse/product-write-set-after-route-fix.json`、`renderer-artifact-after-route-fix.json`;旧快照保留,不覆盖。真实long-list第三轮已启动,目录`long-list-packaged-3`,未预判结果。
|
||||
- long-list第三轮exit1,真实native proof为唯一writeText调用、全文9682字符且expectedTextEqual=true、原receiver、focused/visible/userActivationActive全部成立,但NotAllowedError rejected,实际main read仍为sentinel。observer intervalComplete/nativeRestored=true,console/page errors0。errorKind未知,不擅自重写成已捕获permission-denied;结合生产permission policy明确拒绝clipboard-sanitized-write,进入最小权限修复并由fresh成功验证因果。
|
||||
- 权限修复窄写集:comparison worker仅迁移`electron/mainWindowMediaPermissions.ts`及相邻测试为`mainWindowPermissions.ts`/`.test.ts`,唯一策略同时承接request/check;root仅迁移`main.ts` import与安装两处,保留原browserTabOwner脏补丁。主window主frame允许sanitized-write,读/其他window/null/child/unknown拒绝,媒体保持原能力并区分request数组/check单值,不改embedded browser独立session,不加clipboard IPC/fallback。非重大架构变更,仍由既有Desktop Host permission owner管理;退出条件为先红回归、定向/Host typecheck、fresh Host、真实全文copy及对应门禁,无Windows实跑声明。
|
||||
- 权限模块迁移已冻结:旧策略27项中7项先红,迁移后permission27 + windowOptions7 + loadErrors3合计37/37;两文件lint/格式、完整Electron typecheck均通过。模块SHA `b98655e57bd66ee3b5388f327096feb0ac50a91e2b01b066c310a755f158e6d0`,测试SHA `a5eca6f91b68de7163346f5fae8ce21952f6eb6dd50c35f8fffd866621150643`;旧文件/符号在Electron中无残留,无alias。root增加测试侧native query helper和相邻oracle测试,session-history入口只接import/call;query主/child secure context与读写状态,不读剪贴板内容、不替换handler,finally移除iframe。17/17和root窄lint/格式通过,真实query与copy待fresh Gate;另window只由策略/handler回归覆盖,不把query结果扩大为实测。
|
||||
- tests/guard只读审计未发现产品旧文件名锁定,当前DOM字段服务真实几何和identity。generic collapse oracle合法支持两正文入口,继续保留其message_group正向;rich accepted canonical wrapper需额外明确canonical_turn,避免canonical退回local仍过验收。fixture worker认领shared helper/evidence与相邻rich行为回归,同时修复聚合失败遗漏安全clipboard phase proof;不改产品或long-list已冻结写集,不以源码字符串guard代替实际helper行为。
|
||||
- 同user裸图片media_reference的只读probe已定位独立缺口:相同exact Item/client与直接image URI应由canonical接管,当前仍留下空residual;caption、audio kind、不同URI、refId读取owner、assistant同图说明均需保留。该候选尚未产品实施,不无条件扩全Turn URI集合,不将sidecar缩略图成功当作点击预览职责等价。
|
||||
- 同Item裸图片去重补刀已认领原projection/ownership/UserCollapse三文件,写窗口与Electron permission互不相交。合同只覆盖exact canonical user owner的同一直接图片裸引用;caption/title、refId/sidecarRef/sha256/byteSize、不同previewUrl、MIME/定位冲突、unknown kind以及assistant同图语义保留。先红空残留与保护变体后最小修,不增加第二投影owner、不扩全Turn媒体集合、不碰directTimeline旧脏补丁;定向集成需继续锁一bubble/图片/copy与原preview identity。其他typed媒体职责不在本补刀完成分母。
|
||||
- 同Item裸图片三文件已冻结:先红3fail后projection/ownership/UserCollapse/directTimeline四文件77/77;自有三文件lint/格式/diff通过。projection SHA `740b58aa3a880dd86f00a695a6560057ddd165f1dc43f089345c50e58ea28c1f`,ownership `15014d8ee3f41dcca92741480195423d0ee22f83af333638a54e29c7d730fe65`,UserCollapse `dfd53221e27e78982b31c3d7564eba7c5d83dd7705dfc0a80c95604f43fb7f48`。匹配仅直接URI,额外已定义字段/未知字段与sidecar保留,不扩大媒体职责。
|
||||
- rich测试侧owner合同与安全phase补证已冻结:7项先红6失败、generic message_group1通过,最终5套184/184与lint/格式/3项语法/diff通过。manifest `/tmp/lime-user-message-collapse-rich/rich-canonical-phase-proof-write-set.json`,root核对3文件无漂移。generic两入口正向继续有效;rich wrapper要求canonical_turn,aggregate与native复制失败只保留安全clipboardPhases。前一passive manifest的shared helper摘要已被此新manifest取代,不覆盖历史快照。
|
||||
- fresh Host/renderer均exit0,main新SHA `9401e36c299c48f1032061406a561b1a27d7e60240953ee5ddb646d0df108003`;raw/Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新immutable源码22文件与renderer3287文件快照为`product-write-set-after-permission-fix.json`/`renderer-artifact-after-permission-fix.json`,query窄写集另存`permission-query-write-set.json`。root当前rules/docs guards23/23、脚本治理均通过。
|
||||
- fresh long-list第四轮exit1,目录`.lime/qc/user-message-collapse/long-list-packaged-4/`。主window query write=granted/read=denied,child write/read=denied且均secure、iframe已移除;query只证明check。collapsed/expanded两个严格全文copy阶段均通过,最后原生write fulfilled、全文9682/hash一致、nativeRestored=true;权限修复已有真实因果补证。随后collapse-anchor失败:收起前first-visible successor233 relativeTop=-8.03125px/scrollTop9714.5,收起后first227/scrollTop0;console/page errors0。冷启动尚未执行,不算Gate通过。
|
||||
- 新锚点修复写集:comparison worker仅在`VirtualizedTurnList.measurement.component.test.tsx`模拟浏览器随DOM总高收缩clamp scrollTop,保留真实React/现有virtualizer与折叠组件;0/非零prefix两变体先红,不复制保锚算法。root生产只认领原`VirtualizedTurnList.tsx` layout effect,候选直接提交`currentOffset + nextTop`以免在已clamp位置重复扣高度delta,并移出无用offset ref。现阶段为候选,不先写已修复;真实successor/cold Gate负责关闭。
|
||||
|
||||
- clamp回归先红完成:14条、prefix0/160两变体均被合法layout clamp780px,anchor DOM保持但top从-40变740;原30条overscan与pending导航均绿。root在原effect直接提交currentOffset+nextTop,删除无用previousOffsetRef;修后5文件40/40、生产/测试窄lint和格式通过。新增测试初始化已有一次probe卸载,绝对unmount=0误断言改为折叠前后无增量,DOM/top/clamp/mount其余oracle保持。测试SHA `4716403682585300138c0d1922968c193a679fb4055cd1c41d714661762e3a68`。单位回归已关闭clamp缺陷,fresh真实Gate仍待验证;renderer重新构建与标准typecheck正在执行。
|
||||
|
||||
- fresh renderer与标准typecheck均exit0,源码/宿主与renderer快照分别保存为`product-write-set-after-clamp-fix.json`和`renderer-artifact-after-clamp-fix.json`,未覆盖旧证据。真实long-list第五轮exit0,`.lime/qc/user-message-collapse/long-list-packaged-5/user-message-collapse-long-list-packaged-5-summary.json`:interactive/cold restart均ok;collapsed/expanded/cold copy完整9682字符,SHA `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`,native fulfilled/observer恢复、可信鼠标键盘均通过。后继233相对top从-8.03125到-8.234375px,漂移-0.203125px,无重叠;旧PID退出且新进程启动,之前expanded的实例冷恢复默认collapsed。canonical正文/content parts/text elements/User Items/Turns不变,连续trace完整、禁止mutation0,console/page errors0。只证明surviving successor锚点,不声明消失正文内部offset保持;实际macOS Electron+编译fixture,不等于Forge安装包或Windows/live Provider。
|
||||
- fresh rich第三轮exit1,`.lime/qc/user-message-collapse/rich-packaged-3/user-message-collapse-rich-packaged-3-summary.json`:同Item/client唯一canonical_turn气泡/正文成立,旧空泡已消失。copy-collapsed真实click遭同一user bubble/.space-y-2截获,sentinelObserved=true/buttonClicked=false/native calls0,observer完整并恢复,console/page errors0;这是可点击性失败,不能归为复制权限失败。现有安全快照缺footer/button实际rect及hit-test,fixture worker认领独立安全诊断及相邻测试,不存正文/URL/raw error,不force/dispatch/放宽timeout。root读取styles发现footer由MessageWrapper:hover或focus-within显示;generic wheel仅准备viewport,而long-list references探测经过Tab、rich无此准备。测试漏真实hover为候选,需前后几何和可信hover证据确认,未改产品样式。当前切片保持partial,旧Composer聚合/本地成功不得关闭本轮门禁。
|
||||
- 本轮contracts入口exit0,日志`/tmp/lime-user-message-collapse-contracts-final.log`,包含protocol/client/commands、脚本/发布/desktop reference/CLI边界与docs检查;更新后的current-rules/current-docs两套23/23,日志`/tmp/lime-user-message-collapse-docs-guards-final.log`。capabilities只读覆盖核验未发现产品/测试/脚本恢复旧权限名或锁死旧DOM的guard;当前主frame写与读/子frame/other/null/unknown拒绝、媒体request数组/check单值、唯一canonical rich正文、route/直接图片保护、bounded虚拟挂载/锚点消失/clamp/pending导航均保留行为回归。没有为了绿结果删除有效风险断言,未重跑已经通过且未变的定向集。
|
||||
- copy准备窄修已冻结:独立安全geometry与prepare helper,shared gate/evidence/rich行为回归合计5文件,manifest `/tmp/lime-user-message-collapse-rich/rich-copy-action-write-set.json`,root核对5/5摘要一致。geometry14/14、接线先红7/8、修后6套199/199;lint/格式/5项语法/窄diff通过。准确target真实hover→既有wheel→copy真实hover→原click,不force/dispatch/改timeout,产品样式、权限、runtime与long-list均未修改。`copyActionPhases`保留beforeHover/afterBubbleHover/ready及失败几何,只存有限CSS枚举/rect/结构关系,hover仅声明Playwright API完成,不伪称捕获可信hover事件。fresh rich第四轮已启动;contracts的protocol生成检查触发生成文件mtime更新,fixture按既有freshness要求重新构建renderer/Host,不绕过新鲜度检查,后续另存新产物摘要。
|
||||
|
||||
- fresh rich第四轮exit0:`.lime/qc/user-message-collapse/rich-packaged-4/user-message-collapse-rich-packaged-4-summary.json`,全部69项scenario assertions为true,UserMessage Gate全部19项布尔合同成立,五次真实collapsed/expanded/keyboard切换、两状态1705字符完整copy、同一canonical user/parts/Turns与continuous trace完整/无额外input均通过,console/page errors0。before-hover footerheight0/maxHeight0/pointer-events:none/opacity0、button中心命中bubble;ready height24/opacity1/pointer-events:auto并命中copy后代,关闭测试漏真实hover原因。afterBubbleHover采样落在transition起点仍height0/opacity0,不把它当settled;最终ready与真实click/readback负责验收。hover只声明API动作完成,没有event isTrusted采样。新renderer3384项及22项产品写集与clamp快照逐项均无digest漂移,main/sidecar也保持原SHA,因此long-list第五轮仍匹配同产品产物,无需重复。另存`product-write-set-after-rich-hover-gate.json`与`renderer-artifact-after-rich-hover-gate.json`。本轮current fixture已开始;聚合不含session-history独立Gate,long-list/cold证据分别报告。
|
||||
- 额外只读核验确认permission旧owner缺回流守卫:现有legacy-report仅扫描src,imports entry对“已恢复但零引用”的文件只列候选,不让门禁失败;current-entrypoints虽扫描electron却未禁旧权限owner。当前无残留结论仍成立,但不等于forbidden-to-restore已落实。聚合结束后认领最小catalog dead entry与Electron permission owner AST guard/相邻behavior tests,由既有current-entrypoints接入;禁止空旧源/旧测试文件、改名旧符号、第二注册owner与重复installer,保留独立embedded session current路径。复用同一检测函数对真实目录/临时fixture验收,不扩大整个legacy扫描根,不碰产品权限决策;在default门禁前完成。
|
||||
- 冻结窄例外:packaged聚合的产品产物、fixture加载链与构建输入继续冻结;capabilities可提前在未进入该加载链/构建输入的`permission-owner-guard.mjs`、相邻behavior test与`current-entrypoints.test.mjs`实施独立治理回归。root持有catalog entry,等聚合结束后写入并运行真实目录接线测试;不以测试侧临时mock catalog让current通过。该例外不改变正在运行的renderer/Host/sidecar或scene脚本。
|
||||
- 本切片聚合exit0,22个实际Electron场景全部ok且无false assertion、console/page errors;首末summary时间为2026-10-14T04:43:56.536Z / 05:01:32.373Z。日志`/tmp/lime-user-message-collapse-current-fixture-final.log`,root逐项读回并在`.lime/qc/user-message-collapse/current-fixture-final/aggregate-summary.json`冻结22份summary与摘要,不覆盖上一Composer聚合。该入口仍是controlled fixture而非live Provider;它不含独立长列表/冷启动Gate,不能互相替代。root已登记旧权限两文件dead entry;待AST guard真实接线冻结后运行`verify:local`,其内部`verify:gui-smoke`成功即计本轮GUI门禁,不为相同写集另重复一次。
|
||||
|
||||
### 下一批只读准备:marks 与 dictation
|
||||
|
||||
- [marks/voice审计](../research/codex-desktop-rea/marks-voice-audit.md)已完成,复用三项Composer REA Evidence与两个同摘要原成员,无重复分析;13个UTF16锚点、11个本地链接及格式验证通过。root仅新增精确研究文档白名单,未改产品。
|
||||
- 基础schema确认link/code_block,rich PMU schema分支和provider默认选项另行记录,完整marks清单仍unknown。最小后续合同在唯一input-kit owner收敛schema/types、display/draft/submission序列化、UTF8 ranges、PM位置与实际View编辑;不能只加样式或推测bold/italic工具栏。
|
||||
- current录音/分段ASR/润色、Controller revision保护及现有API/权限/五语言/dead回流守卫保留。permission等待的starting、cancel、旧reject与不同Controller同revision迟到结果是待先红问题;未复现前不写已确认故障,不恢复已删除的global shortcut/device UI或另建音频后端。真实系统麦克风/权限/识别质量仍独立not-run。
|
||||
- 本范围研究映射100%;产品与对应Desktop Gate均0%,不计入长正文完成分母。长正文门禁关闭前不开新产品写窗口。
|
||||
|
||||
### 下一切片只读准备:scoped persistent draft
|
||||
|
||||
状态:设计候选 / 未开启产品写入。长 UserMessage 的真实 Gate 和门禁收口后,再填写实施合同并认领写集。复用已有 REA 保存补证,不重跑相同分析。
|
||||
|
||||
- 唯一活动文档继续归 `ComposerController`;scope owner 管理保存记录与绑定,不创建平行可编辑 Controller。实际导航保存旧 scope 后恢复目标;blocked/missing/deferred/error 的 before callback 只能 flush,不提前绑定。
|
||||
- 首发覆盖 materialized、direct、有/无 draft tab 的 home→session;promotion 仅迁移稳定 entry 身份,不 restore/clear、不推进 pending receipt revision。sessionId 与 canonical threadId 分别使用真实映射,不能按字符串假定等价。
|
||||
- 两处 accepted 消费(Inputbar 与 EmptyState)需要同时结算源 scope/version 与当前 Controller revision。离开 source 后旧 commit 返回 false 只保护新草稿,不会自动消费已发送的旧记录;若忽略此接点,冷恢复会让已提交正文复活。新增编辑、reject/failure 与迟到 interrupted restore 均需专门回归。
|
||||
- 导航用 reset+setInput 路径直接迁出;显式编辑全文路径保留。draft tab 冷启动需由同一草稿索引投影 metadata,关闭 tab 与显式丢弃必须分别定义;不在3100行的 `useWorkspaceSendActions` 中堆逻辑。
|
||||
- 持久层实施前明确与 Codex main persisted atom 的结构映射。现有 `agentChatStorage.savePersisted` 序列化在 try 外、quota 失败先删旧值且不返回保存结果,不能充当已保存证据;持久 blob URL、跨窗口协调、附件容量与失败保旧需闭合合同。若扩展 Host store,同步既有IPC/preload/gateway/catalog/schema/测试及架构,不借用 provider UI state。
|
||||
- 验证必须包含 A↔B/home 富文档、Unicode/paste/mention/resource、真实退出重启、首发promotion、跨scope接受后消费、清除不复活与失败保旧;现有rich/history Gate不证明persistent draft。Codex附件耐久性、账户隔离与强杀窗口继续unknown,不制造上游保证。
|
||||
- storage只读补证:savePersisted唯一owner可把序列化纳入try、拒绝undefined序列化结果、单次setItem并返回boolean,失败保持旧值;删除quota remove+retry,不能连带删仍被saveTransient使用的quota判断。AST35处调用均不消费返回值;新draft owner必须消费结果。现有测试缺savePersisted合同,可补circular/BigInt/toJSON/undefined、quota/security/general失败保旧和后续成功;transcript大状态丢弃、可重建resource-manager quota清理仍是有效测试。snapshot索引和迁移marker忽略保存失败是另域既有缺口,本草稿切片不顺手扩写。仅只读结论,未实施。
|
||||
|
||||
### 权限唯一 owner 护栏 Verification Contract
|
||||
|
||||
- 负责人:lime_desktop_capabilities 实施 AST 检测与恢复反例,root 持有 catalog、计划、报告与统一门禁;2026-10-14。风险 P1,`budget:normal`。
|
||||
- 写集:`scripts/electron/lib/permission-owner-guard.mjs`、相邻 `permission-owner-guard.behavior.test.mjs`、`current-entrypoints.test.mjs` 窄接线及 `legacySurfaceCatalog.json` 单项登记。不是产品权限策略修改,不重构 runtime 或扩大 legacy-report 扫描根。
|
||||
- 合同:同一 TypeScript AST 检测器消费真实目录和临时恢复 fixture;旧源/旧测试即使为空也失败,改名引用旧符号或旧模块失败。主窗口 request/check 注册归 `mainWindowPermissions.ts`,嵌入浏览器独立 session 归 `embeddedBrowserPermissions.ts`;第二注册 owner、重复/缺失主窗口安装失败,合法 import alias 与 namespace import 保持有效。
|
||||
- 验证:先红恢复反例,再真实 current 目录与反例绿;定向 lint/format/语法,catalog/治理和默认门禁。AST 守卫只证明覆盖的静态构造,既有权限行为回归及真实原生复制 Gate 分别证明决策与运行链,不能互相替代。
|
||||
- 研究文档校准:差距矩阵已迁到 current 状态,删除“仍缺虚拟列表/审批入口/Files 导航”的旧判断。rules/docs 回归23/23;legacy-report候选、分类漂移和边界违规均0。产品22项与renderer3384项快照复核无digest漂移,main及raw/Desktop sidecar均仍匹配前述真实Gate摘要。
|
||||
- link/code block 补证已冻结:`marks-voice-audit.md` SHA `a7da8a2cee7a246ff3af3447e5e292cad983f6a4e2740f50b0425ce73b73ca35`;3个原成员摘要、41个UTF16锚点、bounded AST与11个本地链接通过。普通mark协议、基础fence/语言/转义已确认,rich完整选项、PMU与真实roundtrip仍unknown;未扩产品写集。
|
||||
|
||||
### 默认门禁发现:贴底转手动阅读的视口竞争
|
||||
|
||||
- 权限 guard 已冻结,behavior22 + entrypoints17 = 39/39,lint/format/语法/diff通过。root逐项确认3文件SHA并保存 `permission-owner-guard-write-set.json`;worker先红为17 failed/5 passed,证据来自工具输出。新默认门禁实际执行了22/22及17/17,并非只引用旧结果。
|
||||
- 首次本轮 `verify:local` exit1,日志 `/tmp/lime-user-message-collapse-local-final.log`;五语言、lint、硬编码扫描与标准typecheck通过,前端103/126批通过,104批的MessageList两项键盘离底/取消已排队贴底帧回归失败。旧数值护栏未删除或放宽。
|
||||
- 实际问题:scroll controller先停跟随,原生scroll事件尚未更新virtualizer的viewport;布局变化直接写入了上次程序贴底的位置。root窄认领 `VirtualizedTurnList.tsx` 与相邻test,沿同一viewport owner在keydown/wheel/touchmove的capture阶段同步实际视口;不改变键盘业务策略、跟随判定、clamp绝对锚点提交或runtime。
|
||||
- 新独立反例3/3先红:三输入均从29200跳回291680,正确prepend位置应29492。修后五文件63/63,包含原两项失败、三输入竞争、DOM shrink clamp、prefix offset、后继锚点、bounded挂载、copy与折叠保护;两文件lint通过。日志 `/tmp/lime-user-message-collapse-user-input-scroll-{repro,final,lint}.log`。
|
||||
- 该生产补丁重新开启冻结。22场景聚合与rich4/long-list5仍是前一摘要基线,不能充当新renderer证明;续跑默认门禁后,针对本次GUI视口变化补fresh rich、long-list/cold、live-tail及resize/reflow真实场景。Host、sidecar、wire与fixture owner未改;不因这项局部GUI修复无差别重跑已通过的全部runtime场景。
|
||||
|
||||
### 视口竞争修复后的门禁与补验
|
||||
|
||||
- `verify:local` 续跑exit0,日志 `/tmp/lime-user-message-collapse-local-resume-final.log`:前端126/126批、1689个唯一测试文件;首轮103批与续跑104–126批合计12693 passed instances,失败104批的重复结果不计入。contracts、7个Rust crate3179/3179及真实Electron GUI smoke均通过;新增VirtualizedTurnList测试在106批实际通过。
|
||||
- GUI smoke:`.lime/qc/project-gates/standalone-shell-01-20261014055122-75182/shell-01-electron-smoke/summary.json` 为pass,覆盖真实Electron启动/reload/preload/IPC/App Server初始化、设置及三窗尺寸,不单独证明模型Turn或Codex像素一致。
|
||||
- 新冻结:产品22项只漂移`VirtualizedTurnList.tsx`这一预期补丁;renderer3481项重新快照。`product-write-set-after-user-input-scroll-fix.json` / `renderer-artifact-after-user-input-scroll-fix.json`保存于`.lime/qc/user-message-collapse/`。main与raw/Desktop sidecar摘要均保持原值。
|
||||
- fresh long-list第六轮exit0:9682字符三状态native复制完整、真实PID退出/新进程及默认collapsed恢复、后继锚点漂移-0.203125px、同canonical正文/parts/text elements/Items/Turns不变、完整trace和额外input为0;console/page errors均0。summary在`long-list-packaged-6/`。
|
||||
- fresh rich第五轮exit0:69/69 scenario assertions、UserMessage Gate19项布尔合同均true;唯一canonical_turn、1705字符两状态复制、可信键鼠与引用/原稿/接受/历史合同保持;console/page errors均0。summary在`rich-packaged-5/`。fresh live-tail65/65、exit0,GUI首次输出、canonical完成与终态repair通过,无console/page error;是external fixture,非live Provider。
|
||||
- resize/reflow首次补验exit1:wide窗口正文、表格、终态及Files面板已就绪,但activeThreadHeaderStable=false。日志 `/tmp/lime-user-message-collapse-resize-final.log` 与`resize-final/`失败summary/截图保留。error在统一sanitizer中截至2000字,关键header谓词未完整留下;不可据此判定已通过或直接修改布局。
|
||||
- fixture worker窄认领resize-reflow入口的import/call、独立安全header诊断helper与行为test;只把安全谓词/必要几何放入error前部,保留timeout、等待、header/compact-mode/no-overlap断言,禁止正文、URL/raw HTML。原入口近900行,新增逻辑进入独立helper。新产品写窗口保持关闭,取得具体失败项后再确定最近owner修复。
|
||||
|
||||
- 安全诊断3文件已冻结:入口仅import/timeout throw接线,独立helper将12个headerChecks及失败项置前,18/18反例与安全截断测试、既有resize接线1/1、lint/format/语法/scripts治理通过。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-diagnostics-write-set.json`;真实diagnostic-2正在执行,等待/超时/断言未变。
|
||||
|
||||
### Resize/reflow 页头修复 Verification Contract
|
||||
|
||||
- diagnostic-2真实Electron exit1:唯一失败谓词childrenContained。wide页头y8..54、高46;toolbar/actions y-1..63、高64,确认为换行越界。context旧firstElementChild实际选导航,不能继续冒充标题组。
|
||||
- root窄写集:TaskCenterUtilityToolbar.tsx仅workbench-header布局分支,ThreadWorkspaceHeader.tsx标题组稳定context testid;task-strip继续按既有行布局。固定页头内工具栏单行,超宽仍归既有header actions横向滚动owner,不增加另一工具栏/业务owner。
|
||||
- fixture worker窄写集:resize-reflow入口及独立geometry helper/test;显式current context定位、可见几何消费overflow祖先裁剪。保留header/context/actions可见、contained、no-overlap与compact-mode-bar位置语义;不改timeout、等待、终态或表格/消息/input断言。
|
||||
- 验证:旧64/46高度反例继续拒绝;合法横向滚动与真实纵向越界分开覆盖;现有Toolbar/Header定向动作/identity回归、lint/typecheck、fresh renderer三尺寸真实resize Gate、GUI smoke及风险匹配默认门禁。无协议/Host/runtime改动,不扩Rust写集或无差别重跑旧22场景。
|
||||
- 架构影响非重大:既有header presentation、overflow与测量owner不变;诊断和几何helper均test-only。当前切片仍partial,fresh Gate通过前不标完成。
|
||||
|
||||
- root产品最小修复已实施:workbench-header root与group均单行且不收缩;task-strip继续wrap,沿现有actions overflow-x-auto访问超宽操作。标题组增加稳定context定位。现有Toolbar/Header三文件36/36、两产品文件ESLint/格式/diff通过;fresh renderer构建exit0。产品快照24项包含原22项无漂移与两页头文件;renderer本次174项,Host/sidecar三摘要实读匹配。快照为`product-write-set-after-header-layout-fix.json`及`renderer-artifact-after-header-layout-fix.json`,与之前3481项快照区分,不混用文件计数。
|
||||
|
||||
- geometry测试侧已冻结:旧测量12failed/5passed先红,新几何22/22+诊断18/18、原resize接线1/1与lint/format/语法/scripts治理通过。入口886→715行;唯一helper闭包自足直接evaluate,等待/readiness/timeout逐字保持。完整context与toolbar paint不允许旧回退;额外校验toolbar/actions raw纵向span,裁剪不能掩盖旧64/46错位。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-geometry-write-set.json`。
|
||||
- fixed-3启动exit1未进入resize:root误用浏览器build生成`/assets/...`绝对module路径,Electron file页面停在startup,两资源缺失、无业务invoke。该renderer174项快照只保留失败溯源,不能作有效Desktop产品证据。已使用`build:renderer:electron`成功重建为相对`./assets/...`;新快照`renderer-electron-artifact-after-header-layout-fix.json`,fresh fixed-4在跑,生产页头未因此回退。
|
||||
- 新护栏写集:fixture worker仅`electron-fixture-build.mjs`及相邻test,按实际HTML module/stylesheet的file资源路径检查renderer可复用性;独立于mtime与其他segment,不改显式override边界,禁止把browser构建或missing module artifact当fresh packaged资源。仅测试装配合同,无runtime/协议改动。
|
||||
|
||||
- fixed-4真实exit1继续仅childrenContained:完整context定位现正确,但toolbar仍64px;定位上层consumer仍按layoutMode聊天分支传task-strip,未随已经迁入header的位置更新。root追加认领WorkspaceConversationScene.tsx及既有consumer test的最小迁移;placement从同一shouldRenderThreadHeader派生,chat/canvas两种布局挂入header均使用workbench-header,非header使用task-strip,不新增owner或同义helper。
|
||||
- consumer先红:chat分支失败、canvas通过(1failed/1passed/19skipped),日志`/tmp/lime-user-message-collapse-header-placement-red.log`;修后Scene/Header/Toolbar四文件57/57,追加非header分支task-strip保持验证。页头横向溢出从起点排列,避免flex-end形成无法滚回的负方向内容;沿原overflow-x-auto,不加新交互。相关四文件ESLint通过,fresh Electron renderer重建中。真实fixed-4保持失败证据,不把helper裁剪后的部分可见当contained通过。
|
||||
|
||||
- fresh fixed-5真实exit0,三尺寸wide1280×820/compact880×720/restored1280×820,66/66 assertions,console/page errors0。页头46px、actions/toolbar32px(y15..47);完整context、paint containment、重叠、compact栏、table/message/input/right surface与canonical terminal均通过。wide/compact截图已人工检查,通知toast存在但未见toolbar越界或message/input重叠;不从DOM几何推出Codex像素一致或所有水平隐藏操作的键盘可达性。
|
||||
- 新源码26项基于原22项零漂移,包含Toolbar/Header/Scene及Scene consumer test;新Electron renderer174项与Host/sidecar三实读摘要匹配。快照`product-write-set-after-header-placement-fix.json`及`renderer-electron-artifact-after-header-placement-fix.json`;标准typecheck、四产品/测试文件格式与diff通过。此前rich5/long-list6/live-tail-final来自视口修复基线,页头补丁不改变其产品owner;fixed-5补验页头和消息/input/table布局,证据分版本保存。
|
||||
- 构建复用guard已冻结:旧实现14failed/10passed先红,最终28/28及相关source接线2/2通过;两文件lint/format/语法/scripts治理/diff通过。真实HTML按baseURI解析module/stylesheet现存file,空或无module/root-absolute/non-file/非法/missing/directory均使renderer segment stale;不执行脚本或取网络,finally关闭JSDOM。三segment构建、mtime、appUrl/build-ready显式override保持,依赖图不扫描。manifest `/tmp/lime-user-message-collapse-rich/electron-fixture-build-assets-write-set.json`。
|
||||
- 最新`verify:local`已启动,日志`/tmp/lime-user-message-collapse-header-layout-local-final.log`;当前生产与fixture写集冻结到门禁结束,不用前轮126批成功替代本次页头consumer/装配guard结果。
|
||||
|
||||
### 默认门禁发现:复制反馈生命周期
|
||||
|
||||
- 新默认门禁 exit1,停在85/126批。该批16文件178项断言均通过,但环境销毁后 `MessageList.tsx` 的两秒复制反馈定时器仍执行 `setCopiedId`,产生 `window is not defined`;失败不能按断言全绿忽略。
|
||||
- root窄例外写集:`MessageList.tsx` 复制反馈及独立 `MessageList.copyLifecycle.component.test.tsx`。连续复制从最新成功计时;卸载/切换会话取消旧反馈;迟到clipboard成功/失败不覆盖新会话或新复制。平台clipboard调用不伪称可撤回。其他产品与fixture继续冻结,隔离marks/dictation候选不进入src。
|
||||
- 验证:先运行现行产品的生命周期反例,再修复同一显示owner、定向消息回归/lint/typecheck并从85批续跑;不放宽timeout、关闭unhandled error检查或删旧断言。追加真实copy Gate按新的renderer摘要单独记录。架构owner与协议未变。
|
||||
- 原产品生命周期反例8failed/2passed;卸载计时器初始计数含jsdom Storage零延时事件,精确stack确认后仅drain 0ms,不推进两秒反馈计时器。修后10/10,含既有消息五文件合计69/69;产品/测试lint与标准typecheck通过,格式已统一。旧fixture选择器错误与诊断日志保留且不算业务先红证据。
|
||||
- 新快照 `product-write-set-after-copy-lifecycle-fix.json` 27项,只漂移此前26项中的MessageList.tsx并增加独立回归。新default续跑发现新增测试,追加127批,保留之前passed记录;日志 `/tmp/lime-message-copy-lifecycle-local-resume.log`。旧rich/long-list/resize证据仍按各自renderer版本引用,复制新生命周期尚未取得fresh Electron证据。
|
||||
- 最新default续跑exit0:127/127批、1692文件执行、12764 passed instances(首轮失败85批的178项不重复计),unhandled errors0;contracts、7 Rust crate3179/3179和真实Electron GUI smoke通过。smoke summary为 `standalone-shell-01-20261014075111-89042/shell-01-electron-smoke/summary.json`;批次与证据lineage记录到 `default-header-copy-lineage.json`。
|
||||
- 复制生命周期fresh rich Gate exit0,`.lime/qc/user-message-collapse/copy-lifecycle-rich/message-copy-lifecycle-rich-summary.json`:69/69 assertions,正文19项布尔合同均true,两个显示状态1705字符原生复制一致(SHA `3b5ab254cf9c0e7f9d2d7ffcb3e3829910c83fe8114afd88a2f04e050c3debcd`),唯一canonical_turn、真实键鼠、无额外input、console/page errors0。异步晚到与卸载由独立10项组件回归证明,本Gate不冒称验证clipboard延迟失败或live Provider。
|
||||
- 新Electron renderer271项及Host/sidecar三项保存为 `renderer-electron-artifact-after-copy-lifecycle-fix.json`;产品27项及274项产物逐项复核零漂移,Host/sidecar摘要保持此前值。此前long-list6/cold、live-tail-final、resize-fixed5分别保留其产物摘要,当前复制Gate补验新MessageList反馈,不混称同一renderer。长UserMessage合同(两入口/测量/锚点/复制/恢复/有效回流守卫)100%关闭;全面产品仍partial,marks/voice/persistent等不计入该分母。
|
||||
|
||||
### Link/code 与 dictation 生命周期 Verification Contract
|
||||
|
||||
- 负责人:root负责input-kit迁移、计划、架构与统一门禁;lime_desktop_capabilities负责dictation hook/session、Core消费者与五语言。fixture worker先提交只读接线设计,产品冻结后再认领精确fixture。预算 `budget:normal`,风险P1。
|
||||
- 参考:复用 `marks-voice-audit.md` 冻结REA及普通AST锚点。基础link attrs、code_block与序列化采用已确认合同;完整PMU、主动打开/编辑dialog和Codex真实录音表现仍unknown,不把候选变量当原始源码名称。
|
||||
- root写集:唯一input-kit的schema/types/Controller/Editor/keymap/serialization、link helper及相邻行为测试;positions若无语义差异保持现状。Plain display UTF16与Markdown submission UTF8分开,annotation迁移保留富PM文档,禁第二Controller/renderer业务后端。
|
||||
- dictation写集:现有hook/draft/unit test、新独立session helper/lifecycle test,Core最小消费、独立Core听写回归、copy owner与五语言agentInputbar资源。独立starting/cancel动作;permission/RPC晚到按同session generation、Controller实例及revision保护;卸载/会话切换清资源。
|
||||
- IME接线:PM公开handleDOMEvents早于默认compositionend,`isComposing=false`不能证明事务已结算。首版采用有边界的fail-closed:composition相交即无回滚地失效整个voice session,保留编辑文档,拒绝后续ASR/润色;已有组合时不启动录音。不使用私有PM属性、盲timer或跨revision范围映射。明确取消才按当前revision恢复provisional草稿;这不宣称ASR与IME并行合并已实现。
|
||||
- 清理:替换旧hook重复refs/异步reset路径;旧纯文本paste fixture改为MIME-aware而保留原text/plain断言;旧positive shape不能阻碍current link/code语义。无新增compat/deprecated、无新依赖/协议/Rust变更。
|
||||
- 验证:候选先红/绿只作预验证,应用前逐文件检查baseline;实际src定向先红/绿、Controller/Editor/receipt/IME/纯文本/五语言回归,真实Electron link/code→Send→canonical→history/restore Gate,已有voice current权限/API合同与生命周期测试、GUI smoke/default。系统麦克风/真实ASR和Windows若未执行单列not-run,不能以mocked录音生命周期测试替代。
|
||||
- 架构影响:沿既有唯一input-kit、Desktop录音surface与ASR API owner;未新增跨层owner/协议,非重大。实现后同步architecture输入文档与取消生命周期描述。persistent draft/专业文件/原生操作/remote继续在整体待办。
|
||||
|
||||
### Dictation 实际源码与单路径收敛
|
||||
|
||||
- 原14文件actual写集冻结;最终manifest `.lime/qc/dictation-product/actual-manifest.json` SHA `3f009ce3725c04dab322650102b90f30797985cd89a43af1e4442be1e3e67d90`,root逐项复核14文件零漂移。既有Core/copy/五语言脏改动按baseline保留,仅接本合同。
|
||||
- 实际hook先红25 failed/14 passed,Core真实hook/PM接线先红7 failed/1 passed;补音频decode取消竞态先红1项。候选pending接口在Core fail-closed策略下没有生产消费者,删除状态/type/session字段、hasPendingFinal与flush/drop整轨;迁移current行为后先红5 failed/26 passed,最终5文件72/72通过。
|
||||
- 同Controller+sessionId+generation隔离所有异步结果;组合输入开始时整session无回滚discard,结果应用时发现composing同样终止。明确cancel才按最后provisional revision恢复。已发RPC只能隔离结果;音频decode context立即close,迟到decode不再发ASR。starting/独立cancel/输入法中断反馈覆盖五语言;系统mic/ASR质量与Windows仍not-run。
|
||||
- 全仓非候选对旧pending符号扫描0,类型检查含测试、窄lint、Prettier与diff检查通过。hook719行/session196行,lifecycle测试796行,后续新增场景必须独立测试文件,不继续堆叠。无新API/协议/依赖、无compat/deprecated。
|
||||
- root正在冻结构建与标准typecheck;新Electron link/code Gate、current fixture、GUI smoke/default尚未关闭,72项只计组件/生命周期证据。architecture已同步scope/cancel/discard与显式发送边界。
|
||||
|
||||
### Link/code 实际源码迁移
|
||||
|
||||
- 应用前逐文件核对12目标 baseline/candidate SHA,issues为空;冻结候选 manifest SHA 为 `7bd36954f2d5071239c23854e5e0e6ff16d43b54cca0c7b0fd1c3bc11db5d359`,patch 为 `ed0bd38d66719e645760be9526b45cc2a9c6d0ecc7424593d4dd195e15f047c9`。候选摘要不作为实际应用摘要,实际身份见 `.lime/qc/composer-marks-actual/applied-manifest.json`。
|
||||
- 先只迁入独立测试,在实际 src 复现22 failed/8 passed(2文件30项),随后迁入生产实现;20文件224/224通过,覆盖input-kit与typed submission、receipt、restore、workspace消费者。日志分别为 `/tmp/lime-composer-marks-product-red.log` 与 `/tmp/lime-composer-marks-product-green.log`,副本落在上述actual证据目录。
|
||||
- link安全协议、合法literal重放、UTF16 display/UTF8 Markdown范围、code fence/原始空白、PM annotation事务与实际EditorView paste/键盘均在current owner回归。旧clipboard getter只迁为MIME-aware,原CRLF/large-paste合同保留;`composerPositions.ts`无生产改动。
|
||||
- Prettier后三个test describe去掉candidate措辞;Controller达到803行,annotation事务构造拆入纯 `composerAnnotations.ts`,不增加文档owner或compat wrapper;直接调用者与既有行为回归5文件92/92通过,Controller762行/helper59行,窄lint通过。实际写集扩为13文件,actual manifest已刷新。
|
||||
- architecture的schema、Markdown submission、原始draft与range分工已同步。真实Electron HTML→code Enter/ShiftEnter→Send→canonical→history/restore、新default与系统voice仍分别pending/not-run;224项与旧127批不能替代这些门禁。
|
||||
|
||||
### Marks fixture 冻结
|
||||
|
||||
- 实际11文件写集与SHA见 `.lime/qc/composer-marks-gate-plan/actual-fixture-write-set.json`;7文件312/312通过,lint/Prettier/node syntax/scripts治理均通过。先红7 failed/127 passed,其中6项为业务断言、1项为测试DOM清理串扰,已明确区分。
|
||||
- 独立字面Markdown oracle固定五个反引号fence、最终4361 UTF8 bytes,paste `[197,4337)`、Skill `[4338,4361)`;不消费产品serializer、DOM/wire/canonical反推期望。code按真实locator.press记录trusted/focused/defaultPrevented/insideCode,DOM Selection单列programmatic,HTML paste单列synthetic。
|
||||
- history/cancel核对href/title/language/body与独立期望;最终canonical Turn/client/item、media、typed四类input、总input request恰1与终态均进入场景pass/fail。新4项marks断言复用原 `inputbar-rich-restore` 场景,没有新增协议或第二Controller。
|
||||
- 删除无调用的 `richInputExpandedText`/`richInputInstalledSkillText`;`isRichInputDraftRestored`仍有common caller,保留原current导出并验证19/19。真实Electron、新renderer/default尚未执行,312绿只计Unit/wiring。
|
||||
|
||||
### 草稿落盘补证
|
||||
|
||||
- 同一ASAR的bootstrap/startup-requirements成员integrity匹配,REA新增Evidence `ev_6b726d05b1186a46c3e78398b4559cddd089b886382b8619d60fb2d509426d15`:2文件577112 bytes、parse failures0;semantic heap exhaustion导致graph incomplete。类/文件/算法结论来自完整原成员的普通AST,不能冒充REA semantic或运行证据。
|
||||
- 已追到Codex Home `.codex-global-state.json`、export D→class的身份、main500ms debounce、串行保存、主/备份文件、同目录临时文件rename及Windows三档重试。renderer250ms发布与main500ms保存分开,broadcast不能充当落盘确认。原始成员与AST只留本机分析目录,合同/摘要/限制写入 `composer-draft-persistence-audit.md`。
|
||||
- 实际退出/重启、强杀耐久、账户隔离与完整附件持久化仍unknown;Lime scoped persistent draft仍未实施。未扩展Host协议或调用者写集。
|
||||
|
||||
### 本批门禁失败与 fixture 版本边界
|
||||
|
||||
- 首轮 rich Gate exit1,失败阶段为 UserMessage aggregate oracle;marks/cancel/history、canonical四类input和总input恰1通过,两次原生clipboard全文1779 UTF16字符(4361 UTF8 bytes)匹配,console/page errors0。`renderedFullTextPresent=false`,不能以复制成功替代整个Gate成功。
|
||||
- 初次prepare期间两个fixture于08:49:41Z变化,旧11文件manifest未同步;当前源含独立`renderedTextParts`与rich调用接线。原冻结和失败证据保留,修改者/精确旧进程完整模块SHA不可从现有记录还原,不声称该失败对应当前完整fixture身份。54项产品源码仍逐项零漂移。
|
||||
- root新增独立真实MarkdownRenderer消费者回归:确认安全link href/title、code原始空白/正文反引号和全部ordered可见片段;同一rendered DOM拒绝raw Markdown全文显示假设。该harness替换syntax-highlighter与原生资源依赖,仅计组件/fixture证据。首次新增case因测试包围DOM缺失失败,修正测试装配后6文件189/189;不计业务先红。窄lint和格式通过。
|
||||
- 当前冻结为 `.lime/qc/composer-marks-dictation/product-fixture-source-freeze-rendered-body.json`(54产品/14fixture);不覆写旧manifest。fresh进程rich Gate已启动,标准入口因源mtime判定重建Electron renderer;构建完成后重新记录实际Host/sidecar/renderer摘要。
|
||||
- 新`verify:local`在69/126批exit1:中断恢复拓扑guard把`inputbarDictationSession.cancel`的受控恢复算作非法writer。worker只认领guard及反例,不改生产dictation、不给voice伪造中断请求。保留两个UI消费者与request pass-through约束,更新扫描边界后定向验证并resume。旧127批绿不替代本次结果。
|
||||
- 首轮sidecar prepare出现`install_name_tool` rename竞态报错,既有helper允许missing-after-race或already-present-after-race后继续装配;具体分支未记录。当前桌面code-mode-host存在且具有`@executable_path` RPATH,但本富输入Gate不验证CodeMode运行能力。不修改未知Rust产物owner或绕过prepare。
|
||||
- fresh进程rich Gate exit0:73/73 assertions、UserMessage正文19项合同全true、两个显示状态全文1779字符原生copy、同一canonical Turn/Item/client和typed四类input、input总数恰1、marks取消恢复与历史键盘召回均通过,console/page errors0。新Electron smoke renderer271项和Host/sidecar实读摘要保存在`renderer-host-artifacts-rich-rendered-success.json`;raw与桌面App Server均为`df27ddb9…`,不混用旧`4d213628…`。54产品/14fixture逐项无漂移;截图已人工检查。HTML paste/IME仍为synthetic,code键与history/toggle为trusted真实按键,不宣称OS输入法或系统录音已验收。
|
||||
- 中断恢复guard已冻结:只改`AgentChatWorkspace.inputRestoreGuard.test.ts`,旧guard实际1failed/2passed;相关6文件66/66、UI消费者与透传5/5、lint/严格tsc/格式通过。按request类型/字段约束直接writer,保留原两个UI owner和pass-through清单,正向区分听写回滚、反例拒绝跨域直接恢复中断请求。guard SHA`8eef1609…`,manifest `.lime/qc/input-restore-guard/manifest.json`;dictation14项零漂移。root复核后重启`verify:local`,入口自动resume,不从第1批重跑。
|
||||
参考应用Computer Use曾被客户端拒绝,不绕过;参考实时效果仍not-run。REA静态结构、公开源码与Lime运行证据分别报告,不能互相替代。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -93,3 +93,25 @@
|
||||
- 第一份草稿by-tag REST读404、gh run view等待整个run结束才可读logs;改用release GraphQL view和completed job logs REST,未伪造质量通过。所有失败和取消日志保留。
|
||||
|
||||
- 精确fix回归39项与dictation draft纯逻辑10项通过;完整typecheck、ESLint、Prettier、38项预算通过。独立固定源码按 `--from-batch 70` 续跑到82/127无失败(运行中),后续全量Quality再次验证新source。准备正常提交测试修正和最新并行文档、推送main;旧tag仅在旧Release已取消、草稿未发布条件下按expected-old lease更新,重新生成所有产物。
|
||||
|
||||
- 修正commit `e61d6e87fbecd343d60e9d48c2b81a63caa7f11b`、tree `341719502027072321c09bb87ff850fed6cf414a`已正常hook(5文件/0失败)并推送main。final snapshot与独立index更新,产品源码和既有真实交互binary不变,test-only和最新文档变化已单独复验。
|
||||
- 旧Release最终completed/cancelled,所有publish job取消;再次确认draft=true、assets=[]、publishedAt=null。用exact expected-old update-ref及 `--force-with-lease=refs/tags/v1.157.0:73ccc7f484ae6dd4b823305cdfa74df2a7291240` 把未发布草稿tag更新到e61d6e87f,远端实读一致。
|
||||
- 新 [Quality 38543400684](https://github.com/limecloud/lime/actions/runs/38543400684) 与 [Release 38543774892](https://github.com/limecloud/lime/actions/runs/38543774892)的headSha均为修正commit;旧产物不会复用。最终审计以这两次run为准。
|
||||
|
||||
- 固定源码 `npm test -- --from-batch 70` 最终exit0,70–127共58批passed,1–69是显式跳过而非本轮通过;新guard/renderer另以39项定向覆盖。状态JSON保留 `frontend-ci-fix-resume-state.json`。不把续跑状态写成127/127全量,最终完整覆盖等待新Quality。
|
||||
|
||||
- 新Windows build已success;下载的installed packaged identity Gate B确认Lime.exe、App Server、CodeMode、desktop resource manifest和native host指向同一1.157.0/e61d6e87f/run38543774892安装,4类检查passed。Windows release日志实读无signing secrets,按现有合同产出unsigned Squirrel安装包,不声明Windows已签名。
|
||||
- 第一份Quality的Windows Shell Runtime已success,唯一failure为旧inputRestore guard;前后产品源码不变,5路径test/doc delta机器复核记录 `ci-fix-source-equivalence.json`。新Quality的Rust Full和GUI smoke均success,Frontend Full仍进行。新macOS arm64 build已success,Intel build等待完成。
|
||||
|
||||
- macOS arm64 packaged Gate B下载实审通过:candidate SHA/version/run ID匹配新源码,8类native检查passed,Electron检查passed,Developer ID、Gatekeeper accepted及stapleValid均true。Windows安装4类identity检查passed。
|
||||
- 新Quality的完整Frontend Full已success,Rust Full、GUI Smoke和Integrity均success,Quality results汇总进行中。Architecture Confirmation为PR专用,在本次push正常skipped;测试/文档修正没有Rust/Bridge产品变化,Windows/Bridge owner验证复用首轮同产品source结果,不冒充新run执行。
|
||||
|
||||
- 新Quality 38543400684最终completed/success,source为e61d6e87f;完整Frontend Full 126/126批通过(以远端日志为准,独立本地环境127批的分批数不替代远端)。Rust Full、GUI Smoke、Integrity及Quality results均success。发布工作流仍待Intel构建与全部资产/npm收口。
|
||||
|
||||
## Intel 信任检查重试与最终发布审计
|
||||
|
||||
- Release 38543774892 第1次尝试 completed/failure:macOS Intel 构建及资源检查通过,packaged Gate B 在 `xcrun stapler validate` 失败;前置 codesign 与 Gatekeeper 检查已执行。构建日志显示 ticket=stapled,但失败入口未输出 stapler stdout/stderr,暂不把原因归为 Apple 临时故障。失败日志保留,Intel 没有通过证据。
|
||||
- 失败后 GitHub Release 实读仍 draft=true、assets=[]、publishedAt=null,未对外发布该版本。按既有完整授权重跑同一源码的失败任务(attempt 2),保留所有签名、公证、Gatekeeper、staple 和 Gate B 门禁,不移动 tag。
|
||||
- 当前只认领本文和 `.lime/release/v1.157.0/` 忽略证据。tag 固定后的并行 GUI 研究、执行记录、ShellBackgroundTerminalList 测试及本地缓存保留,收尾证据提交不纳入这些后续改动。平台审计按实际产物所属 attempt 精确匹配 source/version/run identity,不把旧失败算通过。
|
||||
- 同一源码重跑的 Intel Gate B 已通过,Developer ID、Gatekeeper、stapleValid 均 true,8 项 native 检查与 Electron 检查 passed。三平台证据下载实审通过:Intel run ID 为 `release-38543774892-2-darwin-x64`,Apple Silicon 与 Windows 沿用第1次成功产物;source/version/各平台 run ID 精确匹配,原始失败证据保留。首次失败具体原因因缺少 stapler 输出仍未确认,不用后续成功倒推根因。
|
||||
- 11:04 UTC 左右检测到整个 `.lime/release/v1.157.0/` 忽略目录不在原路径,原因未确认;原本地日志/快照不能继续声称留存在该路径。固定源码临时副本仍存在,既有验证结论已记录于本文。后续审计改用独立临时目录 `lime-release-v1.157.0-audit-4w7u6u7s`,从 GitHub 重新下载三平台摘要和失败/成功日志,再以公开 Release/R2/npm 实读收口;不重跑已通过的重型本地构建,不修改并行写集。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# v1.158.0 发布执行计划
|
||||
|
||||
状态:active。发布完成度:40%。目标:完成 v1.158.0 候选验证、提交、tag/main 推送及远端发布审计。
|
||||
|
||||
## 授权与候选
|
||||
|
||||
- 用户要求「发布 v1.158.0」,随后明确「不要让我确认,全部递交,并完成发布」。完整候选、必要修复、commit/tag/push 和发布复核均已授权,不再请求确认。
|
||||
- 起点 main/origin/main 和 v1.157.0 均为 `e61d6e87fbecd343d60e9d48c2b81a63caa7f11b`;远端 v1.158.0 不存在。
|
||||
- 初始工作树 347 路径:214 modified、8 deleted、125 untracked。全部产品、测试、脚本、文档和锁文件改动进入候选。
|
||||
- 沿用上版明确排除:`local-conversation-page-a83a1af2c24b.js` 为外部分析产物,`tsconfig.node.tsbuildinfo` 为增量缓存;保留本地,不删除、不提交。
|
||||
- 本轮直接写集:桌面/CLI/Rust 版本事实源、Cargo.lock、双语单页发布说明、本文。产品源码先只读检查与验证,必要修复记录精确路径。
|
||||
- pnpm lock 无 root release version,现有依赖变化一并纳入;Forge 与 App Server 继承版本,不引入第二份版本事实源。
|
||||
|
||||
## 发布内容与边界
|
||||
|
||||
- Composer 草稿通过 Coordinator -> Electron Host persisted atom Store/File 保存,覆盖会话隔离、新建导航、接受提交和停止恢复。
|
||||
- 代码块编辑支持语言选择、自动识别和高亮,结构化输入按接受结果消费或保留。
|
||||
- 设置章节、侧栏和媒体设置复用现有 owner,移除重复名单、旧草稿事件与已替代实现。
|
||||
- current:既有 Electron Host/renderer/Composer 与 Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item 主链;新增 compat/deprecated 为零;deleted 项由当前语义回归承接;受控 provider/fixture 仅 test-only。
|
||||
- 重大架构图的责任确认沿用 current GUI 计划所记录的 2026-10-14/15 root 确认及 architecture 4.3/4.4/4.5;本轮 metadata 不改变业务 authority。整体 GUI 对齐仍 partial。
|
||||
- KISS/DRY:复用版本、构建和发布入口;YAGNI/SOLID:不引入发布包装、兼容层或第二业务后端。
|
||||
|
||||
## 验证与退出条件
|
||||
|
||||
1. 版本统一 1.158.0,双语单页 notes;`verify:app-version` 与完整 `typecheck` 通过。
|
||||
2. contracts、发布/CLI/依赖及受影响 owner 回归按风险通过;已有默认前端 132 批证据按 lineage 记录,不冒充本轮全量重跑。
|
||||
3. 先 current Agent fixture,再标准 GUI smoke;版本变化后重建真实 Electron/sidecar 产物。
|
||||
4. 复核固定候选清单和源码漂移,正常 pre-commit,不绕过门禁;连续 commit/tag/main push/tag push,核对远端引用。
|
||||
5. GitHub Quality/Release、签名/公证、三平台安装/updater、CLI npm 实读收口;只触发 workflow 不算发布完成。
|
||||
|
||||
## 证据与进度
|
||||
|
||||
- 既有默认前端状态 `passed`,完成时间 2026-10-15T07:39:42.985Z;完整 lineage 与 current22 证据见 `codex-gui-alignment-current-plan.md`。仅证明原候选,版本变化后重新验证发布门禁与受影响路径。
|
||||
- 本轮命令日志、候选清单和远端审计材料放在忽略目录 `.lime/release/v1.158.0/`。
|
||||
- live provider、系统 IME/麦克风、Windows 本地 GUI 和安装包等价不以 macOS fixture 替代;远端平台结果分别审计。
|
||||
|
||||
## 固定候选与初轮验证
|
||||
|
||||
- 检测到并行导航/设置写集继续变化,停止本轮旧工作树 typecheck/contracts/current 聚合,仅终止本轮自有进程。不中断其它 Agent 或用户进程。
|
||||
- 独立索引固定 tree `cc30c9c3f50f95a57368834d3e4172f6a5d135e2`,358 改动路径;独立 detached worktree `lime-release-v1.158.0-source-4xw7dr7n` 验证,原工作区及索引不覆盖。冻结后的新改动属于后续候选,按差异保留。
|
||||
- 初轮版本、metadata 格式、CLI npm 9/9 通过。重点 owner 106/111、发布护栏 51/53;所有失败均为既有 5 秒超时,尚无业务断言失败,按单 worker 复验,不提高超时、不改断言。
|
||||
- 初轮 current 聚合仅历史/缓存组 31 项通过,后续因切换固定源码主动停止,不计完整聚合通过。
|
||||
- 固定源码的 pnpm 首次启动受本机旧 Corepack 签名 key 缺失阻断;改用 npm 临时缓存中的精确 pnpm@9.15.9,不改全局工具或跳过签名配置。
|
||||
|
||||
- 精确 pnpm@9.15.9 已可运行;为避免链接的共享 node_modules 被重装,改在仅含 manifests/lockfile 的临时目录执行离线 frozen lockfile 验证,exit0。
|
||||
- 固定候选首轮单 worker 201 项:200 passed、1 failed。旧超时全部消失;唯一失败是新增设置切章测试预期 undefined,而 current sections 明确以 `focus={context.executionPolicyFocus ?? null}` 清理执行策略焦点。仅在固定源码修正该测试为 null,保留 focus 清理、Provider 和同章保留全部行为断言,补跑 SettingsPage 与 useAppNavigation。
|
||||
- 本轮追加窄写集仅 `src/components/settings-v2/_layout/index.test.tsx` 的该单行断言;原工作区并行写集不夹写,提交固定候选后将后续 diff 保留。
|
||||
|
||||
- 固定源码完整 `test:contracts` exit0;protocol 1036 types 无漂移、IPC/command/harness/modality/script/release/desktop-reference/CLI/docs 所有门禁通过。
|
||||
- 设置单行断言修正后 SettingsPage/useAppNavigation 51/51 passed;首轮其它 13 文件通过,发布护栏 53/53、五语言代码块和编辑器均已在原 5 秒时限内通过。既有201项收据中的失败保留,不改写为全绿。
|
||||
- 固定候选已经包含设置 canonical 参数、返回实际会话与 rail Home 分离的并行实施,双语 notes 按实际候选同步;后续 source diff 保留,不直接夹入未经验证的版本。
|
||||
|
||||
- 发现其它进程也在构建桌面壳,已把本轮 Cargo target 改为独立 APFS clone `lime-v1.158.0-cargo-8ngd3xsm/target`,仅复用编译缓存字节,不共享 sidecar/rpath 可变产物。构建与 GUI 产物仍在独立候选目录。
|
||||
|
||||
- 固定 current 聚合前3组 31/34/3项通过;第4组299/300通过,唯一失败为trace collector的302次append覆盖超过既有5秒。单独重试仍68/69通过、同项超时;不改超时、不减append/断言。原聚合exit1,不声称完整wrapper绿。
|
||||
- 从原聚合源码提取未执行的Electron continuation,保留原build owner、全部子场景参数与断言;静态组由独立收据证明,临时runner只在忽略证据目录,不进入产品构建图。正在构建独立1.158.0 Renderer/Host/sidecar。
|
||||
|
||||
- 首次固定候选完整typecheck实际exit2:App.tsx两处仍调用已移除的setActiveAgentSessionId。导航已由useAppNavigation.agentLocation及reportAgentLocation持有,旧route->local session状态同步成为重复且失效owner。
|
||||
- 在固定源码窄修复App.tsx:删除旧session同步及仅供其使用的route变量/import,保留离开Agent时清理streaming;实际会话继续来自既有导航位置投影。不新增state或fallback。已停止本轮未完成的旧Renderer,只重建修复后的产物,重新运行完整npm run typecheck。
|
||||
|
||||
- App修复写回原工作区前以冻结blob逐字节确认未漂移;设置测试已出现并行变化,仅在独立候选保留修正,不覆盖原文件。两个修复文件的严格ESLint和Prettier通过。
|
||||
|
||||
- 本机两个重型编译同时运行推进过慢,已对本轮独立Renderer编译PID27407发送SIGSTOP,保留进度;先完成完整typecheck,再发送SIGCONT接续。未暂停其它进程,构建入口无超时放宽。
|
||||
|
||||
- 暂挂本轮Renderer、降低并发负载后,原trace超时用例按原5秒时限精确复验通过:1项执行、68项由过滤显式跳过,实际1585ms;未改测试、append数量、collector实现或断言。与前轮其余68项通过收据组成完整69项覆盖,原两份超时失败保留。
|
||||
|
||||
- 修复后完整 `npm run typecheck` exit0,Renderer与Node两部分均通过,日志`typecheck-frozen-fixed.log`。已向本轮Renderer PID27407发送SIGCONT恢复构建;不存在仍挂起的本轮编译任务。
|
||||
|
||||
- 修复后Renderer构建exit0(7081 modules,耗时包含暂挂等待),App Server client与Electron Host typecheck/build及桌面资源构建通过;独立Cargo target正在编译v1.158.0 sidecar。
|
||||
@@ -0,0 +1,61 @@
|
||||
# Composer Code Block 高亮与自动语言
|
||||
|
||||
校准日期:2026-10-14。状态:有边界的静态链已确认,正式语言、高亮与 Auto plugin 已接线;定向回归通过,新 Electron 富输入 Gate74/74、current聚合22/22通过,默认前端127/127批与标准GUI smoke已通过;原wrapper失败保留,组合续跑步骤关闭。
|
||||
|
||||
参考 Codex Desktop `26.1007.30912` / build `20156`、bundle `com.openai.codex`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。完整审计、81 个原成员 UTF16 锚点及语言目录曾保存在 `.lime/qc/composer-code-highlight-audit/`。2026-10-14 用户释放空间时删除该目录,原件现已缺失;15 项产物的旧 manifest SHA-256 `35c932d1fb359d22320772c90587c429fadceb5cf94fe1b8f52c5efd46580cd7` 仅为历史记录,恢复版必须独立取摘要和验证。
|
||||
|
||||
REA Evidence `ev_32f87b5f925c87d58fbcfc530fbc03bc33536ab477882f98e9e2a9a70bbb150f` 只覆盖三个 inert 声明文件、13043 bytes、3233 AST nodes,parse failures 0,structural complete / semantic unknown。具体分支由原成员普通 Acorn AST 补证,五个成员摘要与 ASAR header integrity 一致。未执行参考应用、lazy loader 或 grammar,不能将该证据提升为运行准确率或全应用 reachability。
|
||||
|
||||
## 两个独立 plugin
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Controller[唯一 ComposerController / EditorState] --> Highlight[高亮 plugin]
|
||||
Controller --> Auto[Auto plugin]
|
||||
Highlight --> Parser[Lazy parser]
|
||||
Parser --> Decorations[UTF16 inline Decorations]
|
||||
Decorations --> View[PM EditorView / code contentDOM]
|
||||
Auto --> Detector[语言检测]
|
||||
Detector -->|防迟到校验后 no-history attrs transaction| Controller
|
||||
Registry[NodeView registry / toolbar Portal] -->|显式语言 transaction| Controller
|
||||
```
|
||||
|
||||
`rich && code` 创建高亮与 Auto 两个 plugin;有效 plain mode 移除两者。NodeView 只更新 metadata、class 和 toolbar,代码正文仍属于 PM contentDOM,不使用 HTML 或 React 语法渲染器替换其子树。
|
||||
|
||||
高亮 cache 为 `Map<codeBlockPosition, [PM Node, Decoration[]]>`。事务映射删除失效位置,正文或 attrs 改变使 `node.eq` 不成立而清缓存;未变化但移动的块映射原 Decorations。parser 输出 HAST 后转为 inline Decoration,范围使用 UTF16 与 `nodePosition + 1`。plaintext 的空结果也缓存。
|
||||
|
||||
原成员补查区分了底层 parser 与主 Composer 的输入:`gRc`(initial `[10242007,10242330)`)采用 `BVr`(`[3561285,3561361)`)作为 languageExtractor,先按 UI canonical/alias 目录过滤,再处理 math/mermaid;plaintext 提前返回空 Decorations。`dRc` 默认接受 raw attrs,`SRc` 的 raw parser 才拥有 53 grammar 和 5 组专用别名。完整注册 raw parser 不意味着主 Composer 也放开额外语言。HAST 到 PM 的参考投影只把 root element 的完整范围转为 inline Decoration,不递归生成内层 scope;grammar 的嵌套 token 能力与主编辑器的显示投影分别验证。上述结论来自同身份原成员普通 AST,不新增 REA semantic 或运行结论。
|
||||
|
||||
异步 parser 完成后刷新当前 EditorState;不将捕获的旧文档 Decorations 写回。pending Set 与 `view.isDestroyed` 约束 dispatch。上游 parser loader 失败固定使用无高亮 fallback;detector loader 失败会重置后允许再次请求。两者恢复策略不同,不能概括成统一自动重试。
|
||||
|
||||
## Auto 事务与失效
|
||||
|
||||
每个 View 的 `WeakMap<PM Node, Promise<language|null>>` 按节点 identity 缓存检测。初始化调度,文档变化后延迟 150ms;捕获 generation 和文档对象。等待 detector,以及必要时等待 parser 后,均重新确认 generation、同一文档对象和 View 未销毁。
|
||||
|
||||
仅处理 `spec.code === true && autoDetectLanguage !== false` 的节点。回写前重新检查当前位置与属性,以 `setNodeMarkup` 保留正文及其余 attrs,并设 `addToHistory=false`。新节点 seed 检测缓存,避免自动 metadata 回写重复检测。当前 selection 所在代码块从非空清为空时,映射旧文档确认身份后清 `language`;不无条件清扫全部空块。
|
||||
|
||||
自动 attrs 会改变文档;高亮 Decorations/meta 本身不会。Lime 必须分别验证 draft revision、发送 receipt 和 undo,不能由无历史标记推断这些接线已正确。
|
||||
|
||||
## 语言事实源
|
||||
|
||||
| 集合 | 数量 | 用途 |
|
||||
| ------------------------- | ---- | ----------------------------- |
|
||||
| Picker languages | 41 | 工具栏显示,保留源顺序 |
|
||||
| UI aliases | 21 | trim/lowercase 后归 canonical |
|
||||
| Auto subset | 39 | Picker 排除 math / mermaid |
|
||||
| Parser grammars | 53 | 高亮能力,不自动扩大 Picker |
|
||||
| Parser extra alias groups | 5 | parser 专用别名 |
|
||||
|
||||
Auto 决策顺序:空白返回 null;URL、headers、log 形态返回 plaintext;JSON/XML 和 15 条有序语言启发式优先;不足 12 UTF16 字符且无命中返回 plaintext;其余 `highlightAuto` 限定 subset,只有 relevance ≥2 接受,异常或未识别回退 plaintext。具体目录与正则保存在本机 `catalog.json`,不能把静态规则例子当实际执行输出。
|
||||
|
||||
Auto 选择写 `language=null, autoDetectLanguage=true`;Plain text 写 `language=plaintext, autoDetectLanguage=false`;显式语言关闭 Auto。math 高亮归 latex,mermaid 没有高亮映射;不据此增加公式或图表预览。math / plaintext display label 与语言选择器一起覆盖五语言。
|
||||
|
||||
## 实施与验证边界
|
||||
|
||||
唯一 owner 仍为 `src/components/input-kit/`。语言目录与规范化复用一个 owner;高亮和 Auto 保持独立 plugin;Registry / NodeView / Portal 只消费同一 Controller 与 View。新增描述性类名属于 Lime 实现命名,上游原开发名称仍 unknown。
|
||||
|
||||
验证需要真实 parser token spans、UTF16 范围、移动块映射、正文与 attrs 失效、plain mode、manual false、150ms debounce、迟到结果、销毁、restore、undo、资源与 receipt。明确 disabled / IME 策略,这是 Lime 的保护选择,上游外围保护未确认。真实 Electron 必须显示 token、操作 Picker、继续编辑、发送同一 canonical 内容并恢复 attrs;仅 `language-*` class 或 label 不足。
|
||||
|
||||
参考实时检测质量、core tie-break、全部 grammar 语义、实际 loader 恢复、系统 IME 和 Windows 仍 unknown / not-run。静态研究完成不等于产品对齐完成。继续执行见[当前计划](../../exec-plans/codex-gui-alignment-current-plan.md)。
|
||||
|
||||
正式迁移的直接依赖锁为pnpm-lock.yaml(pnpm9.15.9);lowlight3.3.0仅提供HAST,highlight.js11.11.1独立core检测,jsonc-parser3.3.1校验JSONC。新fresh rich-1 Gate实测Auto检测JavaScript→Plain无token→explicit TypeScript真实const token,语言动作不发input;后续编辑/Send/cancel/history保留正文和typed范围。构建护栏现追踪真实pnpm lock/workspace输入,先红2项后30/30,不能复用锁变化前产物。证据及完整覆盖限制见执行计划;53 grammar的全部参考语义仍未逐项实测。
|
||||
@@ -0,0 +1,68 @@
|
||||
# Composer Code Block 的 NodeView 与语言选择器
|
||||
|
||||
校准日期:2026-10-14。状态:参考静态合同已确认;Lime 正式 NodeView、Registry、Portal 与两项 plugin 已迁入,定向验证通过,新的真实 Electron 富输入 Gate 74/74、current 聚合22/22;默认前端127/127批与标准GUI smoke已通过;原wrapper失败保留,组合续跑步骤关闭。基础 schema 历史 Gate 不能替代本切片。
|
||||
|
||||
参考身份复用[主输入审计](./main-composer-audit.md):Codex Desktop `26.1007.30912` / build `20156`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。本次重新核对三个原成员的摘要:
|
||||
|
||||
| 成员 | SHA-256 |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| app-initial-6350d04672d0.js | 604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f |
|
||||
| app-primary-6f60e9ac4103.js | 7fe9c00b03cb1de3ebc3a4bf298fc44b13a7fe67ca9cc335a9e2ecda969a1321 |
|
||||
| local-conversation-page-a83a1af2c24b.js | f6275448cc603026ca3567e60a8c48d77d2b24cdc431c882280bbaa84b6010a0 |
|
||||
|
||||
REA Evidence `ev_bf209739980b2025edc530e2be3a7758959de28623928c7cb1af33d1ce38308f` 分析三个 inert 声明文件,15761 UTF8 bytes、4935 AST nodes、parse failures 0。structural complete,semantic unknown,1005 个保守 unknown;无完整模块 bootstrap/imports、原应用 reachability 或运行证明。具体调用分支由完整原成员的普通 Acorn AST 补证,不冒充 REA semantic 结论。压缩局部名不是上游原始开发名称。
|
||||
|
||||
完整审计、86 个原 UTF16 半开区间锚点、slice identity 和测试矩阵曾保存在 `.lime/qc/composer-code-nodeview-audit/`。2026-10-14 用户释放空间时删除该目录,原件现已缺失;旧 manifest SHA `4900789d…` 仅为历史记录。本页保存继续实施所需的仓库事实;恢复候选重新取摘要和验证,原成员不复制到生产源码。
|
||||
|
||||
## 主路径与 owner
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Provider[主 Composer Provider] --> Controller[唯一 Controller]
|
||||
Controller --> Registry[Code Block NodeView registry]
|
||||
Registry --> NodeView[toolbar + pre + code contentDOM]
|
||||
Registry --> Portal[React Portal 语言选择器]
|
||||
Portal -->|setNodeMarkup transaction| Controller
|
||||
Plugin[PM highlight plugin] -->|DecorationSet| NodeView
|
||||
```
|
||||
|
||||
主 Provider 调用(primary `[1426982,1427148)`)只有 composerController/defaultText/defaultTextKind/initialDocument/onDocumentChange/onDocumentDispose/children,不含 controllerOptions 或 spread。自建分支(initial `[11203980,11204238)`)启用 rich/code/selected-text-links,plain text 来自 setting。外部 Controller 分支提前返回,自建 defaults 不适用于它;外部初始 options 仍 unknown。
|
||||
|
||||
Controller 的 `cBc` 条件为 `enableRichText && enableCodeBlocks && !plainTextMode`;两处 nodeViews 装配在 PMU entries 后覆盖 `code_block`。registry 构造为 `new jRc()`,没有 options。PMU fallback、用户 setting 的真实取值和参考应用运行效果未验证。
|
||||
|
||||
registry(initial `[10245611,10246515)`)持有 NodeView entries、稳定 cached snapshot 和 listeners。createNodeView 登记;update 发布新的 entry;destroy/unregister 注销并通知 Portal 清理。UI 仅消费 registry,不持有第二份代码正文。
|
||||
|
||||
## 编辑 DOM 与事务
|
||||
|
||||
NodeView class(initial `[10246520,10251404)`)的结构为:
|
||||
|
||||
```text
|
||||
div[data-composer-code-block="true"][data-composer-code-block-wrap="false"]
|
||||
div[data-composer-code-block-toolbar="true"][contenteditable="false"]
|
||||
pre[dir="ltr"]
|
||||
code[spellcheck="false"].hljs[.language-*]
|
||||
```
|
||||
|
||||
code 是 PM `contentDOM`;toolbar 的 mutation 和事件被隔离,代码编辑仍交给 PM。update 对 foreign node type 返回 false;同 type 更新 attrs/entry/外层展示。NodeView 只更新 class/dataset,不通过 innerHTML、textContent 或 React syntax renderer 替换 contentDOM 子树。
|
||||
|
||||
`selectLanguage`(initial `[10250586,10250955)`)重新查询 getPos 和当前 node,失效位置/非 code node 直接返回;用 setNodeMarkup 保留其余 attrs、正文和 selection。Auto 写 `language=null, autoDetectLanguage=true`;选择 null 的 Plain text 且 schema 支持 auto attr 时写 `language="plaintext", autoDetectLanguage=false`,不支持 auto attr 才保留 null;显式语言写该语言并关闭 auto。不从 submission 的省略规则推导 UI label。
|
||||
|
||||
主 Portal 只观测到语言选择器:搜索、Auto、Plain text 和语言项。`getWordWrap` 默认 false,缺少 wordWrapAttribute 时 setter 无效。本主路径没有 wrap/copy/preview 控件证据,不将其他 surface 的配置搬入主 toolbar。语言 catalog/alias 和 Auto 回写的静态链已在[高亮补证](./composer-code-highlight-audit.md)闭合,实际运行效果未验证。
|
||||
|
||||
## 高亮与命名边界
|
||||
|
||||
`dRc`(initial `[10239875,10240828)`)由 PM plugin state/cache/decorations/promises/refresh meta 承接高亮,props.decorations 返回 PM DecorationSet。NodeView class 的 hljs/language-\* 只表示展示类别,不能单独证明 parser 已高亮。[高亮补证](./composer-code-highlight-audit.md)已确认按位置与节点相等性失效的 cache、UTF16 Decorations、独立 Auto plugin 和防迟到事务;正式产品已接线,实际 token、revision/receipt 和五语言回归已通过;新富输入/current进程证据与组合默认步骤已关闭,限制见执行计划。
|
||||
|
||||
`CodeBlockNodeViewRegistry`、`CodeBlockNodeView`、`CodeBlockToolbar` 是 Lime 的实现命名;上游原文件/类 descriptive names unknown。可对齐原成员可确认的方法属性:createNodeView/getSnapshot/subscribe/unregisterNodeView/publishNodeViewUpdate/destroy/ignoreMutation/stopEvent/update/selectLanguage/getAutoDetectLanguage/updateCodeElement/focusEditor/getContent。不得迁入 jRc/MRc/XBc 等压缩符号。
|
||||
|
||||
## Lime current 实现与验证
|
||||
|
||||
继续使用唯一 input-kit Controller/EditorView/Transaction owner。registry 放在 Controller 生命周期;Portal 绑定实际 View 的 toolbar entries,View 销毁后移除;禁第二编辑器或资源 store。语言事务不得推进无变化 revision、丢失富草稿、使迟到 receipt 清掉新输入。
|
||||
|
||||
基础 schema 的 pre/code DOM 可服务 serialization/clipboard;主编辑器的旧 `pre[data-composer-code-block]>code[data-language]` 观察必须迁移到上述 NodeView DOM。测试不为旧结构保留兼容分支。共享样式采用 Lime theme tokens,迁移时删除重复规则;多模型与首页 Banner 继续既有 owner。
|
||||
|
||||
验收覆盖 registry 稳定 snapshot/通知/注销、两 code blocks、语言事务与 undo/redo、stale getPos、disabled、toolbar 与 content mutation 隔离、正文/selection/IME/paste、五语言可访问名称、Portal 与 listener 清理。高亮若接线必须独立证明 token range、Decoration mapping、language 切换、异步过期结果和销毁后无 dispatch;Auto 检测结果不能由静态 label 代替。
|
||||
|
||||
真实 Electron 用新 DOM oracle 验证语言切换、继续编辑、Send、canonical readback、history/cancel 和无额外 input,重新冻结源码与实际 renderer/Host/sidecar。主题、多模型、Banner 是保留差异;Windows、系统 IME、原生录音和安装后的应用验收分别登记,不以 DOM synthetic events 替代。
|
||||
|
||||
2026-10-14 正式接线:`src/components/input-kit/` 的 Controller 唯一Registry、PM contentDOM、React Portal和独立highlight/Auto已应用;旧主DOM观察与历史fixture直接迁移,无compat双轨。真实五语言资源测试先红5项,修正为agent namespace后5/5;Toolbar与主Editor接线合计41/41,相邻消费者141/141、fixture189/189。fresh rich-1 Electron Gate74/74确认可信语言选择、正文高亮、单次发送、cancel/history与同canonical输入,证据与45项源/464项renderer/Host产物摘要关联。该结果属于controlled external backend与macOS Electron,不证明参考应用运行、OS IME或Windows;current聚合及default关闭状态以执行计划为准。
|
||||
@@ -1,6 +1,8 @@
|
||||
# Composer 草稿保存补证
|
||||
|
||||
状态:current / 静态结构已查证,Lime 产品迁移未实施。日期:2026-10-14。
|
||||
最新修复(2026-10-15):current 聚合第15场发现 Skills 试用完成但 Composer 仍禁用,程序化初始发送未提交真实新 session 绑定。既有 CommandWiring 现将 canonical accepted 回执交回可见 topic commit;真实集成先红后修,61/61、标准 typecheck/lint/格式通过。新产物的原 Skills 场景三条路径及 Electron 壳均通过,完整 current 聚合22/22及4组定向全部通过,默认门禁正按原失败批次续跑。源码/产物身份见 packet22;不能以 packet21 四合同覆盖新修复。定向与构建收据分别为 `.lime/qc/composer-persistence/imperative-session-scope-repair-directed-receipt.json`、`imperative-session-build-recovery-receipt.json`。
|
||||
|
||||
状态:current / Host、同源索引、Coordinator、Controller、typed acceptance 与公开新建导航已采用。packet21 的三入口完整富输入首发、rich Stop、Home 冷零写、三工作区两次正常冷恢复均通过;后续 packet22 接线修复按上段单独验收。旧 general 随机 draftTab/discovery 已删除,参考应用是否有全草稿 picker 仍 unknown。整体 GUI 为 partial。下文迁移前缺口和逐轮失败保留为历史证据,不作为当前待办。状态校准日期:2026-10-15。
|
||||
|
||||
沿用 [证据索引](evidence-index.json) 的 Codex Desktop 版本、ASAR 摘要及已提取 primary/initial/main 成员。本次仅从同一 ASAR 提取 `app-shared-8bd9d0399038.js`,SHA-256 为 `607ad0866dd25ed3764711b4467a9b60708353a599485b5249ff4cbe0e870245`;没有运行参考应用或 bootstrap。
|
||||
|
||||
@@ -57,26 +59,132 @@ main 原成员 `.vite/build/main-CqEoQDOU.js` 的 SHA-256 为 `09d19386e1f23cf4c
|
||||
|
||||
这两个 key 未直接写图片数组、路径资源数组或完整 attachment view state。相关资源有独立的 `DXr/NV/TXr` 等 owner,未追踪其完整保存链;不能据此宣称 Codex 附件不能持久化。
|
||||
|
||||
## Lime 缺口与接点
|
||||
## 迁移前缺口与接点(历史基线)
|
||||
|
||||
当前 workspace 内唯一 Controller 由 `useAgentChatWorkspaceLocalDisplayState` 创建,跨 Home/Inputbar 共享,但无 scope key、keyed draft map 或持久保存。导航通常复用同一 workspace mount;离开 Agent 页和重启会销毁实例。`TaskCenterDraftTab` 仅有 ID、标题、时间和状态,是导航 metadata。`agentSessionScopedStorage` 存 transcript/read model,`promptHistory` 存已提交正文;两者均不能充当未发草稿 owner。
|
||||
本节保留迁移前问题与接点,不能作为当前缺失清单;正式采用状态见文末。
|
||||
|
||||
首发转绑定有两条 current 路径:
|
||||
研究初始 workspace 内唯一 Controller 由 `useAgentChatWorkspaceLocalDisplayState` 创建,跨 Home/Inputbar 共享,但无 scope key、keyed draft map 或持久保存。导航通常复用同一 workspace mount;离开 Agent 页和重启会销毁实例。`TaskCenterDraftTab` 仅有 ID、标题、时间和状态,是导航 metadata。`agentSessionScopedStorage` 存 transcript/read model,`promptHistory` 存已提交正文;两者均不能充当未发草稿 owner。
|
||||
|
||||
迁移前首发转绑定曾有两条路径;materialization已删除,以下仅为历史基线:
|
||||
|
||||
- 预热/显式 materialization:`useTaskCenterDraftMaterializationRuntime` 记录 draftTabId→newSessionId,`useTaskCenterDraftSendDispatchRuntime` 在 dispatch 前提交导航转换;有 acceptance callback 时 preserveInput 延续 revision。
|
||||
- 直接首发:`useTaskCenterDraftSendRuntime` 将 sourceDraftTabId 与正式 session 交给 `useWorkspaceTaskCenterSendRuntime.handleNonMaterializedSessionReady`;无 draft tab 的首页分支也必须覆盖 home→session。
|
||||
|
||||
sessionId 与 canonical threadId 必须分别处理。`appServerSessionClient` 的 `thread/start` 返回 sessionId,`useAgentSession` 从 read model 建立 thread_id 映射。首发持久 scope 转绑定不能调用 restoreDraft 改动尚待 acceptance 的 revision。
|
||||
|
||||
最小现有 JSON 封装为 `agentChatStorage.loadPersisted/savePersisted`;Chromium sessionData 根由 `electron/appDataPaths.resolveHostSessionData` 统一处理 macOS/Windows。当前没有通用 DesktopHost JSON KV 合同;如对齐 main persisted store,应扩展既有 Host 边界并同步 IPC/preload/gateway/catalog/tests,不能借用 model-provider 的 UI state 或建立第二业务 runtime。`savePersisted` 当前在 try 外 stringify、无成功结果且 quota 失败会删除旧值重试,不能直接用作已保存证明。若选用该封装,最小合同应为 try 内序列化、拒绝 undefined、单次 setItem 返回成功状态、写入失败保留旧值;草稿 owner 必须消费返回值。`isStorageQuotaError` 仍服务 transient storage,不能随此迁移删除。
|
||||
最小现有 JSON 封装为 `agentChatStorage.loadPersisted/savePersisted`;Chromium sessionData 根由 `electron/appDataPaths.resolveHostSessionData` 统一处理 macOS/Windows。研究初始没有通用 DesktopHost JSON KV 合同;本轮已采用 PersistedAtomHost/Store/File、纯 JSON protocol 与唯一 persistedAtoms gateway,以下是迁移前选型约束:如对齐 main persisted store,应扩展既有 Host 边界并同步 IPC/preload/gateway/catalog/tests,不能借用 model-provider 的 UI state 或建立第二业务 runtime。`savePersisted` 当前在 try 外 stringify、无成功结果且 quota 失败会删除旧值重试,不能直接用作已保存证明。若选用该封装,最小合同应为 try 内序列化、拒绝 undefined、单次 setItem 返回成功状态、写入失败保留旧值;草稿 owner 必须消费返回值。`isStorageQuotaError` 仍服务 transient storage,不能随此迁移删除。
|
||||
|
||||
候选清理:scope 导航的 reset+setInput 改为唯一 owner 的保存旧 scope、切绑定、恢复新 scope;显式 clear 和 acceptance 后条件 commit 仍须分别处理,避免无条件写空覆盖旧 scope。具体实施必须先登记 Verification Contract 与架构图。
|
||||
|
||||
接受结果还需要按发送时 scope/version 消费已接受的旧草稿:导航后 `Controller.commit` 因 revision 或 scope 变化返回 false 时,不能让已发草稿在下次启动复活。首发 scope promotion 仅转绑定,不推进等待接受的 receipt revision;materialized/direct/home 无 draft tab 三条消费者路径分别回归。session identity 与 canonical Thread identity 分开记录,不用已有 provider UI state 承接草稿。
|
||||
接受结果还需要按发送时 scope/version 消费已接受的旧草稿:导航后 `Controller.commit` 因 revision 或 scope 变化返回 false 时,不能让已发草稿在下次启动复活。首发 scope promotion 仅转绑定,不推进等待接受的 receipt revision;现有Home/搜索新建/项目新建均沿唯一发送链分别回归。session identity 与 canonical Thread identity 分开记录,不用已有 provider UI state 承接草稿。
|
||||
|
||||
## Renderer 接线设计(2026-10-14)
|
||||
|
||||
本节是 Lime 的实施合同与 current 源码审查,未声称参考应用运行行为或 GUI 已完成。迁移前只读映射覆盖55个源码文件、134个锚点,全部摘要零漂移;43个验收场景。隔离设计manifest为 `7085719e0e5982acf7dfe25385cb307de06ed9d8ab3b137bcc660568b5addcc4`。Host13文件121项、gateway/fixture4文件70项、Main/IPC/诊断接线16文件80项属于迁前隔离验证记录,不覆盖后续正式源码身份。Host、索引、typed acceptance、coordinator 与 root 导航现已正式采用,后续验证见文末。
|
||||
|
||||
保存 owner 固定为 Main 同一 `composer-drafts-v1` atom;renderer coordinator只持有投影、绑定generation和待发mutation队列,唯一可编辑文档仍归Controller。renderer250ms、Main500ms分别合并;发送不await保存、无checkpoint或Quit握手。Main广播仅metadata,update应用成功与savedRevision覆盖必须分开。
|
||||
|
||||
### 冷启动的同源索引
|
||||
|
||||
2026-10-15 公开入口复核:以下索引能发现草稿 metadata,但目前唯一选择 handler 经 TaskCenterTabStrip,general Workspace 的 ThreadHeader/utility toolbar 条件排除该 strip;Home/new-task 与侧栏新建也不保留 strip 条件。因此“索引接线”不等于用户可以选择重启后的草稿。原 cold fixture 的 Home → +tab 路线不可达,不采用、不恢复旧 strip;下一刀依据同版本 REA 确定公开恢复导航。三首发改用 Home、搜索新建和项目侧栏新建,通过同 atom index 与 scope read 唯一关联实际 identity,单独验收逻辑草稿发送,不声称冷选择或标签 UI。
|
||||
|
||||
同版本新增 REA `ev_8109ba0f2d675fb08989285ee7408e675b5a2d4953513f757c11e959c4934ab5`(5 member / 51,330 bytes)及 `ev_5872c498b099311cff8f5d0c5d7ca8b99dae35efcceb3f42eeabd3eb526e9c47`(27 inert declarations / 14,506 bytes)均 parse failures 0、structural complete、semantic unknown。可确认导出名 `startNewConversation`,主侧栏 New chat 调用该领域动作;`draft-thread-identities-v1` 仅 home/panel 当前 client identity,`client-thread-bindings-v1` 绑定 canonical 后清掉匹配旧 identity。恢复 gate 来自主窗口 persistence owner,不能扩展成所有窗口无条件恢复。所有历史未发送草稿的侧栏 picker 仍 unknown,不据此新增 Drafts 列表。
|
||||
|
||||
下一步产品迁移应统一公开 `startNewConversation` 入口并清理隐藏随机 draftTabs 双轨;当前最小真实验收复用既有稳定 home scope、Main/Coordinator/Controller,验证正常重启后公开 Home 恢复完整当前草稿。此方案与未发送多草稿 picker 的缺口分开记录,不能仅凭索引测试关闭后者。
|
||||
|
||||
迁移前草稿标签仅 `useState([])`,重启后不知道旧draftTabId;按已知scope单读不足以完成冷恢复。当前 `useWorkspaceComposerDraftIndex` 已消费同一 atom 的 workspace-index query,发现 metadata 并投影草稿标签。read 合同已直接迁移为判别 query/data,不保留 scope-only 兼容入口:
|
||||
|
||||
- `{key, query:{kind:"scope",scope}}` 返回 `{outcome:"ready",data:{queryKind:"scope",view},...metadata}`。
|
||||
- `{key, query:{kind:"workspace-index",workspaceId}}` 返回 `{outcome:"ready",data:{queryKind:"workspace-index",workspaceId,items},...metadata}`。
|
||||
- item只有 `scope/identity/updatedAt/sessionId?/threadId?`;无正文、附件或标题摘要。invalid/unavailable返回data:null,不能解释为空存储。
|
||||
- Main按workspace精确匹配(含null)、当前binding指向state=draft过滤,排除consumed/tombstone/孤儿。确定性排序,沿10,000记录/绑定预算返回完整metadata集合,不静默截断。
|
||||
- 索引读取不改storeRevision或updatedAt,不做CAS或占record预算;它只用于发现。选中后再读scope并复核identity/version,后续put/promote/consume仍执行原CAS。
|
||||
- 唯一React持久hook投影draft标签,标题使用五语言默认文案;活动选择与关闭收起状态不被迟到索引强制覆盖。不另建localStorage标签索引。
|
||||
|
||||
### 明确接受结果与导航
|
||||
|
||||
| 迁移前缺口 | 最小迁移合同 |
|
||||
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| threadClient丢弃startTurn真实response,adapter/stream/chat多层Promise<void>,Workspace将await包装为true | API保留真实response,在小execution边界投影typed runtime acceptance,透传实际captured sessionId/threadId/turnId |
|
||||
| queue已有QueuedSubmission,InputbarScene丢弃后返回true | 保留真实queuedSubmissionId/clientUserMessageId及session/thread身份,不伪造turnId |
|
||||
| steer响应已接受,后续refresh失败仍整体报发送失败 | receipt在post-processing前结算,projection错误独立上报,不撤销接受事实 |
|
||||
| slash review/diff/init发送prompt后丢receipt;本地help/confirmation/shell也以true/void表示 | prompt分支透传真receipt,本地handled独立分类;true、void、unknown、仅dispatched不能消费草稿 |
|
||||
| Dispatch卸载先settle false,真实sendPromise之后仍可能accepted | 请求closure中的onRuntimeAccepted先通知存活coordinator,UI卸载只解绑可见更新;callback/return重复结算幂等 |
|
||||
| direct/home迟到回调重读activeSessionIdRef | promotion使用请求捕获source和response实际目标,不使用当前global ref |
|
||||
| TopicNavigation/ResetRuntime/Materialization/DraftState导航前无条件reset | before只capture/enqueue旧scope,导航实际committed后bind/restore;blocked/deferred/error保留旧binding |
|
||||
| interrupted restore仅WeakMap防重复,缺scope和编辑guard | UI恢复envelope携带captured logical identity/scope,核实当前编辑后恢复,不放进AppServer wire |
|
||||
|
||||
上表左列是迁移前快照;typed acceptance、opaque restore token 和导航 owner 已直接采用,不能继续列为当前源码缺失。existing local和稳定Home正常冷恢复已通过,公开三首发仍pending。发送同步捕获logical draftId/version、scope、Controller receipt和未发布payload;后台队列固定captured put在consume之前,业务请求立即继续。明确accepted后跨导航消费旧identity,即使当前Controller.commit因新scope/revision返回false;当前UI仅同logical binding与receipt匹配时清理。promotion只移动绑定,不改draftId/version或receipt revision。materialized、direct draft、无draftTab的Home三条分别验收。
|
||||
|
||||
hydrate/navigation restore、accepted commit及clear导致的Controller通知不生成普通put,避免程序清空又创建空草稿。明确用户clear写tombstone,关闭标签只收起导航;消费记录不自动驱逐,迟到put不能在absent恢复旧draftId。容量、无效codec或IO失败整次拒绝保旧,不阻塞业务发送;只有实际savedRevision覆盖后才宣称已接受版本不会在冷启动复活。
|
||||
|
||||
实施顺序为Host基础接线与正常重启 → 同atom索引与typed acceptance → coordinator/codec及导航 → 发送三路径与冷恢复GUI Gate。typed acceptance core与surface已正式迁入;coordinator、codec、导航及发送restore token也已正式采用。root React与项目导航组合owner已正式接线;existing local与稳定Home正常冷恢复已通过,公开三首发仍在验证,不新增void-compatible结果双轨。
|
||||
|
||||
## 未知与边界
|
||||
|
||||
- Codex 真实退出/重启效果、main500ms与renderer250ms待保存窗口的强杀耐久、账户切换清理未验证。globalState文件及静态原子替换链已补证。
|
||||
- 两个 key 的静态形状不能证明跨重启图片、外部路径和完整附件恢复。
|
||||
- Lime 尚未实施 scoped persistent draft;现有内存共享与 history Gate 不关闭这个缺口。
|
||||
- Lime existing local与稳定Home正常冷恢复已取得独立Desktop Gate B,local含发送消费;公开三首发、最新产物Stop和默认门禁仍待验。
|
||||
- 原始 Evidence、切片 manifest 和 AST observations 留在本机 REA 分析目录;仓库只保存身份、合同、映射和限制,不纳入参考应用源码。
|
||||
|
||||
## 基础 Host 正式迁入(2026-10-14)
|
||||
|
||||
37源已直接迁入:`PersistedAtomHost/Store/File`、纯JSON protocol/types、唯一`persistedAtoms` gateway、Main/IPC/preload/event policy、safeInvoke/HTTP诊断、catalog/ESLint/contract guard与公开fixture/driver。参考100/200/400ms rename retry和独立test-root两个overlay一并采用,迁前后before/final SHA全部一致。原隔离包保留冻结,正式证据单独记录。架构与命令事实源同批更新,由root确认Host UI保存与canonical业务链分离;Main只组装/委托/dispose,无新增CAS或落盘业务。
|
||||
|
||||
基础迁入阶段曾使用 scope-only read;后续同源索引已直接替换为 query/data,旧 wire 删除。基础 Host 与索引的正式回归、公开IPC及 saved 后正常新PID重启已通过,详情见下一节。coordinator、codec、typed acceptance、metadata冷标签与root导航现已正式采用;这些基础或领域回归不能计为 GUI 冷恢复产品验收。不存在生产mock、第二runtime或compat包装;dispose不等待flush,pending立即退出与强杀仍未验证。
|
||||
|
||||
## 基础 Host 真实验证与索引直接替换
|
||||
|
||||
基础正式定向组合307项、contracts/typecheck/窄lint/格式通过,标准Electron GUI smoke exit0。公开IPC修后真实15场景通过,36次调用/7原生metadata通知,revision7;PID96100和96356分别正常code0/signalnull退出,新PID读回scope/identity/version/正文摘要一致,tombstone跨重启防复活。fixture起初遗漏parseEnvelope required limits,实际parser回归先红后绿后修复,旧失败证据保留。最终receipt `.lime/qc/composer-persistence/foundation-final-receipt.json` SHA `ef8ed5e08e98557448897860736088a55da3b7afb32ca680715855836ac9f268`。本场为unavailable backend,不证明AppServer业务/runtime accepted或GUI hydrate;保存完成后退出,不证明pending立即退出、强杀、Windows或断电。
|
||||
|
||||
随后索引与消费者23源已成套直接迁入;旧scope-only/top-level view删除,read现在仅query/data。File retry、自有test-root与真实parser driver修复保留;正式索引20文件439/439、contracts/typecheck/lint/格式、标准Electron smoke均通过;新query/data公开IPC21场景/46调用/10原生事件、revision10,PID24613与25004均正常退出。current/other/null三个workspace的index快照在保存前后与正常重启后同revision/同items摘要,consumed不回流。receipt SHA `411ba2f4f4d59805b635780f9f2ba89ee177f8a8bd9e070766da21f3c48ef941`。索引只读metadata、预算完整10,000回归通过;底层通过仍不等于GUI冷标签恢复。
|
||||
|
||||
## GUI current owner 与本轮验证
|
||||
|
||||
`ComposerDraftCoordinator` / codec / `useWorkspaceComposerDraftRuntime` / metadata index / `ComposerDraftStatus` 已进入正式源码。原 boolean/void 接受链直接迁移为 typed result,post-processing、slash、本地处理与queue分开;原capture及opaque恢复令牌透传至ActiveStream/stop恢复。TaskCenter send-driven materialization与迟到ready守卫已完成正式接线和分批定向验证。原导航无条件clear直接替换为prepare旧scope与实际committed目标bind;`useWorkspaceComposerDraftNavigation` 组合项目 picker 与待切topic,区分合法 topic-switch 和显式目标,并在真实session/workspace匹配后才清activation。唯一bind留在root runtime,不新增兼容入口。
|
||||
|
||||
正式owner组合48/48、root导航/reset组合45/45、TopicSwitch来源组合45/45通过,各自收据不相加为唯一case总数。草稿保存文案覆盖五语言并只根据实际savedRevision显示。root发现并修复参数层级丢coordinator、旧topic完成清新draft、初始化new-task误清、同scope误取消请求与layout前迟到promotion反抢目标;回归复用真实Controller/Store。真实Electron完整富JSON/资源、冷标签、三路径accepted与正常重启及默认门禁仍pending,基础Host与静态REA均不替代产品证据。
|
||||
|
||||
新增项目导航领域组合6/6通过:真实ProjectSelection、TopicSwitch、DraftState、Controller、gateway/parser/Store与React root runtime覆盖跨workspace合法切题、匹配前保留activation、旧epoch失效、picker保存旧scope/恢复新home,以及同事件显式draft目标;renderer strict两入口708解析文件零诊断。该结果与前述组合重叠,不累计为唯一case总数,也不构成Electron或重启Gate。
|
||||
|
||||
命名边界:`ComposerDraftCoordinator`、`useWorkspaceComposerDraftRuntime/Navigation/Index` 和 `ComposerDraftStatus` 是Lime current owner命名;参考安装包只确认atom key、成员身份、字段/方法属性与有界调用锚点。`pYr/hYr/BYr/Put/Vr`等压缩局部名不是上游原开发名称,REA semantic仍unknown,无source map或参考实时运行证据。合同对齐不等于恢复了上游完整源码命名或全GUI实现。
|
||||
|
||||
本轮 TaskCenter 73 项不同测试分批通过,send/flow 283、页面消费者 41、owner 守卫 15、pending 新编辑 runtime 9 项分别通过,组合不相加为唯一case。新标准 GUI smoke 通过,仅证明真实 Electron 壳/bridge/重载/三尺寸;本轮 rich/current/default 验收运行中。真实富草稿 fixture 首两轮失败均正常退出,修正 opaque 字符串版本、消费解绑与 installed skill 执行 key 的测试合同,Node evidence 24/24;保留失败收据,不以静态 fixture tests 计产品 Gate。
|
||||
|
||||
正式第二轮 Stop 生命周期修复:自动 refresh 空投影从 restoreDraft({}) 改为 replaceDocument({}),保留同一空正文 revision;人工 restore/history 与严格 scope/generation/identity/revision 令牌不变。真实 Workspace local/home/draft 回归先3失败/14通过→17/17;正式组合269通过/1trace超时,原5秒上限独立69/69通过。fixture 先验完整 GUI Stop,再通过公开资源移除/Skill clear/键盘清空独立验 history,不能把用户 history 编辑后的旧token拒绝误写为产品缺陷。
|
||||
|
||||
侧栏第二 opened project 真实失败已由产品回归确认并修复:查询与过滤统一 conversationDisplayProjects,实际两个返回会话及第二 row 点击先红后71/71。首发四叶与Home cold三叶正式采用,Node84/84/lint通过;前者验完整canonical User Item与media/read字节,后者仅稳定home当前scope,不证明随机多草稿picker。正式发送结算helper遗留临时软链接已按采用收据同SHA落成普通文件,源码行为未变;renderer-only build 与新标准 GUI smoke exit0,仅证明标准壳/bridge 场景。
|
||||
|
||||
### 当前真实失败与验证身份(2026-10-15)
|
||||
|
||||
`composer-stop-lifecycle-20261015-1` 原 rich 场景 exit1:session/thread `01a13bb8-ca6e-7b72-b943-c789f3847033`、turn `turn_65b39477aea54ce3b0d70cba4de012b0` 经真实 runtime accepted、Stop interrupt 与 canonical canceled。GUI 正文/path/image/skill 全空、无 assistant 输出、编辑器可编辑。日志有 inputRestorePlan、dispatch 与 Workspace received,没有 Inputbar apply;token 现场完整性与具体拒绝原因尚 unknown,不能以 hook integration 通过推断此 Gate 通过。
|
||||
|
||||
local 第七轮已点击第二项目会话并出现同 session Composer,随后 code picker 准备超时。只增强 fixture 的同 session/唯一 editor/可编辑 ready 前提,不改产品或恢复 oracle;Node evidence24/24、lint exit0。第八轮三个项目的rich编辑保存与拒绝保稿检查后,在Undo的键盘选区oracle失败,未达冷重启;正常quit收据失败,之后只读确认Main75321已不存在,不计正常重启证据。freeze3 包含 7,885 source / 1,358 artifact 摘要,packet SHA `6a530f8b5b0188a6bcfdb10bb68d4f3d1e825f12eadc252a3983da95c8f482fd`;launch 前后独立核摘要零漂移。它不证明全部模块执行,保留旧 chunk 可能存在。
|
||||
|
||||
默认第87/131批两次分别211通过/1首例5秒超时,超时位置由 references 改到 EmptyState slogan;references 原5秒隔离9/9。未改 timeout、skip 或 resume 状态,资源压力只作为观察,不代替缺陷判断。默认门禁与持久化产品 Gate 继续 pending;公开新建会话的下一迁移见 [导航审计](./new-conversation-navigation-audit.md)。
|
||||
|
||||
Stop拒绝诊断已正式采用,仅在既有AgentDebug开关下console记录revision/generation/owner/scope是否匹配和失败原因,无正文或资源路径;guard谓词未变。诊断renderer构建exit0,rich2仍exit1;freeze4前后7,885 source / 1,463 artifact零漂移。完整context确认token、Controller、Coordinator、revision22、generation1和scope均匹配,唯消费后identity由null变为存在,原因为binding_changed。
|
||||
|
||||
领域回归复现消费前在途refresh读到消费后的absent并误建空identity:两时序先1失败/1通过,#retire递增read使旧读失效后,五文件94/94通过。保留严格恢复守卫与原富输入oracle;新renderer及真实rich复验结果见下文。发送全链候选两叶已独立核冻结并正式采用,受控transport的3项含真实EditorView,正式3/3、四叶lint/格式通过;不证明Electron Gate已关闭。
|
||||
|
||||
随后标准typecheck、新renderer构建均exit0;原rich场景第三轮 `composer-stop-retire-read-20261015-3` exit0,common34/34、scenario21/21,actionable console/page errors均0。同一canonical session/thread `01a13cd5-0f79-7c32-9150-1fefd750b006` 的接受、Stop取消、可见富草稿/资源/marks、history召回及长正文折叠通过。freeze5的7,887 source / 1,568 artifact前后独立核零漂移,packet SHA `989a12818eb5ad891700089721657fd2baad2d038bab1056b94a4ba0420078f9`。收据 `.lime/qc/composer-persistence/retire-read-rich-final-receipt.json`;这是受控backend的Desktop Gate B,不含live、冷重启、Windows或隐藏paste source的DOM召回证明。local9未达冷恢复,后续定位见下段。
|
||||
|
||||
local10在导航后已严格验证原rich JSON和DOM;拒绝前后entry精确相同,无canonical Turn。trusted Undo后的payload/document/hidden paste/资源摘要与原baseline一致、selection恢复[143,146]。随后fixture额外的Meta+End及三次ShiftLeft使最终selection为[143,143],正文与marks仍在;未观察逐键默认行为,不能归因为产品Undo破坏。Main74494正常code0/signalnull退出,freeze6前后零漂移。fixture删除Undo后marker补写与重选,直接要求clean.draft与入口baseline.draft深相等,再验rich,Node24/24、lint/格式通过;scopeVersion不参与原稿比较。local11使用freeze7(仅该fixture变化、产品/产物零delta),packet SHA `4fa24dfa20c8d849cac836d85c0d80fad086cc56200e4f96da40830512403c41`,第三workspace代码picker trigger超时,截图编辑器为空,未达Undo/cold;Main82377正常退出,前后零漂移。尚需定位hydrate/后续清空时序。
|
||||
|
||||
local14(freeze11)已通过完整 native request trace 和正常退出:Main48987 code0/signalnull后启动新Main52369,首次进入完整GUI冷恢复。A恢复后原revision27变28、installed_skill route被删除,未打开B/C的route保留;正文长度按JS UTF16比较,不能把Python Unicode计数差异算作丢字。Inputbar在Skills catalog尚空时将fallback执行key与namespaced mention id直接比较,误清路由;真实Inputbar回归20项中18通过/2纯行为失败。修复复用能力身份owner的namespace/execution匹配,完整富JSON/资源/选区/identity和零额外写入要求不变;新产品Gate待重建与独立冻结后复跑。本次产品缺陷不计参考应用行为或全GUI完成。
|
||||
|
||||
local15修后首次cold严格恢复三个workspaces的完整JSON/DOM/资源/选区/identity,同revision32无额外mutation;后段合法无capability新稿在完整相等通过后,test-only摘要hash(undefined)失败。nullable摘要两叶修复,先27通过/1行为失败→组合78/78,rich route必需及完整相等不变。freeze13只两fixture delta、产品产物零漂移。local16已exit0:三rich、拒绝保稿、原选区Undo、接受consume/new-edit、异workspace interrupt及两次正常cold全部通过;Main34272/38556/40802均code0/signalnull。summary位于 `.lime/qc/composer-persistence/gui-navigation-readiness-evidence/composer-navigation-readiness-local16/composer-draft-gui-persistence.json`。这是受控backend Desktop Gate B,仅关闭existing local conversations,不含Home/首发/live/Windows。
|
||||
|
||||
Home6已在新Home callback构建上exit0,Main17922/18120均正常code0/signalnull。公开Home编辑完整富稿、同atom metadata索引、磁盘原字节与revision在退出和cold后不变,第二进程恢复完整JSON/DOM/资源/原选区/identity,无canonical Thread/Turn,冷trace零atom mutation。Home3/4首次正常quit期限失败保留;Home5误要求直接preload workspace helper出现在GUI safeInvoke记录,fixture最小迁移回归先红后Node62/62及collector69/69,未放宽禁止input/原生完整采集/冷零写。packet16仅两fixture变化、产品产物不变,后验7884source/1883artifact零漂移;收据 `.lime/qc/composer-persistence/home-cold-final-receipt.json`。这是受控backend、saved后正常退出的稳定null Home切片,不证明多草稿picker/live/Windows/强杀耐久。
|
||||
|
||||
packet19已验证Home完整首发/资源/consume/new-edit与正常退出;三入口整体仍失败于search-new富稿保存冲突。真实Coordinator/Store回归复现queued clear前序put通知恢复已退休identity;current修复只增加discard在途read保护和完成时read失效,Store CAS、retired identity及Stop exact token不放宽。非Stop定向通过,Stop原5秒timeout尚未关闭;新产物与当前验证进度以执行计划/证据索引为准,不把旧local16/Home6升级为新产物通过。
|
||||
|
||||
新修复标准typecheck/renderer/freshness/桌面壳均exit0;packet20 first6 Home与search-new完整富输入/资源/consume/new-edit及正常退出通过,证明原搜索保存冲突已在真实路径消失。项目按钮fixture补真实header hover后Node53/53/lint/格式通过,packet21 first7三个入口业务断言全部通过,但第三Main退出超过原15秒期限;随后正常teardown不计整轮pass。first8被动记录退出计时,失败时仅采样自有Main;原Stop测试5秒timeout、current/default仍未关闭。
|
||||
|
||||
packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮及default续跑待关闭。
|
||||
|
||||
## 最终验证收尾护栏(2026-10-15)
|
||||
|
||||
默认验证已按实际前置检查、原生132批续跑、contracts、legacy与标准GUI24/24的组合关闭,原verify:local失败不改写为单次wrapper绿。续跑1761为历史登记路径,1756现存、5项已删除;修改过的早期owner单独回归,新增退出测试不冒充进入原状态。最新产品与2303目录产物来自freeze30;第5轮current短问候接受1978ms超过原1800ms预算,原场景隔离78/78、921ms及第6轮973ms通过,但超预算根因仍unknown。
|
||||
|
||||
第6轮前7场子进程正常结束,第8场审批完成所有业务断言后卡在finally。诊断明确pending BrowserContext.close、Node连接已关闭、无Node待响应调用、renderer/helper已退,仅Main存活;原生sample符号不完整,底层根因unknown。该轮已独立冻结并中断,SIGTERM清理不算正常退出。新fixture复用既有正常退出owner,仍限15秒,只有Main实际code0/signalnull及全部资源释放后才写最终ok并打印pass;失败后强制回收不会修复Gate结果。独立teardown行为回归6项、组合110/110通过;原审批隔离66/66、Main34547正常退出且无强制回收。freeze31仅4项fixture变化,产品产物不变,完整第7轮聚合已exit0、22/22通过,四组31/34/3/300,21个Claw正常Main回执且强制回收0;Coding仅保留既有子进程证据,不虚构Main回执。7886源/2303产物及产物路径集后验零漂移,当前summary/显式ledger/截图与log共67份独立冻结。旧第4轮manifest包含的遗留Skills失败截图保持历史原件,不混入新轮证据;最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭,整体GUI仍partial。收据与失败身份见evidence-index的current_gui_verification_20261015。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Composer link/code 样式补证
|
||||
|
||||
日期:2026-10-14。复用同一 Codex Desktop 版本与 ASAR 摘要,扫描安装包内317个 packed CSS成员、2687091 bytes,逐成员核对inventory integrity。这里是有界文本观察,不是新的REA semantic或参考应用运行证据;原始CSS与观察记录只留本机分析目录。
|
||||
|
||||
| 成员与SHA-256 | 可观察选择器与规则 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| `app-initial-99e8b4b455fa.css` / `1a75f9180b89d2457dc09e4efea288436c9cf6d44e5ba59b2c03d3d571b7bf61` | 生成选择器 `_RichTextInput_juri1_1 .ProseMirror [data-composer-code-block]` 约UTF16位置189069:container宽度/最小宽度、圆角、surface背景与clip;直接子块margin为0,内部 `>pre` 有mono字体、padding、tab-size4与横向滚动;`>pre>code`为block,另有toolbar背景 | CSS存在不证明当前主surface有效NodeView。生成CSS名不作为原始源码类名 |
|
||||
| `app-shared-9a94b907ed47.css` / `69f929f71947824fed18365d54c8aa0c975a856649339e585a50f6f547e215d7` | 约UTF16位置68979的mention样式对breakable URL做ltr/isolate与换行,对text-link-href移除inline padding | 需要追踪实际mark NodeView与生成class的绑定,不能把plain span自动判定为视觉等价 |
|
||||
| `content-bc0b899e44c2.css` / `3875ca200b50e2b5f3868d3091610a5250db34e8f4e05f2957efcd484ec249cb` | 约UTF16位置18290:`data-composer-code-block-wrap=true` 时内部pre/code采用pre-wrap/anywhere | wrap状态来源、默认值与toolbar交互仍unknown |
|
||||
|
||||
Lime本批对齐基础schema、link/code语义、Markdown发送和完整draft恢复;schema当前生成 `pre[data-composer-code-block]>code`,与以上container `>pre>code` 的CSS选择器形状不同。这个观察不能证明Codex当前主surface始终采用后一形状,但基础schema的通过不关闭完整NodeView/toolbar/样式合同。
|
||||
|
||||
后续在既有ComposerEditor/View插件owner追踪NodeView选择与有效选项,确认后复用Lime主题token实现;不得为CSS推断增加第二Controller。主动链接导航/编辑、code toolbar、wrap与完整rich PMU继续单列待办,当前富输入Gate仅证明其声明的基础文档、键盘、发送与恢复合同。
|
||||
|
||||
实施进度与具体产品证据见[当前计划](../../exec-plans/codex-gui-alignment-current-plan.md);迁移前语义证据见[marks/voice审计](marks-voice-audit.md)。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
# Codex Desktop 公开新建会话与草稿导航审计
|
||||
|
||||
状态:设计与源码迁移已完成;packet21 的三入口完整富输入首发、rich Stop、Home 冷零写及三工作区两次正常冷恢复四项真实 GUI 合同全部通过。后续 packet22 的程序化发送绑定修复已通过原 Skills 三路径和桌面壳,完整 current 聚合22/22已通过,默认门禁正按原失败批次续跑。整体 GUI 继续 partial;下文逐轮失败属于原身份历史证据,不能覆盖新产物。
|
||||
|
||||
目标为 `/Applications/ChatGPT.app` 中的 `com.openai.codex`,版本 `26.1007.30912`、build `20156`;ASAR SHA-256 为 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。安装路径不能作为消费者 ChatGPT Desktop 的身份依据。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
复用 REA Evidence `ev_8109ba0f2d675fb08989285ee7408e675b5a2d4953513f757c11e959c4934ab5` 的五个完整 member,以及 `ev_5872c498b099311cff8f5d0c5d7ca8b99dae35efcceb3f42eeabd3eb526e9c47` 的 27 个 focused inert 声明。恢复候选核对了 46 个证据文件,摘要一致;没有新增分析 chunk、执行 bootstrap 或参考应用。
|
||||
|
||||
已确认公开导出名 `startNewConversation`。initial member 的 `wH [4012522,4013720)` 返回首页、聚焦 Composer,仅在 freshDraft/task metadata 等分支重置身份;`h7r [4013720,4013868)` 是相邻导航调用。侧栏调用链见 `les [7997002,7998775)`、`ses [7996562,7996734)`、`Ati [4062791,4063226)`。这些压缩局部变量不能当作上游开发源码名。
|
||||
|
||||
shared 的 `fRn [3640958,3641256)`、`sRn [3640457,3640611)` 与 `TRn [3643120,3643439)` 支持 home/panel 的 current client identity 和 canonical materialization 后解绑的静态映射。是否存在所有未发送草稿的侧栏 picker、普通 New chat 的运行时 fresh 行为、预热时机和全部窗口状态仍 unknown。
|
||||
|
||||
## 迁移前差距(保留原始审计事实)
|
||||
|
||||
| current 路径 | 已观察问题 | 迁移目标 |
|
||||
| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `useAgentChatWorkspaceCommandWiring` / `useWorkspaceResetRuntime` | 初始 Home 已使用稳定 `home/new-conversation` scope | 保持唯一 root bind owner |
|
||||
| `useAppSidebarConversationActions` → `taskCenterDraftTaskEvents` → `useTaskCenterNewTaskPageRuntime` | Home 与 New chat 共用 cancelable event,进入随机 `task-draft-*` | 统一公开 `startNewConversation` 动作和显式 intent |
|
||||
| `useTaskCenterDraftMaterializationRuntime` | 输入触发 1 ms 预热,维护 direct/materialized 首发双轨 | 必要 session 创建只在既有发送主链承担 |
|
||||
| `useWorkspaceComposerDraftIndex` / draftTabs / tab chrome | 恢复 metadata,但 general GUI 没有公开选择 consumer | 清理随机 UI 状态,不据此新增 picker 或恢复 strip |
|
||||
| 新建事件的项目参数 | 显式 null 被兜底为当前项目;请求其他项目被 ignore,listener 仍 preventDefault | null 保真,按真正提交的 route/scope 执行 |
|
||||
|
||||
canonical session tabs、sidebar Thread 投影、后台运行/queue、Canvas 和 image workbench 有真实消费者,需按引用迁参;不能根据名字包含 tab 批量删除。标题 helper 先迁到既有 header owner,再清理 dead general chrome。
|
||||
|
||||
## 实施合同
|
||||
|
||||
Lime 决策为 Home 使用 `freshDraft:false` 恢复当前稳定草稿;明确 New chat 使用 `freshDraft:true` 开始空稿。普通 Codex sidebar 的静态参数未强制 fresh,因此不能把这项严格 fresh 决策写成参考运行事实。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
H[Home: 恢复当前草稿] --> N[startNewConversation]
|
||||
C[New chat: 新空稿意图] --> N
|
||||
N --> A[既有 App navigation 提交 route]
|
||||
A --> R[Workspace root 提交 scope 与 epoch]
|
||||
R --> D[唯一 ComposerDraftCoordinator]
|
||||
D --> P[Main persisted atom Host / Store]
|
||||
D --> E[唯一 Controller / EditorView]
|
||||
E --> S[既有 typed submission]
|
||||
S --> T[App Server canonical Thread / Turn / Item]
|
||||
```
|
||||
|
||||
动作由现有 `useAppNavigation` 持有,经 App props 传给真实调用者;不另建 event bus、context store 或业务后端。`buildHomeAgentParams` 保持纯 route 参数构造职责。进入 owner 后 projectId 必须显式,undefined 与 null 不混用;Logo 的无项目行为明确传 null。
|
||||
|
||||
1. 导航请求只 flush source。路由被阻塞、延迟或拒绝时保留旧正文、资源和身份,不能把 void onNavigate 当作成功确认。
|
||||
2. 读取真实 committed route/request identity;连续请求有独立 epoch,不能只依赖 Date.now。fresh 同 scope 也递增 epoch,重复 effect 只消费一次请求。
|
||||
3. 目标 scope committed 且 bind ready 后,由同一 Coordinator 执行受保护的 fresh CAS;CAS applied 后才清空正文。现有乐观 `clear()` 不能直接充当成功的新建确认。
|
||||
4. 检查 request、epoch、scope、generation、opaque binding、draftId/scopeVersion 与 Controller revision。更新期间编辑、再次导航、冲突或不可用结果不能清除新编辑;Stop exact revision token 不放宽。
|
||||
5. 空稿 identity 允许为 null,下一次有效编辑分配新逻辑 draftId;需证明旧 identity 已退休,不用随机 tab ID 伪造空稿身份。
|
||||
6. 首发沿已有 typed acceptance → canonical receipt → home/local promotion/consume。晚到 receipt 只处理其 captured source,不接管最新可见 target。
|
||||
|
||||
## 写集、清理与验证
|
||||
|
||||
按公开动作/intent → 随机草稿与预热删除 → dead chrome 与测试护栏迁移三步实施,完整迁移后才关闭切片。Host/IPC/schema 暂不夹写;`draft` wire kind 是否删除,待全部真实消费者清点后另同步协议、消费者、fixture 和 contracts。
|
||||
|
||||
迁移旧 event receivedDetails、跨项目 ignore/null 沿用、禁止 onNavigate、warmup 预算、random index、第二层 tabs 正向断言与强制 draftTabs 字符串护栏。继续覆盖唯一 root bind/no parallel store、失败保稿、迟到 receipt、canonical session/Canvas、Stop 恢复、workspace 隔离与后台 queue。
|
||||
|
||||
必要证据包括公开点击 → 真实 route commit → root bind → Main/IPC/store → 完整 PM JSON/selection/resources → canonical receipt 与用户可见结果。定向真实 owner 回归、typecheck、相关 contracts/脚本治理、GUI smoke 与对应 Electron Gate B 分别报告;source-only 设计不能替代运行证据。
|
||||
|
||||
current:startNewConversation、committed route/request、稳定 home/local scope、Main/Coordinator/Controller、typed acceptance 与 canonical 消费者。compat:不新增。deleted:general 随机草稿、discovery、新建事件、新页/预热/materialized owner 和旧 general chrome,9个文件直接删除,调用者/测试/护栏同步迁移。
|
||||
|
||||
设计与源码迁移完成度100%;本切片local16三工作区/两正常cold与Home6稳定Home冷恢复已分别通过,三首发尚未关闭,整体GUI继续partial。root复核78项before/after与冻结证据后正式采用,收据 .lime/qc/new-conversation/formal-adoption.json;保留Settings及旁路增量,未提交代码。架构4.3/4.5由root于2026-10-15确认。正式最新逐文件46suite/540项已通过(formal-latest-directed-receipt.json),非单次全绿:初轮538通过/2失败,Stop夹具未提交可见local会话,按真实产品投影迁移后17/17与EditorView5/5;产品与严格恢复门禁未改,原失败保留。候选438/438与strict3187零诊断独立记录。
|
||||
|
||||
可见目标就绪门禁已窄组合:status.ready只投影真实binding.ready,Runtime要求精确visible scope匹配;首次读取被替代、失败/模式不匹配时禁止编辑。相同EditorView回归在正式新导航上5项行为红,隔离两产品叶修复后5/5,正式5/5与Stop17/17已通过;严格scope/generation/revision/token守卫保持。local11静态窗口成立,是否实际命中仍缺timeline,不倒推为已证明根因。
|
||||
|
||||
packet19的Home首发已取得完整canonical富输入、资源字节、consume/new-edit和Main96352正常退出证据;search-new在替换已保存probe后出现保存冲突,项目新建未达,三首发整体仍fail。queued discard通知重绑已在既有Coordinator真实回归复现并窄修,严格CAS不变;新构建、原三入口与Stop/冷恢复/默认门禁按current计划继续验收。
|
||||
|
||||
queued discard修复的新renderer、标准typecheck、freshness与桌面壳通过。packet20 first6 Home/search-new全部业务及正常退出通过;项目按钮补真实header hover的fixture后Node53/53/lint/格式通过,packet21 first7三个入口完整canonical输入/资源/消费/新稿保护均过。第三次正常quit超过原15秒期限,随后normal teardown仍保留整轮fail;first8只补被动退出诊断,所有原断言/期限保持。三首发与Stop/冷恢复/current/default不据业务断言局部通过宣称整体关闭。
|
||||
|
||||
packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮Skills接线缺陷已另修复;packet22第4轮22/22全部通过、前后源码/产物零漂移,default按原状态续跑中。
|
||||
@@ -1,16 +1,24 @@
|
||||
# Lime 与 Codex Desktop 的差距:REA 对照与实施
|
||||
|
||||
研究基线:2026-10-13;实施状态校准:2026-10-14。对照对象:本机 `/Applications/ChatGPT.app`,实际为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`。下表记录当前差距,详细迁移和验证以 [对齐执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) 为准。
|
||||
研究基线:2026-10-13;实施状态校准:2026-10-15。对照对象:本机 `/Applications/ChatGPT.app`,实际为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`。下表记录当前差距,详细迁移和验证以 [对齐执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) 为准。
|
||||
|
||||
Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构化主输入和 Browser tab 恢复。基础 link/code 已实施并通过实际源码与真实桌面富输入验证;听写生命周期和取消回滚已完成接线及组件回归。本批默认门禁仍在关闭。持久富草稿、完整 Code NodeView、专业文件的排版与批注、原生应用操作,以及远端执行的产品入口仍有明确差距。
|
||||
Lime 已采用长历史虚拟列表、ApprovalCard、Files/diff、单一结构化 Composer/NodeView、持久草稿、typed acceptance、新建会话和单一 SettingsPage 章节 owner。packet21 的三入口首发、rich Stop、Home 冷恢复及三工作区两次正常冷恢复四项合同均通过;设置15章节/24首屏读取也有独立真实 Electron 证据。它们各自绑定原源码和产物,不能覆盖后续改动。
|
||||
|
||||
本报告依据真实安装包的静态代码、Lime 源码和官方功能文档。没有取得 Codex 实时截图与点击证据,因此不评价两者实际流畅度、成功率和模型质量,不给整体“对齐百分比”。Lime 后续真实 Electron 验证独立记录在执行计划,不反推参考应用的运行表现。
|
||||
本轮修复了两项问题:Skills 工作台试用完成后 Composer 仍绑定 Home 而不可编辑;工具状态文案每次查询都重新合并整份 Agent 翻译资源。前者由既有 CommandWiring 将非 Composer 的真实 accepted 回执交回可见 topic commit,继续保留 epoch/ready/scope 与草稿消费 authority;后者直接复用 createI18n 已加载的五语言表,保留实时语言选择、插值与缺失键处理。两项均复用 current owner。
|
||||
|
||||
验证分别绑定身份:packet22 的 Skills 三路径、四组定向31/34/3/294及22个真实 Electron 场景通过,前后7885源码/2198产物零漂移。随后删除五语言一个废弃设置键、迁移两个旧测试,42/42通过;稳定函数引用修正后61/61与零警告lint通过;文案资源回归先3过1红(12次重复合并),修复后6套件81/81,包含未改的历史窗口14/14及原5秒期限。默认第4轮已过严格全src lint、文案扫描、标准typecheck及89—111批,historyWindow原失败两例在89批147/147中通过;112批发现旧turn/start回执夹具,仅迁canonical id/inProgress及同thread identity,60/60定向与严格lint通过。续跑已通过至122批,123批另一旧boolean/void发送夹具迁为完整typed result,三suite24/24与严格lint过;125批旧reset测试缺root callback且混同Home/fresh语义,重复文件删除,风险收回现有导航与reset tests,16/16通过;127批另一个Inputbar布尔接受夹具阻断recent usage;同文件显式布尔发送全部迁typed,完整92+21共113/113通过。freeze30仅该测试增量。前端132/132批、contracts与legacy扫描均通过;新标准verify:gui-smoke wrapper exit0、24/24真实壳断言通过,7884源码/2303目录产物已固定。默认按组合步骤关闭,原wrapper失败保留;新产物current第5轮前两场通过,第3场短问候接受1978ms超过原1800ms预算而失败,完成态已达、7884源码/2303产物零漂移;原短问候场景隔离78/78通过、接受921ms;第6轮同场973ms,但第8场审批在业务pass后Main退出挂起(前7场进程完成),Node连接已关闭、pending BrowserContext.close,底层根因unknown。原挂起证据冻结并中断,不计完整通过。fixture改为既有15秒正常退出合同及全部资源释放后才打印pass,失败后回收不会修复结果;110项定向回归与原审批隔离66/66、Main34547正常code0/signalnull均通过。仅4项fixture输入变化,产品/2303产物不变,freeze31共7886源;第7轮完整聚合exit0、22/22通过,四组31/34/3/300;21个Claw正常Main退出回执、强制回收0,Coding保留既有子进程证据而不补造Main回执。后验7886源/2303产物及产物路径集零漂移。当前summary显式引用的ledger/截图共67份独立冻结;旧第4轮目录遗留的一张Skills失败截图不计本轮证据。最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭,整体GUI仍partial。续跑状态1761为历史登记路径,其中1756现存、5项已删除,不能写成1761当前测试文件均执行。初次构建缓存失败、窄lint曾带warning与原超时均保留在索引及收据中,不改写成单次全绿,也不将文案修复归因为所有旧超时。
|
||||
|
||||
仍有明确缺口:设置 canonical 章节路由与返回、内容搜索和 dirty authority;专业文件的排版与批注;原生应用操作与远端执行产品入口;结构/命名的完整符号覆盖、OS输入与Windows验收。保留差异仅主题、多模型、首页Banner。整体状态为 partial,没有足够分母计算全GUI对齐百分比。下文带日期的实现记录为阶段证据;当前身份和待办以执行计划及索引最新条目为准。
|
||||
|
||||
[结构与命名账本](structure-alignment-ledger.md)集中列出确认导出、方法属性、Lime current owner、删除项和待办;上游原类名/目录未恢复的项目明确保留 unknown。它是已取证范围的映射,完整符号覆盖仍待继续。
|
||||
|
||||
参考应用未取得实时截图或点击证据。本报告以安装包REA、公开源码与已引用官方材料区分静态事实;Lime真实Electron验证独立记录,不反推参考应用的流畅度、成功率或模型质量。已完成的历史默认门禁只覆盖各自切片,不代表当前新产品修复已验收。
|
||||
|
||||
2026-10-14 实施校准:`ComposerUtilityBar` 已直接替换旧项目上下文组件,343 项定向回归及真实项目/Git/冷重启 Gate 通过。公共 pending Queue 的历史/分页隔离完成 74 项 Rust/JSONL 回归,搜索排除完成 17 项回归;真实 pending Gate 已通过历史/分页/搜索排除、富输入 mutation 与首图 media 读回。Right Surface 的 73 项生命周期回归、Browser 完整 live tab 恢复的 82 项回归及双 tab/关闭 primary 不复活的真实 Gate 已通过。rollout 空库重建缺陷已修复,23 项回归通过。
|
||||
|
||||
结构化EditorView、唯一Controller、typed submission和共享surface草稿已实施,历史atom提及抢方向键及receipt恢复route缺陷已修。rich Electron Gate68/68、新版22场景聚合和完整默认门禁均通过,既有Composer实施合同关闭;external backend绕过live provider/runtime prompt lowering,IME/paste为synthetic DOM事件,本地runtime场景使用provider fixture,不是live。长UserMessage两入口折叠、精确canonical去重、主窗口原生复制权限和虚拟列表动态测量已实施;真实macOS长列表与冷启动通过,全文9682字符复制一致,后继阅读锚点漂移0.203125px。权限owner回流守卫39/39通过。视口竞争修复后的rich第五轮69/69、long-list第六轮/冷启动及live-tail65/65通过;页头consumer placement与横向滚动已修复,resize-fixed5三尺寸66/66通过。新默认门禁发现并修复复制反馈卸载泄漏,生命周期10/10、消息回归69/69、默认127/127批及fresh复制Gate69/69通过,1705/9682字符全文复制一致。各场景按其renderer摘要分别记录;较早22/22聚合仍属修复前基线。见[主输入审计](./main-composer-audit.md)、[用户正文审计](./user-message-collapse-audit.md)及[保存补证](./composer-draft-persistence-audit.md)。测试、fixture、文档和dead回流守卫随owner迁移;全产品仍partial。
|
||||
结构化EditorView、唯一Controller、typed submission和共享surface草稿已实施,历史atom提及抢方向键及receipt恢复route缺陷已修。历史主输入切片的rich Electron Gate68/68、新版22场景聚合和该切片完整默认门禁均通过,既有Composer实施合同关闭;external backend绕过live provider/runtime prompt lowering,IME/paste为synthetic DOM事件,本地runtime场景使用provider fixture,不是live。长UserMessage两入口折叠、精确canonical去重、主窗口原生复制权限和虚拟列表动态测量已实施;真实macOS长列表与冷启动通过,全文9682字符复制一致,后继阅读锚点漂移0.203125px。权限owner回流守卫39/39通过。视口竞争修复后的rich第五轮69/69、long-list第六轮/冷启动及live-tail65/65通过;页头consumer placement与横向滚动已修复,resize-fixed5三尺寸66/66通过。新默认门禁发现并修复复制反馈卸载泄漏,生命周期10/10、消息回归69/69、默认127/127批及fresh复制Gate69/69通过,1705/9682字符全文复制一致。各场景按其renderer摘要分别记录;较早22/22聚合仍属修复前基线。见[主输入审计](./main-composer-audit.md)、[用户正文审计](./user-message-collapse-audit.md)及[保存补证](./composer-draft-persistence-audit.md)。测试、fixture、文档和dead回流守卫随owner迁移;全产品仍partial。
|
||||
|
||||
本批link/code依据[迁移前marks/voice补证](./marks-voice-audit.md)实施到唯一input-kit:实际src先22红/8绿,迁移后20文件224/224通过;annotation事务拆分后5文件92/92通过,Controller由803行降为762行。Markdown submission与display UTF16、typed UTF8范围分离,安全link/code属性、原文草稿和编辑生命周期同步回归。听写hook与Core单路径接线72/72、fixture312/312、最终标准typecheck和renderer构建通过。新增实际MarkdownRenderer与fixture回归6文件189/189,fresh Electron富输入Gate73/73、源码/fixture零漂移和console/page errors0;首轮fixture版本问题及默认69批中断恢复guard失败单独登记,默认正在resume,旧127批绿不覆盖本批源码。[样式补证](./composer-marks-css-audit.md)区分基础schema与完整NodeView/toolbar合同。完整rich PMU、主动链接导航/编辑、系统mic/ASR、persistent draft、专业文件、原生操作和remote分别保留unknown或待办。
|
||||
本批link/code依据[迁移前marks/voice补证](./marks-voice-audit.md)实施到唯一input-kit:实际src先22红/8绿,迁移后20文件224/224通过;annotation事务拆分后5文件92/92通过,Controller由803行降为762行。Markdown submission与display UTF16、typed UTF8范围分离,安全link/code属性、原文草稿和编辑生命周期同步回归。听写hook与Core单路径接线72/72、fixture312/312、最终标准typecheck和renderer构建通过。新增实际MarkdownRenderer与fixture回归6文件189/189,fresh Electron富输入Gate73/73;新current聚合22个真实Electron子场景通过,54项产品、14项fixture及277项renderer/Host/sidecar逐项零漂移。首轮fixture版本问题及默认69批中断恢复guard失败单独登记。默认续跑94批出现同步render超时,未修改生产或扩大原5秒上限,显式Node24完整文件14/14通过;95批发现测试继承英文locale,只修测试语言隔离,原批16文件164/164通过,本批完整默认已exit0,真实标准Electron smoke通过。旧127批绿不覆盖随后NodeView源码,旧进程runtime未记录,不声称全部续跑批次都由Node24执行。[样式补证](./composer-marks-css-audit.md)区分基础schema与完整NodeView/toolbar合同。[Code NodeView补证](./composer-code-nodeview-audit.md)与[高亮/Auto补证](./composer-code-highlight-audit.md)已闭合静态合同,Controller下的registry、PM contentDOM、Portal语言选择与两个plugin已正式接线;新rich Gate74/74,实际Auto→Plain→TypeScript、正文高亮、单次发送、cancel/history与canonical富输入均通过。新current聚合22/22、前端127/127批与标准GUI smoke已通过;默认按续跑组合步骤关闭,未改写原wrapper失败。完整rich PMU、主动链接导航/编辑、系统mic/ASR、persistent draft的三首发与最新产物Stop产品Gate、专业文件、原生操作和remote分别保留unknown或待办。
|
||||
|
||||
## 1. 已完成安装
|
||||
|
||||
@@ -22,6 +30,7 @@ Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构
|
||||
- `rea doctor --client codex --skill --json`:`healthy=true`,Node、Codex 注册、skill identity 三项通过。
|
||||
- 实际 MCP stdio 初始化和 `tools/list`:返回服务器 `rea/6.4.0`、170 个工具。
|
||||
- 本轮已通过 CLI 使用 Rea;当前 Agent 会话的 MCP 工具表不会因注册自动刷新,后续会话加载注册需要重启客户端。
|
||||
- 2026-10-14 晚间复核发现注册条目缺失,已重新按 scoped setup 备份并恢复,显式使用独立 Node24;当前非REA配置语义保持相同。doctor Node/注册/skill 三项healthy,真实stdio初始化与tools/list再次返回rea/6.4.0和170工具。本Agent工具表仍未刷新,继续使用已安装CLI。
|
||||
|
||||
## 2. 差距矩阵
|
||||
|
||||
@@ -93,6 +102,8 @@ Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构
|
||||
|
||||
## 4. Rea 证据与边界
|
||||
|
||||
2026-10-14 用户为释放空间删除仓库 `.lime/`。该目录中后续 Composer focused audit 的本机 manifest、候选与产品截图现为缺失的历史原件;仓库 audit 文字和 Evidence ID 仍保留,不能将旧摘要用于恢复版身份或运行验收。用户级 REA 原始成员按实际存留情况复用;必要候选已迁至独立临时目录并重新验证,详见 current 执行计划。此清理不改变静态观察的证据等级,也不代表新候选已经产品验收。
|
||||
|
||||
原始包:`/Applications/ChatGPT.app/Contents/Resources/app.asar`,567,757,587 字节。
|
||||
|
||||
SHA-256:`75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。
|
||||
@@ -145,3 +156,15 @@ ASAR 目录含 21,089 个文件、15,208 个 JavaScript/TypeScript 路径。选
|
||||
完成度按本轮范围记录:安装 **100%**;上述有边界的静态对照 **100%**;实时交互对照 **0%**。不能据此宣称全产品能力已穷尽或 Lime 对齐完成。
|
||||
|
||||
治理分类:研究阶段只增加本报告、精简证据和计划。后续实现复用 `current` 业务 owner,已替代的 compact approval、path-only Files 和旧尾窗进入 `dead / deleted / forbidden-to-restore`,不保留 compat 包装;删除清单、护栏与验证结果统一维护在 current 执行计划。已有 App Server/agent/tool-runtime/命令及未知未跟踪改动继续避让。
|
||||
|
||||
[设置导航审计](./settings-navigation-audit.md) 已校准七 member 与20原字节锚点:下一刀先收敛单一章节 owner 并迁公开 SettingsPage 名称,再处理 canonical 路由、返回、内容目标搜索与 dirty authority。九源章节owner候选已正式采用,公开SettingsPage与调用者同步,重复名单和dead定义直接删除;正式六suite78/78及设置oracle10/10、lint/格式通过,真实Electron15key/24可见页面状态、157原生read、零console/page/trace error及Main22039正常退出已通过;路由/返回/search/dirty仍后置。
|
||||
|
||||
[公开新建会话审计](./new-conversation-navigation-audit.md) 已确认 `startNewConversation` 导出与稳定 home/panel 身份职责;唯一 App navigation 动作、committed fresh CAS 及随机草稿/预热旧路径的直接替换已采用。Home 恢复、New chat 新空稿是明确的 Lime 决策,参考普通侧栏的运行时 fresh 行为仍 unknown。78项正式采用、9个旧文件删除;公开action/committed route/同一Coordinator fresh CAS已收敛,架构由root确认。正式最新逐文件46suite540/540、lint/格式通过;初轮两Stop夹具缺可见会话提交,迁移后原恢复断言通过,失败收据保留。标准typecheck/contracts/legacy/docs和新GUI烟测已通过;local16富草稿/两正常cold、Home6与15设置首屏分别通过;三首发、最新产物Stop和current/default门禁仍待关闭。
|
||||
|
||||
2026-10-15 富输入 Stop 的消费后旧读取竞态已修复,严格恢复守卫保留。原rich第三轮Desktop Gate B(common34/34、scenario21/21)通过;后续Skills冷加载身份误判已在既有owner修复,定向40/40和新GUI烟测24/24通过。local16三工作区完整富稿、拒绝保稿、原选区Undo、accepted consume/new-edit、异workspace隔离及两次正常冷重启全部通过,Main34272/38556/40802均code0/signalnull;该证据属于packet13产物。随后Home漏传Skill按需目录回调已贯通,最新逐文件5suite140/140、标准typecheck/lint/新构建与壳烟测24/24通过,旧timeout记录保留。Home3/4正常退出期限失败、Home5 trace oracle误要求直接preload请求进入GUI safeInvoke记录;最小fixture迁移后Node62/62与collector69/69,packet16产物未变,Home6完整rich/资源/原选区/identity、两正常Main17922/18120、cold零写/无canonical会话回合及原文件bytes/revision均通过,后验零漂移。15设置导航/首屏read已通过;三首发Home创建thread后未发Turn而rejected;诊断确认能力路由覆盖canonical正文,已保留真实source并以五suite165/165回归;另有业务workspace覆盖可见草稿scope,root真实Wiring/Coordinator回归先2/2失败、修后2/2通过,严格ready/epoch守卫保持。两处产品修复已在packet19完成构建、标准typecheck/contracts/legacy与桌面壳验收;最新产物Stop和current/default门禁尚未关闭;无live-provider/Windows/强杀耐久或全GUI完成声明。
|
||||
|
||||
最新packet19首发:Home完整canonical富输入/资源字节、promotion/consume/new-edit与Main96352正常退出通过;search-new富稿保存冲突,项目新建未达。已在真实Controller/Coordinator/Store复现queued discard前序put通知把旧probe恢复:纯行为红后,由既有Coordinator阻止discard在途旧read重绑,Store CAS/退休守卫保持。binding及相关非Stop套件通过;组合Stop有1项、隔离Stop有6项原5秒timeout,稳定性仍未关闭。新renderer、标准typecheck、freshness与壳smoke均exit0。packet20 first6的Home与search-new完整业务和正常退出通过;项目入口fixture补真实header hover,Node53/53/lint/格式通过。packet21 first7三入口canonical富输入/资源/consume/new-edit均过,但第三Main正常退出原15秒超时,随后normal teardown不改整轮fail;first8仅加被动退出计时与失败采样。原packet19与旧cold/settings结果分开保留,详见current计划与证据索引。
|
||||
|
||||
packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮及default续跑待关闭。
|
||||
|
||||
本批四项产品收据:`.lime/qc/composer-persistence/queued-discard-product-final-receipt.json`,SHA `2cafe26c77fe9b0b58d530e3f78a3ae05a5b4b592645551fc73a870b275f17d2`。packet21验收前后7885source/2093artifact零漂移,只证明身份一致,不宣称所有模块执行。
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Codex Desktop 设置导航对齐审计
|
||||
|
||||
七 member 静态审计及第一切片的正式采用记录;章节 owner 与公开 SettingsPage 已迁移,真实 Electron 15 章节/24 首屏状态已通过。canonical 路由、返回、内容搜索和 dirty authority 仍待交付。
|
||||
|
||||
## 证据范围
|
||||
|
||||
目标实际位于 `/Applications/ChatGPT.app`,displayName 为 ChatGPT,但 bundleId 是 `com.openai.codex`,version `26.1007.30912`、build `20156`。此证据归 Codex Desktop,不归消费者 ChatGPT Desktop。ASAR SHA `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`,bytes `567757587`。下文 `evidence/` 路径相对恢复目录 `/var/folders/87/s6cpr7hd1_v43cs833x4s_900000gn/T/lime-code-nodeview-recovery-oulq764w/settings-navigation-audit-candidate/`;七 member 的原字节、hash、ASAR offset 见其中 `evidence/member-manifest.json`;格式化副本仅供阅读,定位以原压缩 JS byte offsets 为准。
|
||||
|
||||
REA full Evidence `ev_4bf8bfc7ccb14fb058096dd32864ae7f9ab83a8f0ad9acb46cfd79b5379ab36c`;summary Evidence `ev_6d2230454a838f113f4e61d100fb41d47ea6b241100765d84eb35674dcd71478`;输入 corpus SHA `2c618ae2c6acb241a854e3f71191626c857485ed664d43dc91a887d7f1730c35`。七 JS 全部解析,101646 bytes、25710 AST nodes、670 findings、0 parse failures。application complete 仅对这七个输入成立;semantic unknown,5265 unknowns,未执行 bootstrap 或包内代码。
|
||||
|
||||
仅确认公开导出原名 `SettingsPage`、`VisibleSettingsCommandMenuSectionItems`、`SettingsExternalSection`。`use-visible-settings-sections` 的导出只有压缩 alias `a/i/n/o/r/s/t`,不得声称已恢复内部 hook 或 sidebar 的原源码名。registry 来源是 app-initial,未提取完整 registry;不得使用 app-shared icon alias 做命名映射。
|
||||
|
||||
可下结论的范围:page shell、可见性/navigation 投影、section route/state、命令菜单共享消费、host filter、搜索入口/结果模型与未保存变更对话框。search-documents 内容 chunk、完整 registry/default page、外部 dirty authority、所有 mode/auth/host 的实际运行可见性仍 unknown。包内字符串不能当作当前帐号已启用功能,也不能据七 member 宣称整个设置应用或全量 dirty enforcement 完整。
|
||||
|
||||
以下映射和下一切片由 root 正式采用;全部15个 current 依赖、冻结摘要和 byte anchors 已核。source-only 审计完成度 100%;第一切片首屏读取已有真实 Electron 证据,设置完整交互完成度不量化。
|
||||
|
||||
## 迁移前映射(原始事实)
|
||||
|
||||
Lime 已有唯一 current 设置入口与真实页面;主要差距是导航事实分散、路由与局部选择脱节、搜索只过滤导航标题。目标证据是 bundle `com.openai.codex` 的 shipped artifact,不是消费者 ChatGPT Desktop。置信范围与 Evidence ID 见上文。
|
||||
|
||||
| 维度 | Codex Desktop 七 member 可确认事实 | Lime current 事实 | 差距与含义 |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 页面入口/命名 | settings-page 原 byte 48070 公开导出 `SettingsPage` | `src/components/settings-v2/index.ts:7` 将 `SettingsLayoutV2` alias 为 `SettingsPageV2`,`AppPageContent.tsx:298` 唯一生产入口 | current 可以直接导出短领域名 `SettingsPage`,不继续增加 V2/compat 外壳;内部目标原名未恢复的不强行对齐 |
|
||||
| 可见性与导航投影 | use-visible 原 byte 38628 的返回对象包含 visible/active/redirect/error/render/navigation 与 enterprise label;page/command menu 共享消费 | `_layout/index.tsx:244` ACTIVE_SETTINGS_TABS、`:272` preload switch、`:321` render switch,category hook 另维护导航 | 先由一个章节 owner 同时派生可用页、导航、预加载和渲染;目前新增/删除页有多份名单漂移风险 |
|
||||
| 分组 | settings-page 包内分组为 Personal、无 heading 的 Teams、Integrations、Coding;成员受 mode/auth/host 可见性过滤 | `hooks/useSettingsCategory.ts:52` 固定 Overview/Account/General/Agent/System 五组 | 分组机制可借鉴;具体章节不可照搬为已启用功能。Lime 多模型/媒体等业务继续保留其 owner |
|
||||
| 章节路由 | page 从 `/settings/:section/*` 解析章节,同投影提供 active section 和 redirect;local-environments 可映射 `worktrees?tab=environments`,cloud-settings 可映射 `cloud-environments?tab=preferences` | `AppPageContent.tsx:300` 消费 PageParams 初始 tab;`_layout/index.tsx:475` local activeTab,`:487` 内部切页只 setState | 内部导航应更新 canonical PageParams,统一直达、点击和重进恢复,避免 shell 路由仍指向旧章节 |
|
||||
| 返回语义 | settings-route-state 有 `returnToPreviousHistoryEntry`,page `function si` 原 byte 47746 优先 history -1,其次 return path,最后 `/` | `_layout/index.tsx:515` handleBackHome 固定 agent Home | 当前返回不能恢复来处;下一刀要保存真实进入上下文并由现有 navigation owner 处理,不另建第二套 history |
|
||||
| 搜索模型 | settings-page messageTextGroups/terms/message IDs、section/path/hash、scrollText;键盘 highlight/select/Escape;搜索目标可落到具体内容 | `SettingsSidebar.tsx:340` title/group substring filter,`:410` Ctrl/Meta+F focus;没有 row target 文档模型 | 导航过滤只能定位页,无法定位具体设置。先收敛 owner,再建立可翻译的 row/section target,不猜未提取 search-documents 的覆盖内容 |
|
||||
| 预加载 | page/命令导航存在 section prefetch 消费 | Lime `_layout/index.tsx:497` prefetchedTabsRef + preloadSettingsTab;失败会移除缓存重试 | Lime 已具备页面预加载机制;缺口主要是清单收敛,不重复造 cache/loader |
|
||||
| 未保存导航 | page 包含 requestNavigation gateway、pendingNavigation 与 unsaved dialog;external dirty authority 未在七 member 中完整展开 | current shell handleTabChange/handleBackHome 未提供统一 pending dirty 导航网关 | shell 未具备可确认的统一保护,但不能推断所有具体页面都丢数据;需后续逐 owner 确认实际 dirty authority,再接统一导航入口 |
|
||||
| host/帐号状态 | visibility projection 消费 local/remote host 和 mode/auth,存在 cloudPreferencesAccessError、shouldRenderRouteContent 等结果 | current category 固定;profile render 内有 OEM managed account 条件 | 能确定机制差距,不能声称所有 target host/auth 功能可用;未来只把已有实际 readiness/capability 投影到设置 owner |
|
||||
|
||||
所有 byte 值均为原 member 内 offset;绝对 ASAR offset 与验证过的 marker 见 `evidence/byte-anchors.json`。可读副本行号不用于原字节身份。
|
||||
|
||||
迁移前分类:`settings-v2`、`SettingsSidebar`、category hook、PageParams 入口和 preload/render 是 current,尽管名字仍有 V2。`Experimental -> Developer` 是现存 compat 直达委托,不新增第二个实验页。`SETTINGS_GROUPS` 在 `src/types/settings.ts:70` 只有定义、无生产消费者,是重复 dead 候选;`ExecutionTracker` 只剩 enum 与旧 fallback 测试,是 dead 候选。旧 `components/settings/**` 导入有负向 guard,继续禁止回流。上述为迁移前快照;SETTINGS_GROUPS、ExecutionTracker、V2 导出别名现已删除,详见下方正式采用收据。Experimental 仍仅委托 Developer。
|
||||
|
||||
不能下结论的范围:默认页是否应该永久保留 Home、完整章节 registry、搜索索引内容覆盖、所有 host/auth 分支和 dirty enforcement。无需为了本轮映射继续提取其他 chunk。后续实施涉及这些行为时再定向补证。
|
||||
|
||||
## 迁移合同与后续切片
|
||||
|
||||
主目标是让同一设置章节事实驱动导航、可用性、预加载、渲染与 canonical PageParams。章节收敛已正式采用并完成定向与真实首屏读取验证;后续推进 canonical 路由、返回与内容搜索。
|
||||
|
||||
已采用的 first slice 保持现有业务章节和默认页,通过短的领域模块承载 current section 清单;将 ACTIVE_SETTINGS_TABS、category hook 与 preload/render 的重复定义收敛为同一 owner 的派生结果。`SettingsPageV2` 直接迁移为公开 `SettingsPage`,同步唯一生产消费者与测试;不保留无消费者 alias。分组/页面中文文案不在本刀重做,也不扩展 MCP/Skills/模型业务。
|
||||
|
||||
窄写集应限定 `src/components/settings-v2/index.ts`、`_layout/index.tsx`、`hooks/useSettingsCategory.ts`、`src/types/settings.ts`、`src/components/AppPageContent.tsx` 和对应定向测试。新增领域文件进入现有 settings owner;若实际写集涉及 `_layout/SettingsSidebar.tsx` 或 navigation/PageParams,先在正式执行计划声明并避让脏区。当前 source hash 观察值见 manifest,不是未来实施可覆盖他人改动的授权。
|
||||
|
||||
完成单一 owner 后,second slice 再把当前内部切页接入现有 navigation/PageParams,保存返回上下文,统一直达、click、back 和重进投影。真实 dirty authority 尚未确认,不提前新造全局 dirty state;搜索 row target 也后置,需按当前已存在设置内容与五语言翻译定义索引,不能直接复制包内隐藏功能清单。
|
||||
|
||||
退出条件:所有 current 设置页只登记一次;导航、可用页、prefetch/render 无孤儿条目;现有 providerView/providerFocus/executionPolicyFocus 直达保真;Experimental 仅委托 Developer;删除无消费者 SETTINGS_GROUPS 与 ExecutionTracker 时同步测试;保留 legacy import 负向 guard。未知 tab/default Home 断言跟随明确决策改写,不能把旧测试当永久产品合同。
|
||||
|
||||
第一切片的定向验证覆盖 `_layout/index.test.tsx`、`_layout/SettingsSidebar.test.tsx`、`hooks/useSettingsCategory.test.tsx`、`legacy-import-guard.test.ts` 及 AppPageContent 消费测试。实施触及 GUI 后按仓库规则跑真实 current `npm run verify:gui-smoke`,并对受影响的 settings current smoke 使用真实 Electron/preload/App Server。已读可复用 consumers 为 `scripts/settings-all-pages-current-smoke.mjs`、`scripts/agent-qc/project-gate-settings-a.mjs`、`scripts/electron/settings-about-fixture-smoke.mjs`、`scripts/electron/mcp-config-fixture-smoke.mjs`;各真实验收入口结果单独记录,单凭静态审计或定向测试不宣称 Gate A/B。
|
||||
|
||||
KISS/DRY 通过单一登记避免四份名单;YAGNI 通过先收敛已有页、推迟未知 registry/dirty/search 行为;SOLID 通过章节 owner 负责事实、页面继续负责业务、现有 navigation owner 负责路由。source-only 审计与 first slice 源码迁移均 100%,该切片首屏读取已通过;后续路由、返回、搜索与 dirty authority 尚未交付。
|
||||
|
||||
## 第一切片正式采用(2026-10-15)
|
||||
|
||||
root核对冻结manifest/patch/29项证据与九源before/after后,正式采用单一sections owner、SettingsPage公开名和调用者迁移;删除重复可用性/preload/render名单、SETTINGS_GROUPS/ExecutionTracker与V2别名。业务页(含媒体脏区)未夹写。收据 `.lime/qc/settings-sections/formal-adoption.json` SHA `84a53337dac5103e9df8c5a28e0457a2b7c54b8eb2fc297949ab4543a4bbd1c8`;架构4.4由root确认。上文映射表保留迁移前事实,正式source采用100%,正式六suite78/78、九源lint/格式通过;receipt `.lime/qc/settings-sections/formal-directed-receipt.json` SHA `d9e7c32d6ad0a2c473e4fe55f356c910fe7544e2b1cfd289f07a8f034699df10`。设置验收清单已迁15current keys,补Skills删旧连接器/自动化,用唯一testid与active确认;core/states定向10/10 exit0,收据 `.lime/qc/settings-sections/formal-oracle-receipt.json`,真实首屏读取验收settings-current-native-20261015-2 exit0:15章节key/24可见状态、157原生bridge read、零console/page/trace errors,Main22039正常code0/signalnull;证据`.lime/qc/settings-sections/settings-current-native-20261015-2/summary.json`。这不证明写入、canonical内部路由、返回、内容搜索、dirty authority、live或Windows。
|
||||
|
||||
## 第二切片只读预检(2026-10-15)
|
||||
|
||||
本轮尚未修改设置产品源码。`useAppNavigation` 已有 requested/committed 与 requestId,设置进入上下文应由它承接;不新增全局 history owner。`SettingsPage` 的 activeTab/providerView/developerLabTab 目前从 initial props 复制为局部状态,内部点击不更新 PageParams。下一刀应让同一 canonical 参数驱动章节与焦点,保留 providerView/providerFocus/executionPolicyFocus 和 Experimental 委托语义;离开章节后不重放旧焦点。
|
||||
|
||||
`SettingsNavigation` 当前将 rail Home 与页头返回共用 onBackHome;对齐返回来处时必须分开两种用户动作,Home 图标仍进入首页,返回按钮恢复进入设置前的已提交目标。恢复目标不能意外重放 newConversationRequest、初始发送或其他一次性 intent。精确返回会话应与现有 reload 最小会话参数投影配合,不能仅保存旧 params 后再次触发发送。
|
||||
|
||||
另一个必要合同:Home 首发 materialize 后,根 PageParams 未必已有真实会话 identity;App 已消费 `onAgentSessionChange` 的实际可见会话投影。单纯保存进入设置前的 PageParams 可能返回 Home 或重放旧初始输入。实施前须沿该 current 回调及真实 workspace 确认返回目标,覆盖“Home 首发后 → 设置 → 返回同一会话”和“已有会话 → 设置内部切章 → 返回”两条真实路径;不得把旧初始参数当成实际可见会话 authority。
|
||||
|
||||
该切片需要同步 `_layout/SettingsNavigation.tsx`、layout、useAppNavigation、App/AppPageContent 接线、五语言返回文案及稳定测试;现有 `settings-home-button` 消费者包括 settings-about Electron fixture、project-gate-settings-a 与 tool-surface-page smoke,必须按真实返回语义迁移,而非强制保留“永远回Home”的旧正向断言。当前 native15章节/24首屏证据仍只证明读取,不覆盖该后续导航合同。search 与 dirty authority 仍待实际 owner 调查。
|
||||
|
||||
进一步只读核实:`useWorkspaceContextSurfaceRuntime` 已同时拥有实际 `sessionId` 与 `runtimeWorkspaceId`,但现有 `onSessionChange` 只发布 sessionId;App 的 `activeAgentSessionId` 同样没有 workspace。第二切片应沿这条既有投影回调携带可见 workspace,或在同一 owner 提供等价的只读位置快照,避免用根 PageParams 的旧 projectId 代替当前 workspace。它属于导航显示上下文,不扩展为业务状态或第二 read model;实现与定向/真实路径验证尚未开始。
|
||||
|
||||
## Current dirty owner 只读预检(2026-10-15)
|
||||
|
||||
本次仅检索 `src/components/settings-v2` 与 `useAppNavigation` 中的 dirty/unsaved 命名并追踪命中的实际消费者,未修改设置产品代码,也不是完整表单清单。已确认两个 current 章节具有自己的未保存状态:Memory 比较本地 draft 与 snapshot,Cancel 恢复 snapshot,Save 经既有 saveConfig 成功后同步两者;Web Search 比较本地 draft 字段与已加载配置,Save 构造同一配置 owner 的更新。两者分别由 `sections.tsx` 直接渲染,当前章节回调未传递 dirty 导航接口。
|
||||
|
||||
`ChannelsDebugWorkbench` 也有 channels/gateway 与 initialJson 的比较,但唯一生产消费者位于 `components/channels/ImConfigPage`,不在这次15章节 registry 内。不能因文件位于 settings-v2 下就将其登记成当前设置章节,或用其 dirty 状态替代 Memory/Web Search。
|
||||
|
||||
当前已检查的 SettingsPage handleTabChange/handleBackHome 没有 pending-navigation/dirty gateway。后续保护应让现有表单 owner 报告当前编辑状态,并由唯一导航入口消费;不要在 shell 复制配置或重算业务 dirty。其他表单、弹窗、独立页面和参考应用外部 dirty authority 未穷举,完整覆盖仍 unknown。此预检不证明实际导航丢稿,真实失败/保存/取消路径仍须验证。
|
||||
@@ -0,0 +1,43 @@
|
||||
# GUI 结构与命名对齐账本
|
||||
|
||||
状态:current / partial。校准日期:2026-10-15。本页归并已有 REA 审计与正式实现映射,不新增参考应用运行结论,也不是全量符号清单。
|
||||
|
||||
参考为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。版本、Evidence ID、原成员摘要和锚点分别由链接的领域审计及[证据索引](evidence-index.json)持有。公开 Rust checkout 只用于共享协议/runtime;不能补出 Desktop 前端原文件名。
|
||||
|
||||
## 已确认名称与 current owner
|
||||
|
||||
| 参考名称及证据性质 | Lime current owner / 调用者 | 已迁移内容与边界 | 待办 |
|
||||
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `QueuedMessageList`,确认公开导出 | [QueuedMessageList](../../../src/components/agent/chat/components/QueuedMessageList.tsx),Workspace queue projection / actions | 直接替换旧 `ThreadQueueStatus`;列表呈现与同一 App Server queue authority 分工;见[导航审计](navigation-composer-audit.md)及[Steer 审计](queue-steer-audit.md) | 暂停、送达未知、拖拽与完整交互须逐合同验收,不能以同名认定全部等价 |
|
||||
| `ComposerUtilityBar`,确认公开导出 | [ComposerUtilityBar](../../../src/components/agent/chat/components/Inputbar/components/ComposerUtilityBar.tsx),Home/Inputbar 共享消费 | 项目、分支与运行位置组合;既有 workspace/Git owner 继续执行;旧项目上下文组件删除,见[导航审计](navigation-composer-audit.md) | staged worktree / remote |
|
||||
| `ReviewThreadSidePanelTab`,确认公开导出 | [ReviewThreadSidePanelTab](../../../src/components/agent/chat/workspace/ReviewThreadSidePanelTab.tsx),RightSurface → 同次 changeView → ChangesPanel | 删除 path-only surface,复用既有 diff/review/file-preview owner;映射及身份见[执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) | 完整 diff/editor 与视觉逐项核对 |
|
||||
| `SettingsPage`,确认公开导出 | [SettingsPage](../../../src/components/settings-v2/_layout/index.tsx),AppPageContent 唯一产品消费者 | `SettingsPageV2`/`SettingsLayoutV2` 别名删除;[sections](../../../src/components/settings-v2/sections.tsx)统一登记、导航、preload/render,见[设置审计](settings-navigation-audit.md) | canonical 章节参数、返回、内容搜索、dirty authority;目录仍为既有 `settings-v2`,未声称目录名已对齐 |
|
||||
| `VisibleSettingsCommandMenuSectionItems`、`SettingsExternalSection`,确认公开导出 | 目前没有登记为同义 owner 的 Lime 对应符号 | [设置审计](settings-navigation-audit.md)仅证明参考模块职责与导出,未据字符串建立空壳或 alias | 现有命令菜单/外部设置消费者需按实际能力映射 |
|
||||
| `startNewConversation`,确认公开导出 | [useAppNavigation](../../../src/hooks/useAppNavigation.ts) → App/Sidebar → Workspace root navigation | 唯一动作、committed request、稳定 Home scope 与 fresh CAS;删除随机草稿事件、预热/materialized 双轨和 dead general tab owner,见[新建导航审计](new-conversation-navigation-audit.md) | 普通 Codex 侧栏的运行时 fresh 行为、完整冷草稿 picker 仍 unknown |
|
||||
|
||||
## 已确认结构与方法,原类名未知
|
||||
|
||||
| 参考职责 / 可确认成员 | Lime current owner | 对齐合同与限制 |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 主 Composer provider / Controller / EditorView;原类与 hook 名 unknown | [ComposerController](../../../src/components/input-kit/ComposerController.ts)、[ComposerEditor](../../../src/components/input-kit/ComposerEditor.tsx)、`useComposerController`,Workspace 共享给 Home/Inputbar | 唯一 EditorState、Transaction、revision 与 typed submission,见[主输入审计](main-composer-audit.md)。`RichTextInput`/`CodexComposerAdapter` 仅为样式或诊断名,不据此迁名 |
|
||||
| registry:`createNodeView`、`getSnapshot`、`subscribe`、`unregisterNodeView`、`publishNodeViewUpdate` | [CodeBlockNodeViewRegistry](../../../src/components/input-kit/CodeBlockNodeViewRegistry.ts) | Controller 生命周期持有,稳定 snapshot/listeners,Portal 只消费同一 registry。类名为 Lime 实现命名,方法名可按原成员核对 |
|
||||
| NodeView:`destroy`、`ignoreMutation`、`stopEvent`、`update`、`selectLanguage`、`getAutoDetectLanguage`、`updateCodeElement`、`focusEditor`、`getContent` | [CodeBlockNodeView](../../../src/components/input-kit/CodeBlockNodeView.ts)、[CodeBlockToolbar](../../../src/components/input-kit/CodeBlockToolbar.tsx) | 保留 PM contentDOM,toolbar 与正文事件分工、语言事务保 attrs/selection;见[NodeView 审计](composer-code-nodeview-audit.md)。类名与 React 组件名不是已恢复的上游原名 |
|
||||
| highlight / Auto detection 两个 PM plugin;原开发名称 unknown | [composerCodeBlockHighlight](../../../src/components/input-kit/composerCodeBlockHighlight.ts)、[composerCodeBlockAutoDetect](../../../src/components/input-kit/composerCodeBlockAutoDetect.ts),同一 Controller/View 下的独立 plugin | 位置/节点 cache、UTF16 Decoration、异步失效与销毁保护;见[高亮审计](composer-code-highlight-audit.md),不能把 NodeView CSS class 当作高亮实现 |
|
||||
| retained/prompt documents → renderer persisted atom → Main file store;原类名 unknown | [ComposerDraftCoordinator](../../../src/components/input-kit/ComposerDraftCoordinator.ts) → `persistedAtomHost` / `persistedAtomStore` / `persistedAtomFile` | 唯一 keyed 草稿 owner,Home/local promotion、CAS、scope/generation/revision/token 与正常退出;见[保存审计](composer-draft-persistence-audit.md)。不搬压缩类名,不建第二 history store |
|
||||
| `thread-virtualizer` / `virtualized-turn-list` 是包模块名,公开符号多为压缩 alias | [threadVirtualizer](../../../src/components/agent/chat/projection/threadVirtualizer.ts)、[VirtualizedTurnList](../../../src/components/agent/chat/components/VirtualizedTurnList.tsx) | 纯几何与 React 消费分工;canonical paging 与 viewport DOM range 分离。删除旧渐进尾窗/全量展开,不声称原目录或类名已恢复 |
|
||||
| pending request card / panel 组合;完整原命名未在本账本确认 | [PendingRequestItemPanel](../../../src/components/agent/chat/components/PendingRequestItemPanel.tsx) → [ApprovalCard](../../../src/components/agent/chat/components/ApprovalCard.tsx) | same server request/decision authority,替换单行审批提示;实现与验证见[执行计划](../../exec-plans/codex-gui-alignment-current-plan.md),不由组件形状推断所有自动审查变体已完成 |
|
||||
| browser/artifact/terminal/subagent 右侧 surface 组合;完整原目录和 registry 名 unknown | [RightSurfaceHost](../../../src/components/agent/chat/workspace/right-surface/RightSurfaceHost.tsx)、既有 registry、BrowserTabHost | 单一 host/scoped display state,既有原生 owner;Browser 生命周期已有相应 Gate,不能扩大到全部 surface |
|
||||
|
||||
以上命名 unknown 不妨碍对齐已观察的职责、调用关系和数据合同;获得新可确认导出或 source map 后再直接迁移调用者。不要以压缩局部名、chunk hash、概念同义或同名文件数量推导源码等价。
|
||||
|
||||
## 仍需映射或实现的范围
|
||||
|
||||
- 设置后续导航/返回、搜索 target 与实际 dirty owner;不能复制未知 registry、隐藏功能或建立第二 history。
|
||||
- 完整 thread shell/header/overflow 与命令菜单消费;现有各局部审计不是全包清单。
|
||||
- Office/PDF 的真实排版、批注与版本 owner;见[专业文件审计](professional-files-audit.md)。目前文本抽取不能替代排版预览。
|
||||
- 原生应用工具、授权/执行/反馈,以及环境连接/remote 的产品入口;共享 App Server 边界继续有效,不能另造 runtime。
|
||||
- 目录、函数、类型、类与所有调用者的完整可追溯覆盖。当前分母未完整枚举,不给出全产品命名或结构百分比。
|
||||
|
||||
current 为表中的既有 owner;compat 只保留设置 `Experimental → Developer` 的现存委托;未完成能力按 gap/unknown 登记。已替换的别名、随机 general 草稿 owner 和旧组件属于 dead/deleted,禁止为旧测试恢复。单个切片的行为、结构、清理和进程验证分别由执行计划记录。
|
||||
|
||||
Lime 保留主题、多模型与首页 Banner,分别走原 token/skin、catalog/provider 和 Home presentation;三项均不扩展为布局、导航或状态机的额外保留例外。
|
||||
Generated
+36
-36
@@ -31,7 +31,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-protocol"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-runtime"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
@@ -170,7 +170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ansi-escape"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"ansi-to-tui",
|
||||
"ratatui",
|
||||
@@ -248,7 +248,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "app-server"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"agent-runtime",
|
||||
@@ -314,7 +314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-client"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"app-server-transport",
|
||||
@@ -330,7 +330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-daemon"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"app-server-transport",
|
||||
@@ -342,7 +342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-protocol"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"schemars",
|
||||
@@ -354,7 +354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-test-client"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-server-client",
|
||||
@@ -365,7 +365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "app-server-transport"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"app-server-protocol",
|
||||
"axum",
|
||||
@@ -1010,7 +1010,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cli"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
@@ -1066,7 +1066,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"code-mode-host",
|
||||
@@ -1084,7 +1084,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-host"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"code-mode-protocol",
|
||||
@@ -1100,7 +1100,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-protocol"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"protoc-bin-vendored",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "code-mode-runtime"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"code-mode-protocol",
|
||||
@@ -1939,7 +1939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "document-preview"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"tempfile",
|
||||
@@ -2159,7 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "execpolicy"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3629,7 +3629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"agent-runtime",
|
||||
@@ -3664,7 +3664,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -3680,7 +3680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"async-trait",
|
||||
@@ -3737,7 +3737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -3765,7 +3765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -3787,7 +3787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-knowledge"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -3800,7 +3800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3826,7 +3826,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -3845,7 +3845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -3885,7 +3885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"async-stream",
|
||||
@@ -3928,7 +3928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -3943,7 +3943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -4008,7 +4008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -4342,7 +4342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "model-provider"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"anyhow",
|
||||
@@ -5169,7 +5169,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||
|
||||
[[package]]
|
||||
name = "patch-apply"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -6277,7 +6277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime-core"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"app-server-protocol",
|
||||
@@ -7554,7 +7554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thread-store"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"chrono",
|
||||
@@ -7881,7 +7881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tool-runtime"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"app-server-protocol",
|
||||
@@ -8130,7 +8130,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tui"
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
dependencies = [
|
||||
"agent-protocol",
|
||||
"ansi-escape",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.157.0"
|
||||
version = "1.158.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "1.157.0",
|
||||
"version": "1.158.0",
|
||||
"description": "Open-source full-stack desktop AI agent for global users: coding, research, content, files, terminals, tools, multimodal work, and multi-agent workflows.",
|
||||
"keywords": [
|
||||
"ai",
|
||||
@@ -363,9 +363,12 @@
|
||||
"dayjs": "^1.11.19",
|
||||
"fabric": "^5.5.2",
|
||||
"hast-util-to-html": "^9.0.5",
|
||||
"highlight.js": "11.11.1",
|
||||
"i18next": "^25.7.3",
|
||||
"jotai": "^2.16.1",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"lodash-es": "^4.17.22",
|
||||
"lowlight": "3.3.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime",
|
||||
"version": "1.157.0",
|
||||
"version": "1.158.0",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"description": "Lime CLI is an agent that runs in your terminal.",
|
||||
"bin": {
|
||||
|
||||
Generated
+35
@@ -248,6 +248,15 @@ importers:
|
||||
zustand:
|
||||
specifier: ^5.0.10
|
||||
version: 5.0.11(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
highlight.js:
|
||||
specifier: 11.11.1
|
||||
version: 11.11.1
|
||||
jsonc-parser:
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
lowlight:
|
||||
specifier: 3.3.0
|
||||
version: 3.3.0
|
||||
devDependencies:
|
||||
'@babel/plugin-transform-react-jsx-source':
|
||||
specifier: ^7.27.1
|
||||
@@ -6491,6 +6500,21 @@ packages:
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
"@types/hast@3.0.5":
|
||||
resolution:
|
||||
integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==
|
||||
highlight.js@11.11.1:
|
||||
resolution:
|
||||
integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
|
||||
engines:
|
||||
node: ">=12.0.0"
|
||||
jsonc-parser@3.3.1:
|
||||
resolution:
|
||||
integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==
|
||||
lowlight@3.3.0:
|
||||
resolution:
|
||||
integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@@ -13398,3 +13422,14 @@ snapshots:
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
"@types/hast@3.0.5":
|
||||
dependencies:
|
||||
"@types/unist": 3.0.3
|
||||
highlight.js@11.11.1: {}
|
||||
jsonc-parser@3.3.1: {}
|
||||
lowlight@3.3.0:
|
||||
dependencies:
|
||||
"@types/hast": 3.0.5
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.11.1
|
||||
|
||||
@@ -377,7 +377,7 @@ async function runTabMatrix(page, { locale, viewport, tabs, observations }) {
|
||||
}
|
||||
|
||||
async function verifyNavigationRecovery(page) {
|
||||
await page.locator('[data-testid="settings-home-button"]').click();
|
||||
await page.locator('[data-testid="app-sidebar-rail-home"]').click();
|
||||
await page
|
||||
.locator('[data-testid="app-sidebar-account-button"]')
|
||||
.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
@@ -20,23 +20,30 @@ function runVitest(label, args) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
runVitest("刷新恢复与后台队列自动续跑", [
|
||||
const hydration = runVitest("刷新与切换的 canonical 队列水合", [
|
||||
"src/components/agent/chat/hooks/useAgentChat.test.tsx",
|
||||
"-t",
|
||||
"页面刷新恢复到排队会话时应自动恢复后台执行|切换到 running thread_read 时只恢复线程|切换话题时应恢复后端返回的排队项",
|
||||
"页面刷新恢复到排队会话时只水合运行态,不自动调用 legacy resume|切换到 running thread_read 时重新建立 canonical Thread 订阅|切换话题时应消费 session list 的 canonical queue count",
|
||||
]);
|
||||
if (hydration.executedTests !== 3) {
|
||||
throw new Error("async-tabs 必须执行全部三项 canonical 队列水合测试");
|
||||
}
|
||||
|
||||
runVitest("任务标题与 tab 运行态提示", [
|
||||
"src/components/agent/chat/components/ChatSidebar.test.tsx",
|
||||
const navigation = runVitest("侧栏任务状态与专用工作台标签", [
|
||||
"src/components/AppSidebar.conversations.test.tsx",
|
||||
"src/components/agent/chat/components/TaskCenterTabStrip.test.tsx",
|
||||
"src/components/agent/chat/utils/taskCenterTabs.test.ts",
|
||||
"src/components/agent/chat/workspace/taskCenterTabProjection.test.ts",
|
||||
"src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx",
|
||||
"-t",
|
||||
"当前运行中的任务标题旁应显示加载状态|任务中心侧栏应使用对话与归档分组标题|应渲染第二层会话 tabs 和加号入口|默认标签应纳入非当前运行中任务|应把草稿与可见任务投影为第二层标签",
|
||||
"回到首页时应按后台未完成会话状态显示侧栏运行图标|回到首页时应按后台排队会话状态显示侧栏排队图标|后台状态不会覆盖已完成的侧栏会话终态|reconcile 当前任务应保留本地追平目标,并跳过 detached 预览|切换中的目标会话尚未成为当前会话时,应标记为待恢复态|应渲染第二层会话 tabs 和加号入口|应清理任务标签里的运行时错误标题|初始 Thread 标题|general home=|specialized workbench keeps canonical",
|
||||
]);
|
||||
if (navigation.executedTests !== 11) {
|
||||
throw new Error("async-tabs 必须执行全部十一项侧栏与专用工作台测试");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${LOG_PREFIX}] async-tabs.summary: hydration_resume=covered; background_running_tabs=covered; title_loading=covered`,
|
||||
`[${LOG_PREFIX}] async-tabs.summary: canonical_queue_hydration=covered; running_thread_subscription=covered; title_loading=covered; general_second_strip=absent; specialized_navigation=covered`,
|
||||
);
|
||||
console.log(`\n[${LOG_PREFIX}] 通过`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readComposerHistorySnapshot } from "./claw-chat-current-fixture-composer-history.mjs";
|
||||
const BODY = " const value = '中😀';\n````\n\n";
|
||||
let host;
|
||||
function mount() {
|
||||
host = document.createElement("div");
|
||||
host.innerHTML =
|
||||
'<div data-testid="composer-editor" role="textbox" data-session-id="modern-history" contenteditable="true"><p>前 <span text-link-href="https://example.invalid/marks" text-link-title="标题" role="link" aria-disabled="false">中😀</span></p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false">Auto JavaScript</div><pre dir="ltr"><code class="hljs language-typescript"> <span class="hljs-keyword">const</span> value = <span class="hljs-string">\'中😀\'</span>;\n````\n\n</code></pre></div><p><span data-composer-token="paste" data-composer-id="paste-1" contenteditable="false">[Pasted text]</span> <span data-composer-token="skill" data-composer-id="user:capability-report" data-composer-name="capability-report" data-composer-path="/fixture/SKILL.md" contenteditable="false">/user:capability-report</span> 尾 </p></div>';
|
||||
document.body.append(host);
|
||||
const editor = host.firstChild;
|
||||
vi.spyOn(editor, "getClientRects").mockReturnValue([{}]);
|
||||
return editor;
|
||||
}
|
||||
const read = () =>
|
||||
readComposerHistorySnapshot(
|
||||
{ evaluate: (fn, arg) => fn(arg) },
|
||||
"modern-history",
|
||||
);
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe("modern code history document projection", () => {
|
||||
it("captures canonical body and atoms while excluding toolbar and highlight arrangement", async () => {
|
||||
const editor = mount();
|
||||
const before = await read();
|
||||
expect(before.text).toBe(
|
||||
`前 中😀\n${BODY}\n[Pasted text] /user:capability-report 尾 `,
|
||||
);
|
||||
expect(before.documentProjection[1]).toEqual({
|
||||
type: "code_block",
|
||||
language: "typescript",
|
||||
content: [{ type: "text", text: BODY }],
|
||||
});
|
||||
expect(before.tokens).toHaveLength(2);
|
||||
expect(before.tokens.map((token) => token.id)).toEqual([
|
||||
"paste-1",
|
||||
"user:capability-report",
|
||||
]);
|
||||
expect(before.marks.links).toEqual([
|
||||
{
|
||||
label: "中😀",
|
||||
href: "https://example.invalid/marks",
|
||||
title: "标题",
|
||||
role: "link",
|
||||
disabled: false,
|
||||
isAutolink: false,
|
||||
isGeneratedAutolink: false,
|
||||
},
|
||||
]);
|
||||
editor.querySelector(
|
||||
'[data-composer-code-block-toolbar="true"]',
|
||||
).textContent = "TypeScript picker search";
|
||||
editor.querySelector("code").replaceChildren(document.createTextNode(BODY));
|
||||
const after = await read();
|
||||
expect(after.text).toBe(before.text);
|
||||
expect(after.documentProjection).toEqual(before.documentProjection);
|
||||
expect(after.tokens).toEqual(before.tokens);
|
||||
expect(after.marks.codeBlocks[0].keywordTokens).toEqual([]);
|
||||
expect(after.marks).not.toEqual(before.marks);
|
||||
});
|
||||
it.each([
|
||||
"body",
|
||||
"language",
|
||||
"old-dom",
|
||||
"lost-paste",
|
||||
"lost-skill",
|
||||
"link-href",
|
||||
"link-title",
|
||||
"toolbar-body",
|
||||
])("exposes %s drift through the independent views", async (mode) => {
|
||||
const editor = mount();
|
||||
const before = await read();
|
||||
const code = editor.querySelector("code");
|
||||
if (mode === "body") code.textContent = BODY.trim();
|
||||
if (mode === "language") code.className = "hljs language-javascript";
|
||||
if (mode === "old-dom") {
|
||||
const pre = code.parentElement;
|
||||
pre.setAttribute("data-composer-code-block", "true");
|
||||
code.setAttribute("data-language", "typescript");
|
||||
pre.parentElement.replaceWith(pre);
|
||||
}
|
||||
if (mode === "lost-paste")
|
||||
editor.querySelector('[data-composer-token="paste"]').remove();
|
||||
if (mode === "lost-skill")
|
||||
editor.querySelector('[data-composer-token="skill"]').remove();
|
||||
if (mode === "link-href")
|
||||
editor
|
||||
.querySelector("[text-link-href]")
|
||||
.setAttribute("text-link-href", "wrong");
|
||||
if (mode === "link-title")
|
||||
editor
|
||||
.querySelector("[text-link-title]")
|
||||
.setAttribute("text-link-title", "wrong");
|
||||
if (mode === "toolbar-body")
|
||||
code.prepend(document.createTextNode("TypeScript"));
|
||||
const after = await read();
|
||||
if (mode.startsWith("lost-")) expect(after.text).not.toBe(before.text);
|
||||
else expect(after.marks).not.toEqual(before.marks);
|
||||
if (
|
||||
["body", "old-dom", "lost-paste", "lost-skill", "toolbar-body"].includes(
|
||||
mode,
|
||||
)
|
||||
)
|
||||
expect(after.documentProjection).not.toEqual(before.documentProjection);
|
||||
if (mode.startsWith("lost-")) expect(after.tokens).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
composerEditorSelector,
|
||||
withComposerEditorDom,
|
||||
} from "../lib/composer-editor-dom.mjs";
|
||||
import { appServerCallFromPage } from "../electron/mcp-config-fixture-smoke.mjs";
|
||||
import {
|
||||
isObservedPreloadAppServerResponse,
|
||||
parseJsonRpcRequestsFromInvokeTrace,
|
||||
} from "../electron/lib/mcp-config-fixture-evidence.mjs";
|
||||
import { assert } from "./claw-chat-current-fixture-utils.mjs";
|
||||
|
||||
const INPUT_METHODS = new Set([
|
||||
"turn/start",
|
||||
"turn/steer",
|
||||
"thread/queue/add",
|
||||
"thread/queue/update",
|
||||
"thread/queue/start",
|
||||
]);
|
||||
const CHOICES = ["auto", "plaintext", "typescript"];
|
||||
|
||||
// Check expresses the current language choice; aria-selected is cmdk keyboard focus.
|
||||
export function readComposerCodePickerDom({ sessionId }) {
|
||||
const editors = findVisibleComposerEditorsDom(sessionId);
|
||||
const editor = editors.length === 1 ? editors[0] : null;
|
||||
const blocks = editor?.querySelectorAll(
|
||||
'div[data-composer-code-block="true"]',
|
||||
);
|
||||
const block = blocks?.length === 1 ? blocks[0] : null;
|
||||
const trigger = block?.querySelector(
|
||||
'[data-composer-code-block-toolbar="true"] .composer-code-language-trigger',
|
||||
);
|
||||
const code = block?.querySelector(":scope > pre > code.hljs");
|
||||
const menus = Array.from(
|
||||
document.querySelectorAll(".composer-code-language-menu"),
|
||||
).filter(
|
||||
(node) =>
|
||||
node.isConnected &&
|
||||
node.getClientRects().length > 0 &&
|
||||
window.getComputedStyle(node).visibility !== "hidden" &&
|
||||
window.getComputedStyle(node).display !== "none",
|
||||
);
|
||||
const menu = menus.length === 1 ? menus[0] : null;
|
||||
const searches = menu?.querySelectorAll('input[role="combobox"]') ?? [];
|
||||
const search = searches.length === 1 ? searches[0] : null;
|
||||
const languages = Array.from(code?.classList ?? []).filter((value) =>
|
||||
value.startsWith("language-"),
|
||||
);
|
||||
return {
|
||||
electron: window.__LIME_ELECTRON__ === true,
|
||||
preload: typeof window.electronAPI?.invoke === "function",
|
||||
editorCount: editors.length,
|
||||
sessionId: editor?.dataset.sessionId ?? null,
|
||||
disabled:
|
||||
!editor ||
|
||||
editor.getAttribute("contenteditable") !== "true" ||
|
||||
editor.getAttribute("aria-disabled") === "true",
|
||||
editorFocused: document.activeElement === editor,
|
||||
text: editor ? readComposerEditorTextDom(editor) : null,
|
||||
blockCount: blocks?.length ?? 0,
|
||||
language:
|
||||
languages.length === 1 ? languages[0].slice("language-".length) : null,
|
||||
body: code?.textContent ?? null,
|
||||
keywordTokens: code
|
||||
? Array.from(code.querySelectorAll("span.hljs-keyword")).map(
|
||||
(node) => node.textContent,
|
||||
)
|
||||
: [],
|
||||
triggerLabel: trigger?.textContent?.trim() ?? null,
|
||||
triggerAriaLabel: trigger?.getAttribute("aria-label") ?? null,
|
||||
expanded: trigger?.getAttribute("aria-expanded") === "true",
|
||||
menuCount: menus.length,
|
||||
menuOwned:
|
||||
!!menu && !!menu.id && trigger?.getAttribute("aria-controls") === menu.id,
|
||||
searchCount: searches.length,
|
||||
searchQuery: search?.value ?? null,
|
||||
searchFocused: !!search && document.activeElement === search,
|
||||
choices: menu
|
||||
? Array.from(menu.querySelectorAll("[data-code-language]")).map(
|
||||
(item) => ({
|
||||
value: item.getAttribute("data-code-language"),
|
||||
label: item.textContent?.trim() ?? null,
|
||||
checked: !!item.querySelector("svg:not(.invisible)"),
|
||||
highlighted: item.getAttribute("aria-selected") === "true",
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
traceRaw: localStorage.getItem("lime_invoke_trace_buffer_v1"),
|
||||
};
|
||||
}
|
||||
export function withComposerCodePickerDom(callback) {
|
||||
return withComposerEditorDom(
|
||||
new Function(
|
||||
"arg",
|
||||
`${readComposerCodePickerDom.toString()}\nreturn (${callback.toString()})(arg);`,
|
||||
),
|
||||
);
|
||||
}
|
||||
export function installComposerCodePickerObserverDom(editor) {
|
||||
const events = [];
|
||||
const handler = (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const trigger = editor.querySelector(".composer-code-language-trigger");
|
||||
if (
|
||||
event.type === "click" &&
|
||||
target?.closest(".composer-code-language-trigger") === trigger
|
||||
) {
|
||||
events.push({
|
||||
kind: "open",
|
||||
trusted: event.isTrusted,
|
||||
sessionId: editor.dataset.sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const menu = target?.closest(".composer-code-language-menu");
|
||||
if (!menu?.id || menu.id !== trigger?.getAttribute("aria-controls")) return;
|
||||
if (event.type === "click") {
|
||||
const choice = target.closest("[data-code-language]");
|
||||
if (choice)
|
||||
events.push({
|
||||
kind: "select",
|
||||
language: choice.getAttribute("data-code-language"),
|
||||
trusted: event.isTrusted,
|
||||
sessionId: editor.dataset.sessionId,
|
||||
});
|
||||
} else if (
|
||||
event.type === "input" &&
|
||||
target.matches('input[role="combobox"]')
|
||||
) {
|
||||
events.push({
|
||||
kind: "search",
|
||||
query: target.value,
|
||||
trusted: event.isTrusted,
|
||||
sessionId: editor.dataset.sessionId,
|
||||
});
|
||||
} else if (event.key === "Escape") {
|
||||
events.push({
|
||||
kind: "close",
|
||||
key: "Escape",
|
||||
trusted: event.isTrusted,
|
||||
sessionId: editor.dataset.sessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const type of ["click", "keydown", "input"])
|
||||
document.addEventListener(type, handler, true);
|
||||
window.__composerCodePickerObserver = { handler, events };
|
||||
}
|
||||
export function takeComposerCodePickerEventsDom() {
|
||||
const observer = window.__composerCodePickerObserver;
|
||||
for (const type of ["click", "keydown", "input"])
|
||||
document.removeEventListener(type, observer.handler, true);
|
||||
delete window.__composerCodePickerObserver;
|
||||
return observer.events;
|
||||
}
|
||||
export function summarizeComposerCodePickerBoundary({
|
||||
stages,
|
||||
events,
|
||||
beforeRead,
|
||||
afterRead,
|
||||
sessionId,
|
||||
threadId,
|
||||
body,
|
||||
expectedText,
|
||||
}) {
|
||||
const live = (snapshot) =>
|
||||
snapshot?.electron === true &&
|
||||
snapshot.preload === true &&
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === sessionId &&
|
||||
snapshot.disabled === false &&
|
||||
snapshot.blockCount === 1 &&
|
||||
snapshot.body === body &&
|
||||
snapshot.text === expectedText;
|
||||
const liveStages =
|
||||
Array.isArray(stages) &&
|
||||
stages.length === 3 &&
|
||||
stages.every(
|
||||
(stage, index) =>
|
||||
stage.selection === CHOICES[index] &&
|
||||
[stage.searched, stage.selected, stage.inspected, stage.closed].every(
|
||||
live,
|
||||
),
|
||||
);
|
||||
const searchObserved =
|
||||
liveStages &&
|
||||
stages.every(
|
||||
(stage) =>
|
||||
stage.searched.menuCount === 1 &&
|
||||
stage.searched.menuOwned === true &&
|
||||
stage.searched.expanded === true &&
|
||||
stage.searched.searchCount === 1 &&
|
||||
stage.searched.searchFocused === true &&
|
||||
stage.searched.searchQuery === stage.selection &&
|
||||
isDeepStrictEqual(
|
||||
stage.searched.choices.map((item) => item.value),
|
||||
[stage.selection],
|
||||
) &&
|
||||
stage.inspected.searchQuery === "",
|
||||
);
|
||||
const choicesObserved =
|
||||
liveStages &&
|
||||
stages.every((stage) => {
|
||||
const inspected = stage.inspected;
|
||||
const checked = inspected.choices.filter((item) => item.checked);
|
||||
return (
|
||||
inspected.menuCount === 1 &&
|
||||
inspected.menuOwned === true &&
|
||||
inspected.expanded === true &&
|
||||
checked.length === 1 &&
|
||||
checked[0].value === stage.selection &&
|
||||
!!inspected.triggerAriaLabel &&
|
||||
(stage.selection === "auto"
|
||||
? /^(自动|自動|Auto|자동)/i.test(inspected.triggerLabel) &&
|
||||
inspected.triggerLabel.includes("JavaScript")
|
||||
: inspected.triggerLabel === checked[0].label)
|
||||
);
|
||||
});
|
||||
const languageAndHighlight =
|
||||
liveStages &&
|
||||
stages.every((stage) =>
|
||||
[stage.selected, stage.inspected, stage.closed].every(
|
||||
(snapshot) =>
|
||||
snapshot.language ===
|
||||
(stage.selection === "auto" ? "javascript" : stage.selection) &&
|
||||
isDeepStrictEqual(
|
||||
snapshot.keywordTokens,
|
||||
stage.selection === "plaintext" ? [] : ["const"],
|
||||
),
|
||||
),
|
||||
);
|
||||
const closeFocusObserved =
|
||||
liveStages &&
|
||||
stages.every((stage) =>
|
||||
[stage.selected, stage.closed].every(
|
||||
(snapshot) =>
|
||||
snapshot.menuCount === 0 &&
|
||||
snapshot.expanded === false &&
|
||||
snapshot.editorFocused === true,
|
||||
),
|
||||
);
|
||||
const expectedEvents = CHOICES.flatMap((language) => [
|
||||
{ kind: "open", trusted: true, sessionId },
|
||||
{ kind: "search", query: language, trusted: true, sessionId },
|
||||
{ kind: "select", language, trusted: true, sessionId },
|
||||
{ kind: "open", trusted: true, sessionId },
|
||||
{ kind: "close", key: "Escape", trusted: true, sessionId },
|
||||
]);
|
||||
const trustedActionsObserved = isDeepStrictEqual(events, expectedEvents);
|
||||
let noInputRequest = false;
|
||||
try {
|
||||
noInputRequest =
|
||||
liveStages &&
|
||||
stages
|
||||
.flatMap((stage) => [
|
||||
stage.searched,
|
||||
stage.selected,
|
||||
stage.inspected,
|
||||
stage.closed,
|
||||
])
|
||||
.every(
|
||||
(snapshot) =>
|
||||
Array.isArray(JSON.parse(snapshot.traceRaw)) &&
|
||||
parseJsonRpcRequestsFromInvokeTrace(snapshot.traceRaw).filter(
|
||||
(request) =>
|
||||
request.params.threadId === threadId &&
|
||||
INPUT_METHODS.has(request.method),
|
||||
).length === 0,
|
||||
);
|
||||
} catch {
|
||||
// Missing trace evidence cannot establish the negative input claim.
|
||||
}
|
||||
const noCanonicalTurn = [beforeRead, afterRead].every(
|
||||
(read) =>
|
||||
isObservedPreloadAppServerResponse(read?.ipcObservation, "thread/read") &&
|
||||
read.ipcObservation.params?.threadId === threadId &&
|
||||
read.result?.thread?.id === threadId &&
|
||||
Array.isArray(read.result.thread.turns) &&
|
||||
read.result.thread.turns.length === 0,
|
||||
);
|
||||
const checks = {
|
||||
liveStages,
|
||||
searchObserved,
|
||||
choicesObserved,
|
||||
languageAndHighlight,
|
||||
closeFocusObserved,
|
||||
trustedActionsObserved,
|
||||
noInputRequest,
|
||||
noCanonicalTurn,
|
||||
};
|
||||
return {
|
||||
ok: Object.values(checks).every(Boolean),
|
||||
...checks,
|
||||
sessionId,
|
||||
threadId,
|
||||
stages,
|
||||
events,
|
||||
actionSource:
|
||||
"Electron/CDP locator.click, search.fill and menu.press(Escape); passive document observations",
|
||||
attributeBoundary:
|
||||
"visible Check choice + canonical code class; private PM attrs are not read",
|
||||
};
|
||||
}
|
||||
export async function verifyComposerMarksCodePicker({
|
||||
page,
|
||||
options,
|
||||
sessionId,
|
||||
threadId,
|
||||
body,
|
||||
expectedText,
|
||||
}) {
|
||||
const beforeRead = await appServerCallFromPage(page, "thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`);
|
||||
const trigger = editor.locator(
|
||||
'[data-composer-code-block-toolbar="true"] .composer-code-language-trigger',
|
||||
);
|
||||
const read = () =>
|
||||
page.evaluate(withComposerCodePickerDom(readComposerCodePickerDom), {
|
||||
sessionId,
|
||||
});
|
||||
const stages = [];
|
||||
await editor.evaluate(installComposerCodePickerObserverDom);
|
||||
let events;
|
||||
try {
|
||||
for (const selection of CHOICES) {
|
||||
await trigger.click();
|
||||
const menu = page.locator(".composer-code-language-menu:visible");
|
||||
await menu.locator('input[role="combobox"]').fill(selection);
|
||||
await page.waitForFunction(
|
||||
withComposerCodePickerDom(({ sessionId, selection }) => {
|
||||
const snapshot = readComposerCodePickerDom({ sessionId });
|
||||
return (
|
||||
snapshot.menuOwned &&
|
||||
snapshot.searchFocused &&
|
||||
snapshot.searchQuery === selection &&
|
||||
snapshot.choices.length === 1 &&
|
||||
snapshot.choices[0].value === selection
|
||||
);
|
||||
}),
|
||||
{ sessionId, selection },
|
||||
{ timeout: Math.min(options.timeoutMs, 10_000) },
|
||||
);
|
||||
const searched = await read();
|
||||
await menu.locator(`[data-code-language="${selection}"]`).click();
|
||||
await page.waitForFunction(
|
||||
withComposerCodePickerDom(({ sessionId, language, body }) => {
|
||||
const snapshot = readComposerCodePickerDom({ sessionId });
|
||||
return (
|
||||
snapshot.menuCount === 0 &&
|
||||
snapshot.editorFocused &&
|
||||
snapshot.language === language &&
|
||||
snapshot.body === body &&
|
||||
(language === "plaintext"
|
||||
? snapshot.keywordTokens.length === 0
|
||||
: snapshot.keywordTokens.join("\u0000") === "const")
|
||||
);
|
||||
}),
|
||||
{
|
||||
sessionId,
|
||||
language: selection === "auto" ? "javascript" : selection,
|
||||
body,
|
||||
},
|
||||
{ timeout: Math.min(options.timeoutMs, 10_000) },
|
||||
);
|
||||
const selected = await read();
|
||||
await trigger.click();
|
||||
await page.waitForFunction(
|
||||
withComposerCodePickerDom(({ sessionId }) => {
|
||||
const snapshot = readComposerCodePickerDom({ sessionId });
|
||||
return (
|
||||
snapshot.menuCount === 1 &&
|
||||
snapshot.menuOwned &&
|
||||
snapshot.expanded &&
|
||||
snapshot.searchQuery === ""
|
||||
);
|
||||
}),
|
||||
{ sessionId },
|
||||
{ timeout: Math.min(options.timeoutMs, 10_000) },
|
||||
);
|
||||
const inspected = await read();
|
||||
await menu.press("Escape");
|
||||
await page.waitForFunction(
|
||||
withComposerCodePickerDom(({ sessionId }) => {
|
||||
const snapshot = readComposerCodePickerDom({ sessionId });
|
||||
return snapshot.menuCount === 0 && snapshot.editorFocused;
|
||||
}),
|
||||
{ sessionId },
|
||||
{ timeout: Math.min(options.timeoutMs, 10_000) },
|
||||
);
|
||||
stages.push({
|
||||
selection,
|
||||
searched,
|
||||
selected,
|
||||
inspected,
|
||||
closed: await read(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
events = await page.evaluate(takeComposerCodePickerEventsDom);
|
||||
}
|
||||
const afterRead = await appServerCallFromPage(page, "thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
const summary = summarizeComposerCodePickerBoundary({
|
||||
stages,
|
||||
events,
|
||||
beforeRead,
|
||||
afterRead,
|
||||
sessionId,
|
||||
threadId,
|
||||
body,
|
||||
expectedText,
|
||||
});
|
||||
assert(
|
||||
summary.ok,
|
||||
`Composer language Picker 编辑边界失败: ${JSON.stringify(summary)}`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installComposerCodePickerObserverDom,
|
||||
readComposerCodePickerDom,
|
||||
summarizeComposerCodePickerBoundary,
|
||||
takeComposerCodePickerEventsDom,
|
||||
withComposerCodePickerDom,
|
||||
} from "./claw-chat-current-fixture-composer-code-picker.mjs";
|
||||
const BODY = " const value = '中😀';\n````";
|
||||
const TEXT = `prompt\n${BODY}\n/user:capability-report [Pasted text]`;
|
||||
const SESSION = "picker-session",
|
||||
THREAD = "picker-thread";
|
||||
const CHOICES = ["auto", "plaintext", "typescript"];
|
||||
function emptyRead() {
|
||||
return {
|
||||
ipcObservation: {
|
||||
electron: true,
|
||||
fallback: false,
|
||||
transport: "electron-ipc",
|
||||
command: "app_server_handle_json_lines",
|
||||
status: "success",
|
||||
method: "thread/read",
|
||||
requestId: "read",
|
||||
responseId: "read",
|
||||
params: { threadId: THREAD },
|
||||
},
|
||||
result: { thread: { id: THREAD, turns: [] } },
|
||||
};
|
||||
}
|
||||
// Manufactured unit evidence tests the oracle, never a native Gate.
|
||||
function boundary() {
|
||||
const stages = CHOICES.map((selection) => {
|
||||
const language = selection === "auto" ? "javascript" : selection;
|
||||
const label =
|
||||
selection === "auto"
|
||||
? "Auto · JavaScript"
|
||||
: selection === "plaintext"
|
||||
? "Plain"
|
||||
: "TypeScript";
|
||||
const live = {
|
||||
electron: true,
|
||||
preload: true,
|
||||
editorCount: 1,
|
||||
sessionId: SESSION,
|
||||
disabled: false,
|
||||
blockCount: 1,
|
||||
body: BODY,
|
||||
text: TEXT,
|
||||
language,
|
||||
keywordTokens: selection === "plaintext" ? [] : ["const"],
|
||||
traceRaw: "[]",
|
||||
triggerLabel: label,
|
||||
triggerAriaLabel: `Language: ${label}`,
|
||||
};
|
||||
const closed = {
|
||||
...live,
|
||||
menuCount: 0,
|
||||
menuOwned: false,
|
||||
expanded: false,
|
||||
editorFocused: true,
|
||||
};
|
||||
const inspected = {
|
||||
...live,
|
||||
menuCount: 1,
|
||||
menuOwned: true,
|
||||
expanded: true,
|
||||
searchCount: 1,
|
||||
searchQuery: "",
|
||||
searchFocused: true,
|
||||
choices: CHOICES.map((value) => ({
|
||||
value,
|
||||
label:
|
||||
value === "auto"
|
||||
? "Auto"
|
||||
: value === "plaintext"
|
||||
? "Plain"
|
||||
: "TypeScript",
|
||||
checked: value === selection,
|
||||
highlighted: value === "auto",
|
||||
})),
|
||||
};
|
||||
const searched = {
|
||||
...inspected,
|
||||
searchQuery: selection,
|
||||
choices: inspected.choices.filter((item) => item.value === selection),
|
||||
};
|
||||
return {
|
||||
selection,
|
||||
searched,
|
||||
selected: structuredClone(closed),
|
||||
inspected,
|
||||
closed,
|
||||
};
|
||||
});
|
||||
const events = CHOICES.flatMap((language) => [
|
||||
{ kind: "open", trusted: true, sessionId: SESSION },
|
||||
{ kind: "search", query: language, trusted: true, sessionId: SESSION },
|
||||
{ kind: "select", language, trusted: true, sessionId: SESSION },
|
||||
{ kind: "open", trusted: true, sessionId: SESSION },
|
||||
{ kind: "close", key: "Escape", trusted: true, sessionId: SESSION },
|
||||
]);
|
||||
return {
|
||||
stages,
|
||||
events,
|
||||
beforeRead: emptyRead(),
|
||||
afterRead: emptyRead(),
|
||||
sessionId: SESSION,
|
||||
threadId: THREAD,
|
||||
body: BODY,
|
||||
expectedText: TEXT,
|
||||
};
|
||||
}
|
||||
function dom() {
|
||||
const editor = document.createElement("div");
|
||||
editor.setAttribute("data-testid", "composer-editor");
|
||||
editor.setAttribute("role", "textbox");
|
||||
editor.setAttribute("contenteditable", "true");
|
||||
editor.dataset.sessionId = SESSION;
|
||||
editor.innerHTML =
|
||||
'<p>prompt</p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false"><button class="composer-code-language-trigger" aria-expanded="true" aria-controls="language-menu" aria-label="Choose language">TypeScript</button></div><pre dir="ltr"><code class="hljs language-typescript"> <span class="hljs-keyword">const</span> value = <span class="hljs-string">\'中😀\'</span>;\n````</code></pre></div><p><span data-composer-token="skill" contenteditable="false">/user:capability-report</span> <span data-composer-token="paste" contenteditable="false">[Pasted text]</span></p>';
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "composer-code-language-menu";
|
||||
menu.id = "language-menu";
|
||||
menu.innerHTML =
|
||||
'<input role="combobox"><div data-code-language="auto" aria-selected="true"><svg class="invisible"></svg>Auto</div><div data-code-language="plaintext" aria-selected="false"><svg class="invisible"></svg>Plain</div><div data-code-language="typescript" aria-selected="false"><svg></svg>TypeScript</div>';
|
||||
document.body.append(editor, menu);
|
||||
for (const node of [editor, menu])
|
||||
vi.spyOn(node, "getClientRects").mockReturnValue([{}]);
|
||||
return {
|
||||
editor,
|
||||
menu,
|
||||
trigger: editor.querySelector("button"),
|
||||
search: menu.querySelector("input"),
|
||||
};
|
||||
}
|
||||
afterEach(() => {
|
||||
if (window.__composerCodePickerObserver) takeComposerCodePickerEventsDom();
|
||||
document.body.replaceChildren();
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe("independent code Picker DOM observations", () => {
|
||||
it("observes owned Portal, actual code spans and Check rather than cmdk highlight", () => {
|
||||
const { search } = dom();
|
||||
search.focus();
|
||||
const reader = withComposerCodePickerDom(readComposerCodePickerDom);
|
||||
const snapshot = new Function(`return (${reader.toString()});`)()({
|
||||
sessionId: SESSION,
|
||||
});
|
||||
expect(snapshot).toMatchObject({
|
||||
body: BODY,
|
||||
text: TEXT,
|
||||
language: "typescript",
|
||||
keywordTokens: ["const"],
|
||||
menuCount: 1,
|
||||
menuOwned: true,
|
||||
searchCount: 1,
|
||||
searchFocused: true,
|
||||
searchQuery: "",
|
||||
});
|
||||
expect(
|
||||
snapshot.choices.filter((item) => item.checked).map((item) => item.value),
|
||||
).toEqual(["typescript"]);
|
||||
expect(
|
||||
snapshot.choices
|
||||
.filter((item) => item.highlighted)
|
||||
.map((item) => item.value),
|
||||
).toEqual(["auto"]);
|
||||
});
|
||||
it("serializes a wait predicate with Picker and DOM helpers in the same lexical chain", () => {
|
||||
dom();
|
||||
const predicate = withComposerCodePickerDom(
|
||||
new Function(
|
||||
"arg",
|
||||
"const snapshot = readComposerCodePickerDom(arg); return snapshot.menuOwned && snapshot.body === arg.body && snapshot.text === arg.text;",
|
||||
),
|
||||
);
|
||||
const serialized = new Function(`return (${predicate.toString()});`)();
|
||||
expect(serialized({ sessionId: SESSION, body: BODY, text: TEXT })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(serialized({ sessionId: "foreign", body: BODY, text: TEXT })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
"foreign-menu",
|
||||
"duplicate-menu",
|
||||
"old-dom",
|
||||
"duplicate-language",
|
||||
"lost-atom",
|
||||
"toolbar-body",
|
||||
"fake-highlight",
|
||||
])("exposes %s instead of inventing valid evidence", (mode) => {
|
||||
const { editor, menu } = dom();
|
||||
const code = editor.querySelector("code");
|
||||
if (mode === "foreign-menu") menu.id = "foreign";
|
||||
if (mode === "duplicate-menu") {
|
||||
const second = menu.cloneNode(true);
|
||||
document.body.append(second);
|
||||
vi.spyOn(second, "getClientRects").mockReturnValue([{}]);
|
||||
}
|
||||
if (mode === "old-dom") {
|
||||
const pre = code.parentElement;
|
||||
pre.setAttribute("data-composer-code-block", "true");
|
||||
pre.parentElement.replaceWith(pre);
|
||||
}
|
||||
if (mode === "duplicate-language")
|
||||
code.classList.add("language-javascript");
|
||||
if (mode === "lost-atom")
|
||||
editor.querySelector('[data-composer-token="paste"]').remove();
|
||||
if (mode === "toolbar-body")
|
||||
code.prepend(document.createTextNode("TypeScript"));
|
||||
if (mode === "fake-highlight")
|
||||
code.querySelector(".hljs-keyword").className = "hljs-string";
|
||||
const snapshot = withComposerCodePickerDom(readComposerCodePickerDom)({
|
||||
sessionId: SESSION,
|
||||
});
|
||||
if (mode.includes("menu")) expect(snapshot.menuOwned).toBe(false);
|
||||
if (mode === "old-dom") expect(snapshot.blockCount).toBe(0);
|
||||
if (mode === "duplicate-language") expect(snapshot.language).toBeNull();
|
||||
if (mode === "lost-atom") expect(snapshot.text).not.toBe(TEXT);
|
||||
if (mode === "toolbar-body") expect(snapshot.body).not.toBe(BODY);
|
||||
if (mode === "fake-highlight") expect(snapshot.keywordTokens).toEqual([]);
|
||||
});
|
||||
it("passively observes synthetic search/click/Escape with trust false and ignores foreign Portal", () => {
|
||||
const { editor, menu, trigger, search } = dom();
|
||||
installComposerCodePickerObserverDom(editor);
|
||||
trigger.click();
|
||||
search.value = "typescript";
|
||||
search.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
menu.querySelector('[data-code-language="typescript"]').click();
|
||||
search.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
menu.id = "foreign";
|
||||
menu.querySelector('[data-code-language="auto"]').click();
|
||||
expect(takeComposerCodePickerEventsDom()).toEqual([
|
||||
{ kind: "open", trusted: false, sessionId: SESSION },
|
||||
{
|
||||
kind: "search",
|
||||
query: "typescript",
|
||||
trusted: false,
|
||||
sessionId: SESSION,
|
||||
},
|
||||
{
|
||||
kind: "select",
|
||||
language: "typescript",
|
||||
trusted: false,
|
||||
sessionId: SESSION,
|
||||
},
|
||||
{ kind: "close", key: "Escape", trusted: false, sessionId: SESSION },
|
||||
]);
|
||||
expect(window.__composerCodePickerObserver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe("Picker fail-closed editing-only oracle", () => {
|
||||
it("requires Auto JavaScript, Plain and searched explicit TypeScript before sending", () =>
|
||||
expect(summarizeComposerCodePickerBoundary(boundary())).toMatchObject({
|
||||
ok: true,
|
||||
searchObserved: true,
|
||||
noInputRequest: true,
|
||||
noCanonicalTurn: true,
|
||||
}));
|
||||
it.each([
|
||||
"missing-stage",
|
||||
"order",
|
||||
"synthetic",
|
||||
"foreign-action",
|
||||
"missing-search",
|
||||
"wrong-query",
|
||||
"unfiltered",
|
||||
"unfocused-search",
|
||||
"foreign-menu",
|
||||
"wrong-check",
|
||||
"highlight-as-check",
|
||||
"wrong-auto",
|
||||
"wrong-final-language",
|
||||
"fake-highlight",
|
||||
"plain-highlight",
|
||||
"lost-atom",
|
||||
"toolbar-body",
|
||||
"lost-focus",
|
||||
"missing-trace",
|
||||
"malformed-trace",
|
||||
"canonical-turn",
|
||||
"foreign-read",
|
||||
"unobserved-read",
|
||||
])("rejects %s", (mode) => {
|
||||
const input = boundary();
|
||||
if (mode === "missing-stage") input.stages.pop();
|
||||
if (mode === "order") input.stages.reverse();
|
||||
if (mode === "synthetic") input.events[1].trusted = false;
|
||||
if (mode === "foreign-action") input.events[2].sessionId = "foreign";
|
||||
if (mode === "missing-search") input.events.splice(1, 1);
|
||||
if (mode === "wrong-query") input.stages[2].searched.searchQuery = "java";
|
||||
if (mode === "unfiltered")
|
||||
input.stages[2].searched.choices.push({ value: "javascript" });
|
||||
if (mode === "unfocused-search")
|
||||
input.stages[2].searched.searchFocused = false;
|
||||
if (mode === "foreign-menu") input.stages[0].inspected.menuOwned = false;
|
||||
if (mode === "wrong-check")
|
||||
input.stages[2].inspected.choices[0].checked = true;
|
||||
if (mode === "highlight-as-check")
|
||||
input.stages[2].inspected.choices.forEach((item) => {
|
||||
item.checked = item.highlighted;
|
||||
});
|
||||
if (mode === "wrong-auto") input.stages[0].selected.language = "typescript";
|
||||
if (mode === "wrong-final-language") input.stages[2].closed.language = "ts";
|
||||
if (mode === "fake-highlight") input.stages[2].closed.keywordTokens = [];
|
||||
if (mode === "plain-highlight")
|
||||
input.stages[1].selected.keywordTokens = ["const"];
|
||||
if (mode === "lost-atom")
|
||||
input.stages[1].inspected.text = `prompt\n${BODY}`;
|
||||
if (mode === "toolbar-body")
|
||||
input.stages[2].closed.body = `TypeScript${BODY}`;
|
||||
if (mode === "lost-focus") input.stages[2].closed.editorFocused = false;
|
||||
if (mode === "missing-trace") input.stages[1].searched.traceRaw = null;
|
||||
if (mode === "malformed-trace") input.stages[1].closed.traceRaw = "{}";
|
||||
if (mode === "canonical-turn")
|
||||
input.afterRead.result.thread.turns.push({ id: "unexpected" });
|
||||
if (mode === "foreign-read")
|
||||
input.afterRead.ipcObservation.params.threadId = "foreign";
|
||||
if (mode === "unobserved-read") delete input.afterRead.ipcObservation;
|
||||
expect(summarizeComposerCodePickerBoundary(input).ok).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
"turn/start",
|
||||
"turn/steer",
|
||||
"thread/queue/add",
|
||||
"thread/queue/update",
|
||||
"thread/queue/start",
|
||||
])("rejects additional %s while searching", (method) => {
|
||||
const input = boundary();
|
||||
input.stages[2].searched.traceRaw = JSON.stringify([
|
||||
{
|
||||
command: "app_server_handle_json_lines",
|
||||
transport: "electron-ipc",
|
||||
status: "success",
|
||||
args_preview: {
|
||||
request: {
|
||||
lines: [
|
||||
JSON.stringify({
|
||||
id: "extra",
|
||||
method,
|
||||
params: {
|
||||
threadId: THREAD,
|
||||
input: [{ type: "text", text: BODY }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(summarizeComposerCodePickerBoundary(input).noInputRequest).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { composerSchema } from "@/components/input-kit/composerSchema";
|
||||
import { CodeBlockNodeViewRegistry } from "@/components/input-kit/CodeBlockNodeViewRegistry";
|
||||
import {
|
||||
createCodeBlockHighlight,
|
||||
loadCodeBlockParser,
|
||||
} from "@/components/input-kit/composerCodeBlockHighlight";
|
||||
import { detectCodeBlockLanguage } from "@/components/input-kit/composerCodeBlockLanguage";
|
||||
import { readComposerEditorTextDom } from "../lib/composer-editor-dom.mjs";
|
||||
import {
|
||||
placeComposerCodeCaretAtEndDom,
|
||||
readComposerMarksDom,
|
||||
} from "./claw-chat-current-fixture-composer-marks.mjs";
|
||||
const BODY = " const value = '中😀';\n````";
|
||||
const cleanups = [];
|
||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()));
|
||||
// Actual PM, NodeView and grammar output in JSDOM; not trusted native input/Gate B.
|
||||
async function mount(language) {
|
||||
const parser = await loadCodeBlockParser();
|
||||
const doc = composerSchema.node("doc", null, [
|
||||
composerSchema.node("paragraph", null, composerSchema.text("before中😀")),
|
||||
composerSchema.node(
|
||||
"code_block",
|
||||
{ language, autoDetectLanguage: false },
|
||||
composerSchema.text(BODY),
|
||||
),
|
||||
composerSchema.node("paragraph", null, composerSchema.text("after")),
|
||||
]);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const registry = new CodeBlockNodeViewRegistry();
|
||||
const view = new EditorView(host, {
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
plugins: [createCodeBlockHighlight({ parser })],
|
||||
}),
|
||||
nodeViews: { code_block: registry.createNodeView },
|
||||
});
|
||||
registry.getSnapshot()[0].toolbarPortalContainer.textContent =
|
||||
"TypeScript toolbar search";
|
||||
cleanups.push(() => {
|
||||
view.destroy();
|
||||
registry.destroy();
|
||||
host.remove();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
describe("actual parser to modern NodeView fixture observations", () => {
|
||||
it.each(["javascript", "typescript"])(
|
||||
"reads real %s spans, preserving source, emoji, whitespace and fences",
|
||||
async (language) => {
|
||||
const view = await mount(language);
|
||||
const marks = readComposerMarksDom(view.dom);
|
||||
expect(marks.codeBlocks).toEqual([
|
||||
{
|
||||
language,
|
||||
body: BODY,
|
||||
validStructure: true,
|
||||
hardBreakCount: 0,
|
||||
keywordTokens: ["const"],
|
||||
},
|
||||
]);
|
||||
expect(marks.blockTags).toEqual(["P", "DIV", "P"]);
|
||||
expect(view.dom.querySelector(".hljs-keyword")?.textContent).toBe(
|
||||
"const",
|
||||
);
|
||||
expect(view.dom.querySelector(".hljs-string")?.textContent).toContain(
|
||||
"中😀",
|
||||
);
|
||||
expect(readComposerEditorTextDom(view.dom)).toBe(
|
||||
`before中😀\n${BODY}\nafter`,
|
||||
);
|
||||
expect(view.state.doc.textContent).toBe(`before中😀${BODY}after`);
|
||||
placeComposerCodeCaretAtEndDom(view.dom);
|
||||
const leaf = window.getSelection().anchorNode;
|
||||
expect(leaf.nodeType).toBe(Node.TEXT_NODE);
|
||||
expect(leaf.parentElement.closest("code.hljs")).not.toBeNull();
|
||||
expect(window.getSelection().anchorOffset).toBe(leaf.nodeValue.length);
|
||||
},
|
||||
);
|
||||
it("the immutable literal detects JavaScript independently of final TypeScript selection", async () => {
|
||||
expect(
|
||||
await detectCodeBlockLanguage(BODY, () => {
|
||||
throw new Error("syntax hint should answer");
|
||||
}),
|
||||
).toBe("javascript");
|
||||
const view = await mount("plaintext");
|
||||
expect(readComposerMarksDom(view.dom).codeBlocks).toEqual([
|
||||
{
|
||||
language: "plaintext",
|
||||
body: BODY,
|
||||
validStructure: true,
|
||||
hardBreakCount: 0,
|
||||
keywordTokens: [],
|
||||
},
|
||||
]);
|
||||
expect(view.dom.querySelectorAll("code span.hljs-keyword")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
isExpectedSkillIdentity,
|
||||
} from "./claw-chat-current-fixture-skill-identity.mjs";
|
||||
import { composerEditorSelector } from "../lib/composer-editor-dom.mjs";
|
||||
import {
|
||||
captureInvokeTraceCollector,
|
||||
validateInvokeTraceCollectorCapture,
|
||||
sameInvokeTraceCollectorProvenance,
|
||||
} from "../lib/invoke-trace-collector.mjs";
|
||||
import { appServerCallFromPage } from "../electron/mcp-config-fixture-smoke.mjs";
|
||||
import {
|
||||
isObservedPreloadAppServerResponse,
|
||||
@@ -34,6 +39,22 @@ function readHistorySnapshotDom({ sessionId }) {
|
||||
if (node.nodeType === Node.TEXT_NODE)
|
||||
return { type: "text", text: node.nodeValue };
|
||||
if (!(node instanceof HTMLElement)) return null;
|
||||
if (node.getAttribute("data-composer-code-block-toolbar") === "true")
|
||||
return null;
|
||||
if (node.matches('div[data-composer-code-block="true"]')) {
|
||||
const code = node.querySelector(":scope > pre > code.hljs");
|
||||
const languages = Array.from(code?.classList ?? []).filter((name) =>
|
||||
name.startsWith("language-"),
|
||||
);
|
||||
return {
|
||||
type: "code_block",
|
||||
language:
|
||||
languages.length === 1
|
||||
? languages[0].slice("language-".length)
|
||||
: null,
|
||||
content: [{ type: "text", text: code?.textContent ?? null }],
|
||||
};
|
||||
}
|
||||
if (node.matches("br.ProseMirror-trailingBreak")) return null;
|
||||
if (node.tagName === "BR") return { type: "hard_break" };
|
||||
const kind = node.getAttribute("data-composer-token");
|
||||
@@ -543,9 +564,61 @@ export async function captureComposerHistoryDraft(
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** History runs after Stop recovery; clear only through actual public GUI actions. */
|
||||
export async function clearComposerDraftForHistory({
|
||||
page,
|
||||
options,
|
||||
sessionId,
|
||||
}) {
|
||||
const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`);
|
||||
const core = editor.locator(
|
||||
'xpath=ancestor::*[@data-testid="inputbar-core-container"][1]',
|
||||
);
|
||||
const image = core.locator('img[src^="data:image/"]');
|
||||
const path = core.locator('[data-testid="inputbar-path-reference-chip"]');
|
||||
assert(
|
||||
(await image.count()) === 1 && (await path.count()) === 1,
|
||||
"history 起点必须是完整 Stop 恢复的图片与路径",
|
||||
);
|
||||
await image.locator("..").getByRole("button").click();
|
||||
await path.getByRole("button").last().click();
|
||||
const trigger = core.locator('[data-testid="inputbar-plus-trigger"]');
|
||||
await trigger.click();
|
||||
await page.locator('[data-testid="inputbar-plus-skills"]:visible').click();
|
||||
const panel = page.locator(
|
||||
'[data-testid="inputbar-plus-panel-skills"]:visible',
|
||||
);
|
||||
await panel.locator('[data-testid="skill-selector-clear"]').click();
|
||||
await trigger.click();
|
||||
await panel.waitFor({
|
||||
state: "hidden",
|
||||
timeout: Math.min(options.timeoutMs, 10_000),
|
||||
});
|
||||
await editor.press("ControlOrMeta+A");
|
||||
await editor.press("Backspace");
|
||||
const cleared = await waitForSnapshot(
|
||||
page,
|
||||
sessionId,
|
||||
options,
|
||||
isEmptySnapshot,
|
||||
"history 独立验收未通过公开 GUI 清空完整草稿",
|
||||
);
|
||||
return {
|
||||
origin: "user_clear_after_verified_stop_restore",
|
||||
sessionId,
|
||||
actionSource:
|
||||
"Electron/CDP public image/path remove, Skill clear and editor keyboard",
|
||||
emptyText: cleared.text === "",
|
||||
tokenCount: cleared.tokens.length,
|
||||
imageCount: cleared.images.length,
|
||||
pathCount: cleared.paths.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyComposerHistoryRecall({
|
||||
page,
|
||||
options,
|
||||
traceCollector,
|
||||
prepared,
|
||||
threadId,
|
||||
sessionId,
|
||||
@@ -555,12 +628,33 @@ export async function verifyComposerHistoryRecall({
|
||||
imageDataUrl,
|
||||
pathReference,
|
||||
}) {
|
||||
const captureTrace = async () => {
|
||||
if (!traceCollector)
|
||||
return {
|
||||
raw: await page.evaluate(() =>
|
||||
localStorage.getItem("lime_invoke_trace_buffer_v1"),
|
||||
),
|
||||
provenance: null,
|
||||
};
|
||||
const observed = validateInvokeTraceCollectorCapture(
|
||||
await captureInvokeTraceCollector(page, traceCollector),
|
||||
traceCollector,
|
||||
);
|
||||
assert(
|
||||
observed.complete,
|
||||
"history invoke trace collector provenance 不完整",
|
||||
);
|
||||
return {
|
||||
raw: JSON.stringify(observed.ledgerEntries),
|
||||
provenance: observed.provenance,
|
||||
};
|
||||
};
|
||||
const cleared = await waitForSnapshot(
|
||||
page,
|
||||
sessionId,
|
||||
options,
|
||||
isEmptySnapshot,
|
||||
"accepted submission 未清空完整 Composer draft",
|
||||
"history 验收起点未清空完整 Composer draft",
|
||||
);
|
||||
cleared.capability = await observeHistorySkillCapability(
|
||||
page,
|
||||
@@ -569,6 +663,8 @@ export async function verifyComposerHistoryRecall({
|
||||
false,
|
||||
options.timeoutMs,
|
||||
);
|
||||
const beforeTrace = await captureTrace();
|
||||
cleared.traceRaw = beforeTrace.raw;
|
||||
const beforeRead = await appServerCallFromPage(page, "thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
@@ -623,9 +719,16 @@ export async function verifyComposerHistoryRecall({
|
||||
includeTurns: true,
|
||||
});
|
||||
// Include all menu actions and the second read in the final transport boundary.
|
||||
returned.traceRaw = await page.evaluate(() =>
|
||||
localStorage.getItem("lime_invoke_trace_buffer_v1"),
|
||||
);
|
||||
const afterTrace = await captureTrace();
|
||||
returned.traceRaw = afterTrace.raw;
|
||||
if (traceCollector)
|
||||
assert(
|
||||
sameInvokeTraceCollectorProvenance(
|
||||
beforeTrace.provenance,
|
||||
afterTrace.provenance,
|
||||
),
|
||||
"history invoke trace collector 必须连续且归属同一次观察",
|
||||
);
|
||||
const keyboardEvents = await page.evaluate(
|
||||
takeComposerHistoryKeyboardEventsDom,
|
||||
);
|
||||
@@ -652,6 +755,10 @@ export async function verifyComposerHistoryRecall({
|
||||
);
|
||||
return {
|
||||
...evidence,
|
||||
traceMode: traceCollector ? "continuous-storage-append" : "bounded-ring",
|
||||
traceProvenance: traceCollector
|
||||
? { before: beforeTrace.provenance, after: afterTrace.provenance }
|
||||
: null,
|
||||
cleared,
|
||||
recalled,
|
||||
returned,
|
||||
|
||||
@@ -305,7 +305,7 @@ describe("real keyboard Composer history evidence", () => {
|
||||
node.setAttribute("data-session-id", "history-session");
|
||||
node.setAttribute("contenteditable", "true");
|
||||
node.innerHTML =
|
||||
'<p>first</p><p><span role="link" text-link-href="https://example.invalid/marks" text-link-title="标题" aria-disabled="false">中😀</span></p><pre data-composer-code-block=""><code data-language="ts"> 中😀\n````\n\n</code></pre><p>tail</p>';
|
||||
'<p>first</p><p><span role="link" text-link-href="https://example.invalid/marks" text-link-title="标题" aria-disabled="false">中😀</span></p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false">TypeScript</div><pre dir="ltr"><code class="hljs language-typescript"> <span class="hljs-keyword">const</span> value = \'中😀\';\n````\n\n</code></pre></div><p>tail</p>';
|
||||
document.body.append(node);
|
||||
vi.spyOn(node, "getClientRects").mockReturnValue([{}]);
|
||||
try {
|
||||
@@ -324,15 +324,18 @@ describe("real keyboard Composer history evidence", () => {
|
||||
isAutolink: false,
|
||||
});
|
||||
expect(prepared.marks.codeBlocks[0]).toEqual({
|
||||
language: "ts",
|
||||
body: " 中😀\n````\n\n",
|
||||
language: "typescript",
|
||||
body: " const value = '中😀';\n````\n\n",
|
||||
validStructure: true,
|
||||
hardBreakCount: 0,
|
||||
keywordTokens: ["const"],
|
||||
});
|
||||
node
|
||||
.querySelector("span")
|
||||
.setAttribute("text-link-href", "https://example.invalid/wrong");
|
||||
node.querySelector("code").setAttribute("data-language", "js");
|
||||
node.querySelector(
|
||||
'[data-composer-code-block-toolbar="true"]',
|
||||
).textContent = "Language picker";
|
||||
const recalled = await readComposerHistorySnapshot(
|
||||
page,
|
||||
"history-session",
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("marks fixture against the current Markdown renderer", () => {
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("title")).toBe("标题");
|
||||
expect(container.querySelector("code")?.textContent).toBe(
|
||||
" 中😀\n````\n\n",
|
||||
" const value = '中😀';\n````\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,6 +88,8 @@ describe("marks fixture against the current Markdown renderer", () => {
|
||||
expect(expected.text).toContain(
|
||||
'[中😀](https://example.invalid/marks "标题")',
|
||||
);
|
||||
expect(expected.text).toContain("`````ts\n 中😀\n````\n\n\n`````");
|
||||
expect(expected.text).toContain(
|
||||
"`````typescript\n const value = '中😀';\n````\n\n\n`````",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseJsonRpcRequestsFromInvokeTrace,
|
||||
} from "../electron/lib/mcp-config-fixture-evidence.mjs";
|
||||
import { assert } from "./claw-chat-current-fixture-utils.mjs";
|
||||
import { verifyComposerMarksCodePicker } from "./claw-chat-current-fixture-composer-code-picker.mjs";
|
||||
|
||||
const INPUT_METHODS = new Set([
|
||||
"turn/start",
|
||||
@@ -27,16 +28,22 @@ const LINK = {
|
||||
isAutolink: false,
|
||||
isGeneratedAutolink: false,
|
||||
};
|
||||
const CODE = " 中😀\n````";
|
||||
const CODE = " const value = '中😀';\n````";
|
||||
const TAIL = "尾中😀";
|
||||
|
||||
function expectedMarks(body) {
|
||||
return {
|
||||
links: [{ ...LINK }],
|
||||
codeBlocks: [
|
||||
{ language: "ts", body, validStructure: true, hardBreakCount: 0 },
|
||||
{
|
||||
language: "typescript",
|
||||
body,
|
||||
validStructure: true,
|
||||
hardBreakCount: 0,
|
||||
keywordTokens: ["const"],
|
||||
},
|
||||
],
|
||||
blockTags: ["P", "P", "PRE", "P"],
|
||||
blockTags: ["P", "P", "DIV", "P"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +65,7 @@ export function composerMarksFixture({ prompt, skillIdentity, pasteText }) {
|
||||
const pasteLabel = `[Pasted text 1: ${pasteText.length} characters]`;
|
||||
const skillLabel = `/${skillIdentity.key}`;
|
||||
const displayPrefix = `${prompt}\n${LINK.label}\n${body}\n${TAIL} `;
|
||||
const markdownPrefix = `/${skillIdentity.name} ${prompt}\n[中😀](https://example.invalid/marks "标题")\n\`\`\`\`\`ts\n${body}\n\`\`\`\`\`\n${TAIL} `;
|
||||
const markdownPrefix = `/${skillIdentity.name} ${prompt}\n[中😀](https://example.invalid/marks "标题")\n\`\`\`\`\`typescript\n${body}\n\`\`\`\`\`\n${TAIL} `;
|
||||
const pasteStart = Buffer.byteLength(markdownPrefix, "utf8");
|
||||
const pasteEnd = pasteStart + Buffer.byteLength(pasteText, "utf8");
|
||||
return {
|
||||
@@ -107,24 +114,51 @@ export function readComposerMarksDom(editor) {
|
||||
isGeneratedAutolink: flag(node, "data-rich-text-generated-autolink"),
|
||||
}),
|
||||
),
|
||||
codeBlocks: Array.from(editor.querySelectorAll("pre")).map((pre) => {
|
||||
const code = pre.querySelector(":scope > code");
|
||||
codeBlocks: Array.from(
|
||||
editor.querySelectorAll('div[data-composer-code-block="true"]'),
|
||||
).map((block) => {
|
||||
const toolbar = block.children[0];
|
||||
const pre = block.children[1];
|
||||
const code = pre?.querySelector(":scope > code");
|
||||
const languages = Array.from(code?.classList ?? []).filter((name) =>
|
||||
name.startsWith("language-"),
|
||||
);
|
||||
const validCodeNode = (node) =>
|
||||
node.nodeType === 3 ||
|
||||
(node instanceof HTMLElement &&
|
||||
(node.matches("br.ProseMirror-trailingBreak") ||
|
||||
(node.tagName === "SPAN" &&
|
||||
node.classList.length > 0 &&
|
||||
Array.from(node.classList).every((name) =>
|
||||
name.startsWith("hljs-"),
|
||||
) &&
|
||||
Array.from(node.childNodes).every(validCodeNode))));
|
||||
return {
|
||||
language: code?.getAttribute("data-language") ?? null,
|
||||
language:
|
||||
languages.length === 1
|
||||
? languages[0].slice("language-".length)
|
||||
: null,
|
||||
body: code?.textContent ?? null,
|
||||
validStructure:
|
||||
pre.hasAttribute("data-composer-code-block") &&
|
||||
block.children.length === 2 &&
|
||||
toolbar?.tagName === "DIV" &&
|
||||
toolbar.getAttribute("data-composer-code-block-toolbar") === "true" &&
|
||||
toolbar.getAttribute("contenteditable") === "false" &&
|
||||
pre?.tagName === "PRE" &&
|
||||
pre.getAttribute("dir") === "ltr" &&
|
||||
pre.childNodes.length === 1 &&
|
||||
!!code &&
|
||||
Array.from(code.childNodes).every(
|
||||
(node) =>
|
||||
node.nodeType === 3 ||
|
||||
(node instanceof HTMLElement &&
|
||||
node.matches("br.ProseMirror-trailingBreak")),
|
||||
),
|
||||
code.classList.contains("hljs") &&
|
||||
!code.hasAttribute("data-language") &&
|
||||
Array.from(code.childNodes).every(validCodeNode),
|
||||
hardBreakCount: code
|
||||
? code.querySelectorAll("br:not(.ProseMirror-trailingBreak)").length
|
||||
: null,
|
||||
keywordTokens: code
|
||||
? Array.from(code.querySelectorAll("span.hljs-keyword")).map(
|
||||
(node) => node.textContent,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
blockTags: Array.from(editor.children).map((node) => node.tagName),
|
||||
@@ -188,8 +222,9 @@ export function installComposerCodeKeyboardObserverDom(node) {
|
||||
sessionId: node.dataset.sessionId,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
insideCode:
|
||||
!!element?.closest("pre[data-composer-code-block] > code") &&
|
||||
node.contains(anchor),
|
||||
!!element?.closest(
|
||||
'div[data-composer-code-block="true"] > pre > code',
|
||||
) && node.contains(anchor),
|
||||
});
|
||||
};
|
||||
// Observe after EditorView's handler, before a later ancestor could consume it.
|
||||
@@ -283,27 +318,46 @@ export function summarizeComposerCodeBoundary({
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareComposerMarksDraft({
|
||||
export function placeComposerCodeCaretAtEndDom(node) {
|
||||
const code = node.querySelector(
|
||||
'div[data-composer-code-block="true"] > pre > code',
|
||||
);
|
||||
if (!code) throw new Error("code selection 缺少现代 NodeView contentDOM");
|
||||
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||
let text = null;
|
||||
while (walker.nextNode()) text = walker.currentNode;
|
||||
if (!text) throw new Error("code selection 缺少实际 text leaf");
|
||||
node.focus();
|
||||
const range = document.createRange();
|
||||
range.setStart(text, text.nodeValue.length);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/** Synthetic clipboard transport into the real EditorView; no model or Controller injection. */
|
||||
export async function pasteComposerMarksDraft({
|
||||
page,
|
||||
options,
|
||||
sessionId,
|
||||
threadId,
|
||||
prompt,
|
||||
body = CODE,
|
||||
}) {
|
||||
const beforeRead = await appServerCallFromPage(page, "thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`);
|
||||
await editor.fill("");
|
||||
const pasteObservation = await editor.evaluate(
|
||||
(node, { prompt }) => {
|
||||
(node, { prompt, body }) => {
|
||||
const data = new DataTransfer();
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.textContent = prompt;
|
||||
const code = document.createElement("pre");
|
||||
const content = document.createElement("code");
|
||||
content.className = "language-typescript";
|
||||
content.textContent = body;
|
||||
code.append(content);
|
||||
data.setData(
|
||||
"text/html",
|
||||
`${paragraph.outerHTML}<p><a href="https://example.invalid/marks" title="标题">中😀</a></p><pre><code data-language="ts"> 中😀\n\`\`\`\`</code></pre><p>尾中😀</p>`,
|
||||
`${paragraph.outerHTML}<p><a href="https://example.invalid/marks" title="标题">中😀</a></p>${code.outerHTML}<p>尾中😀</p>`,
|
||||
);
|
||||
data.setData("text/plain", "PLAIN FALLBACK MUST NOT WIN");
|
||||
const event = new ClipboardEvent("paste", {
|
||||
@@ -320,8 +374,36 @@ export async function prepareComposerMarksDraft({
|
||||
"synthetic DataTransfer/ClipboardEvent in real Electron; OS clipboard not covered",
|
||||
};
|
||||
},
|
||||
{ prompt },
|
||||
{ prompt, body },
|
||||
);
|
||||
return pasteObservation;
|
||||
}
|
||||
|
||||
export async function prepareComposerMarksDraft({
|
||||
page,
|
||||
options,
|
||||
sessionId,
|
||||
threadId,
|
||||
prompt,
|
||||
}) {
|
||||
const beforeRead = await appServerCallFromPage(page, "thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`);
|
||||
const pasteObservation = await pasteComposerMarksDraft({
|
||||
page,
|
||||
sessionId,
|
||||
prompt,
|
||||
});
|
||||
const pickerBoundary = await verifyComposerMarksCodePicker({
|
||||
page,
|
||||
options,
|
||||
sessionId,
|
||||
threadId,
|
||||
body: CODE,
|
||||
expectedText: `${prompt}\n中😀\n${CODE}\n尾中😀`,
|
||||
});
|
||||
const initial = await readComposerMarksSnapshot(page, sessionId);
|
||||
const preparation = {
|
||||
ok:
|
||||
@@ -337,28 +419,14 @@ export async function prepareComposerMarksDraft({
|
||||
preparation.ok,
|
||||
`Composer HTML marks preparation 失败: ${JSON.stringify(preparation)}`,
|
||||
);
|
||||
await editor.evaluate((node) => {
|
||||
const code = node.querySelector("pre[data-composer-code-block] > code");
|
||||
const texts = Array.from(code?.childNodes ?? []).filter(
|
||||
(child) => child.nodeType === 3,
|
||||
);
|
||||
if (!texts.length) throw new Error("code selection 缺少实际 text node");
|
||||
const text = texts.at(-1);
|
||||
node.focus();
|
||||
const range = document.createRange();
|
||||
range.setStart(text, text.nodeValue.length);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
await editor.evaluate(placeComposerCodeCaretAtEndDom);
|
||||
await page.waitForFunction(
|
||||
({ selector }) => {
|
||||
const editor = document.querySelector(selector);
|
||||
const selection = window.getSelection();
|
||||
const anchor = selection?.anchorNode;
|
||||
const code = anchor?.parentElement?.closest(
|
||||
"pre[data-composer-code-block] > code",
|
||||
'div[data-composer-code-block="true"] > pre > code',
|
||||
);
|
||||
return (
|
||||
!!code &&
|
||||
@@ -431,7 +499,7 @@ export async function prepareComposerMarksDraft({
|
||||
{ selector: composerEditorSelector(sessionId) },
|
||||
{ timeout: Math.min(options.timeoutMs, 10_000) },
|
||||
);
|
||||
return { preparation, codeBoundary };
|
||||
return { preparation, pickerBoundary, codeBoundary };
|
||||
}
|
||||
|
||||
export function summarizeComposerMarksCancel({
|
||||
@@ -470,6 +538,9 @@ export function buildComposerMarksScenarioAssertions(summary) {
|
||||
return {
|
||||
inputbarRichRestoreMarksPrepared:
|
||||
summary.inputbarRichRestoreDraftPrepared?.marks?.preparation?.ok === true,
|
||||
inputbarRichRestoreCodePickerEditingOnly:
|
||||
summary.inputbarRichRestoreDraftPrepared?.marks?.pickerBoundary?.ok ===
|
||||
true,
|
||||
inputbarRichRestoreCodeEditingOnly:
|
||||
summary.inputbarRichRestoreDraftPrepared?.marks?.codeBoundary?.ok ===
|
||||
true,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
takeComposerCodeKeyboardEventsDom,
|
||||
summarizeComposerCodeBoundary,
|
||||
summarizeComposerMarksCancel,
|
||||
placeComposerCodeCaretAtEndDom,
|
||||
buildComposerMarksScenarioAssertions,
|
||||
} from "./claw-chat-current-fixture-composer-marks.mjs";
|
||||
|
||||
const PROMPT =
|
||||
"请结合这个截图、文件和 Capability Report 技能,先不要输出正文。";
|
||||
const PASTE = "历史粘贴验证😀\n".repeat(180);
|
||||
const BODY = " 中😀\n````";
|
||||
const BODY = " const value = '中😀';\n````";
|
||||
const EXPECTED_MARKS = {
|
||||
links: [
|
||||
{
|
||||
@@ -30,13 +32,14 @@ const EXPECTED_MARKS = {
|
||||
],
|
||||
codeBlocks: [
|
||||
{
|
||||
language: "ts",
|
||||
language: "typescript",
|
||||
body: `${BODY}\n\n`,
|
||||
validStructure: true,
|
||||
hardBreakCount: 0,
|
||||
keywordTokens: ["const"],
|
||||
},
|
||||
],
|
||||
blockTags: ["P", "P", "PRE", "P"],
|
||||
blockTags: ["P", "P", "DIV", "P"],
|
||||
};
|
||||
|
||||
function oracle() {
|
||||
@@ -104,8 +107,17 @@ function editor() {
|
||||
node.setAttribute("data-session-id", "marks-session");
|
||||
node.setAttribute("contenteditable", "true");
|
||||
node.innerHTML =
|
||||
'<p>prompt</p><p><span text-link-href="https://example.invalid/marks" text-link-title="标题" role="link" aria-disabled="false">中😀</span></p><pre data-composer-code-block=""><code data-language="ts"></code></pre><p>tail</p>';
|
||||
node.querySelector("code").textContent = BODY + "\n\n";
|
||||
'<p>prompt</p><p><span text-link-href="https://example.invalid/marks" text-link-title="标题" role="link" aria-disabled="false">中😀</span></p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false">TypeScript</div><pre dir="ltr"><code class="hljs language-typescript"></code></pre></div><p>tail</p>';
|
||||
const code = node.querySelector("code");
|
||||
code.append(document.createTextNode(" "));
|
||||
const keyword = document.createElement("span");
|
||||
keyword.className = "hljs-keyword";
|
||||
keyword.textContent = "const";
|
||||
code.append(keyword, document.createTextNode(" value = "));
|
||||
const string = document.createElement("span");
|
||||
string.className = "hljs-string";
|
||||
string.textContent = "'中😀'";
|
||||
code.append(string, document.createTextNode(";\n````\n\n"));
|
||||
document.body.append(node);
|
||||
vi.spyOn(node, "getClientRects").mockReturnValue([{}]);
|
||||
return node;
|
||||
@@ -123,27 +135,29 @@ describe("independent marks Markdown/UTF8 fixture", () => {
|
||||
const expected = oracle();
|
||||
expect(expected.marks).toEqual(EXPECTED_MARKS);
|
||||
expect(expected.text).toContain(
|
||||
'[中😀](https://example.invalid/marks "标题")\n`````ts\n 中😀\n````\n\n\n`````\n尾中😀 ',
|
||||
"[中😀](https://example.invalid/marks \"标题\")\n`````typescript\n const value = '中😀';\n````\n\n\n`````\n尾中😀 ",
|
||||
);
|
||||
const bytes = Buffer.from(expected.text, "utf8");
|
||||
expect(bytes.length).toBe(4361);
|
||||
expect(bytes.length).toBe(4386);
|
||||
expect(expected.textElements).toEqual([
|
||||
{
|
||||
byteRange: { start: 197, end: 4337 },
|
||||
byteRange: { start: 222, end: 4362 },
|
||||
placeholder: "[Pasted text 1: 1620 characters]",
|
||||
},
|
||||
{
|
||||
byteRange: { start: 4338, end: 4361 },
|
||||
byteRange: { start: 4363, end: 4386 },
|
||||
placeholder: "/user:capability-report",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
createHash("sha256").update(bytes.subarray(197, 4337)).digest("hex"),
|
||||
createHash("sha256").update(bytes.subarray(222, 4362)).digest("hex"),
|
||||
).toBe("efe7ffb00ece5294d1cd2b7cac4533861af4b728d7988212acc3a9551b98cb0a");
|
||||
expect(bytes.subarray(4338, 4361).toString("utf8")).toBe(
|
||||
expect(bytes.subarray(4363, 4386).toString("utf8")).toBe(
|
||||
"/user:capability-report",
|
||||
);
|
||||
expect(expected.displayText).toContain("中😀\n 中😀\n````\n\n\n尾中😀");
|
||||
expect(expected.displayText).toContain(
|
||||
"中😀\n const value = '中😀';\n````\n\n\n尾中😀",
|
||||
);
|
||||
expect(expected.displayText).not.toContain("https://");
|
||||
});
|
||||
});
|
||||
@@ -171,6 +185,10 @@ describe("actual rendered marks semantics", () => {
|
||||
"wrapper",
|
||||
"missing-link",
|
||||
"missing-code",
|
||||
"old-dom",
|
||||
"multiple-languages",
|
||||
"fake-highlight",
|
||||
"toolbar-body",
|
||||
])("observes %s drift that plain text would hide", (mode) => {
|
||||
const node = editor();
|
||||
const link = node.querySelector("span");
|
||||
@@ -182,7 +200,19 @@ describe("actual rendered marks semantics", () => {
|
||||
if (mode === "generated")
|
||||
link.setAttribute("data-rich-text-generated-autolink", "true");
|
||||
if (mode === "disabled") link.setAttribute("aria-disabled", "true");
|
||||
if (mode === "language") code.setAttribute("data-language", "js");
|
||||
if (mode === "language") code.className = "hljs language-javascript";
|
||||
if (mode === "multiple-languages")
|
||||
code.classList.add("language-javascript");
|
||||
if (mode === "fake-highlight")
|
||||
code.querySelector(".hljs-keyword").className = "hljs-string";
|
||||
if (mode === "toolbar-body")
|
||||
code.prepend(document.createTextNode("TypeScript"));
|
||||
if (mode === "old-dom") {
|
||||
const pre = code.parentElement;
|
||||
pre.setAttribute("data-composer-code-block", "true");
|
||||
code.setAttribute("data-language", "typescript");
|
||||
pre.parentElement.replaceWith(pre);
|
||||
}
|
||||
if (mode === "body") code.textContent = code.textContent.trim();
|
||||
if (mode === "hard_break") code.append(document.createElement("br"));
|
||||
if (mode === "wrapper") code.innerHTML = "<span> 中😀\n````\n\n</span>";
|
||||
@@ -221,15 +251,28 @@ describe("actual rendered marks semantics", () => {
|
||||
});
|
||||
|
||||
describe("passive code keyboard evidence", () => {
|
||||
it("places caret at the final actual text leaf across nested highlight spans", () => {
|
||||
const node = editor();
|
||||
const code = node.querySelector("code");
|
||||
const nested = document.createElement("span");
|
||||
nested.className = "hljs-string";
|
||||
nested.innerHTML = '<span class="hljs-subst"></span>';
|
||||
nested.firstChild.textContent = code.lastChild.nodeValue;
|
||||
code.lastChild.replaceWith(nested);
|
||||
placeComposerCodeCaretAtEndDom(node);
|
||||
expect(window.getSelection().anchorNode).toBe(nested.firstChild.firstChild);
|
||||
expect(window.getSelection().anchorOffset).toBe(
|
||||
nested.firstChild.textContent.length,
|
||||
);
|
||||
expect(readComposerMarksDom(node)).toEqual(EXPECTED_MARKS);
|
||||
});
|
||||
it("observes consumed keys after the EditorView handler and keeps synthetic trust false", () => {
|
||||
const node = editor();
|
||||
node.focus();
|
||||
const text = node.querySelector("code").firstChild;
|
||||
const range = document.createRange();
|
||||
range.setStart(text, text.length);
|
||||
range.collapse(true);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
placeComposerCodeCaretAtEndDom(node);
|
||||
const text = node.querySelector("code").lastChild;
|
||||
expect(window.getSelection().anchorNode).toBe(text);
|
||||
expect(window.getSelection().anchorOffset).toBe(text.nodeValue.length);
|
||||
node.addEventListener("keydown", (event) => event.preventDefault());
|
||||
installComposerCodeKeyboardObserverDom(node);
|
||||
for (const shiftKey of [false, true])
|
||||
@@ -380,3 +423,24 @@ describe("cancel restoration independent semantics", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rich fixture language Picker summary", () => {
|
||||
it("cannot accept marks preparation without the editing-only Picker boundary", () => {
|
||||
const summary = {
|
||||
inputbarRichRestoreDraftPrepared: {
|
||||
marks: { preparation: { ok: true }, codeBoundary: { ok: true } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
buildComposerMarksScenarioAssertions(summary)
|
||||
.inputbarRichRestoreCodePickerEditingOnly,
|
||||
).toBe(false);
|
||||
summary.inputbarRichRestoreDraftPrepared.marks.pickerBoundary = {
|
||||
ok: true,
|
||||
};
|
||||
expect(
|
||||
buildComposerMarksScenarioAssertions(summary)
|
||||
.inputbarRichRestoreCodePickerEditingOnly,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,14 +148,12 @@ export const PLAN_PROMPT = "先给我一个修复计划,不要直接改代码"
|
||||
export const GOAL_PROMPT = "本周完成 Goal E2E 修复";
|
||||
export const INPUTBAR_RICH_RESTORE_PROMPT =
|
||||
"请结合这个截图、文件和 Capability Report 技能,先不要输出正文。";
|
||||
export const INPUTBAR_RICH_RESTORE_PATH_NAME =
|
||||
"clawstream-rich-restore-fixture.md";
|
||||
export const INPUTBAR_RICH_RESTORE_PATH_NAME = "composer-reference.md";
|
||||
export const INPUTBAR_RICH_RESTORE_PATH = path.join(
|
||||
process.cwd(),
|
||||
"internal",
|
||||
"roadmap",
|
||||
"test",
|
||||
"clawstream",
|
||||
"scripts",
|
||||
"agent-runtime",
|
||||
"fixtures",
|
||||
INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
);
|
||||
export const INPUTBAR_RICH_RESTORE_SKILL_NAME = "Capability Report";
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
atomKey,
|
||||
assertRestoredDom,
|
||||
assertRestoredDraft,
|
||||
assertRichDraft,
|
||||
assertWorkspaceIndex,
|
||||
requireDraftProof,
|
||||
savedDraft,
|
||||
sha256,
|
||||
summarizeDraft,
|
||||
} from "../electron/lib/composer-draft-fixture-evidence.mjs";
|
||||
|
||||
export const markers = ["GUI工作区一号", "GUI工作区二号", "GUI工作区三号"];
|
||||
export const nextEdit = "Accepted 后的新编辑,旧回合不得清除。";
|
||||
|
||||
export async function until(context, read, ready, phase) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < context.options.timeoutMs) {
|
||||
const value = await read();
|
||||
if (ready(value)) return value;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, context.options.intervalMs),
|
||||
);
|
||||
}
|
||||
throw new Error(`Composer draft GUI fixture timed out: ${phase}`);
|
||||
}
|
||||
|
||||
export function editor(context, record) {
|
||||
return context.page.locator(
|
||||
`${context.modules.dom.composerEditorSelector(record.sessionId)}:visible`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readScope(context, scope) {
|
||||
const observed = await context.modules.atom.observePersistedAtomCommand(
|
||||
context.page,
|
||||
context.protocol,
|
||||
"persisted_atom_read",
|
||||
{ key: atomKey, query: { kind: "scope", scope } },
|
||||
{ timeoutMs: context.options.timeoutMs },
|
||||
);
|
||||
return observed.result;
|
||||
}
|
||||
|
||||
export async function waitSaved(context, record) {
|
||||
await context.page
|
||||
.locator(
|
||||
'[data-testid="composer-draft-status"][data-state="saved"]:visible',
|
||||
)
|
||||
.waitFor({ timeout: context.options.timeoutMs });
|
||||
return until(
|
||||
context,
|
||||
() => readScope(context, record.scope),
|
||||
(result) => {
|
||||
try {
|
||||
savedDraft(result, record.scope);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"native savedRevision and GUI saved status",
|
||||
);
|
||||
}
|
||||
|
||||
async function readThread(context, record) {
|
||||
const read = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"thread/read",
|
||||
{ threadId: record.threadId, includeTurns: true },
|
||||
);
|
||||
requireDraftProof(
|
||||
read.result?.thread?.id === record.threadId &&
|
||||
read.result.thread.sessionId === record.sessionId &&
|
||||
Array.isArray(read.result.thread.turns),
|
||||
"canonical Thread/session read model identity",
|
||||
);
|
||||
return read.result.thread;
|
||||
}
|
||||
|
||||
/** Preferences assist navigation only; no Controller, draft or fake workspace state. */
|
||||
async function selectWorkspacePreferences(context, record) {
|
||||
await context.page.evaluate(
|
||||
({ workspaceId, sessionId, provider, model }) => {
|
||||
const storage = window.localStorage;
|
||||
const projects = JSON.parse(
|
||||
storage.getItem("agent_opened_project_ids") || "[]",
|
||||
);
|
||||
const opened = [...new Set([...projects, workspaceId])];
|
||||
storage.setItem("agent_last_project_id", JSON.stringify(workspaceId));
|
||||
storage.setItem("agent_opened_project_ids", JSON.stringify(opened));
|
||||
for (const suffix of [workspaceId, "global"]) {
|
||||
storage.setItem(
|
||||
`agent_pref_provider_${suffix}`,
|
||||
JSON.stringify(provider),
|
||||
);
|
||||
storage.setItem(`agent_pref_model_${suffix}`, JSON.stringify(model));
|
||||
storage.setItem(`agent_pref_migrated_${suffix}`, "true");
|
||||
storage.setItem(
|
||||
`agent_topic_model_pref_${suffix}_${sessionId}`,
|
||||
JSON.stringify({ providerType: provider, model }),
|
||||
);
|
||||
}
|
||||
storage.setItem(
|
||||
`agent_session_workspace_${sessionId}`,
|
||||
JSON.stringify(workspaceId),
|
||||
);
|
||||
storage.setItem(
|
||||
`agent_execution_strategy_${workspaceId}`,
|
||||
JSON.stringify("react"),
|
||||
);
|
||||
storage.setItem(
|
||||
`agent_access_mode_${workspaceId}`,
|
||||
JSON.stringify("full-access"),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-persisted-project-id-changed", {
|
||||
detail: { projectId: workspaceId, key: "agent_last_project_id" },
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-opened-project-ids-changed", {
|
||||
detail: { projectIds: opened },
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("lime:agent-runtime-sessions-changed", {
|
||||
detail: { reason: "external", sessionId, workspaceId },
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
},
|
||||
{
|
||||
...record,
|
||||
provider: context.modules.constants.FIXTURE_PROVIDER,
|
||||
model: context.modules.constants.FIXTURE_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRecord(context, record) {
|
||||
await selectWorkspacePreferences(context, record);
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.page.evaluate(({ sessionId, workspaceName }) => {
|
||||
const headers = Array.from(
|
||||
document.querySelectorAll(
|
||||
'[data-testid="app-sidebar-project-conversation-group"] button[aria-expanded]',
|
||||
),
|
||||
);
|
||||
const header = headers.find(
|
||||
(button) => button.getAttribute("title") === workspaceName,
|
||||
);
|
||||
if (header?.getAttribute("aria-expanded") === "false") header.click();
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'[data-testid="app-sidebar-conversation-open"]',
|
||||
),
|
||||
).some(
|
||||
(button) => button.getAttribute("data-session-id") === sessionId,
|
||||
);
|
||||
}, record),
|
||||
(visible) => visible === true,
|
||||
"exact real sidebar conversation row; project header is not a conversation",
|
||||
);
|
||||
await context.modules.sessions.openSessionFromSidebar(
|
||||
context.page,
|
||||
context.options,
|
||||
undefined,
|
||||
record,
|
||||
);
|
||||
requireDraftProof(
|
||||
(await editor(context, record).count()) === 1,
|
||||
"real sidebar committed one same-session visible Composer",
|
||||
);
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
),
|
||||
(snapshot) =>
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === record.sessionId &&
|
||||
!snapshot.disabled,
|
||||
"same-session Composer is editable after committed draft hydration",
|
||||
);
|
||||
return readThread(context, record);
|
||||
}
|
||||
|
||||
export async function createRecord(context, { rootPath, title, marker }) {
|
||||
const workspaceName = `Draft workspace ${marker}`;
|
||||
const workspace = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"workspace/ensure",
|
||||
{ name: workspaceName, rootPath },
|
||||
);
|
||||
const item = workspace.result?.workspace;
|
||||
requireDraftProof(
|
||||
typeof item?.id === "string" && item.id.length > 0,
|
||||
"workspace/ensure current directory owner returns real identity",
|
||||
);
|
||||
const start = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"thread/start",
|
||||
{
|
||||
cwd: rootPath,
|
||||
runtimeWorkspaceRoots: [rootPath],
|
||||
historyMode: "paginated",
|
||||
serviceName: title,
|
||||
threadSource: "appServer",
|
||||
model: context.modules.constants.FIXTURE_MODEL,
|
||||
modelProvider: context.modules.constants.FIXTURE_PROVIDER,
|
||||
},
|
||||
);
|
||||
const thread = start.result?.thread;
|
||||
requireDraftProof(
|
||||
typeof thread?.id === "string" &&
|
||||
thread.id.length > 0 &&
|
||||
typeof thread.sessionId === "string" &&
|
||||
thread.sessionId.length > 0,
|
||||
"thread/start current canonical identity",
|
||||
);
|
||||
return {
|
||||
title,
|
||||
workspaceName,
|
||||
marker,
|
||||
rootPath,
|
||||
workspaceId: item.id,
|
||||
sessionId: thread.sessionId,
|
||||
threadId: thread.id,
|
||||
scope: { workspaceId: item.id, kind: "local", id: thread.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
async function selectMarker(context, record) {
|
||||
const locator = editor(context, record);
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
),
|
||||
(snapshot) =>
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === record.sessionId &&
|
||||
!snapshot.disabled,
|
||||
"same-session Composer is editable before trusted selection keys",
|
||||
);
|
||||
await locator.focus();
|
||||
await locator.press("ControlOrMeta+End");
|
||||
for (let index = 0; index < 3; index += 1)
|
||||
await locator.press("Shift+ArrowLeft");
|
||||
const selection = await locator.evaluate((node) => {
|
||||
const current = window.getSelection();
|
||||
return {
|
||||
text: current?.toString(),
|
||||
inside:
|
||||
node.contains(current?.anchorNode) && node.contains(current?.focusNode),
|
||||
};
|
||||
});
|
||||
requireDraftProof(
|
||||
selection.inside && selection.text === record.marker.slice(-3),
|
||||
"trusted keyboard creates noncollapsed same-editor selection",
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareRecord(context, record) {
|
||||
context.setPhase?.("open-existing-local-session");
|
||||
await openRecord(context, record);
|
||||
const original = await prepareVisibleDraft(context, record, async () => {
|
||||
await context.modules.marks.prepareComposerMarksDraft({
|
||||
page: context.page,
|
||||
options: context.options,
|
||||
sessionId: record.sessionId,
|
||||
threadId: record.threadId,
|
||||
prompt: context.expected(record).prompt,
|
||||
});
|
||||
});
|
||||
const thread = await readThread(context, record);
|
||||
requireDraftProof(
|
||||
thread.turns.length === 0,
|
||||
"editing creates no canonical turn",
|
||||
);
|
||||
return original;
|
||||
}
|
||||
|
||||
async function prepareVisibleMarks(context, record) {
|
||||
const codeBody = " const value = '中😀';\n````";
|
||||
const paste = await context.modules.marks.pasteComposerMarksDraft({
|
||||
page: context.page,
|
||||
sessionId: record.sessionId,
|
||||
prompt: context.expected(record).prompt,
|
||||
});
|
||||
requireDraftProof(
|
||||
paste.trusted === false &&
|
||||
paste.defaultPrevented === true &&
|
||||
JSON.stringify(paste.formats) ===
|
||||
JSON.stringify(["text/html", "text/plain"]),
|
||||
"real HTML paste ingress, no OS clipboard claim",
|
||||
);
|
||||
const locator = editor(context, record);
|
||||
await locator.locator(".composer-code-language-trigger").click();
|
||||
const menu = context.page.locator(".composer-code-language-menu:visible");
|
||||
await menu.getByRole("combobox").fill("TypeScript");
|
||||
await menu.locator('[data-code-language="typescript"]').click();
|
||||
// Public DOM Selection plus trusted keys; no EditorState or transaction injection.
|
||||
await locator.evaluate(context.modules.marks.placeComposerCodeCaretAtEndDom);
|
||||
for (const [index, key] of ["Enter", "Shift+Enter"].entries()) {
|
||||
await locator.press(key);
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
),
|
||||
(snapshot) =>
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === record.sessionId &&
|
||||
snapshot.marks?.codeBlocks?.length === 1 &&
|
||||
snapshot.marks.codeBlocks[0].validStructure &&
|
||||
snapshot.marks.codeBlocks[0].language === "typescript" &&
|
||||
snapshot.marks.codeBlocks[0].body === codeBody + "\n".repeat(index + 1),
|
||||
"trusted code editing retains same-identity rich document",
|
||||
);
|
||||
}
|
||||
await locator.locator("p").last().click();
|
||||
}
|
||||
|
||||
/** Caller verifies public surface identity; this helper touches only visible GUI ingress. */
|
||||
export async function prepareVisibleDraft(
|
||||
context,
|
||||
record,
|
||||
prepareMarks = null,
|
||||
) {
|
||||
requireDraftProof(
|
||||
(await editor(context, record).count()) === 1,
|
||||
"one identity-verified visible Composer before rich editing",
|
||||
);
|
||||
const expected = context.expected(record);
|
||||
context.setPhase?.("prepare-rich-code-and-marks");
|
||||
await (prepareMarks ? prepareMarks() : prepareVisibleMarks(context, record));
|
||||
await context.modules.history.prepareHistoryPaste(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
expected.pasteText,
|
||||
);
|
||||
context.setPhase?.("select-installed-skill");
|
||||
await editor(context, record).focus();
|
||||
await editor(context, record).press("ControlOrMeta+End");
|
||||
await context.page.keyboard.insertText(" /capability-report");
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.page.evaluate(() => {
|
||||
const popover = document.querySelector(
|
||||
'[data-testid="mention-popover-content"]',
|
||||
);
|
||||
const target = Array.from(
|
||||
popover?.querySelectorAll('[cmdk-item], [role="option"]') ?? [],
|
||||
).find(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
node.getClientRects().length > 0 &&
|
||||
node.getAttribute("aria-disabled") !== "true" &&
|
||||
String(node.textContent)
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\s_-]+/g, "")
|
||||
.includes("capabilityreport"),
|
||||
);
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
target.click();
|
||||
return true;
|
||||
}),
|
||||
(selected) => selected === true,
|
||||
"real installed-skill popup selection",
|
||||
);
|
||||
context.setPhase?.("attach-image-through-filechooser");
|
||||
await context.modules.image.attachComposerImage(context.page, {
|
||||
sessionId: record.sessionId,
|
||||
imageButtonName: "添加图片",
|
||||
timeoutMs: context.options.timeoutMs,
|
||||
image: {
|
||||
name: "composer-persistence.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(expected.imageBase64, "base64"),
|
||||
},
|
||||
});
|
||||
context.setPhase?.("drop-temporary-workspace-file");
|
||||
const dropped = await editor(context, record).evaluate(
|
||||
(node, reference) => {
|
||||
const target = node.closest('[data-testid="inputbar-core-container"]');
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData(
|
||||
"application/x-lime-path-reference",
|
||||
JSON.stringify([reference]),
|
||||
);
|
||||
dataTransfer.setData("text/plain", reference.path);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("dragover", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
}),
|
||||
);
|
||||
const event = new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
},
|
||||
{
|
||||
id: `file:${record.path}`,
|
||||
path: record.path,
|
||||
name: record.pathName,
|
||||
isDir: false,
|
||||
size: 128,
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
);
|
||||
requireDraftProof(
|
||||
dropped === true,
|
||||
"temporary real file enters through production path drop",
|
||||
);
|
||||
await until(
|
||||
context,
|
||||
() =>
|
||||
context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
),
|
||||
(snapshot) =>
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === record.sessionId &&
|
||||
snapshot.images.length === 1 &&
|
||||
snapshot.images[0].decoded &&
|
||||
snapshot.paths.length === 1 &&
|
||||
snapshot.paths[0].path === record.path &&
|
||||
snapshot.tokens.some((token) => token.id === "user:capability-report"),
|
||||
"rich GUI resources and installed-skill token visible",
|
||||
);
|
||||
await editor(context, record).focus();
|
||||
await editor(context, record).press("ControlOrMeta+End");
|
||||
await context.page.keyboard.insertText(` ${record.marker}`);
|
||||
context.setPhase?.("save-complete-rich-draft-and-selection");
|
||||
await selectMarker(context, record);
|
||||
const result = await waitSaved(context, record);
|
||||
const entry = savedDraft(result, record.scope);
|
||||
assertRichDraft(entry, context.expected(record));
|
||||
const dom = await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
);
|
||||
assertRestoredDom(dom, dom, record.sessionId);
|
||||
return { entry, dom, revision: result.storeRevision };
|
||||
}
|
||||
|
||||
export async function verifyRecord(context, record, original) {
|
||||
await openRecord(context, record);
|
||||
return verifyVisibleDraft(context, record, original);
|
||||
}
|
||||
|
||||
export async function verifyVisibleDraft(context, record, original) {
|
||||
requireDraftProof(
|
||||
(await editor(context, record).count()) === 1,
|
||||
"one identity-verified visible Composer before restore observation",
|
||||
);
|
||||
const result = await waitSaved(context, record);
|
||||
const json = assertRestoredDraft(result, record.scope, original.entry);
|
||||
// Focusing an already restored PM selection preserves the selection; no state write.
|
||||
await editor(context, record).focus();
|
||||
const selected = await editor(context, record).evaluate(() =>
|
||||
window.getSelection()?.toString(),
|
||||
);
|
||||
const expectedSelection = original.entry.draft.document;
|
||||
requireDraftProof(
|
||||
selected ===
|
||||
expectedSelection.text.slice(
|
||||
expectedSelection.selectionStart,
|
||||
expectedSelection.selectionEnd,
|
||||
),
|
||||
"DOM selection restored from durable JSON",
|
||||
);
|
||||
const dom = await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
);
|
||||
const rendered = assertRestoredDom(dom, original.dom, record.sessionId);
|
||||
return { json, rendered, revision: result.storeRevision };
|
||||
}
|
||||
|
||||
export async function readIndex(context, record, entry) {
|
||||
const observed = await context.modules.atom.observePersistedAtomCommand(
|
||||
context.page,
|
||||
context.protocol,
|
||||
"persisted_atom_read",
|
||||
{
|
||||
key: atomKey,
|
||||
query: { kind: "workspace-index", workspaceId: record.workspaceId },
|
||||
},
|
||||
);
|
||||
return assertWorkspaceIndex(
|
||||
observed.result,
|
||||
record.workspaceId,
|
||||
record.scope,
|
||||
entry,
|
||||
);
|
||||
}
|
||||
|
||||
async function paste(context, record, text) {
|
||||
await editor(context, record).focus();
|
||||
await editor(context, record).press("ControlOrMeta+End");
|
||||
await editor(context, record).evaluate((node, text) => {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", text);
|
||||
const event = new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: data,
|
||||
});
|
||||
node.dispatchEvent(event);
|
||||
if (!event.defaultPrevented)
|
||||
throw new Error("real EditorView paste was not handled");
|
||||
}, text);
|
||||
}
|
||||
|
||||
export async function verifyRejectedSubmission(context, record, recordStage) {
|
||||
const baseline = savedDraft(await waitSaved(context, record), record.scope);
|
||||
assertRichDraft(baseline, context.expected(record));
|
||||
const input = "x".repeat((1 << 20) + 1);
|
||||
await paste(context, record, input);
|
||||
const before = savedDraft(await waitSaved(context, record), record.scope);
|
||||
requireDraftProof(
|
||||
before.draft.document.pendingPastes.some((paste) => paste.text === input),
|
||||
"oversize input is a real saved hidden paste",
|
||||
);
|
||||
await editor(context, record)
|
||||
.locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]')
|
||||
.locator('[data-testid="send-btn"]')
|
||||
.click();
|
||||
await until(
|
||||
context,
|
||||
() => context.page.evaluate(() => document.body.innerText),
|
||||
(text) =>
|
||||
/Input exceeds|maximum length|输入.{0,20}(最大|长度|限制)|輸入.{0,20}(最大|長度|限制)/u.test(
|
||||
text,
|
||||
),
|
||||
"user-visible oversized input rejection",
|
||||
);
|
||||
const thread = await readThread(context, record);
|
||||
requireDraftProof(
|
||||
thread.turns.length === 0,
|
||||
"rejected GUI input creates no canonical Turn",
|
||||
);
|
||||
const after = savedDraft(await waitSaved(context, record), record.scope);
|
||||
requireDraftProof(
|
||||
isDeepStrictEqual(after, before),
|
||||
"failure preserves exact rich draft and all resources",
|
||||
);
|
||||
recordStage?.("rejection-exact-preservation-before-undo", {
|
||||
noCanonicalTurn: true,
|
||||
fullRichDraftPreserved: true,
|
||||
before: summarizeDraft(before),
|
||||
after: summarizeDraft(after),
|
||||
});
|
||||
await editor(context, record).press("ControlOrMeta+z");
|
||||
await until(
|
||||
context,
|
||||
() => readScope(context, record.scope),
|
||||
(result) =>
|
||||
result.data?.view?.entry?.state === "draft" &&
|
||||
!result.data.view.entry.draft.document.pendingPastes.some(
|
||||
(paste) => paste.text === input,
|
||||
),
|
||||
"trusted Undo removes only the oversized source",
|
||||
);
|
||||
const undoDom = await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
);
|
||||
recordStage?.("rejection-after-trusted-undo", {
|
||||
nativeDraft: summarizeDraft(
|
||||
savedDraft(await waitSaved(context, record), record.scope),
|
||||
),
|
||||
dom: {
|
||||
editorCount: undoDom.editorCount,
|
||||
sameSession: undoDom.sessionId === record.sessionId,
|
||||
disabled: undoDom.disabled,
|
||||
textLength: undoDom.text?.length ?? null,
|
||||
codeBlocks: undoDom.marks?.codeBlocks?.length ?? 0,
|
||||
links: undoDom.marks?.links?.length ?? 0,
|
||||
tokenKinds: undoDom.tokens?.map((token) => token.type),
|
||||
images: undoDom.images?.length ?? 0,
|
||||
paths: undoDom.paths?.length ?? 0,
|
||||
},
|
||||
});
|
||||
const clean = savedDraft(await waitSaved(context, record), record.scope);
|
||||
requireDraftProof(
|
||||
isDeepStrictEqual(clean.draft, baseline.draft),
|
||||
"trusted Undo restores the exact original rich draft and selection",
|
||||
);
|
||||
assertRichDraft(clean, context.expected(record));
|
||||
const dom = await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
record.sessionId,
|
||||
);
|
||||
return {
|
||||
evidence: {
|
||||
noCanonicalTurn: true,
|
||||
fullRichDraftPreserved: true,
|
||||
failedDraftSha256: sha256(before.draft),
|
||||
inputCharacters: input.length,
|
||||
rejectionLayer:
|
||||
"GUI send with canonical no-Turn proof; RPC admission requires trace review",
|
||||
undoUsesTrustedKeyboard: true,
|
||||
},
|
||||
original: { entry: clean, dom },
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAcceptedNewEdit(context, records, originals) {
|
||||
const [first, second] = records;
|
||||
await openRecord(context, first);
|
||||
await editor(context, first)
|
||||
.locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]')
|
||||
.locator('[data-testid="send-btn"]')
|
||||
.click();
|
||||
const thread = await until(
|
||||
context,
|
||||
() => readThread(context, first),
|
||||
(thread) => thread.turns.length === 1,
|
||||
"canonical accepted Turn",
|
||||
);
|
||||
const turn = thread.turns[0];
|
||||
requireDraftProof(
|
||||
typeof turn.id === "string" &&
|
||||
turn.id.length > 0 &&
|
||||
["inProgress", "in_progress"].includes(turn.status),
|
||||
"active canonical accepted receipt",
|
||||
);
|
||||
const users = (turn.items ?? []).filter(
|
||||
(item) => item.type === "userMessage",
|
||||
);
|
||||
requireDraftProof(
|
||||
users.length === 1 &&
|
||||
typeof users[0].id === "string" &&
|
||||
users[0].id.length > 0 &&
|
||||
Array.isArray(users[0].content),
|
||||
"accepted Thread/Turn has exactly one canonical user Item",
|
||||
);
|
||||
const text = users[0].content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
const expected = context.expected(first);
|
||||
requireDraftProof(
|
||||
text.includes(expected.prompt) &&
|
||||
text.includes(expected.pasteText) &&
|
||||
text.includes(first.marker) &&
|
||||
text.includes("const value = '中😀';"),
|
||||
"canonical user Item preserves literal rich input and expanded hidden paste",
|
||||
);
|
||||
await until(
|
||||
context,
|
||||
() => readScope(context, first.scope),
|
||||
(result) =>
|
||||
result.outcome === "ready" &&
|
||||
result.data?.queryKind === "scope" &&
|
||||
result.data.view.entry === null &&
|
||||
result.persistence?.state === "saved" &&
|
||||
result.persistence.savedRevision >= result.storeRevision,
|
||||
"accepted receipt consumes only submitted persistent identity",
|
||||
);
|
||||
const consumedIdentity = {
|
||||
draftId: originals[0].entry.draftId,
|
||||
scopeVersion: originals[0].entry.scopeVersion,
|
||||
};
|
||||
const consumption = await context.readConsumedDraft(consumedIdentity);
|
||||
await editor(context, first).fill(nextEdit);
|
||||
const newResult = await waitSaved(context, first);
|
||||
const newEntry = savedDraft(newResult, first.scope);
|
||||
requireDraftProof(
|
||||
newEntry.draft.document.text === nextEdit &&
|
||||
(newEntry.draftId !== originals[0].entry.draftId ||
|
||||
newEntry.scopeVersion !== originals[0].entry.scopeVersion),
|
||||
"new edit owns a newer saved identity after accepted consume",
|
||||
);
|
||||
const newDom = await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
first.sessionId,
|
||||
);
|
||||
await verifyRecord(context, second, originals[1]);
|
||||
// Public canonical interrupt while another workspace is visible. It must not
|
||||
// restore the old submitted body over either current workspace's newer draft.
|
||||
await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: first.threadId,
|
||||
turnId: turn.id,
|
||||
},
|
||||
);
|
||||
const terminal = await until(
|
||||
context,
|
||||
() => readThread(context, first),
|
||||
(value) =>
|
||||
["interrupted", "canceled", "cancelled"].includes(value.turns[0]?.status),
|
||||
"canonical interrupted Turn under foreign GUI scope",
|
||||
);
|
||||
await verifyRecord(context, second, originals[1]);
|
||||
const firstRead = await readScope(context, first.scope);
|
||||
assertRestoredDraft(firstRead, first.scope, newEntry);
|
||||
await verifyRecord(context, first, { entry: newEntry, dom: newDom });
|
||||
originals[0] = { entry: newEntry, dom: newDom };
|
||||
return {
|
||||
threadId: first.threadId,
|
||||
sessionId: first.sessionId,
|
||||
turnId: turn.id,
|
||||
terminalStatus: terminal.turns[0].status,
|
||||
newDraft: summarizeDraft(newEntry),
|
||||
consumption,
|
||||
userItemId: users[0].id,
|
||||
canonicalUserItemSha256: sha256(users[0]),
|
||||
newerEditPreserved: true,
|
||||
foreignWorkspacePreserved: true,
|
||||
exactPendingTokenRace: false,
|
||||
interruptSurface: "preload -> App Server turn/interrupt",
|
||||
canonicalTurnSha256: sha256(terminal.turns[0]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
assertFirstSendConsumed,
|
||||
assertFirstSendNewEdit,
|
||||
assertFirstSendReceipt,
|
||||
assertFirstSendResourceReceipt,
|
||||
assertFirstSendSavedFiles,
|
||||
assertFirstSendScopeObservation,
|
||||
assertFirstSendSource,
|
||||
assertFirstSendRouteCoverage,
|
||||
assertFirstSendTrace,
|
||||
assertUnboundScope,
|
||||
captureFirstSendResources,
|
||||
firstSendRoutes,
|
||||
newEditText,
|
||||
requireFirstSend,
|
||||
} from "../electron/lib/composer-first-send-evidence.mjs";
|
||||
|
||||
const markerByRoute = {
|
||||
home: "Home首发甲一号",
|
||||
"search-new": "搜索新建首发乙二号",
|
||||
"project-new": "项目新建首发丙三号",
|
||||
};
|
||||
|
||||
async function until(context, read, ready, phase) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < context.options.timeoutMs) {
|
||||
const value = await read();
|
||||
if (ready(value)) return value;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, context.options.intervalMs),
|
||||
);
|
||||
}
|
||||
throw new Error("Composer first-send fixture timed out: " + phase);
|
||||
}
|
||||
|
||||
async function canonicalThreads(context) {
|
||||
const response = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"thread/list",
|
||||
{ archived: false, limit: 100 },
|
||||
);
|
||||
requireFirstSend(
|
||||
Array.isArray(response.result?.data) && !response.result.nextCursor,
|
||||
"bounded complete canonical inventory through public RPC",
|
||||
);
|
||||
return response.result.data;
|
||||
}
|
||||
|
||||
async function readThread(context, threadId) {
|
||||
const response = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"thread/read",
|
||||
{ threadId, includeTurns: true },
|
||||
);
|
||||
requireFirstSend(
|
||||
response.result?.thread?.id === threadId,
|
||||
"canonical read identity",
|
||||
);
|
||||
return response.result.thread;
|
||||
}
|
||||
|
||||
async function visibleSurface(context) {
|
||||
return context.page.evaluate(
|
||||
context.modules.dom.withComposerEditorDom(() => {
|
||||
const editors = findVisibleComposerEditorsDom();
|
||||
const editor = editors.length === 1 ? editors[0] : null;
|
||||
return {
|
||||
electron: window.__LIME_ELECTRON__ === true,
|
||||
preload:
|
||||
typeof window.electronAPI?.invoke === "function" &&
|
||||
window.electronAPI.devBridgeFallback === false,
|
||||
editorCount: editors.length,
|
||||
text: editor ? readComposerEditorTextDom(editor) : null,
|
||||
sessionId: editor?.dataset.sessionId || null,
|
||||
disabled: editor
|
||||
? editor.getAttribute("aria-disabled") === "true" ||
|
||||
editor.getAttribute("contenteditable") !== "true"
|
||||
: true,
|
||||
emptyState: Boolean(
|
||||
document.querySelector('[data-testid="empty-state-first-screen"]'),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openRealHome(context, record) {
|
||||
await context.modules.rpc.bindGuiWorkspaceAndModelPreferences(
|
||||
context.page,
|
||||
record.workspaceId,
|
||||
);
|
||||
// Public Home delegates to startNewConversation with explicit projectless scope.
|
||||
await context.page.getByTestId("app-sidebar-home-button").click();
|
||||
await until(
|
||||
context,
|
||||
() => visibleSurface(context),
|
||||
(dom) =>
|
||||
dom.editorCount === 1 &&
|
||||
dom.sessionId === null &&
|
||||
dom.disabled === false &&
|
||||
dom.emptyState === true,
|
||||
"real Home button committed the new-task null-session surface",
|
||||
);
|
||||
}
|
||||
|
||||
async function openRealDraft(context, record, projectNew) {
|
||||
if (projectNew) {
|
||||
const bootstrap = await context.modules.drafts.openRecord(context, record);
|
||||
requireFirstSend(
|
||||
bootstrap.turns.length === 0,
|
||||
"navigation-only bootstrap thread has never been submitted",
|
||||
);
|
||||
const group = context.page
|
||||
.getByTestId("app-sidebar-project-conversation-group")
|
||||
.filter({
|
||||
has: context.page
|
||||
.locator("button[aria-expanded]")
|
||||
.and(context.page.getByTitle(record.workspaceName, { exact: true })),
|
||||
});
|
||||
requireFirstSend(
|
||||
(await group.count()) === 1,
|
||||
"exactly one genuine project group matches the canonical workspace name",
|
||||
);
|
||||
await group.locator("button[aria-expanded]").hover();
|
||||
await group.getByTestId("app-sidebar-project-new-conversation").click();
|
||||
} else {
|
||||
await openRealHome(context, record);
|
||||
// The search creation entry delegates to the same public fresh action.
|
||||
await context.page.getByTestId("app-sidebar-search-button").click();
|
||||
await context.page
|
||||
.getByTestId("app-sidebar-search-new-conversation")
|
||||
.click();
|
||||
}
|
||||
await until(
|
||||
context,
|
||||
() => visibleSurface(context),
|
||||
(value) =>
|
||||
value.editorCount === 1 &&
|
||||
value.sessionId === null &&
|
||||
value.disabled === false,
|
||||
"real public create entry exposes one null-session Composer",
|
||||
);
|
||||
return {
|
||||
...record,
|
||||
sessionId: null,
|
||||
threadId: null,
|
||||
workspaceId: projectNew ? record.workspaceId : null,
|
||||
scope: {
|
||||
workspaceId: projectNew ? record.workspaceId : null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
},
|
||||
navigationOnlyBootstrapSessionId: record.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSourceRecord(context, route) {
|
||||
const title = "Composer first-send " + context.options.runId + " " + route;
|
||||
const marker = markerByRoute[route];
|
||||
let record;
|
||||
if (route === "home" || route === "search-new") {
|
||||
const workspace = await context.modules.rpc.ensureDefaultWorkspace(
|
||||
context.page,
|
||||
);
|
||||
record = {
|
||||
title,
|
||||
marker,
|
||||
rootPath: workspace.rootPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
sessionId: null,
|
||||
threadId: null,
|
||||
scope: {
|
||||
workspaceId: workspace.workspaceId,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
},
|
||||
};
|
||||
if (route === "home") {
|
||||
await openRealHome(context, record);
|
||||
record.workspaceId = null;
|
||||
record.scope = {
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
};
|
||||
} else {
|
||||
record = await openRealDraft(context, record, false);
|
||||
}
|
||||
} else {
|
||||
const rootPath = path.join(
|
||||
context.runtimeEnv.tempRoot,
|
||||
"first-send-" + route,
|
||||
);
|
||||
await fs.mkdir(rootPath);
|
||||
const bootstrap = await context.modules.drafts.createRecord(context, {
|
||||
rootPath,
|
||||
title,
|
||||
marker,
|
||||
});
|
||||
record = await openRealDraft(context, bootstrap, route === "project-new");
|
||||
}
|
||||
record.path = path.join(
|
||||
context.runtimeEnv.tempRoot,
|
||||
"first-send-" + route + ".md",
|
||||
);
|
||||
record.pathName = path.basename(record.path);
|
||||
await fs.writeFile(
|
||||
record.path,
|
||||
"# First-send path reference\n" + marker + "\n",
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function readWorkspaceIndex(context, workspaceId) {
|
||||
const observed = await context.modules.atom.observePersistedAtomCommand(
|
||||
context.page,
|
||||
context.protocol,
|
||||
"persisted_atom_read",
|
||||
{
|
||||
key: "composer-drafts-v1",
|
||||
query: { kind: "workspace-index", workspaceId },
|
||||
},
|
||||
{ timeoutMs: context.options.timeoutMs },
|
||||
);
|
||||
return observed.result;
|
||||
}
|
||||
|
||||
async function observeNativeSource(context, route, record, expectedMarker) {
|
||||
return until(
|
||||
context,
|
||||
async () => {
|
||||
const workspaceIndex = await readWorkspaceIndex(
|
||||
context,
|
||||
record.workspaceId,
|
||||
);
|
||||
const kind = "home";
|
||||
const items = workspaceIndex.data?.items?.filter(
|
||||
(item) => item.scope?.kind === kind,
|
||||
);
|
||||
// Fail closed on absent or ambiguous metadata. No fallback scope/Thread.
|
||||
if (items?.length !== 1) return null;
|
||||
const scopeRead = await context.modules.drafts.readScope(
|
||||
context,
|
||||
items[0].scope,
|
||||
);
|
||||
return {
|
||||
workspaceId: record.workspaceId,
|
||||
workspaceIndex,
|
||||
scopeRead,
|
||||
expectedMarker,
|
||||
dom: await visibleSurface(context),
|
||||
};
|
||||
},
|
||||
(value) => {
|
||||
try {
|
||||
assertFirstSendScopeObservation(route, value ?? {});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"unique same-atom metadata and saved payload match the sole real Composer",
|
||||
);
|
||||
}
|
||||
|
||||
async function sourceBeforeSend(context, route, record) {
|
||||
// Typing must not create a canonical session before the public Send action.
|
||||
const beforeThreads = await canonicalThreads(context);
|
||||
const probe = "Scope probe " + context.options.runId + " " + route;
|
||||
await context.modules.dom.fillComposerEditor(context.page, probe, null);
|
||||
const discovered = await observeNativeSource(context, route, record, probe);
|
||||
requireFirstSend(
|
||||
discovered.dom.text === probe &&
|
||||
discovered.scopeRead.data.view.entry.draft.document.text === probe &&
|
||||
(record.scope === null ||
|
||||
isDeepStrictEqual(record.scope, discovered.scopeRead.data.view.scope)),
|
||||
"real probe matches exact visible and saved text before scope binding",
|
||||
);
|
||||
record.scope = discovered.scopeRead.data.view.scope;
|
||||
const original = await context.modules.drafts.prepareVisibleDraft(
|
||||
context,
|
||||
record,
|
||||
);
|
||||
await context.modules.drafts.verifyVisibleDraft(context, record, original);
|
||||
const observed = await observeNativeSource(
|
||||
context,
|
||||
route,
|
||||
record,
|
||||
record.marker,
|
||||
);
|
||||
const expected = context.expected(record);
|
||||
const skillPath = await fs.realpath(
|
||||
path.join(
|
||||
context.runtimeEnv.env.HOME,
|
||||
".agents",
|
||||
"skills",
|
||||
"capability-report",
|
||||
"SKILL.md",
|
||||
),
|
||||
);
|
||||
requireFirstSend(
|
||||
(await fs.stat(skillPath)).isFile(),
|
||||
"installed Skill identity points to the real fixture SKILL.md",
|
||||
);
|
||||
const skillIdentity = {
|
||||
name: "capability-report",
|
||||
key: "user:capability-report",
|
||||
path: skillPath,
|
||||
};
|
||||
const literal = context.modules.marks.composerMarksFixture({
|
||||
prompt: expected.prompt,
|
||||
skillIdentity,
|
||||
pasteText: expected.pasteText,
|
||||
});
|
||||
const expectedSubmission = {
|
||||
...literal,
|
||||
text: literal.text + " " + record.marker,
|
||||
displayText: literal.displayText + " " + record.marker,
|
||||
};
|
||||
requireFirstSend(
|
||||
original.dom.text === expectedSubmission.displayText,
|
||||
"independent fixed Markdown fixture matches the current rendered rich draft",
|
||||
);
|
||||
const resources = captureFirstSendResources(original.entry, {
|
||||
...expected,
|
||||
skillIdentity,
|
||||
expectedSubmission,
|
||||
skillFileSha256: createHash("sha256")
|
||||
.update(await fs.readFile(skillPath))
|
||||
.digest("hex"),
|
||||
});
|
||||
const preparedThreads = await canonicalThreads(context);
|
||||
return {
|
||||
proof: {
|
||||
...assertFirstSendSource(route, {
|
||||
...observed,
|
||||
scope: record.scope,
|
||||
entry: original.entry,
|
||||
beforeThreads,
|
||||
preparedThreads,
|
||||
}),
|
||||
scopeDiscovery: assertFirstSendScopeObservation(route, discovered),
|
||||
resources,
|
||||
},
|
||||
// Raw body/media stay local to this run; only resource metadata is logged.
|
||||
receiptExpected: {
|
||||
expectedSubmission,
|
||||
prompt: expected.prompt,
|
||||
editorText: original.dom.text,
|
||||
skillIdentity,
|
||||
skillToken: original.dom.tokens.find((token) => token.type === "skill"),
|
||||
pasteToken: original.dom.tokens.find((token) => token.type === "paste"),
|
||||
pasteText: expected.pasteText,
|
||||
imageBase64: expected.imageBase64,
|
||||
pathReference: { name: record.pathName, path: record.path },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function clickSendAndReadReceipt(
|
||||
context,
|
||||
record,
|
||||
source,
|
||||
receiptExpected,
|
||||
) {
|
||||
await context.page
|
||||
.locator(context.modules.dom.composerEditorSelector() + ":visible")
|
||||
.locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]')
|
||||
.getByTestId("send-btn")
|
||||
.click();
|
||||
const dom = await until(
|
||||
context,
|
||||
() => visibleSurface(context),
|
||||
(value) => value.editorCount === 1 && value.sessionId && !value.disabled,
|
||||
"accepted ready callback exposes the genuine local Composer",
|
||||
);
|
||||
const threads = await canonicalThreads(context);
|
||||
const thread = threads.find((item) => item.sessionId === dom.sessionId);
|
||||
requireFirstSend(thread, "visible accepted session has a canonical thread");
|
||||
const expected = context.expected(record);
|
||||
const fragments = [
|
||||
expected.prompt,
|
||||
record.marker,
|
||||
expected.pasteText,
|
||||
"const value = '中😀';",
|
||||
];
|
||||
const accepted = await until(
|
||||
context,
|
||||
() => readThread(context, thread.id),
|
||||
(value) => {
|
||||
try {
|
||||
assertFirstSendReceipt(value, source, fragments);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"accepted first-send canonical Turn and independently expected body fragments",
|
||||
);
|
||||
const receipt = assertFirstSendReceipt(accepted, source, fragments);
|
||||
const mediaRead = await context.modules.launcher.appServerCallFromPage(
|
||||
context.page,
|
||||
"media/read",
|
||||
{ threadId: receipt.threadId, uri: source.resources.image.canonicalUrl },
|
||||
);
|
||||
const { summarizeRichInputSubmission } = await import(
|
||||
pathToFileURL(
|
||||
path.join(
|
||||
context.options.repoRoot,
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.mjs",
|
||||
),
|
||||
).href
|
||||
);
|
||||
const richInput = summarizeRichInputSubmission({
|
||||
...receiptExpected,
|
||||
canonical: { thread: accepted },
|
||||
threadId: receipt.threadId,
|
||||
turnId: receipt.turnId,
|
||||
traceRaw: (
|
||||
await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
receipt.sessionId,
|
||||
)
|
||||
).traceRaw,
|
||||
mediaRead,
|
||||
});
|
||||
return {
|
||||
...receipt,
|
||||
richInput: assertFirstSendResourceReceipt(source, receipt, {
|
||||
richInput,
|
||||
mediaRead,
|
||||
expectedSubmission: receiptExpected.expectedSubmission,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitUnbound(context, scope) {
|
||||
return until(
|
||||
context,
|
||||
() => context.modules.drafts.readScope(context, scope),
|
||||
(result) => {
|
||||
try {
|
||||
assertUnboundScope(result, scope);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"native exact scope unbound after accepted consumption",
|
||||
);
|
||||
}
|
||||
|
||||
async function acceptedNewEdit(fixture, context, record, source, receipt) {
|
||||
const local = {
|
||||
...record,
|
||||
sessionId: receipt.sessionId,
|
||||
threadId: receipt.threadId,
|
||||
scope: {
|
||||
workspaceId: record.workspaceId,
|
||||
kind: "local",
|
||||
id: receipt.sessionId,
|
||||
},
|
||||
};
|
||||
const oldScope = await waitUnbound(context, record.scope);
|
||||
const localScope = await waitUnbound(context, local.scope);
|
||||
requireFirstSend(
|
||||
oldScope.storeRevision === localScope.storeRevision,
|
||||
"old and accepted local scopes acknowledge the same saved consumption",
|
||||
);
|
||||
const consumed = await fixture.inspectSavedFiles(
|
||||
localScope.storeRevision,
|
||||
(envelope) => assertFirstSendConsumed(envelope, source, receipt),
|
||||
);
|
||||
assertFirstSendSavedFiles(consumed, {
|
||||
...source.identity,
|
||||
sameCapturedIdentity: true,
|
||||
sessionId: receipt.sessionId,
|
||||
threadId: receipt.threadId,
|
||||
});
|
||||
// Real contenteditable input; no Controller, PM-state or native-atom injection.
|
||||
await context.modules.dom.fillComposerEditor(
|
||||
context.page,
|
||||
newEditText,
|
||||
receipt.sessionId,
|
||||
);
|
||||
const saved = await context.modules.drafts.waitSaved(context, local);
|
||||
const entry = saved.data.view.entry;
|
||||
const newEdit = assertFirstSendNewEdit(entry, source, receipt);
|
||||
const original = {
|
||||
entry,
|
||||
dom: await context.modules.history.readComposerHistorySnapshot(
|
||||
context.page,
|
||||
receipt.sessionId,
|
||||
),
|
||||
};
|
||||
await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: receipt.threadId,
|
||||
turnId: receipt.turnId,
|
||||
},
|
||||
);
|
||||
const terminal = await until(
|
||||
context,
|
||||
() => readThread(context, receipt.threadId),
|
||||
(thread) =>
|
||||
thread.turns?.length === 1 &&
|
||||
thread.turns[0].id === receipt.turnId &&
|
||||
["interrupted", "canceled", "cancelled"].includes(thread.turns[0].status),
|
||||
"public canonical interrupt settles the accepted old Turn",
|
||||
);
|
||||
await context.modules.drafts.verifyVisibleDraft(context, local, original);
|
||||
const final = await context.modules.drafts.waitSaved(context, local);
|
||||
requireFirstSend(
|
||||
isDeepStrictEqual(final.data.view.entry, entry),
|
||||
"old accepted Turn completion preserves exact new document and identity",
|
||||
);
|
||||
await waitUnbound(context, record.scope);
|
||||
const newFiles = await fixture.inspectSavedFiles(
|
||||
final.storeRevision,
|
||||
(envelope) => {
|
||||
const disk = envelope.atoms["composer-drafts-v1"].entries[entry.draftId];
|
||||
requireFirstSend(
|
||||
isDeepStrictEqual(disk, entry),
|
||||
"new local draft equals actual saved bytes",
|
||||
);
|
||||
return assertFirstSendNewEdit(disk, source, receipt);
|
||||
},
|
||||
);
|
||||
assertFirstSendSavedFiles(newFiles, {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
newEditPreserved: true,
|
||||
documentSha256: newEdit.documentSha256,
|
||||
});
|
||||
return {
|
||||
oldScope: assertUnboundScope(oldScope, record.scope),
|
||||
acceptedLocalScope: assertUnboundScope(localScope, local.scope),
|
||||
consumedFiles: consumed,
|
||||
newEdit,
|
||||
savedNewEditFiles: newFiles,
|
||||
terminalStatus: terminal.turns[0].status,
|
||||
exactPendingAcceptanceRace: false,
|
||||
interruptSurface: "preload -> App Server turn/interrupt",
|
||||
};
|
||||
}
|
||||
|
||||
/** Inject into the sole shared launcher/lifecycle driver after root adoption. */
|
||||
export const firstSendScenario = Object.freeze({
|
||||
id: "composer-first-submissions",
|
||||
scope:
|
||||
"Home restore, projectless search New and project New first submissions through startNewConversation",
|
||||
generationCount: 3,
|
||||
sourcePaths: [
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs",
|
||||
"scripts/electron/lib/composer-first-send-evidence.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs",
|
||||
"scripts/electron/lib/composer-draft-fixture-evidence.mjs",
|
||||
"src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts",
|
||||
"src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts",
|
||||
"src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts",
|
||||
"src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts",
|
||||
"src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts",
|
||||
"src/components/input-kit/ComposerDraftCoordinator.ts",
|
||||
"src/lib/workspace/navigation.ts",
|
||||
"src/hooks/useAppNavigation.ts",
|
||||
"src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts",
|
||||
"src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts",
|
||||
"src/components/AppSidebar.tsx",
|
||||
"src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx",
|
||||
"src/components/agent/chat/workspace/taskCenterTabProjection.ts",
|
||||
"src/components/app-sidebar/AppSidebarSearchDialog.tsx",
|
||||
"src/components/app-sidebar/AppSidebarProjectConversationGroups.tsx",
|
||||
"src/components/app-sidebar/useAppSidebarConversationActions.ts",
|
||||
"src/lib/api/persistedAtomTypes.ts",
|
||||
"src/lib/api/persistedAtomProtocol.ts",
|
||||
"scripts/lib/composer-editor-dom.mjs",
|
||||
"scripts/electron/mcp-config-fixture-smoke.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-skill-identity.mjs",
|
||||
"scripts/electron/lib/mcp-config-fixture-evidence.mjs",
|
||||
"src/components/input-kit/composerSerialization.ts",
|
||||
"src/components/input-kit/composerLinks.ts",
|
||||
"src/components/agent/chat/utils/buildUserInputSubmitOp.ts",
|
||||
"src/components/agent/chat/workspace/inputCapabilityRouting.ts",
|
||||
"lime-rs/crates/app-server/src/runtime/input_media.rs",
|
||||
"lime-rs/crates/app-server/src/processor/thread/projection/item.rs",
|
||||
"lime-rs/crates/app-server-protocol/src/protocol/v2/media.rs",
|
||||
],
|
||||
limitations: [
|
||||
"Requires root-adopted scenario driver and shared visible-editor helpers; no product build or Electron launch was performed while preparing this candidate.",
|
||||
"Navigation-only bootstrap threads are never submitted or counted as first-send coverage.",
|
||||
"Public Home/search/project-new-conversation clicks and exact same-atom stable home metadata identify the editing source; no top strip or draft picker is involved.",
|
||||
"All three entries use the same typed first-send path; canonical inventory must show no session created by editor input before Send.",
|
||||
"New edit follows disk-acknowledged consume; exact pending old-token acceptance timing is not claimed.",
|
||||
"Public RPC interrupt is used; GUI Stop, queue acceptance and rejection routes are not covered.",
|
||||
"Normal process lifecycle is checked by the shared driver; this scenario does not claim cold rich-draft restoration.",
|
||||
"Shared rich-input ingress uses synthetic ClipboardEvent/DataTransfer/DragEvent; OS clipboard and OS drag are not covered.",
|
||||
],
|
||||
async run(fixture) {
|
||||
requireFirstSend(
|
||||
[
|
||||
"launch",
|
||||
"quit",
|
||||
"stopTrace",
|
||||
"inspectSavedFiles",
|
||||
"recordStage",
|
||||
"setPhase",
|
||||
].every((name) => typeof fixture?.[name] === "function"),
|
||||
"root-adopted shared scenario driver contract",
|
||||
);
|
||||
const proofs = [];
|
||||
for (const [index, route] of firstSendRoutes.entries()) {
|
||||
const context = await fixture.launch(index + 1);
|
||||
fixture.setPhase("first-send-" + route + "-source");
|
||||
requireFirstSend(
|
||||
[
|
||||
"createRecord",
|
||||
"openRecord",
|
||||
"readScope",
|
||||
"waitSaved",
|
||||
"prepareVisibleDraft",
|
||||
"verifyVisibleDraft",
|
||||
].every((name) => typeof context.modules.drafts?.[name] === "function"),
|
||||
"root-adopted shared visible-editor action contract",
|
||||
);
|
||||
const record = await createSourceRecord(context, route);
|
||||
const source = await sourceBeforeSend(context, route, record);
|
||||
fixture.recordStage(route + "-actual-first-send-source", source.proof);
|
||||
fixture.setPhase("first-send-" + route + "-accepted");
|
||||
const receipt = await clickSendAndReadReceipt(
|
||||
context,
|
||||
record,
|
||||
source.proof,
|
||||
source.receiptExpected,
|
||||
);
|
||||
const settled = await acceptedNewEdit(
|
||||
fixture,
|
||||
context,
|
||||
record,
|
||||
source.proof,
|
||||
receipt,
|
||||
);
|
||||
const trace = assertFirstSendTrace(await fixture.stopTrace());
|
||||
const proof = {
|
||||
route,
|
||||
workspaceId: record.workspaceId,
|
||||
source: source.proof,
|
||||
receipt,
|
||||
...settled,
|
||||
trace,
|
||||
};
|
||||
fixture.recordStage(
|
||||
route + "-accepted-promotion-consumption-new-edit",
|
||||
proof,
|
||||
);
|
||||
proofs.push(proof);
|
||||
await fixture.quit();
|
||||
}
|
||||
assertFirstSendRouteCoverage(proofs);
|
||||
return {
|
||||
guiDraftRestoration: false,
|
||||
firstSubmissionRoutesVerified: true,
|
||||
logicalDraftScopeOnly: true,
|
||||
visibleTabIdentityVerified: false,
|
||||
firstSubmissionRoutes: proofs,
|
||||
records: proofs.map((proof) => ({
|
||||
route: proof.route,
|
||||
workspaceId: proof.workspaceId,
|
||||
...proof.receipt,
|
||||
sourceIdentity: proof.source.identity,
|
||||
newEditIdentity: {
|
||||
draftId: proof.newEdit.draftId,
|
||||
scopeVersion: proof.newEdit.scopeVersion,
|
||||
},
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -40,6 +40,7 @@ import { withInvokeTraceCollector } from "../lib/invoke-trace-collector.mjs";
|
||||
import {
|
||||
prepareHistoryPaste,
|
||||
captureComposerHistoryDraft,
|
||||
clearComposerDraftForHistory,
|
||||
verifyComposerHistoryRecall,
|
||||
} from "./claw-chat-current-fixture-composer-history.mjs";
|
||||
import {
|
||||
@@ -608,6 +609,12 @@ export async function prepareRichDraft(
|
||||
prompt: INPUTBAR_RICH_RESTORE_PROMPT,
|
||||
})
|
||||
: null;
|
||||
if (marksEvidence)
|
||||
recordRichRestoreStep(
|
||||
summary,
|
||||
"marks-language-picker",
|
||||
marksEvidence.pickerBoundary,
|
||||
);
|
||||
const pasteToken = await prepareHistoryPaste(
|
||||
page,
|
||||
richRestoreSessionId,
|
||||
@@ -800,24 +807,6 @@ async function runObservedInputbarRichRestoreScenario({
|
||||
const threadId = summary?.threadId;
|
||||
summary.inputbarRichRestoreBackendTurnStart =
|
||||
summarizeBackendTurnStart(backendTurnStart);
|
||||
summary.inputbarRichRestoreHistoryBoundary =
|
||||
await verifyComposerHistoryRecall({
|
||||
page,
|
||||
options,
|
||||
prepared: historyPrepared,
|
||||
threadId,
|
||||
sessionId: richRestoreSessionId,
|
||||
skillIdentity,
|
||||
expectedSubmission,
|
||||
backendText: backendTurnStart.entry.inputText,
|
||||
imageDataUrl: `data:image/png;base64,${RICH_RESTORE_IMAGE_BASE64}`,
|
||||
pathReference: {
|
||||
id: `file:${INPUTBAR_RICH_RESTORE_PATH}`,
|
||||
path: INPUTBAR_RICH_RESTORE_PATH,
|
||||
name: INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
isDir: "false",
|
||||
},
|
||||
});
|
||||
|
||||
summary.inputbarRichRestoreStopClick = sanitizeJson(
|
||||
await waitForStopButtonVisibleAndClick(page, options),
|
||||
@@ -896,6 +885,33 @@ async function runObservedInputbarRichRestoreScenario({
|
||||
},
|
||||
);
|
||||
|
||||
// History navigation is a new editing operation and invalidates old restore tokens.
|
||||
// Verify Stop first, then independently recall history from a user-cleared draft.
|
||||
summary.inputbarRichRestoreHistoryStart = await clearComposerDraftForHistory({
|
||||
page,
|
||||
options,
|
||||
sessionId: richRestoreSessionId,
|
||||
});
|
||||
summary.inputbarRichRestoreHistoryBoundary =
|
||||
await verifyComposerHistoryRecall({
|
||||
page,
|
||||
options,
|
||||
traceCollector,
|
||||
prepared: historyPrepared,
|
||||
threadId,
|
||||
sessionId: richRestoreSessionId,
|
||||
skillIdentity,
|
||||
expectedSubmission,
|
||||
backendText: backendTurnStart.entry.inputText,
|
||||
imageDataUrl: `data:image/png;base64,${RICH_RESTORE_IMAGE_BASE64}`,
|
||||
pathReference: {
|
||||
id: `file:${INPUTBAR_RICH_RESTORE_PATH}`,
|
||||
path: INPUTBAR_RICH_RESTORE_PATH,
|
||||
name: INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
isDir: "false",
|
||||
},
|
||||
});
|
||||
|
||||
summary.inputbarRichRestoreUserMessageCollapse =
|
||||
await runRichUserMessageCollapseGate({
|
||||
page,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ensureUserVisibleCapabilityReportSkill } from "./claw-chat-current-fixture-skills-workspace.mjs";
|
||||
import {
|
||||
@@ -9,6 +10,30 @@ import {
|
||||
summarizeRichInputSubmission,
|
||||
} from "./claw-chat-current-fixture-rich-input-evidence.mjs";
|
||||
import { composerMarksFixture } from "./claw-chat-current-fixture-composer-marks.mjs";
|
||||
import {
|
||||
INPUTBAR_RICH_RESTORE_PATH,
|
||||
INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
} from "./claw-chat-current-fixture-constants.mjs";
|
||||
|
||||
describe("rich input current file reference fixture", () => {
|
||||
it("references a real readable file owned by the current fixture domain", () => {
|
||||
expect(path.basename(INPUTBAR_RICH_RESTORE_PATH)).toBe(
|
||||
INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
);
|
||||
expect(path.relative(process.cwd(), INPUTBAR_RICH_RESTORE_PATH)).toBe(
|
||||
path.join(
|
||||
"scripts",
|
||||
"agent-runtime",
|
||||
"fixtures",
|
||||
INPUTBAR_RICH_RESTORE_PATH_NAME,
|
||||
),
|
||||
);
|
||||
expect(fs.statSync(INPUTBAR_RICH_RESTORE_PATH).isFile()).toBe(true);
|
||||
expect(fs.readFileSync(INPUTBAR_RICH_RESTORE_PATH, "utf8").trim()).not.toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let root;
|
||||
let skillIdentity;
|
||||
@@ -205,6 +230,7 @@ describe("rich Composer submission evidence", () => {
|
||||
(mode) => {
|
||||
const input = marksFixture();
|
||||
const part = input.request.params.input[0];
|
||||
const before = part.text;
|
||||
if (mode === "display")
|
||||
part.text = `/capability-report ${input.evidence.editorText}`;
|
||||
if (mode === "href")
|
||||
@@ -215,9 +241,10 @@ describe("rich Composer submission evidence", () => {
|
||||
if (mode === "title") part.text = part.text.replace(' "标题"', "");
|
||||
if (mode === "fence") part.text = part.text.replaceAll("`````", "```");
|
||||
if (mode === "code-space")
|
||||
part.text = part.text.replace(" 中😀", "中😀");
|
||||
part.text = part.text.replace(" const value", "const value");
|
||||
if (mode === "code-lf")
|
||||
part.text = part.text.replace("````\n\n\n`````", "````\n`````");
|
||||
expect(part.text).not.toBe(before);
|
||||
input.evidence.canonical.thread.turns[0].items[0].content[0] =
|
||||
structuredClone(part);
|
||||
expect(summarize(input).ok).toBe(false);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { _electron as electron, chromium } from "playwright";
|
||||
import { resolveElectronAppServerRuntimeEnv } from "../lib/electron-app-server-assets.mjs";
|
||||
import { resolveDevAppServerBinary } from "../lib/electron-dev-sidecar.mjs";
|
||||
import { ensureElectronFixtureBuild } from "../lib/electron-fixture-build.mjs";
|
||||
import { captureMainProcessExit } from "../electron/lib/persisted-atom-fixture-driver.mjs";
|
||||
import { settleFixtureResources } from "./claw-chat-current-fixture-teardown.mjs";
|
||||
import {
|
||||
APP_SERVER_METHOD_SESSION_LIST,
|
||||
APPROVAL_REQUEST_CANCEL_SCENARIO,
|
||||
@@ -938,6 +940,7 @@ async function run() {
|
||||
};
|
||||
|
||||
let app = null;
|
||||
let mainExit = null;
|
||||
let cdpBrowser = null;
|
||||
let page = null;
|
||||
let imageProviderFixtureServer = null;
|
||||
@@ -952,13 +955,32 @@ async function run() {
|
||||
const actionableConsoleErrors = [];
|
||||
const pageErrors = [];
|
||||
const agentDebugLogs = [];
|
||||
const composerDiagnosticReads = [];
|
||||
const pageLifecycleEvents = [];
|
||||
const fixtureConfigSoulOverrides =
|
||||
createSoulStyleFixtureOverrides(soulStyleSelection);
|
||||
const collectConsoleMessage = (message) => {
|
||||
const text = sanitizeText(message.text());
|
||||
if (text.includes("[AgentDebug]")) {
|
||||
agentDebugLogs.push({ type: message.type(), text });
|
||||
const entry = { type: message.type(), text };
|
||||
agentDebugLogs.push(entry);
|
||||
if (text.startsWith("[AgentDebug] ComposerSubmission.")) {
|
||||
// CDP's console text previews only a few object fields; retain this
|
||||
// owner's numeric/boolean diagnostics before its handle is released.
|
||||
composerDiagnosticReads.push(
|
||||
message
|
||||
.args()[1]
|
||||
?.jsonValue()
|
||||
.then(
|
||||
(context) => {
|
||||
entry.context = sanitizeJson(context);
|
||||
},
|
||||
() => {
|
||||
entry.contextUnavailable = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(text);
|
||||
@@ -1044,6 +1066,7 @@ async function run() {
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
|
||||
mainExit = captureMainProcessExit(app.process());
|
||||
app.on("console", collectConsoleMessage);
|
||||
app.on("close", () => {
|
||||
pageLifecycleEvents.push({
|
||||
@@ -1501,6 +1524,7 @@ async function run() {
|
||||
|
||||
summary.consoleErrors = consoleErrors;
|
||||
summary.actionableConsoleErrors = actionableConsoleErrors;
|
||||
await Promise.allSettled(composerDiagnosticReads);
|
||||
summary.agentDebugLogs = agentDebugLogs.slice(-200);
|
||||
summary.agentStreamDebugLogs = agentDebugLogs
|
||||
.filter((entry) => entry.text.includes("[AgentDebug] AgentStream."))
|
||||
@@ -1531,11 +1555,7 @@ async function run() {
|
||||
summary.commonAssertions = assertionReport.commonAssertions;
|
||||
summary.scenarioAssertions = assertionReport.scenarioAssertions;
|
||||
summary.notApplicableAssertions = assertionReport.notApplicableAssertions;
|
||||
summary.ok = true;
|
||||
summary.completedAt = new Date().toISOString();
|
||||
writeJsonFile(summaryPath, summary);
|
||||
console.log(LOG_PREFIX + " summary=" + summaryPath);
|
||||
console.log(LOG_PREFIX + " pass session=" + summary.sessionId);
|
||||
summary.businessAssertionsPassed = true;
|
||||
} catch (error) {
|
||||
try {
|
||||
if (page) {
|
||||
@@ -1606,6 +1626,7 @@ async function run() {
|
||||
applySoulStyleProviderMarkerSummary(summary, textProviderRequests);
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(composerDiagnosticReads);
|
||||
summary.agentDebugLogs = agentDebugLogs.slice(-200);
|
||||
summary.agentStreamDebugLogs = agentDebugLogs
|
||||
.filter((entry) => entry.text.includes("[AgentDebug] AgentStream."))
|
||||
@@ -1628,23 +1649,28 @@ async function run() {
|
||||
console.error(LOG_PREFIX + " failureSummary=" + summaryPath);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (invokeTraceCollector) {
|
||||
await invokeTraceCollector.stop().catch(() => undefined);
|
||||
}
|
||||
if (cdpBrowser) {
|
||||
await cdpBrowser.close().catch(() => undefined);
|
||||
}
|
||||
if (app) {
|
||||
await app.close().catch(() => undefined);
|
||||
}
|
||||
if (imageProviderFixtureServer) {
|
||||
await imageProviderFixtureServer.close().catch(() => undefined);
|
||||
}
|
||||
if (textProviderFixtureServer) {
|
||||
await textProviderFixtureServer.close().catch(() => undefined);
|
||||
}
|
||||
if (!options.keepTemp) {
|
||||
cleanupTempRoot(runtimeEnv.tempRoot);
|
||||
summary.teardown = await settleFixtureResources({
|
||||
app,
|
||||
mainExit,
|
||||
cdpBrowser,
|
||||
invokeTraceCollector,
|
||||
imageProviderFixtureServer,
|
||||
textProviderFixtureServer,
|
||||
cleanupTemp: options.keepTemp
|
||||
? null
|
||||
: () => cleanupTempRoot(runtimeEnv.tempRoot),
|
||||
});
|
||||
summary.pageLifecycleEvents = pageLifecycleEvents;
|
||||
summary.ok =
|
||||
summary.businessAssertionsPassed === true && summary.teardown.ok;
|
||||
summary.completedAt = new Date().toISOString();
|
||||
writeJsonFile(summaryPath, summary);
|
||||
if (summary.ok) {
|
||||
console.log(LOG_PREFIX + " summary=" + summaryPath);
|
||||
console.log(LOG_PREFIX + " pass session=" + summary.sessionId);
|
||||
} else {
|
||||
console.error(LOG_PREFIX + " failureSummary=" + summaryPath);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { quitMainProcessNormally } from "../electron/lib/persisted-atom-fixture-driver.mjs";
|
||||
|
||||
function bounded(callback, timeoutMs, phase) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
Promise.resolve().then(callback),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`fixture teardown timeout: ${phase}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/** Resource failure is a failed Gate, even when all business assertions passed. */
|
||||
export async function settleFixtureResources({
|
||||
app,
|
||||
mainExit,
|
||||
cdpBrowser,
|
||||
invokeTraceCollector,
|
||||
imageProviderFixtureServer,
|
||||
textProviderFixtureServer,
|
||||
cleanupTemp,
|
||||
timeoutMs = 15_000,
|
||||
failureCleanupTimeoutMs = 5_000,
|
||||
}) {
|
||||
const result = {
|
||||
ok: true,
|
||||
steps: [],
|
||||
normalMainExit: null,
|
||||
forcedCleanup: null,
|
||||
};
|
||||
const settle = async (phase, callback) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
const value = await callback();
|
||||
result.steps.push({
|
||||
phase,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
ok: true,
|
||||
});
|
||||
return value;
|
||||
} catch (error) {
|
||||
result.ok = false;
|
||||
result.steps.push({
|
||||
phase,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
ok: false,
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (invokeTraceCollector)
|
||||
await settle("invoke-trace", () =>
|
||||
bounded(() => invokeTraceCollector.stop(), timeoutMs, "invoke-trace"),
|
||||
);
|
||||
if (cdpBrowser)
|
||||
await settle("cdp", () =>
|
||||
bounded(() => cdpBrowser.close(), timeoutMs, "cdp"),
|
||||
);
|
||||
if (app) {
|
||||
result.normalMainExit = await settle("normal-main-exit", () =>
|
||||
quitMainProcessNormally({ app, exit: mainExit }, timeoutMs),
|
||||
);
|
||||
if (!result.normalMainExit && mainExit?.getExitRecord() === null) {
|
||||
// Reap only this launch's Main after the Gate has already failed. A signal
|
||||
// receipt never repairs normalMainExit or the failed Gate result.
|
||||
result.forcedCleanup = await settle("failed-main-reap", async () => {
|
||||
const child = app.process();
|
||||
if (child.pid !== mainExit.pid)
|
||||
throw new Error("fixture Main ownership changed; refusing to signal");
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
return bounded(
|
||||
() => mainExit.exitPromise,
|
||||
failureCleanupTimeoutMs,
|
||||
"failed-main-reap",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [phase, server] of [
|
||||
["image-provider", imageProviderFixtureServer],
|
||||
["text-provider", textProviderFixtureServer],
|
||||
]) {
|
||||
if (server)
|
||||
await settle(phase, () =>
|
||||
bounded(() => server.close(), timeoutMs, phase),
|
||||
);
|
||||
}
|
||||
if (cleanupTemp) await settle("temp-root", cleanupTemp);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureMainProcessExit } from "../electron/lib/persisted-atom-fixture-driver.mjs";
|
||||
import { settleFixtureResources } from "./claw-chat-current-fixture-teardown.mjs";
|
||||
|
||||
function fixture() {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 719,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: vi.fn((signal) => exit(null, signal)),
|
||||
});
|
||||
function exit(code = 0, signal = null) {
|
||||
child.exitCode = code;
|
||||
child.signalCode = signal;
|
||||
child.emit("exit", code, signal);
|
||||
}
|
||||
const app = { process: () => child, close: vi.fn(async () => exit()) };
|
||||
const cleanupTemp = vi.fn();
|
||||
const imageProviderFixtureServer = { close: vi.fn() };
|
||||
const textProviderFixtureServer = { close: vi.fn() };
|
||||
return {
|
||||
app,
|
||||
child,
|
||||
exit,
|
||||
cleanupTemp,
|
||||
mainExit: captureMainProcessExit(child),
|
||||
imageProviderFixtureServer,
|
||||
textProviderFixtureServer,
|
||||
timeoutMs: 50,
|
||||
failureCleanupTimeoutMs: 50,
|
||||
};
|
||||
}
|
||||
|
||||
describe("current fixture resource settlement (explicit fake processes)", () => {
|
||||
it("cannot report success before Main actually exits, even when close resolved", async () => {
|
||||
const f = fixture();
|
||||
f.app.close.mockResolvedValue(undefined);
|
||||
let settled = false;
|
||||
const pending = settleFixtureResources(f).then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(f.cleanupTemp).not.toHaveBeenCalled();
|
||||
f.exit();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: true,
|
||||
normalMainExit: { exitCode: 0, signal: null, mode: "normal" },
|
||||
forcedCleanup: null,
|
||||
});
|
||||
expect(f.child.kill).not.toHaveBeenCalled();
|
||||
expect(f.cleanupTemp).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds a hung close, marks failure and reaps only the owned failed Main", async () => {
|
||||
const f = fixture();
|
||||
f.timeoutMs = 2;
|
||||
f.app.close.mockImplementation(() => new Promise(() => {}));
|
||||
const result = await settleFixtureResources(f);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
normalMainExit: null,
|
||||
forcedCleanup: { pid: 719, exitCode: null, signal: "SIGKILL" },
|
||||
});
|
||||
expect(result.steps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
phase: "normal-main-exit",
|
||||
ok: false,
|
||||
error: expect.stringContaining("normal Main exit timeout"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(f.child.kill).toHaveBeenCalledExactlyOnceWith("SIGKILL");
|
||||
expect(f.imageProviderFixtureServer.close).toHaveBeenCalledOnce();
|
||||
expect(f.textProviderFixtureServer.close).toHaveBeenCalledOnce();
|
||||
expect(f.cleanupTemp).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 1, signal: null },
|
||||
{ code: null, signal: "SIGTERM" },
|
||||
])("rejects an abnormal Main exit %j", async ({ code, signal }) => {
|
||||
const f = fixture();
|
||||
f.app.close.mockImplementation(async () => f.exit(code, signal));
|
||||
const result = await settleFixtureResources(f);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.normalMainExit).toBeNull();
|
||||
expect(f.child.kill).not.toHaveBeenCalled();
|
||||
expect(f.cleanupTemp).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains collector and provider failures while releasing every remaining resource", async () => {
|
||||
const f = fixture();
|
||||
f.invokeTraceCollector = {
|
||||
stop: vi.fn(() => {
|
||||
throw new Error("trace failure");
|
||||
}),
|
||||
};
|
||||
f.cdpBrowser = { close: vi.fn() };
|
||||
f.imageProviderFixtureServer.close.mockRejectedValue(
|
||||
new Error("provider failure"),
|
||||
);
|
||||
const result = await settleFixtureResources(f);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.normalMainExit).toMatchObject({ exitCode: 0, signal: null });
|
||||
expect(
|
||||
result.steps.filter((step) => !step.ok).map((step) => step.phase),
|
||||
).toEqual(["invoke-trace", "image-provider"]);
|
||||
expect(f.cdpBrowser.close).toHaveBeenCalledOnce();
|
||||
expect(f.textProviderFixtureServer.close).toHaveBeenCalledOnce();
|
||||
expect(f.cleanupTemp).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not call a Main that exited before requested normal quit a success", async () => {
|
||||
const f = fixture();
|
||||
f.exit();
|
||||
expect(await settleFixtureResources(f)).toMatchObject({
|
||||
ok: false,
|
||||
normalMainExit: null,
|
||||
forcedCleanup: null,
|
||||
});
|
||||
expect(f.app.close).not.toHaveBeenCalled();
|
||||
expect(f.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,7 @@ function main() {
|
||||
"scripts/electron/session-history-fixture-smoke.test.mjs",
|
||||
"scripts/electron/code-artifact-workbench-fixture-smoke.test.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-unknown-item.test.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-user-message-collapse.test.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-user-message-collapse-evidence.unit.test.mjs",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Composer 引用测试夹具
|
||||
|
||||
此文件用于验证桌面输入器的文件引用、发送历史和中断恢复。
|
||||
|
||||
测试内容:保留正文、图片与文件路径。
|
||||
@@ -339,7 +339,7 @@ function buildEnsureAgentHomeScript() {
|
||||
}
|
||||
|
||||
const settingsHomeButton = document.querySelector(
|
||||
'[data-testid="settings-home-button"]',
|
||||
'[data-testid="app-sidebar-rail-home"]',
|
||||
);
|
||||
const fallbackHomeButton =
|
||||
settingsHomeButton ||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { collectPersistedAtomContractFailures } from "./lib/persisted-atom-contract.mjs";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
@@ -4597,7 +4598,10 @@ function main() {
|
||||
).filter((sectionName) =>
|
||||
retiredMemoryRuntimeCatalogSections.has(sectionName),
|
||||
);
|
||||
const productionBridgeGuardFailures = collectProductionBridgeGuardFailures();
|
||||
const productionBridgeGuardFailures = [
|
||||
...collectProductionBridgeGuardFailures(),
|
||||
...collectPersistedAtomContractFailures(readSource),
|
||||
];
|
||||
const retiredFileBrowserFacadeSourceFailures =
|
||||
collectRetiredFileBrowserFacadeSourceFailures();
|
||||
const retiredAutomationFacadeSourceFailures =
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseComposerDraftFixtureArgs,
|
||||
runComposerDraftGuiFixture,
|
||||
} from "./lib/composer-draft-fixture-driver.mjs";
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const options = parseComposerDraftFixtureArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(`Composer rich draft real Electron GUI fixture
|
||||
|
||||
node scripts/electron/composer-draft-fixture-smoke.mjs \\
|
||||
--run-id <fresh-id> --evidence-dir <directory> \\
|
||||
--verification-packet <current-persisted-atom-packet.json>
|
||||
|
||||
Requires fresh formal product artifacts; never builds product assets.
|
||||
Uses real GUI edits, native preload IPC, App Server, three workspaces,
|
||||
and three same-profile Main processes with public app.quit.
|
||||
Pure protocol/records parsing owners are compiled into the evidence directory.
|
||||
No browser Gate substitution, Host-only GUI claim or renderer mock fallback.`);
|
||||
} else {
|
||||
runComposerDraftGuiFixture(options).then(
|
||||
({ summaryPath }) =>
|
||||
console.log(`[composer-draft-gui] pass evidence=${summaryPath}`),
|
||||
(error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,12 +189,18 @@ function currentElectronEntrypointFiles() {
|
||||
];
|
||||
}
|
||||
|
||||
function isElectronTestOnlyPath(filePath) {
|
||||
return /\.(?:test|testHarness|test-support)(?:\.(?:ts|mjs|js))?$/i.test(
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
|
||||
function isElectronProductionSourceFile(filePath) {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
return (
|
||||
/\.(?:mjs|js|ts)$/i.test(normalized) &&
|
||||
!normalized.endsWith(".d.ts") &&
|
||||
!/\.(?:test|testHarness)\.(?:ts|mjs|js)$/i.test(normalized)
|
||||
!isElectronTestOnlyPath(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,13 +217,11 @@ function productionTestImports(filePath, content) {
|
||||
if (!isElectronProductionSourceFile(filePath)) return [];
|
||||
return [
|
||||
...content.matchAll(
|
||||
/(?:from\s+|import\s*\(|require\s*\()(["'])([^"']+)\1/g,
|
||||
/\b(?:from\s+|import\s*(?:\(\s*)?|require\s*\(\s*)(["'])([^"']+)\1/g,
|
||||
),
|
||||
]
|
||||
.map((match) => match[2])
|
||||
.filter((specifier) =>
|
||||
/\.(?:test|testHarness)(?:\.(?:ts|mjs|js))?$/i.test(specifier),
|
||||
);
|
||||
.filter(isElectronTestOnlyPath);
|
||||
}
|
||||
|
||||
function electronProductionSourceFiles() {
|
||||
@@ -399,6 +403,98 @@ describe("Electron current package entrypoints", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["ts", "mjs", "js"])(
|
||||
"classifies .test-support.%s as a test-only leaf without exempting production siblings",
|
||||
(extension) => {
|
||||
const runtimeDependencies = new Set(["electron"]);
|
||||
const content = 'import { vi } from "vitest";';
|
||||
for (const filePath of [
|
||||
`electron/persistedAtomStore.test-support.${extension}`,
|
||||
`electron\\persistedAtomStore.test-support.${extension}`,
|
||||
]) {
|
||||
expect(
|
||||
unpackagedRuntimeImports(filePath, content, runtimeDependencies),
|
||||
filePath,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
productionTestImports(
|
||||
filePath,
|
||||
'import { put } from "./persistedAtomStore.test-support";',
|
||||
),
|
||||
filePath,
|
||||
).toEqual([]);
|
||||
}
|
||||
for (const filePath of [
|
||||
`electron/persistedAtomStore.${extension}`,
|
||||
`electron/test-supportOwner.${extension}`,
|
||||
`electron/persistedAtomStore.test-supporting.${extension}`,
|
||||
]) {
|
||||
expect(
|
||||
unpackagedRuntimeImports(filePath, content, runtimeDependencies),
|
||||
filePath,
|
||||
).toEqual(["vitest"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"static import",
|
||||
'import { put } from "./persistedAtomStore.test-support";',
|
||||
"./persistedAtomStore.test-support",
|
||||
],
|
||||
[
|
||||
"type import",
|
||||
'import type { Fixture } from "./persistedAtomStore.test-support.ts";',
|
||||
"./persistedAtomStore.test-support.ts",
|
||||
],
|
||||
[
|
||||
"side-effect import",
|
||||
'import "./persistedAtomStore.test-support.js";',
|
||||
"./persistedAtomStore.test-support.js",
|
||||
],
|
||||
[
|
||||
"re-export",
|
||||
'export { put } from "./persistedAtomStore.test-support.mjs";',
|
||||
"./persistedAtomStore.test-support.mjs",
|
||||
],
|
||||
[
|
||||
"dynamic import",
|
||||
'const support = await import( "./persistedAtomStore.test-support" );',
|
||||
"./persistedAtomStore.test-support",
|
||||
],
|
||||
[
|
||||
"require",
|
||||
'const support = require( "./persistedAtomStore.test-support.ts" );',
|
||||
"./persistedAtomStore.test-support.ts",
|
||||
],
|
||||
])(
|
||||
"rejects production %s of test-only support",
|
||||
(_form, content, specifier) => {
|
||||
expect(productionTestImports("electron/main.ts", content)).toEqual([
|
||||
specifier,
|
||||
]);
|
||||
expect(
|
||||
productionTestImports("electron/persistedAtomStore.test.ts", content),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
productionTestImports(
|
||||
"electron/persistedAtomStore.test-support.ts",
|
||||
content,
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not mistake a production import for a test-only support leaf", () => {
|
||||
const content = [
|
||||
'import { PersistedAtomStore } from "./persistedAtomStore";',
|
||||
'import { support } from "./test-supportOwner";',
|
||||
'import "./persistedAtomStore.test-supporting";',
|
||||
].join("\n");
|
||||
expect(productionTestImports("electron/main.ts", content)).toEqual([]);
|
||||
});
|
||||
|
||||
it("legacy verify-gui-smoke script delegates to Electron smoke", () => {
|
||||
const content = readFile("scripts/verify-gui-smoke.mjs");
|
||||
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createRecord,
|
||||
markers,
|
||||
openRecord,
|
||||
prepareRecord,
|
||||
readIndex,
|
||||
readScope,
|
||||
verifyAcceptedNewEdit,
|
||||
verifyRecord,
|
||||
verifyRejectedSubmission,
|
||||
} from "../../agent-runtime/claw-chat-current-fixture-draft-persistence.mjs";
|
||||
import {
|
||||
assertAppServerRequestMethods,
|
||||
assertRestartLifecycle,
|
||||
assertConsumedDraft,
|
||||
requireDraftProof,
|
||||
summarizeDraft,
|
||||
} from "./composer-draft-fixture-evidence.mjs";
|
||||
|
||||
const dependencies = {
|
||||
drafts:
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs",
|
||||
launcher: "scripts/electron/mcp-config-fixture-smoke.mjs",
|
||||
lifecycle: "scripts/electron/lib/persisted-atom-fixture-driver.mjs",
|
||||
owners: "scripts/electron/lib/persisted-atom-fixture-standalone.mjs",
|
||||
atom: "scripts/electron/lib/persisted-atom-fixture.mjs",
|
||||
freshness: "scripts/lib/electron-fixture-build.mjs",
|
||||
assets: "scripts/lib/electron-app-server-assets.mjs",
|
||||
trace: "scripts/lib/invoke-trace-collector.mjs",
|
||||
runtime: "scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs",
|
||||
constants: "scripts/agent-runtime/claw-chat-current-fixture-constants.mjs",
|
||||
rpc: "scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs",
|
||||
sessions: "scripts/agent-runtime/claw-chat-current-fixture-session.mjs",
|
||||
marks: "scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs",
|
||||
image:
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-composer-image-upload.mjs",
|
||||
history:
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs",
|
||||
dom: "scripts/lib/composer-editor-dom.mjs",
|
||||
skills:
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs",
|
||||
};
|
||||
const hashBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const fixtureSources = [
|
||||
"scripts/electron/composer-draft-fixture-smoke.mjs",
|
||||
"scripts/electron/lib/composer-draft-fixture-driver.mjs",
|
||||
"scripts/electron/lib/composer-draft-fixture-evidence.mjs",
|
||||
"scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs",
|
||||
];
|
||||
|
||||
async function sourceFingerprint(repoRoot, relative) {
|
||||
const file = path.join(repoRoot, relative);
|
||||
return { path: file, sha256: hashBytes(await fs.readFile(file)) };
|
||||
}
|
||||
|
||||
async function loadModules(repoRoot) {
|
||||
const result = await Promise.allSettled(
|
||||
Object.entries(dependencies).map(async ([key, relative]) => [
|
||||
key,
|
||||
await import(pathToFileURL(path.join(repoRoot, relative)).href),
|
||||
]),
|
||||
);
|
||||
const loaded = {};
|
||||
for (const [index, entry] of result.entries()) {
|
||||
requireDraftProof(
|
||||
entry.status === "fulfilled",
|
||||
`load current dependency ${Object.keys(dependencies)[index]}`,
|
||||
);
|
||||
loaded[entry.value[0]] = entry.value[1];
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export function parseComposerDraftFixtureArgs(argv, cwd = process.cwd()) {
|
||||
const options = {
|
||||
repoRoot: cwd,
|
||||
evidenceDir: null,
|
||||
packetPath: null,
|
||||
runId: null,
|
||||
timeoutMs: 60_000,
|
||||
intervalMs: 200,
|
||||
quitTimeoutMs: 15_000,
|
||||
help: false,
|
||||
};
|
||||
const fields = {
|
||||
"--repo": "repoRoot",
|
||||
"--evidence-dir": "evidenceDir",
|
||||
"--verification-packet": "packetPath",
|
||||
"--run-id": "runId",
|
||||
"--timeout-ms": "timeoutMs",
|
||||
"--quit-timeout-ms": "quitTimeoutMs",
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--help" || argument === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
const field = fields[argument];
|
||||
requireDraftProof(
|
||||
field && argv[index + 1],
|
||||
"known CLI argument with value",
|
||||
);
|
||||
options[field] = argv[++index];
|
||||
}
|
||||
if (options.help) return options;
|
||||
requireDraftProof(
|
||||
options.evidenceDir &&
|
||||
options.packetPath &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? ""),
|
||||
"explicit verification packet, fresh evidence directory and run ID",
|
||||
);
|
||||
for (const field of ["repoRoot", "evidenceDir", "packetPath"])
|
||||
options[field] = path.resolve(cwd, options[field]);
|
||||
for (const field of ["timeoutMs", "quitTimeoutMs"]) {
|
||||
options[field] = Number(options[field]);
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(options[field]) &&
|
||||
options[field] > 0 &&
|
||||
options[field] <= 60_000,
|
||||
"bounded CLI timeout",
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function runLocalDraftScenario({
|
||||
options,
|
||||
runtimeEnv,
|
||||
launch,
|
||||
quit,
|
||||
stopTrace,
|
||||
inspectSavedFiles,
|
||||
recordStage,
|
||||
setPhase,
|
||||
}) {
|
||||
let context = await launch(1);
|
||||
setPhase("three-real-workspaces-and-rich-gui-edits");
|
||||
const records = [],
|
||||
originals = [];
|
||||
for (const [index, marker] of markers.entries()) {
|
||||
const rootPath = path.join(runtimeEnv.tempRoot, `workspace-${index + 1}`);
|
||||
await fs.mkdir(rootPath);
|
||||
const fixturePath = path.join(rootPath, "composer-persistence.md");
|
||||
const fixtureBytes = Buffer.alloc(128, " ");
|
||||
fixtureBytes.write(`# Composer persistence\n${marker}\n`, "utf8");
|
||||
await fs.writeFile(fixturePath, fixtureBytes);
|
||||
const record = await createRecord(context, {
|
||||
rootPath,
|
||||
marker,
|
||||
title: `Composer persistence ${options.runId} ${index + 1}`,
|
||||
});
|
||||
record.path = fixturePath;
|
||||
record.pathName = path.basename(fixturePath);
|
||||
records.push(record);
|
||||
originals.push(await prepareRecord(context, record));
|
||||
}
|
||||
requireDraftProof(
|
||||
new Set(records.map((record) => record.workspaceId)).size === 3 &&
|
||||
new Set(records.map((record) => record.threadId)).size === 3,
|
||||
"three genuine isolated workspaces and canonical threads",
|
||||
);
|
||||
setPhase("real-gui-rejected-submission-preserves-rich-draft");
|
||||
const rejectionBaseline = await verifyRecord(
|
||||
context,
|
||||
records[0],
|
||||
originals[0],
|
||||
);
|
||||
recordStage("rejection-complete-rich-baseline", rejectionBaseline);
|
||||
const rejected = await verifyRejectedSubmission(
|
||||
context,
|
||||
records[0],
|
||||
recordStage,
|
||||
);
|
||||
originals[0] = rejected.original;
|
||||
recordStage("gui-failure-preserves-rich-draft", rejected.evidence);
|
||||
for (const [index, record] of records.entries()) {
|
||||
await verifyRecord(context, record, originals[index]);
|
||||
recordStage(
|
||||
`workspace-${index + 1}-isolation`,
|
||||
await readIndex(context, record, originals[index].entry),
|
||||
);
|
||||
}
|
||||
const savedBeforeQuit = await readScope(context, records[2].scope);
|
||||
recordStage(
|
||||
"generation-1-saved-files",
|
||||
await inspectSavedFiles(savedBeforeQuit.storeRevision),
|
||||
);
|
||||
await quit();
|
||||
context = await launch(2);
|
||||
setPhase("new-main-pid-cold-gui-restoration");
|
||||
const second = [];
|
||||
for (const [index, record] of records.entries())
|
||||
second.push(await verifyRecord(context, record, originals[index]));
|
||||
requireDraftProof(
|
||||
second.every((proof) => proof.revision === savedBeforeQuit.storeRevision),
|
||||
"cold GUI hydration performs no extra atom mutation",
|
||||
);
|
||||
recordStage("generation-2-cold-gui-restoration", second);
|
||||
setPhase("accepted-current-turn-new-edit-and-foreign-workspace");
|
||||
recordStage(
|
||||
"accepted-new-edit-cross-scope",
|
||||
await verifyAcceptedNewEdit(context, records, originals),
|
||||
);
|
||||
for (const [index, record] of records.entries()) {
|
||||
await verifyRecord(context, record, originals[index]);
|
||||
await readIndex(context, record, originals[index].entry);
|
||||
}
|
||||
const acceptedTrace = await stopTrace();
|
||||
requireDraftProof(
|
||||
acceptedTrace.atomOperations.some(
|
||||
(operation) =>
|
||||
operation.op === "consume" && operation.status === "success",
|
||||
) &&
|
||||
acceptedTrace.atomOperations.some(
|
||||
(operation) => operation.op === "put" && operation.status === "success",
|
||||
),
|
||||
"production GUI performs accepted consume and newer-edit put through native IPC",
|
||||
);
|
||||
const secondSaved = await readScope(context, records[2].scope);
|
||||
recordStage(
|
||||
"generation-2-saved-files",
|
||||
await inspectSavedFiles(secondSaved.storeRevision),
|
||||
);
|
||||
await quit();
|
||||
context = await launch(3);
|
||||
setPhase("normal-restart-restores-new-edit-and-other-workspaces");
|
||||
const third = [];
|
||||
for (const [index, record] of records.entries())
|
||||
third.push(await verifyRecord(context, record, originals[index]));
|
||||
requireDraftProof(
|
||||
third.every((proof) => proof.revision === secondSaved.storeRevision),
|
||||
"second cold restart hydrates without additional mutation",
|
||||
);
|
||||
recordStage("generation-3-cold-gui-restoration", third);
|
||||
await quit();
|
||||
return {
|
||||
guiDraftRestoration: true,
|
||||
records: records.map((record, index) => ({
|
||||
...record,
|
||||
draft: summarizeDraft(originals[index].entry),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Real GUI driver. Native atom mutation is exclusively owned by production UI. */
|
||||
export async function runComposerDraftGuiFixture(options, scenario = null) {
|
||||
requireDraftProof(
|
||||
path.resolve(process.cwd()) === options.repoRoot,
|
||||
"existing Electron launcher uses the formal repository cwd",
|
||||
);
|
||||
const runRoot = path.join(options.evidenceDir, options.runId);
|
||||
await fs.mkdir(options.evidenceDir, { recursive: true });
|
||||
await fs.mkdir(runRoot); // Never overwrite an existing run or reuse an unknown profile.
|
||||
const scope =
|
||||
scenario?.scope ??
|
||||
"existing canonical local conversations and their rich persistent drafts";
|
||||
const generationCount = scenario?.generationCount ?? 3;
|
||||
requireDraftProof(
|
||||
scenario === null ||
|
||||
(/^[a-z][a-z0-9-]{0,60}$/.test(scenario.id ?? "") &&
|
||||
typeof scenario.run === "function" &&
|
||||
typeof scope === "string" &&
|
||||
scope.length > 0 &&
|
||||
Number.isSafeInteger(generationCount) &&
|
||||
generationCount >= 2 &&
|
||||
Array.isArray(scenario.sourcePaths) &&
|
||||
scenario.sourcePaths.every(
|
||||
(relative) =>
|
||||
typeof relative === "string" &&
|
||||
!path.isAbsolute(relative) &&
|
||||
!relative.split(/[\\/]/).includes(".."),
|
||||
)),
|
||||
"explicit current scenario, identity and relative source lineage",
|
||||
);
|
||||
const summaryPath = path.join(
|
||||
runRoot,
|
||||
scenario
|
||||
? `composer-${scenario.id}-gui.json`
|
||||
: "composer-draft-gui-persistence.json",
|
||||
);
|
||||
const generations = [],
|
||||
traceEvidence = [],
|
||||
stageEvidence = [];
|
||||
const consoleErrors = [],
|
||||
pageErrors = [];
|
||||
let active = null,
|
||||
context = null,
|
||||
phase = "current-dependencies";
|
||||
let modules, runtimeEnv, sourceRecords, owners, packetBytes;
|
||||
const recordStage = (name, proof) => stageEvidence.push({ name, proof });
|
||||
try {
|
||||
sourceRecords = await Promise.all(
|
||||
[
|
||||
...new Set([
|
||||
...Object.values(dependencies),
|
||||
...fixtureSources,
|
||||
...(scenario?.sourcePaths ?? []),
|
||||
]),
|
||||
].map((relative) => sourceFingerprint(options.repoRoot, relative)),
|
||||
);
|
||||
modules = await loadModules(options.repoRoot);
|
||||
const freshness = modules.freshness.inspectElectronFixtureBuildFreshness({
|
||||
rootDir: options.repoRoot,
|
||||
});
|
||||
requireDraftProof(
|
||||
freshness.ready === true,
|
||||
"fresh formal renderer, Main, preload and App Server artifacts; build forbidden",
|
||||
);
|
||||
recordStage("current-build-freshness", {
|
||||
ready: true,
|
||||
reason: freshness.reason,
|
||||
});
|
||||
packetBytes = await fs.readFile(options.packetPath);
|
||||
const packet = JSON.parse(packetBytes.toString("utf8"));
|
||||
phase = "current-pure-protocol-and-records";
|
||||
// This only compiles pure protocol/records/appDataPaths for strict parsing;
|
||||
// it does not build renderer, Electron Host, sidecar or product runtime.
|
||||
owners = await modules.owners.bundlePersistedAtomOwners({
|
||||
repoRoot: options.repoRoot,
|
||||
workDir: runRoot,
|
||||
packet,
|
||||
});
|
||||
const eventGuard = await modules.lifecycle.verifyPersistedAtomEventGuard({
|
||||
repoRoot: options.repoRoot,
|
||||
packet: packet.eventGuard,
|
||||
});
|
||||
recordStage("main-event-authority", eventGuard);
|
||||
runtimeEnv = modules.runtime.createTempRuntimeEnv();
|
||||
modules.skills.ensureUserVisibleCapabilityReportSkill(runtimeEnv);
|
||||
const profile = runtimeEnv.electronUserDataDir;
|
||||
const roots = owners.resolveDesktopStorageRoots({
|
||||
platform: process.platform,
|
||||
hostUserData: profile,
|
||||
e2eMode: true,
|
||||
e2eUserDataDir: profile,
|
||||
localAppData: runtimeEnv.env.LOCALAPPDATA,
|
||||
});
|
||||
requireDraftProof(
|
||||
roots.appDataRoot === profile && roots.agentRoot === runtimeEnv.agentRoot,
|
||||
"real platform appDataPaths map the same isolated profile",
|
||||
);
|
||||
const binaryRecords = await Promise.all(
|
||||
["dist-electron/main/main.js", "dist-electron/preload/preload.cjs"].map(
|
||||
(relative) => sourceFingerprint(options.repoRoot, relative),
|
||||
),
|
||||
);
|
||||
const appServerBinary = modules.assets.electronAppServerBinaryDestination({
|
||||
outputRoot: modules.assets.electronAppServerResourcesRoot(
|
||||
options.repoRoot,
|
||||
),
|
||||
});
|
||||
const sidecarRecord = {
|
||||
path: appServerBinary,
|
||||
sha256: hashBytes(await fs.readFile(appServerBinary)),
|
||||
};
|
||||
binaryRecords.push(sidecarRecord);
|
||||
const appServerEnv = {
|
||||
APP_SERVER_BIN: appServerBinary,
|
||||
APP_SERVER_BACKEND_COMMAND: process.execPath,
|
||||
APP_SERVER_BACKEND_ARGS: JSON.stringify([
|
||||
runtimeEnv.backendPath,
|
||||
runtimeEnv.backendLedgerPath,
|
||||
runtimeEnv.cancelSignalPath,
|
||||
runtimeEnv.typedErrorSignalPath,
|
||||
]),
|
||||
APP_SERVER_BACKEND_TIMEOUT_MS: "130000",
|
||||
CLAW_CHAT_FIXTURE_SCENARIO: "inputbar-rich-restore",
|
||||
};
|
||||
async function launch(generation) {
|
||||
phase = `generation-${generation}-real-electron-launch`;
|
||||
requireDraftProof(active === null, "sequential Main processes only");
|
||||
const current = modules.freshness.inspectElectronFixtureBuildFreshness({
|
||||
rootDir: options.repoRoot,
|
||||
});
|
||||
requireDraftProof(
|
||||
current.ready === true,
|
||||
"fresh build before each launch; no implicit build",
|
||||
);
|
||||
for (const record of [...sourceRecords, ...binaryRecords])
|
||||
requireDraftProof(
|
||||
hashBytes(await fs.readFile(record.path)) === record.sha256,
|
||||
"fixture dependencies and real Main/preload unchanged",
|
||||
);
|
||||
const launched = await modules.launcher.launchElectronFixture({
|
||||
options,
|
||||
runtimeEnv,
|
||||
appServerEnv,
|
||||
consoleErrors,
|
||||
pageErrors,
|
||||
backendMode: "external",
|
||||
});
|
||||
active = {
|
||||
...launched,
|
||||
exit: modules.lifecycle.captureMainProcessExit(launched.app.process()),
|
||||
generation,
|
||||
};
|
||||
context = {
|
||||
page: launched.page,
|
||||
options,
|
||||
modules,
|
||||
runtimeEnv,
|
||||
protocol: owners.protocol,
|
||||
setPhase: (value) => {
|
||||
phase = value;
|
||||
process.stdout.write(`[composer-draft-fixture] stage=${value}\n`);
|
||||
},
|
||||
readConsumedDraft: async (identity) => {
|
||||
const bytes = await fs.readFile(
|
||||
path.join(roots.appDataRoot, "host-state.json"),
|
||||
);
|
||||
requireDraftProof(
|
||||
bytes.length <= owners.limits.maxFileBytes,
|
||||
"saved consumption file budget",
|
||||
);
|
||||
return assertConsumedDraft(
|
||||
owners.parseEnvelope(
|
||||
JSON.parse(bytes.toString("utf8")),
|
||||
owners.limits,
|
||||
),
|
||||
identity,
|
||||
);
|
||||
},
|
||||
expected: (record) => ({
|
||||
marker: record.marker,
|
||||
prompt: modules.constants.INPUTBAR_RICH_RESTORE_PROMPT,
|
||||
pasteText: "历史粘贴验证😀\n".repeat(180),
|
||||
imageBase64:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
path: record.path,
|
||||
pathName: record.pathName,
|
||||
}),
|
||||
};
|
||||
await modules.rpc.initializeAppServer(launched.page);
|
||||
active.trace = await modules.trace.installInvokeTraceCollector(
|
||||
launched.page,
|
||||
);
|
||||
return context;
|
||||
}
|
||||
async function stopTrace() {
|
||||
const stopped = await modules.trace.stopInvokeTraceCollector(
|
||||
active.page,
|
||||
active.trace,
|
||||
);
|
||||
const observed = modules.trace.validateInvokeTraceCollectorCapture(
|
||||
stopped,
|
||||
active.trace,
|
||||
);
|
||||
requireDraftProof(
|
||||
observed.complete && stopped.nativeRestored === true,
|
||||
"complete native Storage trace observation without missing ring entries",
|
||||
);
|
||||
const atoms = observed.ledgerEntries.filter(
|
||||
(entry) => entry.command === "persisted_atom_update",
|
||||
);
|
||||
requireDraftProof(
|
||||
atoms.every(
|
||||
(entry) =>
|
||||
entry.transport === "electron-ipc" &&
|
||||
Object.keys(entry.args_preview ?? {}).every((key) =>
|
||||
["key", "op"].includes(key),
|
||||
),
|
||||
),
|
||||
"native GUI atom calls and metadata-only diagnostics",
|
||||
);
|
||||
const rpcEntries = observed.ledgerEntries.filter(
|
||||
(entry) => entry.command === "app_server_handle_json_lines",
|
||||
);
|
||||
const appServerRequestMethods = assertAppServerRequestMethods(rpcEntries);
|
||||
const evidence = {
|
||||
generation: active.generation,
|
||||
appServerRequestMethods,
|
||||
provenance: observed.provenance,
|
||||
atomOperations: atoms.map((entry) => ({
|
||||
op: entry.args_preview?.op,
|
||||
status: entry.status,
|
||||
transport: entry.transport,
|
||||
})),
|
||||
appServerInvokeCount: observed.ledgerEntries.filter(
|
||||
(entry) => entry.command === "app_server_handle_json_lines",
|
||||
).length,
|
||||
};
|
||||
traceEvidence.push(evidence);
|
||||
active.trace = null;
|
||||
return evidence;
|
||||
}
|
||||
async function quit() {
|
||||
phase = `generation-${active.generation}-public-normal-quit`;
|
||||
if (active.trace) await stopTrace();
|
||||
const receipt = await modules.lifecycle.quitMainProcessNormally(
|
||||
active,
|
||||
options.quitTimeoutMs,
|
||||
);
|
||||
generations.push({ ...receipt, profile, generation: active.generation });
|
||||
active = null;
|
||||
}
|
||||
async function inspectSavedFiles(revision, inspectEnvelope = null) {
|
||||
const records = [];
|
||||
for (const name of ["host-state.json", "host-state.json.bak"]) {
|
||||
const file = path.join(roots.appDataRoot, name);
|
||||
const bytes = await fs.readFile(file);
|
||||
requireDraftProof(
|
||||
bytes.length <= owners.limits.maxFileBytes,
|
||||
"native saved file budget",
|
||||
);
|
||||
const envelope = owners.parseEnvelope(
|
||||
JSON.parse(bytes.toString("utf8")),
|
||||
owners.limits,
|
||||
);
|
||||
requireDraftProof(
|
||||
envelope.storeRevision === revision,
|
||||
"both native saved envelopes acknowledge exact revision",
|
||||
);
|
||||
const entryEvidence = inspectEnvelope
|
||||
? await inspectEnvelope(envelope)
|
||||
: null;
|
||||
records.push({
|
||||
...(entryEvidence === null ? {} : { entryEvidence }),
|
||||
path: file,
|
||||
sha256: hashBytes(bytes),
|
||||
storeRevision: envelope.storeRevision,
|
||||
validatedBy: "persistedAtomRecords.parseEnvelope",
|
||||
});
|
||||
}
|
||||
requireDraftProof(
|
||||
records[0].sha256 === records[1].sha256,
|
||||
"same saved primary and backup snapshot",
|
||||
);
|
||||
return records;
|
||||
}
|
||||
const scenarioResult = await (scenario?.run ?? runLocalDraftScenario)({
|
||||
options,
|
||||
runtimeEnv,
|
||||
launch,
|
||||
quit,
|
||||
stopTrace,
|
||||
inspectSavedFiles,
|
||||
recordStage,
|
||||
setPhase: (next) => {
|
||||
context?.setPhase(next);
|
||||
},
|
||||
getContext: () => context,
|
||||
});
|
||||
requireDraftProof(
|
||||
active === null,
|
||||
"scenario completed by public normal quit",
|
||||
);
|
||||
assertRestartLifecycle(generations, profile, generationCount);
|
||||
for (const record of [...sourceRecords, ...binaryRecords])
|
||||
requireDraftProof(
|
||||
hashBytes(await fs.readFile(record.path)) === record.sha256,
|
||||
"current sources and product binaries unchanged through final normal exit",
|
||||
);
|
||||
requireDraftProof(
|
||||
pageErrors.length === 0,
|
||||
"no uncaught renderer exception",
|
||||
);
|
||||
requireDraftProof(
|
||||
hashBytes(await fs.readFile(options.packetPath)) ===
|
||||
hashBytes(packetBytes),
|
||||
"verification packet unchanged throughout real run",
|
||||
);
|
||||
const summary = {
|
||||
schemaVersion: 1,
|
||||
result: "pass",
|
||||
runId: options.runId,
|
||||
guiDraftRestoration: scenarioResult.guiDraftRestoration === true,
|
||||
desktopGateB: true,
|
||||
desktopGateScope: scope,
|
||||
allGuiSlicesClosed: false,
|
||||
firstSubmissionRoutesVerified:
|
||||
scenarioResult.firstSubmissionRoutesVerified === true,
|
||||
firstSubmissionRoutes: scenarioResult.firstSubmissionRoutes ?? [],
|
||||
sameCanonicalThreadTurnItemOwner: true,
|
||||
normalExitMustNotBeReplacedByKill: true,
|
||||
generations,
|
||||
stageEvidence,
|
||||
traceEvidence,
|
||||
sourceRecords,
|
||||
binaryRecords,
|
||||
pureOwnerCompilation: owners.receipt,
|
||||
verificationPacketSha256: hashBytes(packetBytes),
|
||||
isolatedProfile: profile,
|
||||
fixtureRuntimeRoot: runtimeEnv.tempRoot,
|
||||
records: scenarioResult.records,
|
||||
consoleErrorCount: consoleErrors.length,
|
||||
pageErrorCount: pageErrors.length,
|
||||
limitations: scenario?.limitations ?? [
|
||||
"Home, direct draftTab and materialized draftTab first submissions and cold tab discovery are not covered.",
|
||||
"Synthetic DataTransfer/ClipboardEvent and DragEvent use production ingress; OS clipboard and OS drag are not covered.",
|
||||
"Saved is awaited before app.quit; pending-save abrupt exit is not covered.",
|
||||
"Public RPC interrupts the accepted Turn under a foreign GUI scope; GUI Stop button is not covered here.",
|
||||
"New edit follows accepted consume; pending old-token race is covered by behavior tests, not claimed here.",
|
||||
"Submission failure proves visible rejection, no canonical Turn, and exact saved-draft preservation; RPC rejection layer needs trace review.",
|
||||
],
|
||||
};
|
||||
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n");
|
||||
return { summaryPath, summary };
|
||||
} catch (error) {
|
||||
// Teardown still uses public normal quit. A failed quit stays a failed run;
|
||||
// do not kill the process and then present that as normal-restart evidence.
|
||||
let teardown = null;
|
||||
if (active && modules) {
|
||||
await active.page
|
||||
.screenshot({ path: path.join(runRoot, "failed-gui.png") })
|
||||
.catch(() => undefined);
|
||||
if (active.trace)
|
||||
await modules.trace
|
||||
.stopInvokeTraceCollector(active.page, active.trace)
|
||||
.catch(() => undefined);
|
||||
try {
|
||||
teardown = await modules.lifecycle.quitMainProcessNormally(
|
||||
active,
|
||||
options.quitTimeoutMs,
|
||||
);
|
||||
} catch {
|
||||
teardown = { pid: active.exit.pid, normalQuitFailed: true };
|
||||
}
|
||||
}
|
||||
const failure = {
|
||||
schemaVersion: 1,
|
||||
result: "fail",
|
||||
runId: options.runId,
|
||||
phase,
|
||||
guiDraftRestoration: false,
|
||||
desktopGateB: false,
|
||||
desktopGateScope: scope,
|
||||
allGuiSlicesClosed: false,
|
||||
generations,
|
||||
stageEvidence,
|
||||
traceEvidence,
|
||||
teardown,
|
||||
errorClass: error instanceof Error ? error.name : "unknown",
|
||||
errorLocations: String(error?.stack ?? "")
|
||||
.split("\n")
|
||||
.filter((line) => /^\s+at /.test(line))
|
||||
.slice(0, 5),
|
||||
failureReason: /^Composer draft GUI fixture (failed|timed out):/.test(
|
||||
error?.message ?? "",
|
||||
)
|
||||
? error.message
|
||||
: "Current dependency or GUI action failed; raw error omitted",
|
||||
diagnostic:
|
||||
"Inspect the failed phase; request bodies and rich draft data are intentionally omitted.",
|
||||
sourceRecords,
|
||||
isolatedProfile: runtimeEnv?.electronUserDataDir ?? null,
|
||||
};
|
||||
await fs.writeFile(summaryPath, JSON.stringify(failure, null, 2) + "\n");
|
||||
throw new Error(
|
||||
`Composer draft GUI fixture failed at ${phase}; evidence=${summaryPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
export const atomKey = "composer-drafts-v1";
|
||||
export const sha256 = (value) =>
|
||||
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
|
||||
export function requireDraftProof(condition, phase) {
|
||||
if (!condition)
|
||||
throw new Error(`Composer draft GUI fixture failed: ${phase}`);
|
||||
}
|
||||
|
||||
export function assertAppServerRequestMethods(entries) {
|
||||
return entries.flatMap((entry) => {
|
||||
const lines = entry.args_preview?.request?.lines;
|
||||
requireDraftProof(
|
||||
entry.transport === "electron-ipc" &&
|
||||
Array.isArray(lines) &&
|
||||
lines.length > 0 &&
|
||||
lines.every((line) => typeof line === "string" && line.trim()),
|
||||
"complete native App Server request-method projection; no silent parse gaps",
|
||||
);
|
||||
const requests = lines.map((line) => JSON.parse(line));
|
||||
// Codex's current envelopes omit the JSON-RPC version field.
|
||||
requireDraftProof(
|
||||
requests.every(
|
||||
(request) =>
|
||||
request &&
|
||||
!Array.isArray(request) &&
|
||||
(request.jsonrpc === undefined || request.jsonrpc === "2.0") &&
|
||||
typeof request.method === "string" &&
|
||||
request.method.length > 0,
|
||||
),
|
||||
"complete native App Server request-method projection; no silent parse gaps",
|
||||
);
|
||||
return requests.map((request) => request.method);
|
||||
});
|
||||
}
|
||||
|
||||
export function savedDraft(result, scope) {
|
||||
requireDraftProof(
|
||||
result?.outcome === "ready" &&
|
||||
result.key === atomKey &&
|
||||
result.data?.queryKind === "scope" &&
|
||||
isDeepStrictEqual(result.data.view?.scope, scope),
|
||||
"same native atom and exact scope",
|
||||
);
|
||||
const entry = result.data.view.entry;
|
||||
requireDraftProof(
|
||||
entry?.state === "draft" &&
|
||||
typeof entry.draftId === "string" &&
|
||||
entry.draftId.length > 0 &&
|
||||
typeof entry.scopeVersion === "string" &&
|
||||
entry.scopeVersion.trim().length > 0 &&
|
||||
Number.isSafeInteger(result.storeRevision) &&
|
||||
result.persistence?.state === "saved" &&
|
||||
result.persistence.savedRevision >= result.storeRevision,
|
||||
"draft identity applied and acknowledged saved on disk",
|
||||
);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function walk(node, result = []) {
|
||||
result.push(node);
|
||||
for (const child of node?.content ?? []) walk(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fixed literals are independent of the product serializer and observed DOM.
|
||||
export function assertRichDraft(entry, expected) {
|
||||
const payload = entry?.draft;
|
||||
const document = payload?.document;
|
||||
requireDraftProof(
|
||||
payload?.format === "composer-draft" &&
|
||||
payload.version === 1 &&
|
||||
payload.plainTextMode === false &&
|
||||
document?.editorDocument?.type === "doc",
|
||||
"current rich document codec",
|
||||
);
|
||||
const nodes = walk(document.editorDocument);
|
||||
const code = nodes.filter((node) => node?.type === "code_block");
|
||||
const links = nodes.flatMap((node) =>
|
||||
(node?.marks ?? []).filter((mark) => mark.type === "link"),
|
||||
);
|
||||
requireDraftProof(
|
||||
code.length === 1 &&
|
||||
code[0].attrs?.language === "typescript" &&
|
||||
code[0].content.map((node) => node.text).join("") ===
|
||||
" const value = '中😀';\n````\n\n" &&
|
||||
links.length === 1 &&
|
||||
links[0].attrs?.href === "https://example.invalid/marks" &&
|
||||
links[0].attrs.title === "标题",
|
||||
"literal code body, language and link mark",
|
||||
);
|
||||
const pastes = nodes.filter((node) => node?.type === "paste");
|
||||
requireDraftProof(
|
||||
pastes.length === 1 &&
|
||||
pastes[0].attrs.text === expected.pasteText &&
|
||||
Object.values(document.pendingPastes ?? {}).some(
|
||||
(paste) => paste?.text === expected.pasteText,
|
||||
) &&
|
||||
document.text.includes(expected.prompt) &&
|
||||
document.text.includes(expected.marker),
|
||||
"hidden paste source and independent workspace marker",
|
||||
);
|
||||
requireDraftProof(
|
||||
document.attachments?.length === 1 &&
|
||||
document.attachments[0].mediaType === "image/png" &&
|
||||
document.attachments[0].data === expected.imageBase64 &&
|
||||
!Object.hasOwn(document.attachments[0], "previewUrl") &&
|
||||
document.pathReferences?.length === 1 &&
|
||||
document.pathReferences[0].path === expected.path &&
|
||||
document.pathReferences[0].name === expected.pathName &&
|
||||
document.pathReferences[0].isDir === false,
|
||||
"durable image and complete path resource",
|
||||
);
|
||||
requireDraftProof(
|
||||
document.inputCapabilityRoute?.kind === "installed_skill" &&
|
||||
document.inputCapabilityRoute.skillKey === "capability-report" &&
|
||||
String(document.inputCapabilityRoute.skillName)
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\s_-]+/g, "") === "capabilityreport" &&
|
||||
document.mentionBindings?.some(
|
||||
(binding) => binding.id === "user:capability-report",
|
||||
),
|
||||
"installed capability route and mention binding",
|
||||
);
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(document.selectionStart) &&
|
||||
document.selectionEnd - document.selectionStart === 3 &&
|
||||
document.text.slice(document.selectionStart, document.selectionEnd) ===
|
||||
expected.marker.slice(-3),
|
||||
"persisted noncollapsed user selection",
|
||||
);
|
||||
return summarizeDraft(entry);
|
||||
}
|
||||
|
||||
export function summarizeDraft(entry) {
|
||||
return {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
payloadSha256: sha256(entry.draft),
|
||||
documentSha256: sha256(entry.draft.document.editorDocument),
|
||||
textLength: entry.draft.document.text.length,
|
||||
pasteSourceSha256: sha256(entry.draft.document.pendingPastes),
|
||||
attachmentsSha256: sha256(entry.draft.document.attachments),
|
||||
pathsSha256: sha256(entry.draft.document.pathReferences),
|
||||
capabilitySha256: sha256(entry.draft.document.inputCapabilityRoute ?? null),
|
||||
selection: [
|
||||
entry.draft.document.selectionStart,
|
||||
entry.draft.document.selectionEnd,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The caller parses actual disk bytes with the current records owner first. */
|
||||
export function assertConsumedDraft(envelope, identity) {
|
||||
const atom = envelope.atoms[atomKey];
|
||||
const entry = atom.entries[identity.draftId];
|
||||
requireDraftProof(
|
||||
entry?.state === "consumed" &&
|
||||
entry.consumption === "accepted" &&
|
||||
entry.draftId === identity.draftId &&
|
||||
entry.scopeVersion === identity.scopeVersion &&
|
||||
!Object.values(atom.bindings).includes(identity.draftId),
|
||||
"saved accepted tombstone has exact captured identity and no scope binding",
|
||||
);
|
||||
return {
|
||||
...identity,
|
||||
storeRevision: envelope.storeRevision,
|
||||
consumption: entry.consumption,
|
||||
noRemainingBinding: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertRestoredDraft(result, scope, original) {
|
||||
const entry = savedDraft(result, scope);
|
||||
requireDraftProof(
|
||||
entry.draftId === original.draftId &&
|
||||
entry.scopeVersion === original.scopeVersion &&
|
||||
isDeepStrictEqual(entry.draft, original.draft),
|
||||
"cold restore preserves exact rich JSON, resources, selection and identity",
|
||||
);
|
||||
return summarizeDraft(entry);
|
||||
}
|
||||
|
||||
export function assertRestoredDom(actual, original, sessionId) {
|
||||
const projection = (snapshot) => ({
|
||||
text: snapshot.text,
|
||||
documentProjection: snapshot.documentProjection,
|
||||
marks: snapshot.marks,
|
||||
tokens: snapshot.tokens,
|
||||
images: snapshot.images,
|
||||
paths: snapshot.paths,
|
||||
});
|
||||
requireDraftProof(
|
||||
actual?.electron === true &&
|
||||
actual.preload === true &&
|
||||
actual.editorCount === 1 &&
|
||||
actual.sessionId === sessionId &&
|
||||
actual.disabled === false &&
|
||||
actual.images?.every((image) => image.decoded === true) &&
|
||||
isDeepStrictEqual(projection(actual), projection(original)),
|
||||
"same-session real Composer rendered semantics restored",
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
projectionSha256: sha256(projection(actual)),
|
||||
editorCount: actual.editorCount,
|
||||
imageCount: actual.images.length,
|
||||
pathCount: actual.paths.length,
|
||||
tokenCount: actual.tokens.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertWorkspaceIndex(result, workspaceId, scope, entry) {
|
||||
requireDraftProof(
|
||||
result?.outcome === "ready" &&
|
||||
result.key === atomKey &&
|
||||
result.data?.queryKind === "workspace-index" &&
|
||||
result.data.workspaceId === workspaceId &&
|
||||
result.data.items?.length === 1 &&
|
||||
isDeepStrictEqual(result.data.items[0].scope, scope) &&
|
||||
isDeepStrictEqual(result.data.items[0].identity, {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
}),
|
||||
"three workspace indexes contain only their own exact identity",
|
||||
);
|
||||
return { workspaceId, itemsSha256: sha256(result.data.items) };
|
||||
}
|
||||
|
||||
export function assertRestartLifecycle(
|
||||
generations,
|
||||
expectedProfile,
|
||||
expectedCount = 3,
|
||||
) {
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(expectedCount) &&
|
||||
expectedCount >= 2 &&
|
||||
generations.length === expectedCount &&
|
||||
new Set(generations.map((entry) => entry.pid)).size === expectedCount &&
|
||||
generations.every(
|
||||
(entry) =>
|
||||
Number.isSafeInteger(entry.pid) &&
|
||||
entry.pid > 0 &&
|
||||
entry.profile === expectedProfile &&
|
||||
entry.mode === "normal" &&
|
||||
entry.exitCode === 0 &&
|
||||
entry.signal === null &&
|
||||
entry.exited === true,
|
||||
),
|
||||
"exact distinct real Main PIDs, same profile and public normal quit",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
atomKey,
|
||||
assertAppServerRequestMethods,
|
||||
assertRestartLifecycle,
|
||||
assertConsumedDraft,
|
||||
assertRestoredDom,
|
||||
assertRestoredDraft,
|
||||
assertRichDraft,
|
||||
assertWorkspaceIndex,
|
||||
savedDraft,
|
||||
sha256,
|
||||
} from "./composer-draft-fixture-evidence.mjs";
|
||||
import { parseComposerDraftFixtureArgs } from "./composer-draft-fixture-driver.mjs";
|
||||
|
||||
const scope = { workspaceId: "workspace-a", kind: "local", id: "session-a" };
|
||||
|
||||
const nativeTrace = (lines) => [
|
||||
{ transport: "electron-ipc", args_preview: { request: { lines } } },
|
||||
];
|
||||
|
||||
test("native method proof accepts current Codex request and notification envelopes", () => {
|
||||
assert.deepEqual(
|
||||
assertAppServerRequestMethods(
|
||||
nativeTrace([
|
||||
'{"id":1,"method":"thread/list","params":{"limit":100}}',
|
||||
'{"method":"$/cancelRequest","params":{"id":2}}',
|
||||
]),
|
||||
),
|
||||
["thread/list", "$/cancelRequest"],
|
||||
);
|
||||
});
|
||||
|
||||
test("native method proof accepts explicit JSON-RPC 2.0 and rejects other versions", () => {
|
||||
assert.deepEqual(
|
||||
assertAppServerRequestMethods(
|
||||
nativeTrace(['{"jsonrpc":"2.0","id":1,"method":"thread/read"}']),
|
||||
),
|
||||
["thread/read"],
|
||||
);
|
||||
assert.throws(() =>
|
||||
assertAppServerRequestMethods(
|
||||
nativeTrace(['{"jsonrpc":"1.0","id":1,"method":"thread/read"}']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("native method proof rejects missing, empty, malformed, response-only and non-native trace", () => {
|
||||
for (const lines of [
|
||||
undefined,
|
||||
[],
|
||||
[""],
|
||||
["null"],
|
||||
["[]"],
|
||||
["{"],
|
||||
['{"id":1,"result":{}}'],
|
||||
['{"method":""}'],
|
||||
['{"id":1,"method":"thread/list"}', "malformed"],
|
||||
])
|
||||
assert.throws(() => assertAppServerRequestMethods(nativeTrace(lines)));
|
||||
const trace = nativeTrace(['{"id":1,"method":"thread/list"}']);
|
||||
trace[0].transport = "http";
|
||||
assert.throws(() => assertAppServerRequestMethods(trace));
|
||||
});
|
||||
const expected = {
|
||||
prompt: "请结合截图",
|
||||
marker: "GUI工作区一号",
|
||||
pasteText: "独立隐藏源\n".repeat(200),
|
||||
imageBase64: "aGVsbG8=",
|
||||
path: "/isolated/file.md",
|
||||
pathName: "file.md",
|
||||
};
|
||||
|
||||
function fixture() {
|
||||
const text = `${expected.prompt} ${expected.marker}`;
|
||||
const document = {
|
||||
editorDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "中😀",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://example.invalid/marks",
|
||||
title: "标题",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paste",
|
||||
attrs: { id: "paste-1", text: expected.pasteText },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "code_block",
|
||||
attrs: { language: "typescript" },
|
||||
content: [
|
||||
{ type: "text", text: " const value = '中😀';\n````\n\n" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
text,
|
||||
selectionStart: text.length - 3,
|
||||
selectionEnd: text.length,
|
||||
pendingPastes: [{ placeholder: "paste", text: expected.pasteText }],
|
||||
attachments: [{ mediaType: "image/png", data: expected.imageBase64 }],
|
||||
pathReferences: [
|
||||
{ path: expected.path, name: expected.pathName, isDir: false },
|
||||
],
|
||||
mentionBindings: [{ id: "user:capability-report" }],
|
||||
inputCapabilityRoute: {
|
||||
kind: "installed_skill",
|
||||
skillKey: "capability-report",
|
||||
skillName: "Capability Report",
|
||||
},
|
||||
};
|
||||
return {
|
||||
outcome: "ready",
|
||||
key: atomKey,
|
||||
storeRevision: 7,
|
||||
persistence: { state: "saved", savedRevision: 7 },
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: {
|
||||
scope,
|
||||
entry: {
|
||||
state: "draft",
|
||||
draftId: "draft-a",
|
||||
scopeVersion: "version-a",
|
||||
draft: {
|
||||
format: "composer-draft",
|
||||
version: 1,
|
||||
plainTextMode: false,
|
||||
document,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dom = {
|
||||
electron: true,
|
||||
preload: true,
|
||||
editorCount: 1,
|
||||
sessionId: "session-a",
|
||||
disabled: false,
|
||||
text: "rendered",
|
||||
documentProjection: [{ type: "p" }],
|
||||
marks: { links: ["fixture"] },
|
||||
tokens: [{ type: "paste", id: "1" }],
|
||||
images: [{ src: "data:image/png;base64,aGVsbG8=", decoded: true }],
|
||||
paths: [{ path: expected.path }],
|
||||
};
|
||||
|
||||
test("accepts independently literal rich resources and exact native saved identity", () => {
|
||||
const result = fixture();
|
||||
assert.equal(savedDraft(result, scope).draftId, "draft-a");
|
||||
assert.match(
|
||||
assertRichDraft(result.data.view.entry, expected).payloadSha256,
|
||||
/^[a-f0-9]{64}$/,
|
||||
);
|
||||
assertRestoredDraft(result, scope, structuredClone(result.data.view.entry));
|
||||
assertRestoredDom(dom, structuredClone(dom), "session-a");
|
||||
});
|
||||
|
||||
test("summarizes an exact restored plain draft without an optional capability route", () => {
|
||||
const result = fixture();
|
||||
delete result.data.view.entry.draft.document.inputCapabilityRoute;
|
||||
const original = structuredClone(result.data.view.entry);
|
||||
const restored = assertRestoredDraft(result, scope, original);
|
||||
assert.equal(restored.capabilitySha256, sha256(null));
|
||||
assert.equal(restored.payloadSha256, sha256(original.draft));
|
||||
assert.throws(() => assertRichDraft(original, expected), /fixture failed/);
|
||||
result.data.view.entry.draft.document.inputCapabilityRoute = {
|
||||
kind: "installed_skill",
|
||||
skillKey: "other",
|
||||
skillName: "Other",
|
||||
};
|
||||
assert.throws(
|
||||
() => assertRestoredDraft(result, scope, original),
|
||||
/fixture failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("disk consumption requires the exact accepted identity and no remaining binding", () => {
|
||||
const identity = { draftId: "draft-a", scopeVersion: "version-a" };
|
||||
const envelope = {
|
||||
storeRevision: 8,
|
||||
atoms: {
|
||||
[atomKey]: {
|
||||
bindings: {},
|
||||
entries: {
|
||||
"draft-a": {
|
||||
...identity,
|
||||
state: "consumed",
|
||||
consumption: "accepted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.deepEqual(assertConsumedDraft(envelope, identity), {
|
||||
...identity,
|
||||
storeRevision: 8,
|
||||
consumption: "accepted",
|
||||
noRemainingBinding: true,
|
||||
});
|
||||
for (const change of [
|
||||
(value) => {
|
||||
value.atoms[atomKey].entries[identity.draftId].scopeVersion = "other";
|
||||
},
|
||||
(value) => {
|
||||
value.atoms[atomKey].entries[identity.draftId].state = "draft";
|
||||
},
|
||||
(value) => {
|
||||
value.atoms[atomKey].entries[identity.draftId].consumption = "clear";
|
||||
},
|
||||
(value) => {
|
||||
value.atoms[atomKey].bindings.scope = identity.draftId;
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(envelope);
|
||||
change(changed);
|
||||
assert.throws(
|
||||
() => assertConsumedDraft(changed, identity),
|
||||
/fixture failed/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, change] of [
|
||||
[
|
||||
"foreign scope",
|
||||
(result) => {
|
||||
result.data.view.scope = { ...scope, workspaceId: "other" };
|
||||
},
|
||||
],
|
||||
[
|
||||
"applied memory without disk save",
|
||||
(result) => {
|
||||
result.persistence = { state: "pending", savedRevision: 6 };
|
||||
},
|
||||
],
|
||||
[
|
||||
"stale saved revision",
|
||||
(result) => {
|
||||
result.persistence.savedRevision = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
"consumed tombstone",
|
||||
(result) => {
|
||||
result.data.view.entry.state = "consumed";
|
||||
},
|
||||
],
|
||||
[
|
||||
"foreign native atom",
|
||||
(result) => {
|
||||
result.key = "other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"numeric version",
|
||||
(result) => {
|
||||
result.data.view.entry.scopeVersion = 2;
|
||||
},
|
||||
],
|
||||
[
|
||||
"empty opaque version",
|
||||
(result) => {
|
||||
result.data.view.entry.scopeVersion = "";
|
||||
},
|
||||
],
|
||||
])
|
||||
test(`rejects ${name} as GUI saved evidence`, () => {
|
||||
const result = fixture();
|
||||
change(result);
|
||||
assert.throws(() => savedDraft(result, scope), /fixture failed/);
|
||||
});
|
||||
|
||||
for (const [name, change] of [
|
||||
[
|
||||
"flattened link",
|
||||
(draft) => {
|
||||
delete draft.document.editorDocument.content[0].content[0].marks;
|
||||
},
|
||||
],
|
||||
[
|
||||
"lost code language",
|
||||
(draft) => {
|
||||
draft.document.editorDocument.content[1].attrs.language = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
"lost hidden paste source",
|
||||
(draft) => {
|
||||
draft.document.pendingPastes[0].text = "placeholder only";
|
||||
},
|
||||
],
|
||||
[
|
||||
"ephemeral blob preview",
|
||||
(draft) => {
|
||||
draft.document.attachments[0].previewUrl = "blob:dead";
|
||||
},
|
||||
],
|
||||
[
|
||||
"altered durable image",
|
||||
(draft) => {
|
||||
draft.document.attachments[0].data = "other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"foreign path",
|
||||
(draft) => {
|
||||
draft.document.pathReferences[0].path = "/other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing capability route",
|
||||
(draft) => {
|
||||
delete draft.document.inputCapabilityRoute;
|
||||
},
|
||||
],
|
||||
[
|
||||
"catalog key used as execution key",
|
||||
(draft) => {
|
||||
draft.document.inputCapabilityRoute.skillKey = "user:capability-report";
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing mention binding",
|
||||
(draft) => {
|
||||
draft.document.mentionBindings = [];
|
||||
},
|
||||
],
|
||||
[
|
||||
"collapsed selection",
|
||||
(draft) => {
|
||||
draft.document.selectionStart = draft.document.selectionEnd;
|
||||
},
|
||||
],
|
||||
])
|
||||
test(`rejects ${name} despite identical visible plain text`, () => {
|
||||
const result = fixture();
|
||||
change(result.data.view.entry.draft);
|
||||
assert.throws(
|
||||
() => assertRichDraft(result.data.view.entry, expected),
|
||||
/fixture failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("cold restore rejects matching plaintext with changed identity or hidden resource", () => {
|
||||
const before = fixture().data.view.entry;
|
||||
for (const change of [
|
||||
(entry) => {
|
||||
entry.draftId = "new";
|
||||
},
|
||||
(entry) => {
|
||||
entry.scopeVersion = "version-b";
|
||||
},
|
||||
(entry) => {
|
||||
entry.draft.document.pendingPastes[0].text = "changed hidden source";
|
||||
},
|
||||
]) {
|
||||
const after = fixture();
|
||||
change(after.data.view.entry);
|
||||
assert.throws(
|
||||
() => assertRestoredDraft(after, scope, before),
|
||||
/fixture failed/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("DOM gate rejects a hidden or foreign editor and undecoded image", () => {
|
||||
for (const change of [
|
||||
(snapshot) => {
|
||||
snapshot.editorCount = 2;
|
||||
},
|
||||
(snapshot) => {
|
||||
snapshot.sessionId = "other";
|
||||
},
|
||||
(snapshot) => {
|
||||
snapshot.disabled = true;
|
||||
},
|
||||
(snapshot) => {
|
||||
snapshot.electron = false;
|
||||
},
|
||||
(snapshot) => {
|
||||
snapshot.images[0].decoded = false;
|
||||
},
|
||||
(snapshot) => {
|
||||
snapshot.tokens[0].id = "foreign";
|
||||
},
|
||||
]) {
|
||||
const snapshot = structuredClone(dom);
|
||||
change(snapshot);
|
||||
assert.throws(
|
||||
() => assertRestoredDom(snapshot, dom, "session-a"),
|
||||
/fixture failed/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace index rejects foreign or duplicate scopes", () => {
|
||||
const entry = fixture().data.view.entry;
|
||||
const index = {
|
||||
key: atomKey,
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId: scope.workspaceId,
|
||||
items: [
|
||||
{
|
||||
scope,
|
||||
identity: {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
assertWorkspaceIndex(index, scope.workspaceId, scope, entry);
|
||||
for (const change of [
|
||||
(result) => {
|
||||
result.data.items.push(structuredClone(result.data.items[0]));
|
||||
},
|
||||
(result) => {
|
||||
result.data.items[0].scope.workspaceId = "foreign";
|
||||
},
|
||||
(result) => {
|
||||
result.data.items[0].identity.scopeVersion = "version-b";
|
||||
},
|
||||
]) {
|
||||
const result = structuredClone(index);
|
||||
change(result);
|
||||
assert.throws(
|
||||
() => assertWorkspaceIndex(result, scope.workspaceId, scope, entry),
|
||||
/fixture failed/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("normal restart proof rejects window-only closure, kill, duplicate PID or foreign profile", () => {
|
||||
const profile = "/isolated/profile";
|
||||
const generations = [1, 2, 3].map((pid) => ({
|
||||
pid,
|
||||
profile,
|
||||
mode: "normal",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
exited: true,
|
||||
}));
|
||||
assertRestartLifecycle(generations, profile);
|
||||
for (const change of [
|
||||
(records) => {
|
||||
records[0].exited = false;
|
||||
},
|
||||
(records) => {
|
||||
records[0].signal = "SIGKILL";
|
||||
},
|
||||
(records) => {
|
||||
records[1].pid = records[0].pid;
|
||||
},
|
||||
(records) => {
|
||||
records[2].profile = "/foreign";
|
||||
},
|
||||
(records) => {
|
||||
records[2].mode = "window-closed";
|
||||
},
|
||||
]) {
|
||||
const records = structuredClone(generations);
|
||||
change(records);
|
||||
assert.throws(
|
||||
() => assertRestartLifecycle(records, profile),
|
||||
/fixture failed/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI refuses implicit evidence, unsafe run IDs and oversized waits", () => {
|
||||
assert.throws(() => parseComposerDraftFixtureArgs([]), /fixture failed/);
|
||||
const args = [
|
||||
"--evidence-dir",
|
||||
"evidence",
|
||||
"--verification-packet",
|
||||
"packet.json",
|
||||
"--run-id",
|
||||
"fixture",
|
||||
];
|
||||
assert.equal(parseComposerDraftFixtureArgs(args, "/repo").repoRoot, "/repo");
|
||||
assert.throws(
|
||||
() => parseComposerDraftFixtureArgs([...args, "--run-id", "../reuse"]),
|
||||
/fixture failed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseComposerDraftFixtureArgs([...args, "--timeout-ms", "60001"]),
|
||||
/fixture failed/,
|
||||
);
|
||||
assert.equal(parseComposerDraftFixtureArgs(["--help"]).help, true);
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
const atomKey = "composer-drafts-v1";
|
||||
export const firstSendRoutes = Object.freeze([
|
||||
"home",
|
||||
"search-new",
|
||||
"project-new",
|
||||
]);
|
||||
/** Three public entries share one Home scope contract and create independent receipts. */
|
||||
export function assertFirstSendRouteCoverage(proofs) {
|
||||
requireFirstSend(
|
||||
proofs.length === 3 &&
|
||||
firstSendRoutes.every((route, index) => proofs[index]?.route === route),
|
||||
"all three public first-send entries are verified once",
|
||||
);
|
||||
requireFirstSend(
|
||||
proofs.every((proof) => {
|
||||
const scope = proof.source?.sourceScope;
|
||||
return (
|
||||
scope?.kind === "home" &&
|
||||
scope.id === "new-conversation" &&
|
||||
(proof.route === "project-new"
|
||||
? nonempty(scope.workspaceId)
|
||||
: scope.workspaceId === null) &&
|
||||
scope.workspaceId === proof.workspaceId
|
||||
);
|
||||
}),
|
||||
"public entries prove the exact stable Home and explicit workspace scopes",
|
||||
);
|
||||
for (const key of ["sessionId", "threadId"])
|
||||
requireFirstSend(
|
||||
new Set(proofs.map((proof) => proof.receipt?.[key])).size === 3 &&
|
||||
proofs.every((proof) => nonempty(proof.receipt?.[key])),
|
||||
"each public entry creates an independent canonical " + key,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const newEditText = "首发接受后的新编辑,中😀;旧发送不得清除。";
|
||||
|
||||
export function requireFirstSend(condition, phase) {
|
||||
if (!condition)
|
||||
throw new Error("Composer first-send fixture failed: " + phase);
|
||||
}
|
||||
|
||||
const digest = (value) =>
|
||||
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
const nonempty = (value) =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
const hashBytes = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function strictBase64(value) {
|
||||
requireFirstSend(nonempty(value), "image source bytes exist");
|
||||
const bytes = Buffer.from(value, "base64");
|
||||
requireFirstSend(
|
||||
bytes.length > 0 && bytes.toString("base64") === value,
|
||||
"image source is complete canonical base64",
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Retain resource identity/digests only; source body and image data stay local. */
|
||||
export function captureFirstSendResources(entry, expected) {
|
||||
capturedIdentity(entry);
|
||||
const doc = entry.draft?.document;
|
||||
const bytes = strictBase64(expected.imageBase64);
|
||||
const image = doc?.attachments?.[0];
|
||||
const reference = doc?.pathReferences?.[0];
|
||||
const binding = doc?.mentionBindings?.[0];
|
||||
const skill = expected.skillIdentity;
|
||||
const submission = expected.expectedSubmission;
|
||||
requireFirstSend(
|
||||
doc?.attachments?.length === 1 &&
|
||||
image.mediaType === "image/png" &&
|
||||
strictBase64(image.data).equals(bytes) &&
|
||||
doc.pathReferences?.length === 1 &&
|
||||
reference.path === expected.path &&
|
||||
reference.name === expected.pathName &&
|
||||
reference.id === "file:" + expected.path &&
|
||||
reference.isDir === false &&
|
||||
doc.mentionBindings?.length === 1 &&
|
||||
binding.kind === "skill" &&
|
||||
binding.id === skill?.key &&
|
||||
binding.name === skill.name &&
|
||||
binding.path === skill.path &&
|
||||
nonempty(skill.path) &&
|
||||
doc.inputCapabilityRoute?.kind === "installed_skill" &&
|
||||
doc.inputCapabilityRoute.skillKey === skill.name &&
|
||||
/^[a-f0-9]{64}$/.test(expected.skillFileSha256 ?? "") &&
|
||||
Object.values(doc.pendingPastes ?? {}).length === 1 &&
|
||||
Object.values(doc.pendingPastes)[0].text === expected.pasteText &&
|
||||
Object.values(doc.pendingPastes)[0].placeholder ===
|
||||
submission?.textElements?.[0]?.placeholder &&
|
||||
nonempty(expected.pasteText) &&
|
||||
nonempty(submission?.text) &&
|
||||
doc.text === submission.displayText &&
|
||||
submission.text.includes(expected.prompt) &&
|
||||
submission.text.includes(expected.marker) &&
|
||||
submission.text.includes(expected.pasteText) &&
|
||||
Array.isArray(submission.textElements) &&
|
||||
submission.textElements.length === 2 &&
|
||||
submission.marks?.codeBlocks?.length === 1 &&
|
||||
submission.marks?.links?.length === 1,
|
||||
"captured native image, file and installed Skill match independent fixture resources",
|
||||
);
|
||||
const imageSha256 = hashBytes(bytes);
|
||||
return {
|
||||
image: {
|
||||
mimeType: image.mediaType,
|
||||
bytes: bytes.length,
|
||||
sha256: imageSha256,
|
||||
canonicalUrl: "sidecar://media/input-" + imageSha256 + ".png",
|
||||
},
|
||||
path: { path: reference.path, name: reference.name, isDir: false },
|
||||
skill: {
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
key: skill.key,
|
||||
fileSha256: expected.skillFileSha256,
|
||||
},
|
||||
expandedText: {
|
||||
length: Buffer.byteLength(submission.text, "utf8"),
|
||||
sha256: hashBytes(submission.text),
|
||||
textElementsSha256: digest(submission.textElements),
|
||||
},
|
||||
hiddenPaste: {
|
||||
bytes: Buffer.byteLength(expected.pasteText, "utf8"),
|
||||
sha256: hashBytes(expected.pasteText),
|
||||
},
|
||||
code: {
|
||||
language: submission.marks.codeBlocks[0].language,
|
||||
bodySha256: hashBytes(submission.marks.codeBlocks[0].body),
|
||||
},
|
||||
link: {
|
||||
href: submission.marks.links[0].href,
|
||||
title: submission.marks.links[0].title,
|
||||
labelSha256: hashBytes(submission.marks.links[0].label),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reuse the current full-input oracle, then bind real media bytes to source. */
|
||||
export function assertFirstSendResourceReceipt(
|
||||
source,
|
||||
receipt,
|
||||
{ richInput, mediaRead, expectedSubmission },
|
||||
) {
|
||||
const resources = source.resources;
|
||||
const media = mediaRead?.result;
|
||||
requireFirstSend(
|
||||
resources &&
|
||||
receipt.canonicalAccepted === true &&
|
||||
richInput?.ok === true &&
|
||||
richInput.threadId === receipt.threadId &&
|
||||
richInput.itemId === receipt.userItemId &&
|
||||
richInput.imageUrl === resources.image.canonicalUrl &&
|
||||
richInput.canonicalFullInputPreserved === true &&
|
||||
richInput.canonicalSkillPreserved === true &&
|
||||
richInput.pathIdentityPreserved === true &&
|
||||
richInput.pasteExpanded === true &&
|
||||
richInput.textElementsPreserved === true &&
|
||||
richInput.canonicalImageBytesPreserved === true &&
|
||||
hashBytes(expectedSubmission.text) === resources.expandedText.sha256 &&
|
||||
digest(expectedSubmission.textElements) ===
|
||||
resources.expandedText.textElementsSha256 &&
|
||||
media?.threadId === receipt.threadId &&
|
||||
media.uri === resources.image.canonicalUrl &&
|
||||
media.mimeType === resources.image.mimeType &&
|
||||
media.offset === 0 &&
|
||||
media.bytes === resources.image.bytes &&
|
||||
media.totalBytes === resources.image.bytes &&
|
||||
media.length === resources.image.bytes &&
|
||||
media.hasMore === false &&
|
||||
media.sha256 === "sha256:" + resources.image.sha256 &&
|
||||
hashBytes(strictBase64(media.contentBase64)) === resources.image.sha256,
|
||||
"complete canonical input and actual thread-scoped media bytes match the captured rich source",
|
||||
);
|
||||
return {
|
||||
completeCanonicalInputVerified: true,
|
||||
sourceResourcesSha256: digest(resources),
|
||||
imageSha256: resources.image.sha256,
|
||||
mediaReadSha256: media.sha256,
|
||||
...richInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function capturedIdentity(entry) {
|
||||
requireFirstSend(
|
||||
entry?.state === "draft" &&
|
||||
nonempty(entry.draftId) &&
|
||||
nonempty(entry.scopeVersion),
|
||||
"captured native identity uses opaque nonempty strings",
|
||||
);
|
||||
return { draftId: entry.draftId, scopeVersion: entry.scopeVersion };
|
||||
}
|
||||
|
||||
/** Public metadata and an exact saved payload bind the sole visible Composer. */
|
||||
export function assertFirstSendScopeObservation(route, proof) {
|
||||
const { workspaceId, workspaceIndex, scopeRead, dom, expectedMarker } = proof;
|
||||
const kind = "home";
|
||||
requireFirstSend(firstSendRoutes.includes(route), "known first-send route");
|
||||
requireFirstSend(
|
||||
(workspaceId === null || nonempty(workspaceId)) &&
|
||||
workspaceIndex?.key === atomKey &&
|
||||
workspaceIndex.outcome === "ready" &&
|
||||
workspaceIndex.data?.queryKind === "workspace-index" &&
|
||||
workspaceIndex.data.workspaceId === workspaceId &&
|
||||
Array.isArray(workspaceIndex.data.items) &&
|
||||
workspaceIndex.data.items.every(
|
||||
(item) => item.scope?.workspaceId === workspaceId,
|
||||
),
|
||||
"same atom public workspace index contains no foreign workspace",
|
||||
);
|
||||
const matches = workspaceIndex.data.items.filter(
|
||||
(item) => item.scope?.kind === kind,
|
||||
);
|
||||
requireFirstSend(
|
||||
matches.length === 1 &&
|
||||
nonempty(matches[0].scope.id) &&
|
||||
!matches[0].sessionId &&
|
||||
!matches[0].threadId &&
|
||||
matches[0].scope.id === "new-conversation",
|
||||
"exactly one actual unpromoted source scope; no fallback identity",
|
||||
);
|
||||
const item = matches[0];
|
||||
requireFirstSend(
|
||||
scopeRead?.key === atomKey &&
|
||||
scopeRead.outcome === "ready" &&
|
||||
scopeRead.data?.queryKind === "scope" &&
|
||||
isDeepStrictEqual(scopeRead.data.view?.scope, item.scope) &&
|
||||
[workspaceIndex, scopeRead].every(
|
||||
(result) =>
|
||||
Number.isSafeInteger(result.storeRevision) &&
|
||||
result.persistence?.state === "saved" &&
|
||||
result.persistence.savedRevision === result.storeRevision,
|
||||
) &&
|
||||
scopeRead.storeRevision === workspaceIndex.storeRevision,
|
||||
"public index and exact scope read acknowledge the same saved revision",
|
||||
);
|
||||
const entry = scopeRead.data.view.entry;
|
||||
const identity = capturedIdentity(entry);
|
||||
requireFirstSend(
|
||||
isDeepStrictEqual(item.identity, identity) &&
|
||||
!entry.sessionId &&
|
||||
!entry.threadId &&
|
||||
entry.originScopeKey ===
|
||||
JSON.stringify([workspaceId, kind, item.scope.id]) &&
|
||||
dom?.electron === true &&
|
||||
dom.preload === true &&
|
||||
dom.editorCount === 1 &&
|
||||
dom.disabled === false &&
|
||||
dom.sessionId === null &&
|
||||
nonempty(expectedMarker) &&
|
||||
dom.text?.includes(expectedMarker) &&
|
||||
entry.draft?.document?.text?.includes(expectedMarker),
|
||||
"one native null-session Composer and saved payload share the unique input marker",
|
||||
);
|
||||
return {
|
||||
scope: item.scope,
|
||||
identity,
|
||||
storeRevision: scopeRead.storeRevision,
|
||||
workspaceIndexSha256: digest(workspaceIndex.data.items),
|
||||
payloadSha256: digest(entry.draft),
|
||||
markerSha256: digest(expectedMarker),
|
||||
logicalDraftScopeOnly: true,
|
||||
visibleTabIdentityVerified: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The actual saved logical scope is captured before a real Send button click. */
|
||||
export function assertFirstSendSource(route, proof) {
|
||||
const { scope, entry, dom, beforeThreads, preparedThreads } = proof;
|
||||
const observation = assertFirstSendScopeObservation(route, proof);
|
||||
requireFirstSend(
|
||||
isDeepStrictEqual(scope, observation.scope) &&
|
||||
isDeepStrictEqual(entry, proof.scopeRead.data.view.entry) &&
|
||||
(route !== "home" || dom.emptyState === true),
|
||||
"captured source is the exact current native payload and logical scope",
|
||||
);
|
||||
const identity = capturedIdentity(entry);
|
||||
requireFirstSend(
|
||||
!entry.sessionId &&
|
||||
nonempty(entry.originScopeKey) &&
|
||||
Array.isArray(beforeThreads) &&
|
||||
Array.isArray(preparedThreads) &&
|
||||
[...beforeThreads, ...preparedThreads].every(
|
||||
(thread) => nonempty(thread.id) && nonempty(thread.sessionId),
|
||||
),
|
||||
"source remains unpromoted and canonical pre-send inventory exists",
|
||||
);
|
||||
const priorIds = new Set(beforeThreads.map((thread) => thread.id));
|
||||
const created = preparedThreads.filter((thread) => !priorIds.has(thread.id));
|
||||
requireFirstSend(
|
||||
new Set(beforeThreads.map((thread) => thread.id)).size ===
|
||||
beforeThreads.length &&
|
||||
new Set(preparedThreads.map((thread) => thread.id)).size ===
|
||||
preparedThreads.length,
|
||||
"canonical inventory has unique thread identities",
|
||||
);
|
||||
requireFirstSend(
|
||||
created.length === 0,
|
||||
"editor input creates no extra canonical thread before Send",
|
||||
);
|
||||
return {
|
||||
route,
|
||||
sourceScope: scope,
|
||||
identity,
|
||||
originScopeKey: entry.originScopeKey,
|
||||
preexistingThreadIds: [...priorIds],
|
||||
preexistingSessionIds: beforeThreads.map((thread) => thread.sessionId),
|
||||
sourceDocumentSha256: digest(entry.draft),
|
||||
actualNullSessionComposer: true,
|
||||
nativeScopeObservation: observation,
|
||||
};
|
||||
}
|
||||
|
||||
/** Receipt identity and text fragments; the resource oracle proves full input. */
|
||||
export function assertFirstSendReceipt(thread, source, expectedFragments) {
|
||||
requireFirstSend(
|
||||
nonempty(thread?.id) &&
|
||||
nonempty(thread.sessionId) &&
|
||||
Array.isArray(thread.turns) &&
|
||||
thread.turns.length === 1 &&
|
||||
!source.preexistingThreadIds.includes(thread.id) &&
|
||||
!source.preexistingSessionIds.includes(thread.sessionId),
|
||||
"accepted canonical thread matches this first-send route",
|
||||
);
|
||||
const turn = thread.turns[0];
|
||||
requireFirstSend(
|
||||
nonempty(turn.id) && ["inProgress", "in_progress"].includes(turn.status),
|
||||
"canonical receipt is an active accepted Turn",
|
||||
);
|
||||
const users = (turn.items ?? []).filter(
|
||||
(item) => item.type === "userMessage",
|
||||
);
|
||||
requireFirstSend(
|
||||
users.length === 1 &&
|
||||
nonempty(users[0].id) &&
|
||||
Array.isArray(users[0].content),
|
||||
"one canonical user Item for the submitted draft",
|
||||
);
|
||||
const text = users[0].content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
requireFirstSend(
|
||||
expectedFragments.length > 0 &&
|
||||
expectedFragments.every(
|
||||
(fragment) => nonempty(fragment) && text.includes(fragment),
|
||||
),
|
||||
"canonical user text contains independent body fragments",
|
||||
);
|
||||
return {
|
||||
sessionId: thread.sessionId,
|
||||
threadId: thread.id,
|
||||
turnId: turn.id,
|
||||
userItemId: users[0].id,
|
||||
userItemSha256: digest(users[0]),
|
||||
canonicalAccepted: true,
|
||||
bodyFragmentsVerified: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertUnboundScope(result, scope) {
|
||||
requireFirstSend(
|
||||
result?.key === atomKey &&
|
||||
result.outcome === "ready" &&
|
||||
result.data?.queryKind === "scope" &&
|
||||
isDeepStrictEqual(result.data.view.scope, scope) &&
|
||||
result.data.view.entry === null &&
|
||||
result.persistence?.state === "saved" &&
|
||||
result.persistence.savedRevision === result.storeRevision,
|
||||
"exact old or local scope is unbound and acknowledged saved",
|
||||
);
|
||||
return { scope, storeRevision: result.storeRevision, unbound: true };
|
||||
}
|
||||
|
||||
/** The shared driver parses actual primary/backup bytes with the current owner. */
|
||||
export function assertFirstSendConsumed(envelope, source, receipt) {
|
||||
const atom = envelope.atoms[atomKey];
|
||||
const entry = atom.entries[source.identity.draftId];
|
||||
requireFirstSend(
|
||||
entry?.state === "consumed" &&
|
||||
entry.consumption === "accepted" &&
|
||||
entry.draftId === source.identity.draftId &&
|
||||
entry.scopeVersion === source.identity.scopeVersion &&
|
||||
entry.originScopeKey === source.originScopeKey &&
|
||||
entry.sessionId === receipt.sessionId &&
|
||||
entry.threadId === receipt.threadId &&
|
||||
!Object.hasOwn(entry, "draft") &&
|
||||
!Object.values(atom.bindings).includes(source.identity.draftId),
|
||||
"exact submitted identity was promoted to receipt metadata and consumed",
|
||||
);
|
||||
return {
|
||||
...source.identity,
|
||||
sessionId: entry.sessionId,
|
||||
threadId: entry.threadId,
|
||||
storeRevision: envelope.storeRevision,
|
||||
consumption: entry.consumption,
|
||||
sameCapturedIdentity: true,
|
||||
noRemainingBinding: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertFirstSendNewEdit(entry, source, receipt) {
|
||||
const identity = capturedIdentity(entry);
|
||||
requireFirstSend(
|
||||
entry.sessionId === receipt.sessionId &&
|
||||
entry.draft?.document?.text === newEditText &&
|
||||
(identity.draftId !== source.identity.draftId ||
|
||||
identity.scopeVersion !== source.identity.scopeVersion),
|
||||
"new local edit has a different opaque identity and exact user text",
|
||||
);
|
||||
return {
|
||||
...identity,
|
||||
documentSha256: digest(entry.draft),
|
||||
newEditPreserved: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject an old driver that silently ignores the envelope inspector callback. */
|
||||
export function assertFirstSendSavedFiles(files, expected) {
|
||||
requireFirstSend(
|
||||
nonempty(expected.draftId) &&
|
||||
nonempty(expected.scopeVersion) &&
|
||||
Array.isArray(files) &&
|
||||
files.length === 2 &&
|
||||
files.every(
|
||||
(file) =>
|
||||
file.entryEvidence &&
|
||||
Object.entries(expected).every(
|
||||
([key, value]) => file.entryEvidence[key] === value,
|
||||
),
|
||||
),
|
||||
"shared driver inspected the exact identity in both actual disk files",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertFirstSendTrace(trace) {
|
||||
const operations = trace.atomOperations;
|
||||
requireFirstSend(
|
||||
Array.isArray(operations) &&
|
||||
operations.every(
|
||||
(item) =>
|
||||
item.transport === "electron-ipc" && item.status === "success",
|
||||
) &&
|
||||
trace.appServerInvokeCount > 0,
|
||||
"complete shared-driver ledger uses native IPC and current App Server",
|
||||
);
|
||||
const promotions = operations.flatMap((item, index) =>
|
||||
item.op === "promote" ? [index] : [],
|
||||
);
|
||||
const consumptions = operations.flatMap((item, index) =>
|
||||
item.op === "consume" ? [index] : [],
|
||||
);
|
||||
requireFirstSend(
|
||||
promotions.length === 1 &&
|
||||
consumptions.length === 1 &&
|
||||
promotions[0] < consumptions[0] &&
|
||||
operations.some(
|
||||
(item, index) => item.op === "put" && index > consumptions[0],
|
||||
),
|
||||
"one production promotion precedes consume and a later new-edit put",
|
||||
);
|
||||
return {
|
||||
promotionIndex: promotions[0],
|
||||
consumeIndex: consumptions[0],
|
||||
productionPromotionBeforeConsume: true,
|
||||
newEditPutAfterConsume: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
assertFirstSendConsumed,
|
||||
assertFirstSendNewEdit,
|
||||
assertFirstSendReceipt,
|
||||
assertFirstSendSavedFiles,
|
||||
assertFirstSendScopeObservation,
|
||||
assertFirstSendSource,
|
||||
assertFirstSendRouteCoverage,
|
||||
assertFirstSendTrace,
|
||||
assertUnboundScope,
|
||||
capturedIdentity,
|
||||
newEditText,
|
||||
} from "./composer-first-send-evidence.mjs";
|
||||
|
||||
function sourceInput(route) {
|
||||
const project = route === "project-new";
|
||||
const scope = {
|
||||
workspaceId: project ? "workspace-a" : null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
};
|
||||
const baseline = { id: "bootstrap-thread", sessionId: "bootstrap-session" };
|
||||
const entry = {
|
||||
state: "draft",
|
||||
draftId: "captured-draft",
|
||||
scopeVersion: "z-opaque-original",
|
||||
originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]),
|
||||
draft: { document: { text: "独立的提交正文" } },
|
||||
};
|
||||
const metadata = {
|
||||
key: "composer-drafts-v1",
|
||||
outcome: "ready",
|
||||
storeRevision: 8,
|
||||
persistence: { state: "saved", savedRevision: 8 },
|
||||
};
|
||||
return {
|
||||
scope,
|
||||
workspaceId: scope.workspaceId,
|
||||
entry,
|
||||
expectedMarker: "独立的提交正文",
|
||||
workspaceIndex: {
|
||||
...structuredClone(metadata),
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId: scope.workspaceId,
|
||||
items: [
|
||||
{
|
||||
scope: structuredClone(scope),
|
||||
identity: {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
},
|
||||
updatedAt: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
scopeRead: {
|
||||
...structuredClone(metadata),
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: {
|
||||
scope: structuredClone(scope),
|
||||
entry: structuredClone(entry),
|
||||
},
|
||||
},
|
||||
},
|
||||
dom: {
|
||||
electron: true,
|
||||
preload: true,
|
||||
editorCount: 1,
|
||||
text: "独立的提交正文",
|
||||
sessionId: null,
|
||||
disabled: false,
|
||||
emptyState: true,
|
||||
},
|
||||
beforeThreads: [baseline],
|
||||
preparedThreads: [baseline],
|
||||
};
|
||||
}
|
||||
|
||||
const fragments = [
|
||||
"独立首发请求",
|
||||
"隐藏源一行\n隐藏源二行",
|
||||
"const value = '中😀';",
|
||||
];
|
||||
function receiptThread(source) {
|
||||
return {
|
||||
id: "accepted-new-thread",
|
||||
sessionId: "accepted-new-session",
|
||||
turns: [
|
||||
{
|
||||
id: "accepted-turn",
|
||||
status: "inProgress",
|
||||
items: [
|
||||
{
|
||||
id: "canonical-user-item",
|
||||
type: "userMessage",
|
||||
content: [{ type: "text", text: fragments.join("\n") }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function accepted(route = "search-new") {
|
||||
const input = sourceInput(route);
|
||||
const source = assertFirstSendSource(route, input);
|
||||
const receipt = assertFirstSendReceipt(
|
||||
receiptThread(source),
|
||||
source,
|
||||
fragments,
|
||||
);
|
||||
return { input, source, receipt };
|
||||
}
|
||||
|
||||
function consumedEnvelope(source, receipt) {
|
||||
return {
|
||||
storeRevision: 13,
|
||||
atoms: {
|
||||
"composer-drafts-v1": {
|
||||
bindings: {},
|
||||
entries: {
|
||||
[source.identity.draftId]: {
|
||||
...source.identity,
|
||||
originScopeKey: source.originScopeKey,
|
||||
sessionId: receipt.sessionId,
|
||||
threadId: receipt.threadId,
|
||||
state: "consumed",
|
||||
consumption: "accepted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const route of ["home", "search-new", "project-new"]) {
|
||||
test(
|
||||
"accepts actual native " +
|
||||
route +
|
||||
" source and its canonical first receipt",
|
||||
() => {
|
||||
const { source, receipt } = accepted(route);
|
||||
assert.equal(source.route, route);
|
||||
assert.equal(source.identity.scopeVersion, "z-opaque-original");
|
||||
assert.equal(receipt.canonicalAccepted, true);
|
||||
assert.match(receipt.userItemSha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(JSON.stringify(receipt).includes(fragments[1]), false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("never classifies an existing local send or foreign visible Composer as first submission", () => {
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.scope.kind = "local";
|
||||
},
|
||||
(value) => {
|
||||
value.dom.sessionId = "already-local";
|
||||
},
|
||||
(value) => {
|
||||
value.dom.editorCount = 2;
|
||||
},
|
||||
(value) => {
|
||||
value.dom.preload = false;
|
||||
},
|
||||
(value) => {
|
||||
value.dom.electron = false;
|
||||
},
|
||||
(value) => {
|
||||
value.dom.disabled = true;
|
||||
},
|
||||
(value) => {
|
||||
value.scope.workspaceId = "foreign-workspace";
|
||||
},
|
||||
(value) => {
|
||||
value.entry.sessionId = "promoted-before-send";
|
||||
},
|
||||
]) {
|
||||
const input = sourceInput("search-new");
|
||||
mutate(input);
|
||||
assert.throws(() => assertFirstSendSource("search-new", input));
|
||||
}
|
||||
});
|
||||
|
||||
test("Home requires the exact native Home scope and its visible empty surface", () => {
|
||||
const input = sourceInput("home");
|
||||
input.scope.id = "another-home-scope";
|
||||
assert.throws(() => assertFirstSendSource("home", input));
|
||||
input.scope.id = "new-conversation";
|
||||
input.dom.emptyState = false;
|
||||
assert.throws(() => assertFirstSendSource("home", input));
|
||||
});
|
||||
|
||||
test("public index and payload prove the stable Home scope without top tabs", () => {
|
||||
const input = sourceInput("search-new");
|
||||
input.workspaceIndex.data.items.push({
|
||||
scope: { workspaceId: null, kind: "local", id: "earlier-session" },
|
||||
identity: { draftId: "earlier-edit", scopeVersion: "earlier-version" },
|
||||
sessionId: "earlier-session",
|
||||
updatedAt: 122,
|
||||
});
|
||||
const proof = assertFirstSendSource("search-new", input);
|
||||
assert.equal(proof.nativeScopeObservation.logicalDraftScopeOnly, true);
|
||||
assert.equal(proof.nativeScopeObservation.visibleTabIdentityVerified, false);
|
||||
assert.equal(proof.sourceScope.id, "new-conversation");
|
||||
});
|
||||
|
||||
test("native scope lookup fails closed for ambiguity, wrong workspace, identity, revision or payload", () => {
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.workspaceIndex.key = "other-atom";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.outcome = "unavailable";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.queryKind = "scope";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.workspaceId = "foreign";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items = [];
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items.push(
|
||||
structuredClone(value.workspaceIndex.data.items[0]),
|
||||
);
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].scope.workspaceId = "foreign";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].scope.kind = "local";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].scope.id = "";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].sessionId = "already-promoted";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].threadId = "already-promoted";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.data.items[0].identity.scopeVersion =
|
||||
"other-version";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.key = "other-atom";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.outcome = "invalid";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.queryKind = "workspace-index";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.scope.id = "foreign-scope";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.entry = null;
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.persistence.state = "pending";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.persistence.savedRevision = 7;
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.persistence.state = "pending";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.persistence.savedRevision = 7;
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceIndex.storeRevision = 7;
|
||||
value.workspaceIndex.persistence.savedRevision = 7;
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.entry.originScopeKey = "foreign-origin";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.entry.sessionId = "already-promoted";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.entry.threadId = "already-promoted";
|
||||
},
|
||||
(value) => {
|
||||
value.scopeRead.data.view.entry.draft.document.text = "foreign-payload";
|
||||
},
|
||||
(value) => {
|
||||
value.dom.text = "foreign-visible-input";
|
||||
},
|
||||
(value) => {
|
||||
value.expectedMarker = "";
|
||||
},
|
||||
(value) => {
|
||||
value.workspaceId = "foreign-project";
|
||||
},
|
||||
]) {
|
||||
const input = sourceInput("search-new");
|
||||
mutate(input);
|
||||
assert.throws(() => assertFirstSendScopeObservation("search-new", input));
|
||||
assert.throws(() => assertFirstSendSource("search-new", input));
|
||||
}
|
||||
});
|
||||
|
||||
test("captured rich source cannot diverge from the current saved scope payload", () => {
|
||||
const input = sourceInput("search-new");
|
||||
input.entry.draft.document.text += " stale captured payload";
|
||||
assert.throws(() => assertFirstSendSource("search-new", input));
|
||||
});
|
||||
|
||||
test("each public entry rejects canonical sessions created by input before Send", () => {
|
||||
for (const route of ["home", "search-new", "project-new"]) {
|
||||
const input = sourceInput(route);
|
||||
input.preparedThreads.push({
|
||||
id: "unexpected-thread",
|
||||
sessionId: "unexpected-session",
|
||||
});
|
||||
assert.throws(() => assertFirstSendSource(route, input));
|
||||
}
|
||||
});
|
||||
|
||||
test("identity version is opaque text, not numeric or ordered", () => {
|
||||
for (const version of [1, "", " ", null, undefined]) {
|
||||
assert.throws(() =>
|
||||
capturedIdentity({
|
||||
state: "draft",
|
||||
draftId: "draft",
|
||||
scopeVersion: version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { source, receipt } = accepted();
|
||||
const entry = {
|
||||
state: "draft",
|
||||
draftId: source.identity.draftId,
|
||||
scopeVersion: "a-opaque-next",
|
||||
sessionId: receipt.sessionId,
|
||||
draft: { document: { text: newEditText } },
|
||||
};
|
||||
assert.equal(
|
||||
assertFirstSendNewEdit(entry, source, receipt).newEditPreserved,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("preexisting local or preexisting canonical thread cannot provide the first-send receipt", () => {
|
||||
for (const route of ["home", "search-new", "project-new"]) {
|
||||
const { source } = accepted(route);
|
||||
for (const mutate of [
|
||||
(thread) => {
|
||||
thread.id = "bootstrap-thread";
|
||||
},
|
||||
(thread) => {
|
||||
thread.sessionId = "bootstrap-session";
|
||||
},
|
||||
(thread) => {
|
||||
thread.turns.push({ id: "second-turn" });
|
||||
},
|
||||
(thread) => {
|
||||
thread.turns[0].status = "completed";
|
||||
},
|
||||
(thread) => {
|
||||
thread.turns[0].items[0].id = "";
|
||||
},
|
||||
(thread) => {
|
||||
thread.turns[0].items[0].content = [
|
||||
{ type: "text", text: fragments[0] },
|
||||
];
|
||||
},
|
||||
]) {
|
||||
const thread = receiptThread(source);
|
||||
mutate(thread);
|
||||
assert.throws(() => assertFirstSendReceipt(thread, source, fragments));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("scope read proves saved unbinding rather than exposing a consumed entry", () => {
|
||||
const { source } = accepted();
|
||||
const result = {
|
||||
key: "composer-drafts-v1",
|
||||
outcome: "ready",
|
||||
storeRevision: 13,
|
||||
persistence: { state: "saved", savedRevision: 13 },
|
||||
data: {
|
||||
queryKind: "scope",
|
||||
view: { scope: source.sourceScope, entry: null },
|
||||
},
|
||||
};
|
||||
assert.equal(assertUnboundScope(result, source.sourceScope).unbound, true);
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.data.view.entry = { state: "consumed" };
|
||||
},
|
||||
(value) => {
|
||||
value.data.view.scope.id = "another-scope";
|
||||
},
|
||||
(value) => {
|
||||
value.persistence.state = "pending";
|
||||
},
|
||||
(value) => {
|
||||
value.persistence.savedRevision = 12;
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(result);
|
||||
mutate(changed);
|
||||
assert.throws(() => assertUnboundScope(changed, source.sourceScope));
|
||||
}
|
||||
});
|
||||
|
||||
test("saved tombstone preserves exact original identity and accepted local metadata", () => {
|
||||
const { source, receipt } = accepted();
|
||||
const envelope = consumedEnvelope(source, receipt);
|
||||
const proof = assertFirstSendConsumed(envelope, source, receipt);
|
||||
assert.equal(proof.sameCapturedIdentity, true);
|
||||
assert.equal(proof.sessionId, receipt.sessionId);
|
||||
assert.equal(proof.threadId, receipt.threadId);
|
||||
assert.equal(proof.noRemainingBinding, true);
|
||||
for (const mutate of [
|
||||
(entry) => {
|
||||
entry.scopeVersion = "another-version";
|
||||
},
|
||||
(entry) => {
|
||||
entry.draftId = "another-draft";
|
||||
},
|
||||
(entry) => {
|
||||
entry.sessionId = "another-session";
|
||||
},
|
||||
(entry) => {
|
||||
entry.threadId = "another-thread";
|
||||
},
|
||||
(entry) => {
|
||||
entry.originScopeKey = "another-origin";
|
||||
},
|
||||
(entry) => {
|
||||
entry.consumption = "clear";
|
||||
},
|
||||
(entry) => {
|
||||
entry.state = "draft";
|
||||
},
|
||||
(entry) => {
|
||||
entry.draft = { document: { text: "must-be-absent" } };
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(envelope);
|
||||
mutate(
|
||||
changed.atoms["composer-drafts-v1"].entries[source.identity.draftId],
|
||||
);
|
||||
assert.throws(() => assertFirstSendConsumed(changed, source, receipt));
|
||||
}
|
||||
envelope.atoms["composer-drafts-v1"].bindings.oldScope =
|
||||
source.identity.draftId;
|
||||
assert.throws(() => assertFirstSendConsumed(envelope, source, receipt));
|
||||
});
|
||||
|
||||
test("new edit cannot reuse the consumed identity or silently replace current text", () => {
|
||||
const { source, receipt } = accepted();
|
||||
const entry = {
|
||||
state: "draft",
|
||||
draftId: "genuine-new-draft",
|
||||
scopeVersion: "independent-version",
|
||||
sessionId: receipt.sessionId,
|
||||
draft: { document: { text: newEditText } },
|
||||
};
|
||||
assert.equal(
|
||||
assertFirstSendNewEdit(entry, source, receipt).newEditPreserved,
|
||||
true,
|
||||
);
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
Object.assign(value, source.identity);
|
||||
},
|
||||
(value) => {
|
||||
value.draft.document.text = "restored old submitted text";
|
||||
},
|
||||
(value) => {
|
||||
value.sessionId = "foreign-session";
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(entry);
|
||||
mutate(changed);
|
||||
assert.throws(() => assertFirstSendNewEdit(changed, source, receipt));
|
||||
}
|
||||
});
|
||||
|
||||
test("an old driver that ignores the disk callback cannot satisfy saved identity evidence", () => {
|
||||
const expected = {
|
||||
draftId: "actual-draft",
|
||||
scopeVersion: "opaque-version",
|
||||
sameCapturedIdentity: true,
|
||||
sessionId: "actual-session",
|
||||
threadId: "actual-thread",
|
||||
};
|
||||
const files = [0, 1].map(() => ({ entryEvidence: { ...expected } }));
|
||||
assert.doesNotThrow(() => assertFirstSendSavedFiles(files, expected));
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.pop();
|
||||
},
|
||||
(value) => {
|
||||
delete value[0].entryEvidence;
|
||||
},
|
||||
(value) => {
|
||||
value[1].entryEvidence.scopeVersion = "different";
|
||||
},
|
||||
(value) => {
|
||||
value[1].entryEvidence.sameCapturedIdentity = false;
|
||||
},
|
||||
(value) => {
|
||||
value[1].entryEvidence.threadId = "different";
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(files);
|
||||
mutate(changed);
|
||||
assert.throws(() => assertFirstSendSavedFiles(changed, expected));
|
||||
}
|
||||
});
|
||||
|
||||
test("native production promotion precedes one consume and a later edit put", () => {
|
||||
const trace = {
|
||||
appServerInvokeCount: 5,
|
||||
atomOperations: ["put", "promote", "consume", "put"].map((op) => ({
|
||||
op,
|
||||
status: "success",
|
||||
transport: "electron-ipc",
|
||||
})),
|
||||
};
|
||||
assert.deepEqual(assertFirstSendTrace(trace), {
|
||||
promotionIndex: 1,
|
||||
consumeIndex: 2,
|
||||
productionPromotionBeforeConsume: true,
|
||||
newEditPutAfterConsume: true,
|
||||
});
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.atomOperations.reverse();
|
||||
},
|
||||
(value) => {
|
||||
value.atomOperations.pop();
|
||||
},
|
||||
(value) => {
|
||||
value.atomOperations.push({ ...value.atomOperations[2] });
|
||||
},
|
||||
(value) => {
|
||||
value.atomOperations[1].transport = "browser-mirror";
|
||||
},
|
||||
(value) => {
|
||||
value.atomOperations[2].status = "error";
|
||||
},
|
||||
(value) => {
|
||||
value.appServerInvokeCount = 0;
|
||||
},
|
||||
]) {
|
||||
const changed = structuredClone(trace);
|
||||
mutate(changed);
|
||||
assert.throws(() => assertFirstSendTrace(changed));
|
||||
}
|
||||
});
|
||||
|
||||
test("aggregate coverage accepts shared stable Home scopes and independent canonical receipts", () => {
|
||||
const proofs = ["home", "search-new", "project-new"].map((route, index) => ({
|
||||
route,
|
||||
workspaceId: index === 2 ? "workspace-a" : null,
|
||||
source: {
|
||||
sourceScope: {
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
workspaceId: index === 2 ? "workspace-a" : null,
|
||||
},
|
||||
},
|
||||
receipt: { sessionId: "session-" + index, threadId: "thread-" + index },
|
||||
}));
|
||||
assert.equal(assertFirstSendRouteCoverage(proofs), true);
|
||||
for (const edit of [
|
||||
(value) => value.pop(),
|
||||
(value) => {
|
||||
value[1].route = "materialized-draft";
|
||||
},
|
||||
(value) => {
|
||||
value[1].source.sourceScope.kind = "draft";
|
||||
},
|
||||
(value) => {
|
||||
value[0].source.sourceScope.workspaceId = "inherited";
|
||||
},
|
||||
(value) => {
|
||||
value[2].source.sourceScope.workspaceId = null;
|
||||
},
|
||||
(value) => {
|
||||
value[1].receipt.sessionId = value[0].receipt.sessionId;
|
||||
},
|
||||
(value) => {
|
||||
value[1].receipt.threadId = value[0].receipt.threadId;
|
||||
},
|
||||
]) {
|
||||
const bad = structuredClone(proofs);
|
||||
edit(bad);
|
||||
assert.throws(() => assertFirstSendRouteCoverage(bad));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { after, before, test } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
assertFirstSendReceipt,
|
||||
assertFirstSendResourceReceipt,
|
||||
captureFirstSendResources,
|
||||
} from "./composer-first-send-evidence.mjs";
|
||||
|
||||
// Load the current independent fixture/oracle; do not reproduce lowering here.
|
||||
const fromFormal = (name) =>
|
||||
import(
|
||||
pathToFileURL(path.join(process.cwd(), "scripts/agent-runtime", name))
|
||||
);
|
||||
const { summarizeRichInputSubmission, richInputImageUrl } = await fromFormal(
|
||||
"claw-chat-current-fixture-rich-input-evidence.mjs",
|
||||
);
|
||||
const { composerMarksFixture } = await fromFormal(
|
||||
"claw-chat-current-fixture-composer-marks.mjs",
|
||||
);
|
||||
const { ensureUserVisibleCapabilityReportSkill } = await fromFormal(
|
||||
"claw-chat-current-fixture-skills-workspace.mjs",
|
||||
);
|
||||
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let fixtureRoot;
|
||||
let skillIdentity;
|
||||
let skillFileSha256;
|
||||
before(() => {
|
||||
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "first-send-resource-"));
|
||||
skillIdentity = ensureUserVisibleCapabilityReportSkill({
|
||||
env: { HOME: fixtureRoot },
|
||||
}).skillIdentity;
|
||||
skillFileSha256 = sha(fs.readFileSync(skillIdentity.path));
|
||||
});
|
||||
after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
|
||||
|
||||
function fixture() {
|
||||
const prompt = "首发资源完整保留。";
|
||||
const marker = "Home首发甲一号";
|
||||
const pasteText = "历史粘贴验证😀\n".repeat(180);
|
||||
const imageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const literal = composerMarksFixture({ prompt, skillIdentity, pasteText });
|
||||
const expectedSubmission = {
|
||||
...literal,
|
||||
text: literal.text + " " + marker,
|
||||
displayText: literal.displayText + " " + marker,
|
||||
};
|
||||
const expected = {
|
||||
prompt,
|
||||
marker,
|
||||
pasteText,
|
||||
imageBase64,
|
||||
path: path.join(fixtureRoot, "reference.md"),
|
||||
pathName: "reference.md",
|
||||
skillIdentity,
|
||||
skillFileSha256,
|
||||
expectedSubmission,
|
||||
};
|
||||
const skillToken = {
|
||||
type: "skill",
|
||||
id: skillIdentity.key,
|
||||
name: skillIdentity.name,
|
||||
path: skillIdentity.path,
|
||||
label: "/" + skillIdentity.key,
|
||||
editable: "false",
|
||||
};
|
||||
const pasteToken = {
|
||||
type: "paste",
|
||||
id: "paste-1",
|
||||
label: literal.textElements[0].placeholder,
|
||||
editable: "false",
|
||||
};
|
||||
const entry = {
|
||||
state: "draft",
|
||||
draftId: "native-draft-opaque",
|
||||
scopeVersion: "native-scope-opaque",
|
||||
draft: {
|
||||
document: {
|
||||
text: expectedSubmission.displayText,
|
||||
attachments: [{ mediaType: "image/png", data: imageBase64 }],
|
||||
pathReferences: [
|
||||
{
|
||||
id: "file:" + expected.path,
|
||||
path: expected.path,
|
||||
name: expected.pathName,
|
||||
isDir: false,
|
||||
},
|
||||
],
|
||||
mentionBindings: [{ ...skillToken, kind: "skill" }],
|
||||
inputCapabilityRoute: {
|
||||
kind: "installed_skill",
|
||||
skillKey: skillIdentity.name,
|
||||
},
|
||||
pendingPastes: [
|
||||
{ text: pasteText, placeholder: literal.textElements[0].placeholder },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const input = [
|
||||
{
|
||||
type: "text",
|
||||
text: expectedSubmission.text,
|
||||
text_elements: structuredClone(expectedSubmission.textElements),
|
||||
},
|
||||
{ type: "skill", name: skillIdentity.name, path: skillIdentity.path },
|
||||
{ type: "mention", name: expected.pathName, path: expected.path },
|
||||
{ type: "image", url: "data:image/png;base64," + imageBase64 },
|
||||
];
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "start-first-send",
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-first-send",
|
||||
clientUserMessageId: "client-first-send",
|
||||
input,
|
||||
},
|
||||
};
|
||||
const thread = {
|
||||
id: request.params.threadId,
|
||||
sessionId: "session-first-send",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-first-send",
|
||||
status: "inProgress",
|
||||
items: [
|
||||
{
|
||||
type: "userMessage",
|
||||
id: "item-first-send",
|
||||
clientId: request.params.clientUserMessageId,
|
||||
content: structuredClone(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const imageUrl = richInputImageUrl(imageBase64);
|
||||
thread.turns[0].items[0].content[3].url = imageUrl;
|
||||
const imageBytes = Buffer.from(imageBase64, "base64");
|
||||
const mediaRead = {
|
||||
ipcObservation: {
|
||||
electron: true,
|
||||
fallback: false,
|
||||
transport: "electron-ipc",
|
||||
command: "app_server_handle_json_lines",
|
||||
status: "success",
|
||||
method: "media/read",
|
||||
requestId: "read-first-send",
|
||||
responseId: "read-first-send",
|
||||
params: { threadId: thread.id, uri: imageUrl },
|
||||
},
|
||||
result: {
|
||||
threadId: thread.id,
|
||||
uri: imageUrl,
|
||||
mimeType: "image/png",
|
||||
offset: 0,
|
||||
bytes: imageBytes.length,
|
||||
totalBytes: imageBytes.length,
|
||||
length: imageBytes.length,
|
||||
hasMore: false,
|
||||
sha256: "sha256:" + sha(imageBytes),
|
||||
contentBase64: imageBase64,
|
||||
},
|
||||
};
|
||||
const source = {
|
||||
preexistingThreadIds: [],
|
||||
preexistingSessionIds: [],
|
||||
resources: captureFirstSendResources(entry, expected),
|
||||
};
|
||||
const receipt = assertFirstSendReceipt(thread, source, [prompt, marker]);
|
||||
return {
|
||||
entry,
|
||||
expected,
|
||||
source,
|
||||
receipt,
|
||||
request,
|
||||
oracle: {
|
||||
canonical: { thread },
|
||||
threadId: thread.id,
|
||||
turnId: thread.turns[0].id,
|
||||
prompt,
|
||||
editorText: expectedSubmission.displayText,
|
||||
expectedSubmission,
|
||||
skillIdentity,
|
||||
skillToken,
|
||||
pasteToken,
|
||||
pasteText,
|
||||
imageBase64,
|
||||
pathReference: { name: expected.pathName, path: expected.path },
|
||||
mediaRead,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function verify(value) {
|
||||
const trace = [
|
||||
{
|
||||
command: "app_server_handle_json_lines",
|
||||
transport: "electron-ipc",
|
||||
status: "success",
|
||||
args_preview: { request: { lines: [JSON.stringify(value.request)] } },
|
||||
},
|
||||
];
|
||||
const richInput = summarizeRichInputSubmission({
|
||||
...value.oracle,
|
||||
traceRaw: JSON.stringify(trace),
|
||||
});
|
||||
return assertFirstSendResourceReceipt(value.source, value.receipt, {
|
||||
richInput,
|
||||
mediaRead: value.oracle.mediaRead,
|
||||
expectedSubmission: value.oracle.expectedSubmission,
|
||||
});
|
||||
}
|
||||
|
||||
test("binds canonical ordered resources and actual media bytes to the source", () => {
|
||||
const value = fixture();
|
||||
const result = verify(value);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.completeCanonicalInputVerified, true);
|
||||
assert.equal(result.imageSha256, value.source.resources.image.sha256);
|
||||
const logged = JSON.stringify({ resources: value.source.resources, result });
|
||||
assert.equal(logged.includes(value.expected.imageBase64), false);
|
||||
assert.equal(logged.includes(value.expected.pasteText), false);
|
||||
assert.equal(logged.includes(value.oracle.expectedSubmission.text), false);
|
||||
});
|
||||
|
||||
const sourceMutations = {
|
||||
"missing image": (doc) => (doc.attachments = []),
|
||||
"foreign source image bytes": (doc) =>
|
||||
(doc.attachments[0].data = Buffer.from("foreign").toString("base64")),
|
||||
"noncanonical source base64": (doc) => (doc.attachments[0].data += "\n"),
|
||||
"wrong image mime": (doc) => (doc.attachments[0].mediaType = "image/jpeg"),
|
||||
"missing path": (doc) => (doc.pathReferences = []),
|
||||
"foreign path": (doc) => (doc.pathReferences[0].path += ".other"),
|
||||
"foreign path name": (doc) => (doc.pathReferences[0].name = "other.md"),
|
||||
"directory in place of file": (doc) => (doc.pathReferences[0].isDir = true),
|
||||
"missing Skill": (doc) => (doc.mentionBindings = []),
|
||||
"foreign Skill": (doc) => (doc.mentionBindings[0].path += ".other"),
|
||||
"wrong Skill route": (doc) =>
|
||||
(doc.inputCapabilityRoute.skillKey = "other-skill"),
|
||||
"missing hidden paste": (doc) => (doc.pendingPastes = []),
|
||||
"hidden paste drift": (doc) => (doc.pendingPastes[0].text += "drift"),
|
||||
"paste placeholder drift": (doc) =>
|
||||
(doc.pendingPastes[0].placeholder = "other-placeholder"),
|
||||
"native display drift": (doc) => (doc.text += "drift"),
|
||||
};
|
||||
for (const [name, mutate] of Object.entries(sourceMutations)) {
|
||||
test("rejects captured " + name, () => {
|
||||
const value = fixture();
|
||||
mutate(value.entry.draft.document);
|
||||
assert.throws(() => captureFirstSendResources(value.entry, value.expected));
|
||||
});
|
||||
}
|
||||
|
||||
const receiptMutations = {
|
||||
"canonical image omission": (value) =>
|
||||
value.oracle.canonical.thread.turns[0].items[0].content.pop(),
|
||||
"canonical Skill omission": (value) =>
|
||||
value.oracle.canonical.thread.turns[0].items[0].content.splice(1, 1),
|
||||
"canonical path omission": (value) =>
|
||||
value.oracle.canonical.thread.turns[0].items[0].content.splice(2, 1),
|
||||
"foreign canonical Skill": (value) =>
|
||||
(value.oracle.canonical.thread.turns[0].items[0].content[1].path +=
|
||||
".other"),
|
||||
"foreign canonical path": (value) =>
|
||||
(value.oracle.canonical.thread.turns[0].items[0].content[2].path +=
|
||||
".other"),
|
||||
"foreign canonical Thread": (value) =>
|
||||
(value.oracle.canonical.thread.id = "foreign-thread"),
|
||||
"foreign canonical User Item": (value) =>
|
||||
(value.oracle.canonical.thread.turns[0].items[0].id = "foreign-item"),
|
||||
"missing observed media IPC": (value) =>
|
||||
delete value.oracle.mediaRead.ipcObservation,
|
||||
"foreign media read Thread": (value) =>
|
||||
(value.oracle.mediaRead.result.threadId = "foreign-thread"),
|
||||
"foreign actual media bytes": (value) =>
|
||||
(value.oracle.mediaRead.result.contentBase64 =
|
||||
Buffer.from("foreign").toString("base64")),
|
||||
"foreign response SHA": (value) =>
|
||||
(value.oracle.mediaRead.result.sha256 = "sha256:" + "0".repeat(64)),
|
||||
"noncanonical response base64": (value) =>
|
||||
(value.oracle.mediaRead.result.contentBase64 += "\n"),
|
||||
"partial image read": (value) =>
|
||||
(value.oracle.mediaRead.result.hasMore = true),
|
||||
"different source text SHA": (value) =>
|
||||
(value.source.resources.expandedText.sha256 = "0".repeat(64)),
|
||||
"different source text-element SHA": (value) =>
|
||||
(value.source.resources.expandedText.textElementsSha256 = "0".repeat(64)),
|
||||
};
|
||||
for (const [name, mutate] of Object.entries(receiptMutations)) {
|
||||
test("rejects receipt " + name, () => {
|
||||
const value = fixture();
|
||||
mutate(value);
|
||||
assert.throws(() => verify(value));
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of [
|
||||
"hidden-paste",
|
||||
"code-space",
|
||||
"code-lf",
|
||||
"link-href",
|
||||
"link-title",
|
||||
]) {
|
||||
test("rejects self-consistent wire/canonical " + mode + " drift", () => {
|
||||
const value = fixture();
|
||||
const text = value.request.params.input[0];
|
||||
if (mode === "hidden-paste")
|
||||
text.text = text.text.replace(value.expected.pasteText, "short paste");
|
||||
if (mode === "code-space")
|
||||
text.text = text.text.replace(" const value", "const value");
|
||||
if (mode === "code-lf")
|
||||
text.text = text.text.replace("````\n\n\n`````", "````\n`````");
|
||||
if (mode === "link-href")
|
||||
text.text = text.text.replace(
|
||||
"https://example.invalid/marks",
|
||||
"https://example.invalid/other",
|
||||
);
|
||||
if (mode === "link-title") text.text = text.text.replace(' "标题"', "");
|
||||
assert.notEqual(text.text, value.oracle.expectedSubmission.text);
|
||||
value.oracle.canonical.thread.turns[0].items[0].content[0] =
|
||||
structuredClone(text);
|
||||
assert.throws(() => verify(value));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
atomKey,
|
||||
assertWorkspaceIndex,
|
||||
requireDraftProof,
|
||||
savedDraft,
|
||||
sha256,
|
||||
} from "./composer-draft-fixture-evidence.mjs";
|
||||
|
||||
const inputMethods = new Set([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/steer",
|
||||
"thread/queue/add",
|
||||
"thread/queue/update",
|
||||
"thread/queue/start",
|
||||
]);
|
||||
|
||||
/** Only public visible DOM; no router, React, Controller or PM state. */
|
||||
export function readHomeDraftSurfaceDom() {
|
||||
const visible = (element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.isConnected &&
|
||||
element.getClientRects().length > 0 &&
|
||||
window.getComputedStyle(element).display !== "none" &&
|
||||
!["hidden", "collapse"].includes(
|
||||
window.getComputedStyle(element).visibility,
|
||||
);
|
||||
const editors = Array.from(
|
||||
document.querySelectorAll(
|
||||
'[data-testid="composer-editor"][role="textbox"]',
|
||||
),
|
||||
).filter(visible);
|
||||
const editor = editors.length === 1 ? editors[0] : null;
|
||||
return {
|
||||
electron: window.__LIME_ELECTRON__ === true,
|
||||
preload: typeof window.electronAPI?.invoke === "function",
|
||||
homeButtonCount: Array.from(
|
||||
document.querySelectorAll('[data-testid="app-sidebar-home-button"]'),
|
||||
).filter(visible).length,
|
||||
editorCount: editors.length,
|
||||
sessionId: editor?.dataset.sessionId ?? null,
|
||||
editorText: editor?.textContent ?? null,
|
||||
disabled:
|
||||
!editor ||
|
||||
editor.getAttribute("aria-disabled") === "true" ||
|
||||
editor.getAttribute("contenteditable") !== "true",
|
||||
};
|
||||
}
|
||||
|
||||
export function assertHomeDraftSurface(snapshot) {
|
||||
requireDraftProof(
|
||||
snapshot?.electron === true &&
|
||||
snapshot.preload === true &&
|
||||
snapshot.homeButtonCount === 1 &&
|
||||
snapshot.editorCount === 1 &&
|
||||
snapshot.sessionId === null &&
|
||||
snapshot.disabled === false &&
|
||||
typeof snapshot.editorText === "string",
|
||||
"public Home has one real unmaterialized editable Composer",
|
||||
);
|
||||
return { editorCount: 1, sessionId: null, homeButtonCount: 1 };
|
||||
}
|
||||
|
||||
function assertHomeScope(scope, workspaceId) {
|
||||
requireDraftProof(
|
||||
(workspaceId === null ||
|
||||
(typeof workspaceId === "string" && workspaceId.trim().length > 0)) &&
|
||||
isDeepStrictEqual(scope, {
|
||||
workspaceId,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
}),
|
||||
"actual saved scope exactly matches the public Home workspace, including null",
|
||||
);
|
||||
}
|
||||
|
||||
function assertIndex(index, workspaceId, scope, entry) {
|
||||
assertHomeScope(scope, workspaceId);
|
||||
assertWorkspaceIndex(index, workspaceId, scope, entry);
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(index.storeRevision) &&
|
||||
index.storeRevision >= 0 &&
|
||||
index.persistence?.state === "saved" &&
|
||||
index.persistence.savedRevision >= index.storeRevision &&
|
||||
typeof index.data.items[0].updatedAt === "number" &&
|
||||
Number.isSafeInteger(index.data.items[0].updatedAt) &&
|
||||
index.data.items[0].updatedAt >= 0 &&
|
||||
index.data.items[0].sessionId == null &&
|
||||
index.data.items[0].threadId == null,
|
||||
"saved home index is unmaterialized metadata with acknowledged revision",
|
||||
);
|
||||
}
|
||||
|
||||
/** Scope comes from the native index after a unique public GUI edit. */
|
||||
export function assertHomeDraftDiscovery({
|
||||
index,
|
||||
scopeRead,
|
||||
workspaceId,
|
||||
probe,
|
||||
surface,
|
||||
}) {
|
||||
assertHomeDraftSurface(surface);
|
||||
requireDraftProof(
|
||||
typeof probe === "string" &&
|
||||
probe.length > 0 &&
|
||||
surface.editorText === probe &&
|
||||
index?.data?.items?.length === 1,
|
||||
"one actual native index item corresponds to the unique visible probe",
|
||||
);
|
||||
const scope = index.data.items[0].scope;
|
||||
assertHomeScope(scope, workspaceId);
|
||||
const entry = savedDraft(scopeRead, scope);
|
||||
assertIndex(index, workspaceId, scope, entry);
|
||||
requireDraftProof(
|
||||
scopeRead.storeRevision === index.storeRevision &&
|
||||
entry.sessionId == null &&
|
||||
entry.threadId == null &&
|
||||
entry.draft?.format === "composer-draft" &&
|
||||
entry.draft.version === 1 &&
|
||||
entry.draft.document?.text?.trim() === probe,
|
||||
"native home scope payload and index identity match public probe exactly",
|
||||
);
|
||||
return {
|
||||
scope,
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
storeRevision: index.storeRevision,
|
||||
payloadSha256: sha256(entry.draft),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertHomeIndexUnchanged(before, after, scope, original) {
|
||||
assertIndex(before, scope.workspaceId, scope, original);
|
||||
assertIndex(after, scope.workspaceId, scope, original);
|
||||
requireDraftProof(
|
||||
before.storeRevision === after.storeRevision &&
|
||||
isDeepStrictEqual(before.data.items, after.data.items),
|
||||
"home index reads preserve exact scope, identity, metadata and revision",
|
||||
);
|
||||
return {
|
||||
scope,
|
||||
storeRevision: after.storeRevision,
|
||||
itemsSha256: sha256(after.data.items),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertEmptyHomeCanonicalInventory(result) {
|
||||
requireDraftProof(
|
||||
Array.isArray(result?.data) &&
|
||||
result.data.length === 0 &&
|
||||
Object.hasOwn(result, "nextCursor") &&
|
||||
result.nextCursor === null,
|
||||
"complete public thread/list inventory remains empty before submission",
|
||||
);
|
||||
return { threadCount: 0, turnCount: 0, paginationComplete: true };
|
||||
}
|
||||
|
||||
export function assertHomeDraftTrace(trace, editing = false) {
|
||||
// This ledger observes GUI safeInvoke diagnostics. The workspace helper uses
|
||||
// preload directly; its returned identity is checked separately by the scenario.
|
||||
requireDraftProof(
|
||||
Array.isArray(trace?.atomOperations) &&
|
||||
trace.atomOperations.every(
|
||||
(operation) =>
|
||||
operation.transport === "electron-ipc" &&
|
||||
operation.status === "success",
|
||||
) &&
|
||||
(editing
|
||||
? trace.atomOperations.some((operation) => operation.op === "put")
|
||||
: trace.atomOperations.length === 0) &&
|
||||
Array.isArray(trace.appServerRequestMethods) &&
|
||||
trace.appServerRequestMethods.includes("thread/list") &&
|
||||
trace.appServerRequestMethods.every(
|
||||
(method) => typeof method === "string" && !inputMethods.has(method),
|
||||
) &&
|
||||
Number.isSafeInteger(trace.appServerInvokeCount) &&
|
||||
trace.appServerInvokeCount > 0 &&
|
||||
trace.provenance?.mode === "continuous-storage-append" &&
|
||||
typeof trace.provenance.runId === "string" &&
|
||||
trace.provenance.runId.length > 0,
|
||||
editing
|
||||
? "real GUI owns native home put without canonical materialization or input"
|
||||
: "cold Home and native index reads perform no atom update or input request",
|
||||
);
|
||||
return {
|
||||
atomOperationCount: trace.atomOperations.length,
|
||||
inputRequestCount: 0,
|
||||
traceProvenance: trace.provenance,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertHomeColdHydration({
|
||||
beforeRevision,
|
||||
indexBeforeRevision,
|
||||
indexAfterRevision,
|
||||
restoredRevision,
|
||||
trace,
|
||||
}) {
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(beforeRevision) &&
|
||||
beforeRevision >= 0 &&
|
||||
indexBeforeRevision === beforeRevision &&
|
||||
indexAfterRevision === beforeRevision &&
|
||||
restoredRevision === beforeRevision,
|
||||
"cold Home reads and rich restoration retain original global revision",
|
||||
);
|
||||
return {
|
||||
storeRevision: beforeRevision,
|
||||
...assertHomeDraftTrace(trace),
|
||||
};
|
||||
}
|
||||
|
||||
/** Main owner already parsed actual bytes and enforced the native file budget. */
|
||||
export function assertHomeSavedEntry(envelope, scope, original) {
|
||||
assertHomeScope(scope, scope?.workspaceId);
|
||||
const atom = envelope?.atoms?.[atomKey];
|
||||
const bindings = Object.entries(atom?.bindings ?? {}).filter(
|
||||
([, id]) => id === original?.draftId,
|
||||
);
|
||||
requireDraftProof(
|
||||
Number.isSafeInteger(envelope?.storeRevision) &&
|
||||
envelope.storeRevision >= 0 &&
|
||||
original?.state === "draft" &&
|
||||
original.sessionId == null &&
|
||||
original.threadId == null &&
|
||||
bindings.length === 1 &&
|
||||
bindings[0][0] ===
|
||||
JSON.stringify([scope.workspaceId, scope.kind, scope.id]) &&
|
||||
isDeepStrictEqual(atom.entries?.[original.draftId], original),
|
||||
"actual saved bytes retain exact unmaterialized home entry and one binding",
|
||||
);
|
||||
return {
|
||||
draftId: original.draftId,
|
||||
scopeVersion: original.scopeVersion,
|
||||
storeRevision: envelope.storeRevision,
|
||||
scope,
|
||||
payloadSha256: sha256(original.draft),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertHomeSavedFiles(receipts, scope, original, revision) {
|
||||
const names = ["host-state.json", "host-state.json.bak"];
|
||||
requireDraftProof(
|
||||
Array.isArray(receipts) &&
|
||||
receipts.length === names.length &&
|
||||
receipts.every(
|
||||
(receipt, index) =>
|
||||
typeof receipt.path === "string" &&
|
||||
path.basename(receipt.path) === names[index] &&
|
||||
/^[a-f0-9]{64}$/.test(receipt.sha256 ?? "") &&
|
||||
receipt.validatedBy === "persistedAtomRecords.parseEnvelope" &&
|
||||
receipt.storeRevision === revision &&
|
||||
receipt.entryEvidence?.storeRevision === revision &&
|
||||
receipt.entryEvidence.draftId === original.draftId &&
|
||||
receipt.entryEvidence.scopeVersion === original.scopeVersion &&
|
||||
isDeepStrictEqual(receipt.entryEvidence.scope, scope) &&
|
||||
receipt.entryEvidence.payloadSha256 === sha256(original.draft),
|
||||
) &&
|
||||
receipts[0].sha256 === receipts[1].sha256,
|
||||
"both real parsed primary and backup files ran the full home-entry callback",
|
||||
);
|
||||
return receipts;
|
||||
}
|
||||
|
||||
export function assertHomeSavedBytesUnchanged(before, after) {
|
||||
requireDraftProof(
|
||||
Array.isArray(before) &&
|
||||
before.length === 2 &&
|
||||
isDeepStrictEqual(after, before),
|
||||
"cold Home, index reads and normal quits preserve exact saved file receipts",
|
||||
);
|
||||
return {
|
||||
files: after.map(({ sha256, storeRevision }) => ({
|
||||
sha256,
|
||||
storeRevision,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
assertEmptyHomeCanonicalInventory,
|
||||
assertHomeColdHydration,
|
||||
assertHomeDraftDiscovery,
|
||||
assertHomeDraftSurface,
|
||||
assertHomeDraftTrace,
|
||||
assertHomeIndexUnchanged,
|
||||
assertHomeSavedBytesUnchanged,
|
||||
assertHomeSavedEntry,
|
||||
assertHomeSavedFiles,
|
||||
} from "./composer-home-draft-fixture-evidence.mjs";
|
||||
import {
|
||||
atomKey,
|
||||
assertRestartLifecycle,
|
||||
} from "./composer-draft-fixture-evidence.mjs";
|
||||
|
||||
const scope = {
|
||||
workspaceId: null,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
};
|
||||
const probe = "首页唯一公开探针";
|
||||
const entry = {
|
||||
state: "draft",
|
||||
draftId: "actual-home-draft",
|
||||
scopeVersion: "opaque-version-a",
|
||||
originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]),
|
||||
updatedAt: 123,
|
||||
draft: {
|
||||
format: "composer-draft",
|
||||
version: 1,
|
||||
document: {
|
||||
text: probe,
|
||||
editorDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: probe }] },
|
||||
],
|
||||
},
|
||||
selectionStart: 1,
|
||||
selectionEnd: 4,
|
||||
attachments: [{ mediaType: "image/png", data: "durable-png" }],
|
||||
pendingPastes: [{ text: "full private paste" }],
|
||||
pathReferences: [{ path: "/isolated/file.md", isDir: false }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const clone = (value) => structuredClone(value);
|
||||
function surface() {
|
||||
return {
|
||||
electron: true,
|
||||
preload: true,
|
||||
homeButtonCount: 1,
|
||||
editorCount: 1,
|
||||
sessionId: null,
|
||||
editorText: probe,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
function index() {
|
||||
return {
|
||||
key: atomKey,
|
||||
outcome: "ready",
|
||||
storeRevision: 7,
|
||||
persistence: { state: "saved", savedRevision: 7 },
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId: scope.workspaceId,
|
||||
items: [
|
||||
{
|
||||
scope,
|
||||
identity: {
|
||||
draftId: entry.draftId,
|
||||
scopeVersion: entry.scopeVersion,
|
||||
},
|
||||
updatedAt: entry.updatedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
function scopeRead() {
|
||||
return {
|
||||
key: atomKey,
|
||||
outcome: "ready",
|
||||
storeRevision: 7,
|
||||
persistence: { state: "saved", savedRevision: 7 },
|
||||
data: { queryKind: "scope", view: { scope, entry } },
|
||||
};
|
||||
}
|
||||
function discovery() {
|
||||
return clone({
|
||||
index: index(),
|
||||
scopeRead: scopeRead(),
|
||||
workspaceId: scope.workspaceId,
|
||||
probe,
|
||||
surface: surface(),
|
||||
});
|
||||
}
|
||||
function trace() {
|
||||
return {
|
||||
atomOperations: [],
|
||||
appServerRequestMethods: ["workspace/default/ensure", "thread/list"],
|
||||
appServerInvokeCount: 2,
|
||||
provenance: {
|
||||
mode: "continuous-storage-append",
|
||||
runId: "real-native-observer",
|
||||
},
|
||||
};
|
||||
}
|
||||
function hydration() {
|
||||
return {
|
||||
beforeRevision: 7,
|
||||
indexBeforeRevision: 7,
|
||||
indexAfterRevision: 7,
|
||||
restoredRevision: 7,
|
||||
trace: trace(),
|
||||
};
|
||||
}
|
||||
function envelope() {
|
||||
return clone({
|
||||
storeRevision: 7,
|
||||
atoms: {
|
||||
[atomKey]: {
|
||||
bindings: { [entry.originScopeKey]: entry.draftId },
|
||||
entries: { [entry.draftId]: entry },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
function files() {
|
||||
return ["host-state.json", "host-state.json.bak"].map((name) => ({
|
||||
path: `/isolated/${name}`,
|
||||
sha256: "a".repeat(64),
|
||||
storeRevision: 7,
|
||||
validatedBy: "persistedAtomRecords.parseEnvelope",
|
||||
entryEvidence: assertHomeSavedEntry(envelope(), scope, entry),
|
||||
}));
|
||||
}
|
||||
|
||||
test("public Home keeps explicit null scope instead of the ensured default workspace", () => {
|
||||
const value = discovery();
|
||||
assert.equal(assertHomeDraftDiscovery(value).scope.workspaceId, null);
|
||||
value.workspaceId = "default-real-workspace";
|
||||
assert.throws(() => assertHomeDraftDiscovery(value));
|
||||
});
|
||||
|
||||
test("public GUI probe is associated with actual current home scope and native identity", () => {
|
||||
assert.equal(assertHomeDraftDiscovery(discovery()).draftId, entry.draftId);
|
||||
assert.deepEqual(assertHomeDraftSurface(surface()), {
|
||||
editorCount: 1,
|
||||
sessionId: null,
|
||||
homeButtonCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
for (const [name, mutate] of [
|
||||
[
|
||||
"browser mirror",
|
||||
(v) => {
|
||||
v.surface.electron = false;
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing preload",
|
||||
(v) => {
|
||||
v.surface.preload = false;
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicate Home buttons",
|
||||
(v) => {
|
||||
v.surface.homeButtonCount = 2;
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicate editors",
|
||||
(v) => {
|
||||
v.surface.editorCount = 2;
|
||||
},
|
||||
],
|
||||
[
|
||||
"disabled editor",
|
||||
(v) => {
|
||||
v.surface.disabled = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
"materialized editor",
|
||||
(v) => {
|
||||
v.surface.sessionId = "canonical-session";
|
||||
},
|
||||
],
|
||||
[
|
||||
"foreign visible probe",
|
||||
(v) => {
|
||||
v.surface.editorText = "other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"ambiguous index",
|
||||
(v) => {
|
||||
v.index.data.items.push(clone(v.index.data.items[0]));
|
||||
},
|
||||
],
|
||||
[
|
||||
"random draftTab scope",
|
||||
(v) => {
|
||||
v.index.data.items[0].scope.kind = "draft";
|
||||
v.index.data.items[0].scope.id = "task-draft-other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"foreign workspace",
|
||||
(v) => {
|
||||
v.workspaceId = "other-default";
|
||||
},
|
||||
],
|
||||
[
|
||||
"index and payload identity mismatch",
|
||||
(v) => {
|
||||
v.index.data.items[0].identity.draftId = "other";
|
||||
},
|
||||
],
|
||||
[
|
||||
"different index revision",
|
||||
(v) => {
|
||||
v.index.storeRevision = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
"unsaved index",
|
||||
(v) => {
|
||||
v.index.persistence.state = "pending";
|
||||
},
|
||||
],
|
||||
[
|
||||
"unacknowledged index",
|
||||
(v) => {
|
||||
v.index.persistence.savedRevision = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
"materialized index metadata",
|
||||
(v) => {
|
||||
v.index.data.items[0].sessionId = "new-session";
|
||||
},
|
||||
],
|
||||
[
|
||||
"materialized scope entry",
|
||||
(v) => {
|
||||
v.scopeRead.data.view.entry.threadId = "new-thread";
|
||||
},
|
||||
],
|
||||
[
|
||||
"different saved probe",
|
||||
(v) => {
|
||||
v.scopeRead.data.view.entry.draft.document.text = "other";
|
||||
},
|
||||
],
|
||||
]) {
|
||||
test(`discovery rejects ${name}`, () => {
|
||||
const value = discovery();
|
||||
mutate(value);
|
||||
assert.throws(() => assertHomeDraftDiscovery(value));
|
||||
});
|
||||
}
|
||||
|
||||
test("same-atom home index survives reads with exact metadata", () => {
|
||||
assert.equal(
|
||||
assertHomeIndexUnchanged(index(), clone(index()), scope, entry)
|
||||
.storeRevision,
|
||||
7,
|
||||
);
|
||||
for (const mutate of [
|
||||
(v) => {
|
||||
v.storeRevision = 8;
|
||||
v.persistence.savedRevision = 8;
|
||||
},
|
||||
(v) => {
|
||||
v.data.items[0].updatedAt = 124;
|
||||
},
|
||||
(v) => {
|
||||
v.data.items[0].identity.scopeVersion = "new-version";
|
||||
},
|
||||
(v) => {
|
||||
v.data.items = [];
|
||||
},
|
||||
]) {
|
||||
const value = clone(index());
|
||||
mutate(value);
|
||||
assert.throws(() => assertHomeIndexUnchanged(index(), value, scope, entry));
|
||||
}
|
||||
});
|
||||
|
||||
test("empty canonical inventory proves no thread or Turn and complete pagination", () => {
|
||||
assert.equal(
|
||||
assertEmptyHomeCanonicalInventory({ data: [], nextCursor: null }).turnCount,
|
||||
0,
|
||||
);
|
||||
for (const result of [
|
||||
{ data: [] },
|
||||
{ data: [], nextCursor: "more" },
|
||||
{ data: [{ id: "new-thread", turns: [] }], nextCursor: null },
|
||||
{ items: [], nextCursor: null },
|
||||
])
|
||||
assert.throws(() => assertEmptyHomeCanonicalInventory(result));
|
||||
});
|
||||
|
||||
test("cold restoration requires unchanged revision, native trace and no atom mutation", () => {
|
||||
assert.equal(assertHomeColdHydration(hydration()).atomOperationCount, 0);
|
||||
for (const field of [
|
||||
"beforeRevision",
|
||||
"indexBeforeRevision",
|
||||
"indexAfterRevision",
|
||||
"restoredRevision",
|
||||
]) {
|
||||
const value = hydration();
|
||||
value[field] += 1;
|
||||
assert.throws(() => assertHomeColdHydration(value));
|
||||
}
|
||||
});
|
||||
|
||||
for (const method of [
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/steer",
|
||||
"thread/queue/add",
|
||||
"thread/queue/update",
|
||||
"thread/queue/start",
|
||||
]) {
|
||||
test(`home cold trace rejects ${method}`, () => {
|
||||
const value = trace();
|
||||
value.appServerRequestMethods.push(method);
|
||||
assert.throws(() => assertHomeDraftTrace(value));
|
||||
});
|
||||
}
|
||||
|
||||
test("native edit put is required and cannot substitute for mutation-free cold restore", () => {
|
||||
const value = trace();
|
||||
assert.throws(() => assertHomeDraftTrace(value, true));
|
||||
value.atomOperations.push({
|
||||
op: "put",
|
||||
status: "success",
|
||||
transport: "electron-ipc",
|
||||
});
|
||||
assert.equal(assertHomeDraftTrace(value, true).atomOperationCount, 1);
|
||||
assert.throws(() => assertHomeDraftTrace(value));
|
||||
value.atomOperations[0].transport = "mock";
|
||||
assert.throws(() => assertHomeDraftTrace(value, true));
|
||||
});
|
||||
|
||||
test("GUI trace accepts native reads without the direct-preload workspace helper", () => {
|
||||
const value = trace();
|
||||
value.appServerRequestMethods = ["workspace/ensureReady", "thread/list"];
|
||||
assert.equal(assertHomeDraftTrace(value).atomOperationCount, 0);
|
||||
value.atomOperations.push({
|
||||
op: "put",
|
||||
status: "success",
|
||||
transport: "electron-ipc",
|
||||
});
|
||||
assert.equal(assertHomeDraftTrace(value, true).atomOperationCount, 1);
|
||||
});
|
||||
|
||||
test("incomplete trace or absent current App Server read cannot pass", () => {
|
||||
for (const mutate of [
|
||||
(v) => {
|
||||
delete v.atomOperations;
|
||||
},
|
||||
(v) => {
|
||||
v.appServerRequestMethods = ["workspace/default/ensure"];
|
||||
},
|
||||
(v) => {
|
||||
v.appServerInvokeCount = 0;
|
||||
},
|
||||
(v) => {
|
||||
v.provenance.mode = "ring-buffer";
|
||||
},
|
||||
(v) => {
|
||||
v.provenance.runId = "";
|
||||
},
|
||||
]) {
|
||||
const value = trace();
|
||||
mutate(value);
|
||||
assert.throws(() => assertHomeDraftTrace(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("actual parsed saved envelope preserves full PM JSON, selection and resources", () => {
|
||||
assert.equal(
|
||||
assertHomeSavedEntry(envelope(), scope, entry).scopeVersion,
|
||||
entry.scopeVersion,
|
||||
);
|
||||
for (const mutate of [
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[
|
||||
entry.draftId
|
||||
].draft.document.editorDocument.content = [];
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[entry.draftId].draft.document.selectionStart = 0;
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[
|
||||
entry.draftId
|
||||
].draft.document.attachments[0].data = "truncated";
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[entry.draftId].draft.document.pendingPastes = [];
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[
|
||||
entry.draftId
|
||||
].draft.document.pathReferences[0].path = "/foreign.md";
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].bindings.other = entry.draftId;
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].bindings = {};
|
||||
},
|
||||
(v) => {
|
||||
v.atoms[atomKey].entries[entry.draftId].state = "consumed";
|
||||
},
|
||||
]) {
|
||||
const value = envelope();
|
||||
mutate(value);
|
||||
assert.throws(() => assertHomeSavedEntry(value, scope, entry));
|
||||
}
|
||||
});
|
||||
|
||||
test("both native parsed files need exact entry evidence and unchanged raw bytes", () => {
|
||||
const before = files();
|
||||
assert.equal(assertHomeSavedFiles(before, scope, entry, 7).length, 2);
|
||||
assert.equal(
|
||||
assertHomeSavedBytesUnchanged(before, clone(before)).files.length,
|
||||
2,
|
||||
);
|
||||
for (const mutate of [
|
||||
(v) => {
|
||||
v[1].validatedBy = "fixture-parser";
|
||||
},
|
||||
(v) => {
|
||||
delete v[1].entryEvidence;
|
||||
},
|
||||
(v) => {
|
||||
v[1].entryEvidence.scope.workspaceId = "foreign";
|
||||
},
|
||||
(v) => {
|
||||
v[1].entryEvidence.payloadSha256 = "changed";
|
||||
},
|
||||
(v) => {
|
||||
v[1].sha256 = "b".repeat(64);
|
||||
},
|
||||
(v) => {
|
||||
v[1].path = v[0].path;
|
||||
},
|
||||
]) {
|
||||
const value = clone(before);
|
||||
mutate(value);
|
||||
assert.throws(() => assertHomeSavedFiles(value, scope, entry, 7));
|
||||
assert.throws(() => assertHomeSavedBytesUnchanged(before, value));
|
||||
}
|
||||
});
|
||||
|
||||
test("two generations need distinct real Main PIDs and the same normally exited profile", () => {
|
||||
const entries = [1, 2].map((generation) => ({
|
||||
generation,
|
||||
pid: generation + 100,
|
||||
profile: "/isolated/profile",
|
||||
mode: "normal",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
exited: true,
|
||||
}));
|
||||
assertRestartLifecycle(entries, "/isolated/profile", 2);
|
||||
for (const mutate of [
|
||||
(v) => {
|
||||
v[1].pid = v[0].pid;
|
||||
},
|
||||
(v) => {
|
||||
v[1].profile = "/other/profile";
|
||||
},
|
||||
(v) => {
|
||||
v[1].signal = "SIGKILL";
|
||||
},
|
||||
(v) => {
|
||||
v[1].exited = false;
|
||||
},
|
||||
]) {
|
||||
const value = clone(entries);
|
||||
mutate(value);
|
||||
assert.throws(() => assertRestartLifecycle(value, "/isolated/profile", 2));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
atomKey,
|
||||
assertWorkspaceIndex,
|
||||
requireDraftProof,
|
||||
summarizeDraft,
|
||||
} from "./composer-draft-fixture-evidence.mjs";
|
||||
import {
|
||||
assertEmptyHomeCanonicalInventory,
|
||||
assertHomeColdHydration,
|
||||
assertHomeDraftDiscovery,
|
||||
assertHomeDraftSurface,
|
||||
assertHomeDraftTrace,
|
||||
assertHomeIndexUnchanged,
|
||||
assertHomeSavedBytesUnchanged,
|
||||
assertHomeSavedEntry,
|
||||
assertHomeSavedFiles,
|
||||
readHomeDraftSurfaceDom,
|
||||
} from "./composer-home-draft-fixture-evidence.mjs";
|
||||
|
||||
const marker = "首页真实冷恢复草稿一号";
|
||||
|
||||
async function readIndex(context, workspaceId) {
|
||||
const observed = await context.modules.atom.observePersistedAtomCommand(
|
||||
context.page,
|
||||
context.protocol,
|
||||
"persisted_atom_read",
|
||||
{ key: atomKey, query: { kind: "workspace-index", workspaceId } },
|
||||
{ timeoutMs: context.options.timeoutMs },
|
||||
);
|
||||
return observed.result;
|
||||
}
|
||||
|
||||
async function openHome(context) {
|
||||
const home = context.page
|
||||
.getByTestId("app-sidebar-home-button")
|
||||
.filter({ visible: true });
|
||||
requireDraftProof((await home.count()) === 1, "one public Home button");
|
||||
await home.click();
|
||||
const snapshot = await context.modules.drafts.until(
|
||||
context,
|
||||
() => context.page.evaluate(readHomeDraftSurfaceDom),
|
||||
(value) => {
|
||||
try {
|
||||
assertHomeDraftSurface(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"public Home click commits one editable null-session Composer",
|
||||
);
|
||||
return assertHomeDraftSurface(snapshot);
|
||||
}
|
||||
|
||||
async function readEmptyInventory(context) {
|
||||
const observed = await context.modules.rpc.invokeAppServerFromPage(
|
||||
context.page,
|
||||
"thread/list",
|
||||
{ limit: 100 },
|
||||
);
|
||||
return assertEmptyHomeCanonicalInventory(observed.result);
|
||||
}
|
||||
|
||||
async function discoverEditedScope(context, workspaceId, probe) {
|
||||
return context.modules.drafts.until(
|
||||
context,
|
||||
async () => {
|
||||
const index = await readIndex(context, workspaceId);
|
||||
if (index.data?.items?.length !== 1) return null;
|
||||
const scope = index.data.items[0].scope;
|
||||
const scopeRead = await context.modules.drafts.readScope(context, scope);
|
||||
const surface = await context.page.evaluate(readHomeDraftSurfaceDom);
|
||||
try {
|
||||
return assertHomeDraftDiscovery({
|
||||
index,
|
||||
scopeRead,
|
||||
workspaceId,
|
||||
probe,
|
||||
surface,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(value) => value !== null,
|
||||
"public GUI probe uniquely identifies native default-workspace home scope",
|
||||
);
|
||||
}
|
||||
|
||||
export const homeDraftColdScenario = {
|
||||
id: "home-draft-cold",
|
||||
scope:
|
||||
"stable null-workspace home/new-conversation draft, restored through public Home after normal process restart",
|
||||
generationCount: 2,
|
||||
sourcePaths: [
|
||||
"scripts/electron/lib/composer-home-draft-fixture-scenario.mjs",
|
||||
"scripts/electron/lib/composer-home-draft-fixture-evidence.mjs",
|
||||
"scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs",
|
||||
],
|
||||
limitations: [
|
||||
"Only the public Home null-workspace scope is covered; project New and first submission are separate slices.",
|
||||
"No all-draft sidebar picker or TaskCenterTabStrip is introduced or exercised.",
|
||||
"First submission, materialization, queue and GUI Stop are separate slices; this draft stays unmaterialized.",
|
||||
"Synthetic clipboard/drop events use production GUI ingress; OS clipboard and OS drag are not covered.",
|
||||
"Saved is awaited before public normal quit; abrupt exit with pending save is not covered.",
|
||||
"Invoke observation begins after each launcher returns; unchanged full bytes/revision and empty canonical inventory additionally cover committed startup mutation.",
|
||||
"External backend proves current App Server/read-model transport, not production model execution or live provider.",
|
||||
],
|
||||
async run(fixture) {
|
||||
const { options, runtimeEnv, recordStage, setPhase } = fixture;
|
||||
let context = await fixture.launch(1);
|
||||
setPhase("public-home-with-explicit-null-workspace");
|
||||
const workspace = await context.modules.rpc.ensureDefaultWorkspace(
|
||||
context.page,
|
||||
);
|
||||
recordStage("generation-1-public-home", await openHome(context));
|
||||
recordStage(
|
||||
"generation-1-no-canonical-thread",
|
||||
await readEmptyInventory(context),
|
||||
);
|
||||
recordStage("generation-1-default-workspace-fact", workspace);
|
||||
const empty = await readIndex(context, null);
|
||||
requireDraftProof(
|
||||
empty.outcome === "ready" &&
|
||||
empty.key === atomKey &&
|
||||
empty.data?.queryKind === "workspace-index" &&
|
||||
empty.data.workspaceId === null &&
|
||||
empty.data.items.length === 0,
|
||||
"fresh isolated public Home null workspace has no persisted draft index item",
|
||||
);
|
||||
const probe = `首页草稿范围探针 ${options.runId}`;
|
||||
await context.modules.dom.fillComposerEditor(context.page, probe);
|
||||
const discovered = await discoverEditedScope(context, null, probe);
|
||||
recordStage("public-edit-native-scope-identity-discovery", discovered);
|
||||
const rootPath = path.join(runtimeEnv.tempRoot, "home-draft-resources");
|
||||
await fs.mkdir(rootPath);
|
||||
const filePath = path.join(rootPath, "composer-home-draft.md");
|
||||
const bytes = Buffer.alloc(128, " ");
|
||||
bytes.write(`# Current Home draft\n${marker}\n`, "utf8");
|
||||
await fs.writeFile(filePath, bytes);
|
||||
const record = {
|
||||
workspaceId: discovered.scope.workspaceId,
|
||||
scope: discovered.scope,
|
||||
sessionId: null,
|
||||
marker,
|
||||
rootPath,
|
||||
path: filePath,
|
||||
pathName: path.basename(filePath),
|
||||
};
|
||||
setPhase("public-rich-home-draft-edit-and-save");
|
||||
const original = await context.modules.drafts.prepareVisibleDraft(
|
||||
context,
|
||||
record,
|
||||
);
|
||||
recordStage(
|
||||
"generation-1-rich-home-surface",
|
||||
assertHomeDraftSurface(
|
||||
await context.page.evaluate(readHomeDraftSurfaceDom),
|
||||
),
|
||||
);
|
||||
const originalIndex = await readIndex(context, record.workspaceId);
|
||||
recordStage(
|
||||
"generation-1-same-atom-index",
|
||||
assertWorkspaceIndex(
|
||||
originalIndex,
|
||||
record.workspaceId,
|
||||
record.scope,
|
||||
original.entry,
|
||||
),
|
||||
);
|
||||
const rereadIndex = await readIndex(context, record.workspaceId);
|
||||
recordStage(
|
||||
"generation-1-index-read-is-idempotent",
|
||||
assertHomeIndexUnchanged(
|
||||
originalIndex,
|
||||
rereadIndex,
|
||||
record.scope,
|
||||
original.entry,
|
||||
),
|
||||
);
|
||||
requireDraftProof(
|
||||
originalIndex.storeRevision === original.revision,
|
||||
"rich scope and native index agree on original revision",
|
||||
);
|
||||
recordStage(
|
||||
"generation-1-unmaterialized-inventory",
|
||||
await readEmptyInventory(context),
|
||||
);
|
||||
const inspect = async () =>
|
||||
assertHomeSavedFiles(
|
||||
await fixture.inspectSavedFiles(original.revision, (envelope) =>
|
||||
assertHomeSavedEntry(envelope, record.scope, original.entry),
|
||||
),
|
||||
record.scope,
|
||||
original.entry,
|
||||
original.revision,
|
||||
);
|
||||
const savedFiles = await inspect();
|
||||
recordStage("generation-1-real-saved-files", savedFiles);
|
||||
recordStage(
|
||||
"generation-1-native-edit-trace",
|
||||
assertHomeDraftTrace(await fixture.stopTrace(), true),
|
||||
);
|
||||
await fixture.quit();
|
||||
recordStage(
|
||||
"generation-1-normal-quit-files-unchanged",
|
||||
assertHomeSavedBytesUnchanged(savedFiles, await inspect()),
|
||||
);
|
||||
context = await fixture.launch(2);
|
||||
setPhase("same-profile-new-pid-current-home-cold-restore");
|
||||
const secondWorkspace = await context.modules.rpc.ensureDefaultWorkspace(
|
||||
context.page,
|
||||
);
|
||||
requireDraftProof(
|
||||
secondWorkspace.workspaceId === workspace.workspaceId,
|
||||
"same real default workspace identity after cold restart",
|
||||
);
|
||||
const coldIndexBeforeHome = await readIndex(context, record.workspaceId);
|
||||
recordStage(
|
||||
"generation-2-pre-home-index-unchanged",
|
||||
assertHomeIndexUnchanged(
|
||||
originalIndex,
|
||||
coldIndexBeforeHome,
|
||||
record.scope,
|
||||
original.entry,
|
||||
),
|
||||
);
|
||||
recordStage("generation-2-true-public-home-click", await openHome(context));
|
||||
const restored = await context.modules.drafts.verifyVisibleDraft(
|
||||
context,
|
||||
record,
|
||||
original,
|
||||
);
|
||||
recordStage(
|
||||
"generation-2-full-json-dom-resources-selection-restored",
|
||||
restored,
|
||||
);
|
||||
const coldIndexAfterHome = await readIndex(context, record.workspaceId);
|
||||
recordStage(
|
||||
"generation-2-post-home-index-unchanged",
|
||||
assertHomeIndexUnchanged(
|
||||
coldIndexBeforeHome,
|
||||
coldIndexAfterHome,
|
||||
record.scope,
|
||||
original.entry,
|
||||
),
|
||||
);
|
||||
const secondScopeRead = await context.modules.drafts.readScope(
|
||||
context,
|
||||
record.scope,
|
||||
);
|
||||
requireDraftProof(
|
||||
secondScopeRead.storeRevision === original.revision,
|
||||
"scope observation keeps original global revision",
|
||||
);
|
||||
recordStage(
|
||||
"generation-2-no-canonical-thread-or-turn",
|
||||
await readEmptyInventory(context),
|
||||
);
|
||||
recordStage(
|
||||
"generation-2-real-saved-files-unchanged",
|
||||
assertHomeSavedBytesUnchanged(savedFiles, await inspect()),
|
||||
);
|
||||
recordStage(
|
||||
"generation-2-cold-hydration-no-update",
|
||||
assertHomeColdHydration({
|
||||
beforeRevision: original.revision,
|
||||
indexBeforeRevision: coldIndexBeforeHome.storeRevision,
|
||||
indexAfterRevision: coldIndexAfterHome.storeRevision,
|
||||
restoredRevision: restored.revision,
|
||||
trace: await fixture.stopTrace(),
|
||||
}),
|
||||
);
|
||||
await fixture.quit();
|
||||
recordStage(
|
||||
"generation-2-normal-quit-files-unchanged",
|
||||
assertHomeSavedBytesUnchanged(savedFiles, await inspect()),
|
||||
);
|
||||
return {
|
||||
guiDraftRestoration: true,
|
||||
firstSubmissionRoutesVerified: false,
|
||||
records: [{ ...record, draft: summarizeDraft(original.entry) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const SHA = /^[a-f0-9]{64}$/;
|
||||
const POLICY_CASES = [
|
||||
"allows existing renderer events and rejects Main persistence notifications",
|
||||
"does not coerce malformed IPC names or execute their getters",
|
||||
"rejects forged saved acknowledgements at the Main IPC handler",
|
||||
];
|
||||
|
||||
export function assertDriver(condition, phase) {
|
||||
if (!condition)
|
||||
throw new Error(`persisted atom Electron driver failed: ${phase}`);
|
||||
}
|
||||
|
||||
export const hashBytes = (bytes) =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
export async function assertSourceFile(record, phase) {
|
||||
assertDriver(
|
||||
path.isAbsolute(record?.path ?? "") && SHA.test(record?.sha256 ?? ""),
|
||||
phase,
|
||||
);
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await fs.readFile(record.path);
|
||||
} catch {
|
||||
assertDriver(false, phase);
|
||||
}
|
||||
assertDriver(hashBytes(bytes) === record.sha256, phase);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Static source/test evidence supplements native listen; it is never a live spoof test. */
|
||||
export async function verifyPersistedAtomEventGuard({ repoRoot, packet }) {
|
||||
const source = {
|
||||
path: path.join(repoRoot, "electron/rendererEventPolicy.ts"),
|
||||
sha256: packet.sourceSha256,
|
||||
};
|
||||
await assertSourceFile(source, "current Main renderer event policy source");
|
||||
await assertSourceFile(
|
||||
{ path: packet.testPath, sha256: packet.testSha256 },
|
||||
"frozen event policy test source",
|
||||
);
|
||||
const runtimeTestPath = path.join(
|
||||
repoRoot,
|
||||
"electron/rendererEventPolicy.test.ts",
|
||||
);
|
||||
await assertSourceFile(
|
||||
{ path: runtimeTestPath, sha256: packet.testSha256 },
|
||||
"same formal and frozen event policy test source",
|
||||
);
|
||||
const bytes = await assertSourceFile(
|
||||
{ path: packet.receiptPath, sha256: packet.receiptSha256 },
|
||||
"frozen event policy test receipt",
|
||||
);
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(bytes.toString("utf8"));
|
||||
} catch {
|
||||
assertDriver(false, "event policy test JSON receipt");
|
||||
}
|
||||
const testPath = await fs.realpath(packet.testPath);
|
||||
const suites = [];
|
||||
for (const suite of report.testResults ?? []) {
|
||||
try {
|
||||
if ((await fs.realpath(suite.name)) === testPath) suites.push(suite);
|
||||
} catch {
|
||||
/* Unrelated suites are not policy evidence. */
|
||||
}
|
||||
}
|
||||
assertDriver(
|
||||
report.numFailedTests === 0 && suites.length === 1,
|
||||
"unambiguous passing event policy test suite",
|
||||
);
|
||||
const tests = suites[0].assertionResults;
|
||||
assertDriver(
|
||||
Array.isArray(tests) &&
|
||||
tests.length === POLICY_CASES.length &&
|
||||
POLICY_CASES.every((title) =>
|
||||
tests.some((test) => test.title === title && test.status === "passed"),
|
||||
),
|
||||
"all required Main IPC authority cases passed",
|
||||
);
|
||||
return {
|
||||
validated: true,
|
||||
sourceSha256: source.sha256,
|
||||
proofClass: "static-main-ipc-policy",
|
||||
sourcePath: source.path,
|
||||
testPath,
|
||||
runtimeTestPath,
|
||||
testSha256: packet.testSha256,
|
||||
receiptPath: packet.receiptPath,
|
||||
receiptSha256: packet.receiptSha256,
|
||||
passedCases: POLICY_CASES.length,
|
||||
liveRendererSpoofAttempted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function bounded(promise, timeoutMs, phase) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(new Error(`persisted atom Electron driver failed: ${phase}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/** Capture the actual child exit from launch; do not infer it from window closure. */
|
||||
export function captureMainProcessExit(child) {
|
||||
assertDriver(
|
||||
Number.isSafeInteger(child?.pid) &&
|
||||
child.pid > 0 &&
|
||||
child.exitCode === null &&
|
||||
child.signalCode === null,
|
||||
"live public Main child process",
|
||||
);
|
||||
const pid = child.pid;
|
||||
let exitRecord = null;
|
||||
const exitPromise = new Promise((resolve) => {
|
||||
child.once("exit", (exitCode, signal) => {
|
||||
exitRecord = { pid, exited: true, exitCode, signal };
|
||||
resolve(exitRecord);
|
||||
});
|
||||
});
|
||||
return { pid, exitPromise, getExitRecord: () => exitRecord };
|
||||
}
|
||||
|
||||
export async function quitMainProcessNormally(handle, timeoutMs) {
|
||||
assertDriver(
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= 60_000,
|
||||
"bounded normal quit timeout",
|
||||
);
|
||||
assertDriver(
|
||||
handle.exit.getExitRecord() === null,
|
||||
"Main did not exit before requested normal quit",
|
||||
);
|
||||
// Playwright's public close calls app.quit and releases its Node debugger.
|
||||
// Exit evidence still requires the actual Main child to return code 0, no signal.
|
||||
void handle.app.close().catch(() => undefined);
|
||||
const receipt = await bounded(
|
||||
handle.exit.exitPromise,
|
||||
timeoutMs,
|
||||
"normal Main exit timeout; no kill fallback",
|
||||
);
|
||||
assertDriver(
|
||||
receipt.exitCode === 0 && receipt.signal === null,
|
||||
"normal Main exit status",
|
||||
);
|
||||
return { ...receipt, mode: "normal" };
|
||||
}
|
||||
|
||||
async function defaultLauncher(repoRoot) {
|
||||
assertDriver(
|
||||
path.resolve(process.cwd()) === path.resolve(repoRoot),
|
||||
"standalone must run with repository cwd",
|
||||
);
|
||||
const module = await import(
|
||||
pathToFileURL(
|
||||
path.join(repoRoot, "scripts/electron/mcp-config-fixture-smoke.mjs"),
|
||||
).href
|
||||
);
|
||||
return module.launchElectronFixture;
|
||||
}
|
||||
|
||||
async function defaultFreshnessInspector(repoRoot) {
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(repoRoot, "scripts/lib/electron-fixture-build.mjs"))
|
||||
.href
|
||||
);
|
||||
// Read-only inspection only; never ensureElectronFixtureBuild or rebuild.
|
||||
return module.inspectElectronFixtureBuildFreshness({ rootDir: repoRoot });
|
||||
}
|
||||
|
||||
/** Uses an existing launcher; all business mutations stay in the frozen public IPC fixture. */
|
||||
export async function createPersistedAtomElectronDriver({
|
||||
repoRoot,
|
||||
appDataRoot,
|
||||
ownerBundle,
|
||||
packet,
|
||||
launchFixture,
|
||||
inspectFreshness = defaultFreshnessInspector,
|
||||
env = process.env,
|
||||
launchTimeoutMs = 60_000,
|
||||
quitTimeoutMs = 15_000,
|
||||
}) {
|
||||
const root = path.resolve(repoRoot);
|
||||
assertDriver(path.isAbsolute(appDataRoot), "isolated absolute profile");
|
||||
assertDriver(
|
||||
launchTimeoutMs > 0 && launchTimeoutMs <= 60_000,
|
||||
"bounded launch timeout",
|
||||
);
|
||||
const roots = ownerBundle.resolveDesktopStorageRoots({
|
||||
platform: process.platform,
|
||||
hostUserData: appDataRoot,
|
||||
e2eMode: true,
|
||||
e2eUserDataDir: appDataRoot,
|
||||
localAppData: env.LOCALAPPDATA,
|
||||
});
|
||||
assertDriver(
|
||||
roots.appDataRoot === appDataRoot &&
|
||||
roots.agentRoot === path.join(appDataRoot, "app-server"),
|
||||
"real appDataPaths E2E mapping",
|
||||
);
|
||||
const policy = await verifyPersistedAtomEventGuard({
|
||||
repoRoot: root,
|
||||
packet: packet.eventGuard,
|
||||
});
|
||||
let active = null;
|
||||
let generation = 0;
|
||||
const lifecycle = [];
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
const currentFingerprints = async () => {
|
||||
for (const [label, record] of Object.entries(ownerBundle.receipt.sources))
|
||||
await assertSourceFile(record, `frozen owner source ${label}`);
|
||||
await assertSourceFile(
|
||||
{
|
||||
path: path.join(root, "scripts/lib/electron-fixture-build.mjs"),
|
||||
sha256: packet.freshnessInspectorSha256,
|
||||
},
|
||||
"current build freshness owner",
|
||||
);
|
||||
const freshness = await inspectFreshness(root);
|
||||
assertDriver(
|
||||
freshness.ready === true && freshness.segments?.host?.ready === true,
|
||||
"current renderer/Host/sidecar build freshness",
|
||||
);
|
||||
const guard = await verifyPersistedAtomEventGuard({
|
||||
repoRoot: root,
|
||||
packet: packet.eventGuard,
|
||||
});
|
||||
const mainPath = path.join(root, "dist-electron/main/main.js");
|
||||
const preloadPath = path.join(root, "dist-electron/preload/preload.cjs");
|
||||
const [main, preload] = await Promise.all([
|
||||
fs.readFile(mainPath),
|
||||
fs.readFile(preloadPath),
|
||||
]);
|
||||
assertDriver(
|
||||
main.length > 0 && preload.length > 0,
|
||||
"current nonempty Host/preload artifacts",
|
||||
);
|
||||
return {
|
||||
hostSha256: hashBytes(main),
|
||||
preloadSha256: hashBytes(preload),
|
||||
eventEmissionGuard: guard,
|
||||
freshness: {
|
||||
reason: freshness.reason,
|
||||
host: freshness.segments.host.reason,
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
async launch({
|
||||
appDataRoot: requestedRoot,
|
||||
generation: requestedGeneration,
|
||||
}) {
|
||||
assertDriver(
|
||||
active === null &&
|
||||
requestedRoot === appDataRoot &&
|
||||
requestedGeneration === generation + 1,
|
||||
"same profile and sequential generation after complete exit",
|
||||
);
|
||||
const fingerprints = await currentFingerprints();
|
||||
const launcher = launchFixture ?? (await defaultLauncher(root));
|
||||
let launched;
|
||||
try {
|
||||
launched = await launcher({
|
||||
options: { timeoutMs: launchTimeoutMs, intervalMs: 100 },
|
||||
runtimeEnv: {
|
||||
electronUserDataDir: appDataRoot,
|
||||
appServerDataDir: roots.agentRoot,
|
||||
env: {
|
||||
...env,
|
||||
LIME_ALLOW_LIVE_PROVIDER_SMOKE: "0",
|
||||
LIME_REAL_API_TEST: "0",
|
||||
},
|
||||
},
|
||||
appServerEnv: env.APP_SERVER_BIN
|
||||
? { APP_SERVER_BIN: env.APP_SERVER_BIN }
|
||||
: {},
|
||||
consoleErrors,
|
||||
pageErrors,
|
||||
backendMode: "unavailable",
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
"persisted atom Electron driver failed: existing launcher unavailable",
|
||||
);
|
||||
}
|
||||
const child = launched.app.process();
|
||||
const exit = captureMainProcessExit(child);
|
||||
generation = requestedGeneration;
|
||||
active = {
|
||||
app: launched.app,
|
||||
page: launched.page,
|
||||
child,
|
||||
exit,
|
||||
identity: { pid: exit.pid, appDataRoot, ...fingerprints },
|
||||
closePromise: null,
|
||||
};
|
||||
lifecycle.push({
|
||||
phase: "launch",
|
||||
pid: exit.pid,
|
||||
generation,
|
||||
appDataRoot,
|
||||
});
|
||||
return active;
|
||||
},
|
||||
async close(handle) {
|
||||
assertDriver(
|
||||
handle === active,
|
||||
"close only the current owned Main process",
|
||||
);
|
||||
handle.closePromise ??= quitMainProcessNormally(handle, quitTimeoutMs);
|
||||
const receipt = await handle.closePromise;
|
||||
lifecycle.push({ phase: "normal-exit", ...receipt });
|
||||
active = null;
|
||||
return receipt;
|
||||
},
|
||||
async inspectFiles({ appDataRoot: requestedRoot }) {
|
||||
assertDriver(
|
||||
requestedRoot === appDataRoot,
|
||||
"inspect isolated profile only",
|
||||
);
|
||||
const receipts = {};
|
||||
for (const [kind, name] of [
|
||||
["primary", "host-state.json"],
|
||||
["backup", "host-state.json.bak"],
|
||||
]) {
|
||||
const filePath = path.join(appDataRoot, name);
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
assertDriver(
|
||||
stat.isFile() && stat.size <= ownerBundle.limits.maxFileBytes,
|
||||
"bounded persisted atom file",
|
||||
);
|
||||
const bytes = await fs.readFile(filePath);
|
||||
const envelope = ownerBundle.parseEnvelope(
|
||||
JSON.parse(bytes.toString("utf8")),
|
||||
ownerBundle.limits,
|
||||
);
|
||||
receipts[kind] = {
|
||||
path: filePath,
|
||||
validatedBy: "persistedAtomRecords.parseEnvelope",
|
||||
storeRevision: envelope.storeRevision,
|
||||
sha256: hashBytes(bytes),
|
||||
};
|
||||
} catch {
|
||||
throw new Error(
|
||||
`persisted atom Electron driver failed: invalid saved ${kind} file`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return receipts;
|
||||
},
|
||||
getEvidence() {
|
||||
return {
|
||||
staticEventAuthorityCompanion: policy,
|
||||
ownerBundle: ownerBundle.receipt,
|
||||
appDataRoots: roots,
|
||||
lifecycle: structuredClone(lifecycle),
|
||||
outstandingMainPids:
|
||||
active && active.exit.getExitRecord() === null
|
||||
? [active.identity.pid]
|
||||
: [],
|
||||
// Existing launcher errors can contain content; report counts only.
|
||||
consoleErrorCount: consoleErrors.length,
|
||||
pageErrorCount: pageErrors.length,
|
||||
backendMode: "unavailable",
|
||||
productionProviderUsed: false,
|
||||
normalQuitUses:
|
||||
"public Electron app.quit + public app.process child exit",
|
||||
forcedKillFallback: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseEnvelope } from "../../../electron/persistedAtomRecords";
|
||||
import { PERSISTED_ATOM_LIMITS } from "../../../src/lib/api/persistedAtomProtocol";
|
||||
import {
|
||||
captureMainProcessExit,
|
||||
createPersistedAtomElectronDriver,
|
||||
hashBytes,
|
||||
quitMainProcessNormally,
|
||||
verifyPersistedAtomEventGuard,
|
||||
} from "./persisted-atom-fixture-driver.mjs";
|
||||
|
||||
const ownedTestRoots = new Set();
|
||||
async function createTestRoot(prefix) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
ownedTestRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const root of ownedTestRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
ownedTestRoots.delete(root);
|
||||
}
|
||||
});
|
||||
const cases = [
|
||||
"allows existing renderer events and rejects Main persistence notifications",
|
||||
"does not coerce malformed IPC names or execute their getters",
|
||||
"rejects forged saved acknowledgements at the Main IPC handler",
|
||||
];
|
||||
|
||||
async function project({ reportMutator } = {}) {
|
||||
const repoRoot = await createTestRoot("lime-persisted-atom-driver-unit-");
|
||||
const write = async (relative, text) => {
|
||||
const filePath = path.join(repoRoot, relative);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, text);
|
||||
return { path: filePath, sha256: hashBytes(Buffer.from(text)) };
|
||||
};
|
||||
const policy = await write(
|
||||
"electron/rendererEventPolicy.ts",
|
||||
"explicit fake policy for unit testing",
|
||||
);
|
||||
const formalTest = await write(
|
||||
"electron/rendererEventPolicy.test.ts",
|
||||
"explicit fake test source for unit testing",
|
||||
);
|
||||
const test = await write(
|
||||
"frozen/rendererEventPolicy.test.ts",
|
||||
"explicit fake test source for unit testing",
|
||||
);
|
||||
const report = {
|
||||
numFailedTests: 0,
|
||||
testResults: [
|
||||
{
|
||||
name: test.path,
|
||||
assertionResults: cases.map((title) => ({ title, status: "passed" })),
|
||||
},
|
||||
],
|
||||
};
|
||||
reportMutator?.(report);
|
||||
const receipt = await write("policy-test.json", JSON.stringify(report));
|
||||
const freshness = await write(
|
||||
"scripts/lib/electron-fixture-build.mjs",
|
||||
"unit freshness inspector marker",
|
||||
);
|
||||
await write("dist-electron/main/main.js", "unit host artifact marker");
|
||||
await write(
|
||||
"dist-electron/preload/preload.cjs",
|
||||
"unit preload artifact marker",
|
||||
);
|
||||
const source = await write(
|
||||
"src/lib/api/persistedAtomProtocol.ts",
|
||||
"unit frozen source marker",
|
||||
);
|
||||
const appDataRoot = path.join(repoRoot, "profile");
|
||||
await fs.mkdir(appDataRoot);
|
||||
const packet = {
|
||||
freshnessInspectorSha256: freshness.sha256,
|
||||
eventGuard: {
|
||||
sourceSha256: policy.sha256,
|
||||
testPath: test.path,
|
||||
testSha256: test.sha256,
|
||||
receiptPath: receipt.path,
|
||||
receiptSha256: receipt.sha256,
|
||||
},
|
||||
};
|
||||
const parseEnvelope = vi.fn((value) => {
|
||||
if (value.format !== "electron-persisted-atom-state")
|
||||
throw new Error("private body rejected");
|
||||
return value;
|
||||
});
|
||||
const ownerBundle = {
|
||||
receipt: { sources: { protocol: source } },
|
||||
resolveDesktopStorageRoots: vi.fn(({ e2eUserDataDir }) => ({
|
||||
appDataRoot: e2eUserDataDir,
|
||||
agentRoot: path.join(e2eUserDataDir, "app-server"),
|
||||
hostSessionData: e2eUserDataDir,
|
||||
})),
|
||||
parseEnvelope,
|
||||
limits: { maxFileBytes: 1000 },
|
||||
};
|
||||
return {
|
||||
repoRoot,
|
||||
appDataRoot,
|
||||
packet,
|
||||
ownerBundle,
|
||||
write,
|
||||
policy,
|
||||
test,
|
||||
formalTest,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeMain(
|
||||
pid,
|
||||
{ exitCode = 0, signal = null, noExit = false, quitRejects = false } = {},
|
||||
) {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const quit = vi.fn(() => {
|
||||
if (!noExit)
|
||||
setTimeout(() => {
|
||||
child.exitCode = exitCode;
|
||||
child.signalCode = signal;
|
||||
child.emit("exit", exitCode, signal);
|
||||
}, 1);
|
||||
});
|
||||
const app = {
|
||||
process: vi.fn(() => child),
|
||||
close: vi.fn(async () => {
|
||||
quit();
|
||||
if (quitRejects) throw new Error("Target closed private body");
|
||||
}),
|
||||
evaluate: vi.fn(async (callback) => {
|
||||
callback({ app: { quit } });
|
||||
if (quitRejects) throw new Error("Target closed private body");
|
||||
}),
|
||||
};
|
||||
return { app, child, quit };
|
||||
}
|
||||
|
||||
describe("static event authority companion (unit receipt validation only)", () => {
|
||||
it("pins current source, test source, report SHA and all Main IPC authority cases", async () => {
|
||||
const p = await project();
|
||||
const guard = await verifyPersistedAtomEventGuard({
|
||||
repoRoot: p.repoRoot,
|
||||
packet: p.packet.eventGuard,
|
||||
});
|
||||
expect(guard).toMatchObject({
|
||||
validated: true,
|
||||
sourceSha256: p.policy.sha256,
|
||||
proofClass: "static-main-ipc-policy",
|
||||
passedCases: 3,
|
||||
liveRendererSpoofAttempted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["source", "test", "receipt"])("rejects %s drift", async (fault) => {
|
||||
const p = await project();
|
||||
await fs.appendFile(p[fault === "source" ? "policy" : fault].path, "drift");
|
||||
await expect(
|
||||
verifyPersistedAtomEventGuard({
|
||||
repoRoot: p.repoRoot,
|
||||
packet: p.packet.eventGuard,
|
||||
}),
|
||||
).rejects.toThrow(/driver failed/);
|
||||
});
|
||||
|
||||
it("requires the formal test to match the frozen candidate receipt's exact test source", async () => {
|
||||
const p = await project();
|
||||
await fs.appendFile(p.formalTest.path, "drift");
|
||||
await expect(
|
||||
verifyPersistedAtomEventGuard({
|
||||
repoRoot: p.repoRoot,
|
||||
packet: p.packet.eventGuard,
|
||||
}),
|
||||
).rejects.toThrow(/same formal and frozen/);
|
||||
});
|
||||
|
||||
it.each(["failed", "missing", "ambiguous", "unrelated"])(
|
||||
"cannot declare validated for a %s policy receipt",
|
||||
async (fault) => {
|
||||
const p = await project({
|
||||
reportMutator: (report) => {
|
||||
if (fault === "failed")
|
||||
report.testResults[0].assertionResults[2].status = "failed";
|
||||
if (fault === "missing") report.testResults[0].assertionResults.pop();
|
||||
if (fault === "ambiguous")
|
||||
report.testResults.push(report.testResults[0]);
|
||||
if (fault === "unrelated")
|
||||
report.testResults[0].name = "/unrelated.test.ts";
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
verifyPersistedAtomEventGuard({
|
||||
repoRoot: p.repoRoot,
|
||||
packet: p.packet.eventGuard,
|
||||
}),
|
||||
).rejects.toThrow(/suite|cases/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("normal process exit receipt (fake child process, no Electron)", () => {
|
||||
it("uses public application close to release the debugger before Main exit", async () => {
|
||||
const { app, child } = fakeMain(505, { noExit: true });
|
||||
app.close.mockImplementation(async () => {
|
||||
child.exitCode = 0;
|
||||
child.emit("exit", 0, null);
|
||||
});
|
||||
await expect(
|
||||
quitMainProcessNormally({ app, exit: captureMainProcessExit(child) }, 5),
|
||||
).resolves.toMatchObject({ exitCode: 0, signal: null, mode: "normal" });
|
||||
expect(app.close).toHaveBeenCalledOnce();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
it("captures the public Main exit and tolerates app.quit evaluation closing its connection", async () => {
|
||||
const { app, child, quit } = fakeMain(501, { quitRejects: true });
|
||||
const exit = captureMainProcessExit(child);
|
||||
await expect(quitMainProcessNormally({ app, exit }, 100)).resolves.toEqual({
|
||||
pid: 501,
|
||||
exited: true,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
mode: "normal",
|
||||
});
|
||||
expect(quit).toHaveBeenCalledTimes(1);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ exitCode: 1 },
|
||||
{ exitCode: null, signal: "SIGKILL" },
|
||||
{ exitCode: 0, signal: "SIGTERM" },
|
||||
])("rejects a non-normal exit", async (options) => {
|
||||
const { app, child } = fakeMain(502, options);
|
||||
await expect(
|
||||
quitMainProcessNormally(
|
||||
{ app, exit: captureMainProcessExit(child) },
|
||||
100,
|
||||
),
|
||||
).rejects.toThrow(/normal Main exit status/);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails a bounded timeout without killing or inventing a normal exit", async () => {
|
||||
const { app, child } = fakeMain(503, { noExit: true });
|
||||
await expect(
|
||||
quitMainProcessNormally({ app, exit: captureMainProcessExit(child) }, 2),
|
||||
).rejects.toThrow(/timeout; no kill/);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cannot count an already exited Main as requested normal Quit", async () => {
|
||||
const { app, child } = fakeMain(504);
|
||||
const exit = captureMainProcessExit(child);
|
||||
child.emit("exit", 0, null);
|
||||
await expect(quitMainProcessNormally({ app, exit }, 100)).rejects.toThrow(
|
||||
/before requested normal quit/,
|
||||
);
|
||||
expect(app.evaluate).not.toHaveBeenCalled();
|
||||
expect(app.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-driver orchestration with explicit fake launcher", () => {
|
||||
async function configured({ mainOptions, freshness, roots } = {}) {
|
||||
const p = await project();
|
||||
const apps = [];
|
||||
const launch = vi.fn(async (options) => {
|
||||
const main = fakeMain(700 + apps.length, mainOptions);
|
||||
apps.push(main);
|
||||
options.consoleErrors.push("private draft error");
|
||||
return { app: main.app, page: { evaluate: vi.fn() } };
|
||||
});
|
||||
if (roots) p.ownerBundle.resolveDesktopStorageRoots.mockReturnValue(roots);
|
||||
const inspect = vi.fn(
|
||||
async () =>
|
||||
freshness ?? {
|
||||
ready: true,
|
||||
reason: "fresh-artifacts",
|
||||
segments: { host: { ready: true, reason: "fresh-artifacts" } },
|
||||
},
|
||||
);
|
||||
const driver = await createPersistedAtomElectronDriver({
|
||||
...p,
|
||||
ownerBundle: p.ownerBundle,
|
||||
packet: p.packet,
|
||||
launchFixture: launch,
|
||||
inspectFreshness: inspect,
|
||||
env: { LOCALAPPDATA: "/local" },
|
||||
quitTimeoutMs: 100,
|
||||
});
|
||||
return { ...p, driver, launch, inspect, apps };
|
||||
}
|
||||
|
||||
it("reuses the current launcher in isolated unavailable mode and starts generation 2 only after exit", async () => {
|
||||
const p = await configured();
|
||||
const first = await p.driver.launch({
|
||||
appDataRoot: p.appDataRoot,
|
||||
generation: 1,
|
||||
});
|
||||
await expect(
|
||||
p.driver.launch({ appDataRoot: p.appDataRoot, generation: 2 }),
|
||||
).rejects.toThrow(/complete exit/);
|
||||
const receipt = await p.driver.close(first);
|
||||
expect(receipt.mode).toBe("normal");
|
||||
const second = await p.driver.launch({
|
||||
appDataRoot: p.appDataRoot,
|
||||
generation: 2,
|
||||
});
|
||||
expect(second.identity.pid).not.toBe(first.identity.pid);
|
||||
expect(second.identity.hostSha256).toBe(first.identity.hostSha256);
|
||||
await p.driver.close(second);
|
||||
const args = p.launch.mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
backendMode: "unavailable",
|
||||
runtimeEnv: {
|
||||
electronUserDataDir: p.appDataRoot,
|
||||
appServerDataDir: path.join(p.appDataRoot, "app-server"),
|
||||
env: { LIME_ALLOW_LIVE_PROVIDER_SMOKE: "0", LIME_REAL_API_TEST: "0" },
|
||||
},
|
||||
});
|
||||
expect(args.runtimeEnv.env).not.toHaveProperty("HOME");
|
||||
const evidence = p.driver.getEvidence();
|
||||
expect(evidence.outstandingMainPids).toEqual([]);
|
||||
expect(evidence.lifecycle.map((record) => record.phase)).toEqual([
|
||||
"launch",
|
||||
"normal-exit",
|
||||
"launch",
|
||||
"normal-exit",
|
||||
]);
|
||||
expect(evidence.consoleErrorCount).toBe(2);
|
||||
expect(JSON.stringify(evidence)).not.toContain("private draft error");
|
||||
expect(p.inspect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not launch stale source artifacts or implicitly build them", async () => {
|
||||
const p = await configured({
|
||||
freshness: { ready: false, segments: { host: { ready: false } } },
|
||||
});
|
||||
await expect(
|
||||
p.driver.launch({ appDataRoot: p.appDataRoot, generation: 1 }),
|
||||
).rejects.toThrow(/build freshness/);
|
||||
expect(p.launch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a freshness-owner source drift before launching", async () => {
|
||||
const p = await configured();
|
||||
await p.write("scripts/lib/electron-fixture-build.mjs", "drift");
|
||||
await expect(
|
||||
p.driver.launch({ appDataRoot: p.appDataRoot, generation: 1 }),
|
||||
).rejects.toThrow(/freshness owner/);
|
||||
expect(p.launch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read persisted atoms through a second file validator", async () => {
|
||||
const p = await configured();
|
||||
const bytes = JSON.stringify({
|
||||
format: "electron-persisted-atom-state",
|
||||
storeRevision: 9,
|
||||
privateDraft: "not in receipt",
|
||||
});
|
||||
for (const name of ["host-state.json", "host-state.json.bak"])
|
||||
await fs.writeFile(path.join(p.appDataRoot, name), bytes);
|
||||
const files = await p.driver.inspectFiles({ appDataRoot: p.appDataRoot });
|
||||
expect(p.ownerBundle.parseEnvelope).toHaveBeenCalledTimes(2);
|
||||
expect(files.primary).toMatchObject({
|
||||
storeRevision: 9,
|
||||
validatedBy: "persistedAtomRecords.parseEnvelope",
|
||||
sha256: hashBytes(Buffer.from(bytes)),
|
||||
});
|
||||
expect(JSON.stringify(files)).not.toContain("not in receipt");
|
||||
await expect(
|
||||
p.driver.inspectFiles({ appDataRoot: "/other" }),
|
||||
).rejects.toThrow(/isolated profile/);
|
||||
});
|
||||
|
||||
it("validates saved files with the real owner and its declared limits", async () => {
|
||||
const p = await configured();
|
||||
p.ownerBundle.parseEnvelope = parseEnvelope;
|
||||
p.ownerBundle.limits = PERSISTED_ATOM_LIMITS;
|
||||
const bytes = JSON.stringify({
|
||||
format: "electron-persisted-atom-state",
|
||||
version: 1,
|
||||
storeRevision: 9,
|
||||
atoms: { "composer-drafts-v1": { entries: {}, bindings: {} } },
|
||||
});
|
||||
for (const name of ["host-state.json", "host-state.json.bak"])
|
||||
await fs.writeFile(path.join(p.appDataRoot, name), bytes);
|
||||
const files = await p.driver.inspectFiles({ appDataRoot: p.appDataRoot });
|
||||
expect(files.primary.storeRevision).toBe(9);
|
||||
expect(files.backup.sha256).toBe(files.primary.sha256);
|
||||
});
|
||||
|
||||
it("redacts parse rejection text and cannot produce a file receipt", async () => {
|
||||
const p = await configured();
|
||||
await fs.writeFile(
|
||||
path.join(p.appDataRoot, "host-state.json"),
|
||||
JSON.stringify({ privateDraft: "private body rejected" }),
|
||||
);
|
||||
await expect(
|
||||
p.driver.inspectFiles({ appDataRoot: p.appDataRoot }),
|
||||
).rejects.toThrow("invalid saved primary file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
assertDriver,
|
||||
assertSourceFile,
|
||||
createPersistedAtomElectronDriver,
|
||||
hashBytes,
|
||||
} from "./persisted-atom-fixture-driver.mjs";
|
||||
|
||||
const OWNER_PATHS = {
|
||||
protocol: "src/lib/api/persistedAtomProtocol.ts",
|
||||
types: "src/lib/api/persistedAtomTypes.ts",
|
||||
records: "electron/persistedAtomRecords.ts",
|
||||
appDataPaths: "electron/appDataPaths.ts",
|
||||
};
|
||||
|
||||
/** Compile only the unique pure owners into an isolated diagnostic module. */
|
||||
export async function bundlePersistedAtomOwners({
|
||||
repoRoot,
|
||||
workDir,
|
||||
packet,
|
||||
build,
|
||||
}) {
|
||||
const sources = Object.fromEntries(
|
||||
Object.entries(OWNER_PATHS).map(([label, relative]) => [
|
||||
label,
|
||||
{
|
||||
path: path.join(repoRoot, relative),
|
||||
sha256: packet.ownerSha256[label],
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const [label, record] of Object.entries(sources))
|
||||
await assertSourceFile(record, `current pure owner ${label}`);
|
||||
const outfile = path.join(workDir, "persisted-atom-pure-owners.mjs");
|
||||
const builder =
|
||||
build ??
|
||||
createRequire(path.join(repoRoot, "package.json"))("esbuild").build;
|
||||
const exportFrom = (names, source) =>
|
||||
`export { ${names} } from ${JSON.stringify(source)};`;
|
||||
const contents = [
|
||||
exportFrom(
|
||||
"parsePersistedAtomReadRequest, parsePersistedAtomReadResult, parsePersistedAtomUpdateResult, parsePersistedAtomUpdateEvent, PERSISTED_ATOM_LIMITS",
|
||||
sources.protocol.path,
|
||||
),
|
||||
exportFrom("parseEnvelope", sources.records.path),
|
||||
exportFrom("resolveDesktopStorageRoots", sources.appDataPaths.path),
|
||||
].join("\n");
|
||||
const result = await builder({
|
||||
stdin: {
|
||||
contents,
|
||||
sourcefile: "persisted-atom-owner-entry.mjs",
|
||||
resolveDir: repoRoot,
|
||||
},
|
||||
absWorkingDir: repoRoot,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
outfile,
|
||||
write: true,
|
||||
metafile: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
for (const [label, record] of Object.entries(sources))
|
||||
await assertSourceFile(record, `unchanged pure owner ${label}`);
|
||||
const dependencies = {};
|
||||
for (const input of Object.keys(result.metafile.inputs)) {
|
||||
if (input === "persisted-atom-owner-entry.mjs") continue;
|
||||
const inputPath = path.resolve(repoRoot, input);
|
||||
dependencies[inputPath] = hashBytes(await fs.readFile(inputPath));
|
||||
}
|
||||
assertDriver(
|
||||
Object.keys(dependencies).includes(sources.protocol.path) &&
|
||||
Object.keys(dependencies).includes(sources.records.path) &&
|
||||
Object.keys(dependencies).includes(sources.appDataPaths.path),
|
||||
"bundle contains the unique current owner inputs",
|
||||
);
|
||||
const bytes = await fs.readFile(outfile);
|
||||
const module = await import(
|
||||
`${pathToFileURL(outfile).href}?sha256=${hashBytes(bytes)}`
|
||||
);
|
||||
for (const name of [
|
||||
"parsePersistedAtomReadRequest",
|
||||
"parsePersistedAtomReadResult",
|
||||
"parsePersistedAtomUpdateResult",
|
||||
"parsePersistedAtomUpdateEvent",
|
||||
"parseEnvelope",
|
||||
"resolveDesktopStorageRoots",
|
||||
])
|
||||
assertDriver(
|
||||
typeof module[name] === "function",
|
||||
"pure owner module exports",
|
||||
);
|
||||
assertDriver(
|
||||
Number.isSafeInteger(module.PERSISTED_ATOM_LIMITS?.maxFileBytes),
|
||||
"single owner file budget",
|
||||
);
|
||||
return {
|
||||
protocol: {
|
||||
parseReadRequest: module.parsePersistedAtomReadRequest,
|
||||
parseReadResult: module.parsePersistedAtomReadResult,
|
||||
parseUpdateResult: module.parsePersistedAtomUpdateResult,
|
||||
parseUpdateEvent: module.parsePersistedAtomUpdateEvent,
|
||||
},
|
||||
parseEnvelope: module.parseEnvelope,
|
||||
resolveDesktopStorageRoots: module.resolveDesktopStorageRoots,
|
||||
limits: module.PERSISTED_ATOM_LIMITS,
|
||||
receipt: {
|
||||
sources,
|
||||
dependencies,
|
||||
bundlePath: outfile,
|
||||
bundleSha256: hashBytes(bytes),
|
||||
compilationScope:
|
||||
"pure protocol + records + appDataPaths only; no product build",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePersistedAtomStandaloneArgs(argv, cwd = process.cwd()) {
|
||||
const options = {
|
||||
repoRoot: cwd,
|
||||
evidenceDir: null,
|
||||
packetPath: null,
|
||||
runId: null,
|
||||
timeoutMs: 10_000,
|
||||
quitTimeoutMs: 15_000,
|
||||
help: false,
|
||||
};
|
||||
const names = {
|
||||
"--repo": "repoRoot",
|
||||
"--evidence-dir": "evidenceDir",
|
||||
"--verification-packet": "packetPath",
|
||||
"--run-id": "runId",
|
||||
"--timeout-ms": "timeoutMs",
|
||||
"--quit-timeout-ms": "quitTimeoutMs",
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
const field = names[arg];
|
||||
assertDriver(
|
||||
field && argv[index + 1],
|
||||
"known standalone argument with value",
|
||||
);
|
||||
options[field] = argv[++index];
|
||||
}
|
||||
if (options.help) return options;
|
||||
assertDriver(
|
||||
options.evidenceDir &&
|
||||
options.packetPath &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? ""),
|
||||
"explicit evidence directory, verification packet and run id",
|
||||
);
|
||||
for (const name of ["repoRoot", "evidenceDir", "packetPath"])
|
||||
options[name] = path.resolve(cwd, options[name]);
|
||||
for (const name of ["timeoutMs", "quitTimeoutMs"]) {
|
||||
options[name] = Number(options[name]);
|
||||
assertDriver(
|
||||
Number.isSafeInteger(options[name]) &&
|
||||
options[name] > 0 &&
|
||||
options[name] <= 60_000,
|
||||
"bounded standalone timeout",
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function runPersistedAtomStandalone(
|
||||
options,
|
||||
{ build, makeDriver = createPersistedAtomElectronDriver } = {},
|
||||
) {
|
||||
assertDriver(
|
||||
/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? "") &&
|
||||
[options.repoRoot, options.evidenceDir, options.packetPath].every(
|
||||
(value) => typeof value === "string" && path.isAbsolute(value),
|
||||
),
|
||||
"standalone absolute paths and safe run identity",
|
||||
);
|
||||
let phase = "verification-packet";
|
||||
let driver;
|
||||
const runRoot = path.join(options.evidenceDir, options.runId);
|
||||
await fs.mkdir(options.evidenceDir, { recursive: true });
|
||||
// A fresh directory is mandatory; do not overwrite a profile from another run.
|
||||
await fs.mkdir(runRoot);
|
||||
const summaryPath = path.join(
|
||||
runRoot,
|
||||
"persisted-atom-desktop-persistence.json",
|
||||
);
|
||||
try {
|
||||
const packetBytes = await fs.readFile(options.packetPath);
|
||||
const packetSha256 = hashBytes(packetBytes);
|
||||
const packet = JSON.parse(packetBytes.toString("utf8"));
|
||||
phase = "current-pure-owners";
|
||||
const ownerBundle = await bundlePersistedAtomOwners({
|
||||
repoRoot: options.repoRoot,
|
||||
workDir: runRoot,
|
||||
packet,
|
||||
build,
|
||||
});
|
||||
phase = "current-fixture-source";
|
||||
const fixtureRecord = {
|
||||
path: path.join(
|
||||
options.repoRoot,
|
||||
"scripts/electron/lib/persisted-atom-fixture.mjs",
|
||||
),
|
||||
sha256: packet.fixtureSha256,
|
||||
};
|
||||
await assertSourceFile(fixtureRecord, "frozen current IPC fixture helper");
|
||||
const fixture = await import(
|
||||
`${pathToFileURL(fixtureRecord.path).href}?sha256=${fixtureRecord.sha256}`
|
||||
);
|
||||
phase = "guarded-driver";
|
||||
const appDataRoot = path.join(runRoot, "electron-user-data");
|
||||
await fs.mkdir(appDataRoot);
|
||||
driver = await makeDriver({
|
||||
repoRoot: options.repoRoot,
|
||||
appDataRoot,
|
||||
ownerBundle,
|
||||
packet,
|
||||
quitTimeoutMs: options.quitTimeoutMs,
|
||||
});
|
||||
phase = "real-ipc-and-restart";
|
||||
const evidence = await fixture.runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol: ownerBundle.protocol,
|
||||
appDataRoot,
|
||||
runId: options.runId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
const driverEvidence = driver.getEvidence();
|
||||
assertDriver(
|
||||
driverEvidence.outstandingMainPids.length === 0,
|
||||
"no Main process remains after successful fixture",
|
||||
);
|
||||
assertDriver(
|
||||
hashBytes(await fs.readFile(options.packetPath)) === packetSha256,
|
||||
"verification packet unchanged during the scenario",
|
||||
);
|
||||
const summary = {
|
||||
...evidence,
|
||||
driverEvidence,
|
||||
verificationPacketPath: options.packetPath,
|
||||
verificationPacketSha256: packetSha256,
|
||||
fixtureSource: fixtureRecord,
|
||||
strongClaimBoundary:
|
||||
"Desktop Host IPC/CAS/persistence with normal restart; no GUI hydrate or App Server acceptance Gate",
|
||||
};
|
||||
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n");
|
||||
return { summaryPath, summary };
|
||||
} catch {
|
||||
await fs.writeFile(
|
||||
summaryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
result: "fail",
|
||||
phase,
|
||||
runtimeGate: false,
|
||||
guiDraftRestoration: false,
|
||||
driverEvidence: driver?.getEvidence() ?? null,
|
||||
verificationPacketPath: options.packetPath,
|
||||
normalExitMustNotBeReplacedByKill: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
throw new Error(
|
||||
`Desktop persistence standalone failed; see ${summaryPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
try {
|
||||
const options = parsePersistedAtomStandaloneArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(
|
||||
"Usage: node scripts/electron/lib/persisted-atom-fixture-standalone.mjs --run-id <id> --evidence-dir <isolated-dir> --verification-packet <frozen-json> [--timeout-ms 10000] [--quit-timeout-ms 15000]",
|
||||
);
|
||||
} else {
|
||||
const result = await runPersistedAtomStandalone(options);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: "pass",
|
||||
proofLevel: "Desktop persistence",
|
||||
runtimeGate: false,
|
||||
summaryPath: result.summaryPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.error(
|
||||
"Desktop persistence standalone failed; inspect the content-free evidence receipt.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// @vitest-environment node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { hashBytes } from "./persisted-atom-fixture-driver.mjs";
|
||||
import {
|
||||
bundlePersistedAtomOwners,
|
||||
parsePersistedAtomStandaloneArgs,
|
||||
runPersistedAtomStandalone,
|
||||
} from "./persisted-atom-fixture-standalone.mjs";
|
||||
|
||||
const ownedTestRoots = new Set();
|
||||
async function createTestRoot(prefix) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
ownedTestRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const root of ownedTestRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
ownedTestRoots.delete(root);
|
||||
}
|
||||
});
|
||||
const relativeSources = {
|
||||
protocol: "src/lib/api/persistedAtomProtocol.ts",
|
||||
types: "src/lib/api/persistedAtomTypes.ts",
|
||||
records: "electron/persistedAtomRecords.ts",
|
||||
appDataPaths: "electron/appDataPaths.ts",
|
||||
};
|
||||
|
||||
async function sourceProject() {
|
||||
const repoRoot = await createTestRoot("lime-persisted-atom-standalone-unit-");
|
||||
const ownerSha256 = {};
|
||||
for (const [label, relative] of Object.entries(relativeSources)) {
|
||||
const filePath = path.join(repoRoot, relative);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const content = `explicit fake ${label} owner source; unit orchestration only`;
|
||||
await fs.writeFile(filePath, content);
|
||||
ownerSha256[label] = hashBytes(Buffer.from(content));
|
||||
}
|
||||
const packet = { ownerSha256 };
|
||||
const build = vi.fn(async ({ outfile }) => {
|
||||
// This is a fake compiler result, not a copied production validator schema.
|
||||
await fs.writeFile(
|
||||
outfile,
|
||||
`
|
||||
export function parsePersistedAtomReadRequest(value) { return value; }
|
||||
export function parsePersistedAtomReadResult(value) { return value; }
|
||||
export function parsePersistedAtomUpdateResult(value) { return value; }
|
||||
export function parsePersistedAtomUpdateEvent(value) { return value; }
|
||||
export function parseEnvelope(value) { return value; }
|
||||
export function resolveDesktopStorageRoots() { return {}; }
|
||||
export const PERSISTED_ATOM_LIMITS = { maxFileBytes: 1000 };
|
||||
`,
|
||||
);
|
||||
return {
|
||||
metafile: {
|
||||
inputs: Object.fromEntries(
|
||||
[
|
||||
"persisted-atom-owner-entry.mjs",
|
||||
...Object.values(relativeSources),
|
||||
].map((name) => [name, {}]),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { repoRoot, workDir: repoRoot, packet, build };
|
||||
}
|
||||
|
||||
describe("standalone pure-owner compiler orchestration (fake compiler, no product build)", () => {
|
||||
it("points esbuild to only the unique current source owners and records their hashes", async () => {
|
||||
const p = await sourceProject();
|
||||
const bundle = await bundlePersistedAtomOwners(p);
|
||||
const options = p.build.mock.calls[0][0];
|
||||
expect(options).toMatchObject({
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
metafile: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
for (const label of ["protocol", "records", "appDataPaths"])
|
||||
expect(options.stdin.contents).toContain(
|
||||
JSON.stringify(path.join(p.repoRoot, relativeSources[label])),
|
||||
);
|
||||
expect(options.stdin.contents).not.toContain("PersistedAtomStore");
|
||||
expect(options.stdin.contents).toContain("parsePersistedAtomReadRequest");
|
||||
expect(typeof bundle.protocol.parseReadRequest).toBe("function");
|
||||
expect(bundle.receipt.sources.protocol.sha256).toBe(
|
||||
p.packet.ownerSha256.protocol,
|
||||
);
|
||||
expect(Object.keys(bundle.receipt.dependencies)).toContain(
|
||||
path.join(p.repoRoot, relativeSources.protocol),
|
||||
);
|
||||
expect(bundle.receipt.compilationScope).toContain("no product build");
|
||||
});
|
||||
|
||||
it.each(["protocol", "types", "records", "appDataPaths"])(
|
||||
"rejects %s source drift before compilation",
|
||||
async (label) => {
|
||||
const p = await sourceProject();
|
||||
await fs.appendFile(
|
||||
path.join(p.repoRoot, relativeSources[label]),
|
||||
"drift",
|
||||
);
|
||||
await expect(bundlePersistedAtomOwners(p)).rejects.toThrow(
|
||||
/current pure owner/,
|
||||
);
|
||||
expect(p.build).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects source mutation while the isolated compiler is running", async () => {
|
||||
const p = await sourceProject();
|
||||
const original = p.build.getMockImplementation();
|
||||
p.build.mockImplementation(async (options) => {
|
||||
const result = await original(options);
|
||||
await fs.appendFile(
|
||||
path.join(p.repoRoot, relativeSources.protocol),
|
||||
"drift",
|
||||
);
|
||||
return result;
|
||||
});
|
||||
await expect(bundlePersistedAtomOwners(p)).rejects.toThrow(
|
||||
/unchanged pure owner/,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot treat a module built from a different schema input as current owner evidence", async () => {
|
||||
const p = await sourceProject();
|
||||
const original = p.build.getMockImplementation();
|
||||
p.build.mockImplementation(async (options) => {
|
||||
const result = await original(options);
|
||||
delete result.metafile.inputs[relativeSources.protocol];
|
||||
return result;
|
||||
});
|
||||
await expect(bundlePersistedAtomOwners(p)).rejects.toThrow(
|
||||
/unique current owner inputs/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("standalone argument and failure boundary", () => {
|
||||
const argv = [
|
||||
"--run-id",
|
||||
"persistence-test",
|
||||
"--evidence-dir",
|
||||
"./evidence",
|
||||
"--verification-packet",
|
||||
"./packet.json",
|
||||
];
|
||||
|
||||
it("requires explicit packet/evidence identity and bounded timeouts", () => {
|
||||
expect(parsePersistedAtomStandaloneArgs(argv, "/repo")).toMatchObject({
|
||||
repoRoot: "/repo",
|
||||
evidenceDir: "/repo/evidence",
|
||||
packetPath: "/repo/packet.json",
|
||||
runId: "persistence-test",
|
||||
timeoutMs: 10000,
|
||||
quitTimeoutMs: 15000,
|
||||
});
|
||||
expect(parsePersistedAtomStandaloneArgs(["--help"], "/repo").help).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(
|
||||
[
|
||||
[],
|
||||
["--unknown", "true"],
|
||||
[...argv, "--timeout-ms", "Infinity"],
|
||||
[...argv, "--quit-timeout-ms", "60001"],
|
||||
[...argv, "--run-id", "../other-profile"],
|
||||
].map((input) => ({ input })),
|
||||
)("rejects invalid or unbounded standalone input", ({ input }) => {
|
||||
expect(() => parsePersistedAtomStandaloneArgs(input, "/repo")).toThrow(
|
||||
/driver failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before launching when the unique current fixture source is unavailable", async () => {
|
||||
const p = await sourceProject();
|
||||
const packetPath = path.join(p.repoRoot, "packet.json");
|
||||
await fs.writeFile(
|
||||
packetPath,
|
||||
JSON.stringify({ ...p.packet, fixtureSha256: "a".repeat(64) }),
|
||||
);
|
||||
const makeDriver = vi.fn();
|
||||
const options = {
|
||||
repoRoot: p.repoRoot,
|
||||
evidenceDir: path.join(p.repoRoot, "evidence"),
|
||||
packetPath,
|
||||
runId: "missing-fixture",
|
||||
timeoutMs: 100,
|
||||
quitTimeoutMs: 100,
|
||||
};
|
||||
await expect(
|
||||
runPersistedAtomStandalone(options, { build: p.build, makeDriver }),
|
||||
).rejects.toThrow(/standalone failed/);
|
||||
expect(makeDriver).not.toHaveBeenCalled();
|
||||
const summary = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(
|
||||
options.evidenceDir,
|
||||
options.runId,
|
||||
"persisted-atom-desktop-persistence.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
result: "fail",
|
||||
phase: "current-fixture-source",
|
||||
runtimeGate: false,
|
||||
guiDraftRestoration: false,
|
||||
normalExitMustNotBeReplacedByKill: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite a previous run profile", async () => {
|
||||
const p = await sourceProject();
|
||||
const options = {
|
||||
repoRoot: p.repoRoot,
|
||||
evidenceDir: path.join(p.repoRoot, "evidence"),
|
||||
packetPath: path.join(p.repoRoot, "packet.json"),
|
||||
runId: "existing-run",
|
||||
};
|
||||
await fs.mkdir(path.join(options.evidenceDir, options.runId), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(options.evidenceDir, options.runId, "keep.json"),
|
||||
"retain",
|
||||
);
|
||||
await expect(
|
||||
runPersistedAtomStandalone(options, { build: p.build }),
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(options.evidenceDir, options.runId, "keep.json"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("retain");
|
||||
expect(p.build).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
export const PERSISTED_ATOM_FIXTURE_KEY = "composer-drafts-v1";
|
||||
export const PERSISTED_ATOM_FIXTURE_COMMANDS = Object.freeze([
|
||||
"persisted_atom_read",
|
||||
"persisted_atom_update",
|
||||
]);
|
||||
const EVENT = "persisted-atom-update";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
class FixtureProofError extends Error {}
|
||||
|
||||
function requireProof(condition, name) {
|
||||
if (!condition)
|
||||
throw new FixtureProofError(`persisted atom fixture failed: ${name}`);
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function parseProtocol(parser, value, name, request) {
|
||||
try {
|
||||
const result = parser(value, request);
|
||||
requireProof(result !== null && result !== undefined, name);
|
||||
return result;
|
||||
} catch {
|
||||
throw new FixtureProofError(`persisted atom fixture failed: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeView(result, name) {
|
||||
requireProof(
|
||||
result?.outcome === "ready" && result.data?.queryKind === "scope",
|
||||
name,
|
||||
);
|
||||
return result.data.view;
|
||||
}
|
||||
|
||||
function expectDraft(result, identity, draft, name) {
|
||||
const entry =
|
||||
result.outcome === "ready" ? scopeView(result, name).entry : result.entry;
|
||||
requireProof(
|
||||
entry?.state === "draft" &&
|
||||
entry.draftId === identity.draftId &&
|
||||
entry.scopeVersion === identity.scopeVersion &&
|
||||
digest(entry.draft) === digest(draft),
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
function expectEmpty(result, name) {
|
||||
requireProof(scopeView(result, name).entry === null, name);
|
||||
}
|
||||
|
||||
function expectIndex(result, workspaceId, expected, name) {
|
||||
requireProof(
|
||||
result?.outcome === "ready" &&
|
||||
result.data?.queryKind === "workspace-index" &&
|
||||
result.data.workspaceId === workspaceId,
|
||||
name,
|
||||
);
|
||||
const items = result.data.items;
|
||||
requireProof(
|
||||
items.length === expected.length &&
|
||||
expected.every(([scope, identity]) =>
|
||||
items.some(
|
||||
(item) =>
|
||||
digest(item.scope) === digest(scope) &&
|
||||
digest(item.identity) === digest(identity),
|
||||
),
|
||||
),
|
||||
name,
|
||||
);
|
||||
return {
|
||||
workspaceId,
|
||||
storeRevision: result.storeRevision,
|
||||
itemCount: items.length,
|
||||
itemsSha256: digest(items),
|
||||
};
|
||||
}
|
||||
|
||||
function expectOutcome(result, outcome, changed, reason, name) {
|
||||
requireProof(
|
||||
result.outcome === outcome &&
|
||||
result.changed === changed &&
|
||||
(reason === undefined || result.reason === reason),
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
function verifyIdentity(handle, appDataRoot) {
|
||||
const identity = handle.identity;
|
||||
requireProof(
|
||||
Number.isSafeInteger(identity?.pid) &&
|
||||
identity.pid > 0 &&
|
||||
identity.appDataRoot === appDataRoot &&
|
||||
SHA256.test(identity.hostSha256 ?? "") &&
|
||||
SHA256.test(identity.preloadSha256 ?? "") &&
|
||||
identity.eventEmissionGuard?.validated === true &&
|
||||
SHA256.test(identity.eventEmissionGuard.sourceSha256 ?? ""),
|
||||
"current Host/preload and renderer-emission source guard",
|
||||
);
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Only the public preload IPC surface is evaluated; no store or Controller access. */
|
||||
export async function observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
command,
|
||||
args,
|
||||
{ timeoutMs = 10_000 } = {},
|
||||
) {
|
||||
requireProof(
|
||||
PERSISTED_ATOM_FIXTURE_COMMANDS.includes(command),
|
||||
"known command",
|
||||
);
|
||||
const request =
|
||||
command === "persisted_atom_read"
|
||||
? parseProtocol(
|
||||
protocol.parseReadRequest,
|
||||
args,
|
||||
"strict read request protocol",
|
||||
)
|
||||
: args;
|
||||
let observation;
|
||||
try {
|
||||
observation = await page.evaluate(
|
||||
async ({ command: name, args: request, eventName, timeout }) => {
|
||||
const api = window.electronAPI;
|
||||
if (
|
||||
window.__LIME_ELECTRON__ !== true ||
|
||||
api?.devBridgeFallback !== false ||
|
||||
typeof api.invoke !== "function" ||
|
||||
typeof api.listen !== "function" ||
|
||||
typeof api.supportsCommand !== "function" ||
|
||||
!api.supportsCommand(name)
|
||||
) {
|
||||
throw new Error("native persisted atom preload unavailable");
|
||||
}
|
||||
const events = [];
|
||||
let resolveEvent;
|
||||
let expectedRevision;
|
||||
const eventSeen = new Promise((resolve) => {
|
||||
resolveEvent = resolve;
|
||||
});
|
||||
const unlisten = api.listen(eventName, (envelope) => {
|
||||
events.push(envelope);
|
||||
if (envelope?.payload?.storeRevision === expectedRevision)
|
||||
resolveEvent();
|
||||
});
|
||||
if (typeof unlisten !== "function") {
|
||||
throw new Error("native persisted atom listener unavailable");
|
||||
}
|
||||
let timer;
|
||||
try {
|
||||
const result = await api.invoke(name, request);
|
||||
if (name === "persisted_atom_update" && result?.changed === true) {
|
||||
expectedRevision = result.storeRevision;
|
||||
if (
|
||||
events.some(
|
||||
(event) => event?.payload?.storeRevision === expectedRevision,
|
||||
)
|
||||
)
|
||||
resolveEvent();
|
||||
await Promise.race([
|
||||
eventSeen,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, timeout);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return { result, events, transport: "electron-ipc" };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
unlisten();
|
||||
}
|
||||
},
|
||||
{ command, args: request, eventName: EVENT, timeout: timeoutMs },
|
||||
);
|
||||
} catch {
|
||||
// Transport errors may include request data. Do not echo them into evidence.
|
||||
throw new Error(`persisted atom fixture transport failed: ${command}`);
|
||||
}
|
||||
requireProof(
|
||||
observation?.transport === "electron-ipc",
|
||||
"native IPC transport",
|
||||
);
|
||||
const result = parseProtocol(
|
||||
command === "persisted_atom_read"
|
||||
? protocol.parseReadResult
|
||||
: protocol.parseUpdateResult,
|
||||
observation.result,
|
||||
"strict response protocol",
|
||||
request,
|
||||
);
|
||||
const events = observation.events.map((envelope) => {
|
||||
requireProof(envelope?.event === EVENT, "native event envelope");
|
||||
return parseProtocol(
|
||||
protocol.parseUpdateEvent,
|
||||
envelope.payload,
|
||||
"strict metadata-only event protocol",
|
||||
);
|
||||
});
|
||||
if (command === "persisted_atom_update" && result.changed) {
|
||||
requireProof(
|
||||
events.some((event) => event.storeRevision === result.storeRevision),
|
||||
"Main mutation broadcast revision",
|
||||
);
|
||||
}
|
||||
return { result, events };
|
||||
}
|
||||
|
||||
function verifyFiles(files, appDataRoot, revision) {
|
||||
for (const [kind, filename] of [
|
||||
["primary", "host-state.json"],
|
||||
["backup", "host-state.json.bak"],
|
||||
]) {
|
||||
const file = files?.[kind];
|
||||
requireProof(
|
||||
file?.path === path.join(appDataRoot, filename) &&
|
||||
file.validatedBy === "persistedAtomRecords.parseEnvelope" &&
|
||||
file.storeRevision === revision &&
|
||||
SHA256.test(file.sha256 ?? ""),
|
||||
`saved ${kind} envelope`,
|
||||
);
|
||||
}
|
||||
requireProof(
|
||||
files.primary.sha256 === files.backup.sha256,
|
||||
"same saved snapshot",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
["primary", "backup"].map((kind) => {
|
||||
const {
|
||||
path: filePath,
|
||||
storeRevision,
|
||||
sha256,
|
||||
validatedBy,
|
||||
} = files[kind];
|
||||
return [kind, { path: filePath, storeRevision, sha256, validatedBy }];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function verifyExit(receipt, pid) {
|
||||
requireProof(
|
||||
receipt?.pid === pid &&
|
||||
receipt.exited === true &&
|
||||
receipt.exitCode === 0 &&
|
||||
receipt.signal === null &&
|
||||
receipt.mode === "normal",
|
||||
"complete normal Main process exit",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver owns launch/close and isolated-profile file IO. Shared wire parsers and
|
||||
* the existing file envelope owner are mandatory; fake drivers are unit evidence.
|
||||
*/
|
||||
export async function runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
timeoutMs = 10_000,
|
||||
intervalMs = 50,
|
||||
}) {
|
||||
requireProof(path.isAbsolute(appDataRoot), "isolated absolute AppDataRoot");
|
||||
requireProof(/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(runId), "run identity");
|
||||
requireProof(timeoutMs > 0 && timeoutMs <= 60_000, "bounded timeout");
|
||||
requireProof(intervalMs > 0 && intervalMs <= timeoutMs, "bounded polling");
|
||||
const scope = (kind, id) => ({ workspaceId: `fixture-${runId}`, kind, id });
|
||||
const home = scope("home", "new-conversation");
|
||||
const local = scope("local", `session-${runId}`);
|
||||
const draftScope = scope("draft", `draft-tab-${runId}`);
|
||||
const otherScope = { ...draftScope, workspaceId: `other-${runId}` };
|
||||
const nullScope = { ...home, workspaceId: null };
|
||||
const identity = (suffix, version) => ({
|
||||
draftId: `draft-${runId}-${suffix}`,
|
||||
scopeVersion: `version-${suffix}-${version}`,
|
||||
});
|
||||
const a1 = identity("a", 1);
|
||||
const a2 = identity("a", 2);
|
||||
const b1 = identity("b", 1);
|
||||
const c1 = identity("c", 1);
|
||||
const d1 = identity("d", 1);
|
||||
const e1 = identity("e", 1);
|
||||
const f1 = identity("f", 1);
|
||||
const firstDraft = {
|
||||
document: { type: "doc", content: [] },
|
||||
text: "fixture 中😀",
|
||||
};
|
||||
const finalDraft = {
|
||||
document: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: "typescript" },
|
||||
content: [{ type: "text", text: "const n = 1;" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
text: "const n = 1;",
|
||||
selection: { from: 2, to: 4 },
|
||||
annotations: [{ kind: "fixture", label: "中😀" }],
|
||||
images: [{ source: "fixture-only.png", unavailable: true }],
|
||||
};
|
||||
let active;
|
||||
const commands = [];
|
||||
let mutationEvents = 0;
|
||||
const invoke = async (command, request) => {
|
||||
const observation = await observePersistedAtomCommand(
|
||||
active.page,
|
||||
protocol,
|
||||
command,
|
||||
request,
|
||||
{ timeoutMs },
|
||||
);
|
||||
commands.push(command);
|
||||
mutationEvents += observation.events.length;
|
||||
return observation.result;
|
||||
};
|
||||
const read = (target) =>
|
||||
invoke("persisted_atom_read", {
|
||||
key: PERSISTED_ATOM_FIXTURE_KEY,
|
||||
query: { kind: "scope", scope: target },
|
||||
});
|
||||
const index = (workspaceId) =>
|
||||
invoke("persisted_atom_read", {
|
||||
key: PERSISTED_ATOM_FIXTURE_KEY,
|
||||
query: { kind: "workspace-index", workspaceId },
|
||||
});
|
||||
const update = (mutation) =>
|
||||
invoke("persisted_atom_update", {
|
||||
key: PERSISTED_ATOM_FIXTURE_KEY,
|
||||
mutation,
|
||||
});
|
||||
const put = (target, token, expectedVersion, draft) =>
|
||||
update({
|
||||
op: "put",
|
||||
scope: target,
|
||||
draftId: token.draftId,
|
||||
expectedVersion,
|
||||
nextVersion: token.scopeVersion,
|
||||
draft,
|
||||
});
|
||||
const readReady = async (target) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await read(target);
|
||||
if (result.persistence.state !== "loading") return result;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new FixtureProofError(
|
||||
"persisted atom fixture failed: initial load timeout",
|
||||
);
|
||||
};
|
||||
try {
|
||||
active = await driver.launch({ appDataRoot, generation: 1 });
|
||||
const before = verifyIdentity(active, appDataRoot);
|
||||
expectEmpty(await readReady(home), "fresh Home scope after initial load");
|
||||
expectIndex(
|
||||
await index(home.workspaceId),
|
||||
home.workspaceId,
|
||||
[],
|
||||
"fresh workspace index",
|
||||
);
|
||||
expectOutcome(
|
||||
await put(home, a1, null, firstDraft),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"create",
|
||||
);
|
||||
expectDraft(await read(home), a1, firstDraft, "create readback");
|
||||
expectIndex(
|
||||
await index(home.workspaceId),
|
||||
home.workspaceId,
|
||||
[[home, a1]],
|
||||
"Home current binding discoverable",
|
||||
);
|
||||
expectOutcome(
|
||||
await put(home, a2, a1.scopeVersion, finalDraft),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"version update",
|
||||
);
|
||||
const stale = await put(
|
||||
home,
|
||||
identity("a", 3),
|
||||
a1.scopeVersion,
|
||||
firstDraft,
|
||||
);
|
||||
expectOutcome(
|
||||
stale,
|
||||
"conflict",
|
||||
false,
|
||||
"version_conflict",
|
||||
"stale writer CAS",
|
||||
);
|
||||
expectDraft(stale, a2, finalDraft, "CAS preserves latest draft");
|
||||
expectOutcome(
|
||||
await put(local, b1, null, firstDraft),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"occupied target",
|
||||
);
|
||||
expectIndex(
|
||||
await index(home.workspaceId),
|
||||
home.workspaceId,
|
||||
[
|
||||
[home, a2],
|
||||
[local, b1],
|
||||
],
|
||||
"Home and local bindings isolated to workspace",
|
||||
);
|
||||
const promotion = {
|
||||
op: "promote",
|
||||
source: home,
|
||||
target: local,
|
||||
identity: a2,
|
||||
expectedTarget: null,
|
||||
sessionId: local.id,
|
||||
threadId: `thread-${runId}`,
|
||||
};
|
||||
expectOutcome(
|
||||
await update(promotion),
|
||||
"conflict",
|
||||
false,
|
||||
"target_conflict",
|
||||
"promotion target CAS",
|
||||
);
|
||||
expectDraft(await read(home), a2, finalDraft, "conflicted source retained");
|
||||
expectDraft(
|
||||
await read(local),
|
||||
b1,
|
||||
firstDraft,
|
||||
"conflicted target retained",
|
||||
);
|
||||
expectOutcome(
|
||||
await update({ op: "clear", identity: b1 }),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"explicit clear",
|
||||
);
|
||||
expectOutcome(
|
||||
await update(promotion),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"scope promotion",
|
||||
);
|
||||
expectEmpty(await read(home), "promoted source unbound");
|
||||
const promoted = await read(local);
|
||||
expectDraft(
|
||||
promoted,
|
||||
a2,
|
||||
finalDraft,
|
||||
"promotion keeps identity and payload",
|
||||
);
|
||||
requireProof(
|
||||
scopeView(promoted, "promoted scope").entry.sessionId === local.id &&
|
||||
scopeView(promoted, "promoted scope").entry.threadId ===
|
||||
promotion.threadId,
|
||||
"session/thread metadata remain distinct",
|
||||
);
|
||||
expectOutcome(
|
||||
await update({ op: "consume", identity: a1 }),
|
||||
"conflict",
|
||||
false,
|
||||
"version_conflict",
|
||||
"old acceptance preserves newer version",
|
||||
);
|
||||
expectDraft(await read(local), a2, finalDraft, "old acceptance unchanged");
|
||||
const consumed = await update({ op: "consume", identity: a2 });
|
||||
expectOutcome(consumed, "applied", true, undefined, "exact acceptance");
|
||||
requireProof(
|
||||
consumed.entry?.state === "consumed" &&
|
||||
!Object.hasOwn(consumed.entry, "draft"),
|
||||
"tombstone contains no body",
|
||||
);
|
||||
expectEmpty(await read(local), "consumed scope unbound");
|
||||
expectIndex(
|
||||
await index(home.workspaceId),
|
||||
home.workspaceId,
|
||||
[],
|
||||
"cleared and consumed drafts absent from index",
|
||||
);
|
||||
expectOutcome(
|
||||
await put(local, identity("a", 3), null, firstDraft),
|
||||
"conflict",
|
||||
false,
|
||||
"retired_draft",
|
||||
"late put cannot resurrect",
|
||||
);
|
||||
expectOutcome(
|
||||
await put(local, c1, null, finalDraft),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"new logical draft",
|
||||
);
|
||||
expectOutcome(
|
||||
await update({ op: "consume", identity: a2 }),
|
||||
"applied",
|
||||
false,
|
||||
undefined,
|
||||
"idempotent old acceptance",
|
||||
);
|
||||
expectDraft(
|
||||
await read(local),
|
||||
c1,
|
||||
finalDraft,
|
||||
"new logical draft survives old acceptance",
|
||||
);
|
||||
for (const [target, token] of [
|
||||
[draftScope, d1],
|
||||
[otherScope, e1],
|
||||
[nullScope, f1],
|
||||
]) {
|
||||
expectOutcome(
|
||||
await put(target, token, null, firstDraft),
|
||||
"applied",
|
||||
true,
|
||||
undefined,
|
||||
"isolated workspace draft creation",
|
||||
);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let saved;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const candidate = await read(local);
|
||||
expectDraft(
|
||||
candidate,
|
||||
c1,
|
||||
finalDraft,
|
||||
"saved polling keeps current draft",
|
||||
);
|
||||
if (
|
||||
candidate.persistence.state === "error" ||
|
||||
candidate.persistence.state === "unavailable"
|
||||
) {
|
||||
throw new FixtureProofError(
|
||||
"persisted atom fixture failed: disk save unavailable",
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.persistence.state === "saved" &&
|
||||
candidate.persistence.savedRevision === candidate.storeRevision
|
||||
) {
|
||||
saved = candidate;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
requireProof(saved !== undefined, "real saved revision receipt");
|
||||
const indexSnapshots = [];
|
||||
for (const [workspaceId, expected] of [
|
||||
[
|
||||
home.workspaceId,
|
||||
[
|
||||
[local, c1],
|
||||
[draftScope, d1],
|
||||
],
|
||||
],
|
||||
[otherScope.workspaceId, [[otherScope, e1]]],
|
||||
[null, [[nullScope, f1]]],
|
||||
]) {
|
||||
const result = await index(workspaceId);
|
||||
indexSnapshots.push(
|
||||
expectIndex(
|
||||
result,
|
||||
workspaceId,
|
||||
expected,
|
||||
"saved workspace index isolation",
|
||||
),
|
||||
);
|
||||
requireProof(
|
||||
result.storeRevision === saved.storeRevision &&
|
||||
result.persistence.state === "saved" &&
|
||||
result.persistence.savedRevision === saved.storeRevision,
|
||||
"index and saved file metadata revision",
|
||||
);
|
||||
}
|
||||
const files = verifyFiles(
|
||||
await driver.inspectFiles({ appDataRoot }),
|
||||
appDataRoot,
|
||||
saved.storeRevision,
|
||||
);
|
||||
const firstExit = await driver.close(active);
|
||||
verifyExit(firstExit, before.pid);
|
||||
active = null;
|
||||
active = await driver.launch({ appDataRoot, generation: 2 });
|
||||
const after = verifyIdentity(active, appDataRoot);
|
||||
requireProof(after.pid !== before.pid, "new Main PID after full exit");
|
||||
requireProof(
|
||||
after.hostSha256 === before.hostSha256 &&
|
||||
after.preloadSha256 === before.preloadSha256 &&
|
||||
after.eventEmissionGuard.sourceSha256 ===
|
||||
before.eventEmissionGuard.sourceSha256,
|
||||
"same Host/preload artifacts across restart",
|
||||
);
|
||||
const restored = await readReady(local);
|
||||
expectDraft(
|
||||
restored,
|
||||
c1,
|
||||
finalDraft,
|
||||
"same scope/version after real restart",
|
||||
);
|
||||
requireProof(
|
||||
restored.storeRevision === saved.storeRevision &&
|
||||
restored.persistence.state === "saved" &&
|
||||
restored.persistence.savedRevision === saved.storeRevision,
|
||||
"restart saved revision unchanged",
|
||||
);
|
||||
const restoredIndexSnapshots = [];
|
||||
for (const [workspaceId, expected] of [
|
||||
[
|
||||
home.workspaceId,
|
||||
[
|
||||
[local, c1],
|
||||
[draftScope, d1],
|
||||
],
|
||||
],
|
||||
[otherScope.workspaceId, [[otherScope, e1]]],
|
||||
[null, [[nullScope, f1]]],
|
||||
]) {
|
||||
const result = await index(workspaceId);
|
||||
restoredIndexSnapshots.push(
|
||||
expectIndex(
|
||||
result,
|
||||
workspaceId,
|
||||
expected,
|
||||
"workspace index discovered after normal restart",
|
||||
),
|
||||
);
|
||||
requireProof(
|
||||
result.storeRevision === saved.storeRevision &&
|
||||
result.persistence.state === "saved" &&
|
||||
result.persistence.savedRevision === saved.storeRevision,
|
||||
"restart index saved revision unchanged",
|
||||
);
|
||||
}
|
||||
requireProof(
|
||||
digest(indexSnapshots) === digest(restoredIndexSnapshots),
|
||||
"same detached index metadata across restart",
|
||||
);
|
||||
expectEmpty(
|
||||
await read(home),
|
||||
"promoted/consumed Home remains absent after restart",
|
||||
);
|
||||
expectOutcome(
|
||||
await put(local, identity("a", 4), null, firstDraft),
|
||||
"conflict",
|
||||
false,
|
||||
"retired_draft",
|
||||
"tombstone survives restart",
|
||||
);
|
||||
expectDraft(
|
||||
await read(local),
|
||||
c1,
|
||||
finalDraft,
|
||||
"restored new draft protected",
|
||||
);
|
||||
const secondExit = await driver.close(active);
|
||||
verifyExit(secondExit, after.pid);
|
||||
active = null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
scenario: "persisted-atom-host-ipc-restart",
|
||||
proofLevel: "Desktop persistence",
|
||||
runtimeGate: false,
|
||||
guiDraftRestoration: false,
|
||||
realRuntimeAcceptance: false,
|
||||
result: "pass",
|
||||
runId,
|
||||
appDataRoot,
|
||||
pids: [before.pid, after.pid],
|
||||
hostSha256: before.hostSha256,
|
||||
preloadSha256: before.preloadSha256,
|
||||
rendererEventEmissionGuardSha256: before.eventEmissionGuard.sourceSha256,
|
||||
scope: local,
|
||||
identity: c1,
|
||||
draftSha256: digest(finalDraft),
|
||||
savedRevision: saved.storeRevision,
|
||||
workspaceIndexSnapshots: indexSnapshots,
|
||||
restoredWorkspaceIndexSnapshots: restoredIndexSnapshots,
|
||||
files,
|
||||
commands: [...new Set(commands)],
|
||||
ipcInvocationCount: commands.length,
|
||||
nativeMetadataEventCount: mutationEvents,
|
||||
normalExitCount: 2,
|
||||
cases: [
|
||||
"create",
|
||||
"update",
|
||||
"CAS-conflict",
|
||||
"promotion-target-conflict",
|
||||
"clear",
|
||||
"promote",
|
||||
"old-version-consume-conflict",
|
||||
"consume",
|
||||
"late-put",
|
||||
"new-draft-protection",
|
||||
"saved-receipt",
|
||||
"primary-backup",
|
||||
"new-PID-restart",
|
||||
"same-scope-version-read",
|
||||
"restart-tombstone",
|
||||
"same-atom-workspace-index",
|
||||
"Home-draft-local-current-binding",
|
||||
"workspace-and-null-isolation",
|
||||
"consumed-index-removal",
|
||||
"index-file-revision",
|
||||
"restart-index-discovery",
|
||||
],
|
||||
unknown: [
|
||||
"GUI rich-draft hydration",
|
||||
"App Server acceptance",
|
||||
"Windows process",
|
||||
"force kill/power loss durability",
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
// Never serialize Host errors, rejected payloads, or user draft data.
|
||||
if (error instanceof FixtureProofError) throw error;
|
||||
throw new Error("persisted atom Host IPC/restart fixture did not complete");
|
||||
} finally {
|
||||
if (active) await driver.close(active).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parsePersistedAtomReadRequest,
|
||||
parsePersistedAtomReadResult,
|
||||
parsePersistedAtomUpdateEvent,
|
||||
parsePersistedAtomUpdateResult,
|
||||
} from "@/lib/api/persistedAtomProtocol";
|
||||
import {
|
||||
observePersistedAtomCommand,
|
||||
runPersistedAtomFixture,
|
||||
} from "./persisted-atom-fixture.mjs";
|
||||
|
||||
const protocol = {
|
||||
parseReadRequest: parsePersistedAtomReadRequest,
|
||||
parseReadResult: parsePersistedAtomReadResult,
|
||||
parseUpdateResult: parsePersistedAtomUpdateResult,
|
||||
parseUpdateEvent: parsePersistedAtomUpdateEvent,
|
||||
};
|
||||
const atomKey = "composer-drafts-v1";
|
||||
const eventName = "persisted-atom-update";
|
||||
const runId = "unit-test";
|
||||
const appDataRoot = "/isolated/unit-test";
|
||||
const sha = "a".repeat(64);
|
||||
const home = {
|
||||
workspaceId: `fixture-${runId}`,
|
||||
kind: "home",
|
||||
id: "new-conversation",
|
||||
};
|
||||
const local = {
|
||||
workspaceId: `fixture-${runId}`,
|
||||
kind: "local",
|
||||
id: `session-${runId}`,
|
||||
};
|
||||
const token = (suffix, version) => ({
|
||||
draftId: `draft-${runId}-${suffix}`,
|
||||
scopeVersion: `version-${suffix}-${version}`,
|
||||
});
|
||||
const a1 = token("a", 1),
|
||||
a2 = token("a", 2),
|
||||
b1 = token("b", 1),
|
||||
c1 = token("c", 1),
|
||||
d1 = token("d", 1),
|
||||
e1 = token("e", 1),
|
||||
f1 = token("f", 1);
|
||||
const draftScope = { ...home, kind: "draft", id: `draft-tab-${runId}` };
|
||||
const otherScope = { ...draftScope, workspaceId: `other-${runId}` };
|
||||
const nullScope = { ...home, workspaceId: null };
|
||||
const firstDraft = {
|
||||
document: { type: "doc", content: [] },
|
||||
text: "fixture 中😀",
|
||||
};
|
||||
const finalDraft = {
|
||||
document: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: "typescript" },
|
||||
content: [{ type: "text", text: "const n = 1;" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
text: "const n = 1;",
|
||||
selection: { from: 2, to: 4 },
|
||||
annotations: [{ kind: "fixture", label: "中😀" }],
|
||||
images: [{ source: "fixture-only.png", unavailable: true }],
|
||||
};
|
||||
const entry = (identity, draft, metadata = {}) => {
|
||||
const origin = [b1.draftId, c1.draftId].includes(identity.draftId)
|
||||
? local
|
||||
: home;
|
||||
return {
|
||||
...identity,
|
||||
originScopeKey: JSON.stringify([
|
||||
origin.workspaceId,
|
||||
origin.kind,
|
||||
origin.id,
|
||||
]),
|
||||
updatedAt: 100,
|
||||
state: "draft",
|
||||
draft,
|
||||
...(origin.kind === "local" ? { sessionId: origin.id } : {}),
|
||||
...metadata,
|
||||
};
|
||||
};
|
||||
const retired = (identity, consumption = "accepted") => ({
|
||||
...identity,
|
||||
originScopeKey: JSON.stringify([home.workspaceId, home.kind, home.id]),
|
||||
updatedAt: 100,
|
||||
state: "consumed",
|
||||
consumption,
|
||||
consumedAt: 100,
|
||||
});
|
||||
const meta = (revision, state = "pending") => ({
|
||||
key: atomKey,
|
||||
storeRevision: revision,
|
||||
persistence: { state, savedRevision: state === "saved" ? revision : 0 },
|
||||
});
|
||||
const read = (scope, entry, revision, state) => ({
|
||||
...meta(revision, state),
|
||||
outcome: "ready",
|
||||
data: { queryKind: "scope", view: { scope, entry } },
|
||||
});
|
||||
const indexRead = (workspaceId, rows, revision, state) => ({
|
||||
...meta(revision, state),
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId,
|
||||
items: rows.map(([scope, identity]) => ({
|
||||
scope,
|
||||
identity,
|
||||
updatedAt: 100,
|
||||
...(scope.kind === "local" ? { sessionId: scope.id } : {}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
const scopedEntry = (scope, identity, draft) =>
|
||||
entry(identity, draft, {
|
||||
originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]),
|
||||
});
|
||||
const update = (
|
||||
entry,
|
||||
revision,
|
||||
outcome = "applied",
|
||||
changed = true,
|
||||
reason,
|
||||
) => ({
|
||||
...meta(revision),
|
||||
outcome,
|
||||
changed,
|
||||
...(reason ? { reason } : {}),
|
||||
entry,
|
||||
});
|
||||
const event = (revision) => ({ ...meta(revision), event: eventName });
|
||||
const envelope = (revision) => ({ event: eventName, payload: event(revision) });
|
||||
const observation = (result) => ({
|
||||
result,
|
||||
events: result.changed ? [envelope(result.storeRevision)] : [],
|
||||
transport: "electron-ipc",
|
||||
});
|
||||
|
||||
const fileReceipts = () =>
|
||||
Object.fromEntries(
|
||||
["primary", "backup"].map((kind) => [
|
||||
kind,
|
||||
{
|
||||
path: path.join(
|
||||
appDataRoot,
|
||||
kind === "primary" ? "host-state.json" : "host-state.json.bak",
|
||||
),
|
||||
validatedBy: "persistedAtomRecords.parseEnvelope",
|
||||
storeRevision: 10,
|
||||
sha256: sha,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
function scriptedDriver({
|
||||
mutate,
|
||||
firstIdentity,
|
||||
secondIdentity,
|
||||
exitReceipt,
|
||||
files,
|
||||
neverSaved = false,
|
||||
loadingReads = 0,
|
||||
} = {}) {
|
||||
const promoted = entry(a2, finalDraft, {
|
||||
sessionId: local.id,
|
||||
threadId: `thread-${runId}`,
|
||||
});
|
||||
const steps = [
|
||||
["read", read(home, null, 0, "saved")],
|
||||
["index", indexRead(home.workspaceId, [], 0, "saved")],
|
||||
["put", update(entry(a1, firstDraft), 1)],
|
||||
["read", read(home, entry(a1, firstDraft), 1)],
|
||||
["index", indexRead(home.workspaceId, [[home, a1]], 1)],
|
||||
["put", update(entry(a2, finalDraft), 2)],
|
||||
[
|
||||
"put",
|
||||
update(entry(a2, finalDraft), 2, "conflict", false, "version_conflict"),
|
||||
],
|
||||
["put", update(entry(b1, firstDraft), 3)],
|
||||
[
|
||||
"index",
|
||||
indexRead(
|
||||
home.workspaceId,
|
||||
[
|
||||
[home, a2],
|
||||
[local, b1],
|
||||
],
|
||||
3,
|
||||
),
|
||||
],
|
||||
[
|
||||
"promote",
|
||||
update(entry(a2, finalDraft), 3, "conflict", false, "target_conflict"),
|
||||
],
|
||||
["read", read(home, entry(a2, finalDraft), 3)],
|
||||
["read", read(local, entry(b1, firstDraft), 3)],
|
||||
["clear", update(retired(b1, "clear"), 4)],
|
||||
["promote", update(promoted, 5)],
|
||||
["read", read(home, null, 5)],
|
||||
["read", read(local, promoted, 5)],
|
||||
["consume", update(promoted, 5, "conflict", false, "version_conflict")],
|
||||
["read", read(local, promoted, 5)],
|
||||
["consume", update(retired(a2), 6)],
|
||||
["read", read(local, null, 6)],
|
||||
["index", indexRead(home.workspaceId, [], 6)],
|
||||
["put", update(retired(a2), 6, "conflict", false, "retired_draft")],
|
||||
["put", update(entry(c1, finalDraft), 7)],
|
||||
["consume", update(retired(a2), 7, "applied", false)],
|
||||
["read", read(local, entry(c1, finalDraft), 7)],
|
||||
["put", update(scopedEntry(draftScope, d1, firstDraft), 8)],
|
||||
["put", update(scopedEntry(otherScope, e1, firstDraft), 9)],
|
||||
["put", update(scopedEntry(nullScope, f1, firstDraft), 10)],
|
||||
["read", read(local, entry(c1, finalDraft), 10, "saved")],
|
||||
[
|
||||
"index",
|
||||
indexRead(
|
||||
home.workspaceId,
|
||||
[
|
||||
[local, c1],
|
||||
[draftScope, d1],
|
||||
],
|
||||
10,
|
||||
"saved",
|
||||
),
|
||||
],
|
||||
[
|
||||
"index",
|
||||
indexRead(otherScope.workspaceId, [[otherScope, e1]], 10, "saved"),
|
||||
],
|
||||
["index", indexRead(null, [[nullScope, f1]], 10, "saved")],
|
||||
["read", read(local, entry(c1, finalDraft), 10, "saved")],
|
||||
[
|
||||
"index",
|
||||
indexRead(
|
||||
home.workspaceId,
|
||||
[
|
||||
[local, c1],
|
||||
[draftScope, d1],
|
||||
],
|
||||
10,
|
||||
"saved",
|
||||
),
|
||||
],
|
||||
[
|
||||
"index",
|
||||
indexRead(otherScope.workspaceId, [[otherScope, e1]], 10, "saved"),
|
||||
],
|
||||
["index", indexRead(null, [[nullScope, f1]], 10, "saved")],
|
||||
["read", read(home, null, 10, "saved")],
|
||||
["put", update(retired(a2), 10, "conflict", false, "retired_draft")],
|
||||
["read", read(local, entry(c1, finalDraft), 10, "saved")],
|
||||
];
|
||||
const order = [];
|
||||
let index = 0;
|
||||
const evaluate = vi.fn(async (_callback, args) => {
|
||||
if (index === 0 && loadingReads-- > 0) {
|
||||
expect(args.command).toBe("persisted_atom_read");
|
||||
return observation({
|
||||
key: atomKey,
|
||||
storeRevision: 0,
|
||||
persistence: { state: "loading", savedRevision: null },
|
||||
outcome: "unavailable",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
if (neverSaved && index >= 28)
|
||||
return observation(read(local, entry(c1, finalDraft), 10));
|
||||
const stepIndex = index++;
|
||||
const [op, result] = steps[stepIndex];
|
||||
expect(args.command).toBe(
|
||||
["read", "index"].includes(op)
|
||||
? "persisted_atom_read"
|
||||
: "persisted_atom_update",
|
||||
);
|
||||
if (!["read", "index"].includes(op)) expect(args.args.mutation.op).toBe(op);
|
||||
else {
|
||||
expect(args.args.query).toEqual(
|
||||
op === "read"
|
||||
? { kind: "scope", scope: result.data.view.scope }
|
||||
: { kind: "workspace-index", workspaceId: result.data.workspaceId },
|
||||
);
|
||||
expect(args.args).not.toHaveProperty("scope");
|
||||
}
|
||||
expect(args.args.key).toBe(atomKey);
|
||||
const value = observation(structuredClone(result));
|
||||
mutate?.(value, stepIndex, args);
|
||||
order.push(`ipc:${stepIndex}`);
|
||||
return value;
|
||||
});
|
||||
const identity = (generation) => ({
|
||||
pid: generation === 1 ? 100 : 101,
|
||||
appDataRoot,
|
||||
hostSha256: sha,
|
||||
preloadSha256: sha,
|
||||
eventEmissionGuard: { validated: true, sourceSha256: sha },
|
||||
...(generation === 1 ? firstIdentity : secondIdentity),
|
||||
});
|
||||
const driver = {
|
||||
launch: vi.fn(async ({ generation, appDataRoot: root }) => {
|
||||
order.push(`launch:${generation}`);
|
||||
expect(root).toBe(appDataRoot);
|
||||
return { page: { evaluate }, identity: identity(generation) };
|
||||
}),
|
||||
inspectFiles: vi.fn(async () => {
|
||||
order.push("files");
|
||||
return files ?? fileReceipts();
|
||||
}),
|
||||
close: vi.fn(async (handle) => {
|
||||
order.push(`exit:${handle.identity.pid}`);
|
||||
return {
|
||||
pid: handle.identity.pid,
|
||||
exited: true,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
mode: "normal",
|
||||
...exitReceipt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
return { driver, order, evaluate };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("persisted atom fixture lifecycle (scripted fake bridge; not Electron evidence)", () => {
|
||||
it("requires saved files, complete exit, new PID and same scope/version after restart", async () => {
|
||||
const { driver, order, evaluate } = scriptedDriver();
|
||||
const evidence = await runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
});
|
||||
expect(evidence).toMatchObject({
|
||||
result: "pass",
|
||||
proofLevel: "Desktop persistence",
|
||||
runtimeGate: false,
|
||||
guiDraftRestoration: false,
|
||||
realRuntimeAcceptance: false,
|
||||
pids: [100, 101],
|
||||
savedRevision: 10,
|
||||
normalExitCount: 2,
|
||||
ipcInvocationCount: 39,
|
||||
});
|
||||
expect(order.indexOf("files")).toBeLessThan(order.indexOf("exit:100"));
|
||||
expect(order.indexOf("exit:100")).toBeLessThan(order.indexOf("launch:2"));
|
||||
expect(evaluate).toHaveBeenCalledTimes(39);
|
||||
expect(JSON.stringify(evidence)).not.toContain("const n = 1;");
|
||||
expect(JSON.stringify(evidence)).not.toContain("fixture 中😀");
|
||||
});
|
||||
|
||||
it("waits for initial Main load through read IPC before creating a draft", async () => {
|
||||
const { driver, evaluate } = scriptedDriver({ loadingReads: 2 });
|
||||
await expect(
|
||||
runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
intervalMs: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({ result: "pass", ipcInvocationCount: 41 });
|
||||
expect(
|
||||
evaluate.mock.calls.slice(0, 3).map((call) => call[1].command),
|
||||
).toEqual(Array(3).fill("persisted_atom_read"));
|
||||
});
|
||||
|
||||
it("projects only metadata from file receipts into evidence", async () => {
|
||||
const files = fileReceipts();
|
||||
files.primary.parsedEnvelope = { draft: "private draft 中😀" };
|
||||
files.backup.debug = "private draft 中😀";
|
||||
const { driver } = scriptedDriver({ files });
|
||||
const evidence = await runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
});
|
||||
expect(evidence.files).toEqual(fileReceipts());
|
||||
expect(JSON.stringify(evidence)).not.toContain("private draft");
|
||||
});
|
||||
|
||||
it.each(["revision", "digest", "path", "parser-owner"])(
|
||||
"rejects mismatched backup %s",
|
||||
async (fault) => {
|
||||
const files = fileReceipts();
|
||||
if (fault === "revision") files.backup.storeRevision = 6;
|
||||
if (fault === "digest") files.backup.sha256 = "b".repeat(64);
|
||||
if (fault === "path") files.backup.path = "/outside/host-state.json.bak";
|
||||
if (fault === "parser-owner")
|
||||
files.backup.validatedBy = "unvalidated-json";
|
||||
const { driver } = scriptedDriver({ files });
|
||||
await expect(
|
||||
runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }),
|
||||
).rejects.toThrow(/saved backup|same saved snapshot/);
|
||||
expect(driver.launch).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat a saved revision carried by an error state as a success", async () => {
|
||||
const { driver } = scriptedDriver({
|
||||
mutate: (value, index) => {
|
||||
if (index === 28)
|
||||
value.result.persistence = {
|
||||
state: "error",
|
||||
savedRevision: 10,
|
||||
diagnostic: { phase: "backup", code: "EIO" },
|
||||
};
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }),
|
||||
).rejects.toThrow(/disk save unavailable/);
|
||||
expect(driver.inspectFiles).not.toHaveBeenCalled();
|
||||
expect(driver.launch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing current artifacts",
|
||||
{ firstIdentity: { hostSha256: "unknown" } },
|
||||
/Host\/preload/,
|
||||
],
|
||||
[
|
||||
"renderer emission guard absent",
|
||||
{
|
||||
firstIdentity: {
|
||||
eventEmissionGuard: { validated: false, sourceSha256: sha },
|
||||
},
|
||||
},
|
||||
/source guard/,
|
||||
],
|
||||
[
|
||||
"different profile",
|
||||
{ secondIdentity: { appDataRoot: "/other" } },
|
||||
/Host\/preload/,
|
||||
],
|
||||
["same PID", { secondIdentity: { pid: 100 } }, /new Main PID/],
|
||||
[
|
||||
"changed artifacts",
|
||||
{ secondIdentity: { hostSha256: "b".repeat(64) } },
|
||||
/same Host\/preload/,
|
||||
],
|
||||
["not exited", { exitReceipt: { exited: false } }, /complete normal/],
|
||||
[
|
||||
"killed",
|
||||
{ exitReceipt: { signal: "SIGKILL", mode: "forced" } },
|
||||
/complete normal/,
|
||||
],
|
||||
["bad exit status", { exitReceipt: { exitCode: 1 } }, /complete normal/],
|
||||
["backup missing", { files: {} }, /saved primary/],
|
||||
])("rejects %s", async (_name, options, expected) => {
|
||||
const { driver } = scriptedDriver(options);
|
||||
await expect(
|
||||
runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }),
|
||||
).rejects.toThrow(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"CAS overwrites body",
|
||||
6,
|
||||
(value) => {
|
||||
value.result.entry.draft = firstDraft;
|
||||
},
|
||||
/CAS preserves/,
|
||||
],
|
||||
[
|
||||
"promotion changes version",
|
||||
13,
|
||||
(value) => {
|
||||
value.result.entry.scopeVersion = "wrong";
|
||||
},
|
||||
/strict response/,
|
||||
],
|
||||
[
|
||||
"consumed entry carries body",
|
||||
18,
|
||||
(value) => {
|
||||
value.result.entry.draft = firstDraft;
|
||||
},
|
||||
/strict response/,
|
||||
],
|
||||
[
|
||||
"late put accepted",
|
||||
21,
|
||||
(value) => {
|
||||
value.result.outcome = "applied";
|
||||
value.result.changed = true;
|
||||
},
|
||||
/strict response/,
|
||||
],
|
||||
[
|
||||
"restored version drift",
|
||||
32,
|
||||
(value) => {
|
||||
value.result.data.view.entry.scopeVersion = "wrong";
|
||||
},
|
||||
/same scope\/version/,
|
||||
],
|
||||
[
|
||||
"missing mutation event",
|
||||
2,
|
||||
(value) => {
|
||||
value.events = [];
|
||||
},
|
||||
/Main mutation broadcast/,
|
||||
],
|
||||
[
|
||||
"renderer payload in event",
|
||||
2,
|
||||
(value) => {
|
||||
value.events[0].payload.draft = firstDraft;
|
||||
},
|
||||
/metadata-only event/,
|
||||
],
|
||||
[
|
||||
"wrong event envelope",
|
||||
2,
|
||||
(value) => {
|
||||
value.events[0].event = "other";
|
||||
},
|
||||
/native event envelope/,
|
||||
],
|
||||
[
|
||||
"http fallback",
|
||||
0,
|
||||
(value) => {
|
||||
value.transport = "http-bridge";
|
||||
},
|
||||
/native IPC transport/,
|
||||
],
|
||||
[
|
||||
"consumed index identity remains",
|
||||
20,
|
||||
(value) => {
|
||||
value.result.data.items = indexRead(
|
||||
home.workspaceId,
|
||||
[[local, a2]],
|
||||
6,
|
||||
).data.items;
|
||||
},
|
||||
/cleared and consumed/,
|
||||
],
|
||||
[
|
||||
"saved index omits draft binding",
|
||||
29,
|
||||
(value) => {
|
||||
value.result.data.items = value.result.data.items.slice(0, 1);
|
||||
},
|
||||
/saved workspace index isolation/,
|
||||
],
|
||||
[
|
||||
"saved index includes stale Home binding",
|
||||
29,
|
||||
(value) => {
|
||||
value.result.data.items[0].scope = home;
|
||||
delete value.result.data.items[0].sessionId;
|
||||
},
|
||||
/saved workspace index isolation/,
|
||||
],
|
||||
[
|
||||
"foreign workspace in saved index",
|
||||
29,
|
||||
(value) => {
|
||||
value.result.data.items[0].scope.workspaceId = otherScope.workspaceId;
|
||||
},
|
||||
/strict response/,
|
||||
],
|
||||
[
|
||||
"saved index revision mismatches files",
|
||||
29,
|
||||
(value) => {
|
||||
value.result.storeRevision = 9;
|
||||
value.result.persistence.savedRevision = 9;
|
||||
},
|
||||
/index and saved file metadata/,
|
||||
],
|
||||
[
|
||||
"restarted index metadata drifts",
|
||||
33,
|
||||
(value) => {
|
||||
value.result.data.items[0].updatedAt = 101;
|
||||
},
|
||||
/same detached index metadata/,
|
||||
],
|
||||
])("rejects %s", async (_name, target, alter, expected) => {
|
||||
const { driver } = scriptedDriver({
|
||||
mutate: (value, index) => {
|
||||
if (index === target) alter(value);
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }),
|
||||
).rejects.toThrow(expected);
|
||||
expect(driver.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cannot use Map events as a saved receipt and never restarts unsaved state", async () => {
|
||||
const { driver } = scriptedDriver({ neverSaved: true });
|
||||
await expect(
|
||||
runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
timeoutMs: 5,
|
||||
intervalMs: 1,
|
||||
}),
|
||||
).rejects.toThrow(/real saved revision receipt/);
|
||||
expect(driver.inspectFiles).not.toHaveBeenCalled();
|
||||
expect(driver.launch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses transport error text containing content while still cleaning up", async () => {
|
||||
const { driver, evaluate } = scriptedDriver();
|
||||
evaluate.mockRejectedValueOnce(new Error("private draft 中😀"));
|
||||
const error = await runPersistedAtomFixture({
|
||||
driver,
|
||||
protocol,
|
||||
appDataRoot,
|
||||
runId,
|
||||
}).catch((value) => value);
|
||||
expect(String(error)).not.toContain("private draft");
|
||||
expect(driver.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public preload observation function (explicit fake native bridge)", () => {
|
||||
function nativePage({ bridge = {}, invoke, listen } = {}) {
|
||||
let receiver;
|
||||
const remove = vi.fn();
|
||||
const api = {
|
||||
devBridgeFallback: false,
|
||||
supportsCommand: vi.fn(() => true),
|
||||
listen:
|
||||
listen ??
|
||||
vi.fn((_name, handler) => {
|
||||
receiver = handler;
|
||||
return remove;
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
if (invoke) return invoke((value) => receiver(value));
|
||||
receiver(envelope(1));
|
||||
return update(entry(a1, firstDraft), 1);
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
...bridge,
|
||||
};
|
||||
vi.stubGlobal("window", { __LIME_ELECTRON__: true, electronAPI: api });
|
||||
const page = { evaluate: (callback, arg) => callback(arg) };
|
||||
const request = {
|
||||
key: atomKey,
|
||||
mutation: {
|
||||
op: "put",
|
||||
scope: home,
|
||||
draftId: a1.draftId,
|
||||
expectedVersion: null,
|
||||
nextVersion: a1.scopeVersion,
|
||||
draft: firstDraft,
|
||||
},
|
||||
};
|
||||
return { page, api, remove, request };
|
||||
}
|
||||
|
||||
it("registers before invoke and uses invoke/listen only", async () => {
|
||||
const { page, api, remove, request } = nativePage();
|
||||
const observed = await observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_update",
|
||||
request,
|
||||
);
|
||||
expect(observed.result.changed).toBe(true);
|
||||
expect(api.listen).toHaveBeenCalledWith(eventName, expect.any(Function));
|
||||
expect(api.listen.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
api.invoke.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(api.invoke).toHaveBeenCalledWith("persisted_atom_update", request);
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for the mutation revision even when an older save event arrives first", async () => {
|
||||
const { page, remove, request } = nativePage({
|
||||
invoke: (send) => {
|
||||
send(envelope(0));
|
||||
setTimeout(() => send(envelope(1)), 5);
|
||||
return update(entry(a1, firstDraft), 1);
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_update",
|
||||
request,
|
||||
{ timeoutMs: 100 },
|
||||
),
|
||||
).resolves.toMatchObject({ events: [event(0), event(1)] });
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ devBridgeFallback: true },
|
||||
{ supportsCommand: () => false },
|
||||
{ invoke: undefined },
|
||||
{ listen: undefined },
|
||||
])("rejects an unavailable or fallback native bridge", async (bridge) => {
|
||||
const { page, request } = nativePage({ bridge });
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_update",
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow(/transport failed/);
|
||||
});
|
||||
|
||||
it("rejects no-op mutation broadcast and removes its listener", async () => {
|
||||
const { page, remove, request } = nativePage({
|
||||
invoke: () => update(entry(a1, firstDraft), 1),
|
||||
});
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_update",
|
||||
request,
|
||||
{ timeoutMs: 2 },
|
||||
),
|
||||
).rejects.toThrow(/Main mutation broadcast/);
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never invokes an unregistered command", async () => {
|
||||
const { page, api, request } = nativePage();
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"app_server_handle_json_lines",
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow(/known command/);
|
||||
expect(api.invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redacts a parser exception rather than echoing rejected input", async () => {
|
||||
const { page, request } = nativePage();
|
||||
const badProtocol = {
|
||||
...protocol,
|
||||
parseUpdateResult: () => {
|
||||
throw new Error("private draft 中😀");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
badProtocol,
|
||||
"persisted_atom_update",
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow("strict response protocol");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parsePersistedAtomReadRequest,
|
||||
parsePersistedAtomReadResult,
|
||||
parsePersistedAtomUpdateEvent,
|
||||
parsePersistedAtomUpdateResult,
|
||||
} from "@/lib/api/persistedAtomProtocol";
|
||||
import { observePersistedAtomCommand } from "./persisted-atom-fixture.mjs";
|
||||
|
||||
const protocol = {
|
||||
parseReadRequest: parsePersistedAtomReadRequest,
|
||||
parseReadResult: parsePersistedAtomReadResult,
|
||||
parseUpdateResult: parsePersistedAtomUpdateResult,
|
||||
parseUpdateEvent: parsePersistedAtomUpdateEvent,
|
||||
};
|
||||
const key = "composer-drafts-v1";
|
||||
const metadata = {
|
||||
key,
|
||||
storeRevision: 4,
|
||||
persistence: { state: "saved", savedRevision: 4 },
|
||||
};
|
||||
const scope = { workspaceId: "workspace", kind: "draft", id: "draft-tab" };
|
||||
const item = {
|
||||
scope,
|
||||
identity: { draftId: "draft", scopeVersion: "v1" },
|
||||
updatedAt: 100,
|
||||
};
|
||||
const scopeRequest = { key, query: { kind: "scope", scope } };
|
||||
const indexRequest = {
|
||||
key,
|
||||
query: { kind: "workspace-index", workspaceId: "workspace" },
|
||||
};
|
||||
const scopeResponse = {
|
||||
...metadata,
|
||||
outcome: "ready",
|
||||
data: { queryKind: "scope", view: { scope, entry: null } },
|
||||
};
|
||||
const indexResponse = {
|
||||
...metadata,
|
||||
outcome: "ready",
|
||||
data: {
|
||||
queryKind: "workspace-index",
|
||||
workspaceId: "workspace",
|
||||
items: [item],
|
||||
},
|
||||
};
|
||||
|
||||
function publicBridge(response) {
|
||||
const remove = vi.fn();
|
||||
const api = {
|
||||
devBridgeFallback: false,
|
||||
supportsCommand: vi.fn(() => true),
|
||||
invoke: vi.fn(async () => response),
|
||||
listen: vi.fn(() => remove),
|
||||
emit: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("window", { __LIME_ELECTRON__: true, electronAPI: api });
|
||||
const page = { evaluate: vi.fn((callback, args) => callback(args)) };
|
||||
return { page, api, remove };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("index consumer public IPC contract (fake native surface; real shared parsers)", () => {
|
||||
it.each([
|
||||
[scopeRequest, scopeResponse],
|
||||
[indexRequest, indexResponse],
|
||||
[
|
||||
{ key, query: { kind: "workspace-index", workspaceId: null } },
|
||||
{
|
||||
...indexResponse,
|
||||
data: { queryKind: "workspace-index", workspaceId: null, items: [] },
|
||||
},
|
||||
],
|
||||
])(
|
||||
"uses only public invoke/listen with the exact discriminated query",
|
||||
async (request, response) => {
|
||||
const { page, api, remove } = publicBridge(response);
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_read",
|
||||
request,
|
||||
),
|
||||
).resolves.toEqual({ result: response, events: [] });
|
||||
expect(api.invoke).toHaveBeenCalledExactlyOnceWith(
|
||||
"persisted_atom_read",
|
||||
request,
|
||||
);
|
||||
expect(api.listen).toHaveBeenCalledExactlyOnceWith(
|
||||
"persisted-atom-update",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ ...metadata, outcome: "ready", view: { scope, entry: null } },
|
||||
scopeResponse,
|
||||
{ ...indexResponse, data: { ...indexResponse.data, workspaceId: null } },
|
||||
{ ...indexResponse, data: { ...indexResponse.data, workspaceId: "other" } },
|
||||
{
|
||||
...indexResponse,
|
||||
data: {
|
||||
...indexResponse.data,
|
||||
items: [{ ...item, scope: { ...scope, workspaceId: "other" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
...indexResponse,
|
||||
data: {
|
||||
...indexResponse.data,
|
||||
items: [
|
||||
item,
|
||||
{ ...item, identity: { draftId: "other", scopeVersion: "v2" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
...indexResponse,
|
||||
data: {
|
||||
...indexResponse.data,
|
||||
items: [item, { ...item, scope: { ...scope, id: "other" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
...indexResponse,
|
||||
data: {
|
||||
...indexResponse.data,
|
||||
items: [{ ...item, draft: { text: "private content" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
...indexResponse,
|
||||
data: {
|
||||
...indexResponse.data,
|
||||
items: [{ ...item, title: "private title" }],
|
||||
},
|
||||
},
|
||||
])(
|
||||
"rejects legacy, uncorrelated or body-bearing receipts after public IPC",
|
||||
async (response) => {
|
||||
const { page, api, remove } = publicBridge(response);
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_read",
|
||||
indexRequest,
|
||||
),
|
||||
).rejects.toThrow("strict response protocol");
|
||||
expect(api.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ key, scope },
|
||||
{ key, query: { kind: "workspace-index" } },
|
||||
{
|
||||
key,
|
||||
query: { kind: "workspace-index", workspaceId: "workspace", scope },
|
||||
},
|
||||
{ key, query: { kind: "scope", scope: { ...scope, route: "/private" } } },
|
||||
])(
|
||||
"rejects invalid read query before evaluating any browser surface",
|
||||
async (request) => {
|
||||
const { page, api } = publicBridge(indexResponse);
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_read",
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow("strict read request protocol");
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(api.invoke).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([scopeRequest, indexRequest])(
|
||||
"preserves unavailable data as null",
|
||||
async (request) => {
|
||||
const response = {
|
||||
...metadata,
|
||||
persistence: { state: "loading", savedRevision: null },
|
||||
outcome: "unavailable",
|
||||
data: null,
|
||||
};
|
||||
const { page } = publicBridge(response);
|
||||
await expect(
|
||||
observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_read",
|
||||
request,
|
||||
),
|
||||
).resolves.toEqual({ result: response, events: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an accessor without executing it or exposing its error", async () => {
|
||||
const get = vi.fn(() => {
|
||||
throw new Error("private content");
|
||||
});
|
||||
const request = Object.defineProperty({ key }, "query", {
|
||||
enumerable: true,
|
||||
get,
|
||||
});
|
||||
const { page } = publicBridge(indexResponse);
|
||||
const error = await observePersistedAtomCommand(
|
||||
page,
|
||||
protocol,
|
||||
"persisted_atom_read",
|
||||
request,
|
||||
).catch((value) => value);
|
||||
expect(String(error)).toContain("strict read request protocol");
|
||||
expect(String(error)).not.toContain("private content");
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -258,7 +258,7 @@ async function run() {
|
||||
};
|
||||
writeJsonFile(summaryPath, summary);
|
||||
|
||||
await page.locator('[data-testid="settings-home-button"]').click();
|
||||
await page.locator('[data-testid="app-sidebar-rail-home"]').click();
|
||||
const homeState = await readHomeState(page, options);
|
||||
const homeTrace = summarizeSettingsAboutTrace(homeState.traceRaw);
|
||||
await page.screenshot({ path: homeScreenshotPath, fullPage: true });
|
||||
|
||||
@@ -12,6 +12,8 @@ export function readComposerEditorTextDom(element) {
|
||||
const readNode = (node) => {
|
||||
if (node.nodeType === 3) return node.nodeValue || "";
|
||||
if (node.nodeType !== 1) return "";
|
||||
if (node.getAttribute("data-composer-code-block-toolbar") === "true")
|
||||
return "";
|
||||
if (node.tagName === "BR") {
|
||||
return node.classList.contains("ProseMirror-trailingBreak") ? "" : "\n";
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ function editor(html, attributes = {}) {
|
||||
|
||||
describe("Composer fixture DOM projection", () => {
|
||||
it.each([" 中😀\n````\n\n", ""])(
|
||||
"separates P/PRE/P without flattening code whitespace %j",
|
||||
"separates P/NodeView/P without flattening code whitespace %j",
|
||||
(body) => {
|
||||
const element = editor("<p>first</p><pre><code></code></pre><p>last</p>");
|
||||
const element = editor(
|
||||
'<p>first</p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false">Auto TypeScript</div><pre dir="ltr"><code class="hljs language-typescript"></code></pre></div><p>last</p>',
|
||||
);
|
||||
try {
|
||||
element.querySelector("code").textContent = body;
|
||||
expect(readComposerEditorTextDom(element)).toBe(`first\n${body}\nlast`);
|
||||
@@ -37,6 +39,15 @@ describe("Composer fixture DOM projection", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
it("skips toolbar text while retaining nested syntax leaves and visible atoms", () => {
|
||||
const element = editor(
|
||||
'<p>lead <span data-composer-token="skill" contenteditable="false">/user:capability-report</span></p><div data-composer-code-block="true"><div data-composer-code-block-toolbar="true" contenteditable="false">Auto TypeScript search</div><pre dir="ltr"><code class="hljs language-typescript"> <span class="hljs-keyword">const</span> value = <span class="hljs-string">中😀</span>;\n````\n\n</code></pre></div><p><span data-composer-token="paste" contenteditable="false" data-payload="hidden">[Pasted text]</span> tail</p>',
|
||||
);
|
||||
expect(readComposerEditorTextDom(element)).toBe(
|
||||
"lead /user:capability-report\n const value = 中😀;\n````\n\n\n[Pasted text] tail",
|
||||
);
|
||||
element.remove();
|
||||
});
|
||||
it("preserves whitespace and explicit hard breaks without the view trailing break", () => {
|
||||
const element = editor(
|
||||
'<p> lead <br>line<br><br class="ProseMirror-trailingBreak"></p>',
|
||||
|
||||
@@ -32,7 +32,8 @@ const RENDERER_SOURCE_DIRS = [
|
||||
];
|
||||
const RENDERER_SOURCE_FILES = [
|
||||
"index.html",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.renderer.json",
|
||||
@@ -47,7 +48,8 @@ const HOST_SOURCE_DIRS = [
|
||||
"assets/plugins",
|
||||
];
|
||||
const HOST_SOURCE_FILES = [
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"package.json",
|
||||
"tsconfig.electron.json",
|
||||
"tsconfig.json",
|
||||
|
||||
@@ -380,6 +380,30 @@ describe("electron fixture build freshness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["pnpm-lock.yaml", "pnpm-workspace.yaml"])(
|
||||
"invalidates renderer and host when %s changes without a manifest edit",
|
||||
(dependencyInput) => {
|
||||
const rootDir = createTempRoot();
|
||||
writeRequiredFiles(rootDir, new Date("2026-01-01T00:10:00.000Z"));
|
||||
writeFile(
|
||||
path.join(rootDir, dependencyInput),
|
||||
"changed dependency input",
|
||||
new Date("2026-01-01T00:20:00.000Z"),
|
||||
);
|
||||
|
||||
expect(inspectElectronFixtureBuildFreshness({ rootDir })).toMatchObject({
|
||||
ready: false,
|
||||
reason: "stale-source",
|
||||
staleSegments: ["renderer", "host"],
|
||||
segments: {
|
||||
renderer: { newestSource: { relativePath: dependencyInput } },
|
||||
host: { newestSource: { relativePath: dependencyInput } },
|
||||
appServer: { ready: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not mark renderer stale when only Rust app-server sources are newer", () => {
|
||||
const rootDir = createTempRoot();
|
||||
writeRequiredFiles(rootDir, new Date("2026-01-01T00:10:00.000Z"));
|
||||
|
||||
@@ -48,7 +48,7 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
};
|
||||
const readRaw = () => Reflect.apply(getItem, storage, [key]);
|
||||
const seedEntries = parseRing(readRaw());
|
||||
let knownRing = seedEntries;
|
||||
let knownRingJson = JSON.stringify(seedEntries);
|
||||
const ledgerEntries = [];
|
||||
const observations = [];
|
||||
const invalidations = [];
|
||||
@@ -61,8 +61,8 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
sequence: observations.length + 1,
|
||||
});
|
||||
};
|
||||
const hash = (value) => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
||||
const hash = (serialized) => {
|
||||
const bytes = new TextEncoder().encode(serialized);
|
||||
return globalThis.crypto.subtle
|
||||
.digest("SHA-256", bytes)
|
||||
.then((buffer) =>
|
||||
@@ -75,7 +75,8 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
return null;
|
||||
});
|
||||
};
|
||||
const seedDigestPromise = hash(seedEntries);
|
||||
const seedDigestPromise = hash(knownRingJson);
|
||||
let knownRingDigestPromise = seedDigestPromise;
|
||||
const observe = (operation, args, beforeRaw, afterRaw) => {
|
||||
const targetKey = operation === "clear" || args[0] === key;
|
||||
if (operation === "clear" || (operation === "removeItem" && targetKey)) {
|
||||
@@ -94,20 +95,21 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
try {
|
||||
const before = parseRing(beforeRaw);
|
||||
const after = parseRing(afterRaw);
|
||||
if (JSON.stringify(before) !== JSON.stringify(knownRing)) {
|
||||
if (JSON.stringify(before) !== knownRingJson) {
|
||||
invalidate("lost-observation-window", operation);
|
||||
return;
|
||||
}
|
||||
const appended = after.at(-1);
|
||||
const afterJson = JSON.stringify(after);
|
||||
if (
|
||||
!appended ||
|
||||
JSON.stringify(after) !==
|
||||
JSON.stringify([...before, appended].slice(-limit))
|
||||
afterJson !== JSON.stringify([...before, appended].slice(-limit))
|
||||
) {
|
||||
invalidate("non-continuous-append", operation);
|
||||
return;
|
||||
}
|
||||
const sequence = observations.length + 1;
|
||||
const afterDigestPromise = hash(afterJson);
|
||||
ledgerEntries.push(appended);
|
||||
observations.push({
|
||||
sequence,
|
||||
@@ -115,11 +117,13 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
nativeSucceeded: true,
|
||||
beforeCount: before.length,
|
||||
afterCount: after.length,
|
||||
beforeDigestPromise: hash(before),
|
||||
afterDigestPromise: hash(after),
|
||||
entryDigestPromise: hash(appended),
|
||||
// Reuse only after the native before-window matched the known bytes.
|
||||
beforeDigestPromise: knownRingDigestPromise,
|
||||
afterDigestPromise,
|
||||
entryDigestPromise: hash(JSON.stringify(appended)),
|
||||
});
|
||||
knownRing = after;
|
||||
knownRingJson = afterJson;
|
||||
knownRingDigestPromise = afterDigestPromise;
|
||||
} catch {
|
||||
invalidate("invalid-trace-write", operation);
|
||||
}
|
||||
@@ -182,12 +186,13 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) {
|
||||
invalidate("native-read-invalid", "capture");
|
||||
}
|
||||
if (!intact) invalidate("collector-hook-lost", "capture");
|
||||
if (JSON.stringify(currentEntries) !== JSON.stringify(knownRing))
|
||||
const currentJson = JSON.stringify(currentEntries);
|
||||
if (currentJson !== knownRingJson)
|
||||
invalidate("lost-observation-window", "capture");
|
||||
// Snapshot before the first await. Later appends belong to a later capture.
|
||||
const ledger = ledgerEntries.slice();
|
||||
const records = observations.slice();
|
||||
const currentDigestPromise = hash(currentEntries);
|
||||
const currentDigestPromise = hash(currentJson);
|
||||
const transitions = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
sequence: record.sequence,
|
||||
@@ -313,25 +318,28 @@ export function validateInvokeTraceCollectorCapture(capture, handle) {
|
||||
)
|
||||
throw new Error("invalid collector provenance");
|
||||
let ring = capture.seedEntries;
|
||||
let ringDigest = hash(ring);
|
||||
for (const [index, entry] of capture.ledgerEntries.entries()) {
|
||||
const record = capture.observations[index];
|
||||
const next = [...ring, entry].slice(-handle.traceLimit);
|
||||
const nextDigest = hash(next);
|
||||
if (
|
||||
record?.sequence !== index + 1 ||
|
||||
record.operation !== "append" ||
|
||||
record.nativeSucceeded !== true ||
|
||||
record.beforeCount !== ring.length ||
|
||||
record.afterCount !== next.length ||
|
||||
record.beforeDigest !== hash(ring) ||
|
||||
record.afterDigest !== hash(next) ||
|
||||
record.beforeDigest !== ringDigest ||
|
||||
record.afterDigest !== nextDigest ||
|
||||
record.entryDigest !== hash(entry)
|
||||
)
|
||||
throw new Error("non-continuous collector provenance");
|
||||
ring = next;
|
||||
ringDigest = nextDigest;
|
||||
}
|
||||
if (
|
||||
capture.currentRingCount !== ring.length ||
|
||||
capture.currentRingDigest !== hash(ring)
|
||||
capture.currentRingDigest !== ringDigest
|
||||
)
|
||||
throw new Error("collector final window mismatch");
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const commands = ["persisted_atom_read", "persisted_atom_update"];
|
||||
|
||||
/** Static ownership checks complement, but never replace, actual IPC evidence. */
|
||||
export function collectPersistedAtomContractFailures(readSource) {
|
||||
const failures = [];
|
||||
const sources = new Map();
|
||||
const read = (file) => {
|
||||
if (!sources.has(file)) sources.set(file, readSource(file));
|
||||
return sources.get(file);
|
||||
};
|
||||
const require = (file, pattern, message) => {
|
||||
if (!pattern.test(read(file))) failures.push({ file, message });
|
||||
};
|
||||
const forbid = (file, pattern, message) => {
|
||||
if (pattern.test(read(file))) failures.push({ file, message });
|
||||
};
|
||||
const catalogFile = "src/lib/governance/agentCommandCatalog.json";
|
||||
const catalog = JSON.parse(read(catalogFile));
|
||||
if (
|
||||
JSON.stringify(catalog.persistedAtomCommands) !== JSON.stringify(commands)
|
||||
)
|
||||
failures.push({
|
||||
file: catalogFile,
|
||||
message:
|
||||
"Persisted atom catalog must register only the two Desktop UI commands",
|
||||
});
|
||||
for (const command of commands) {
|
||||
for (const section of ["runtimeGatewayCommands", "capabilityDraftCommands"])
|
||||
if (catalog[section]?.includes(command))
|
||||
failures.push({
|
||||
file: catalogFile,
|
||||
message:
|
||||
"Desktop UI persistence cannot become an Agent runtime command",
|
||||
});
|
||||
if (Object.hasOwn(catalog.deprecatedCommandReplacements ?? {}, command))
|
||||
failures.push({
|
||||
file: catalogFile,
|
||||
message: "Current persisted atom commands cannot be deprecated aliases",
|
||||
});
|
||||
require("electron/ipcChannels.ts", new RegExp(
|
||||
'export const ELECTRON_HOST_COMMANDS = \\[([^\\]]*"' + command + '")',
|
||||
), "Preload Host allowlist must contain " + command);
|
||||
for (const file of [
|
||||
"src/lib/dev-bridge/mockPriorityCommands.ts",
|
||||
"src/lib/dev-bridge/commandPolicy.ts",
|
||||
"src/lib/desktop-host/core.ts",
|
||||
])
|
||||
forbid(
|
||||
file,
|
||||
new RegExp("[\"'\x60]" + command + "[\"'\x60]"),
|
||||
"Desktop draft persistence cannot fall back to a mock or legacy command policy",
|
||||
);
|
||||
}
|
||||
const main = "electron/main.ts";
|
||||
require(main, /new PersistedAtomHost\(\s*desktopStorageRoots\.appDataRoot,\s*broadcast,?\s*\)/, "Persisted UI files must use the resolved platform AppDataRoot and Main broadcast owner");
|
||||
require(main, /if\s*\(command === "persisted_atom_read"\)\s*\{\s*return persistedAtomHost\.sendPersistedAtomState\(args\);\s*\}/, "Read IPC must delegate to the unique persisted atom Host");
|
||||
require(main, /if\s*\(command === "persisted_atom_update"\)\s*\{\s*return persistedAtomHost\.updatePersistedAtomState\(args\);\s*\}/, "Update IPC must delegate to the unique persisted atom Host");
|
||||
require(main, /registerRendererEventHandler\(ipcMain, broadcast\)/, "Main must register the tested renderer event authority");
|
||||
forbid(
|
||||
main,
|
||||
/ipcMain\.on\(\s*IPC_EMIT_CHANNEL/,
|
||||
"Generic renderer emission cannot bypass the Main event authority",
|
||||
);
|
||||
require("electron/rendererEventPolicy.ts", /event !== "persisted-atom-update"/, "Renderer cannot forge persisted-state acknowledgements");
|
||||
require("electron/rendererEventPolicy.ts", /if\s*\(canBroadcastRendererEvent\(event\)\)\s*broadcast\(event, payload\)/, "The actual IPC listener must enforce renderer event authority");
|
||||
require("electron/preload.ts", /emit:[\s\S]*?canBroadcastRendererEvent\(event\)[\s\S]*?send:[\s\S]*?canBroadcastRendererEvent\(event\)/, "Both preload event entry points must reject Main notifications");
|
||||
const gateway = "src/lib/api/persistedAtoms.ts";
|
||||
require("eslint.config.js", /agentCommandCatalog\.persistedAtomCommands/, "Persisted Host commands must stay behind their typed API gateway");
|
||||
require("eslint.config.js", /\.\.\.persistedAtomGatewayCommandSelectors/, "Business code must enforce the persisted atom gateway restriction");
|
||||
require(gateway, /invokePersistedAtom\("persisted_atom_read"/, "Typed read gateway must use the current Host command");
|
||||
require(gateway, /invokePersistedAtom\("persisted_atom_update"/, "Typed update gateway must use the current Host command");
|
||||
for (const parser of [
|
||||
"parsePersistedAtomReadRequest",
|
||||
"parsePersistedAtomReadResult",
|
||||
"parsePersistedAtomUpdateResult",
|
||||
"parsePersistedAtomUpdateEvent",
|
||||
])
|
||||
require(gateway, new RegExp(
|
||||
parser + "\\(",
|
||||
), "Typed gateway must consume the shared " + parser);
|
||||
require(gateway, /query:\s*parsedRequest\.query/, "Read IPC must carry the shared parsed query");
|
||||
forbid(
|
||||
gateway,
|
||||
/scope:\s*request\.scope/,
|
||||
"The scope-only read wire has been replaced",
|
||||
);
|
||||
const types = "src/lib/api/persistedAtomTypes.ts";
|
||||
require(types, /readonly query:\s*PersistedAtomReadQuery/, "Read requests must use the discriminated query owner");
|
||||
require(types, /kind:\s*"workspace-index"/, "The existing read command must support workspace discovery");
|
||||
const fixture = "scripts/electron/lib/persisted-atom-fixture.mjs";
|
||||
require(fixture, /protocol\.parseReadRequest/, "IPC observations must validate read requests with the shared owner");
|
||||
require(fixture, /query:\s*\{\s*kind:\s*"scope",\s*scope:\s*target/, "Scope fixture reads must use the current query wire");
|
||||
require(fixture, /data\?\.queryKind === "scope"/, "Scope readbacks must discriminate ready data");
|
||||
forbid(
|
||||
fixture,
|
||||
/(?:result|promoted)\.view\b/,
|
||||
"IPC readbacks cannot consume the removed top-level view",
|
||||
);
|
||||
require(gateway, /safeInvoke<unknown>\(command, args\)/, "Persisted gateway must preserve real bridge diagnostics and fail closed");
|
||||
require("src/lib/dev-bridge/safeInvoke.ts", /event === "persisted-atom-update"/, "Native persisted-state subscriptions must fail closed");
|
||||
require("src/lib/dev-bridge/safeInvoke.ts", /args_preview: invokeArgsPreview\(command, args\)/, "Draft diagnostics must use the unique content-free preview owner");
|
||||
require("src/lib/dev-bridge/safeInvoke.ts", /error: invokeDiagnosticError\(command, error\)/, "Draft errors must not echo user content into diagnostic storage");
|
||||
const http = "src/lib/dev-bridge/http-client.ts";
|
||||
require(http, /isPersistedAtomCommand\(cmd\)\s*\? invokeArgsPreview\(cmd, args as Record<string, unknown> \| undefined\)\s*: args/, "HTTP draft logs must preserve the content-free preview boundary");
|
||||
require(http, /console\.error\([\s\S]*?invokeConsoleError\(cmd, (?:error|e)\)/, "HTTP draft failures cannot echo content to the console");
|
||||
forbid(
|
||||
http,
|
||||
/console\.(?:debug|error)\(\s*`\[DevBridge\] HTTP [^`]*`\s*,\s*(?:error|e)\s*,?\s*\)/,
|
||||
"HTTP invoke diagnostics must use the shared command-aware error owner",
|
||||
);
|
||||
for (const file of [
|
||||
"electron/persistedAtomHost.ts",
|
||||
"electron/persistedAtomStore.ts",
|
||||
"electron/persistedAtomFile.ts",
|
||||
"electron/persistedAtomRecords.ts",
|
||||
"src/lib/api/persistedAtomTypes.ts",
|
||||
"src/lib/api/persistedAtomProtocol.ts",
|
||||
])
|
||||
forbid(
|
||||
file,
|
||||
/from\s+["'][^"']*(?:react|prosemirror|components\/input-kit)[^"']*["']/,
|
||||
"Host transport must remain independent of React and the editor owner",
|
||||
);
|
||||
const draftRuntime =
|
||||
"src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts";
|
||||
require(draftRuntime, /new ComposerDraftCoordinator\(/, "Workspace must assemble the single renderer draft coordinator");
|
||||
require(draftRuntime, /\.bindCommitted\(/, "The React editing-target owner must bind committed scopes");
|
||||
for (const file of [
|
||||
"src/components/agent/chat/components/EmptyState.tsx",
|
||||
"src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts",
|
||||
"src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts",
|
||||
"src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts",
|
||||
"src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts",
|
||||
]) {
|
||||
forbid(
|
||||
file,
|
||||
/new ComposerDraftCoordinator\(|\.bindCommitted\(/,
|
||||
"Presentation and navigation must delegate draft binding to the React editing-target owner",
|
||||
);
|
||||
forbid(
|
||||
file,
|
||||
/(?:localStorage|sessionStorage)\.(?:setItem|getItem)\([\s\S]{0,100}["']composer-drafts/,
|
||||
"Draft scopes cannot introduce a parallel browser store",
|
||||
);
|
||||
}
|
||||
forbid(
|
||||
"src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts",
|
||||
/composerDraftCoordinator\??\.clear\(/,
|
||||
"Home and new-task navigation must preserve the source draft instead of discard",
|
||||
);
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ESLint } from "eslint";
|
||||
import { collectPersistedAtomContractFailures } from "./persisted-atom-contract.mjs";
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
const source = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const lint = new ESLint({
|
||||
cwd: root,
|
||||
overrideConfigFile: path.join(root, "eslint.config.js"),
|
||||
});
|
||||
const businessPath = path.join(
|
||||
root,
|
||||
"src/components/agent/chat/PersistedAtomLeak.ts",
|
||||
);
|
||||
const gatewayPath = path.join(root, "src/lib/api/persistedAtoms.ts");
|
||||
// Load the repository configuration once; each assertion still runs real lintText.
|
||||
await lint.calculateConfigForFile(businessPath);
|
||||
await lint.calculateConfigForFile(gatewayPath);
|
||||
|
||||
function mutated(file, edit) {
|
||||
const before = source(file);
|
||||
const after = edit(before);
|
||||
expect(after).not.toBe(before);
|
||||
return collectPersistedAtomContractFailures((current) =>
|
||||
current === file ? after : source(current),
|
||||
);
|
||||
}
|
||||
|
||||
describe("Desktop persisted atom ownership guard", () => {
|
||||
it("accepts the current Host/gateway/IPC and renderer draft owners", () => {
|
||||
expect(collectPersistedAtomContractFailures(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"src/components/agent/chat/components/EmptyState.tsx",
|
||||
"new ComposerDraftCoordinator(controller);",
|
||||
],
|
||||
[
|
||||
"src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts",
|
||||
"coordinator.bindCommitted(scope);",
|
||||
],
|
||||
[
|
||||
"src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts",
|
||||
'localStorage.setItem("composer-drafts-v1", JSON.stringify(draft));',
|
||||
],
|
||||
[
|
||||
"src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts",
|
||||
"composerDraftCoordinator.clear();",
|
||||
],
|
||||
])(
|
||||
"rejects a parallel draft owner or navigation discard in %s",
|
||||
(file, injected) => {
|
||||
expect(mutated(file, (text) => text + "\n" + injected + "\n")).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ file })]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects missing Host registration", () => {
|
||||
expect(
|
||||
mutated("electron/ipcChannels.ts", (text) =>
|
||||
text.replace(' "persisted_atom_update",', ""),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "electron/ipcChannels.ts" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a command misclassified as an Agent runtime method", () => {
|
||||
expect(
|
||||
mutated("src/lib/governance/agentCommandCatalog.json", (text) => {
|
||||
const catalog = JSON.parse(text);
|
||||
catalog.runtimeGatewayCommands.push("persisted_atom_read");
|
||||
return JSON.stringify(catalog);
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Desktop UI persistence cannot become an Agent runtime command",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing Host delegation and an unresolved platform path", () => {
|
||||
for (const edit of [
|
||||
(text) =>
|
||||
text.replace(
|
||||
"persistedAtomHost.updatePersistedAtomState(args)",
|
||||
"hostCommands.invoke(command, args)",
|
||||
),
|
||||
(text) =>
|
||||
text.replace(
|
||||
"new PersistedAtomHost(\n desktopStorageRoots.appDataRoot,",
|
||||
'new PersistedAtomHost(\n "/tmp/drafts",',
|
||||
),
|
||||
])
|
||||
expect(mutated("electron/main.ts", edit)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "electron/main.ts" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Main or preload bypass of the reserved notification", () => {
|
||||
expect(
|
||||
mutated("electron/main.ts", (text) =>
|
||||
text.replace(
|
||||
"registerRendererEventHandler(ipcMain, broadcast)",
|
||||
"ipcMain.on(IPC_EMIT_CHANNEL, (_sender, event, payload) => broadcast(event, payload))",
|
||||
),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "electron/main.ts" }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
mutated("electron/preload.ts", (text) =>
|
||||
text.replaceAll("!canBroadcastRendererEvent(event)", "false"),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "electron/preload.ts" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a content-bearing diagnostic or fake successful event subscription", () => {
|
||||
for (const edit of [
|
||||
(text) => text.replaceAll("invokeArgsPreview(command, args)", "args"),
|
||||
(text) => text.replace('event === "persisted-atom-update"', "false"),
|
||||
])
|
||||
expect(mutated("src/lib/dev-bridge/safeInvoke.ts", edit)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "src/lib/dev-bridge/safeInvoke.ts" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects schema drift, editor dependency and legacy mock restoration", () => {
|
||||
const cases = [
|
||||
[
|
||||
"src/lib/api/persistedAtoms.ts",
|
||||
(text) =>
|
||||
text.replaceAll("parsePersistedAtomReadResult", "parseOwnShape"),
|
||||
],
|
||||
[
|
||||
"electron/persistedAtomHost.ts",
|
||||
(text) => text + '\nimport React from "react";\n',
|
||||
],
|
||||
[
|
||||
"src/lib/dev-bridge/mockPriorityCommands.ts",
|
||||
(text) => text + '\nconst fallback = "persisted_atom_update";\n',
|
||||
],
|
||||
];
|
||||
for (const [file, edit] of cases)
|
||||
expect(mutated(file, edit)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ file })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects HTTP logging that restores full draft arguments or failures", () => {
|
||||
for (const edit of [
|
||||
(text) =>
|
||||
text.replaceAll(
|
||||
"invokeArgsPreview(cmd, args as Record<string, unknown> | undefined)",
|
||||
"args",
|
||||
),
|
||||
(text) => text.replaceAll("invokeConsoleError(cmd, error)", "error"),
|
||||
])
|
||||
expect(mutated("src/lib/dev-bridge/http-client.ts", edit)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "src/lib/dev-bridge/http-client.ts",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects restoring a scope-only wire or dropping the shared read request parser", () => {
|
||||
for (const edit of [
|
||||
(text) =>
|
||||
text.replaceAll("parsePersistedAtomReadRequest", "parseOwnRead"),
|
||||
(text) =>
|
||||
text.replace("query: parsedRequest.query", "scope: request.scope"),
|
||||
])
|
||||
expect(mutated("src/lib/api/persistedAtoms.ts", edit)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ file: "src/lib/api/persistedAtoms.ts" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a fixture that consumes old view data or invents its own request validation", () => {
|
||||
for (const edit of [
|
||||
(text) => text.replace("return result.data.view", "return result.view"),
|
||||
(text) =>
|
||||
text.replace("protocol.parseReadRequest", "parseOwnReadRequest"),
|
||||
])
|
||||
expect(
|
||||
mutated("scripts/electron/lib/persisted-atom-fixture.mjs", edit),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "scripts/electron/lib/persisted-atom-fixture.mjs",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("the actual ESLint config rejects business calls and admits the typed gateway", async () => {
|
||||
const code =
|
||||
'import { safeInvoke } from "@/lib/dev-bridge"; export async function save() { await safeInvoke("persisted_atom_update", {}); }';
|
||||
const [business] = await lint.lintText(code, {
|
||||
filePath: businessPath,
|
||||
});
|
||||
expect(business.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ruleId: "no-restricted-syntax" }),
|
||||
]),
|
||||
);
|
||||
const [gateway] = await lint.lintText(code, {
|
||||
filePath: gatewayPath,
|
||||
});
|
||||
expect(gateway.messages.filter((entry) => entry.severity === 2)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,12 @@ export const SETTINGS_GATE_A_TABS = Object.freeze([
|
||||
"memory",
|
||||
"archived-conversations",
|
||||
"providers",
|
||||
"skills",
|
||||
"media-services",
|
||||
"mcp-server",
|
||||
"web-search",
|
||||
"environment",
|
||||
"execution-policy",
|
||||
"chrome-relay",
|
||||
"automation",
|
||||
"developer",
|
||||
"about",
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settingsSections } from "../../src/components/settings-v2/sections";
|
||||
|
||||
import {
|
||||
SETTINGS_GATE_A_CRITICAL_TABS,
|
||||
@@ -90,6 +91,11 @@ function build(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("project Gate SETTINGS-01 Gate A evidence", () => {
|
||||
it("covers every current section without retired navigation targets", () => {
|
||||
expect(SETTINGS_GATE_A_TABS).toEqual(
|
||||
settingsSections.map((section) => section.key),
|
||||
);
|
||||
});
|
||||
it("fails closed when component-state evidence is missing", () => {
|
||||
const evidence = build({ stateObservations: [] });
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
import { SETTINGS_GATE_A_TABS } from "./lib/project-gate-settings-a-core.mjs";
|
||||
|
||||
const DEFAULTS = {
|
||||
appUrl: "http://127.0.0.1:1420/",
|
||||
@@ -23,25 +25,6 @@ const DEFAULTS = {
|
||||
headless: true,
|
||||
};
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
"设置首页",
|
||||
"个人资料",
|
||||
"数据统计",
|
||||
"外观",
|
||||
"记忆",
|
||||
"已归档对话",
|
||||
"AI 服务商",
|
||||
"服务模型",
|
||||
"MCP 服务器",
|
||||
"网络搜索",
|
||||
"环境变量",
|
||||
"执行策略",
|
||||
"连接器",
|
||||
"自动化设置",
|
||||
"开发者与实验功能",
|
||||
"关于",
|
||||
];
|
||||
|
||||
const PROBLEM_PATTERNS = [
|
||||
/无法连接后端桥接/,
|
||||
/Desktop Host 尚未支持命令/,
|
||||
@@ -465,82 +448,19 @@ function clearInvokeBuffers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function clickByButtonText(page, label) {
|
||||
const result = await page.evaluate((targetLabel) => {
|
||||
const findVisibleButton = (scope) =>
|
||||
[...scope.querySelectorAll("button")].find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
if (
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
candidate.hasAttribute("disabled") ||
|
||||
candidate.getAttribute("aria-disabled") === "true"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const text = candidate.textContent?.replace(/\s+/g, " ").trim();
|
||||
const aria = candidate.getAttribute("aria-label");
|
||||
return (
|
||||
text === targetLabel ||
|
||||
text === `${targetLabel}进入配置` ||
|
||||
text?.startsWith(targetLabel) ||
|
||||
text?.startsWith(`${targetLabel} `) ||
|
||||
aria === targetLabel
|
||||
);
|
||||
});
|
||||
|
||||
const scopes = [
|
||||
document.querySelector('[data-testid="settings-sidebar"]'),
|
||||
document.querySelector('[data-testid="settings-floating-nav-panel"]'),
|
||||
document.body,
|
||||
].filter(Boolean);
|
||||
const button = scopes.map(findVisibleButton).find(Boolean);
|
||||
|
||||
if (!button) {
|
||||
const visibleButtons = [...document.querySelectorAll("button")]
|
||||
.map((candidate, index) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return {
|
||||
index,
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
text: candidate.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
||||
aria: candidate.getAttribute("aria-label") ?? "",
|
||||
testId: candidate.getAttribute("data-testid") ?? "",
|
||||
disabled:
|
||||
candidate.hasAttribute("disabled") ||
|
||||
candidate.getAttribute("aria-disabled") === "true",
|
||||
};
|
||||
})
|
||||
.filter((candidate) => candidate.visible && !candidate.disabled)
|
||||
.slice(0, 40);
|
||||
|
||||
return {
|
||||
clicked: false,
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
settingsSidebarMounted: Boolean(
|
||||
document.querySelector('[data-testid="settings-sidebar"]'),
|
||||
),
|
||||
visibleButtons,
|
||||
bodyStart: document.body.innerText
|
||||
?.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
button.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
button.click();
|
||||
return { clicked: true };
|
||||
}, label);
|
||||
assert(
|
||||
result.clicked,
|
||||
`未找到设置按钮: ${label}; ${formatDiagnostics(result)}`,
|
||||
async function clickSettingsSection(page, key) {
|
||||
const button = page.getByTestId(`settings-sidebar-tab-${key}`);
|
||||
assert((await button.count()) === 1, `设置章节入口必须唯一: ${key}`);
|
||||
await button.click();
|
||||
await page.waitForFunction(
|
||||
(key) =>
|
||||
document
|
||||
.querySelector(`[data-testid="settings-sidebar-tab-${key}"]`)
|
||||
?.getAttribute("aria-current") === "page",
|
||||
key,
|
||||
{ timeout: 12_000 },
|
||||
);
|
||||
await page
|
||||
.waitForLoadState("domcontentloaded", { timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
return button.innerText();
|
||||
}
|
||||
|
||||
async function ensureSettingsGroupsExpanded(page) {
|
||||
@@ -635,6 +555,7 @@ async function snapshotSettingsPage(page, tabName, subTabName = null) {
|
||||
recentErrors: errors.slice(-10),
|
||||
traceErrorCount: errorTraces.length,
|
||||
recentTraceErrors: errorTraces.slice(-10),
|
||||
traces,
|
||||
textStart: sanitizeTextInPage(text.slice(0, 400)),
|
||||
};
|
||||
},
|
||||
@@ -749,7 +670,9 @@ async function enterSettings(page, options) {
|
||||
|
||||
const opened = await page.evaluate(() => {
|
||||
const settings =
|
||||
document.querySelector('[data-testid="app-sidebar-account-model-settings"]') ||
|
||||
document.querySelector(
|
||||
'[data-testid="app-sidebar-account-model-settings"]',
|
||||
) ||
|
||||
[...document.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.trim() === "模型设置",
|
||||
) ||
|
||||
@@ -782,14 +705,15 @@ async function enterSettings(page, options) {
|
||||
);
|
||||
}
|
||||
|
||||
async function clickSettingsTabs(page, options) {
|
||||
export async function clickSettingsTabs(page, options) {
|
||||
const results = [];
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("lime_invoke_error_buffer_v1");
|
||||
localStorage.removeItem("lime_invoke_trace_buffer_v1");
|
||||
});
|
||||
|
||||
for (const tab of SETTINGS_TABS) {
|
||||
for (const key of SETTINGS_GATE_A_TABS) {
|
||||
const tab = key;
|
||||
let settingsMounted = await waitForSettingsMounted(page, 8_000);
|
||||
if (!settingsMounted) {
|
||||
logStage(`recover-settings:${tab}`);
|
||||
@@ -804,7 +728,7 @@ async function clickSettingsTabs(page, options) {
|
||||
);
|
||||
|
||||
logStage(`tab:${tab}`);
|
||||
await clickByButtonText(page, tab);
|
||||
await clickSettingsSection(page, key);
|
||||
const stillMounted = await waitForSettingsMounted(page, 12_000);
|
||||
assert(
|
||||
stillMounted,
|
||||
@@ -812,7 +736,7 @@ async function clickSettingsTabs(page, options) {
|
||||
await collectPageDiagnostics(page),
|
||||
)}`,
|
||||
);
|
||||
await page.waitForTimeout(tab === "AI 服务商" ? 1_800 : 1_300);
|
||||
await page.waitForTimeout(key === "providers" ? 1_800 : 1_300);
|
||||
|
||||
results.push(await snapshotSettingsPage(page, tab));
|
||||
|
||||
@@ -917,7 +841,7 @@ async function main() {
|
||||
invokeResponseErrors.length === 0,
|
||||
checkedAt: new Date().toISOString(),
|
||||
appUrl: options.appUrl,
|
||||
tabs: SETTINGS_TABS,
|
||||
tabs: SETTINGS_GATE_A_TABS,
|
||||
pages,
|
||||
failedPages,
|
||||
consoleErrors,
|
||||
@@ -980,11 +904,16 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[smoke:settings-current] failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[smoke:settings-current] failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
+10
-17
@@ -63,7 +63,6 @@ import {
|
||||
listenOpenVoiceModelSettingsRequest,
|
||||
persistVoiceModelSettingsFocusRequest,
|
||||
} from "./lib/voiceModelSettingsNavigation";
|
||||
import type { AgentPageParams } from "./types/page";
|
||||
|
||||
const AppContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -153,22 +152,17 @@ function AppContent() {
|
||||
requestedPage,
|
||||
requestedPageParams,
|
||||
handleNavigate,
|
||||
startNewConversation,
|
||||
returnFromSettings,
|
||||
reportAgentLocation,
|
||||
agentLocation,
|
||||
} = useAppNavigation();
|
||||
const [agentHasMessages, setAgentHasMessages] = useState(false);
|
||||
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const activeAgentSessionId = agentLocation?.sessionId ?? null;
|
||||
const [activeAgentStreaming, setActiveAgentStreaming] = useState(false);
|
||||
const [backgroundAgentSessionRuntime, setBackgroundAgentSessionRuntime] =
|
||||
useState<AgentBackgroundSessionRuntimeSnapshot | null>(null);
|
||||
const activeAgentPage = requestedPage ?? currentPage;
|
||||
const activeAgentPageParams = requestedPageParams ?? pageParams;
|
||||
const activeAgentRouteSessionId =
|
||||
activeAgentPage === "agent"
|
||||
? ((
|
||||
activeAgentPageParams as AgentPageParams | undefined
|
||||
)?.initialSessionId?.trim() ?? null)
|
||||
: null;
|
||||
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
const [pendingRecommendation, setPendingRecommendation] = useState<{
|
||||
@@ -415,13 +409,9 @@ function AppContent() {
|
||||
);
|
||||
useEffect(() => {
|
||||
if (activeAgentPage !== "agent") {
|
||||
setActiveAgentSessionId(null);
|
||||
setActiveAgentStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveAgentSessionId(activeAgentRouteSessionId);
|
||||
}, [activeAgentPage, activeAgentRouteSessionId]);
|
||||
}, [activeAgentPage]);
|
||||
|
||||
const handleBackgroundSessionRuntimeChange = useCallback(
|
||||
(snapshot: AgentBackgroundSessionRuntimeSnapshot | null) => {
|
||||
@@ -474,6 +464,7 @@ function AppContent() {
|
||||
requestedPage={requestedPage}
|
||||
requestedPageParams={requestedPageParams}
|
||||
onNavigate={handleNavigate}
|
||||
onStartNewConversation={startNewConversation}
|
||||
onStartWindowDrag={handleWindowDragStart}
|
||||
/>
|
||||
)}
|
||||
@@ -494,8 +485,10 @@ function AppContent() {
|
||||
requestedPage={requestedPage}
|
||||
requestedPageParams={requestedPageParams}
|
||||
onNavigate={handleNavigate}
|
||||
onStartNewConversation={startNewConversation}
|
||||
onAgentHasMessagesChange={setAgentHasMessages}
|
||||
onAgentSessionChange={setActiveAgentSessionId}
|
||||
onAgentLocationChange={reportAgentLocation}
|
||||
onSettingsBack={returnFromSettings}
|
||||
onAgentStreamingChange={setActiveAgentStreaming}
|
||||
onBackgroundSessionRuntimeChange={
|
||||
handleBackgroundSessionRuntimeChange
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Suspense, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentPageParams } from "@/types/page";
|
||||
import { AppPageContent } from "./AppPageContent";
|
||||
|
||||
const page = vi.hoisted(() => ({
|
||||
props: null as Record<string, unknown> | null,
|
||||
}));
|
||||
// Other page presentation is outside this committed Agent route contract.
|
||||
vi.mock("./scheduled-tasks/ScheduledTasksPage", () => ({
|
||||
ScheduledTasksPage: () => null,
|
||||
}));
|
||||
vi.mock("./channels/ImConfigPage", () => ({ ImConfigPage: () => null }));
|
||||
vi.mock("./settings-v2", () => ({ SettingsPage: () => null }));
|
||||
vi.mock("@/features/plugin/ui/PluginWorkspaceTabs", () => ({
|
||||
PluginWorkspaceTabs: () => null,
|
||||
}));
|
||||
vi.mock("./agent/chat", () => ({
|
||||
AgentChatPage: (props: Record<string, unknown>) => {
|
||||
page.props = props;
|
||||
return <div data-testid="agent-consumer" />;
|
||||
},
|
||||
}));
|
||||
const mounted: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
mounted.splice(0).forEach((close) => close());
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AppPageContent committed New intent boundary", () => {
|
||||
it("requested preview cannot forward fresh or reset time before the actual route commits", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const element = document.createElement("div");
|
||||
document.body.appendChild(element);
|
||||
const root = createRoot(element);
|
||||
mounted.push(() => {
|
||||
root.unmount();
|
||||
element.remove();
|
||||
});
|
||||
const startNewConversation = vi.fn();
|
||||
const before: AgentPageParams = {
|
||||
agentEntry: "claw",
|
||||
initialSessionId: "old-session",
|
||||
newChatAt: 10,
|
||||
};
|
||||
const requested: AgentPageParams = {
|
||||
agentEntry: "new-task",
|
||||
newChatAt: 11,
|
||||
newConversationRequest: { id: 11, workspaceId: null, freshDraft: true },
|
||||
};
|
||||
const render = async (committed: AgentPageParams) => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<Suspense fallback={null}>
|
||||
<AppPageContent
|
||||
currentPage="agent"
|
||||
pageParams={committed}
|
||||
requestedPage="agent"
|
||||
requestedPageParams={requested}
|
||||
onNavigate={vi.fn()}
|
||||
onAgentHasMessagesChange={vi.fn()}
|
||||
onStartNewConversation={startNewConversation}
|
||||
/>
|
||||
</Suspense>,
|
||||
),
|
||||
);
|
||||
};
|
||||
await render(before);
|
||||
expect(page.props?.newConversationRequest).toBeUndefined();
|
||||
expect(page.props?.newChatAt).toBeUndefined();
|
||||
await render({
|
||||
...requested,
|
||||
newConversationRequest: { ...requested.newConversationRequest!, id: 12 },
|
||||
});
|
||||
expect(page.props?.newConversationRequest).toBeUndefined();
|
||||
expect(page.props?.newChatAt).toBeUndefined();
|
||||
await render(requested);
|
||||
expect(page.props?.newConversationRequest).toEqual(
|
||||
requested.newConversationRequest,
|
||||
);
|
||||
expect(page.props?.newChatAt).toBe(11);
|
||||
expect(page.props?.projectId).toBeUndefined();
|
||||
expect(page.props?.onStartNewConversation).toBe(startNewConversation);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AgentLocation,
|
||||
AgentPageParams,
|
||||
Page,
|
||||
PageParams,
|
||||
@@ -53,7 +54,7 @@ vi.mock("./channels/ImConfigPage", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./settings-v2", () => ({
|
||||
SettingsPageV2: (props: Record<string, unknown>) => {
|
||||
SettingsPage: (props: Record<string, unknown>) => {
|
||||
latestSettingsPageProps.value = props;
|
||||
return <div data-testid="settings-page" />;
|
||||
},
|
||||
@@ -108,7 +109,7 @@ interface RenderContentOptions {
|
||||
requestedPage?: Page;
|
||||
requestedPageParams?: PageParams;
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
onAgentSessionChange?: (sessionId: string | null) => void;
|
||||
onAgentLocationChange?: (location: AgentLocation) => void;
|
||||
}
|
||||
|
||||
function renderContentWithNavigationState(options: RenderContentOptions) {
|
||||
@@ -127,7 +128,8 @@ function renderContentWithNavigationState(options: RenderContentOptions) {
|
||||
requestedPageParams={nextOptions.requestedPageParams}
|
||||
onNavigate={nextOptions.onNavigate ?? vi.fn()}
|
||||
onAgentHasMessagesChange={vi.fn()}
|
||||
onAgentSessionChange={nextOptions.onAgentSessionChange}
|
||||
onSettingsBack={vi.fn()}
|
||||
onAgentLocationChange={nextOptions.onAgentLocationChange}
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
@@ -275,8 +277,8 @@ describe("AppPageContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("AgentChatPage 的 onSessionChange 应回调到上层 onAgentSessionChange", async () => {
|
||||
const onAgentSessionChange = vi.fn();
|
||||
it("AgentChatPage 的 onLocationChange 应回调到上层 onAgentLocationChange", async () => {
|
||||
const onAgentLocationChange = vi.fn();
|
||||
|
||||
renderContentWithNavigationState({
|
||||
currentPage: "agent",
|
||||
@@ -285,20 +287,26 @@ describe("AppPageContent", () => {
|
||||
projectId: "project-1",
|
||||
initialSessionId: "session-route",
|
||||
} satisfies AgentPageParams,
|
||||
onAgentSessionChange,
|
||||
onAgentLocationChange,
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const onSessionChange = latestAgentChatProps.value?.onSessionChange;
|
||||
expect(typeof onSessionChange).toBe("function");
|
||||
const onLocationChange = latestAgentChatProps.value?.onLocationChange;
|
||||
expect(typeof onLocationChange).toBe("function");
|
||||
|
||||
await act(async () => {
|
||||
(onSessionChange as (sessionId: string | null) => void)(" session-live ");
|
||||
(onLocationChange as (location: AgentLocation) => void)({
|
||||
sessionId: " session-live ",
|
||||
workspaceId: " workspace-live ",
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onAgentSessionChange).toHaveBeenCalledWith("session-live");
|
||||
expect(onAgentSessionChange).toHaveBeenCalledTimes(1);
|
||||
expect(onAgentLocationChange).toHaveBeenCalledWith({
|
||||
sessionId: "session-live",
|
||||
workspaceId: "workspace-live",
|
||||
});
|
||||
expect(onAgentLocationChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("agent 页面应透传专家 Agent 身份,重复恢复同一专家不触发新 key", async () => {
|
||||
@@ -865,14 +873,7 @@ describe("AppPageContent", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(latestSettingsPageProps.value).toMatchObject({
|
||||
initialTab: "execution-policy",
|
||||
initialExecutionPolicyFocus: {
|
||||
section: "network",
|
||||
ruleId: "download-block",
|
||||
target: "url",
|
||||
value: "https://example.com/install.sh",
|
||||
reasonCode: "request_download_url",
|
||||
},
|
||||
pageParams,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -892,14 +893,7 @@ describe("AppPageContent", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(latestSettingsPageProps.value).toMatchObject({
|
||||
initialTab: "providers",
|
||||
initialProviderView: "settings",
|
||||
initialProviderFocus: {
|
||||
providerId: "custom-coder",
|
||||
modelId: "coder-large",
|
||||
reasonCode: "missing_enabled_api_key",
|
||||
recoveryAction: "add_enabled_api_key",
|
||||
},
|
||||
pageParams,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { lazy, useCallback } from "react";
|
||||
import type {
|
||||
AgentLocation,
|
||||
AgentPageParams,
|
||||
PluginsPageParams,
|
||||
ExpertsPageParams,
|
||||
@@ -20,7 +21,7 @@ import type {
|
||||
import type { AgentBackgroundSessionRuntimeSnapshot } from "./agent/chat";
|
||||
import { ScheduledTasksPage } from "./scheduled-tasks/ScheduledTasksPage";
|
||||
import { ImConfigPage } from "./channels/ImConfigPage";
|
||||
import { SettingsPageV2 } from "./settings-v2";
|
||||
import { SettingsPage } from "./settings-v2";
|
||||
import { PluginWorkspaceTabs } from "@/features/plugin/ui/PluginWorkspaceTabs";
|
||||
|
||||
const columnPageStyle = {
|
||||
@@ -69,8 +70,10 @@ interface AppPageContentProps {
|
||||
requestedPage?: Page;
|
||||
requestedPageParams?: PageParams;
|
||||
onNavigate: (page: Page, params?: PageParams) => void;
|
||||
onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation;
|
||||
onAgentHasMessagesChange: (hasMessages: boolean) => void;
|
||||
onAgentSessionChange?: (sessionId: string | null) => void;
|
||||
onAgentLocationChange?: (location: AgentLocation) => void;
|
||||
onSettingsBack: () => void;
|
||||
onAgentStreamingChange?: (isStreaming: boolean) => void;
|
||||
onBackgroundSessionRuntimeChange?: (
|
||||
snapshot: AgentBackgroundSessionRuntimeSnapshot | null,
|
||||
@@ -123,8 +126,10 @@ export function AppPageContent({
|
||||
requestedPage,
|
||||
requestedPageParams,
|
||||
onNavigate,
|
||||
onStartNewConversation,
|
||||
onAgentHasMessagesChange,
|
||||
onAgentSessionChange,
|
||||
onAgentLocationChange,
|
||||
onSettingsBack,
|
||||
onAgentStreamingChange,
|
||||
onBackgroundSessionRuntimeChange,
|
||||
}: AppPageContentProps) {
|
||||
@@ -140,12 +145,14 @@ export function AppPageContent({
|
||||
),
|
||||
);
|
||||
};
|
||||
const handleAgentSessionChange = useCallback(
|
||||
(sessionId: string | null) => {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
onAgentSessionChange?.(normalizedSessionId || null);
|
||||
const handleAgentLocationChange = useCallback(
|
||||
(location: AgentLocation) => {
|
||||
onAgentLocationChange?.({
|
||||
sessionId: location.sessionId?.trim() || null,
|
||||
workspaceId: location.workspaceId?.trim() || null,
|
||||
});
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[onAgentLocationChange],
|
||||
);
|
||||
|
||||
if (activePage === "scheduled-tasks") {
|
||||
@@ -173,6 +180,16 @@ export function AppPageContent({
|
||||
|
||||
if (activePage === "agent") {
|
||||
const agentPageParams = activePageParams as AgentPageParams;
|
||||
const committedAgentParams =
|
||||
currentPage === "agent" ? (pageParams as AgentPageParams) : null;
|
||||
const committedHome =
|
||||
committedAgentParams !== null &&
|
||||
committedAgentParams.newChatAt === agentPageParams.newChatAt &&
|
||||
committedAgentParams?.newConversationRequest?.id ===
|
||||
agentPageParams.newConversationRequest?.id;
|
||||
const committedRequest = committedHome
|
||||
? committedAgentParams.newConversationRequest
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div style={columnPageStyle}>
|
||||
@@ -217,10 +234,12 @@ export function AppPageContent({
|
||||
agentPageParams.agentEntry !== "new-task" &&
|
||||
!agentPageParams.immersiveHome
|
||||
}
|
||||
newChatAt={agentPageParams.newChatAt}
|
||||
newChatAt={committedHome ? agentPageParams.newChatAt : undefined}
|
||||
newConversationRequest={committedRequest}
|
||||
onStartNewConversation={onStartNewConversation}
|
||||
expertAgentLaunch={agentPageParams.expertAgentLaunch}
|
||||
onHasMessagesChange={onAgentHasMessagesChange}
|
||||
onSessionChange={handleAgentSessionChange}
|
||||
onLocationChange={handleAgentLocationChange}
|
||||
onAgentStreamingChange={onAgentStreamingChange}
|
||||
onBackgroundSessionRuntimeChange={onBackgroundSessionRuntimeChange}
|
||||
/>
|
||||
@@ -295,18 +314,10 @@ export function AppPageContent({
|
||||
if (activePage === "settings") {
|
||||
return (
|
||||
<div style={columnPageStyle}>
|
||||
<SettingsPageV2
|
||||
<SettingsPage
|
||||
onNavigate={onNavigate}
|
||||
initialTab={(activePageParams as SettingsPageParams).tab}
|
||||
initialProviderView={
|
||||
(activePageParams as SettingsPageParams).providerView
|
||||
}
|
||||
initialProviderFocus={
|
||||
(activePageParams as SettingsPageParams).providerFocus
|
||||
}
|
||||
initialExecutionPolicyFocus={
|
||||
(activePageParams as SettingsPageParams).executionPolicyFocus
|
||||
}
|
||||
pageParams={activePageParams as SettingsPageParams}
|
||||
onBack={onSettingsBack}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupAppSidebarTest,
|
||||
flushEffects,
|
||||
@@ -42,25 +43,34 @@ describe("AppSidebar conversation projects", () => {
|
||||
|
||||
afterEach(cleanupAppSidebarTest);
|
||||
|
||||
it("进入项目对话后仍展示全部已打开项目,并查询全局与当前项目会话", async () => {
|
||||
it("进入项目对话后仍可选择全部已打开项目的会话", async () => {
|
||||
mockListAgentRuntimeSessions.mockImplementation(
|
||||
async (options?: { limit?: number; cwd?: string | string[] }) =>
|
||||
options?.cwd === "/repo/project-1"
|
||||
options?.cwd === "/repo/project-1" || options?.cwd === "/repo/project-2"
|
||||
? [
|
||||
{
|
||||
id: "session-project-1",
|
||||
name: "项目一会话",
|
||||
id:
|
||||
options.cwd === "/repo/project-1"
|
||||
? "session-project-1"
|
||||
: "session-project-2",
|
||||
name:
|
||||
options.cwd === "/repo/project-1"
|
||||
? "项目一会话"
|
||||
: "项目二会话",
|
||||
created_at: 1714000000,
|
||||
updated_at: 1714000600,
|
||||
archived_at: null,
|
||||
workspace_id: "project-1",
|
||||
working_dir: "/repo/project-1",
|
||||
workspace_id:
|
||||
options.cwd === "/repo/project-1" ? "project-1" : "project-2",
|
||||
working_dir: options.cwd,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const onNavigate = vi.fn();
|
||||
const container = mountSidebarContainer({
|
||||
onNavigate,
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "claw",
|
||||
@@ -74,6 +84,7 @@ describe("AppSidebar conversation projects", () => {
|
||||
expect(projectGroups[0]?.textContent).toContain("项目一");
|
||||
expect(projectGroups[0]?.textContent).toContain("项目一会话");
|
||||
expect(projectGroups[1]?.textContent).toContain("项目二");
|
||||
expect(projectGroups[1]?.textContent).toContain("项目二会话");
|
||||
expect(projectGroups[1]?.textContent).not.toContain("项目一会话");
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({
|
||||
limit: 11,
|
||||
@@ -82,9 +93,23 @@ describe("AppSidebar conversation projects", () => {
|
||||
limit: 11,
|
||||
cwd: "/repo/project-1",
|
||||
});
|
||||
expect(mockListAgentRuntimeSessions).not.toHaveBeenCalledWith({
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({
|
||||
limit: 11,
|
||||
cwd: "/repo/project-2",
|
||||
});
|
||||
const secondConversation =
|
||||
projectGroups[1]?.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-conversation-open"][data-session-id="session-project-2"]',
|
||||
);
|
||||
expect(secondConversation).not.toBeNull();
|
||||
act(() => secondConversation!.click());
|
||||
await flushEffects();
|
||||
expect(onNavigate).toHaveBeenCalledWith(
|
||||
"agent",
|
||||
expect.objectContaining({
|
||||
projectId: "project-2",
|
||||
initialSessionId: "session-project-2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarOpenedProjectSummary } from "@/components/app-sidebar/sidebarConversationGroups";
|
||||
import {
|
||||
AppSidebar,
|
||||
TASK_CENTER_CREATE_DRAFT_TASK_EVENT,
|
||||
mockStartNewConversation,
|
||||
act,
|
||||
buildMockConversationImportPreview,
|
||||
cleanupAppSidebarTest,
|
||||
@@ -139,23 +139,14 @@ describe("AppSidebar conversations", () => {
|
||||
}
|
||||
|
||||
function captureTaskCenterDraftRequests() {
|
||||
const receivedDetails: unknown[] = [];
|
||||
const listener = (event: Event) => {
|
||||
receivedDetails.push(
|
||||
event instanceof CustomEvent ? event.detail : undefined,
|
||||
);
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener);
|
||||
|
||||
const first = mockStartNewConversation.mock.calls.length;
|
||||
return {
|
||||
receivedDetails,
|
||||
dispose: () => {
|
||||
window.removeEventListener(
|
||||
TASK_CENTER_CREATE_DRAFT_TASK_EVENT,
|
||||
listener,
|
||||
);
|
||||
get receivedDetails() {
|
||||
return mockStartNewConversation.mock.calls
|
||||
.slice(first)
|
||||
.map(([request]) => request);
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2569,7 +2560,7 @@ describe("AppSidebar conversations", () => {
|
||||
});
|
||||
|
||||
expect(taskCenterRequests.receivedDetails).toEqual([
|
||||
{ source: "sidebar", projectId: "project-1" },
|
||||
{ projectId: "project-1", freshDraft: false },
|
||||
]);
|
||||
} finally {
|
||||
taskCenterRequests.dispose();
|
||||
@@ -2599,14 +2590,14 @@ describe("AppSidebar conversations", () => {
|
||||
});
|
||||
|
||||
expect(taskCenterRequests.receivedDetails).toEqual([
|
||||
{ source: "sidebar", projectId: null },
|
||||
{ projectId: null, freshDraft: true },
|
||||
]);
|
||||
} finally {
|
||||
taskCenterRequests.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("任务中心不可拦截时,对话加号 fallback 也不应继承当前项目 ID", async () => {
|
||||
it("公开对话加号路由应保留 explicit null 项目", async () => {
|
||||
mockListAgentRuntimeSessions.mockImplementation(
|
||||
async (options?: { limit?: number; cwd?: string | string[] }) =>
|
||||
options?.cwd === "/repo/project-1"
|
||||
@@ -2672,7 +2663,7 @@ describe("AppSidebar conversations", () => {
|
||||
});
|
||||
|
||||
expect(taskCenterRequests.receivedDetails).toEqual([
|
||||
{ source: "sidebar", projectId: "project-1" },
|
||||
{ projectId: "project-1", freshDraft: true },
|
||||
]);
|
||||
} finally {
|
||||
taskCenterRequests.dispose();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user