From 602c9a3e1d03234520ad55a615d8d2bdc4a859f9 Mon Sep 17 00:00:00 2001 From: imcreazy <1602004504@qq.com> Date: Wed, 21 Oct 2026 07:13:30 +0800 Subject: [PATCH 01/37] fix(studio): closing the last page offers a new tab --- desktop/frontend-next/src/ui/WorkbenchPanel.test.tsx | 5 +++-- desktop/frontend-next/src/ui/useTabClose.ts | 4 ++++ .../src/ui/workbench-tab-menu.interaction.test.tsx | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/desktop/frontend-next/src/ui/WorkbenchPanel.test.tsx b/desktop/frontend-next/src/ui/WorkbenchPanel.test.tsx index 2401ab3e63..a03c99c672 100644 --- a/desktop/frontend-next/src/ui/WorkbenchPanel.test.tsx +++ b/desktop/frontend-next/src/ui/WorkbenchPanel.test.tsx @@ -370,7 +370,7 @@ describe("WorkbenchPanel", () => { expect(screen.getByRole("tab", { name: "百度热搜" }).getAttribute("aria-selected")).toBe("true"); }); - it("keeps the column while another tab is open, and folds it with the last", async () => { + it("keeps the column while another tab is open, and offers a new tab with the last", async () => { const user = userEvent.setup(); const onCloseManual = vi.fn(); const pages = [ @@ -382,7 +382,8 @@ describe("WorkbenchPanel", () => { expect(onCloseManual).not.toHaveBeenCalled(); expect(screen.getByRole("tab", { name: "My page" })).toBeTruthy(); await user.click(screen.getByRole("button", { name: "关闭 My page" })); - expect(onCloseManual).toHaveBeenCalledTimes(1); + expect(onCloseManual).not.toHaveBeenCalled(); + expect(screen.getByRole("group", { name: "开始" })).toBeTruthy(); }); it("keeps the column when its last tab closes while the explorer is open", async () => { diff --git a/desktop/frontend-next/src/ui/useTabClose.ts b/desktop/frontend-next/src/ui/useTabClose.ts index b13038c452..1f51719071 100644 --- a/desktop/frontend-next/src/ui/useTabClose.ts +++ b/desktop/frontend-next/src/ui/useTabClose.ts @@ -49,6 +49,10 @@ export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, fromExplorer.current = ""; setLauncher?.(false); setShowFiles?.(true); + } else if (targets.some((s) => s.kind !== "file")) { + // Closing the last page leaves the column with something to offer + // rather than folding it away: the same place a new tab starts from. + setLauncher?.(true); } else { onCloseManual(); } diff --git a/desktop/frontend-next/src/ui/workbench-tab-menu.interaction.test.tsx b/desktop/frontend-next/src/ui/workbench-tab-menu.interaction.test.tsx index 07a612e324..5aec611073 100644 --- a/desktop/frontend-next/src/ui/workbench-tab-menu.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/workbench-tab-menu.interaction.test.tsx @@ -90,13 +90,14 @@ it("retains a refused right-side page and disables close controls while awaiting expect((screen.getByRole("button", { name: "关闭 Gamma" }) as HTMLButtonElement).disabled).toBe(false); }); -it("disables empty bulk choices and folds the column with its last browser", async () => { +it("disables empty bulk choices and offers a new tab with its last browser", async () => { const pane = draw([pages[0]!]); offer("Alpha"); expect((screen.getByRole("menuitem", { name: "关闭其他浏览器标签" }) as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole("menuitem", { name: "关闭右侧浏览器标签" }) as HTMLButtonElement).disabled).toBe(true); await userEvent.click(screen.getByRole("menuitem", { name: "关闭" })); - expect(pane.onCloseManual).toHaveBeenCalledTimes(1); + expect(pane.onCloseManual).not.toHaveBeenCalled(); + expect(screen.getByRole("group", { name: "开始" })).toBeTruthy(); expect(screen.queryByRole("menu")).toBeNull(); }); From 0f943c7f7a8b33b2c3c459b30a48a374a1f4f113 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:37:40 +0800 Subject: [PATCH 02/37] fix(worktree): rebuild private index before capturing content Problem: A candidate snapshot can contain old bytes after the working file changed while preserving its size and mtime. Root cause: Copying the source index carries Git's trusted stat cache and assume-unchanged state into StageAll, so staging can skip the actual bytes. Fix: Preserve the copied index's staged tree, rebuild the private index from that tree, then stage current content. Handle an unmerged private index first. Keep staged ignored inputs, the real conflicted index, HEAD and refs intact. Document concrete Git/path/authority boundaries and the unchanged need for source coordination. Include the inherited documentation-lint prerequisite. Verification: All three cache-policy variants fail against the original function through a compiler overlay and pass with the repair. Full worktree race passes, plus final expanded cache and conflict regressions. Check, lint and root vet pass. Full builtin/boot exits zero (22.243s / 444.076s tests), using a measured 15-minute package alarm with no product-deadline change. The exact-commit full root sweep and hosted checks remain separate evidence. Refs #14025 Refs #14133 --- docs/WORKTREE_SNAPSHOT_SECURITY.md | 65 +++++++++++++++++ docs/design/FIRST_RUN_WORKSPACE.md | 19 +++-- internal/platform/worktree/candidate.go | 32 ++++++-- .../worktree/content_snapshot_test.go | 73 +++++++++++++++++++ .../worktree/snapshot_unmerged_test.go | 60 +++++++++++++++ .../unreleased/14025-snapshot-content.md | 2 + 6 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 docs/WORKTREE_SNAPSHOT_SECURITY.md create mode 100644 internal/platform/worktree/content_snapshot_test.go create mode 100644 internal/platform/worktree/snapshot_unmerged_test.go create mode 100644 release-notes/studio/unreleased/14025-snapshot-content.md diff --git a/docs/WORKTREE_SNAPSHOT_SECURITY.md b/docs/WORKTREE_SNAPSHOT_SECURITY.md new file mode 100644 index 0000000000..be850b7a47 --- /dev/null +++ b/docs/WORKTREE_SNAPSHOT_SECURITY.md @@ -0,0 +1,65 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-24 +--- + +# Worktree content snapshot review / Worktree 内容快照审查 + +## Purpose / 目的 + +This is the author's security assessment of the content-snapshot slice extracted +from #14133 at `467a61668a0781cab171536158536283d931e4d9`. It is independent of +the transaction-library PR #14368. It is not independent security approval. + +本批修复已有快照入口受 Git stat 缓存影响的问题,独立于事务基础库。本文是 +作者审查,不代表独立安全审批,也不替代后续隔离、整合及恢复层的审查。 + +## Reference tables / 边界对照 + +| Value / 输入 | Owner and use / 所有者及用途 | +| --- | --- | +| Repository and Git index path | Existing resolved `gitcmd.Repo` and `rev-parse --git-path index`. Existing copy reads that index. No model-provided arbitrary index path is added. | +| Private index | Existing `os.MkdirTemp` directory and fixed `index` leaf. `GIT_INDEX_FILE` is passed as environment data to every new Git command. Only this private index is removed/rebuilt. | +| Tree identity | `write-tree` output from the private index is passed as one argument to `read-tree`. No shell string concatenation or new shell executable is introduced. | +| Workspace bytes | Existing `Repo.StageAll` reads the authorized source through the established Git driver. Git configuration/filter policy is not broadened. | +| Source Git state | The real index, HEAD and refs are not updated. Writing unreachable tree/commit objects remains an existing snapshot side effect governed by Git's own object publication. | + +各路径来自现有宿主解析的仓库身份或新建的私有临时目录。新命令仍经现有 Git +驱动执行;参数不作为 shell 脚本拼接。真实索引和 refs 不参与私有索引重建。 + +## Rules and failure / 规则与失败 + +1. The copied index MUST first preserve its staged tree, including explicitly + staged ignored files. Rebuild from that tree before staging current bytes, + so trusted size/mtime cache entries cannot select stale content. +2. An unmerged copied index MUST be staged from working bytes before its tree + can be written. This records conflict markers as bytes; it does not resolve + or rewrite the user's actual conflicted index. +3. Every new Git invocation MUST retain the existing driver and deadline. + Failure returns an error. The temporary directory is removed by its existing + owner; Git object cleanup remains ordinary Git garbage collection. +4. Callers MUST still coordinate the source during capture. This repair does + not make a directory scan atomic against concurrent external writers, grant + access, isolate execution or issue a verification receipt. +5. Existing snapshot/result formats remain unchanged. No metadata field, + migration, settings change or automatic-isolation policy is introduced. + +规则保证私有索引重建时保留已暂存的忽略文件,并处理真实冲突状态。它没有把 +目录扫描变成跨文件原子快照;调用者仍需协调源文件写入。失败必须返回错误, +不能把不完整捕获当作有效验证证据。 + +## Verification / 验证 + +1. `TestSnapshotHashesBytesDespiteTrustedCachedStat` covers trusted size/mtime, + `assume-unchanged` and `core.ignorestat`. Old code captures old bytes; fixed + code captures new bytes, keeps staged ignored input, and preserves index/HEAD. +2. `TestSnapshotRecordsAWorkspaceMidConflict` uses a real merge conflict and + checks both captured conflict-marker bytes and unchanged real index bytes. +3. The complete worktree race suite covers neighbouring creation, cancellation, + filtering, relative roots, application and rollback behavior. Platform and + broader assembly results are reported separately in the PR. + +回归先证明旧实现在真实 Git 仓库中捕获旧内容,再验证修复后内容、忽略文件、 +索引及 HEAD 均符合预期。其他平台和装配层结论按各自实际测试结果记录。 diff --git a/docs/design/FIRST_RUN_WORKSPACE.md b/docs/design/FIRST_RUN_WORKSPACE.md index 5a616a5d9d..432a4b7d3a 100644 --- a/docs/design/FIRST_RUN_WORKSPACE.md +++ b/docs/design/FIRST_RUN_WORKSPACE.md @@ -1,13 +1,18 @@ --- -owner: @Oliver-Rui -status: proposed +owner: @esengine +backup: @SivanCola +author: @Oliver-Rui +status: active +reviewed: 2026-10-24 --- # First run without a picked workspace Status: proposed. Nothing in this document is shipped unless §8 says so. It lives under `docs/design/` so the product-doc retrieval corpus (`docs/*.md`) cannot present it to a model as a feature. -Reference: how MiMo Desktop (observed 2026-10-24 on a machine running it) handles the same three first-run decisions — connect a model, pick a workspace, start a session. Its behaviour below is read off installed artifacts (config schema, directory layout, state store), not source; the mapping onto this repository in §6 is grounded in source. +Reference: how MiMo Desktop (observed 2026-10-24 on a machine running it) handles the same three first-run decisions — connect a model, pick a workspace, start a session. + +Its behaviour below is read off installed artifacts (config schema, directory layout, state store), not source; the mapping onto this repository in §6 is grounded in source. ## 1. The problem @@ -19,9 +24,13 @@ Studio's first launch asks three things at once, and only one of them is honestl | Start a session | the composer is reachable as soon as the workbench opens | yes | | Pick a workspace | `pickLaunchWorkspace(cwd, appExe, remembered, home)` — with nothing remembered, the first pane's root is the **user's home directory** | no | -The fallback chain in `cmd/reasonix-studio-host/launchroot.go` is careful about *which* cwd it trusts — a Start-menu launch inherits the install directory, and taking that as a project "puts the app's own folder in the sidebar again after every removal" — but its last resort is the home directory. That folder was never chosen as a project. It is broad, it is sensitive, and it is not represented in the sidebar as anything the user picked, so a session running there reads as a temporary session: not in the user's project, scoped to a folder they did not choose, and removing it does not stick. +The fallback chain in `cmd/reasonix-studio-host/launchroot.go` is careful about *which* cwd it trusts — a Start-menu launch inherits the install directory, and taking that as a project "puts the app's own folder in the sidebar again after every removal" — but its last resort is the home directory. -Everything else is already in place for a decoupled first run. Session transcripts persist in the global store (`SessionDir()` = `/sessions`), so a session without a chosen project is not lost. Each logical session already owns a private scratch directory (`SessionTemp`, rotated per session, deleted when it ends, surfaced to the model as `$TMPDIR`). Provider setup already refuses to block. The workspace is the last coupled decision. +That folder was never chosen as a project. It is broad, it is sensitive, and it is not represented in the sidebar as anything the user picked, so a session running there reads as a temporary session: not in the user's project, scoped to a folder they did not choose, and removing it does not stick. + +Everything else is already in place for a decoupled first run. Session transcripts persist in the global store (`SessionDir()` = `/sessions`), so a session without a chosen project is not lost. + +Each logical session already owns a private scratch directory (`SessionTemp`, rotated per session, deleted when it ends, surfaced to the model as `$TMPDIR`). Provider setup already refuses to block. The workspace is the last coupled decision. ## 2. What MiMo does instead diff --git a/internal/platform/worktree/candidate.go b/internal/platform/worktree/candidate.go index bde3380c19..cba5ced81e 100644 --- a/internal/platform/worktree/candidate.go +++ b/internal/platform/worktree/candidate.go @@ -193,8 +193,8 @@ func RemoveCandidate(ctx context.Context, snap Snapshot, c Candidate) error { return nil } -// workingTree writes repo's working state as a tree through a private copy of -// its index, so only changed files are hashed and the real index is untouched. +// workingTree hashes actual workspace bytes through a rebuilt private index. +// Git's cached stat identity is not a content version; the real index is untouched. func workingTree(ctx context.Context, repo gitcmd.Repo) (string, error) { indexPath, _, err := runGit(ctx, repo, "rev-parse", "--git-path", "index") if err != nil { @@ -216,6 +216,29 @@ func workingTree(ctx context.Context, repo gitcmd.Repo) (string, error) { env := []string{"GIT_INDEX_FILE=" + private} stageCtx, cancel := context.WithTimeout(ctx, gitWorktreeAddTimeout) defer cancel() + if _, err := os.Stat(private); err == nil { + unmerged, stderr, err := runGitEnv(stageCtx, repo, env, "ls-files", "--unmerged", "-z") + if err != nil { + return "", fmt.Errorf("read private index: %w%s", err, stderrSuffix(stderr)) + } + if unmerged != "" { + if err := repo.StageAll(stageCtx, env...); err != nil { + return "", fmt.Errorf("stage conflicted workspace snapshot: %w", err) + } + } + indexTree, stderr, err := runGitEnv(stageCtx, repo, env, "write-tree") + if err != nil { + return "", fmt.Errorf("record private index: %w%s", err, stderrSuffix(stderr)) + } + if err := os.Remove(private); err != nil { + return "", err + } + if _, stderr, err := runGitEnv(stageCtx, repo, env, "read-tree", strings.TrimSpace(indexTree)); err != nil { + return "", fmt.Errorf("rebuild private index: %w%s", err, stderrSuffix(stderr)) + } + } else if !os.IsNotExist(err) { + return "", err + } if err := repo.StageAll(stageCtx, env...); err != nil { return "", fmt.Errorf("stage workspace snapshot: %w", err) } @@ -257,9 +280,8 @@ func runGitEnv(parent context.Context, repo gitcmd.Repo, env []string, args ...s return outBuf.String(), strings.TrimSpace(errBuf.String()), err } -// copyFile copies an index keeping its modification time: git re-hashes an -// entry written in the index's own second only while the index file's mtime -// says so, and a fresh mtime would hide a same-size edit made in that second. +// copyFile preserves the index bytes and timestamp. workingTree rebuilds its +// cached entries before staging the current workspace content. func copyFile(from, to string) error { in, err := os.Open(from) if err != nil { diff --git a/internal/platform/worktree/content_snapshot_test.go b/internal/platform/worktree/content_snapshot_test.go new file mode 100644 index 0000000000..2ac31755e7 --- /dev/null +++ b/internal/platform/worktree/content_snapshot_test.go @@ -0,0 +1,73 @@ +package worktree + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSnapshotHashesBytesDespiteTrustedCachedStat(t *testing.T) { + for _, policy := range []string{"stat-cache", "assume-unchanged", "ignore-stat"} { + t.Run(policy, func(t *testing.T) { testSnapshotCurrentBytes(t, policy) }) + } +} + +func testSnapshotCurrentBytes(t *testing.T, policy string) { + t.Helper() + requireGit(t) + root := t.TempDir() + gitIn(t, root, "init", "-q") + gitIn(t, root, "config", "core.trustctime", "false") + gitIn(t, root, "config", "core.checkstat", "minimal") + if policy == "ignore-stat" { + gitIn(t, root, "config", "core.ignorestat", "true") + } + writeRepoFile(t, root, "input", "old\n") + writeRepoFile(t, root, ".gitignore", "staged-ignored\n") + path := filepath.Join(root, "input") + stamp := time.Now().Add(-time.Hour).Truncate(time.Second) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + gitIn(t, root, "add", "-A") + gitIn(t, root, "commit", "-qm", "base") + if policy == "assume-unchanged" { + gitIn(t, root, "update-index", "--assume-unchanged", "input") + } + writeRepoFile(t, root, "staged-ignored", "must survive\n") + gitIn(t, root, "add", "-f", "staged-ignored") + index := filepath.Join(root, ".git", "index") + before, err := os.ReadFile(index) + if err != nil { + t.Fatal(err) + } + writeRepoFile(t, root, "input", "new\n") + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + // The ordinary stat cache really does hide this edit; otherwise the test + // would not distinguish hashing bytes from copying Git's cached version. + if diff := gitIn(t, root, "diff", "--", "input"); diff != "" { + t.Fatalf("fixture did not reproduce a trusted stale stat cache: %s", diff) + } + snapshot, err := TakeSnapshot(t.Context(), opened(t, root)) + if err != nil { + t.Fatal(err) + } + if got := gitIn(t, root, "show", snapshot.Tree+":input"); got != "new\n" { + t.Fatalf("snapshot reused stale stat cache: %q", got) + } + if got := gitIn(t, root, "show", snapshot.Tree+":staged-ignored"); got != "must survive\n" { + t.Fatalf("private index rebuild lost staged ignored input: %q", got) + } + after, err := os.ReadFile(index) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("snapshot modified the user's index: %v", err) + } + if got := strings.TrimSpace(gitIn(t, root, "show", "HEAD:input")); got != "old" { + t.Fatalf("snapshot changed HEAD: %s", got) + } +} diff --git a/internal/platform/worktree/snapshot_unmerged_test.go b/internal/platform/worktree/snapshot_unmerged_test.go new file mode 100644 index 0000000000..d311e5b629 --- /dev/null +++ b/internal/platform/worktree/snapshot_unmerged_test.go @@ -0,0 +1,60 @@ +package worktree + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestSnapshotRecordsAWorkspaceMidConflict(t *testing.T) { + requireGit(t) + repo := initRepo(t) + git := func(args ...string) error { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + return cmd.Run() + } + write := func(body string) { + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := git("checkout", "-q", "-b", "other"); err != nil { + t.Fatal(err) + } + write("other\n") + if err := git("commit", "-qam", "other"); err != nil { + t.Fatal(err) + } + if err := git("checkout", "-q", "-"); err != nil { + t.Fatal(err) + } + write("mine\n") + if err := git("commit", "-qam", "mine"); err != nil { + t.Fatal(err) + } + if err := git("merge", "-q", "other"); err == nil { + t.Fatal("fixture merge did not conflict") + } + index := filepath.Join(repo, ".git", "index") + before, err := os.ReadFile(index) + if err != nil { + t.Fatal(err) + } + snap, err := TakeSnapshot(t.Context(), opened(t, repo)) + if err != nil { + t.Fatalf("a conflicted index blocked the snapshot: %v", err) + } + out, err := exec.Command("git", "-C", repo, "cat-file", "-p", snap.Tree+":README.md").Output() + if err != nil || !strings.Contains(string(out), "<<<<<<<") { + t.Fatalf("snapshot did not record the working file: %q %v", out, err) + } + after, err := os.ReadFile(index) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("snapshot rewrote the user's conflicted index: %v", err) + } +} diff --git a/release-notes/studio/unreleased/14025-snapshot-content.md b/release-notes/studio/unreleased/14025-snapshot-content.md new file mode 100644 index 0000000000..677f1373b1 --- /dev/null +++ b/release-notes/studio/unreleased/14025-snapshot-content.md @@ -0,0 +1,2 @@ +- 修复文件大小和修改时间未变时,候选快照可能复用 Git 缓存中的旧内容;同时覆盖 `assume-unchanged` 和 `core.ignorestat` 设置。关联 #14025。 +- 私有索引重建保留用户暂存区、已暂存的忽略文件和未解决的合并冲突现场。关联 #14025。 From fd8729692b69c20ad0cf59f95398afaa275289a4 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:48:54 +0800 Subject: [PATCH 03/37] test(worktree): reject stat-cache-hidden source overwrites Problem: A snapshot-only regression does not establish that the shared tree reader also protects the source workspace before Apply publishes a candidate. Root cause: The old cached index can hide a user's intervening edit from the whole-tree drift check, allowing Apply to return success and overwrite it. Fix: Exercise actual snapshot/candidate/Apply assembly on disposable real Git repositories. Require ErrWorkspaceMoved and unchanged user bytes. Document the existing Git driver confinement and the additional rehashing cost explicitly. Verification: A compiling overlay of the original production file fails for both wrong success and actual overwrite; the repaired owner passes. Full final worktree race passes (48.247s). Broader root and static qualification records are retained separately; production code is unchanged by this test follow-up. Refs #14025 Refs #14133 --- docs/WORKTREE_SNAPSHOT_SECURITY.md | 7 +++ .../worktree/apply_stat_cache_test.go | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/platform/worktree/apply_stat_cache_test.go diff --git a/docs/WORKTREE_SNAPSHOT_SECURITY.md b/docs/WORKTREE_SNAPSHOT_SECURITY.md index be850b7a47..b2068d7705 100644 --- a/docs/WORKTREE_SNAPSHOT_SECURITY.md +++ b/docs/WORKTREE_SNAPSHOT_SECURITY.md @@ -29,6 +29,10 @@ the transaction-library PR #14368. It is not independent security approval. 各路径来自现有宿主解析的仓库身份或新建的私有临时目录。新命令仍经现有 Git 驱动执行;参数不作为 shell 脚本拼接。真实索引和 refs 不参与私有索引重建。 +The existing driver disables Git hooks/fsmonitor and submodule recursion for +`read-tree`. The extra invocations inherit that policy. Index rebuilding and +rehashing add work; this slice makes no capture-latency improvement claim. + ## Rules and failure / 规则与失败 1. The copied index MUST first preserve its staged tree, including explicitly @@ -60,6 +64,9 @@ the transaction-library PR #14368. It is not independent security approval. 3. The complete worktree race suite covers neighbouring creation, cancellation, filtering, relative roots, application and rollback behavior. Platform and broader assembly results are reported separately in the PR. +4. `TestApplyRejectsStatCacheHiddenSourceEdit` reproduces an actual stale + candidate overwriting a hidden user edit with the old code. The repair + returns `ErrWorkspaceMoved` and keeps the user's bytes. 回归先证明旧实现在真实 Git 仓库中捕获旧内容,再验证修复后内容、忽略文件、 索引及 HEAD 均符合预期。其他平台和装配层结论按各自实际测试结果记录。 diff --git a/internal/platform/worktree/apply_stat_cache_test.go b/internal/platform/worktree/apply_stat_cache_test.go new file mode 100644 index 0000000000..e76df24d58 --- /dev/null +++ b/internal/platform/worktree/apply_stat_cache_test.go @@ -0,0 +1,57 @@ +package worktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestApplyRejectsStatCacheHiddenSourceEdit(t *testing.T) { + requireGit(t) + root := t.TempDir() + gitIn(t, root, "init", "-q") + gitIn(t, root, "config", "core.trustctime", "false") + gitIn(t, root, "config", "core.checkstat", "minimal") + writeRepoFile(t, root, "input", "old\n") + path := filepath.Join(root, "input") + stamp := time.Now().Add(-time.Hour).Truncate(time.Second) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + gitIn(t, root, "add", "-A") + gitIn(t, root, "commit", "-qm", "base") + snapshot, err := TakeSnapshot(t.Context(), opened(t, root)) + if err != nil { + t.Fatal(err) + } + candidate, err := CreateCandidate(t.Context(), snapshot, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := RemoveCandidate(context.WithoutCancel(t.Context()), snapshot, candidate); err != nil { + t.Error(err) + } + }) + writeRepoFile(t, candidate.WorkspaceRoot, "input", "new\n") + tree, changes, err := CandidateTree(t.Context(), snapshot, candidate) + if err != nil || len(changes) != 1 { + t.Fatalf("candidate fixture: %v %v", changes, err) + } + writeRepoFile(t, root, "input", "usr\n") + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + if diff := gitIn(t, root, "diff", "--", "input"); diff != "" { + t.Fatalf("fixture did not hide the user's edit: %s", diff) + } + if err := Apply(t.Context(), snapshot, tree, changes); !errors.Is(err, ErrWorkspaceMoved) { + t.Errorf("stale candidate was not refused: %v", err) + } + if got := readRepoFile(t, root, "input"); got != "usr\n" { + t.Fatalf("candidate overwrote the user's edit: %q", got) + } +} From 25169a45f1bd789152c5e3ee933be7852db053e1 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:21:21 +0800 Subject: [PATCH 04/37] feat(mutation): admit explicitly opaque write effects Problem: Directory-scoped coordination cannot exclude writers that address a source file through a hardlink outside that directory. Root cause: Path containment and named inode identity do not represent an unresolved write effect; reinterpreting old directory records would silently change their resource promise and nested admission could reuse a weaker hold. Fix: Publish explicit version-3 opaque effects in the existing registry, coordinate them symmetrically against all path-bearing transactions, and require effect coverage when reusing a parent lease. Preserve ordinary named parallelism, model capacity, previous directory semantics and final OS-locked grant authority. No runtime or default requests the new effect here. Verification: Full 239-package root inventory and independent builtin/boot commands exit 0; mutation race 82 passes/zero skips; preceding-reader binary preserves live/dead new records; weakened-owner control fails eight cases; selected wait-snapshot composition has 36 race passes/zero skips. Root vet, full lint, changed-file checks and formatting pass at unchanged baseline 2664. Windows/Linux amd64 compile only; local execution is macOS. --- docs/OPAQUE_WRITE_ADMISSION_SECURITY.md | 125 ++++++++++ internal/state/mutation/capacity.go | 4 + internal/state/mutation/coordinator.go | 6 +- internal/state/mutation/opaque.go | 14 ++ .../state/mutation/opaque_process_test.go | 89 +++++++ .../state/mutation/opaque_protocol_test.go | 97 +++++++ internal/state/mutation/opaque_test.go | 236 ++++++++++++++++++ internal/state/mutation/registry.go | 2 +- internal/state/mutation/resources.go | 2 +- internal/state/mutation/types.go | 1 + .../14025-opaque-write-admission.md | 7 + 11 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 docs/OPAQUE_WRITE_ADMISSION_SECURITY.md create mode 100644 internal/state/mutation/opaque.go create mode 100644 internal/state/mutation/opaque_process_test.go create mode 100644 internal/state/mutation/opaque_protocol_test.go create mode 100644 internal/state/mutation/opaque_test.go create mode 100644 release-notes/studio/unreleased/14025-opaque-write-admission.md diff --git a/docs/OPAQUE_WRITE_ADMISSION_SECURITY.md b/docs/OPAQUE_WRITE_ADMISSION_SECURITY.md new file mode 100644 index 0000000000..28abb9a316 --- /dev/null +++ b/docs/OPAQUE_WRITE_ADMISSION_SECURITY.md @@ -0,0 +1,125 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-25 +--- + +# Opaque write admission / 未知范围写入准入 + +## Scope and cause / 范围与根因 + +A directory reservation cannot enumerate hardlink aliases outside its tree. +The actual filesystem probe holds a Workspace lease, then admits a named write +through an external hardlink and changes the held source. + +Named-file aliases and direct descendants are already excluded. This is a documented primitive +limitation, not an observed production incident; shared runtime activation is +still separate. + +`OpaqueWrite` explicitly requests conservative exclusion across all path-bearing +transactions in the same trusted registry. Named disjoint writes still proceed +concurrently. Capacity-only/model requests do not participate in that exclusion. +No tool, host adapter, permission, default or prompt definition changes here. + +目录范围不能枚举树外硬链接。真实文件探针证明旧 Workspace 租约不能阻止通过该 +别名的具名写入;具名文件别名和目录内直接路径已有互斥。本次明确新增 +`OpaqueWrite`,不悄悄扩大旧目录协议含义,也不启用运行时新策略。 + +## Source, sink and authority / 输入、副作用与权威 + +- Hosts select `Intent.Effect` and the trusted state directory. This package + does not parse tool arguments, approve commands or authorize filesystem access. +- Opaque records publish version 3; only this effect is supported in version 3. + Versions 1 and 2 retain their existing path/capacity semantics and encoding. + The shared `mutations-v1` directory remains the admission authority; a separate + directory would let old named writers miss the new owner entirely. +- One symmetric resource relation makes opaque owners and older opaque waiters + conflict with every path-bearing request, including other roots and Git paths. +- Queue order, complete-set admission, live OS ownership, compatibility claims + and the final fresh locked check remain in the common owner. No new singleton + execution lock is added to every named writer. +- The paths retained on an opaque lease still constrain nested calls, expected + content versions and compatibility claims. Global exclusion is not global + filesystem permission. A directory parent cannot satisfy an opaque child. +- Continuations retain the opaque effect and acquire a new transaction. Cancel + and process death use the existing identified cleanup, not forced lock theft. + +影响类型由宿主选择,权限检查仍属于宿主。新版本记录显式表达全注册表互斥,旧版本 +保持原义。嵌套调用同时检查路径、额度和影响覆盖,不能靠父目录将普通租约升级为 +全域租约;全域互斥也不扩大嵌套路径授权。 + +## Compatibility / 兼容性 + +| Input or participant | New coordinator | Preceding coordinator | +| --- | --- | --- | +| Known version 1/2 records | Existing semantics | Existing semantics | +| Version 3 opaque owner, live or dead | Interpret/reclaim when understood | Refuse and preserve bytes | +| Version 1/2 opaque effect | Refuse and preserve | Unsupported semantics; do not publish | +| Version 3 nonopaque effect or missing paths | Refuse and preserve | Refuse and preserve | +| Unknown version/field | Refuse and preserve | Version refusal; field behavior is version-specific | +| Legacy workspacelease writer | Existing workspace claim coverage | No registry-wide opaque guarantee | + +The preceding accepted coordinator is compiled and executed against live/dead +version-3 records, with real legacy cleanup as a control. No new normal runtime +publishes these records yet. + +While one exists, preceding coordinators refuse all new admission instead of +trying to interpret global semantics. That is an intentional availability +boundary; an implementation that understands version 3 +can reclaim a known dead owner. Do not delete unknown records to bypass it. + +已接受的前版通过真实编译二进制验证:新记录持有者存活或死亡时都保留字节并拒绝 +准入,旧记录清理控制正常执行。旧程序遇新协议会阻塞,必须由理解该协议的实现 +处理;不能通过手删记录制造并发。旧 workspacelease 写入者仍只有原工作区域保障。 + +## Evidence / 验证 + +- Real hardlink aliases in both acquisition orders, with an actual write after + release proving the alias is effective; unrelated-root cross-process owners + exclude each other and recover after the child is killed and joined. +- Disjoint named writers proceed; active opaque ownership and queued opaque + writes do not consume unrelated model capacity. Cancellation closes the wait + notice and removes the new contribution without releasing the active owner. +- An older opaque waiter prevents later named bypass. Nested directory upgrades + refuse; outside nested paths refuse even under opaque ownership. Continuation + retains exclusion after the parent ends. +- Versioned publication with and without capacities, legacy directory semantics, + unknown versions/fields and invalid resource shapes are covered separately. +- Deliberately restoring path-only conflict and path-only nested coverage + compiles and breaks the new invariants. This is a weakened-control test of a + new effect, not proof that the preceding API already promised that effect. + +真实文件、子进程、队列、取消、嵌套、续接和版本兼容分别验证。故意削弱新规则的 +对照用于证明测试有效,不能冒充旧版已实现新接口却违反承诺的缺陷复现。 + +## Limits and next integration / 边界与后续接入 + +This is cooperative exclusion inside one trusted state namespace. Other state +directories, arbitrary processes and external services remain outside it. +The registry is not authenticated or bounded by this change. Existing Workspace +and directory-valued NamedPaths leases retain their documented alias limitation. + +Before runtime activation, map unresolved tool/hook effects explicitly, authorize +first and keep the lease only during physical mutation. + +Lifetime completion, unknown-scope recovery, strict per-actor reads/CAS and evidence remain separate. +Never hold it through thinking, queuing or user waits. This unit does not close +the future shell/hook completion API or automatically select isolation. + +Applying this global effect to every worktree command would serialize independent +tasks. A private execution adapter needs demonstrated write-root, alias, backend +and shared-Git boundaries before choosing narrower coordination. + +A changed cwd or initially distinct inodes does not prove isolation. No worktree availability, +complete sandbox, Windows runtime or final performance claim is made here. + +同一可信注册表以外的进程和外部副作用不受约束。后续必须先权限检查,再按真实影响 +接入并限定物理执行生命周期;模型思考、任务排队和等待用户不应持有该租约。 +不能对所有 worktree 命令简单套全域锁后声称隔离任务仍可并行。私有执行域需要实际 +后端、授权根、别名和共享 Git 边界证据。本次不是完整沙箱或最终性能验收。 + +This is the author's security assessment, not independent approval or a +disposition of the original large PR's CodeQL findings. + +本说明为作者评估,不替代独立安全审查,也不批量判定原大 PR 的告警。 diff --git a/internal/state/mutation/capacity.go b/internal/state/mutation/capacity.go index 8a44d70292..dde1ffaa5e 100644 --- a/internal/state/mutation/capacity.go +++ b/internal/state/mutation/capacity.go @@ -41,6 +41,10 @@ func coversCapacities(held, requested []Capacity) bool { } func validRecordResources(r record) bool { + if r.Version == 3 { + _, err := normalizedCapacities(r.Capacities) + return r.Effect == OpaqueWrite && len(r.Paths) > 0 && filepath.IsAbs(r.Workspace) && err == nil + } if r.Version == 1 { // The original path-only protocol permitted an omitted effect. knownEffect := r.Effect == "" || r.Effect == NamedPaths || r.Effect == Workspace || r.Effect == GitMetadata diff --git a/internal/state/mutation/coordinator.go b/internal/state/mutation/coordinator.go index 75d5637554..738037226d 100644 --- a/internal/state/mutation/coordinator.go +++ b/internal/state/mutation/coordinator.go @@ -99,7 +99,8 @@ func (c *Coordinator) Acquire(ctx context.Context, intent Intent) (acquired *Lea return &Lease{}, nil } if parent, _ := ctx.Value(leaseContextKey{}).(*Lease); parent != nil && parent.coordinator == c { - if parent.closed.Load() || !covers(parent.record.Paths, paths) || !coversCapacities(parent.record.Capacities, capacities) { + if parent.closed.Load() || !coversEffect(parent.record.Effect, intent.Effect) || + !covers(parent.record.Paths, paths) || !coversCapacities(parent.record.Capacities, capacities) { return nil, ErrInvalidIntent } if err := c.validate(ctx, intent, paths); err != nil { @@ -118,6 +119,9 @@ func (c *Coordinator) Acquire(ctx context.Context, intent Intent) (acquired *Lea if len(capacities) != 0 { l.record.Version = 2 } + if intent.Effect == OpaqueWrite { + l.record.Version = 3 + } l.record.ID = l.ID defer func() { if errors.Is(resultErr, context.DeadlineExceeded) { diff --git a/internal/state/mutation/opaque.go b/internal/state/mutation/opaque.go new file mode 100644 index 0000000000..d4c08f0e25 --- /dev/null +++ b/internal/state/mutation/opaque.go @@ -0,0 +1,14 @@ +package mutation + +// Opaque exclusion spans the trusted registry, including tree-external aliases. +// Its paths still bound nested admission and legacy workspace compatibility. +func recordsOverlap(a, b record) bool { + if len(a.Paths) == 0 || len(b.Paths) == 0 { + return false + } + return a.Effect == OpaqueWrite || b.Effect == OpaqueWrite || overlaps(a.Paths, b.Paths) +} + +func coversEffect(held, requested Effect) bool { + return requested != OpaqueWrite || held == OpaqueWrite +} diff --git a/internal/state/mutation/opaque_process_test.go b/internal/state/mutation/opaque_process_test.go new file mode 100644 index 0000000000..7a3d9d7dc4 --- /dev/null +++ b/internal/state/mutation/opaque_process_test.go @@ -0,0 +1,89 @@ +package mutation + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestOpaqueProcessHelper(t *testing.T) { + if os.Getenv("REASONIX_OPAQUE_PROCESS") != "1" { + return + } + c, err := New(os.Getenv("REASONIX_OPAQUE_ROOT"), os.Getenv("REASONIX_OPAQUE_STATE"), nil) + if err != nil { + t.Fatal(err) + } + request := opaqueIntent() + if os.Getenv("REASONIX_OPAQUE_NAMED") == "1" { + request = intent("file") + } + lease, err := c.Acquire(t.Context(), request) + if err != nil { + t.Fatal(err) + } + defer lease.End(NotExecuted) + fmt.Println("opaque-ready") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') +} + +func TestOpaqueWriteCrossProcessAndDeath(t *testing.T) { + for _, namedChild := range []string{"0", "1"} { + t.Run("named_child_"+namedChild, func(t *testing.T) { + root, extra, state := t.TempDir(), t.TempDir(), t.TempDir() + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestOpaqueProcessHelper$") + cmd.Env = append(os.Environ(), "REASONIX_OPAQUE_PROCESS=1", "REASONIX_OPAQUE_ROOT="+root, + "REASONIX_OPAQUE_STATE="+state, "REASONIX_OPAQUE_NAMED="+namedChild) + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = in.Close(); _ = cmd.Process.Kill(); _ = cmd.Wait() }) + ready := make(chan string, 1) + go func() { line, _ := bufio.NewReader(out).ReadString('\n'); ready <- line }() + select { + case line := <-ready: + if line != "opaque-ready\n" { + t.Fatal(line) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + c := testCoordinator(t, extra, state) + request := intent(filepath.Join(extra, "unrelated")) + if namedChild == "1" { + request = opaqueIntent() + } + if lease, err := c.TryAcquire(ctx, request); !errors.Is(err, ErrBusy) { + if lease != nil { + lease.End(NotExecuted) + } + t.Fatalf("cross-process global exclusion absent: %v", err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + lease, err := c.Acquire(ctx, request) + if err != nil { + t.Fatalf("dead owner not reclaimed: %v", err) + } + lease.End(NotExecuted) + }) + } +} diff --git a/internal/state/mutation/opaque_protocol_test.go b/internal/state/mutation/opaque_protocol_test.go new file mode 100644 index 0000000000..eafb2c7736 --- /dev/null +++ b/internal/state/mutation/opaque_protocol_test.go @@ -0,0 +1,97 @@ +package mutation + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "reasonix/internal/base/filelock" +) + +func TestOpaqueWritePublishesExplicitVersionAndPreservesLegacyDirectoryMeaning(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + for _, pool := range []bool{false, true} { + request := opaqueIntent() + if pool { + request.Capacities = []Capacity{capacity("model")} + } + lease, err := c.Acquire(t.Context(), request) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(c.recordPath(lease.ID)) + if err != nil { + t.Fatal(err) + } + var r record + if err := json.Unmarshal(raw, &r); err != nil || r.Version != 3 || r.Effect != OpaqueWrite { + t.Fatalf("opaque semantics published under an old version: %s %v", raw, err) + } + lease.End(NotExecuted) + } + legacy, err := c.Acquire(t.Context(), Intent{Effect: Workspace}) + if err != nil { + t.Fatal(err) + } + defer legacy.End(NotExecuted) + other := testCoordinator(t, t.TempDir(), state) + named, err := other.TryAcquire(t.Context(), intent("unrelated")) + if err != nil { + t.Fatalf("legacy directory silently became global: %v", err) + } + named.End(NotExecuted) +} + +func TestOpaqueUnsupportedProtocolPreservesLiveAndDeadRecords(t *testing.T) { + for _, scenario := range []string{"old_version", "future_version", "other_effect", "no_paths", "unknown_field"} { + t.Run(scenario, func(t *testing.T) { + c := testCoordinator(t, t.TempDir(), t.TempDir()) + r := record{Version: 3, ID: "opaque", Order: 1, Active: true, Effect: OpaqueWrite, + Workspace: c.root, Paths: []string{c.root}} + switch scenario { + case "old_version": + r.Version = 1 + case "future_version": + r.Version = 4 + case "other_effect": + r.Effect = NamedPaths + case "no_paths": + r.Paths = nil + } + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + if scenario == "unknown_field" { + raw = append(raw[:len(raw)-1], []byte(`,"future":true}`)...) + } + if err := os.WriteFile(c.recordPath(r.ID), raw, 0600); err != nil { + t.Fatal(err) + } + release, err := filelock.TryAcquire(c.livePath(r.ID)) + if err != nil { + t.Fatal(err) + } + defer release() + for _, phase := range []string{"live", "dead"} { + if phase == "dead" { + release() + } + lease, err := c.TryAcquire(t.Context(), intent(filepath.Join(c.root, "new"))) + if lease != nil { + lease.End(NotExecuted) + } + if lease != nil || !errors.Is(err, ErrProtocol) { + t.Errorf("%s invalid record admitted mutation: %v", phase, err) + } + actual, err := os.ReadFile(c.recordPath(r.ID)) + if err != nil || string(actual) != string(raw) { + t.Errorf("%s invalid record changed: %q %v", phase, actual, err) + } + } + }) + } +} diff --git a/internal/state/mutation/opaque_test.go b/internal/state/mutation/opaque_test.go new file mode 100644 index 0000000000..61dc4d9539 --- /dev/null +++ b/internal/state/mutation/opaque_test.go @@ -0,0 +1,236 @@ +package mutation + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func opaqueIntent() Intent { return Intent{Effect: Effect("opaque_write")} } + +func TestOpaqueWriteExcludesExternalHardlinkInBothOrders(t *testing.T) { + for _, opaqueFirst := range []bool{true, false} { + t.Run(map[bool]string{true: "opaque_first", false: "named_first"}[opaqueFirst], func(t *testing.T) { + root, extra, state := t.TempDir(), t.TempDir(), t.TempDir() + source, alias := filepath.Join(root, "source"), filepath.Join(extra, "alias") + if err := os.WriteFile(source, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Link(source, alias); err != nil { + t.Fatal(err) + } + a, b := testCoordinator(t, root, state), testCoordinator(t, extra, state) + first, second := opaqueIntent(), intent(alias) + if !opaqueFirst { + a, b, first, second = b, a, second, first + } + held, err := a.Acquire(t.Context(), first) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + blocked, err := b.TryAcquire(t.Context(), second) + if blocked != nil { + blocked.End(NotExecuted) + } + if !errors.Is(err, ErrBusy) { + t.Fatalf("cross-root alias admitted: %v", err) + } + held.End(NotExecuted) + next, err := b.TryAcquire(t.Context(), second) + if err != nil { + t.Fatalf("ended opaque owner still blocks: %v", err) + } + defer next.End(Committed) + if err := os.WriteFile(alias, []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(source) + if err != nil || string(actual) != "changed" { + t.Fatalf("alias control did not mutate source: %q %v", actual, err) + } + }) + } +} + +func TestOpaqueWriteDoesNotSerializeNamedOrModelWork(t *testing.T) { + root, extra, state := t.TempDir(), t.TempDir(), t.TempDir() + a, b := testCoordinator(t, root, state), testCoordinator(t, extra, state) + held, err := a.Acquire(t.Context(), intent("first")) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + disjoint, err := b.TryAcquire(t.Context(), intent("second")) + if err != nil { + t.Fatal(err) + } + disjoint.End(NotExecuted) + held.End(NotExecuted) + opaque, err := a.Acquire(t.Context(), opaqueIntent()) + if err != nil { + t.Fatal(err) + } + defer opaque.End(NotExecuted) + model, err := b.TryAcquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("model")}}) + if err != nil { + t.Fatalf("opaque file owner consumed unrelated model capacity: %v", err) + } + model.End(NotExecuted) + for _, effect := range []Effect{ReadOnly, External} { + lease, err := b.TryAcquire(t.Context(), Intent{Effect: effect}) + if err != nil { + t.Fatalf("non-file admission: %v", err) + } + lease.End(NotExecuted) + } +} + +func TestOpaqueWriteNestedCoverageAndContinuation(t *testing.T) { + c := testCoordinator(t, t.TempDir(), t.TempDir()) + for _, parent := range []Intent{{Effect: Workspace}, intent(c.root), opaqueIntent()} { + held, err := c.Acquire(t.Context(), parent) + if err != nil { + t.Fatal(err) + } + ctx := held.Context(t.Context()) + child, err := c.TryAcquire(ctx, opaqueIntent()) + if parent.Effect != opaqueIntent().Effect { + if child != nil || !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("directory scope upgraded to opaque: %v", err) + } + } else { + if err != nil { + t.Fatal(err) + } + child.End(NotExecuted) + } + if _, err := c.TryAcquire(ctx, intent(filepath.Join(t.TempDir(), "outside"))); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("global exclusion expanded nested authority: %v", err) + } + held.End(NotExecuted) + } + held, err := c.Acquire(t.Context(), opaqueIntent()) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + continuation, err := held.PrepareContinuation() + if err != nil { + t.Fatal(err) + } + held.End(NotExecuted) + next, err := continuation.Acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + defer next.End(NotExecuted) + other := testCoordinator(t, t.TempDir(), c.compatDir) + if lease, err := other.TryAcquire(t.Context(), intent("outside")); !errors.Is(err, ErrBusy) { + if lease != nil { + lease.End(NotExecuted) + } + t.Fatalf("continuation lost opaque exclusion: %v", err) + } +} + +func TestOpaqueWriteQueueCancellationDoesNotConsumeModelCapacity(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + held, err := testCoordinator(t, root, state).Acquire(t.Context(), intent("held")) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + notices := make(chan Notice, 8) + c, err := New(t.TempDir(), state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + done := make(chan error, 1) + joined := make(chan struct{}) + t.Cleanup(func() { cancel(); <-joined }) + request := opaqueIntent() + request.Capacities = []Capacity{capacity("model")} + go func() { + defer close(joined) + lease, err := c.Acquire(ctx, request) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + awaitCapacityNotice(t, notices, "waiting") + model, err := c.TryAcquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("model")}}) + if err != nil { + t.Fatalf("file waiter reserved model capacity: %v", err) + } + model.End(NotExecuted) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if n := awaitCapacityNotice(t, notices, "abandoned"); n.Reason != "cancelled" { + t.Fatal(n) + } + next, err := c.TryAcquire(t.Context(), intent("disjoint")) + if err != nil { + t.Fatalf("cancelled opaque waiter retained global exclusion: %v", err) + } + next.End(NotExecuted) +} + +func TestOpaqueWriteQueuePreventsNamedBypass(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + held, err := c.Acquire(t.Context(), intent("held")) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + notices := make(chan Notice, 8) + waiter, err := New(t.TempDir(), state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + done := make(chan error, 1) + joined, release := make(chan struct{}), make(chan struct{}) + t.Cleanup(func() { cancel(); <-joined }) + go func() { + defer close(joined) + lease, err := waiter.Acquire(ctx, opaqueIntent()) + done <- err + if lease != nil { + defer lease.End(NotExecuted) + select { + case <-release: + case <-ctx.Done(): + } + } + }() + awaitCapacityNotice(t, notices, "waiting") + if lease, err := c.TryAcquire(t.Context(), intent("unrelated")); !errors.Is(err, ErrBusy) { + if lease != nil { + lease.End(NotExecuted) + } + t.Fatalf("later named request bypassed opaque queue head: %v", err) + } + held.End(NotExecuted) + if err := <-done; err != nil { + t.Fatal(err) + } + awaitCapacityNotice(t, notices, "acquired") + close(release) + <-joined + next, err := c.TryAcquire(t.Context(), intent("unrelated")) + if err != nil { + t.Fatalf("released opaque queue owner still blocks: %v", err) + } + next.End(NotExecuted) +} diff --git a/internal/state/mutation/registry.go b/internal/state/mutation/registry.go index ba0a445852..ec8e3076ba 100644 --- a/internal/state/mutation/registry.go +++ b/internal/state/mutation/registry.go @@ -209,7 +209,7 @@ func (c *Coordinator) recordsBlocker(r record, records []record) (*blocker, erro func pathBlocker(r record, records []record) *record { for _, other := range records { - if other.ID != r.ID && (other.Active || other.Order < r.Order) && overlaps(r.Paths, other.Paths) { + if other.ID != r.ID && (other.Active || other.Order < r.Order) && recordsOverlap(r, other) { return &other } } diff --git a/internal/state/mutation/resources.go b/internal/state/mutation/resources.go index 60ac3873ba..9028a2f0b5 100644 --- a/internal/state/mutation/resources.go +++ b/internal/state/mutation/resources.go @@ -25,7 +25,7 @@ func (c *Coordinator) resources(i Intent) ([]string, error) { return nil, ErrInvalidIntent } return nil, nil - case Workspace: + case Workspace, OpaqueWrite: paths = append([]string{c.root}, paths...) paths = append(paths, c.workspaceResources...) case NamedPaths, GitMetadata: diff --git a/internal/state/mutation/types.go b/internal/state/mutation/types.go index a808aea5b7..4ac0bb83f9 100644 --- a/internal/state/mutation/types.go +++ b/internal/state/mutation/types.go @@ -14,6 +14,7 @@ const ( GitMetadata Effect = "git_metadata" External Effect = "external" CapacityOnly Effect = "capacity" + OpaqueWrite Effect = "opaque_write" ) var ( diff --git a/release-notes/studio/unreleased/14025-opaque-write-admission.md b/release-notes/studio/unreleased/14025-opaque-write-admission.md new file mode 100644 index 0000000000..993d5c4ddc --- /dev/null +++ b/release-notes/studio/unreleased/14025-opaque-write-admission.md @@ -0,0 +1,7 @@ +## 变更 + +- 为范围未知的写入增加显式事务原语,跨根路径及硬链接别名保守互斥,同时保留 + 不相交具名写入与模型额度独立;尚未接入运行时或改变默认策略。Refs #14025 +- Add explicit opaque-write admission across cooperating registry users while + preserving disjoint named writes and independent model capacity. Runtime and + default activation remain separate. Refs #14025 From 5d484c4fe22f32086bdc541349c57667fa067a6a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:00:34 +0800 Subject: [PATCH 05/37] feat(proc): expose tracked root wait completion Problem: A foreground command can return bounded cancellation while cmd.Wait is still blocked, so call return cannot establish resource-lifetime completion. Root cause: The tracked wait owner exposes its logical result and cleanup diagnostics but no separate observation of the still-running root Wait. Fix: Register a stable root-wait channel before the waiter starts and close it after Wait returns, before result publication. Expose it with the explicit RootWaitCompleted name. Preserve existing execution, cancellation, retry and error behavior. No production lease consumer or whole-scope claim is added. Verification: Deterministic cancellation/retry/publication tests, portable Go child execution and a real blocked-output process/lease regression pass. Root Wait remains distinct from descendant or external-scope completion; nil means no registered observation. Full local/static and platform evidence is recorded in the PR without inheriting previous heads' native results. --- docs/PROCESS_ROOT_WAIT_SECURITY.md | 118 +++++++++++ internal/base/proc/root_wait.go | 18 ++ internal/base/proc/root_wait_test.go | 194 ++++++++++++++++++ internal/base/proc/run.go | 19 +- internal/state/mutation/root_wait_test.go | 126 ++++++++++++ .../unreleased/14025-root-wait-observation.md | 6 + 6 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 docs/PROCESS_ROOT_WAIT_SECURITY.md create mode 100644 internal/base/proc/root_wait.go create mode 100644 internal/base/proc/root_wait_test.go create mode 100644 internal/state/mutation/root_wait_test.go create mode 100644 release-notes/studio/unreleased/14025-root-wait-observation.md diff --git a/docs/PROCESS_ROOT_WAIT_SECURITY.md b/docs/PROCESS_ROOT_WAIT_SECURITY.md new file mode 100644 index 0000000000..70d72032a2 --- /dev/null +++ b/docs/PROCESS_ROOT_WAIT_SECURITY.md @@ -0,0 +1,118 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-25 +--- + +# Root wait observation / 根进程等待观察 + +## Cause and scope / 根因与范围 + +Tracked foreground execution deliberately returns after a cancellation grace +period even when `cmd.Wait` has not returned. That bounded result is useful for +responsive cancellation; it cannot also prove physical completion. + +`TrackedCommand.RootWaitCompleted` exposes only the registered root wait. Its +channel closes after the wait function returns and before its result is sent. +Cancellation and cleanup-retry expiry do not close it. No production lease, +tool, hook or resource consumer uses the new method in this unit. + +前台取消采用有界返回以保持响应,但返回时 `cmd.Wait` 仍可能未完成。本次保留 +这一行为,新增明确命名的根等待完成信号,不把调用返回等同于物理执行结束。 + +## Source, sink and authority / 输入、副作用与权威 + +- The existing tracked-command owner registers a channel before starting its + wait goroutine. Access to channel identity uses its existing mutex. +- The goroutine closes the channel only after `wait()` returns. It closes + before publishing the buffered result, so receiving the result cannot race + ahead of notification. A blocked result receiver cannot delay notification. +- The buffered result remains available to the existing foreground or retry + consumer. The signal adds no goroutine, timer, command, path or OS handle. +- Root completion preserves success, exit errors and cancellation error + identities. Existing launch, tracking, kill and retry policies stay unchanged. +- Nil handles and handles without a registered wait return nil. Nil is not + completion evidence or permission to release a resource. + +信号身份沿现有 mutex 发布,在等待返回后、结果发送前关闭。既有取消、重试、 +错误分类和进程操作保持原样,没有新命令、路径、句柄或后台 goroutine。 + +## Meaning of each boundary / 各边界的含义 + +| Observation | What it proves | What it does not prove | +| --- | --- | --- | +| Foreground cancellation returns | Grace policy returned control | Root wait or descendants ended | +| Retry window expires | Automatic retry budget ended | Root wait completed | +| RootWaitCompleted closes | Registered root `cmd.Wait` returned | Descendants or external scope ended | +| Method returns nil | No wait was registered on this handle | Command never ran or all effects ended | +| ProcessState exists | os/exec observed a root process result | Output copies or descendants ended | + +A root wait may return an error and still be completed. Conversely a failed +launch can return a tracked handle without registering a wait. In particular, +Windows launch preparation may start and reap a suspended child before returning +an error. The nil signal makes no stronger claim about that path. + +完成可以伴随错误;失败启动也可能产生未登记等待的句柄。Windows 的启动准备 +可能先创建再回收挂起子进程,因此 nil 不能被解释为“肯定未执行”。 + +## Verification / 验证 + +- Deterministic synthetic-time tests keep Wait blocked through cancellation, + grace expiry and retry expiry, then release it and observe the same channel. +- An unbuffered receiver control proves completion notification precedes result + publication without depending on goroutine scheduling speed. +- Actual portable Go child commands cover success, nonzero exit, missing + executable, cancellation before launch and unchanged untracked behavior. +- An actual child writes into a deliberately blocked output sink. Bounded + cancellation returns while `cmd.Wait` remains pending. A test-only consumer + binds the signal to an existing mutation lease and ends it logically. +- Conflicting admission remains busy, disjoint admission proceeds, and releasing + the output sink lets the lease close. Repeating old cleanup does not release + the next transaction. All fixture processes and waits are joined. + +真实子进程测试固定阻塞输出处理,因此不靠时间快慢猜测 Wait 是否完成。将信号 +接到租约的调用仅存在于回归测试,不代表运行时已启用写事务。 + +## Compatibility / 兼容性 + +| Contract | Before | After | +| --- | --- | --- | +| Tracked foreground return/error | Existing bounded cancellation | Unchanged | +| Untracked RunCommand | Nil tracked handle | Unchanged | +| Root wait observation method | Absent | Additive, local-only method | +| Diagnostics/wire/session records | Existing fields | Unchanged | +| OS tracking and sandbox policy | Existing backend behavior | Unchanged | + +There is no new persisted field, migration, model-visible schema or prompt +prefix. This method has no preceding API implementation to compare. Deliberately +weakened completion ordering or cancellation closure controls validate the new +contract; they are not claims that an older API already promised this signal. + +本次没有持久化或 wire 变更。新增接口此前不存在,故意削弱信号规则的对照用于 +验证新测试,不能冒称旧版已经承诺这个接口却违反它。 + +## Limits and next integration / 边界与后续接入 + +Do not use this signal alone to release a lease for an unresolved shell or hook. +The command may spawn descendants, detach, delegate work to a service or return +`exec.ErrWaitDelay`. Root Wait is not a process-scope termination protocol. + +Full write-lifetime integration must separately establish the backend's supported +scope, distinguish unknown termination, retain ownership when completion cannot +be proved, and provide attributable diagnostics and recovery. Closing every +channel is not a valid substitute for proving completion. + +Until those boundaries are implemented, this unit does not activate shared +transactions, settle unknown scopes, recover process crashes or guarantee every +descendant is gone. Existing cancellation cleanup remains best-effort where the +backend is best-effort. Native platform evidence is recorded separately. + +不能仅凭根等待完成就释放未知 shell/hook 写入租约。后代、脱离进程、服务委托和 +输出超时都有独立边界。无法证明范围终止时必须保留占用并给出诊断及恢复路径, +不能为了关闭 channel 而伪造完成。本次不启用共享事务,也不完成未知范围恢复。 + +This is the author's security assessment, not independent review or disposition +of the original large PR's CodeQL findings. + +本说明为作者评估,不替代独立安全审查或原大 PR 的告警逐项判断。 diff --git a/internal/base/proc/root_wait.go b/internal/base/proc/root_wait.go new file mode 100644 index 0000000000..f21a50a65f --- /dev/null +++ b/internal/base/proc/root_wait.go @@ -0,0 +1,18 @@ +package proc + +func publishRootWait(done chan struct{}, results chan<- error, err error) { + close(done) + results <- err +} + +// RootWaitCompleted closes after the registered cmd.Wait returns, before its +// result is received. Nil means no registered wait, not completed execution. +// It does not prove that descendants or an external execution scope have ended. +func (p *TrackedCommand) RootWaitCompleted() <-chan struct{} { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + return p.rootWait +} diff --git a/internal/base/proc/root_wait_test.go b/internal/base/proc/root_wait_test.go new file mode 100644 index 0000000000..d55e517b3d --- /dev/null +++ b/internal/base/proc/root_wait_test.go @@ -0,0 +1,194 @@ +package proc + +import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "testing" + "testing/synctest" + "time" +) + +func assertRootWaitClosed(t *testing.T, done <-chan struct{}) { + t.Helper() + if done == nil { + t.Fatal("registered root wait has no completion signal") + } + select { + case <-done: + default: + t.Fatal("root wait completed without closing its signal") + } +} + +func TestRootWaitCompletionPrecedesResultAndRetainsWaitError(t *testing.T) { + for _, errWait := range []error{nil, errors.New("root wait failed")} { + tracked := &TrackedCommand{} + err := waitForTrackedCommand(t.Context(), tracked, func() error { return errWait }, time.Second, time.Second, time.Second) + if !errors.Is(err, errWait) { + t.Fatalf("wait error changed: %v", err) + } + done := tracked.RootWaitCompleted() + assertRootWaitClosed(t, done) + if tracked.RootWaitCompleted() != done { + t.Fatal("completion identity changed") + } + } +} + +func TestRootWaitPublicationDoesNotDependOnReceiver(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + done, results := make(chan struct{}), make(chan error) + expected := errors.New("root wait result") + go publishRootWait(done, results, expected) + defer func() { + if err := <-results; !errors.Is(err, expected) { + t.Fatalf("result changed: %v", err) + } + }() + synctest.Wait() + assertRootWaitClosed(t, done) + }) +} + +func TestRootWaitCompletionSurvivesCancellationAndRetryExpiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + tracked := &TrackedCommand{} + started, unblock := make(chan struct{}), make(chan struct{}) + release := sync.OnceFunc(func() { close(unblock) }) + defer release() + returned := make(chan error, 1) + go func() { + returned <- waitForTrackedCommand(ctx, tracked, func() error { + close(started) + <-unblock + return nil + }, time.Second, time.Second, 3*time.Second) + }() + <-started + done := tracked.RootWaitCompleted() + if done == nil { + t.Fatal("wait signal was not published before entering Wait") + } + cancel() + if err := <-returned; !errors.Is(err, context.Canceled) { + t.Fatalf("logical cancellation result changed: %v", err) + } + synctest.Wait() + select { + case <-done: + t.Fatal("bounded cancellation was mistaken for root completion") + default: + } + time.Sleep(4 * time.Second) + synctest.Wait() + select { + case <-done: + t.Fatal("retry expiry was mistaken for root completion") + default: + } + release() + synctest.Wait() + assertRootWaitClosed(t, done) + if done != tracked.RootWaitCompleted() { + t.Fatal("late completion replaced the registered signal") + } + }) +} + +func TestRootWaitCompletionDoesNotChangeCancelledWaitErrors(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + tracked := &TrackedCommand{} + entered, release := make(chan struct{}), make(chan struct{}) + errWait := errors.New("root exited after cancellation") + returned := make(chan error, 1) + go func() { + returned <- waitForTrackedCommand(ctx, tracked, func() error { + close(entered) + <-release + return errWait + }, time.Hour, time.Hour, time.Hour) + }() + <-entered + cancel() + synctest.Wait() + close(release) + if err := <-returned; !errors.Is(err, context.Canceled) || !errors.Is(err, errWait) { + t.Fatalf("cancel/wait identities changed: %v", err) + } + assertRootWaitClosed(t, tracked.RootWaitCompleted()) + }) +} + +func TestRootWaitUnregisteredIsNotCompleted(t *testing.T) { + for _, tracked := range []*TrackedCommand{nil, {}} { + if done := tracked.RootWaitCompleted(); done != nil { + t.Fatal("unregistered wait fabricated completion") + } + } +} + +func TestRootWaitCommandHelper(t *testing.T) { + if mode := os.Getenv("REASONIX_ROOT_WAIT_HELPER"); mode != "" { + if mode == "error" { + os.Exit(7) + } + os.Exit(0) + } +} + +func TestRootWaitCompletionOnRealCommands(t *testing.T) { + for _, mode := range []string{"success", "error"} { + t.Run(mode, func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestRootWaitCommandHelper$") + cmd.Env = append(os.Environ(), "REASONIX_ROOT_WAIT_HELPER="+mode) + tracked, err := RunCommand(t.Context(), cmd, RunOptions{Track: true}) + if mode == "success" && err != nil { + t.Fatal(err) + } + if mode == "error" { + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 7 { + t.Fatalf("exit error changed: %v", err) + } + } + if cmd.ProcessState == nil { + t.Fatal("real command was not reaped") + } + assertRootWaitClosed(t, tracked.RootWaitCompleted()) + }) + } +} + +func TestRootWaitDoesNotRegisterBeforeStartOrForUntrackedCommands(t *testing.T) { + for _, scenario := range []string{"cancelled_before_start", "missing_executable", "untracked"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRootWaitCommandHelper$") + cmd.Env = append(os.Environ(), "REASONIX_ROOT_WAIT_HELPER=success") + if scenario == "cancelled_before_start" { + cancel() + } + if scenario == "missing_executable" { + cmd.Path = t.TempDir() + string(os.PathSeparator) + "missing" + } + tracked, err := RunCommand(ctx, cmd, RunOptions{Track: scenario != "untracked"}) + if scenario != "untracked" && err == nil { + t.Fatal("unexpected successful launch") + } + if scenario == "untracked" && (tracked != nil || err != nil) { + t.Fatalf("untracked contract changed: %v", err) + } + if tracked.RootWaitCompleted() != nil { + t.Fatal("unregistered wait has completion proof") + } + }) + } +} diff --git a/internal/base/proc/run.go b/internal/base/proc/run.go index 228afbdb15..5e16630d47 100644 --- a/internal/base/proc/run.go +++ b/internal/base/proc/run.go @@ -46,11 +46,12 @@ type RunDiagnostics struct { type TrackedCommand struct { cmd *exec.Cmd - mu sync.Mutex - job *TrackedJob - tree *TreeTracker - killed bool - diag RunDiagnostics + mu sync.Mutex + job *TrackedJob + tree *TreeTracker + killed bool + diag RunDiagnostics + rootWait <-chan struct{} } // RunCommand starts cmd and waits for it. When tracking is enabled, cancellation @@ -138,7 +139,13 @@ func normalizeRunOptions(opts RunOptions) RunOptions { func waitForTrackedCommand(ctx context.Context, tracked *TrackedCommand, wait func() error, grace, retryEvery, retryFor time.Duration) error { waitCh := make(chan error, 1) - go func() { waitCh <- wait() }() + done := make(chan struct{}) + tracked.mu.Lock() + tracked.rootWait = done + tracked.mu.Unlock() + go func() { + publishRootWait(done, waitCh, wait()) + }() select { case err := <-waitCh: diff --git a/internal/state/mutation/root_wait_test.go b/internal/state/mutation/root_wait_test.go new file mode 100644 index 0000000000..849d444578 --- /dev/null +++ b/internal/state/mutation/root_wait_test.go @@ -0,0 +1,126 @@ +package mutation + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "sync" + "testing" + "time" + + "reasonix/internal/base/proc" +) + +func TestMutationRootWaitHelper(t *testing.T) { + if os.Getenv("REASONIX_MUTATION_ROOT_WAIT_HELPER") == "1" { + fmt.Print("root-output") + os.Exit(0) + } +} + +type rootWaitOutput struct { + once sync.Once + entered chan struct{} + release chan struct{} +} + +func (w *rootWaitOutput) Write(p []byte) (int, error) { + w.once.Do(func() { close(w.entered) }) + <-w.release + return len(p), nil +} + +func TestRootWaitRetainsLeaseAfterRealBoundedCancellation(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + released := make(chan struct{}, 1) + c, err := New(root, state, func(n Notice) { + if n.State == "released" && n.CallID == "root-wait" { + released <- struct{}{} + } + }) + if err != nil { + t.Fatal(err) + } + request := intent("held") + request.CallID = "root-wait" + lease, err := c.Acquire(t.Context(), request) + if err != nil { + t.Fatal(err) + } + defer lease.End(NotExecuted) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + output := &rootWaitOutput{entered: make(chan struct{}), release: make(chan struct{})} + unblock := sync.OnceFunc(func() { close(output.release) }) + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestMutationRootWaitHelper$") + cmd.Env = append(os.Environ(), "REASONIX_MUTATION_ROOT_WAIT_HELPER=1") + cmd.Stdout = output + type result struct { + tracked *proc.TrackedCommand + err error + } + returned, joined := make(chan result, 1), make(chan struct{}) + go func() { + defer close(joined) + tracked, err := proc.RunCommand(ctx, cmd, proc.RunOptions{Track: true, + CancelWaitGrace: time.Millisecond, CancelRetryInterval: time.Millisecond, CancelRetryFor: time.Millisecond}) + returned <- result{tracked, err} + }() + t.Cleanup(func() { unblock(); cancel(); <-joined }) + select { + case <-output.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + cancel() + res := <-returned + if !errors.Is(res.err, context.Canceled) { + t.Fatalf("bounded cancellation result: %v", res.err) + } + done := res.tracked.RootWaitCompleted() + if done == nil { + t.Fatal("real tracked wait was not registered") + } + t.Cleanup(func() { unblock(); <-done }) + select { + case <-done: + t.Fatal("blocked output copy was not a pending cmd.Wait") + default: + } + if err := lease.HoldUntil(done); err != nil { + t.Fatal(err) + } + lease.End(Unknown) + other := testCoordinator(t, root, state) + if next, err := other.TryAcquire(t.Context(), request); !errors.Is(err, ErrBusy) { + if next != nil { + next.End(NotExecuted) + } + t.Fatalf("logical return released physical ownership: %v", err) + } + disjoint, err := other.TryAcquire(t.Context(), intent("disjoint")) + if err != nil { + t.Fatalf("unrelated file blocked: %v", err) + } + disjoint.End(NotExecuted) + unblock() + select { + case <-released: + case <-time.After(10 * time.Second): + t.Fatal("completed root wait did not release the lease") + } + next, err := other.TryAcquire(t.Context(), request) + if err != nil { + t.Fatal(err) + } + defer next.End(NotExecuted) + lease.End(NotExecuted) + if probe, err := c.TryAcquire(t.Context(), request); !errors.Is(err, ErrBusy) { + if probe != nil { + probe.End(NotExecuted) + } + t.Fatalf("old cleanup released a new transaction: %v", err) + } +} diff --git a/release-notes/studio/unreleased/14025-root-wait-observation.md b/release-notes/studio/unreleased/14025-root-wait-observation.md new file mode 100644 index 0000000000..e3beacdb16 --- /dev/null +++ b/release-notes/studio/unreleased/14025-root-wait-observation.md @@ -0,0 +1,6 @@ +## 变更 + +- 为受跟踪命令增加根进程 Wait 完成信号,明确区分取消返回与等待真正完成; + 不改变取消策略,不把该信号当成整个进程范围结束的证明。Refs #14025 +- Add a root-wait completion observation for tracked commands, separate from + bounded cancellation return; it does not prove descendant completion. Refs #14025 From 728a0ed165ba5e8577614a325d63fe9ca407a908 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:50:29 +0800 Subject: [PATCH 06/37] feat(isolation): prepare Store-owned candidates from one baseline Problem: candidate groups need a common baseline while preserving the result Store's source, managed-root and identity ownership. Root cause: ordinary Begin captures afresh, while arbitrary snapshot/path adoption would bypass the owning Store and its existing settlement contract. Fix: add an opaque process-local Preparation, recheck source/workspace/Git directory identities, and create independent results from the captured snapshot. Extract the existing Begin bodies into private shared helpers. No best-of, runtime, default or persisted-format activation is included. Verification: complete 239-package root inventory, full isolation race, independent builtin/boot command, vet, full lint and make check pass with the 2664 baseline unchanged. Compiling unsafe controls distinguish baseline refresh and replaced-directory admission; the preceding Store implementation passes the new preparation tests. Linked-source overlay passes; Windows and Linux amd64 compile only. Native/hosted validation and independent review remain separate gates. --- docs/ISOLATION_PREPARATION_SECURITY.md | 121 ++++++++ internal/runtime/isolation/preparation.go | 133 ++++++++ .../runtime/isolation/preparation_test.go | 283 ++++++++++++++++++ internal/runtime/isolation/store.go | 13 +- .../unreleased/14025-isolation-preparation.md | 4 + 5 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 docs/ISOLATION_PREPARATION_SECURITY.md create mode 100644 internal/runtime/isolation/preparation.go create mode 100644 internal/runtime/isolation/preparation_test.go create mode 100644 release-notes/studio/unreleased/14025-isolation-preparation.md diff --git a/docs/ISOLATION_PREPARATION_SECURITY.md b/docs/ISOLATION_PREPARATION_SECURITY.md new file mode 100644 index 0000000000..044bb5f4b8 --- /dev/null +++ b/docs/ISOLATION_PREPARATION_SECURITY.md @@ -0,0 +1,121 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-26 +--- + +# Store-owned candidate preparation / 结果存储拥有的候选准备 + +## Scope / 范围 + +`Store.Prepare` captures one Git snapshot. Its process-local `Preparation` +creates independent candidates from that snapshot through `Begin`. The source, +workspace, managed root and result IDs come from the Store and its existing +creation owner; callers do not pass an arbitrary snapshot or candidate path. + +Existing `Store.Begin` still captures a fresh snapshot per call. Its snapshot +and candidate creation bodies share private helpers with the new entry point. +No runtime caller uses the new preparation API in this change. + +宿主可先捕获一次 Git 快照,再从同一个结果 Store 创建多个独立候选。调用者不能向 +准备对象传入任意快照或候选路径。原 `Store.Begin` 仍逐次捕获新快照,仅提取了共享 +私有实现;本次没有运行时接线或默认策略变更。 + +## Input, authority and effects / 输入、权威与副作用 + +- The host constructs a Store with its selected Git repository, workspace and + managed root. `Prepare` takes only a context. The returned object's fields + are private; nil and zero preparations refuse creation. +- Preparation checks that the Store workspace is the captured source's actual + directory or selected subdirectory using the existing rooted identity helper. + Mismatched host configuration is refused before creating a candidate. +- It records filesystem identities for source, selected workspace, Git common + and Git administrative directories. `Begin` rechecks them before creating a + candidate; matching path spelling alone does not accept replaced directories. +- Existing Git repository/backlink validation still runs in candidate creation. + Context cancellation is checked before capture and before candidate creation. +- A snapshot operation writes dangling Git objects. Candidate creation writes + a managed worktree and its Git administration. This is not a pure read or a + filesystem transaction, although it does not apply work into the source. +- Each candidate gets its own existing result ID and worktree. There is no + group-wide abort, automatic Finish or publication of running results. + +源目录、所选工作区域与 Git 元数据目录会核对实际目录身份,创建候选前再次检查。 +相同路径下替换目录不能仅凭名称继续使用准备对象;既有 Git 回链校验仍执行。 + +捕获会写 Git 对象,创建会写托管工作树与管理信息,不是纯读取或文件系统事务。 +每个候选拥有独立 ID;本次不自动终结整批候选,也不发布运行中身份。 + +## Compatibility / 兼容性 + +| Surface | Behaviour | +| --- | --- | +| Existing `Store.Begin` | Fresh capture per call; existing arguments and error policy | +| Prepared candidates | One captured Git commit/tree, distinct IDs and worktrees | +| Finished `entry.json` | Existing encoding, readers, apply/discard and discovery | +| Failed inspection | Existing inspection-v1 owner and fence; no new state | +| A preparation during restart | No persisted representation; not discoverable | +| Returned `Entry` | Existing trusted-host mutable type; no new unforgeability claim | + +No persisted version or field, wire payload, permission, tool definition or +provider-visible prefix changes. Old readers handle finished candidates as +ordinary results. An un-Finished candidate retains the existing crash window; +this API does not establish durable Begin or running-result recovery. + +已有完成记录格式、检查失败记录、恢复读取器和 apply/discard 接口不变。准备对象仅 +存在于进程内,重启后不能重建它。未 Finish 的候选仍有原有崩溃窗口,不是持久化 +Begin、运行中恢复或新的权限协议。 + +## Evidence / 验证 + +Tests create two candidates after the source changes and assert their original +captured Git baseline. An ordinary Begin beside them sees the new source. +Concurrent creation yields separate IDs and candidate state; a write in one +candidate reaches neither its sibling nor the source. + +Selected-subdirectory identity, mismatched Store configuration, nil/zero +objects, cancellation and real directory replacement are covered. Finished +prepared results are discovered by a reconstructed Store, refuse an ordinary +stale apply and remain available for existing discard. + +Unsafe compiled controls can deliberately refresh the baseline or ignore +recorded directory identity. These test the new API's promises; the previous +release has no preparation API, so they are not preceding-product defects. + +测试覆盖源变化后的同快照候选、原 Begin 的新快照、并发独立性、子目录、错配配置、 +取消和真实目录替换。准备出的完成记录可由重建 Store 发现,普通变化导致的过期应用 +仍拒绝,随后可用既有 discard 处理。故意削弱新接口的编译对照不冒充旧版缺陷复现。 + +## Limits and next owner / 边界与下一层所有者 + +A captured Git tree is not a complete immutable filesystem view. Existing stat +cache, hidden edit, ignored-file, filter, symlink and native metadata boundaries +remain; [snapshot content work](https://github.com/esengine/DeepSeek-Reasonix/pull/14371) +is a separate pending review. + +Stable Git identities do not prove identical +native bytes across changed checkout configuration. + +Directory identity checks are observations before Git operations, not held +authority over every subsequent access. They do not fence an arbitrary +external writer or establish sandbox/private-worktree isolation. The Store's +managed directory remains trusted under its existing creation contract. + +The snapshot's dangling objects are not pinned against Git garbage collection. +Creation/partial cleanup errors retain existing semantics; this change does not +repair the separate ignored-cleanup-error, retention or settlement-journal +gaps. Capturing once does not implement budgets, CAS or a short transaction. + +Best-of has not been connected. Its runner panic still needs a separate +goroutine boundary and an uninspected-result owner before uncertain work can +be kept out of judging and automatic cleanup. This preparation API alone +neither catches that panic nor makes a result durable before execution. + +Git 快照不等于完整冻结的文件系统;隐藏编辑、过滤器、忽略文件和原生元数据等边界 +仍在,快照内容修复另行待审。目录身份只是 Git 操作前的观察,不会约束任意外部 +写入者,也不构成沙箱证明。托管目录仍需可信。 + +本次不固定 Git 对象的 GC 寿命,不修复既有部分创建清理、回收或结算日志缺口, +不建立额度、CAS 或短事务。best-of 尚未接入,其 goroutine panic 与未检查结果保留 +仍是下一层工作。本文是作者安全评估,不代替独立审查。 diff --git a/internal/runtime/isolation/preparation.go b/internal/runtime/isolation/preparation.go new file mode 100644 index 0000000000..6d80a653b2 --- /dev/null +++ b/internal/runtime/isolation/preparation.go @@ -0,0 +1,133 @@ +package isolation + +import ( + "context" + "os" + "path/filepath" + + "reasonix/internal/contract/tool" + "reasonix/internal/platform/worktree" +) + +// Preparation binds candidate creation to one Store and captured Git snapshot. +// It is process-local and does not publish or retain a running result. +type Preparation struct { + store *Store + snap worktree.Snapshot + identity preparationIdentity +} + +type preparationIdentity struct{ source, workspace, common, git os.FileInfo } + +// Prepare captures one Git snapshot for subsequent candidates from this Store. +// It does not take arbitrary snapshot, source or candidate-path inputs. +func (s *Store) Prepare(ctx context.Context) (*Preparation, error) { + if s == nil { + return nil, errUnavailable + } + if err := ctx.Err(); err != nil { + return nil, err + } + snap, err := s.captureSnapshot(ctx) + if err != nil { + return nil, err + } + identity, err := preparationWorkspace(s, snap) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return &Preparation{store: s, snap: snap, identity: identity}, nil +} + +// Begin creates a distinct result candidate from the captured snapshot. +// The Store owns its source workspace, managed root and result identity. +func (p *Preparation) Begin(ctx context.Context) (*Entry, error) { + if p == nil || p.store == nil { + return nil, errUnavailable + } + if err := ctx.Err(); err != nil { + return nil, err + } + current, err := preparationWorkspace(p.store, p.snap) + if err != nil { + return nil, err + } + if !samePreparationIdentity(p.identity, current) { + return nil, errUnavailable + } + if err := ctx.Err(); err != nil { + return nil, err + } + return p.store.beginSnapshot(ctx, p.store.workspace, p.snap) +} + +func preparationWorkspace(store *Store, snap worktree.Snapshot) (preparationIdentity, error) { + source, err := openRecordDirectory(snap.RepoRoot) + if err != nil { + return preparationIdentity{}, errUnavailable + } + defer source.root.Close() + prefix := filepath.FromSlash(snap.Prefix) + if prefix == "" { + prefix = "." + } + if !filepath.IsLocal(prefix) || !source.same(store.workspace, prefix) { + return preparationIdentity{}, tool.Refusal{Code: CodeUnavailable, Message: "the Store workspace no longer matches its prepared source"} + } + common, err := openRecordDirectory(snap.Repo.CommonDir) + if err != nil { + return preparationIdentity{}, errUnavailable + } + defer common.root.Close() + gitDir, ok := common.local(snap.Repo.GitDir) + if !ok { + return preparationIdentity{}, errUnavailable + } + var identity preparationIdentity + for _, item := range []struct { + root *os.Root + path string + info *os.FileInfo + }{{source.root, ".", &identity.source}, {source.root, prefix, &identity.workspace}, + {common.root, ".", &identity.common}, {common.root, gitDir, &identity.git}} { + info, err := item.root.Stat(item.path) + if err != nil || !info.IsDir() { + return preparationIdentity{}, errUnavailable + } + *item.info = info + } + return identity, nil +} + +func samePreparationIdentity(a, b preparationIdentity) bool { + for _, pair := range [][2]os.FileInfo{{a.source, b.source}, {a.workspace, b.workspace}, {a.common, b.common}, {a.git, b.git}} { + if pair[0] == nil || pair[1] == nil || !os.SameFile(pair[0], pair[1]) { + return false + } + } + return true +} + +func (s *Store) captureSnapshot(ctx context.Context) (worktree.Snapshot, error) { + snap, err := worktree.TakeSnapshot(ctx, s.repo) + if err != nil { + return worktree.Snapshot{}, tool.Refusal{Code: CodeNotGit, Message: "worktree isolation needs a Git workspace with a commit: " + err.Error()} + } + return snap, nil +} + +func (s *Store) beginSnapshot(ctx context.Context, workspaceRoot string, snap worktree.Snapshot) (*Entry, error) { + cand, err := worktree.CreateCandidate(ctx, snap, s.managedRoot) + if err != nil { + return nil, err + } + id, err := newID() + if err != nil { + _ = worktree.RemoveCandidate(context.WithoutCancel(ctx), snap, cand) + return nil, err + } + return &Entry{ID: id, Workspace: workspaceRoot, Snap: snap, Cand: cand}, nil +} diff --git a/internal/runtime/isolation/preparation_test.go b/internal/runtime/isolation/preparation_test.go new file mode 100644 index 0000000000..b238e99fc9 --- /dev/null +++ b/internal/runtime/isolation/preparation_test.go @@ -0,0 +1,283 @@ +package isolation + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "reasonix/internal/contract/tool" + "reasonix/internal/platform/gitcmd" +) + +func TestPreparationUsesOneCapturedSnapshotAcrossCandidates(t *testing.T) { + root, managed := gitRepo(t), t.TempDir() + store := NewStore(managed, root, opened(t, root)) + prepared, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + write(t, root, "a.txt", "source moved\n") + var entries []*Entry + t.Cleanup(func() { + for _, e := range entries { + if err := store.Abort(context.Background(), e); err != nil { + t.Error(err) + } + } + }) + for range 2 { + e, err := prepared.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + entries = append(entries, e) + if got := read(t, e.Cand.WorkspaceRoot, "a.txt"); got != "one\n" { + t.Errorf("preparation refreshed the source: %q", got) + } + if e.Workspace != root || filepath.Dir(filepath.Dir(e.Cand.WorktreeRoot)) != managed { + t.Errorf("candidate escaped its Store: %+v", e) + } + } + if entries[0].ID == entries[1].ID || entries[0].Cand.WorktreeRoot == entries[1].Cand.WorktreeRoot { + t.Fatal("preparation reused a result identity or candidate") + } + if entries[0].Snap.Commit != entries[1].Snap.Commit || entries[0].Snap.Tree != entries[1].Snap.Tree { + t.Fatal("candidate baselines differ") + } + legacy, err := store.Begin(t.Context(), root) + if err != nil { + t.Fatal(err) + } + entries = append(entries, legacy) + if got := read(t, legacy.Cand.WorkspaceRoot, "a.txt"); got != "source moved\n" { + t.Errorf("ordinary Begin did not capture fresh source: %q", got) + } + if legacy.Snap.Commit == entries[0].Snap.Commit || legacy.Snap.Tree == entries[0].Snap.Tree { + t.Fatal("ordinary Begin reused an older preparation") + } + if len(store.Pending()) != 0 { + t.Fatal("preparation published unfinished results") + } +} + +func TestPreparationResultsUseExistingSettlementAndRecovery(t *testing.T) { + root, managed := gitRepo(t), t.TempDir() + store := NewStore(managed, root, opened(t, root)) + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + e, err := p.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + write(t, e.Cand.WorkspaceRoot, "a.txt", "candidate\n") + if kept, err := store.Finish(t.Context(), e); err != nil || !kept { + t.Fatalf("Finish: kept=%v error=%v", kept, err) + } + rebuilt := NewStore(managed, root, opened(t, root)) + if ids := rebuilt.Pending(); len(ids) != 1 || ids[0] != e.ID { + t.Fatalf("ordinary pending reader did not find prepared result: %v", ids) + } + write(t, root, "a.txt", "user edit\n") + var refusal tool.Refusal + if _, err := rebuilt.Apply(t.Context(), e.ID); !errors.As(err, &refusal) || refusal.Code != CodeApplyConflict { + t.Fatalf("stale prepared result applied: %v", err) + } + if got := read(t, root, "a.txt"); got != "user edit\n" { + t.Fatalf("stale apply changed user bytes: %q", got) + } + if ids := rebuilt.Pending(); len(ids) != 1 || ids[0] != e.ID { + t.Fatalf("conflicted result disappeared: %v", ids) + } + if _, err := rebuilt.Discard(t.Context(), e.ID); err != nil { + t.Fatal(err) + } + if len(rebuilt.Pending()) != 0 { + t.Fatal("discard left a prepared result published") + } +} + +func TestPreparationRejectsUnavailableAndCancelledCalls(t *testing.T) { + var store *Store + if p, err := store.Prepare(t.Context()); p != nil || !errors.Is(err, errUnavailable) { + t.Fatalf("nil Store preparation: %v %v", p, err) + } + for _, p := range []*Preparation{nil, {}} { + if e, err := p.Begin(t.Context()); e != nil || !errors.Is(err, errUnavailable) { + t.Fatalf("unbound preparation: %v %v", e, err) + } + } + root, managed := gitRepo(t), t.TempDir() + store = NewStore(managed, root, opened(t, root)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if p, err := store.Prepare(ctx); p != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled capture: %v %v", p, err) + } + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + if e, err := p.Begin(ctx); e != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled candidate creation: %v %v", e, err) + } + files, err := os.ReadDir(managed) + if err != nil || len(files) != 0 { + t.Fatalf("cancelled preparation created candidates: %v %v", files, err) + } + if e, err := p.Begin(t.Context()); err != nil { + t.Fatal(err) + } else if err := store.Abort(t.Context(), e); err != nil { + t.Fatal(err) + } +} + +func TestPreparationPreservesSubdirectoryIdentity(t *testing.T) { + root := gitRepo(t) + write(t, root, "nested/a.txt", "nested\n") + for _, args := range [][]string{{"add", "nested/a.txt"}, {"commit", "-q", "-m", "nested fixture"}} { + cmd := gitcmd.Command(t.Context(), root, args...) + cmd.Env = append(cmd.Env, "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + sub := filepath.Join(root, "nested") + repo, err := gitcmd.Open(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + store := NewStore(t.TempDir(), sub, repo) + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + e, err := p.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := store.Abort(context.Background(), e); err != nil { + t.Error(err) + } + }() + if e.Workspace != sub || e.Snap.Prefix != "nested" || filepath.Base(e.Cand.WorkspaceRoot) != "nested" { + t.Fatalf("lost source subdirectory: %+v", e) + } + if got := read(t, e.Cand.WorkspaceRoot, "a.txt"); got != "nested\n" { + t.Fatalf("subdirectory candidate bytes: %q", got) + } +} + +func TestPreparationRejectsMismatchedStoreWorkspace(t *testing.T) { + root, other, managed := gitRepo(t), gitRepo(t), t.TempDir() + store := NewStore(managed, other, opened(t, root)) + if p, err := store.Prepare(t.Context()); p != nil || err == nil { + t.Fatalf("mismatched source/workspace preparation: %v %v", p, err) + } + if files, err := os.ReadDir(managed); err != nil || len(files) != 0 { + t.Fatalf("mismatched source created candidates: %v %v", files, err) + } +} + +func TestPreparationRejectsReplacedSourceDirectories(t *testing.T) { + for _, what := range []string{"workspace", "git_metadata"} { + t.Run(what, func(t *testing.T) { + root := gitRepo(t) + store := NewStore(t.TempDir(), root, opened(t, root)) + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + path := root + if what == "git_metadata" { + path = filepath.Join(root, ".git") + } + parked := path + ".parked" + if err := os.Rename(path, parked); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.RemoveAll(path); err != nil { + t.Error(err) + } + if err := os.Rename(parked, path); err != nil { + t.Error(err) + } + }() + if err := os.CopyFS(path, os.DirFS(parked)); err != nil { + t.Fatal(err) + } + e, err := p.Begin(t.Context()) + if e != nil { + if cleanupErr := store.Abort(t.Context(), e); cleanupErr != nil { + t.Error(cleanupErr) + } + } + if e != nil || !errors.Is(err, errUnavailable) { + t.Fatalf("preparation accepted replaced %s: %v %v", what, e, err) + } + }) + } +} + +func TestPreparationRejectsReplacedGitIdentity(t *testing.T) { + root := gitRepo(t) + store := NewStore(t.TempDir(), root, opened(t, root)) + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + gitDir := filepath.Join(root, ".git") + if err := os.Rename(gitDir, gitDir+".parked"); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Rename(gitDir+".parked", gitDir); err != nil { + t.Error(err) + } + }() + if e, err := p.Begin(t.Context()); e != nil || err == nil { + t.Fatalf("preparation accepted missing source identity: %v %v", e, err) + } +} + +func TestPreparationConcurrentCandidatesDoNotShareResultState(t *testing.T) { + root := gitRepo(t) + store := NewStore(t.TempDir(), root, opened(t, root)) + p, err := store.Prepare(t.Context()) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + entries, errs := make([]*Entry, 2), make([]error, 2) + for i := range entries { + wg.Go(func() { entries[i], errs[i] = p.Begin(t.Context()) }) + } + wg.Wait() + for i, e := range entries { + if errs[i] != nil { + t.Errorf("candidate %d: %v", i, errs[i]) + continue + } + defer func() { + if err := store.Abort(context.Background(), e); err != nil { + t.Error(err) + } + }() + } + if t.Failed() { + return + } + write(t, entries[0].Cand.WorkspaceRoot, "a.txt", "private\n") + if read(t, entries[1].Cand.WorkspaceRoot, "a.txt") != "one\n" || read(t, root, "a.txt") != "one\n" { + t.Fatal("candidate write leaked to sibling or source") + } + if entries[0].ID == entries[1].ID || entries[0].Cand.WorktreeRoot == entries[1].Cand.WorktreeRoot { + t.Fatal("concurrent creation reused result state") + } +} diff --git a/internal/runtime/isolation/store.go b/internal/runtime/isolation/store.go index 6ab97edf25..e2869daf90 100644 --- a/internal/runtime/isolation/store.go +++ b/internal/runtime/isolation/store.go @@ -65,20 +65,11 @@ func (s *Store) Begin(ctx context.Context, workspaceRoot string) (*Entry, error) if s == nil { return nil, errUnavailable } - snap, err := worktree.TakeSnapshot(ctx, s.repo) - if err != nil { - return nil, tool.Refusal{Code: CodeNotGit, Message: "worktree isolation needs a Git workspace with a commit: " + err.Error()} - } - cand, err := worktree.CreateCandidate(ctx, snap, s.managedRoot) + snap, err := s.captureSnapshot(ctx) if err != nil { return nil, err } - id, err := newID() - if err != nil { - _ = worktree.RemoveCandidate(context.WithoutCancel(ctx), snap, cand) - return nil, err - } - return &Entry{ID: id, Workspace: workspaceRoot, Snap: snap, Cand: cand}, nil + return s.beginSnapshot(ctx, workspaceRoot, snap) } // Finish records what the run changed. Inspection errors retain its identity diff --git a/release-notes/studio/unreleased/14025-isolation-preparation.md b/release-notes/studio/unreleased/14025-isolation-preparation.md new file mode 100644 index 0000000000..2cedafe784 --- /dev/null +++ b/release-notes/studio/unreleased/14025-isolation-preparation.md @@ -0,0 +1,4 @@ +## 变更 + +- Add Store-owned preparation for distinct result candidates sharing one captured Git baseline; existing calls still capture freshly. No runtime policy is activated. (#14025) +- 新增由结果 Store 拥有的候选准备接口,同批候选共享捕获的 Git 基线但保留独立身份;原调用仍捕获新快照,本次不启用新运行策略。 (#14025) From e062fcad00ee6a1b0956f04f47277ecedfe93ade Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Mon, 26 Oct 2026 07:05:53 -0700 Subject: [PATCH 07/37] feat(studio): the vision row lists only models declared to read images A model chosen earlier stays visible, marked as not declared. Refs #14418. --- desktop/frontend-next/src/i18n/en_settings.ts | 1 + desktop/frontend-next/src/ui/ModelUsage.test.tsx | 16 ++++++++++++++++ desktop/frontend-next/src/ui/ModelUsage.tsx | 10 ++++++---- .../unreleased/14418-vision-role-only-readers.md | 2 ++ 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 release-notes/studio/unreleased/14418-vision-role-only-readers.md diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 97d8ac60aa..cf90334ee0 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -127,6 +127,7 @@ export const EN_SETTINGS: Record = { "项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.", "尚无可用搜索模型": "No search models available", "{model}(不可用)": "{model} (unavailable)", + "{model}(未声明读图)": "{model} (not declared to read images)", "模型用途": "Model uses", "用途": "Use", "使用模型": "Model", diff --git a/desktop/frontend-next/src/ui/ModelUsage.test.tsx b/desktop/frontend-next/src/ui/ModelUsage.test.tsx index 8d5ca5d6e3..78f43fff60 100644 --- a/desktop/frontend-next/src/ui/ModelUsage.test.tsx +++ b/desktop/frontend-next/src/ui/ModelUsage.test.tsx @@ -87,3 +87,19 @@ it("keeps naming effort fixed off even when the model exposes effort levels", as await userEvent.selectOptions(screen.getByRole("combobox", { name: "子代理的思考强度" }), "max"); expect(onRole).toHaveBeenCalledWith("subagent", models[1].ref, "max"); }); + +it("offers the vision row only models that read images, and no other row loses its models", () => { + draw(); + const vision = [...(screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement).options].map((o) => o.textContent); + expect(vision).toEqual(["跟随主模型", "deepseek-flash · 读图"]); + const subagent = [...(screen.getByRole("combobox", { name: "子代理" }) as HTMLSelectElement).options].map((o) => o.textContent); + expect(subagent).toContain("deepseek-pro"); +}); + +it("keeps a vision model chosen earlier visible when it is not declared to read images", () => { + const roles: RoleAssignments = { ...ROLES, roles: { ...ROLES.roles, vision: "deepseek/deepseek-pro" } }; + render(); + const select = screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement; + expect(select.value).toBe("deepseek/deepseek-pro"); + expect(select.selectedOptions[0]?.textContent).toBe("deepseek/deepseek-pro(未声明读图)"); +}); diff --git a/desktop/frontend-next/src/ui/ModelUsage.tsx b/desktop/frontend-next/src/ui/ModelUsage.tsx index 494d9a2a04..39a4e69bad 100644 --- a/desktop/frontend-next/src/ui/ModelUsage.tsx +++ b/desktop/frontend-next/src/ui/ModelUsage.tsx @@ -5,7 +5,7 @@ import { activeKind, contextLabel, groupVendors, type Vendor } from "./Models"; import { orderAccounts, useProviderOrder } from "../state/providerorder"; type RoleKey = keyof RoleAssignments["roles"]; -type Answers = "chat" | "decision" | "search"; +type Answers = "chat" | "decision" | "search" | "vision"; // Decision is the one job that cannot follow the main model: it asks a question // set, which a chat model has no answer for, so its row offers the decision @@ -20,7 +20,8 @@ const ROLES: [RoleKey, string, string, Answers][] = [ ]; const answersOf = (m: ModelEntry): Answers => (m.answers === "decision" ? "decision" : "chat"); -const answersMatch = (m: ModelEntry, answers: Answers) => answers === "search" ? m.webSearch === true : answersOf(m) === answers; +const answersMatch = (m: ModelEntry, answers: Answers) => + answers === "search" ? m.webSearch === true : answers === "vision" ? m.vision === true && answersOf(m) === "chat" : answersOf(m) === answers; // Only what the config or the catalog declares: an inferred "reads images" sends // the user to a request the endpoint rejects. @@ -90,13 +91,13 @@ export function ModelUsage({ models, roles, main, busy, protocol, effort = "auto const set = roles.roles[key]; const roleModel = byRef(set || (key === "title" || answers === "decision" ? "" : main)); const roleEffort = roles.efforts?.[key] || "auto"; - const offered = models.filter((m) => answersOf(m) === answers); + const offered = models.filter((m) => answersMatch(m, answers)); const none = answers === "decision" && offered.length === 0; return (
{t(name)}{t(tag)} - onRole(key, ref, "auto")} /> @@ -217,6 +218,7 @@ function Choices({ onChange={(e) => onPick(e.target.value)}> {empty !== undefined && } {answers === "search" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && } + {answers === "vision" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && } {groups.map(({ v, rows }) => ( {rows.map((m) => ( diff --git a/release-notes/studio/unreleased/14418-vision-role-only-readers.md b/release-notes/studio/unreleased/14418-vision-role-only-readers.md new file mode 100644 index 0000000000..38bd3435bf --- /dev/null +++ b/release-notes/studio/unreleased/14418-vision-role-only-readers.md @@ -0,0 +1,2 @@ +- 改进:设置里「模型偏好」的「看图」下拉框现在只列出已声明支持读图的模型;已选的旧模型仍会显示,并标明"未声明读图"。关联 #14418。 +- Changed: the vision row in Model preferences now lists only models declared to read images; a model chosen earlier stays visible, marked as not declared. Refs #14418. From 51f2b1b5ecdc44d77ec4f6471f5032ecad3335c5 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Mon, 26 Oct 2026 07:46:44 -0700 Subject: [PATCH 08/37] feat(providers): a provider can hide the prefix all its models share The prefix is read off the entry's own model list up to its last slash and left out of how models are drawn, in every listing; ids and refs never change, an alias wins, and models that would read the same keep their prefix. Fixes #14573. --- desktop/frontend-next/src/i18n/en_settings.ts | 3 + desktop/frontend-next/src/port/model.ts | 2 + desktop/frontend-next/src/port/provider.ts | 6 ++ desktop/frontend-next/src/ui/Composer.tsx | 1 + desktop/frontend-next/src/ui/EditConn.tsx | 20 +++++- desktop/frontend-next/src/ui/ModelChoice.tsx | 6 +- desktop/frontend-next/src/ui/ModelLimits.tsx | 6 +- .../frontend-next/src/ui/ModelPrefix.test.tsx | 67 +++++++++++++++++++ desktop/frontend-next/src/ui/ModelPrices.tsx | 5 +- desktop/frontend-next/src/ui/ModelUsage.tsx | 3 +- desktop/frontend-next/src/ui/modelmenu.ts | 2 +- internal/assembly/boot/provider_config.go | 1 + internal/contract/config/model_prefix.go | 56 ++++++++++++++++ internal/contract/config/model_prefix_test.go | 62 +++++++++++++++++ internal/contract/config/provider_entry.go | 37 +++++----- internal/contract/config/render_providers.go | 2 + internal/frontend/serve/modelcaps.go | 1 + internal/frontend/serve/provider_edit.go | 5 ++ .../frontend/serve/provider_model_short.go | 18 +++++ .../serve/provider_model_short_test.go | 57 ++++++++++++++++ internal/frontend/serve/providers.go | 6 ++ internal/frontend/serve/settings.go | 5 +- .../unreleased/14573-hide-model-prefix.md | 2 + 23 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 desktop/frontend-next/src/ui/ModelPrefix.test.tsx create mode 100644 internal/contract/config/model_prefix.go create mode 100644 internal/contract/config/model_prefix_test.go create mode 100644 internal/frontend/serve/provider_model_short.go create mode 100644 internal/frontend/serve/provider_model_short_test.go create mode 100644 release-notes/studio/unreleased/14573-hide-model-prefix.md diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 068bf0211b..089c9aa470 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -1352,6 +1352,9 @@ export const EN_SETTINGS: Record = { "图标栏": "Icon rail", "导航入口": "Navigation entries", "放大设置窗口": "Enlarge the settings window", + "隐藏共同前缀": "Hide the shared prefix", + "{name} 的模型名前缀": "Model name prefix of {name}", + "路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.", "缩小设置窗口": "Shrink the settings window", "图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.", "还原排列": "Reset arrangement", diff --git a/desktop/frontend-next/src/port/model.ts b/desktop/frontend-next/src/port/model.ts index b762822684..cbaa113f11 100644 --- a/desktop/frontend-next/src/port/model.ts +++ b/desktop/frontend-next/src/port/model.ts @@ -56,6 +56,8 @@ export interface ModelEntry { modelAlias?: string; // The vendor slug of the preset this provider came from; absent when none declared it. mark?: string; + // The model as it reads with its entry's shared prefix left out; absent draws it as it is. + modelShort?: string; // The service's own vendor slug; differs from mark on a relay, whose models are others'. serviceMark?: string; kind?: string; diff --git a/desktop/frontend-next/src/port/provider.ts b/desktop/frontend-next/src/port/provider.ts index 5177cfdaad..93a77f92a9 100644 --- a/desktop/frontend-next/src/port/provider.ts +++ b/desktop/frontend-next/src/port/provider.ts @@ -12,6 +12,10 @@ export interface ProviderEntry { baseUrl: string; models: string[]; modelAliases?: Record; + // Whether the prefix every model shares is left out of how they are drawn, and each + // model as it then reads; a model drawn as it is has no entry. + hideModelPrefix?: boolean; + modelShorts?: Record; default: string; hasKey: boolean; keyEnv?: string; @@ -249,6 +253,8 @@ export interface ProviderEdit { name: string; // Omitted preserves aliases; blank or missing values reset listed models. modelAliases?: Record; + // Omitted leaves the choice alone. + hideModelPrefix?: boolean; // Omitted leaves the wire format alone. kind?: string; baseUrl?: string; diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index 901c4034fd..747d3e3e15 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -391,6 +391,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus, const forcedThinking = forcesThinkingFor(models, status?.modelRef); const modelLb = status?.modelRef === UNCONFIGURED_REF ? t("未连接") : models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim() + || models.find((m) => m.ref === status?.modelRef)?.modelShort || (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—"); // A model switch rebuilds the runtime kernel-side; other controls here may // land at once. Each click needs its own pending state: greying the whole diff --git a/desktop/frontend-next/src/ui/EditConn.tsx b/desktop/frontend-next/src/ui/EditConn.tsx index ec3e4d7b75..232cf46bf8 100644 --- a/desktop/frontend-next/src/ui/EditConn.tsx +++ b/desktop/frontend-next/src/ui/EditConn.tsx @@ -46,6 +46,7 @@ export function EditConn({ const [models, setModels] = useState(seededModels); const [picked, setPicked] = useState(entry.models); const [aliases, setAliases] = useState>(entry.modelAliases ?? {}); + const [hidePrefix, setHidePrefix] = useState(entry.hideModelPrefix === true); const [vision, setVision] = useState(seededVision); const [visionSettable, setVisionSettable] = useState( entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined, @@ -228,6 +229,7 @@ export function EditConn({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), models: picked, + ...(hidePrefix !== (entry.hideModelPrefix === true) && { hideModelPrefix: hidePrefix }), modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])), default: picked.includes(def) ? def : picked[0] ?? "", vision: vision.filter((m) => picked.includes(m)), @@ -356,13 +358,26 @@ export function EditConn({
{(picked.length > 1 || picked.some((m) => entry.modelLimits?.[m])) && ( - + )} - {picked.length > 0 && 0 && void Promise.resolve(port.openExternal(url)).catch(() => {})} />} {picked.length > 0 &&
} +
+ {t("隐藏共同前缀")} +
+ {[true, false].map((on) => ( + + ))} +
+ {t("路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。")} +
+
{t("模型")} @@ -403,6 +418,7 @@ export function EditConn({ visionLocked={visionLocked} onCheck={checkModel} aliases={aliases} + labels={entry.modelShorts} onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))} checkDisabled={busy !== "" || testing} onToggle={(m) => toggle(picked, setPicked, m)} diff --git a/desktop/frontend-next/src/ui/ModelChoice.tsx b/desktop/frontend-next/src/ui/ModelChoice.tsx index 4a13a30462..5587678571 100644 --- a/desktop/frontend-next/src/ui/ModelChoice.tsx +++ b/desktop/frontend-next/src/ui/ModelChoice.tsx @@ -51,6 +51,8 @@ interface Props { // be a dead control. One endpoint can serve both kinds, so it is not a // property of the connection. visionLocked?: (m: string) => boolean; + // How a model reads with its shared prefix left out; the id stays in the tooltip. + labels?: Record; facts?: Record; onCheck?: (m: string) => void; checkDisabled?: boolean; @@ -64,7 +66,7 @@ interface Props { } export function ModelChoice({ - models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, facts, onCheck, checkDisabled, onToggle, onAdd, + models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, labels, facts, onCheck, checkDisabled, onToggle, onAdd, onFetch, fetching, fetchDisabled, fetchFail, }: Props) { const [q, setQ] = useState(""); @@ -132,7 +134,7 @@ export function ModelChoice({ - {m} + {labels?.[m] ?? m} {onAlias && , } export function ModelLimits({ - models, value, onChange, inherited, + models, value, onChange, inherited, labels, }: { models: string[]; + // How a model reads when its shared prefix is left out; absent draws it as it is. + labels?: Record; value: Record; onChange: (next: Record) => void; inherited: (model: string) => ModelLimit | null | undefined; @@ -53,7 +55,7 @@ export function ModelLimits({ const own = cur.win !== "" || cur.out !== ""; return (
  • - {shortId(model)} + {labels?.[model] ?? shortId(model)} { + const items = modelMenu(routed); + const rows = items.filter((it) => !it.header && !it.plain); + expect(rows.map((r) => r.label)).toEqual(["deepseek-v4.1-flash", "My GLM"]); + expect(rows.map((r) => r.value)).toEqual(["mag/group/deepseek-v4.1-flash", "mag/group/glm-5"]); + expect(rows[0].desc).toBeUndefined(); + expect(rows[1].desc).toBe("group/glm-5"); + render(); + const options = [...(screen.getByRole("combobox", { name: "默认模型" }) as HTMLSelectElement).options].map((o) => o.textContent); + expect(options).toEqual(["deepseek-v4.1-flash", "My GLM"]); +}); + +it("shows the short name on a limits row and keeps the full id in the tooltip", () => { + render( null} + labels={{ "group/deepseek-v4.1-flash": "deepseek-v4.1-flash" }} />); + const row = document.querySelector('[data-model="group/deepseek-v4.1-flash"]')!; + const name = within(row as HTMLElement).getByText(/deepseek/); + expect(name.textContent).toBe("deepseek-v4.1-flash"); + expect(name.getAttribute("title")).toBe("group/deepseek-v4.1-flash"); + expect(document.querySelector('[data-model="other"] .me-name')?.textContent).toBe("other"); +}); + +function drawEdit(entry: Partial = {}) { + const editProvider = vi.fn().mockResolvedValue(undefined); + const full = { + name: "mag", kind: "openai", baseUrl: "https://m.example/v1", + models: ["group/a", "group/b"], default: "group/a", hasKey: true, inUse: false, preset: false, ...entry, + } as ProviderEntry; + const port = { editProvider, protocols: vi.fn(async () => []), checkProvider: vi.fn(async () => ({ ok: true, models: [] })) } as unknown as Port; + render( {}} onDone={vi.fn()} onRevert={() => {}} />); + return { editProvider, group: screen.getByRole("group", { name: "mag 的模型名前缀" }) }; +} + +it("starts off, turns on with a click, and sends the choice with the model ids untouched", async () => { + const { editProvider, group } = drawEdit(); + expect(within(group).getByRole("button", { name: "关" }).getAttribute("aria-pressed")).toBe("true"); + await userEvent.click(within(group).getByRole("button", { name: "开" })); + await userEvent.click(screen.getByRole("button", { name: /保存/ })); + expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: true, models: ["group/a", "group/b"] }); +}); + +it("shows a stored choice as on and sends it off when turned off", async () => { + const { editProvider, group } = drawEdit({ hideModelPrefix: true, modelShorts: { "group/a": "a", "group/b": "b" } }); + expect(within(group).getByRole("button", { name: "开" }).getAttribute("aria-pressed")).toBe("true"); + await userEvent.click(within(group).getByRole("button", { name: "关" })); + await userEvent.click(screen.getByRole("button", { name: /保存/ })); + expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: false }); +}); diff --git a/desktop/frontend-next/src/ui/ModelPrices.tsx b/desktop/frontend-next/src/ui/ModelPrices.tsx index b512efdc9b..d18b0c908a 100644 --- a/desktop/frontend-next/src/ui/ModelPrices.tsx +++ b/desktop/frontend-next/src/ui/ModelPrices.tsx @@ -89,9 +89,10 @@ export function pricesToSend(picked: string[], text: Record, } export function ModelPrices({ - models, value, onChange, official = false, stored = {}, onOpenSource, + models, value, onChange, official = false, stored = {}, onOpenSource, labels, }: { models: string[]; + labels?: Record; value: Record; onChange: (next: Record) => void; official?: boolean; @@ -114,7 +115,7 @@ export function ModelPrices({ const freeCached = foreign && cur.hit !== "" && Number(cur.hit) === 0 && Number(cur.inp) > 0; return (
  • - {shortId(model)} + {labels?.[model] ?? shortId(model)} m.modelAlias?.trim() || m.modelShort || m.model; const answersOf = (m: ModelEntry): Answers => (m.answers === "decision" ? "decision" : "chat"); const answersMatch = (m: ModelEntry, answers: Answers) => answers === "search" ? m.webSearch === true : answersOf(m) === answers; @@ -220,7 +221,7 @@ function Choices({ {groups.map(({ v, rows }) => ( {rows.map((m) => ( - + ))} ))} diff --git a/desktop/frontend-next/src/ui/modelmenu.ts b/desktop/frontend-next/src/ui/modelmenu.ts index 32781ef330..272794e740 100644 --- a/desktop/frontend-next/src/ui/modelmenu.ts +++ b/desktop/frontend-next/src/ui/modelmenu.ts @@ -20,7 +20,7 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c for (const m of a.byKind[kind]) { out.push({ value: m.ref, - label: m.modelAlias?.trim() || m.model, + label: m.modelAlias?.trim() || m.modelShort || m.model, desc: m.modelAlias?.trim() ? m.model : undefined, meta: t(KIND_LABEL[kind] ?? kind), badge: m.vision ? t("读图") : undefined, diff --git a/internal/assembly/boot/provider_config.go b/internal/assembly/boot/provider_config.go index 352a9d08b9..677640b6c9 100644 --- a/internal/assembly/boot/provider_config.go +++ b/internal/assembly/boot/provider_config.go @@ -97,6 +97,7 @@ func providerFingerprint(e *config.ProviderEntry, proxy netclient.ProxySpec) str entry := *e entry.Effort = "" entry.ModelAliases = nil + entry.HideModelPrefix = false payload := struct { Entry config.ProviderEntry APIKey string diff --git a/internal/contract/config/model_prefix.go b/internal/contract/config/model_prefix.go new file mode 100644 index 0000000000..ad764f5c62 --- /dev/null +++ b/internal/contract/config/model_prefix.go @@ -0,0 +1,56 @@ +package config + +import "strings" + +func renderHideModelPrefix(b *strings.Builder, p ProviderEntry) { + if p.HideModelPrefix { + b.WriteString("hide_model_prefix = true # display only: leave out the prefix every model of this entry shares\n") + } +} + +// ModelShort is how model is drawn with the entry's shared prefix left out, or +// "" when it is drawn as it is: the switch is off, no prefix is shared by every +// listed model, or leaving it out would make the model read the same as another +// (its own alias included). The id and ref never change. +func (e *ProviderEntry) ModelShort(model string) string { + if !e.HideModelPrefix { + return "" + } + prefix := sharedModelPrefix(e.ModelList()) + if prefix == "" || !strings.HasPrefix(model, prefix) || model == prefix { + return "" + } + short := strings.TrimPrefix(model, prefix) + for _, other := range e.ModelList() { + if other == model { + continue + } + if e.ModelAlias(other) == short || (e.ModelAlias(other) == "" && strings.TrimPrefix(other, prefix) == short) { + return "" + } + } + return short +} + +// sharedModelPrefix is the longest leading path every model has in common, up to +// and including its last "/": what a router puts in front of every model it +// offers. It is read off the entry's own list, so no group name is ever written +// into the code. +func sharedModelPrefix(models []string) string { + if len(models) == 0 { + return "" + } + common := models[0] + for _, m := range models[1:] { + n := 0 + for n < len(common) && n < len(m) && common[n] == m[n] { + n++ + } + common = common[:n] + } + at := strings.LastIndex(common, "/") + if at < 0 { + return "" + } + return common[:at+1] +} diff --git a/internal/contract/config/model_prefix_test.go b/internal/contract/config/model_prefix_test.go new file mode 100644 index 0000000000..17071777fb --- /dev/null +++ b/internal/contract/config/model_prefix_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func labelOf(e ProviderEntry, model string) string { return e.ModelShort(model) } + +func TestHideModelPrefixDropsTheSharedGroupFromTheDisplayOnly(t *testing.T) { + e := ProviderEntry{Models: []string{"group/deepseek-v4.1-flash", "group/glm-5-flash"}, HideModelPrefix: true} + if got := labelOf(e, "group/deepseek-v4.1-flash"); got != "deepseek-v4.1-flash" { + t.Fatalf("short = %q", got) + } + if got := labelOf(e, "group/glm-5-flash"); got != "glm-5-flash" { + t.Fatalf("short = %q", got) + } + if e.Models[0] != "group/deepseek-v4.1-flash" { + t.Fatal("the model id the wire carries was changed") + } +} + +func TestHideModelPrefixIsOffUnlessAskedAndNeedsAPrefixEveryModelShares(t *testing.T) { + off := ProviderEntry{Models: []string{"group/a", "group/b"}} + if got := labelOf(off, "group/a"); got != "" { + t.Fatalf("off by default, short = %q", got) + } + mixed := ProviderEntry{Models: []string{"group/a", "other/b", "plain"}, HideModelPrefix: true} + for _, m := range mixed.Models { + if got := labelOf(mixed, m); got != "" { + t.Fatalf("no prefix is shared, yet %q reads %q", m, got) + } + } + nested := ProviderEntry{Models: []string{"team/group/a", "team/group/b"}, HideModelPrefix: true} + if got := labelOf(nested, "team/group/a"); got != "a" { + t.Fatalf("nested short = %q, want the whole shared path dropped", got) + } +} + +func TestHideModelPrefixKeepsTheModelsThatWouldBeIndistinguishable(t *testing.T) { + e := ProviderEntry{Models: []string{"group/x", "group/y"}, ModelAliases: map[string]string{"group/y": "x"}, HideModelPrefix: true} + if got := labelOf(e, "group/x"); got != "" { + t.Fatalf("group/x would read the same as the alias of group/y, short = %q", got) + } +} + +func TestHideModelPrefixRoundTripsThroughTheConfigFile(t *testing.T) { + cfg := &Config{Providers: []ProviderEntry{{Name: "mag", Kind: "openai", BaseURL: "https://m.example/v1", Models: []string{"group/a"}, HideModelPrefix: true}}} + out := RenderTOMLForScope(cfg, RenderScopeUser) + if !strings.Contains(out, "hide_model_prefix = true") { + t.Fatalf("not rendered:\n%s", out) + } + path := t.TempDir() + "/config.toml" + if err := os.WriteFile(path, []byte(out), 0o600); err != nil { + t.Fatal(err) + } + back, err := LoadForEditReadOnlyStrict(path) + if err != nil || len(back.Providers) != 1 || !back.Providers[0].HideModelPrefix { + t.Fatalf("reload err=%v providers=%+v", err, back.Providers) + } +} diff --git a/internal/contract/config/provider_entry.go b/internal/contract/config/provider_entry.go index cd3d784116..8467b32308 100644 --- a/internal/contract/config/provider_entry.go +++ b/internal/contract/config/provider_entry.go @@ -26,23 +26,26 @@ func UnconfiguredEntry() ProviderEntry { // token budget; the harness compacts older history as a turn's prompt approaches // it (see agent compaction). 0 disables compaction for the instance. type ProviderEntry struct { - Name string `toml:"name"` - DisplayName string `toml:"display_name"` // what the UI calls this entry; Name stays the identity refs point at - Kind string `toml:"kind"` - BaseURL string `toml:"base_url"` - ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics - RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI - Model string `toml:"model"` // a single model (back-compat) - Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) - ModelAliases map[string]string `toml:"model_aliases"` // display only; keys are exact model IDs within this provider - ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup - Default string `toml:"default"` // default model when Models is set (else Models[0]) - APIKeyEnv string `toml:"api_key_env"` - PresetID string `toml:"preset_id"` // curated preset provenance: UI dedupe, and the vetting a wire contract reads. - PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations. - Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env. - ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways. - AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key. + Name string `toml:"name"` + DisplayName string `toml:"display_name"` // what the UI calls this entry; Name stays the identity refs point at + Kind string `toml:"kind"` + BaseURL string `toml:"base_url"` + ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics + RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI + Model string `toml:"model"` // a single model (back-compat) + Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) + ModelAliases map[string]string `toml:"model_aliases"` // display only; keys are exact model IDs within this provider + // HideModelPrefix drops the prefix every listed model shares (a router's group) + // from how the models are drawn; ids and refs never change. + HideModelPrefix bool `toml:"hide_model_prefix"` + ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup + Default string `toml:"default"` // default model when Models is set (else Models[0]) + APIKeyEnv string `toml:"api_key_env"` + PresetID string `toml:"preset_id"` // curated preset provenance: UI dedupe, and the vetting a wire contract reads. + PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations. + Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env. + ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways. + AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key. // ResponsesMode selects the Responses API context strategy. Empty preserves // vendor detection; DeepSeek is stateless while compatible endpoints may use // stateful previous_response_id continuation. diff --git a/internal/contract/config/render_providers.go b/internal/contract/config/render_providers.go index df83a0978f..c4d21a0818 100644 --- a/internal/contract/config/render_providers.go +++ b/internal/contract/config/render_providers.go @@ -36,6 +36,7 @@ func renderProviderEntryAnnotated(b *strings.Builder, p ProviderEntry) { } renderProviderIdentity(b, p.APIKeyEnv, p.DisplayName) renderModelAliases(b, p.ModelAliases) + renderHideModelPrefix(b, p) if p.PresetID != "" { fmt.Fprintf(b, "preset_id = %q # curated preset identity; settings UI uses it to avoid duplicate installs\n", p.PresetID) } @@ -161,6 +162,7 @@ func renderProviderEntryPlain(b *strings.Builder, p ProviderEntry) { } renderProviderIdentity(b, p.APIKeyEnv, p.DisplayName) renderModelAliases(b, p.ModelAliases) + renderHideModelPrefix(b, p) if p.PresetID != "" { fmt.Fprintf(b, "preset_id = %q\n", p.PresetID) } diff --git a/internal/frontend/serve/modelcaps.go b/internal/frontend/serve/modelcaps.go index 3ac94190dc..81c40d303c 100644 --- a/internal/frontend/serve/modelcaps.go +++ b/internal/frontend/serve/modelcaps.go @@ -71,6 +71,7 @@ func capabilitiesFor(cfg *config.Config, p *config.ProviderEntry, model string, return } into.ModelAlias = p.ModelAlias(model) + into.ModelShort = p.ModelShort(model) resolved, ok := cfg.ResolveModel(p.Name + "/" + model) if !ok || resolved.Name != p.Name || resolved.Model != model { into.Vendor = vendorOf(p.BaseURL) diff --git a/internal/frontend/serve/provider_edit.go b/internal/frontend/serve/provider_edit.go index 871cfd9af4..bc785d0483 100644 --- a/internal/frontend/serve/provider_edit.go +++ b/internal/frontend/serve/provider_edit.go @@ -60,6 +60,9 @@ func (s *Server) editProvider(w http.ResponseWriter, r *http.Request) { refuse(w, http.StatusBadRequest, bad.code, bad.message, bad.detail) return } + if body.HideModelPrefix != nil { + entry.HideModelPrefix = *body.HideModelPrefix + } applyVisionSelection(entry, trimmedNonEmpty(body.Vision)) if !applyNumericFields(w, entry, body.ContextWindow, body.MaxOutputTokens, body.IdleTimeoutSeconds) { @@ -152,6 +155,8 @@ type providerEditBody struct { ModelLimits *map[string]modelLimitsView `json:"modelLimits"` ModelPrices *map[string]*modelPriceView `json:"modelPrices"` ModelAliases *map[string]string `json:"modelAliases"` + // HideModelPrefix leaves the prefix every model shares out of how they are drawn. + HideModelPrefix *bool `json:"hideModelPrefix"` // Declarations a panel used to write one click at a time. Pointers, so // an unsent one leaves the stored choice alone. Whether thinking // parameters are sent is reasoningProtocol "none", not a field of its own. diff --git a/internal/frontend/serve/provider_model_short.go b/internal/frontend/serve/provider_model_short.go new file mode 100644 index 0000000000..9d7e55da70 --- /dev/null +++ b/internal/frontend/serve/provider_model_short.go @@ -0,0 +1,18 @@ +package serve + +import "reasonix/internal/contract/config" + +// modelShorts is how each listed model reads with the entry's shared prefix left +// out; a model drawn as it is has no entry. +func modelShorts(p *config.ProviderEntry) map[string]string { + var out map[string]string + for _, model := range p.ModelList() { + if short := p.ModelShort(model); short != "" { + if out == nil { + out = map[string]string{} + } + out[model] = short + } + } + return out +} diff --git a/internal/frontend/serve/provider_model_short_test.go b/internal/frontend/serve/provider_model_short_test.go new file mode 100644 index 0000000000..d0fd8e652f --- /dev/null +++ b/internal/frontend/serve/provider_model_short_test.go @@ -0,0 +1,57 @@ +package serve + +import ( + "net/http" + "testing" + + "reasonix/internal/contract/config" +) + +func TestHidingTheSharedModelPrefixIsDisplayOnlyAndReachesEveryListing(t *testing.T) { + srv := newRichProviderServer(t) + resp := postProvider(t, srv.URL, "/providers/edit", `{"name":"rich","models":["grp/alpha","grp/beta"],"default":"grp/alpha","vision":[],"hideModelPrefix":true}`) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("edit status %d", resp.StatusCode) + } + var models struct { + Models []struct { + Ref string `json:"ref"` + Model string `json:"model"` + Short string `json:"modelShort"` + } `json:"models"` + } + getJSON(t, srv.URL+"/models", &models) + if len(models.Models) != 2 { + t.Fatalf("models = %+v", models.Models) + } + for _, m := range models.Models { + want := map[string]string{"grp/alpha": "alpha", "grp/beta": "beta"}[m.Model] + if m.Short != want || m.Ref != "rich/"+m.Model { + t.Fatalf("model %+v: short want %q and the ref untouched", m, want) + } + } + var providers []struct { + Name string `json:"name"` + Hide bool `json:"hideModelPrefix"` + Shorts map[string]string `json:"modelShorts"` + } + getJSON(t, srv.URL+"/providers", &providers) + if len(providers) != 1 || !providers[0].Hide || providers[0].Shorts["grp/alpha"] != "alpha" { + t.Fatalf("providers = %+v", providers) + } + entry, _ := config.Load() + if p, ok := entry.Provider("rich"); !ok || !p.HideModelPrefix || p.Models[0] != "grp/alpha" { + t.Fatalf("stored entry = %+v", p) + } + off := postProvider(t, srv.URL, "/providers/edit", `{"name":"rich","models":["grp/alpha","grp/beta"],"default":"grp/alpha","vision":[],"hideModelPrefix":false}`) + off.Body.Close() + var after []struct { + Hide bool `json:"hideModelPrefix"` + Shorts map[string]string `json:"modelShorts"` + } + getJSON(t, srv.URL+"/providers", &after) + if len(after) != 1 || after[0].Hide || len(after[0].Shorts) != 0 { + t.Fatalf("switched off, yet %+v", after) + } +} diff --git a/internal/frontend/serve/providers.go b/internal/frontend/serve/providers.go index 9dcbc1df50..77cc88a67b 100644 --- a/internal/frontend/serve/providers.go +++ b/internal/frontend/serve/providers.go @@ -69,6 +69,10 @@ type providerView struct { BaseURL string `json:"baseUrl"` Models []string `json:"models"` ModelAliases map[string]string `json:"modelAliases,omitempty"` + // HideModelPrefix and ModelShorts say how the models are drawn once the shared + // prefix is left out; ids never change. + HideModelPrefix bool `json:"hideModelPrefix,omitempty"` + ModelShorts map[string]string `json:"modelShorts,omitempty"` // VisionModels is which of them read images, so the form shows the current // answer instead of asking the user to remember it. VisionModels []string `json:"visionModels"` @@ -176,6 +180,8 @@ func (s *Server) providers(w http.ResponseWriter, _ *http.Request) { BaseURL: p.BaseURL, Models: nonNilStrings(p.ChatModelList()), ModelAliases: p.ModelAliases, + HideModelPrefix: p.HideModelPrefix, + ModelShorts: modelShorts(p), VisionModels: nonNilStrings(visionModelsOf(cfg, p)), CanSetVision: len(settable) > 0 || config.CanConfigureVision(p), VisionSettable: nonNilStrings(settable), diff --git a/internal/frontend/serve/settings.go b/internal/frontend/serve/settings.go index dfc55f6193..715282d7b6 100644 --- a/internal/frontend/serve/settings.go +++ b/internal/frontend/serve/settings.go @@ -276,7 +276,10 @@ type modelEntry struct { DisplayName string `json:"displayName,omitempty"` Model string `json:"model"` ModelAlias string `json:"modelAlias,omitempty"` - Kind string `json:"kind,omitempty"` + // ModelShort is the model drawn with its entry's shared prefix left out, when + // that was asked for; empty draws the model as it is. + ModelShort string `json:"modelShort,omitempty"` + Kind string `json:"kind,omitempty"` // What this model produces, projected from the protocol table so a chooser // filters on a declaration rather than on a kind's spelling. Answers string `json:"answers,omitempty"` diff --git a/release-notes/studio/unreleased/14573-hide-model-prefix.md b/release-notes/studio/unreleased/14573-hide-model-prefix.md new file mode 100644 index 0000000000..88495fe2ae --- /dev/null +++ b/release-notes/studio/unreleased/14573-hide-model-prefix.md @@ -0,0 +1,2 @@ +- 新增:服务商设置里可以「隐藏共同前缀」,路由类服务给每个模型加的同一段前缀(如 group/)在各处显示时省略,只改显示。关联 #14573。 +- Added: a provider can now hide the prefix all its models share (such as group/ from a router) everywhere the model name is drawn; display only. Refs #14573. From 0aedda5da2b082997ac5bb48656604897449dbfa Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 02:36:36 +0800 Subject: [PATCH 09/37] feat(studio): expose wallpaper settings and selected preview --- desktop/frontend-next/src/i18n/en.ts | 3 - desktop/frontend-next/src/styles/app.css | 2 - .../frontend-next/src/ui/Appearance.test.tsx | 69 +++++++++++++++++++ desktop/frontend-next/src/ui/Appearance.tsx | 10 +-- .../unreleased/14470-wallpaper-settings.md | 2 + 5 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 desktop/frontend-next/src/ui/Appearance.test.tsx create mode 100644 release-notes/studio/unreleased/14470-wallpaper-settings.md diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 5f863c0b71..d532eb37f0 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -755,9 +755,6 @@ export const EN: Record = { "代码块、命令、差异与工具输出共用这一等宽字体,不跟随界面主题改变。": "Code blocks, commands, diffs and tool output share this monospace font and do not change with the interface theme.", "等宽字体": "Monospace font", - "更多个性化": "More personalization", - "已设置自定义壁纸": "Custom wallpaper set", - "壁纸与背景位置": "Wallpaper and background position", "文字粗细": "Text weight", "视觉效果": "Visual effects", "窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.", diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 18e851d127..d791af9345 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -4085,8 +4085,6 @@ .theme-more > summary b { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) } .theme-more > summary em { font: var(--w-reg) var(--fs-meta) var(--ui); font-style: normal; color: var(--faint) } .theme-more > .palettes { padding: 5px 0 15px } - .personal { margin-top: 12px } - .personal > .grp { border-top: 1px solid var(--hair); margin-top: 0 } .pal { display: flex; flex-direction: column; gap: 7px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer } .pal-art { position: relative; display: flex; aspect-ratio: 16 / 10; overflow: hidden; diff --git a/desktop/frontend-next/src/ui/Appearance.test.tsx b/desktop/frontend-next/src/ui/Appearance.test.tsx new file mode 100644 index 0000000000..c6e3471f30 --- /dev/null +++ b/desktop/frontend-next/src/ui/Appearance.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { t } from "../i18n"; +import { MockPort } from "../port/mock"; +import type { Appearance as Look } from "../port/port"; +import { Appearance } from "./Appearance"; + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} }); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +const selected: Look = { + wallpaper: { kind: "image", url: "/selected.jpg", opacity: 0.5, dim: 0.55, focusX: 0.25, focusY: 0.75 }, +}; + +function draw(initial: Look = selected) { + const port = new MockPort(); + const upload = vi.spyOn(port, "uploadWallpaper").mockResolvedValue(selected); + const clear = vi.spyOn(port, "clearWallpaper").mockResolvedValue(); + const save = vi.fn(); + function Settings() { + const [look, setLook] = useState(initial); + return {}} onContrast={() => {}} + onWeight={() => {}} reloadThemes={() => {}} look={look} onLook={(next) => { save(next); setLook(next); }} />; + } + return { ...render(), upload, clear, save }; +} + +it("exposes the wallpaper section and its single selected preview without opening a disclosure", async () => { + const { container } = draw(); + const section = container.querySelector('#set-wallpaper[data-setting="wallpaper"]')!; + expect(section.closest("details")).toBeNull(); + const preview = screen.getByRole("img", { name: t("壁纸") }); + expect(section.contains(preview)).toBe(true); + expect(container.querySelectorAll(".paperview")).toHaveLength(1); + expect(preview.style.backgroundImage).toContain("/selected.jpg"); + expect(preview.style.backgroundPosition).toBe("25% 75%"); + expect((preview.querySelector("i") as HTMLElement).style.opacity).toBe("0.5"); + expect((preview.querySelector("b") as HTMLElement).style.opacity).toBe("0.55"); + await waitFor(() => expect(container.querySelector(".palettes")).not.toBeNull()); +}); + +it("keeps upload, replacement, adjustment and removal on the same appearance state", async () => { + const { container, upload, clear, save } = draw({}); + const input = container.querySelector('input[data-action="wallpaper.change"]') as HTMLInputElement; + const first = new File(["image"], "wallpaper.png", { type: "image/png" }); + expect(container.querySelector(".paperview")).toBeNull(); + fireEvent.change(input, { target: { files: [first] } }); + await waitFor(() => expect(upload).toHaveBeenCalledWith(first)); + await screen.findByRole("img", { name: t("壁纸") }); + expect(input.value).toBe(""); + const second = new File(["replacement"], "replacement.jpg", { type: "image/jpeg" }); + fireEvent.change(input, { target: { files: [second] } }); + await waitFor(() => expect(upload).toHaveBeenCalledWith(second)); + for (const [name, field, value] of [["浓度", "opacity", "0.8"], ["压暗", "dim", "0.2"], ["横向焦点", "focusX", "0.6"], ["纵向焦点", "focusY", "0.4"]]) { + fireEvent.change(screen.getByRole("slider", { name: new RegExp(t(name)) }), { target: { value } }); + expect(save.mock.lastCall?.[0].wallpaper[field]).toBe(Number(value)); + } + expect(screen.getByRole("img", { name: t("壁纸") }).style.backgroundPosition).toBe("60% 40%"); + fireEvent.click(container.querySelector('[data-action="wallpaper.remove"]')!); + await waitFor(() => expect(clear).toHaveBeenCalledOnce()); + await waitFor(() => expect(container.querySelector(".paperview")).toBeNull()); + expect(save.mock.lastCall?.[0].wallpaper).toBeUndefined(); + expect(screen.getByRole("button", { name: t("选择图片或视频…") })).toBeTruthy(); +}); diff --git a/desktop/frontend-next/src/ui/Appearance.tsx b/desktop/frontend-next/src/ui/Appearance.tsx index 59a88c53d8..a0d93a0d1b 100644 --- a/desktop/frontend-next/src/ui/Appearance.tsx +++ b/desktop/frontend-next/src/ui/Appearance.tsx @@ -378,13 +378,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, -
    - - - {t("更多个性化")} - {look.wallpaper ? t("已设置自定义壁纸") : t("壁纸与背景位置")} - -

    {t("壁纸")}

    @@ -422,6 +415,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, {look.wallpaper && (
    -
    diff --git a/release-notes/studio/unreleased/14470-wallpaper-settings.md b/release-notes/studio/unreleased/14470-wallpaper-settings.md new file mode 100644 index 0000000000..d68638835d --- /dev/null +++ b/release-notes/studio/unreleased/14470-wallpaper-settings.md @@ -0,0 +1,2 @@ +- 改进:将「壁纸」及其选择、替换、移除、Wallpaper Engine 导入、浓度、压暗和焦点控件从「更多个性化」移到「外观」的独立区块;已选壁纸的预览直接显示,无需展开。关联 #14470。 +- Changed: Wallpaper, choose/change/remove, scene import, strength, dim and focus move from More personalization to Appearance. The selected preview is visible directly. Refs #14470. From 4a28c20aad529597b633f3143b9871d3f74817b0 Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 02:39:02 +0800 Subject: [PATCH 10/37] feat(theme): add per-scheme backdrop surface opacity --- .../frontend-next/perf/surface-opacity.mjs | 89 +++++++++++++++ desktop/frontend-next/src/port/look.ts | 14 +++ desktop/frontend-next/src/styles/TOKENS.md | 4 + desktop/frontend-next/src/styles/app.css | 10 +- desktop/frontend-next/src/styles/studio.css | 18 ++- desktop/frontend-next/src/ui/theme.test.ts | 44 +++++++ desktop/frontend-next/src/ui/theme.ts | 7 ++ docs/THEME_AUTHOR_GUIDE.md | 45 ++++++++ docs/theme-pack.schema.json | 20 ++++ internal/ext/theme/plugin_warnings_test.go | 1 + internal/ext/theme/surface_opacity_test.go | 52 +++++++++ internal/ext/theme/theme.go | 108 +++++++++++++++--- tools/repolint/wireparity.go | 2 + 13 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 desktop/frontend-next/perf/surface-opacity.mjs create mode 100644 internal/ext/theme/surface_opacity_test.go diff --git a/desktop/frontend-next/perf/surface-opacity.mjs b/desktop/frontend-next/perf/surface-opacity.mjs new file mode 100644 index 0000000000..53ab21cc70 --- /dev/null +++ b/desktop/frontend-next/perf/surface-opacity.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { chromium } from "playwright"; + +const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const shots = process.env.SURFACE_SCREENSHOTS; +if (shots) await mkdir(shots, { recursive: true }); +const browser = await chromium.launch(); +const errors = []; +const surfaces = ["nav", "rail", "chrome", "side", "compose"]; +const opacity = { + dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 }, + light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 }, +}; +try { + for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) { + const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" }); + await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme); + const page = await context.newPage(); + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '' })); + await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" }); + await page.waitForSelector(".compose"); + await page.evaluate(() => document.fonts.ready); + await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" }); + // Details no longer mount .side in the dock layout; phone omits .nav. + // Exercise their actual stylesheet selectors without changing visible UI. + await page.evaluate((surfaces) => { + for (const name of surfaces) if (!document.querySelector(`.${name}`)) { + const probe = document.createElement("aside"); + probe.className = name; + probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px"; + document.body.append(probe); + } + }, surfaces); + const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => { + const style = getComputedStyle(document.querySelector(`.${name}`)); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = style.backgroundColor; + ctx.fillRect(0, 0, 1, 1); + return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }]; + })), surfaces); + const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => { + const { apply } = await import("/src/ui/theme.ts"); + const tokens = { + light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" }, + dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" }, + }; + apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme); + }, { surfaceOpacity, backdrop, scheme }); + const baseline = await styles(); + await paint({}); + assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly"); + const empty = await page.screenshot({ animations: "disabled" }); + await paint(undefined); + assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly"); + await paint(opacity); + const custom = await styles(); + for (const name of surfaces) { + assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`); + assert.equal(custom[name].blur, baseline[name].blur); + assert.equal(custom[name].shadow, baseline[name].shadow); + } + if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" }); + await paint(opacity, "sky"); + for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`); + await paint(opacity, "none"); + const withoutBackdrop = await styles(); + await paint(undefined, "none"); + assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map"); + await paint({ [scheme]: { nav: 0, compose: 1 } }); + const boundaries = await styles(); + assert.equal(boundaries.nav.alpha, 0); + assert.equal(boundaries.compose.alpha, 1); + for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`); + await paint(undefined); + assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint"); + await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light")); + assert.equal(await page.locator("#pack-theme").count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined); + console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`); + await context.close(); + } + assert.deepEqual(errors, []); +} finally { + await browser.close(); +} diff --git a/desktop/frontend-next/src/port/look.ts b/desktop/frontend-next/src/port/look.ts index dec92c19d8..feca5dbbd3 100644 --- a/desktop/frontend-next/src/port/look.ts +++ b/desktop/frontend-next/src/port/look.ts @@ -14,6 +14,20 @@ export interface ThemeBackground { taskOpacity: number; overlayStrength: number; textGlow?: "default" | "none"; + surfaceOpacity?: ThemeSurfaceOpacities; +} + +export interface ThemeSurfaceOpacities { + light?: ThemeSurfaceOpacity; + dark?: ThemeSurfaceOpacity; +} + +export interface ThemeSurfaceOpacity { + nav?: number; + rail?: number; + chrome?: number; + side?: number; + compose?: number; } // A live backdrop the window draws, rather than a picture it places. Absent on diff --git a/desktop/frontend-next/src/styles/TOKENS.md b/desktop/frontend-next/src/styles/TOKENS.md index 0f6c38fc7f..04d4dcff63 100644 --- a/desktop/frontend-next/src/styles/TOKENS.md +++ b/desktop/frontend-next/src/styles/TOKENS.md @@ -319,6 +319,10 @@ pack states a shape, never a number the layout has to agree with. pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px halo when this variable is absent. The choice affects text only; the frosted backing remains. Reduced effects overrides the text shadow. +- `background.surfaceOpacity.{light,dark}` paints `--bg-{nav,rail,chrome,side,compose}-background` + through the pack sheet, only for a backdrop. Omitted values keep existing paint. +- Shell opacity mixes `--page`; composer opacity mixes `--raised`. Existing + Studio scheme-specific overrides and blur remain the defaults. - Stroke weight is an axis beside contrast, not part of it: however bright the colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 18e851d127..99fd52bc9b 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -62,24 +62,24 @@ :root[data-sky] .app { background: transparent } :root[data-bg] .app { background: transparent } - .rail, .side { background: var(--page) } .panes { background: var(--surface) } - :root[data-bg] .main, :root[data-bg] .panes { background: transparent } - :root[data-bg] .rail, :root[data-bg] .side, :root[data-bg] .nav, :root[data-bg] .chrome { - background: color-mix(in srgb, var(--page) 62%, transparent); text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent), 0 0 6px color-mix(in srgb, var(--page) 70%, transparent); } + :root[data-bg] .nav { background: var(--bg-nav-background, color-mix(in srgb, var(--page) 62%, transparent)) } + :root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 62%, transparent)) } + :root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 62%, transparent)) } + :root[data-bg] .side { background: var(--bg-side-background, color-mix(in srgb, var(--page) 62%, transparent)) } :root[data-bg] .flow:not([data-empty]) { text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent), 0 0 7px color-mix(in srgb, var(--page) 72%, transparent)); } :root[data-bg] .compose { - background: color-mix(in srgb, var(--raised) 80%, transparent); + background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 80%, transparent)); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 10f21ca4ed..1c311980cb 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -1298,7 +1298,7 @@ body { background: var(--page) !important; } } /* The inspector is contextual, not a permanent dashboard. */ -.side { background: var(--page); border-left: 1px solid var(--hair); } +.side { background: var(--bg-side-background, var(--page)); border-left: 1px solid var(--hair); } .side .scroll { background: var(--page); } .side .meter { border-color: var(--hair); } @@ -1582,7 +1582,7 @@ body { background: var(--page) !important; } } :root[data-theme="light"] .btn.send { background: var(--accent); color: var(--accent-fg); } :root[data-theme="light"] .btn.send:hover { background: #111210; } -:root[data-theme="light"] .side, +:root[data-theme="light"] .side { background: var(--bg-side-background, var(--overlay)); border-color: var(--border); } :root[data-theme="light"] .side .scroll { background: var(--overlay); border-color: var(--border); } :root[data-theme="light"] .prefs-sheet, :root[data-theme="light"] .prefs-hd, @@ -1839,9 +1839,8 @@ body { background: var(--page) !important; } :root[data-bg] .pane:has([data-pane="flow"] .flow[data-empty]) { background-color: transparent !important; } -:root[data-bg] :is(.rail, .chrome) { - background: color-mix(in srgb, var(--page) 80%, transparent) !important; -} +:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; } +:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; } .hero .rmark { filter: drop-shadow(0 0 28px var(--studio-light-primary)); @@ -3266,9 +3265,8 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1) .studio-workspace-kind { color: var(--muted); } .studio-user-foot .acctrow:hover, .studio-settings:hover { background: var(--accent-wash); color: var(--text); } -:root[data-theme="light"][data-bg] :is(.rail, .chrome) { - background: color-mix(in srgb, var(--page) 86%, transparent) !important; -} +:root[data-theme="light"][data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; } +:root[data-theme="light"][data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; } :root[data-theme="light"][data-bg] .railfoot { box-shadow: 0 -1px color-mix(in srgb, var(--border) 62%, transparent); } @@ -3283,10 +3281,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1) background: var(--accent-wash); color: var(--text); } -.side, .side .scroll, .studio-browser, .studio-browser-toolbar { background: var(--surface); border-color: var(--hair); color: var(--text); } +.side { background: var(--bg-side-background, var(--surface)); border-color: var(--hair); color: var(--text); } .studio-browser-address { background: var(--raised); border-color: var(--border); } .studio-browser-address input { color: var(--text); } /* A wallpaper remains visible, while cards keep enough of the selected pack's @@ -3297,7 +3295,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1) -webkit-backdrop-filter: blur(11px) saturate(1.04); backdrop-filter: blur(11px) saturate(1.04); } -:root[data-bg] .compose { background: color-mix(in srgb, var(--raised) 88%, transparent) !important; } +:root[data-bg] .compose { background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 88%, transparent)) !important; } :root[data-bg] .flow:not([data-empty]) { isolation: isolate; } :root[data-bg] .flow:not([data-empty])::before { content: ""; diff --git a/desktop/frontend-next/src/ui/theme.test.ts b/desktop/frontend-next/src/ui/theme.test.ts index 5e13b5438d..d1848c953b 100644 --- a/desktop/frontend-next/src/ui/theme.test.ts +++ b/desktop/frontend-next/src/ui/theme.test.ts @@ -262,3 +262,47 @@ describe("a pack's recipes step the shape and the rhythm", () => { expect(read("--d-scale")).toBe(""); }); }); + +describe("backdrop surface opacities", () => { + const surfaces = ["nav", "rail", "chrome", "side", "compose"] as const; + const glass: ThemePack = { + ...illustrated, + background: { + ...illustrated.background!, + surfaceOpacity: { + dark: { nav: 0, rail: 0.45, chrome: 0.8, side: 1, compose: 0.88 }, + light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 }, + }, + }, + }; + + it("paints five independent percentages for the active scheme without rounding", () => { + for (const scheme of ["light", "dark"] as const) { + apply(glass, scheme); + for (const surface of surfaces) { + expect(read(`--bg-${surface}-background`)).toBe(`color-mix(in srgb, var(${surface === "compose" ? "--raised" : "--page"}) ${glass.background!.surfaceOpacity![scheme]![surface]! * 100}%, transparent)`); + } + } + }); + + it("keeps omitted values on stylesheet defaults and removes stale scheme values", () => { + apply(glass, "dark"); + apply({ ...glass, background: { ...glass.background!, surfaceOpacity: { light: { nav: 0.6 } } } }, "light"); + expect(read("--bg-nav-background")).toBe("color-mix(in srgb, var(--page) 60%, transparent)"); + for (const surface of surfaces.slice(1)) expect(read(`--bg-${surface}-background`)).toBe(""); + apply(illustrated, "dark"); + for (const surface of surfaces) expect(read(`--bg-${surface}-background`)).toBe(""); + }); + + it("supports sky without an image, but changes nothing without a backdrop", () => { + const noImage = { ...glass, background: { ...glass.background!, image: false } }; + apply({ ...noImage, sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } }, "dark"); + expect(read("--bg-rail-background")).toBe("color-mix(in srgb, var(--page) 45%, transparent)"); + expect(document.documentElement.dataset.bg).toBe("on"); + apply(noImage, "dark"); + for (const surface of surfaces) expect(read(`--bg-${surface}-background`)).toBe(""); + expect(document.documentElement.dataset.bg).toBeUndefined(); + apply(null, "dark"); + expect(sheet()).toBeNull(); + }); +}); diff --git a/desktop/frontend-next/src/ui/theme.ts b/desktop/frontend-next/src/ui/theme.ts index 8627f0879b..b72ccb90d2 100644 --- a/desktop/frontend-next/src/ui/theme.ts +++ b/desktop/frontend-next/src/ui/theme.ts @@ -182,6 +182,13 @@ function declarations(pack: ThemePack, scheme: "light" | "dark", busy: boolean, const bg = pack.background; if (bg?.textGlow === "none") out.push(["--flow-text-shadow", "none"]); + if (sky || bg?.image) { + const opacity = bg?.surfaceOpacity?.[scheme]; + for (const surface of ["nav", "rail", "chrome", "side", "compose"] as const) { + const value = opacity?.[surface]; + if (value !== undefined) out.push([`--bg-${surface}-background`, `color-mix(in srgb, var(${surface === "compose" ? "--raised" : "--page"}) ${value * 100}%, transparent)`]); + } + } if (!bg?.image) return out; // The server owns freshness for this mutable URL. encodeURIComponent keeps // a pack id out of the CSS url() grammar. diff --git a/docs/THEME_AUTHOR_GUIDE.md b/docs/THEME_AUTHOR_GUIDE.md index 645cee1ccb..a2709bf737 100644 --- a/docs/THEME_AUTHOR_GUIDE.md +++ b/docs/THEME_AUTHOR_GUIDE.md @@ -145,6 +145,51 @@ The current `background` block controls `focusX`, `focusY`, `safeArea` Use a low task opacity so the image does not compete with a transcript. Check your image licence and avoid including workspace screenshots or secrets. +### Backdrop surface opacity + +`background.surfaceOpacity` optionally sets each shell surface's background +alpha over a backdrop, independently for light and dark: + +```json +"background": { + "surfaceOpacity": { + "dark": { "nav": 0.45, "rail": 0.45, "chrome": 0.80, "side": 0.80, "compose": 0.88 }, + "light": { "nav": 0.60, "rail": 0.60, "chrome": 0.86, "side": 0.86, "compose": 0.88 } + } +} +``` + +| Surface key | Selector | Colour mixed with transparency | +| --- | --- | --- | +| `nav` | `.nav` (icon navigation) | `--page` | +| `rail` | `.rail` (sessions) | `--page` | +| `chrome` | `.chrome` (top bar) | `--page` | +| `side` | `.side` (legacy details) | `--page` | +| `compose` | `.compose` (composer card) | `--raised` | + +This takes effect only with `data-bg`, from a sky or background image. +Blur, transcript backing and text shadows do not change. +There is no scalar shorthand or new appearance control. + +Omitted values retain the current stylesheet paint; older packs look unchanged. + +| Surface | Current Studio default | Base layout fallback | +| --- | --- | --- | +| Navigation | 0.62 | 0.62 | +| Sessions/top bar | 0.80 dark; 0.86 light | 0.62 | +| Composer | 0.88 | 0.80 | +| Legacy details | Opaque light overlay/dark surface | 0.62 | + +The current dock does not mount the legacy `.side` selector. + +- Numbers are clamped to 0–1; zero means fully transparent. +- Unknown schemes/surfaces are dropped with theme warnings, also in plugin diagnostics. +- Null means omitted. Wrong types fail manifest decoding. +- Switching scheme/pack, clearing the pack, or removing the backdrop clears old values. + +Check ordinary and muted text over your image in both schemes and at phone width. + + ### Transcript text glow Set `background.textGlow` to `"none"` to disable the transcript's wallpaper diff --git a/docs/theme-pack.schema.json b/docs/theme-pack.schema.json index 8290557c54..fb6c1a5688 100644 --- a/docs/theme-pack.schema.json +++ b/docs/theme-pack.schema.json @@ -50,6 +50,15 @@ "taskOpacity": { "type": "number", "minimum": 0, "maximum": 1 }, "overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 }, "textGlow": { "type": "string", "enum": ["default", "none"], "default": "default", "description": "Studio transcript text shadow when a backdrop is active; does not change the frosted backing." }, + "surfaceOpacity": { + "type": "object", + "additionalProperties": false, + "description": "Studio backdrop surface alphas, independently per scheme. Omitted values retain the existing surface paint.", + "properties": { + "light": { "$ref": "#/$defs/surfaceOpacity" }, + "dark": { "$ref": "#/$defs/surfaceOpacity" } + } + }, "paneOpacity": { "type": "number", "minimum": 0, "maximum": 1 } } }, @@ -62,6 +71,17 @@ } ], "$defs": { + "surfaceOpacity": { + "type": "object", + "additionalProperties": false, + "properties": { + "nav": { "type": "number", "minimum": 0, "maximum": 1 }, + "rail": { "type": "number", "minimum": 0, "maximum": 1 }, + "chrome": { "type": "number", "minimum": 0, "maximum": 1 }, + "side": { "type": "number", "minimum": 0, "maximum": 1 }, + "compose": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, "hexColor": { "type": "string", "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" diff --git a/internal/ext/theme/plugin_warnings_test.go b/internal/ext/theme/plugin_warnings_test.go index f230cc4575..f9937d8a5e 100644 --- a/internal/ext/theme/plugin_warnings_test.go +++ b/internal/ext/theme/plugin_warnings_test.go @@ -26,6 +26,7 @@ func TestPluginWarningsUsesThemeValidationWithoutRegistration(t *testing.T) { {name: "missing scheme", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"bg":"#FFFFFF"}}}`, want: []string{"no dark tokens"}}, {name: "no usable tokens", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"sidebar":"#FFFFFF"},"dark":{"bg":"#111111"}}}`, want: []string{"no usable light tokens"}}, {name: "partial", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"bg":"#FFFFFF","fg":"not-a-colour","sidebar":"#111111"},"dark":{"bg":"#111111"}}}`, want: []string{`"fg"`, `"sidebar" is not a theme token`}}, + {name: "unknown surface", file: manifestName, raw: strings.Replace(valid, `"tokens":`, `"background":{"surfaceOpacity":{"dark":{"sidebar":0.45}}},"tokens":`, 1), want: []string{`background.surfaceOpacity.dark: "sidebar" is not a surface`}}, {name: "other file", file: "ignored.txt", raw: "Not a theme manifest."}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/ext/theme/surface_opacity_test.go b/internal/ext/theme/surface_opacity_test.go new file mode 100644 index 0000000000..ed56967144 --- /dev/null +++ b/internal/ext/theme/surface_opacity_test.go @@ -0,0 +1,52 @@ +package theme + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSurfaceOpacityManifest(t *testing.T) { + const base = `{"schemaVersion":1,"tokens":{"light":{"bg":"#fff"},"dark":{"bg":"#111"}},"background":{"surfaceOpacity":%s}}` + for _, tc := range []struct{ name, value, want, warning string }{ + {"independent", `{"dark":{"nav":0,"rail":0.45,"chrome":0.8,"side":1,"compose":0.88},"light":{"nav":0.6}}`, `{"light":{"nav":0.6},"dark":{"nav":0,"rail":0.45,"chrome":0.8,"side":1,"compose":0.88}}`, ""}, + {"clamped", `{"dark":{"nav":-2,"compose":3}}`, `{"dark":{"nav":0,"compose":1}}`, ""}, + {"unknown surface", `{"dark":{"sidebar":0.4,"rail":0.5}}`, `{"dark":{"rail":0.5}}`, "background.surfaceOpacity.dark: \"sidebar\" is not a surface"}, + {"unknown scheme", `{"sepia":{"rail":0.4}}`, `null`, "background.surfaceOpacity: \"sepia\" is not a scheme"}, + {"empty", `{}`, `null`, ""}, + {"null values", `{"light":{"nav":null}}`, `null`, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + pack, _, err := decodeManifest([]byte(strings.Replace(base, "%s", tc.value, 1)), "probe") + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(pack.Background) + if err != nil { + t.Fatal(err) + } + var bg map[string]json.RawMessage + if err := json.Unmarshal(encoded, &bg); err != nil { + t.Fatal(err) + } + actual := string(bg["surfaceOpacity"]) + if actual == "" { + actual = "null" + } + if actual != tc.want { + t.Errorf("surfaceOpacity=%s, want %s", actual, tc.want) + } + if tc.warning == "" && len(pack.Warnings) != 0 { + t.Fatalf("warnings=%v", pack.Warnings) + } + if tc.warning != "" && (len(pack.Warnings) != 1 || pack.Warnings[0] != tc.warning) { + t.Fatalf("warnings=%v, want %q", pack.Warnings, tc.warning) + } + }) + } + for _, value := range []string{`0.62`, `{"dark":{"nav":"0.5"}}`, `{"dark":[]}`} { + if _, _, err := decodeManifest([]byte(strings.Replace(base, "%s", value, 1)), "probe"); err == nil || !strings.Contains(err.Error(), "surfaceOpacity") { + t.Errorf("value %s: error=%v", value, err) + } + } +} diff --git a/internal/ext/theme/theme.go b/internal/ext/theme/theme.go index 38b9fec3e8..f6e080bb0c 100644 --- a/internal/ext/theme/theme.go +++ b/internal/ext/theme/theme.go @@ -76,10 +76,28 @@ type Background struct { // can be kept away from it: left | center | right. SafeArea string `json:"safeArea,omitempty"` // HomeOpacity applies when nothing is running, TaskOpacity while a turn is. - HomeOpacity float64 `json:"homeOpacity"` - TaskOpacity float64 `json:"taskOpacity"` - OverlayStrength float64 `json:"overlayStrength"` - TextGlow string `json:"textGlow,omitempty"` + HomeOpacity float64 `json:"homeOpacity"` + TaskOpacity float64 `json:"taskOpacity"` + OverlayStrength float64 `json:"overlayStrength"` + TextGlow string `json:"textGlow,omitempty"` + SurfaceOpacity *SurfaceOpacities `json:"surfaceOpacity,omitempty"` +} + +// SurfaceOpacities names independent backdrop surface alphas for each scheme. +// Omitted schemes and surfaces leave the frontend's existing paint unchanged. +type SurfaceOpacities struct { + Light *SurfaceOpacity `json:"light,omitempty"` + Dark *SurfaceOpacity `json:"dark,omitempty"` +} + +// SurfaceOpacity is the alpha of each shell surface's own background. +// Pointers distinguish a fully transparent surface from an omitted value. +type SurfaceOpacity struct { + Nav *float64 `json:"nav,omitempty"` + Rail *float64 `json:"rail,omitempty"` + Chrome *float64 `json:"chrome,omitempty"` + Side *float64 `json:"side,omitempty"` + Compose *float64 `json:"compose,omitempty"` } // Sky is a live backdrop rather than a picture: drifting cloud, a sun, and a @@ -124,14 +142,15 @@ type manifestSky struct { } type manifestBackground struct { - Image string `json:"image"` - FocusX *float64 `json:"focusX"` - FocusY *float64 `json:"focusY"` - SafeArea string `json:"safeArea"` - HomeOpacity *float64 `json:"homeOpacity"` - TaskOpacity *float64 `json:"taskOpacity"` - OverlayStrength *float64 `json:"overlayStrength"` - TextGlow string `json:"textGlow"` + Image string `json:"image"` + FocusX *float64 `json:"focusX"` + FocusY *float64 `json:"focusY"` + SafeArea string `json:"safeArea"` + HomeOpacity *float64 `json:"homeOpacity"` + TaskOpacity *float64 `json:"taskOpacity"` + OverlayStrength *float64 `json:"overlayStrength"` + TextGlow string `json:"textGlow"` + SurfaceOpacity map[string]map[string]*float64 `json:"surfaceOpacity"` } // Dir is where user-installed packs live: one directory per pack, each with a @@ -234,7 +253,12 @@ func decode(raw []byte, dirID string) (Pack, error) { if err != nil { return Pack{}, err } - pack.Background = backgroundOf(m.Background, hasAsset(dirID, assetBackground)) + if hasAsset(dirID, assetBackground) { + if pack.Background == nil { + pack.Background = backgroundOf(nil, nil) + } + pack.Background.Image = true + } pack.Sky = skyOf(m.Sky) pack.HasPreview = hasAsset(dirID, assetPreview) return pack, nil @@ -286,10 +310,14 @@ func decodeManifest(raw []byte, dirID string) (Pack, manifest, error) { bg.TextGlow = "" } } + var background *Background + if m.Background != nil { + background = backgroundOf(m.Background, &warnings) + } // Map iteration order would otherwise make the same pack report its // problems in a different order on every read. slices.Sort(warnings) - pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens, Recipes: recipes, Warnings: warnings} + pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens, Recipes: recipes, Background: background, Warnings: warnings} return pack, m, nil } @@ -363,15 +391,13 @@ func skyOf(m *manifestSky) *Sky { // declares a focus and two opacities has said how it wants to be shown, and a // user dropping their own background.webp next to the manifest should get that // placement rather than a centred default. -func backgroundOf(m *manifestBackground, image bool) *Background { - if m == nil && !image { - return nil - } - out := &Background{Image: image, FocusX: 0.5, FocusY: 0.5, HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.65} +func backgroundOf(m *manifestBackground, warnings *[]string) *Background { + out := &Background{FocusX: 0.5, FocusY: 0.5, HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.65} if m == nil { return out } out.TextGlow = m.TextGlow + out.SurfaceOpacity = surfaceOpacitiesOf(m.SurfaceOpacity, warnings) if m.FocusX != nil { out.FocusX = clamp01(*m.FocusX) } @@ -394,6 +420,50 @@ func backgroundOf(m *manifestBackground, image bool) *Background { return out } +func surfaceOpacitiesOf(raw map[string]map[string]*float64, warnings *[]string) *SurfaceOpacities { + out := &SurfaceOpacities{} + for scheme, surfaces := range raw { + var target **SurfaceOpacity + switch scheme { + case "light": + target = &out.Light + case "dark": + target = &out.Dark + default: + *warnings = append(*warnings, fmt.Sprintf("background.surfaceOpacity: %q is not a scheme", scheme)) + continue + } + values := &SurfaceOpacity{} + for name, value := range surfaces { + var field **float64 + switch name { + case "nav": + field = &values.Nav + case "rail": + field = &values.Rail + case "chrome": + field = &values.Chrome + case "side": + field = &values.Side + case "compose": + field = &values.Compose + default: + *warnings = append(*warnings, fmt.Sprintf("background.surfaceOpacity.%s: %q is not a surface", scheme, name)) + continue + } + if value != nil { + clamped := clamp01(*value) + *field = &clamped + *target = values + } + } + } + if out.Light == nil && out.Dark == nil { + return nil + } + return out +} + func clamp01(v float64) float64 { if v < 0 { return 0 diff --git a/tools/repolint/wireparity.go b/tools/repolint/wireparity.go index 148941dca6..0330c4a9df 100644 --- a/tools/repolint/wireparity.go +++ b/tools/repolint/wireparity.go @@ -324,6 +324,8 @@ var mirroredWireTypes = []wireMirror{ // reader's own size and wallpaper. A field one side cannot read is absent. {"internal/frontend/serve/themes.go", "themeView", tsLookFile, "ThemePack"}, {"internal/ext/theme/theme.go", "Background", tsLookFile, "ThemeBackground"}, + {"internal/ext/theme/theme.go", "SurfaceOpacities", tsLookFile, "ThemeSurfaceOpacities"}, + {"internal/ext/theme/theme.go", "SurfaceOpacity", tsLookFile, "ThemeSurfaceOpacity"}, {"internal/ext/theme/theme.go", "Sky", tsLookFile, "ThemeSky"}, {"internal/ext/theme/theme.go", "Recipes", tsLookFile, "ThemeRecipes"}, {"internal/ext/theme/install.go", "Installed", tsLookFile, "ThemeImport"}, From 47df9ff015da9ba67d60b5404bb6fd705ff3f046 Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 02:39:29 +0800 Subject: [PATCH 11/37] fix(studio): make soft contrast lower without crossing AA --- desktop/frontend-next/perf/contrast.mjs | 11 ++- desktop/frontend-next/perf/tiers.mjs | 96 ++++++++++++++++++--- desktop/frontend-next/src/styles/TOKENS.md | 55 ++++++++---- desktop/frontend-next/src/styles/tokens.css | 20 ++++- desktop/frontend-next/src/ui/theme.test.ts | 20 ++++- 5 files changed, 165 insertions(+), 37 deletions(-) diff --git a/desktop/frontend-next/perf/contrast.mjs b/desktop/frontend-next/perf/contrast.mjs index 5b5549d070..747e5e7fa6 100644 --- a/desktop/frontend-next/perf/contrast.mjs +++ b/desktop/frontend-next/perf/contrast.mjs @@ -164,7 +164,7 @@ const settled = async (page) => { }; const found = new Map(); -for (const scheme of ["light", "dark"]) { +for (const scheme of ["light", "dark"]) for (const tier of ["", "soft"]) { const page = await browser.newPage({ viewport: { width: 1512, height: 950 }, locale: "zh-CN", colorScheme: scheme }); page.on("pageerror", (e) => fails.push("页面异常: " + e.message)); // The receipt card is off unless a machine asked for it, and an unrendered @@ -177,6 +177,11 @@ for (const scheme of ["light", "dark"]) { }); await page.goto(PAGE, { waitUntil: "networkidle" }); await page.waitForSelector(".compose"); + await page.evaluate(({ scheme, tier }) => { + document.documentElement.dataset.theme = scheme; + if (tier) document.documentElement.dataset.contrast = tier; + else delete document.documentElement.dataset.contrast; + }, { scheme, tier }); await page.waitForTimeout(400); // 一屏里画不出所有状态:审批、提问、失败的调用、设置页各自带着自己的配色。 @@ -223,11 +228,11 @@ for (const scheme of ["light", "dark"]) { f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: ["只在 darwin 验过"], unverified: ["Windows"], saysSomething: true } }); }); await settled(page); - for (const row of await sweep(page, scheme, "工作台")) found.set(row.key, row); + for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "工作台")) found.set(row.key, row); await page.locator('[data-action="chrome.settings"]').first().click(); await settled(page); - for (const row of await sweep(page, scheme, "设置")) if (!found.has(row.key)) found.set(row.key, row); + for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "设置")) if (!found.has(row.key)) found.set(row.key, row); await page.close(); } diff --git a/desktop/frontend-next/perf/tiers.mjs b/desktop/frontend-next/perf/tiers.mjs index e1f55845f8..1b626c4903 100644 --- a/desktop/frontend-next/perf/tiers.mjs +++ b/desktop/frontend-next/perf/tiers.mjs @@ -1,21 +1,31 @@ -// Ink contrast per tier and scheme. Each tier's inks must be at least as legible -// as the default palette stepped once, and as the recorded floors below (ratios -// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds. +// Built-in soft deliberately lowers contrast without crossing AA, including +// small ghost text. Unset and the higher tiers retain their recorded palettes. +// Custom packs keep authored inks for soft; see TOKENS.md for that boundary. import { chromium } from "playwright"; const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1"; const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] }; const INKS = Object.keys(STEPS); -const GROUNDS = ["--page", "--surface"]; +const GROUNDS = ["--page", "--surface", "--raised", "--overlay", "--float", "--float-hi"]; const TIERS = ["soft", "normal", "strong"]; const FLOOR = { - "light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 }, "light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 }, "light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 }, - "dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 }, "dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 }, "dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 }, }; +const SNAPSHOT = { + light: { + "": ["#292b29", "#5e625c", "#666a63"], + normal: ["oklch(.245 .0218 85)", "oklch(.34 .0196 85)", "oklch(.405 .0196 85)"], + strong: ["oklch(.103 .0196 85)", "oklch(.26 .0184 85)", "oklch(.335 .0184 85)"], + }, + dark: { + "": ["#e7e8e3", "#b4b7b0", "#8e9289"], + normal: ["oklch(.9588 .006756 115.3)", "oklch(.81 .01031 125.5)", "oklch(.728 .013 255)"], + strong: ["oklch(1 .006756 115.3)", "oklch(.893 .011 255)", "oklch(.806 .012 255)"], + }, +}; const fails = []; const check = (name, ok, detail = "") => { @@ -29,7 +39,7 @@ page.on("pageerror", (e) => fails.push("page error: " + e.message)); await page.goto(PAGE, { waitUntil: "networkidle" }); await page.waitForSelector(".compose"); -const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => { +const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }) => { const root = document.documentElement; const probe = document.createElement("div"); document.body.append(probe); @@ -61,6 +71,15 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => { root.dataset.theme = scheme; delete root.dataset.contrast; const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)])); + for (const [tier, expected] of Object.entries(SNAPSHOT[scheme])) { + if (tier) root.dataset.contrast = tier; + else delete root.dataset.contrast; + for (const [i, ink] of INKS.entries()) { + probe.style.color = expected[Math.min(i, 2)]; + const ref = rgb(getComputedStyle(probe).color); + out[`${scheme}/${tier || "unset"}/${ink}/snapshot`] = { got: rgb(read(ink)).join(","), ref: ref.join(",") }; + } + } const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))])); for (const tier of TIERS) { const k = TIERS.indexOf(tier); @@ -70,21 +89,74 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => { probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink]; const ref = rgb(getComputedStyle(probe).color); const got = rgb(read(ink)); - for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) }; + for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), base: ratio(rgb(base[ink]), grounds[g]), got: ratio(got, grounds[g]) }; } } } probe.remove(); return out; -}, { STEPS, INKS, GROUNDS, TIERS }); +}, { STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }); -for (const [key, { ref, got }] of Object.entries(measured)) { +for (const [key, { ref, base, got }] of Object.entries(measured)) { const [scheme, tier, ink, ground] = key.split("/"); - const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0; - check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`); + if (ground === "snapshot") { + check(`${key} stays unchanged`, got === ref, `${got} vs ${ref}`); + continue; + } + if (tier === "soft") { + const lowers = ink === "--text" || ink === "--muted"; + check(`${key} ${lowers ? "is genuinely softer than" : "retains the AA floor of"} unset`, lowers ? got < base - 0.1 : Math.abs(got - base) < 0.005, `${got.toFixed(2)} vs ${base.toFixed(2)}`); + } else { + const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0; + check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`); + } check(`${key} clears AA`, got >= 4.5, got.toFixed(2)); } +const explicit = await page.evaluate((INKS) => { + const root = document.documentElement; + const p = document.createElement("div"); + document.body.append(p); + const read = () => INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color)); + const out = {}; + for (const scheme of ["light", "dark"]) { + root.dataset.theme = scheme; + for (const tier of ["soft", "normal", "strong"]) { + root.dataset.contrast = tier; + out[`${scheme}/${tier}`] = read(); + } + } + p.remove(); + return out; +}, INKS); +for (const system of ["light", "dark"]) { + await page.emulateMedia({ colorScheme: system, contrast: "more" }); + for (const [key, expected] of Object.entries(explicit)) { + const got = await page.evaluate(({ key, INKS }) => { + const [scheme, tier] = key.split("/"); + document.documentElement.dataset.theme = scheme; + document.documentElement.dataset.contrast = tier; + const p = document.createElement("div"); + document.body.append(p); + const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color)); + p.remove(); + return out; + }, { key, INKS }); + check(`${key} outranks system ${system} and prefers-contrast: more`, JSON.stringify(got) === JSON.stringify(expected)); + } +} +await page.emulateMedia({ colorScheme: "dark", contrast: "no-preference" }); +const automaticDark = await page.evaluate((INKS) => { + delete document.documentElement.dataset.theme; + document.documentElement.dataset.contrast = "soft"; + const p = document.createElement("div"); + document.body.append(p); + const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color)); + p.remove(); + return out; +}, INKS); +check("system dark soft matches explicit dark soft", JSON.stringify(automaticDark) === JSON.stringify(explicit["dark/soft"])); + for (const scheme of ["light", "dark"]) { await page.emulateMedia({ colorScheme: scheme, contrast: "more" }); const same = await page.evaluate((scheme) => { diff --git a/desktop/frontend-next/src/styles/TOKENS.md b/desktop/frontend-next/src/styles/TOKENS.md index 0f6c38fc7f..9b627b3631 100644 --- a/desktop/frontend-next/src/styles/TOKENS.md +++ b/desktop/frontend-next/src/styles/TOKENS.md @@ -28,8 +28,10 @@ the gate is named. - The selector ties the contrast tiers (`:root[data-theme][data-contrast]`) and beats them by order. With no pack there is no element: the tiers alone carry the inks, and the reader's contrast is theirs. -- A pack's own inks take the reader's contrast as a relative-colour lightness - offset inside that rule; the built-in inks need none. +- A pack's own inks take normal/strong as relative-colour lightness offsets + inside that rule. Unset and soft preserve the author's baseline: arbitrary + inks and grounds may already meet AA only narrowly, so a blind negative + offset is not safe. This is not an accessibility guarantee for user packs. - `--ok --warn --err --net --deleg --add --del --focus` are not in the pack vocabulary and are never written. - Decoration that is not a status has its own tokens, which a pack may set @@ -325,20 +327,43 @@ pack states a shape, never a number the layout has to agree with. Latin still holds, so the Chinese interface starts at medium. - The `heavy` tier steps further for Chinese, whose default is already the Latin bold tier, and stops at 700: above it a Han character's closed strokes fill in. -- Text contrast has three tiers; backgrounds and semantic colours do not - participate. The floor is AA body text: `--ghost` must reach 4.5:1 on the - darkest ground it actually lands on, because it appears at 10–11px and the - large-text exemption starts at 18.66px. That ground is `--page` — the rail, - the side column and the chrome all lay it, where `--ghost` appears two orders - of magnitude more often than on `--raised`. The default tier starts at that - line and the other two step up; the body end does not move, because near-white - on a dark ground haloes and narrowing the span is what "softer" means. - Enforced by `perf/contrast.mjs`. -- `--ghost` is `--faint`'s value in every tier: a lighter one fails the floor. -- `perf/tiers.mjs` holds every tier: each ink is at least as legible as the - default palette stepped once (3.0/10.1, 3.5/7.5 and 3.5/7.0 lightness points - for text, muted and faint) and as the floors recorded there, on both grounds. +- The proposed decision for #12019 retains **soft** as a lower-contrast built-in + body/muted palette. Unlike the previous contract, the body end now moves. +- Unset, normal and strong remain unchanged. Pack-authored inks keep their + baseline for soft, as described under Theme packs. +- Every text ink, including 10–11px faint/ghost, must clear 4.5:1 on its actual + grounds; these sizes do not qualify for the large-text exemption. Applicable + non-text graphics still need 3:1. No semantic colours or graphics are softened. +- Faint/ghost keep the default AA floor: light is only 4.532:1 on `--overlay`, + dark only 4.504:1 on `--float-hi`. Softening them would fail AA despite passing + on page. `--ghost` equals `--faint` in every tier. +- Light soft previously made faint/ghost stronger than unset; it now preserves + the actual default instead. +- `perf/tiers.mjs` measures Chromium colours on page, surface, raised, overlay, + float and float-hi. It pins unset/normal/strong and requires softer body/muted, + stable faint/ghost, and AA. The higher tiers keep their stepped-palette floors. +- `perf/contrast.mjs` and `tools/contrast-sweep.mjs` also audit rendered text and + composited grounds. - `prefers-contrast: more` is followed when the user has not chosen a tier. Light and dark are judged separately: a manual dark choice under a light system scheme would otherwise paint the light theme's dark body text onto a dark ground. + +### Soft contrast decision measurements + +Chromium, canonical Studio palette, WCAG sRGB relative luminance. Each cell is +**page / surface**, rounded to three decimals; thresholds use unrounded ratios. +These are small-text measurements, not large-text exemptions. + +| Scheme / tier | text | muted | faint | ghost | +| --- | --- | --- | --- | --- | +| Light / unset (unchanged) | 12.496 / 13.653 | 5.445 / 5.949 | 4.831 / 5.279 | 4.831 / 5.279 | +| Light / previous soft | 12.496 / 13.653 | 7.695 / 8.407 | 5.815 / 6.353 | 5.815 / 6.353 | +| Light / soft | 9.324 / 10.187 | 5.140 / 5.616 | 4.831 / 5.279 | 4.831 / 5.279 | +| Light / normal (unchanged) | 14.198 / 15.513 | 10.316 / 11.271 | 7.933 / 8.668 | 7.933 / 8.668 | +| Light / strong (unchanged) | 18.018 / 19.686 | 13.669 / 14.934 | 10.510 / 11.483 | 10.510 / 11.483 | +| Dark / unset and previous soft | 14.730 / 14.314 | 8.934 / 8.682 | 5.722 / 5.560 | 5.722 / 5.560 | +| Dark / soft | 10.822 / 10.516 | 7.411 / 7.202 | 5.722 / 5.560 | 5.722 / 5.560 | +| Dark / normal (unchanged) | 16.125 / 15.670 | 10.065 / 9.781 | 7.565 / 7.352 | 7.565 / 7.352 | +| Dark / strong (unchanged) | 18.099 / 17.588 | 13.163 / 12.792 | 9.916 / 9.636 | 9.916 / 9.636 | + diff --git a/desktop/frontend-next/src/styles/tokens.css b/desktop/frontend-next/src/styles/tokens.css index 7180875051..68e16d85a4 100644 --- a/desktop/frontend-next/src/styles/tokens.css +++ b/desktop/frontend-next/src/styles/tokens.css @@ -221,9 +221,12 @@ :root[data-weight="heavy"]:lang(zh) { --w-reg: 600; --w-med: 700; --w-semi: 700 } - :root, :root[data-contrast="soft"] { + :root { --text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85); } + :root[data-contrast="soft"] { + --text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63; + } :root[data-contrast="normal"] { --text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85); } @@ -232,9 +235,12 @@ } @media (prefers-color-scheme: dark) { - :root, :root[data-contrast="soft"] { + :root { --text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289; } + :root[data-contrast="soft"] { + --text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289; + } :root[data-contrast="normal"] { --text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255); } @@ -243,18 +249,24 @@ } } - :root[data-theme="dark"], :root[data-theme="dark"][data-contrast="soft"] { + :root[data-theme="dark"] { --text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289; } + :root[data-theme="dark"][data-contrast="soft"] { + --text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289; + } :root[data-theme="dark"][data-contrast="normal"] { --text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255); } :root[data-theme="dark"][data-contrast="strong"] { --text: oklch(1 .006756 115.3); --muted: oklch(.893 .011 255); --faint: oklch(.806 .012 255); --ghost: oklch(.806 .012 255); } - :root[data-theme="light"], :root[data-theme="light"][data-contrast="soft"] { + :root[data-theme="light"] { --text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85); } + :root[data-theme="light"][data-contrast="soft"] { + --text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63; + } :root[data-theme="light"][data-contrast="normal"] { --text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85); } diff --git a/desktop/frontend-next/src/ui/theme.test.ts b/desktop/frontend-next/src/ui/theme.test.ts index 5e13b5438d..d1fae8d846 100644 --- a/desktop/frontend-next/src/ui/theme.test.ts +++ b/desktop/frontend-next/src/ui/theme.test.ts @@ -121,15 +121,29 @@ describe("a pack's inks answer to the reader's contrast", () => { expect(read("--text")).toContain("l + 0.03"); }); - // The step a pack states is the one someone who never opened the setting - // sees, so those two must land on the author's value untouched. - it("leaves the pack's value alone on the default step", () => { + // An arbitrary pack may already sit at the AA floor on its own grounds. + // Soft keeps that authored baseline; only the built-in palette can promise + // a lower accessible body ink without a pack-specific accessibility audit. + it("leaves the pack's value alone on default and soft", () => { apply(pack, "light", false, ""); expect(read("--text")).toBe("#1A1A1A"); apply(pack, "light", false, "soft"); expect(read("--text")).toBe("#1A1A1A"); }); + it.each(["light", "dark"] as const)("keeps soft stable through tier changes and reapply in %s", (scheme) => { + const tokens = pack.tokens[scheme]!; + const expected = [tokens.fg, tokens.fgDim, tokens.fgFaint, tokens.fgFaint]; + const inks = ["--text", "--muted", "--faint", "--ghost"]; + for (const tier of ["soft", "normal", "strong", "soft", "soft", "", "soft"]) { + apply(pack, scheme, false, tier); + if (tier === "" || tier === "soft") expect(inks.map(read)).toEqual(expected); + expect(read("--page")).toBe(tokens.bg); + expect(read("--surface")).toBe(tokens.bgSoft); + expect(read("--accent")).toBe(tokens.accent); + } + }); + it("does not move a surface, only the inks", () => { apply(pack, "light", false, "strong"); expect(read("--page")).toBe("#FFFFFF"); From c5334b70f355b17f50afb125726363e32a688090 Mon Sep 17 00:00:00 2001 From: lancy69 <297596661+lancy69@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:40:28 +0800 Subject: [PATCH 12/37] fix(studio): integrate pending queue into composer layout --- desktop/frontend-next/perf/harness.tsx | 4 +- desktop/frontend-next/perf/queue-layout.mjs | 61 +++++++++++++++++++ desktop/frontend-next/perf/queue.mjs | 4 +- desktop/frontend-next/perf/reask.mjs | 4 +- desktop/frontend-next/src/i18n/en_composer.ts | 2 + desktop/frontend-next/src/styles/LAYOUT.md | 27 ++++---- desktop/frontend-next/src/styles/app.css | 32 +++++----- desktop/frontend-next/src/styles/studio.css | 6 +- desktop/frontend-next/src/ui/Composer.tsx | 5 +- desktop/frontend-next/src/ui/Pane.tsx | 2 +- desktop/frontend-next/src/ui/Queue.tsx | 17 +++--- desktop/frontend-next/src/ui/queue.test.tsx | 30 +++++++++ .../unreleased/14497-queue-composer-layout.md | 2 + 13 files changed, 147 insertions(+), 49 deletions(-) create mode 100644 desktop/frontend-next/perf/queue-layout.mjs create mode 100644 release-notes/studio/unreleased/14497-queue-composer-layout.md diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx index 39fa4c8190..59e8ef9654 100644 --- a/desktop/frontend-next/perf/harness.tsx +++ b/desktop/frontend-next/perf/harness.tsx @@ -383,7 +383,8 @@ class BenchPort extends MockPort { // Lines the person queued: the panel leaves out the host's own queued // continuations, so a seed of those would draw nothing. intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup", - state: "queued" as const, + state: QUEUE_STATES && i < 2 ? (i === 0 ? "blocked" as const : "uncertain" as const) : "queued" as const, + ...(QUEUE_STATES && i < 2 ? { blockReason: "This queued instruction needs review before delivery. ".repeat(5) } : {}), ...(QUEUE_IMAGES && i === 0 ? { images: true } : {}), // 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板 // (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes, @@ -498,6 +499,7 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0); // ?queue= is how many lines are already waiting when the window opens. const QUEUE = Number(query.get("queue") ?? 0); const QUEUE_BODY = query.has("queuebody"); +const QUEUE_STATES = query.has("queuestates"); // ?queueimages makes the first seeded line a steer that carries a picture. const QUEUE_IMAGES = query.has("queueimages"); const JOBS = Number(query.get("jobs") ?? 0); diff --git a/desktop/frontend-next/perf/queue-layout.mjs b/desktop/frontend-next/perf/queue-layout.mjs new file mode 100644 index 0000000000..02b1741fbc --- /dev/null +++ b/desktop/frontend-next/perf/queue-layout.mjs @@ -0,0 +1,61 @@ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; +import assert from "node:assert/strict"; + +const url = process.env.PERF_URL ?? "http://127.0.0.1:4407/perf.html"; +const shots = process.env.QUEUE_SHOTS ?? "/tmp/queue-14497-shots"; +const baseline = process.env.QUEUE_BASELINE === "1"; +mkdirSync(shots, { recursive: true }); +const browser = await chromium.launch(); +const failures = []; +for (const theme of ["light", "dark"]) { + for (const width of [1440, 390]) { + const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: "zh-CN" }); + const page = await context.newPage(); + page.on("pageerror", (e) => failures.push(e.message)); + await page.goto(`${url}?queue=12&queuebody&queuestates&turns=8`, { waitUntil: "networkidle" }); + await page.locator(".qi").first().waitFor(); + await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; }, theme); + await page.waitForTimeout(500); + const geometry = await page.evaluate(() => { + const list = document.querySelector(".qitems"); + const row = document.querySelector(".qi"); + const style = getComputedStyle(row); + return { border: style.borderTopWidth, height: row.getBoundingClientRect().height, + scrolls: list.scrollHeight > list.clientHeight, visibleHeight: list.clientHeight, width: document.documentElement.scrollWidth, + background: style.backgroundColor, gap: getComputedStyle(list).gap }; + }); + console.log(`${baseline ? "before" : "after"}/${theme}/${width}`, geometry); + await page.screenshot({ path: `${shots}/${baseline ? "before" : "after"}-${theme}-${width}.png` }); + if (baseline) { + assert.notEqual(geometry.border, "0px", "baseline must expose the standalone row border"); + } else { + assert.equal(geometry.border, "0px"); + assert.ok(geometry.height >= 36 && geometry.height <= 40, JSON.stringify(geometry)); + assert.equal(geometry.gap, "2px"); + assert.ok(geometry.scrolls); + assert.ok(geometry.width <= width); + assert.equal(await page.getByRole("combobox", { name: "任务输入" }).getAttribute("placeholder"), "继续输入以排队后续修改"); + const row = page.locator(".qi").first(); + assert.equal(await row.locator(".qwhy").getAttribute("title"), "This queued instruction needs review before delivery. ".repeat(5)); + assert.ok(geometry.visibleHeight >= 180 && geometry.visibleHeight <= 208, JSON.stringify(geometry)); + await row.getByRole("button", { name: "改", exact: true }).focus(); + assert.equal(await row.getByRole("button", { name: "下移", exact: true }).evaluate((el) => getComputedStyle(el).opacity), "1"); + await page.screenshot({ path: `${shots}/focus-${theme}-${width}.png` }); + await row.getByRole("button", { name: "改", exact: true }).click(); + await page.locator(".qedit").waitFor(); + assert.ok(await page.locator(".qedit").inputValue()); + await page.screenshot({ path: `${shots}/edit-${theme}-${width}.png` }); + await page.locator(".qedit").press("Escape"); + assert.equal(await page.locator(".qedit").count(), 0); + assert.equal(await row.getByRole("button", { name: "取回", exact: true }).getAttribute("title"), "取回"); + await page.locator(".qitems").evaluate((el) => { el.scrollTop = el.scrollHeight; }); + await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).focus(); + assert.ok(await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).isVisible()); + } + await context.close(); + } +} +await browser.close(); +assert.deepEqual(failures, []); +console.log("Queue layout browser matrix passed"); diff --git a/desktop/frontend-next/perf/queue.mjs b/desktop/frontend-next/perf/queue.mjs index 8aa0981fcf..91dec06601 100644 --- a/desktop/frontend-next/perf/queue.mjs +++ b/desktop/frontend-next/perf/queue.mjs @@ -38,7 +38,7 @@ check("播出来的行在", (await rows.count()) === 3, `${await rows.count()} const first = rows.nth(0); const wasFirst = await first.locator(".pv").innerText(); -await first.locator('button:has-text("改")').click(); +await first.getByRole("button", { name: "改", exact: true }).click(); await page.waitForTimeout(500); const box = await page.locator(".queue .qedit").count(); @@ -50,7 +50,7 @@ check("这一行说了为什么", await why.isVisible().catch(() => false), (awa // 说明是这一行的,不是面板的:换一行再点,上一行的说明得跟着走。一条粘住不动的 // 提示会让下一次失败看起来像上一次的余音。 const second = rows.nth(1); -await second.locator('button:has-text("改")').click(); +await second.getByRole("button", { name: "改", exact: true }).click(); await page.waitForTimeout(500); check("说明跟着被点的那一行走", (await first.locator(".qwhy[data-err]").count()) === 0); check("新的那一行才带着说明", await second.locator(".qwhy[data-err]").isVisible().catch(() => false)); diff --git a/desktop/frontend-next/perf/reask.mjs b/desktop/frontend-next/perf/reask.mjs index bf95f36f8b..f2871b3881 100644 --- a/desktop/frontend-next/perf/reask.mjs +++ b/desktop/frontend-next/perf/reask.mjs @@ -73,7 +73,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]]) await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" }); await page.waitForSelector(".queue .qi", { timeout: 15000 }); await page.waitForTimeout(600); - await page.locator('.queue .qi').first().locator('button:has-text("改")').click(); + await page.locator('.queue .qi').first().getByRole("button", { name: "改", exact: true }).click(); await page.waitForTimeout(600); const q = await page.evaluate(() => { const el = document.querySelector(".queue .qedit"); @@ -92,7 +92,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]]) await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" }); await page.waitForSelector(".queue .qi", { timeout: 15000 }); await page.waitForTimeout(600); - await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click(); + await page.locator(".queue .qi").nth(1).getByRole("button", { name: "改", exact: true }).click(); await page.waitForTimeout(600); if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); } const g = await page.evaluate(() => { diff --git a/desktop/frontend-next/src/i18n/en_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts index 87ff2d54e8..e7cc01fa1b 100644 --- a/desktop/frontend-next/src/i18n/en_composer.ts +++ b/desktop/frontend-next/src/i18n/en_composer.ts @@ -112,6 +112,8 @@ export const EN_COMPOSER: Record = { "正在发送…": "Sending…", "描述任务、问题或要改的内容…": "Describe a task, question, or change…", "描述任务…": "Describe a task…", + "继续输入以排队后续修改": "Keep typing to queue further changes", + "继续输入…": "Keep typing…", "任务输入": "Task input", "命令与技能": "Commands and skills", "正在加载命令与技能…": "Loading commands and skills…", diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 42b34e7eda..6e89aa88c7 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -267,21 +267,22 @@ and selected states, while long titles truncate within the remaining width. that land", so the state is a chip on the row. The count and the byte total each refuse alone: a header showing one of them is wrong about the other exactly when it matters. -- The number of entries is capped (64); their height is not. The head stays put - and the rows grow; total height comes from `.composeaux`, and the rule here - only says that the rows scroll rather than the head scrolling away with them. -- `steer_consumed` means this turn has already read the line into its context: - it cannot be taken back, and `AckDequeue` removes it when the turn ends. It - stops being a card and becomes a record — still visible, because a crash before - the write has to leave it on screen, but no longer a card's height. Its border - goes transparent rather than away, or the column loses its alignment. -- Its actions are quiet but present: a row of controls that exists only under the - pointer reads as a row that does nothing. +- The queue is the upper half of the composer, sharing its surface and outer + frame. Transparent 38px rows have 12px horizontal padding and 2px gaps; only + status pills carry status colour. A quiet separator leads into the input. +- The head stays put while roughly five rows fit before internal scrolling, + with edge fades. `.composeaux` still owns the shared height budget. +- `steer_consumed` and `running` entries leave this pending strip: they have + already reached the transcript and are not input waiting to be sent. +- Edit and withdraw use icons with unchanged accessible names and titles. + Withdraw restores input, never discards it. Move controls reveal on hover or + focus and remain visible on touch; their keyboard order is unchanged. - Everything stacked above the input shares one ceiling, `--aux-budget`, so a full queue scrolls inside it instead of taking the transcript. -- The studio composer's chrome is taller than the one the budget was set - against, so studio lowers it (28vh, 19vh when short): with 64 queued lines the - transcript keeps 40% of the pane, which `perf/budget.mjs` holds. +- Without notices, a queue gets a 44vh auxiliary budget (28vh when short), + capped at 420px (260px when short), so about five compact rows fit. Notices + restore the smaller 28vh/19vh budget to protect transcript space; the input + stays visible under the 64-entry pressure case in `perf/budget.mjs`. - The queued line is the only thing on screen that has not happened yet, so its cancel sits beside "queued" and stays visible. A cancel revealed on hover is no cancel. diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 18e851d127..2b90f8ebab 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -405,8 +405,8 @@ .pane[data-run="done"] .rmark { color: var(--ok) } .queue { - display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; - min-height: 0; + display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; + min-height: 0; padding-bottom: 10px; border-bottom: 1px solid var(--hair); font: var(--w-reg) var(--fs-meta) var(--mono); animation: qin var(--m-card) both; } @@ -446,18 +446,17 @@ .qhold:disabled { opacity: .45; cursor: default } .qitems { - display: grid; gap: 4px; min-height: 0; + display: grid; gap: 2px; min-height: 0; max-height: 208px; overflow-y: auto; overscroll-behavior: contain; + mask-image: linear-gradient(to bottom, transparent, #000 5px, #000 calc(100% - 5px), transparent); } .qi { - display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 9px; - border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); + display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 38px; padding: 4px 12px; + border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text); animation: qstep var(--t-card) var(--ease-out) both; - transition: - padding var(--t-card) var(--ease-out), font-size var(--t-card) var(--ease-out), - opacity var(--t-card) var(--ease-out), border-color var(--t-card) var(--ease-out), - background var(--t-card) var(--ease-out); + transition: opacity var(--t-card) var(--ease-out), background var(--t-card) var(--ease-out); } + .qi:hover, .qi:focus-within { background: var(--overlay) } .qdot { width: 5px; height: 5px; flex: none; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); @@ -470,7 +469,7 @@ } .pv { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .rf { flex: none; color: var(--faint) } - .qwhy { flex: none; color: var(--warn-ink) } + .qwhy { flex: 0 1 24%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted) } .qwhy[data-err] { color: var(--err-ink) } .qedit { flex: 1; min-width: 0; padding: 2px 5px; border: 1px solid var(--focus); border-radius: var(--r-xs); @@ -485,7 +484,7 @@ .qacts { display: flex; align-items: center; gap: 1px; flex: none } .qacts button { - min-width: 17px; height: 19px; padding: 0 4px; border: none; border-radius: var(--r-xs); + min-width: 24px; height: 26px; padding: 0 4px; border: none; border-radius: var(--r-xs); display: inline-flex; align-items: center; justify-content: center; background: none; color: var(--faint); font: inherit; cursor: pointer; transition: color var(--m-line) , background var(--m-line) ; @@ -498,14 +497,15 @@ .qacts button.qnow:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-ink) } .qacts button:hover:not(:disabled) { background: var(--overlay); color: var(--text) } .qacts button:disabled { opacity: .3; cursor: default } + .qacts .qmove { opacity: 0; width: 0; min-width: 0; padding: 0; overflow: hidden } + .qi:hover .qmove, .qi:focus-within .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px } + @media (hover: none) { .qacts .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px } } .qi .x { - flex: none; padding: 2px 7px; border: none; border-radius: var(--r-pill); background: none; - color: var(--faint); font: var(--w-med) var(--fs-micro) var(--ui); line-height: 1.3; cursor: pointer; + flex: none; width: 26px; height: 26px; padding: 2px; border: none; border-radius: var(--r-xs); background: none; + color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); line-height: 1.3; cursor: pointer; transition: color var(--m-line) , background var(--m-line) ; } - .qi .x:hover { color: var(--err-ink); background: color-mix(in srgb, var(--err) 12%, transparent) } - .qi .x { position: relative } - .qi .x::after { content: ""; position: absolute; inset: -5px -2px } + .qi .x:hover, .qi .x:focus-visible { color: var(--text); background: var(--overlay) } @keyframes bumpup { 0% { transform: translateY(2px); opacity: .5 } 100% { transform: none; opacity: 1 } } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 10f21ca4ed..ff2d773d11 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -1084,7 +1084,8 @@ body { background: var(--page) !important; } .compose::after { border-color: var(--border); } .compose:hover::after { border-color: var(--border); } .compose { --aux-budget: min(calc(28vh / var(--zoom, 1)), 280px); } -[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); } +.compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(44vh / var(--zoom, 1)), 420px); } +[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); } [data-fold~="short"] .compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(28vh / var(--zoom, 1)), 260px); } .compose:focus-within { box-shadow: 0 0 0 3px rgb(197 201 192 / .055), 0 20px 48px -32px #000; } .compose:focus-within::after { border-color: #4a4e47; } .compose .prompt { display: none; } @@ -1173,7 +1174,7 @@ body { background: var(--page) !important; } .btn.send { width: 34px; height: 34px; border: 0; border-radius: 50%; background: var(--accent); color: var(--accent-fg); } .btn.send:hover { background: var(--accent); transform: translateY(-1px); } .btn.send:disabled { background: var(--hair); color: var(--ghost); } -.composeaux, +.composeaux { width: 100%; margin-inline: 0; } .composenotes { width: min(var(--column), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; } .composenotes { color: var(--ghost); } .studio-meterrail { @@ -1485,7 +1486,6 @@ body { background: var(--page) !important; } .app[data-rail="off"] .rail { pointer-events: none; } .flow, .compose, - .composeaux, .composenotes { width: calc(100% - 24px); } .flow { left: 0; padding-inline: 0; } .chrome .crumb-proj, diff --git a/desktop/frontend-next/src/ui/Composer.tsx b/desktop/frontend-next/src/ui/Composer.tsx index 901c4034fd..0aa0167950 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -44,6 +44,7 @@ interface Props { port: AgentPort; status: SessionStatus | null; running: boolean; + queued?: boolean; // Text a card asked to be quoted, and a counter that makes the same text // twice two requests. Quoting the same reply again is an ordinary thing to // do, and comparing the string alone would drop the second one. @@ -79,7 +80,7 @@ interface Props { draftKey?: string; } -export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) { +export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) { const touch = touchKeyboard(); const providerOrder = useProviderOrder(); const modelGroups = useModelFilter(); @@ -217,7 +218,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus, pending.current = null; }, []); useFitHeight(box, text, placeCaret); - const placeholder = usePlaceholderFit(box, [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]); + const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]); // Attachments ride into the turn as path references, exactly as they do from // the CLI — the host saved the bytes, the turn parser resolves the token. A diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx index 0f79de0577..ab0257f806 100644 --- a/desktop/frontend-next/src/ui/Pane.tsx +++ b/desktop/frontend-next/src/ui/Pane.tsx @@ -636,7 +636,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc {alert &&
    {alert}
    } {pill && } - setSideAsk((on) => !on)} sidePanel={sideAsk ? setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} /> + 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} /> - {queue.paused && {t("已暂停")}} {queue.readonly && {t("只读")}} {/* The kernel pauses itself when it recovers entries: something was said that no one has since re-confirmed. */} @@ -218,7 +217,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend const coded = it.blockCode ? BLOCK_WHY[it.blockCode] : undefined; const why = coded ? t(coded) : it.blockReason; return ( -
    +
    {/* The chip is the answer to "did that land". Its wording says which turn the line goes into; the title says when. */} {ordinary(it) ? ( @@ -257,7 +256,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend )} {!!it.refs?.length && {t("冻结 {n} 文件", { n: it.refs.length })}} - {why && {why}} + {why && (it.state === "blocked" || it.state === "uncertain") && {why}} {unread?.id === it.id && ( {t("无法读取该条的正文,未打开编辑:{why}", { why: unread.why })} @@ -266,15 +265,15 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend {live && ( {items.length > 1 && <> - - } - {/* Waiting is the normal case and needs no button. This is the other one: the turn in front of this line is not worth @@ -304,8 +303,8 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend )} {live && ( - )}
    diff --git a/desktop/frontend-next/src/ui/queue.test.tsx b/desktop/frontend-next/src/ui/queue.test.tsx index 72dfdbc1a4..eb48bb7483 100644 --- a/desktop/frontend-next/src/ui/queue.test.tsx +++ b/desktop/frontend-next/src/ui/queue.test.tsx @@ -41,6 +41,36 @@ const paint = (q: QueueSnapshot, running: boolean) => const draw = (q: QueueSnapshot) => paint(q, true); const drawIdle = (q: QueueSnapshot) => paint(q, false); +describe("compact queue presentation", () => { + it("keeps action identities and order when edit and withdraw become icons", () => { + const html = draw(snapshot({ items: [item(), item({ id: "i2" })] })); + expect(html).toContain('title="编辑" aria-label="改"'); + expect(html).toContain('title="取回" aria-label="取回"'); + const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]); + expect(actions).toEqual(["queue.pause", "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel", + "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel"]); + expect(html).not.toContain("draggable"); + }); + + it("says paused once and preserves the resume control", () => { + const html = draw(snapshot({ paused: true })); + expect(html).toContain("发送已暂停"); + expect(html).not.toContain('data-hold'); + expect(html).toContain("继续派发"); + }); + + it("keeps long reasons in titles and limits inline reasons to held entries", () => { + const why = "long delivery explanation"; + const queued = draw(snapshot({ items: [item({ state: "queued", blockReason: why })] })); + expect(queued).toContain(`title="${why}"`); + expect(queued).not.toContain('class="qwhy"'); + for (const state of ["blocked", "uncertain"] as const) { + expect(draw(snapshot({ items: [item({ state, blockReason: why })] }))) + .toContain(`class="qwhy" title="${why}"`); + } + }); +}); + describe("why a held entry stopped", () => { const held = (over: Partial) => draw(snapshot({ paused: true, items: [item({ state: "uncertain", ...over })] })); diff --git a/release-notes/studio/unreleased/14497-queue-composer-layout.md b/release-notes/studio/unreleased/14497-queue-composer-layout.md new file mode 100644 index 0000000000..ee65203bf7 --- /dev/null +++ b/release-notes/studio/unreleased/14497-queue-composer-layout.md @@ -0,0 +1,2 @@ +- 改进:待发送队列与输入框共用外框和底色,采用紧凑透明行、内部滚动与轻量图标操作;受阻原因降低对比,取回仍恢复到输入框,现有操作与快捷键保持不变。关联 #14497。 +- Changed: pending messages share the composer frame, with compact transparent rows, internal scrolling and icon actions. Existing queue actions and shortcuts are unchanged. Refs #14497. From 03c406a5a4b6d8c3d13df0e0979eb86be4a844f2 Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 09:33:58 +0800 Subject: [PATCH 13/37] fix(studio): keep file Save compact and readable --- desktop/frontend-next/perf/README.md | 1 + desktop/frontend-next/perf/workbench-save.mjs | 119 ++++++++++++++++++ desktop/frontend-next/src/styles/LAYOUT.md | 3 + desktop/frontend-next/src/styles/studio.css | 10 +- 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 desktop/frontend-next/perf/workbench-save.mjs diff --git a/desktop/frontend-next/perf/README.md b/desktop/frontend-next/perf/README.md index de6b5e6d1f..73de6f3942 100644 --- a/desktop/frontend-next/perf/README.md +++ b/desktop/frontend-next/perf/README.md @@ -49,6 +49,7 @@ node perf/queue.mjs # 待送达:正文读不回来时,「改」不许 node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍 node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels +node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏 node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文 diff --git a/desktop/frontend-next/perf/workbench-save.mjs b/desktop/frontend-next/perf/workbench-save.mjs new file mode 100644 index 0000000000..cfd299160a --- /dev/null +++ b/desktop/frontend-next/perf/workbench-save.mjs @@ -0,0 +1,119 @@ +import { mkdirSync } from "node:fs"; +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const SHOTS = process.env.SHOTS_DIR; +const fails = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); + if (!ok) fails.push(name); +}; +if (SHOTS) mkdirSync(SHOTS, { recursive: true }); + +const measure = (page) => page.locator(".workbench-save").evaluate((button) => { + const style = getComputedStyle(button); + const bar = button.parentElement; + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + const rgba = (color) => { + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + return [...ctx.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v); + }; + const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3])); + const ancestors = []; + for (let node = button.parentElement; node; node = node.parentElement) ancestors.unshift(node); + let ground = [255, 255, 255]; + for (const node of ancestors) ground = over(rgba(getComputedStyle(node).backgroundColor), ground); + const opacity = Number(style.opacity); + const fill = over(rgba(style.backgroundColor), ground); + const ink = over(rgba(style.color), fill); + const lum = (rgb) => rgb.map((v) => { + v /= 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); + const [hi, lo] = [lum(over([...ink, opacity], ground)), lum(over([...fill, opacity], ground))].sort((a, b) => b - a); + const text = [...button.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); + const range = document.createRange(); + range.selectNodeContents(text); + const width = button.getBoundingClientRect().width; + const natural = range.getBoundingClientRect().width + button.querySelector("svg").getBoundingClientRect().width + + parseFloat(style.columnGap) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth); + const rect = bar.getBoundingClientRect(); + const children = [...bar.children].map((node) => node.getBoundingClientRect()); + return { + width, natural, contrast: (hi + 0.05) / (lo + 0.05), disabled: button.disabled, + singleRow: Math.max(...children.map((r) => r.top)) < Math.min(...children.map((r) => r.bottom)), + contained: children.every((r) => r.left >= rect.left - 1 && r.right <= rect.right + 1), + }; +}); + +const browser = await chromium.launch(); +try { + for (const scheme of ["light", "dark"]) { + for (const locale of ["zh", "en"]) { + for (const width of [1440, 390]) { + for (const desktop of [true, false]) { + const page = await browser.newPage({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce" }); + page.setDefaultTimeout(15000); + page.on("pageerror", (error) => fails.push(error.message)); + // Native capabilities render both optional toolbar controls; no native action is invoked. + if (desktop) await page.addInitScript(() => { + window.reasonixHost = { + shell: "electron", platform: "win32", titleBar: false, + isWindowMaximised: async () => false, setUnreadBadge: async () => {}, + revealPath: async () => null, revealWorkspace: async () => null, + }; + }); + const url = new URL(BASE); + url.searchParams.set("pref", locale); + url.searchParams.set("ws", "2"); + url.searchParams.set("sess", "6"); + url.searchParams.set("turns", "2"); + await page.goto(url.href, { waitUntil: "networkidle" }); + const workbench = page.locator('[data-action="pane.view"][data-value="browser"]'); + if (!(await workbench.isVisible())) await page.locator('[data-action="chrome.more"]').click(); + await workbench.click(); + await page.locator('[data-action="workbench.launch-files"]').click(); + await page.locator(".workbench-filelist button").filter({ hasText: "README.md" }).click(); + await page.locator(".workbench-save").waitFor(); + await page.evaluate(() => document.fonts.ready); + const tag = `${scheme}/${locale}/${width}/${desktop ? "desktop" : "browser"}`; + const verify = async (state) => { + await page.locator(".workbench-save").evaluate(async (button) => { + await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); + const result = await measure(page); + check(`${tag}/${state}: content-sized Save`, result.width <= result.natural + 1, `${result.width.toFixed(1)}px vs ${result.natural.toFixed(1)}px content`); + check(`${tag}/${state}: controls share one row`, result.singleRow); + check(`${tag}/${state}: controls stay inside toolbar`, result.contained); + check(`${tag}/${state}: readable label`, result.contrast >= 4.5, `${result.contrast.toFixed(2)}:1`); + check(`${tag}/${state}: native disabled state`, result.disabled === (state !== "modified")); + if (SHOTS && desktop && locale === "zh") await page.screenshot({ path: `${SHOTS}/${scheme}-${width}-${state}.png` }); + }; + await verify("unchanged"); + await page.locator('[data-action="workbench.mode"][data-value="file"]').click(); + const editor = page.locator(".cm-content"); + await editor.fill("# Save button regression\n\nChanged in the editor."); + await page.locator(".workbench-save:not(:disabled)").waitFor(); + await verify("modified"); + await page.locator(".workbench-save").click(); + await page.locator(".workbench-save:disabled").waitFor(); + await verify("saved"); + await page.locator('[data-action="workbench.mode"][data-value="read"]').click(); + check(`${tag}: saved content remains readable`, await page.locator(".workbench-read h1").textContent() === "Save button regression"); + await page.close(); + } + } + } + } +} finally { + await browser.close(); +} +if (fails.length) { + console.error(`${fails.length} failures: ${fails.join("; ")}`); + process.exitCode = 1; +} diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 42b34e7eda..4901c4c81c 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -342,6 +342,9 @@ and selected states, while long titles truncate within the remaining width. the pane's tab-row line so the two rows read as one: the strip is `--pane-bar-h` less the card's top inset and border. `perf/dock.mjs` holds the two lines together. +- The file toolbar gives spare width to the truncated path; optional controls + and Save keep their intrinsic widths. Enabled Save uses accent foreground ink; + disabled Save uses opaque muted ink on a neutral surface. - The workbench is hidden, not unmounted. It holds the open files and a browser per tab, each with its own history, and a glance at the conversation must not cost them. `.scroll[data-pane="browser"][hidden]` was already written for it. diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 10f21ca4ed..308b350040 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -1422,14 +1422,14 @@ body { background: var(--page) !important; } .workbench-filelist span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workbench-filelist em { color: var(--warn-ink); font-style: normal; } .workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); text-align: center; } -.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); } -.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); } +.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); } +.workbench-filebar strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); } +.workbench-filebar > :not(strong) { flex-shrink: 0; } .workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; } .workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 calc(10px * var(--fs-scale, 1))/1 var(--ui); } .workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); } -.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; } -.workbench-filebar .workbench-save { background: var(--accent); color: white; } -.workbench-filebar .workbench-save:disabled { opacity: .35; } +.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; background: var(--accent); color: var(--accent-fg); } +.workbench-filebar .workbench-save:disabled { background: var(--overlay); color: var(--muted); cursor: default; } .workbench-filebar .workbench-readonly { padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); white-space: nowrap; } .workbench-filebar svg { width: 13px; height: 13px; } .workbench-editor { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--face-code); } From e0f288b8ed78b5a297d28493600b1c3a6e235455 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 26 Oct 2026 14:24:02 +0800 Subject: [PATCH 14/37] fix(doctor): a catalog that recorded a failure is not ok Problem: `reasonix doctor catalogs` printed the projection's integrity_check string as the catalog's state, so a history catalog whose history_sources row recorded health='corrupt' printed `ok`, and a future-schema database -- which the runtime opens as StateDegraded -- printed `ok` too. The command a user is told to run to check their data agreed with neither the catalog nor `catalogs reindex --json`, which reports the same state as "failed": 1. Root cause: the state column came from Inspection.Integrity and nothing else. The command never asked the catalog what it recorded, and never compared the recorded schema against the schema this build migrates to. Fix: read the facts reindex already reads. refreshStatus and refresh now share one readStatusCounts with the new read-only Inspect readers, so the diagnostic's failed is the number reindex reports, and the reason is the one the catalog recorded. A newer schema reports degraded against projectiondb.SupportedVersion, which ApplyMigrations also uses. --json gains state, failed, pending, corruptLines/lagBytes and failures only when there is something to say, so a healthy catalog's output is unchanged. Text stays one line per catalog. Exit stays 0: like `catalogs reindex --json`, this command reports rather than repairs. Verification: red-first. A reindexed undecodable transcript printed `history ok`; it now prints `history failed ... failed=1 source=: `. A schema-2 database printed `ok`; it now prints `degraded ... supported=1`. Controls: healthy line and exit unchanged, missing line unchanged, healthy JSON keys unchanged. Mutation: dropping the failure read turns the corrupt-source test red, dropping the degraded read turns the future-schema test red. projectiondb.OpenReadOnly reads a live WAL, which immutable=1 skipped. No projection schema, history index or reindex path changed. Refs docs/USAGE_CATALOG.md:25, docs/HISTORY_SEARCH_CATALOG.md:42-43. --- internal/frontend/cli/catalogs.go | 114 ++++++- internal/frontend/cli/catalogs_history.go | 1 + internal/frontend/cli/catalogs_usage.go | 1 + internal/frontend/cli/doctor_catalogs_test.go | 306 ++++++++++++++++++ internal/state/historycatalog/catalog.go | 8 +- internal/state/historycatalog/inspect.go | 62 ++++ internal/state/historycatalog/inspect_test.go | 52 +++ internal/state/projectiondb/projectiondb.go | 65 +++- .../state/projectiondb/projectiondb_test.go | 32 ++ internal/state/usagecatalog/catalog.go | 3 +- internal/state/usagecatalog/inspect.go | 32 ++ 11 files changed, 653 insertions(+), 23 deletions(-) create mode 100644 internal/frontend/cli/doctor_catalogs_test.go create mode 100644 internal/state/historycatalog/inspect.go create mode 100644 internal/state/historycatalog/inspect_test.go create mode 100644 internal/state/usagecatalog/inspect.go diff --git a/internal/frontend/cli/catalogs.go b/internal/frontend/cli/catalogs.go index a712864388..1e12d45049 100644 --- a/internal/frontend/cli/catalogs.go +++ b/internal/frontend/cli/catalogs.go @@ -18,12 +18,20 @@ import ( type catalogInspection struct { Name string `json:"name"` Info projectiondb.Inspection `json:"info"` + // State is set only when the catalog exists and is not ok, so its presence + // is the signal and a healthy catalog's JSON keeps the shape scripts read. + State string `json:"state,omitempty"` + // Supported is the schema this build migrates to; it explains degraded. + Supported int `json:"supported,omitempty"` + projectiondb.Recorded } type catalogCommand struct { name string path func() string reindex func([]string) int + supportedSchema func() int + inspect func(context.Context, string) (projectiondb.Recorded, error) completionFlags []cliCompletionFlag } @@ -107,7 +115,7 @@ func doctorCatalogsCommand(args []string) int { defer cancel() items := make([]catalogInspection, 0, len(catalogCommands)) for _, command := range catalogCommands { - items = append(items, catalogInspection{Name: command.name, Info: projectiondb.Inspect(ctx, command.path())}) + items = append(items, inspectCatalog(ctx, command)) } if *jsonOut { enc := json.NewEncoder(os.Stdout) @@ -120,17 +128,107 @@ func doctorCatalogsCommand(args []string) int { } fmt.Println("Reasonix disposable catalogs") for _, item := range items { - state := "missing" - if item.Info.Exists && item.Info.Error == "" { - state = item.Info.Integrity - } else if item.Info.Error != "" { - state = "error: " + item.Info.Error - } - fmt.Printf(" %-8s %-12s schema=%d size=%d path=%s\n", item.Name, state, item.Info.Schema, item.Info.Size, item.Info.Path) + fmt.Println(catalogLine(item)) } + // Inspection only reports. It exits 0 in every state because + // `catalogs reindex --json` reports the same failures with 0: a script + // reads the facts, and a person still has a command to run. return 0 } +// inspectCatalog answers two things the old line conflated: what this build +// would do with the file, and what the catalog itself recorded. Only a schema +// this build understands is asked what it recorded — a newer one may not have +// the tables. +func inspectCatalog(ctx context.Context, command catalogCommand) catalogInspection { + item := catalogInspection{Name: command.name, Info: projectiondb.Inspect(ctx, command.path())} + switch { + case !item.Info.Exists: + return item + case item.Info.Error != "": + item.State = "error: " + item.Info.Error + return item + case item.Info.Integrity != "" && item.Info.Integrity != "ok": + item.State = "corrupt" + return item + } + supported := 0 + if command.supportedSchema != nil { + supported = command.supportedSchema() + } + if supported > 0 && item.Info.Schema > supported { + // The runtime serves memory and keeps the file for the build that + // wrote it; saying ok would tell the user their search is complete. + item.State = "degraded" + item.Supported = supported + return item + } + if command.inspect == nil { + return item + } + recorded, err := command.inspect(ctx, item.Info.Path) + if err != nil { + item.State = "error: " + err.Error() + return item + } + item.Recorded = recorded + if recorded.Failed > 0 || recorded.CorruptLines > 0 { + item.State = "failed" + } + return item +} + +// catalogLine keeps one line per catalog, facts included. The prefix is the +// shape it always was, so an older reader still finds schema, size and path; +// the facts append to it because a failure is what the line was missing. +func catalogLine(item catalogInspection) string { + var facts []string + for _, fact := range []struct { + label string + value int64 + }{ + {"failed", item.Failed}, + {"corrupt-lines", item.CorruptLines}, + {"pending", item.Pending}, + {"lag", item.LagBytes}, + } { + if fact.value > 0 { + facts = append(facts, fmt.Sprintf("%s=%d", fact.label, fact.value)) + } + } + if item.Supported > 0 && item.State == "degraded" { + facts = append(facts, fmt.Sprintf("supported=%d", item.Supported)) + } + if len(item.Failures) > 0 { + facts = append(facts, "source="+item.Failures[0].Path+": "+item.Failures[0].LastError) + } + line := fmt.Sprintf(" %-8s %-12s schema=%d size=%d path=%s", + item.Name, oneLine(item.displayState()), item.Info.Schema, item.Info.Size, item.Info.Path) + if len(facts) > 0 { + line += " " + strings.Join(facts, " ") + } + return oneLine(line) +} + +// displayState is the state column's word. An empty State means the catalog is +// there and read cleanly: ok and missing stay out of the JSON, where exists and +// the old fields already said so. +func (item catalogInspection) displayState() string { + if item.State != "" { + return item.State + } + if !item.Info.Exists { + return "missing" + } + return "ok" +} + +// oneLine keeps the promise of one line per catalog: a recorded error carries +// whatever the failure wrapped. +func oneLine(text string) string { + return strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(text) +} + func catalogsCommand(args []string) int { if len(args) < 2 || args[0] != "reindex" { fmt.Fprintln(os.Stderr, "usage: reasonix catalogs reindex [options]") diff --git a/internal/frontend/cli/catalogs_history.go b/internal/frontend/cli/catalogs_history.go index fcbd7b8b1c..b3e307c90f 100644 --- a/internal/frontend/cli/catalogs_history.go +++ b/internal/frontend/cli/catalogs_history.go @@ -14,6 +14,7 @@ import ( func init() { registerCatalogCommand(catalogCommand{ name: "history", path: historycatalog.DefaultPath, reindex: reindexHistoryCatalog, + supportedSchema: historycatalog.SupportedSchema, inspect: historycatalog.Inspect, completionFlags: []cliCompletionFlag{ completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), diff --git a/internal/frontend/cli/catalogs_usage.go b/internal/frontend/cli/catalogs_usage.go index 5082a11646..1090952762 100644 --- a/internal/frontend/cli/catalogs_usage.go +++ b/internal/frontend/cli/catalogs_usage.go @@ -13,6 +13,7 @@ import ( func init() { registerCatalogCommand(catalogCommand{ name: "usage", path: usagecatalog.DefaultPath, reindex: reindexUsageCatalog, + supportedSchema: usagecatalog.SupportedSchema, inspect: usagecatalog.Inspect, completionFlags: []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue)}, }) } diff --git a/internal/frontend/cli/doctor_catalogs_test.go b/internal/frontend/cli/doctor_catalogs_test.go new file mode 100644 index 0000000000..762a48f94b --- /dev/null +++ b/internal/frontend/cli/doctor_catalogs_test.go @@ -0,0 +1,306 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/config" + "reasonix/internal/state/historycatalog" + "reasonix/internal/state/projectiondb" +) + +// catalogCacheDir points the disposable projections at a directory this test +// owns, so `doctor catalogs` reads exactly what the test seeded. +func catalogCacheDir(t *testing.T) string { + t.Helper() + dir := testenv.TempDir(t) + t.Setenv("REASONIX_CACHE_HOME", dir) + return dir +} + +func fileSize(t *testing.T, path string) int64 { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Size() +} + +// reindexHistory runs the release path that writes a real catalog, so the +// fixture carries a failure the product itself recorded rather than one a test +// assigned to a private field. +func reindexHistory(t *testing.T, dirs ...string) { + t.Helper() + args := []string{"--json"} + for _, dir := range dirs { + args = append(args, "--dir", dir) + } + var code int + _, stderr := captureCLIOutput(t, func() { code = reindexHistoryCatalog(args) }) + if code != 0 { + t.Fatalf("reindex history exit=%d stderr=%s", code, stderr) + } +} + +// seedCorruptHistoryCatalog leaves one undecodable transcript behind: the +// catalog records it as health='corrupt' with the reason it failed. +func seedCorruptHistoryCatalog(t *testing.T) (catalogPath, badPath string) { + t.Helper() + sessions := testenv.TempDir(t) + badPath = filepath.Join(sessions, "undecodable.jsonl") + if err := os.WriteFile(badPath, []byte("this is not a session transcript\n"), 0o600); err != nil { + t.Fatal(err) + } + reindexHistory(t, sessions) + return historycatalog.DefaultPath(), badPath +} + +// bumpSchemaVersion writes the ledger row a newer build's migration writes. +func bumpSchemaVersion(t *testing.T, path string, version int) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`INSERT INTO schema_migrations(version,applied_at) VALUES(?,1)`, version); err != nil { + t.Fatal(err) + } +} + +func runDoctorCatalogs(t *testing.T, args ...string) (stdout string, code int) { + t.Helper() + stdout, _ = captureCLIOutput(t, func() { code = doctorCatalogsCommand(args) }) + return stdout, code +} + +// doctorCatalogLine is the one line the command printed for a catalog; one line per +// catalog is part of what the command promises. +func doctorCatalogLine(t *testing.T, stdout, name string) string { + t.Helper() + var found []string + for line := range strings.SplitSeq(strings.TrimSuffix(stdout, "\n"), "\n") { + if strings.HasPrefix(line, " "+name+" ") { + found = append(found, line) + } + } + if len(found) != 1 { + t.Fatalf("want exactly one %q line, got %d:\n%s", name, len(found), stdout) + } + return found[0] +} + +// A catalog that recorded a failed source is not ok. The old line said it was, +// which is why the person never ran the reindex that would name the source. +func TestDoctorCatalogsNamesARecordedSourceFailure(t *testing.T) { + catalogCacheDir(t) + catalogPath, badPath := seedCorruptHistoryCatalog(t) + + stdout, code := runDoctorCatalogs(t) + if code != 0 { + t.Fatalf("doctor catalogs exit=%d, want 0: unlike `doctor repair`, this only reports", code) + } + line := doctorCatalogLine(t, stdout, "history") + // The exact line the defect printed for this fixture, kept as the thing + // that must never come back. + oldLine := fmt.Sprintf(" history ok schema=1 size=%d path=%s", + fileSize(t, catalogPath), catalogPath) + if line == oldLine { + t.Fatalf("a catalog that recorded a failure still prints %q", oldLine) + } + prefix := fmt.Sprintf(" history failed schema=1 size=%d path=%s failed=1 source=%s: ", + fileSize(t, catalogPath), catalogPath, badPath) + if !strings.HasPrefix(line, prefix) { + t.Fatalf("history line =\n %q\nwant prefix\n %q", line, prefix) + } + if reason := strings.TrimPrefix(line, prefix); strings.TrimSpace(reason) == "" { + t.Fatalf("history line names the source but not why it failed: %q", line) + } + if strings.Contains(line, " ok ") { + t.Fatalf("history line still says ok: %q", line) + } +} + +// The runtime opens a newer schema as StateDegraded and serves memory; the +// catalog is not ok, it is read-only until the build catches up. +func TestDoctorCatalogsReportsFutureSchemaAsDegraded(t *testing.T) { + catalogCacheDir(t) + reindexHistory(t, testenv.TempDir(t)) + catalogPath := historycatalog.DefaultPath() + bumpSchemaVersion(t, catalogPath, 2) + // Prove the fixture is the state the runtime actually reaches. + opened, err := historycatalog.Open(context.Background(), historycatalog.Options{Path: catalogPath}) + if err != nil { + t.Fatal(err) + } + state := opened.Status().State + if err := opened.Close(context.Background()); err != nil { + t.Fatal(err) + } + if state != string(projectiondb.StateDegraded) { + t.Fatalf("fixture: runtime state = %q, want degraded", state) + } + + stdout, code := runDoctorCatalogs(t) + if code != 0 { + t.Fatalf("doctor catalogs exit=%d, want 0", code) + } + want := fmt.Sprintf(" history degraded schema=2 size=%d path=%s supported=1", + fileSize(t, catalogPath), catalogPath) + if line := doctorCatalogLine(t, stdout, "history"); line != want { + t.Fatalf("history line =\n %q\nwant\n %q", line, want) + } +} + +func TestDoctorCatalogsHealthyCatalogStillSaysOK(t *testing.T) { + catalogCacheDir(t) + reindexHistory(t, testenv.TempDir(t)) + catalogPath := historycatalog.DefaultPath() + + stdout, code := runDoctorCatalogs(t) + if code != 0 { + t.Fatalf("doctor catalogs exit=%d, want 0", code) + } + want := fmt.Sprintf(" history ok schema=1 size=%d path=%s", fileSize(t, catalogPath), catalogPath) + if line := doctorCatalogLine(t, stdout, "history"); line != want { + t.Fatalf("history line =\n %q\nwant the unchanged healthy line\n %q", line, want) + } +} + +func TestDoctorCatalogsMissingCatalogStillSaysSo(t *testing.T) { + catalogCacheDir(t) + + stdout, code := runDoctorCatalogs(t) + if code != 0 { + t.Fatalf("doctor catalogs exit=%d, want 0", code) + } + for _, command := range catalogCommands { + want := fmt.Sprintf(" %-8s %-12s schema=0 size=0 path=%s", command.name, "missing", command.path()) + if line := doctorCatalogLine(t, stdout, command.name); line != want { + t.Fatalf("%s line =\n %q\nwant\n %q", command.name, line, want) + } + } +} + +// A script has to be able to act on the same facts the text says. +func TestDoctorCatalogsJSONCarriesTheRecordedFailure(t *testing.T) { + catalogCacheDir(t) + _, badPath := seedCorruptHistoryCatalog(t) + + stdout, code := runDoctorCatalogs(t, "--json") + if code != 0 { + t.Fatalf("doctor catalogs --json exit=%d, want 0", code) + } + history := decodeCatalogRows(t, stdout)["history"] + for _, field := range []string{"name", "info"} { + if _, ok := history[field]; !ok { + t.Fatalf("healthy-shaped fields were dropped: no %q in %v", field, history) + } + } + if got := jsonString(t, history["state"]); got != "failed" { + t.Fatalf("state = %q, want failed", got) + } + if got := jsonNumber(t, history["failed"]); got != 1 { + t.Fatalf("failed = %d, want 1", got) + } + var failures []struct { + Path string `json:"path"` + Health string `json:"health"` + LastError string `json:"lastError"` + } + if err := json.Unmarshal(history["failures"], &failures); err != nil { + t.Fatalf("failures: %v (%s)", err, history["failures"]) + } + if len(failures) != 1 || failures[0].Path != badPath || failures[0].Health != "corrupt" || failures[0].LastError == "" { + t.Fatalf("failures = %#v, want the corrupt source at %s with its reason", failures, badPath) + } +} + +func TestDoctorCatalogsJSONHealthyKeepsItsShape(t *testing.T) { + catalogCacheDir(t) + reindexHistory(t, testenv.TempDir(t)) + + stdout, _ := runDoctorCatalogs(t, "--json") + history := decodeCatalogRows(t, stdout)["history"] + for field := range history { + switch field { + case "name", "info": + default: + t.Fatalf("healthy JSON grew a %q field a script did not see before: %v", field, history) + } + } + var info map[string]json.RawMessage + if err := json.Unmarshal(history["info"], &info); err != nil { + t.Fatal(err) + } + for _, field := range []string{"exists", "path", "schema", "size"} { + if _, ok := info[field]; !ok { + t.Fatalf("info lost %q: %v", field, info) + } + } +} + +func decodeCatalogRows(t *testing.T, stdout string) map[string]map[string]json.RawMessage { + t.Helper() + var items []map[string]json.RawMessage + if err := json.Unmarshal([]byte(stdout), &items); err != nil { + t.Fatalf("decode %q: %v", stdout, err) + } + byName := map[string]map[string]json.RawMessage{} + for _, item := range items { + byName[jsonString(t, item["name"])] = item + } + return byName +} + +func jsonString(t *testing.T, raw json.RawMessage) string { + t.Helper() + var value string + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s as string: %v", raw, err) + } + return value +} + +func jsonNumber(t *testing.T, raw json.RawMessage) int64 { + t.Helper() + var value int64 + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s as number: %v", raw, err) + } + return value +} + +// The usage catalog records unreadable lines too; the same column must not say +// ok while it has. +func TestDoctorCatalogsUsageCorruptLinesAreNotOK(t *testing.T) { + catalogCacheDir(t) + t.Setenv("REASONIX_STATE_HOME", testenv.TempDir(t)) + stats := config.StatsDir() + if err := os.MkdirAll(stats, 0o700); err != nil { + t.Fatal(err) + } + day := filepath.Join(stats, "2026-08-02.jsonl") + if err := os.WriteFile(day, []byte("not json at all\n"), 0o600); err != nil { + t.Fatal(err) + } + var code int + if _, stderr := captureCLIOutput(t, func() { code = reindexUsageCatalog([]string{"--json"}) }); code != 0 { + t.Fatalf("reindex usage exit=%d stderr=%s", code, stderr) + } + stdout, _ := runDoctorCatalogs(t) + line := doctorCatalogLine(t, stdout, "usage") + if !strings.Contains(line, " failed ") || !strings.Contains(line, " corrupt-lines=1") { + t.Fatalf("usage line = %q, want the recorded corrupt line reported as a count", line) + } +} diff --git a/internal/state/historycatalog/catalog.go b/internal/state/historycatalog/catalog.go index f1d66b5491..de04469860 100644 --- a/internal/state/historycatalog/catalog.go +++ b/internal/state/historycatalog/catalog.go @@ -637,13 +637,9 @@ func (c *Catalog) Purge(ctx context.Context, path string) error { } func (c *Catalog) refreshStatus(ctx context.Context) { - var indexed, total, pending, failed int64 - _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_sources WHERE health='ok'`).Scan(&indexed) - _ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total),0) FROM history_roots`).Scan(&total) - _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_roots WHERE state<>'ready'`).Scan(&pending) - _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_sources WHERE health='corrupt'`).Scan(&failed) + counts := readStatusCounts(ctx, c.db) c.statusMu.Lock() - c.status.Indexed, c.status.Total, c.status.Pending, c.status.Failed = indexed, total, pending, failed + c.status.Indexed, c.status.Total, c.status.Pending, c.status.Failed = counts.Indexed, counts.Total, counts.Pending, counts.Failed c.status.Revision = c.revision.Load() c.statusMu.Unlock() } diff --git a/internal/state/historycatalog/inspect.go b/internal/state/historycatalog/inspect.go new file mode 100644 index 0000000000..86408ecdf7 --- /dev/null +++ b/internal/state/historycatalog/inspect.go @@ -0,0 +1,62 @@ +package historycatalog + +import ( + "context" + "database/sql" + + "reasonix/internal/state/projectiondb" +) + +// reportedFailures caps the sources a diagnostic names. The count covers every +// failed source; the names are the first ones to act on. +const reportedFailures = 5 + +// SupportedSchema is the newest projection schema this build migrates to. A +// catalog recording a higher one is what the runtime opens as degraded. +func SupportedSchema() int { return projectiondb.SupportedVersion(migrations()) } + +// statusCounts are the numbers Status publishes. +type statusCounts struct { + Indexed int64 + Total int64 + Pending int64 + Failed int64 +} + +// readStatusCounts is the one read behind both Status and Inspect, so a +// diagnostic and a reindex cannot disagree about what the catalog recorded. +func readStatusCounts(ctx context.Context, db *sql.DB) statusCounts { + var counts statusCounts + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_sources WHERE health='ok'`).Scan(&counts.Indexed) + _ = db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total),0) FROM history_roots`).Scan(&counts.Total) + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_roots WHERE state<>'ready'`).Scan(&counts.Pending) + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM history_sources WHERE health='corrupt'`).Scan(&counts.Failed) + return counts +} + +// Inspect reads what an existing history catalog recorded about the sources it +// could not index, without opening it for writing. Failed is the number +// `catalogs reindex --json` reports: both come from readStatusCounts. +func Inspect(ctx context.Context, path string) (projectiondb.Recorded, error) { + db, err := projectiondb.OpenReadOnly(ctx, path) + if err != nil { + return projectiondb.Recorded{}, err + } + defer db.Close() + counts := readStatusCounts(ctx, db) + recorded := projectiondb.Recorded{Failed: counts.Failed, Pending: counts.Pending} + rows, err := db.QueryContext(ctx, `SELECT path,health,last_error FROM history_sources + WHERE health='corrupt' ORDER BY path LIMIT ?`, reportedFailures) + if err != nil { + return recorded, err + } + defer rows.Close() + for rows.Next() { + var failure projectiondb.Failure + if err := rows.Scan(&failure.Path, &failure.Health, &failure.LastError); err != nil { + return recorded, err + } + recorded.Failures = append(recorded.Failures, failure) + } + return recorded, rows.Err() +} diff --git a/internal/state/historycatalog/inspect_test.go b/internal/state/historycatalog/inspect_test.go new file mode 100644 index 0000000000..bb31430eaf --- /dev/null +++ b/internal/state/historycatalog/inspect_test.go @@ -0,0 +1,52 @@ +package historycatalog + +import ( + "context" + "os" + "path/filepath" + "testing" + + "reasonix/internal/base/testenv" +) + +// A diagnostic and a reindex must read the same numbers. Failed here is the +// count `catalogs reindex --json` reports, and the source it names is the one +// the catalog recorded as corrupt. +func TestInspectAgreesWithStatusOnFailedSources(t *testing.T) { + sessions := testenv.TempDir(t) + path := filepath.Join(testenv.TempDir(t), "history.sqlite") + catalog, err := Open(context.Background(), Options{Path: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + badPath := filepath.Join(sessions, "undecodable.jsonl") + if err := os.WriteFile(badPath, []byte("this is not a session transcript\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := catalog.ReconcileRoot(context.Background(), Root{Path: sessions, Source: "global", Scope: "global"}); err != nil { + t.Fatal(err) + } + status := catalog.Status() + if status.Failed != 1 { + t.Fatalf("fixture: status failed=%d, want the corrupt source recorded", status.Failed) + } + + // The catalog is still open, so its row lives in the WAL: an inspection + // that skipped the WAL would report a healthy catalog here. + recorded, err := Inspect(context.Background(), path) + if err != nil { + t.Fatal(err) + } + if recorded.Failed != status.Failed || recorded.Pending != status.Pending { + t.Fatalf("inspect failed=%d pending=%d, status failed=%d pending=%d", + recorded.Failed, recorded.Pending, status.Failed, status.Pending) + } + if len(recorded.Failures) != 1 { + t.Fatalf("failures = %#v, want the one corrupt source", recorded.Failures) + } + failure := recorded.Failures[0] + if failure.Path != badPath || failure.Health != "corrupt" || failure.LastError == "" { + t.Fatalf("failure = %#v, want %s recorded corrupt with its reason", failure, badPath) + } +} diff --git a/internal/state/projectiondb/projectiondb.go b/internal/state/projectiondb/projectiondb.go index 73301ad386..bf7cc295bc 100644 --- a/internal/state/projectiondb/projectiondb.go +++ b/internal/state/projectiondb/projectiondb.go @@ -276,12 +276,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, migrations []Migration, no if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(¤t); err != nil { return err } - supported := 0 - for _, migration := range migrations { - if migration.Version > supported { - supported = migration.Version - } - } + supported := SupportedVersion(migrations) if current > supported { return &FutureSchemaError{Found: current, Supported: supported} } @@ -321,6 +316,62 @@ type Inspection struct { Error string `json:"error,omitempty"` } +// Recorded is what a projection's own tables say it could not index. It is +// read back, never derived from the source files: only the database knows what +// it recorded as failed, and why. +type Recorded struct { + Failed int64 `json:"failed,omitempty"` + Pending int64 `json:"pending,omitempty"` + LagBytes int64 `json:"lagBytes,omitempty"` + CorruptLines int64 `json:"corruptLines,omitempty"` + Failures []Failure `json:"failures,omitempty"` +} + +// Failure is one source a projection recorded as unindexable. Health is the +// projection's own word for the state; projections that do not classify leave +// it empty. +type Failure struct { + Path string `json:"path"` + Health string `json:"health,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +// SupportedVersion is the newest migration this build can apply. A projection +// recording a higher version is the one Open serves as StateDegraded. +func SupportedVersion(migrations []Migration) int { + supported := 0 + for _, migration := range migrations { + if migration.Version > supported { + supported = migration.Version + } + } + return supported +} + +// OpenReadOnly opens an existing projection for inspection. It never creates, +// migrates, repairs, or quarantines one. mode=ro is tried first because +// immutable=1 makes SQLite skip a live WAL, so a catalog another process holds +// open would read as its last checkpoint; immutable=1 stays the fallback for +// media where the shared-memory sidecar cannot be created. +func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) { + var firstErr error + for _, dsn := range []string{diskFileDSN(path) + "&mode=ro", diskFileDSN(path) + "&mode=ro&immutable=1"} { + db, err := sql.Open("sqlite", dsn) + if err != nil { + firstErr = err + continue + } + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + firstErr = err + continue + } + return db, nil + } + return nil, firstErr +} + // Inspect is deliberately read-only: it never creates, migrates, repairs, or // quarantines a database. func Inspect(ctx context.Context, path string) Inspection { @@ -335,7 +386,7 @@ func Inspect(ctx context.Context, path string) Inspection { } out.Exists = true out.Size = info.Size() - db, err := sql.Open("sqlite", diskFileDSN(path)+"&mode=ro&immutable=1") + db, err := OpenReadOnly(ctx, path) if err != nil { out.Error = err.Error() return out diff --git a/internal/state/projectiondb/projectiondb_test.go b/internal/state/projectiondb/projectiondb_test.go index 19f4599db0..3e9a92ed67 100644 --- a/internal/state/projectiondb/projectiondb_test.go +++ b/internal/state/projectiondb/projectiondb_test.go @@ -83,6 +83,38 @@ func TestInspectDoesNotCreateMissingDatabase(t *testing.T) { } } +// A live catalog keeps its recent rows in the WAL. Reading the file as +// immutable=1 would answer from the last checkpoint, so an inspection would +// call a row that just recorded a failure absent. +func TestOpenReadOnlySeesRowsAWriterHasNotCheckpointed(t *testing.T) { + t.Parallel() + path := filepath.Join(testenv.TempDir(t), "catalog.sqlite") + handle, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "wal", Migrations: testMigrations()}) + if err != nil { + t.Fatal(err) + } + defer handle.DB.Close() + // Settle the schema into the main file so only the row below is in the WAL. + if _, err := handle.DB.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + t.Fatal(err) + } + if _, err := handle.DB.Exec(`INSERT INTO values_table(value) VALUES('wal-only')`); err != nil { + t.Fatal(err) + } + db, err := OpenReadOnly(context.Background(), path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var rows int + if err := db.QueryRow(`SELECT COUNT(*) FROM values_table`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("rows = %d, want the row the writer has not checkpointed", rows) + } +} + func TestRebuildPublishesOnlyValidatedReplacement(t *testing.T) { t.Parallel() path := filepath.Join(testenv.TempDir(t), "catalog.sqlite") diff --git a/internal/state/usagecatalog/catalog.go b/internal/state/usagecatalog/catalog.go index bff4cf9643..9d5287f517 100644 --- a/internal/state/usagecatalog/catalog.go +++ b/internal/state/usagecatalog/catalog.go @@ -715,8 +715,7 @@ func bump(ctx context.Context, tx *sql.Tx) (uint64, error) { func (c *Catalog) NoteFallback() { c.fallback.Add(1) } func (c *Catalog) refresh(ctx context.Context) { - var files, lag, corrupt int64 - _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(SUM(MAX(size-indexed_offset,0)),0),COALESCE(SUM(corrupt_lines),0) FROM usage_files`).Scan(&files, &lag, &corrupt) + files, lag, corrupt := readStatusCounts(ctx, c.db) c.statusMu.Lock() c.status.Revision, c.status.IndexedFiles, c.status.LagBytes, c.status.CorruptLines = c.revision.Load(), files, lag, corrupt c.status.Fallbacks = c.fallback.Load() diff --git a/internal/state/usagecatalog/inspect.go b/internal/state/usagecatalog/inspect.go new file mode 100644 index 0000000000..0d7b47be34 --- /dev/null +++ b/internal/state/usagecatalog/inspect.go @@ -0,0 +1,32 @@ +package usagecatalog + +import ( + "context" + "database/sql" + + "reasonix/internal/state/projectiondb" +) + +// SupportedSchema is the newest projection schema this build migrates to. A +// catalog recording a higher one is what the runtime opens as degraded. +func SupportedSchema() int { return projectiondb.SupportedVersion(migrations()) } + +// readStatusCounts is the one read behind both Status and Inspect, so a +// diagnostic and a reindex cannot disagree about what the catalog recorded. +func readStatusCounts(ctx context.Context, db *sql.DB) (files, lag, corrupt int64) { + _ = db.QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(SUM(MAX(size-indexed_offset,0)),0),COALESCE(SUM(corrupt_lines),0) FROM usage_files`).Scan(&files, &lag, &corrupt) + return files, lag, corrupt +} + +// Inspect reads what an existing usage catalog recorded about the lines it +// could not read and the bytes it has not caught up to, without opening it for +// writing. Both numbers come from readStatusCounts, the read Status publishes. +func Inspect(ctx context.Context, path string) (projectiondb.Recorded, error) { + db, err := projectiondb.OpenReadOnly(ctx, path) + if err != nil { + return projectiondb.Recorded{}, err + } + defer db.Close() + _, lag, corrupt := readStatusCounts(ctx, db) + return projectiondb.Recorded{LagBytes: lag, CorruptLines: corrupt}, nil +} From 1bcded8b1b097b17b02ced9064f83fa121ff12fa Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 27 Oct 2026 11:03:38 +0800 Subject: [PATCH 15/37] docs(release-notes): record #14549 --- release-notes/studio/unreleased/14549-doctor-catalogs-state.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 release-notes/studio/unreleased/14549-doctor-catalogs-state.md diff --git a/release-notes/studio/unreleased/14549-doctor-catalogs-state.md b/release-notes/studio/unreleased/14549-doctor-catalogs-state.md new file mode 100644 index 0000000000..44b1aa02b5 --- /dev/null +++ b/release-notes/studio/unreleased/14549-doctor-catalogs-state.md @@ -0,0 +1,2 @@ +- 修复:`reasonix doctor catalogs` 不再对记录过失败的目录打印 `ok`,而是显示 `failed` 或 `degraded`,并给出失败数量与来源。关联 #14549。 +- Fixed: `reasonix doctor catalogs` no longer prints `ok` for a catalog that recorded a failure; it reports `failed` or `degraded` with the count and the failing source. Refs #14549. From 87f7238ab782acf9eba38285e4bd7fce519eeadf Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 27 Oct 2026 11:51:05 +0800 Subject: [PATCH 16/37] chore: retrigger the suite after a flaky boot run The boot package ran 726s on that job against 264s here, and this change does not reach it: projectiondb.OpenReadOnly/Inspect have one production caller and it is the doctor command. No code change. From e031cd0cf6d2fcc82f84e6d036d37b4e8c77a254 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:27 +0800 Subject: [PATCH 17/37] fix(studio): clarify worktree labels and directory choices Problem: Directory labels were inconsistent across the header and composer, and the default green badge looked like a success status. Root cause: Separate presentation strings described the same workspace state, including an unnecessary badge for the ordinary directory case. Fix: Show neutral labels only for isolated worktrees, retain explicit current-folder/worktree choices with checkmarks, and share explanatory tooltips while preserving full paths and branches. Update translations, usage documentation, and the release note. Verification: Frontend interaction, translation and style checks; TypeScript; make check; gofmt; go vet; make lint; browser comparisons in light/dark themes at wide and phone widths. Required Go package and production build results are recorded in the pull request. --- desktop/frontend-next/src/i18n/en.ts | 6 ++- desktop/frontend-next/src/i18n/en_composer.ts | 8 +-- desktop/frontend-next/src/styles/app.css | 2 +- desktop/frontend-next/src/styles/mono.test.ts | 2 +- .../src/styles/worktree-setup.css | 2 +- desktop/frontend-next/src/styles/worktree.css | 6 +-- desktop/frontend-next/src/ui/Chrome.tsx | 7 +-- .../ui/Composer.worktree.interaction.test.tsx | 2 +- desktop/frontend-next/src/ui/WorktreeChip.tsx | 14 ++++-- .../src/ui/WorktreeSetupPage.tsx | 2 +- .../src/ui/workspaceDirectory.ts | 7 +++ .../ui/worktree-composer.interaction.test.tsx | 50 ++++++++++++++++--- .../ui/worktree-project.interaction.test.tsx | 21 ++++---- docs/GUIDE.md | 14 ++++-- docs/GUIDE.zh-CN.md | 6 ++- docs/SESSION_WORKTREES.md | 8 +-- docs/workspace-preparation.md | 8 +-- .../unreleased/worktree-directory-labels.md | 5 ++ 18 files changed, 120 insertions(+), 50 deletions(-) create mode 100644 desktop/frontend-next/src/ui/workspaceDirectory.ts create mode 100644 release-notes/studio/unreleased/worktree-directory-labels.md diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 5f863c0b71..3294ed1bdf 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -603,8 +603,10 @@ export const EN: Record = { "读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.", "使用当前位置": "Stay where I am", "隔离": "Isolated", - "原目录": "Local checkout", - "隔离目录": "Worktree", + "独立工作树": "Isolated worktree", + "直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.", + "在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.", + "发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.", "移除": "Remove", "删除": "Delete", "删除该会话": "Delete this session", diff --git a/desktop/frontend-next/src/i18n/en_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts index 87ff2d54e8..714dce6b78 100644 --- a/desktop/frontend-next/src/i18n/en_composer.ts +++ b/desktop/frontend-next/src/i18n/en_composer.ts @@ -59,13 +59,10 @@ export const EN_COMPOSER: Record = { "创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.", "创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.", "配置工作目录": "Configure workspace", - "独立 worktree · {branch}": "Isolated worktree · {branch}", - "当前目录 · {branch}": "Current folder · {branch}", - "新建 worktree": "New worktree", - "独立 worktree": "Isolated worktree", + "独立工作树 · {branch}": "Isolated worktree · {branch}", + "当前目录": "Current folder", "此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.", "如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.", - "使用当前目录": "Use current folder", "关闭工作目录配置": "Close workspace configuration", "发送消息时开始创建。": " Creation starts when you send the message.", "选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.", @@ -78,7 +75,6 @@ export const EN_COMPOSER: Record = { "选择起始分支": "Choose source branch", "搜索分支": "Search branches", "搜索分支…": "Search branches…", - "当前目录": "Current folder", "查看完整路径": "Show full path", "复制完整路径": "Copy full path", "复制操作编号": "Copy operation ID", diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 18e851d127..9e2bbe3b86 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -201,7 +201,7 @@ .btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); } .picker { position: relative; flex: none } - .isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) } + .isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap); color: var(--muted); padding: 1.5px 6px; border-radius: var(--r-pill); background: var(--overlay) } .isolab[hidden] { display: none } .hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) } diff --git a/desktop/frontend-next/src/styles/mono.test.ts b/desktop/frontend-next/src/styles/mono.test.ts index 01a42976f3..ffd6f666bf 100644 --- a/desktop/frontend-next/src/styles/mono.test.ts +++ b/desktop/frontend-next/src/styles/mono.test.ts @@ -35,7 +35,7 @@ const MARKS: Record = { // change per call site rather than per rule — recorded so the count cannot grow // while the work is outstanding. Never widen this to land a change. const MIXED = new Set([ - "c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "isolab", "k", + "c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "k", "lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt", "sc", "st", "sym", "sz", "tag", "u", "uspan", "v", ]); diff --git a/desktop/frontend-next/src/styles/worktree-setup.css b/desktop/frontend-next/src/styles/worktree-setup.css index 8455d345ca..ac0e128cb2 100644 --- a/desktop/frontend-next/src/styles/worktree-setup.css +++ b/desktop/frontend-next/src/styles/worktree-setup.css @@ -3,7 +3,7 @@ .worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px } .worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 } .worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) } -.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) } +.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--overlay); color: var(--muted); font-size: var(--fs-micro) } .worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em } .worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 } .worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) } diff --git a/desktop/frontend-next/src/styles/worktree.css b/desktop/frontend-next/src/styles/worktree.css index 8804073483..7ed4508a08 100644 --- a/desktop/frontend-next/src/styles/worktree.css +++ b/desktop/frontend-next/src/styles/worktree.css @@ -1,6 +1,6 @@ .worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } -:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) } -:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) } +:root .compose .mode.worktree-trigger[data-on] { color: var(--muted); background: var(--overlay) } +:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--muted) } .workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) } .workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 } .workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) } @@ -9,7 +9,7 @@ .workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px } .workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) } .workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) } -.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) } +.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--muted); background: var(--overlay); font-size: var(--fs-micro) } .worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px } .worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer } .worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) } diff --git a/desktop/frontend-next/src/ui/Chrome.tsx b/desktop/frontend-next/src/ui/Chrome.tsx index 4f28c65e67..97a502eca1 100644 --- a/desktop/frontend-next/src/ui/Chrome.tsx +++ b/desktop/frontend-next/src/ui/Chrome.tsx @@ -10,6 +10,7 @@ import { ariaChord, chord } from "./keys"; import { PhonePop } from "./PhonePop"; import { ViewSwitch } from "./ViewSwitch"; import { TooltipButton } from "./TooltipButton"; +import { workspaceDirectoryDescription } from "./workspaceDirectory"; import { WindowControls, zoomOnTitleBar } from "./WindowControls"; import type { PaneView } from "../state/paneview"; @@ -99,9 +100,9 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe {project} - + {ws?.isolated && ws.current === root && + {t("独立工作树")} + } diff --git a/desktop/frontend-next/src/ui/Composer.worktree.interaction.test.tsx b/desktop/frontend-next/src/ui/Composer.worktree.interaction.test.tsx index f53fec8a5c..26fe5db08b 100644 --- a/desktop/frontend-next/src/ui/Composer.worktree.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/Composer.worktree.interaction.test.tsx @@ -18,7 +18,7 @@ it.each(["failed", "cancelled"])("restores composer focus after dismissing %s pr const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus; render(); fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" })); - fireEvent.click(screen.getByRole("button", { name: "新建 worktree" })); + fireEvent.click(screen.getByRole("button", { name: "独立工作树" })); fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" })); const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement; box.focus(); diff --git a/desktop/frontend-next/src/ui/WorktreeChip.tsx b/desktop/frontend-next/src/ui/WorktreeChip.tsx index 52d07f1ddc..1e177a9b4a 100644 --- a/desktop/frontend-next/src/ui/WorktreeChip.tsx +++ b/desktop/frontend-next/src/ui/WorktreeChip.tsx @@ -5,6 +5,7 @@ import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/ import type { AgentPort, WorkspaceGit } from "../port/port"; import { useWorkspaceBranches } from "./useWorkspaceBranches"; import { StudioIcon } from "./StudioIcon"; +import { workspaceDirectoryDescription } from "./workspaceDirectory"; import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath"; import { listenAction } from "./listen"; import { beside, pinToViewport, useFollow, zoom } from "./place"; @@ -43,6 +44,8 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated; const chosen = sources.sources.find((source) => source.ref === selection?.ref); const label = chosen?.name ?? selection?.ref ?? branch; + const creationDescription = t("发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。"); + const directoryDescription = selection ? creationDescription : workspaceDirectoryDescription(isolated); const scope = useRef({ port, root: sources.sourceRoot }); useEffect(() => { let live = true; @@ -96,13 +99,16 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan const failed = selection ? sourceError : reading === "failed"; const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0; return
    - {open && createPortal(
    {t("配置工作目录")}
    -
    {workspaceName(sources.sourceRoot)}{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}
    - {canCreate &&
    } +
    {workspaceName(sources.sourceRoot)}{(selection || isolated) && {t("独立工作树")}}
    + {canCreate &&
    + + +
    } {selection &&
    {t("起始分支")}{compactWorkspaceValue(label)}{t("任务分支自动创建")}
    }
    setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector('[role="option"]:not(:disabled)')?.focus(); } diff --git a/desktop/frontend-next/src/ui/WorktreeSetupPage.tsx b/desktop/frontend-next/src/ui/WorktreeSetupPage.tsx index 016d1381dd..9be9d2d844 100644 --- a/desktop/frontend-next/src/ui/WorktreeSetupPage.tsx +++ b/desktop/frontend-next/src/ui/WorktreeSetupPage.tsx @@ -53,7 +53,7 @@ export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel, const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock"; const phase = error || t(phases[stage.phase] || "正在等待状态确认"); return
    -
    {workspaceName(stage.sourceRoot)}{t("独立 worktree")}

    {t(headline)}

    {t(description)}

    +
    {workspaceName(stage.sourceRoot)}{t("独立工作树")}

    {t(headline)}

    {t(description)}

    {active ? : }{phase}{active && {t("已等待 {n} 秒", { n: elapsed })}}
    {slow &&

    {t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}

    } {stage.phase === "cleanup_failed" &&

    {t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}

    } diff --git a/desktop/frontend-next/src/ui/workspaceDirectory.ts b/desktop/frontend-next/src/ui/workspaceDirectory.ts new file mode 100644 index 0000000000..c4cac9ccdc --- /dev/null +++ b/desktop/frontend-next/src/ui/workspaceDirectory.ts @@ -0,0 +1,7 @@ +import { t } from "../i18n"; + +export function workspaceDirectoryDescription(isolated: boolean): string { + return isolated + ? t("在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。") + : t("直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。"); +} diff --git a/desktop/frontend-next/src/ui/worktree-composer.interaction.test.tsx b/desktop/frontend-next/src/ui/worktree-composer.interaction.test.tsx index 5d52b63403..7b23f63dec 100644 --- a/desktop/frontend-next/src/ui/worktree-composer.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/worktree-composer.interaction.test.tsx @@ -23,7 +23,7 @@ function draw(fresh = true, branch = "main", isolated = false) { async function choose() { fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" })); - fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" })); + fireEvent.click(await screen.findByRole("button", { name: "独立工作树" })); fireEvent.change(screen.getByRole("searchbox"), { target: { value: "studio" } }); fireEvent.click(screen.getByRole("option", { name: /studio/ })); fireEvent.keyDown(window, { key: "Escape" }); @@ -33,8 +33,14 @@ async function choose() { it("selects a source without switching the checkout and creates only when sending", async () => { const { port, isolate, submit } = draw(); const switchBranch = vi.spyOn(port, "switchBranch"); - expect((await screen.findByRole("button", { name: "配置工作目录" })).closest(".turntools")).not.toBeNull(); + const trigger = await screen.findByRole("button", { name: "配置工作目录" }); + expect(trigger.closest(".turntools")).not.toBeNull(); + expect(trigger.textContent).toBe("main"); + expect(trigger.title).toContain("直接在已打开的项目文件夹中工作"); + expect(trigger.title).toContain("/work/demo"); await choose(); + expect(trigger.textContent).toContain("独立工作树 · studio"); + expect(trigger.title).toContain("发送消息时创建独立工作树"); expect(isolate).not.toHaveBeenCalled(); expect(switchBranch).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "发送" })); @@ -73,7 +79,7 @@ it("keeps workspace configuration and branch switching in existing conversations expect(trigger.closest(".turntools")).not.toBeNull(); fireEvent.click(trigger); expect(screen.getByLabelText("查看完整路径")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull(); + expect(screen.queryByRole("button", { name: "独立工作树" })).toBeNull(); fireEvent.click(await screen.findByRole("option", { name: /studio/ })); await waitFor(() => expect(move).toHaveBeenCalledWith("studio")); }); @@ -82,13 +88,15 @@ it("shows a completed worktree's actual identity, full branch and path", async ( const branch = "reasonix/delivery-20261017-170557-387acbfc21"; draw(false, branch, true); const trigger = await screen.findByRole("button", { name: "配置工作目录" }); - await waitFor(() => expect(trigger.textContent).toContain("独立 worktree")); - expect(trigger.title).toBe(branch); + await waitFor(() => expect(trigger.textContent).toContain("独立工作树")); + expect(trigger.title).toContain(branch); + expect(trigger.title).toContain("在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。"); + expect(trigger.title).toContain("/work/demo"); fireEvent.click(trigger); expect(screen.getByLabelText("查看完整路径")).toBeTruthy(); expect(screen.getByRole("searchbox", { name: "搜索分支" })).toBeTruthy(); expect(screen.getByText("选择后立即切换,同目录会话也会受影响")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "新建 worktree" })).toBeNull(); + expect(screen.queryByRole("button", { name: "独立工作树" })).toBeNull(); }); it("shows real preparation state and restores the draft only after confirmed cancellation", async () => { @@ -131,10 +139,38 @@ it("offers local branch switching while worktree mode is off", async () => { it("defaults a new worktree to the actual branch after an earlier branch switch", async () => { draw(true, "studio"); fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" })); - fireEvent.click(await screen.findByRole("button", { name: "新建 worktree" })); + fireEvent.click(await screen.findByRole("button", { name: "独立工作树" })); expect(screen.getByRole("option", { name: "studio" }).getAttribute("aria-selected")).toBe("true"); }); +it("keeps both directory choices in the picker without adding a default badge or creating a checkout", async () => { + const { isolate, port } = draw(); + const move = vi.spyOn(port, "switchBranch"); + const trigger = await screen.findByRole("button", { name: "配置工作目录" }); + fireEvent.click(trigger); + const toggle = screen.getByRole("button", { name: "独立工作树" }); + const current = screen.getByRole("button", { name: "当前目录" }); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(current.getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByText("项目目录")).toBeNull(); + expect(document.querySelector(".workspace-palette-identity > span")).toBeNull(); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(current.getAttribute("aria-pressed")).toBe("false"); + expect(trigger.textContent).toBe("独立工作树 · main"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(current); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(current.getAttribute("aria-pressed")).toBe("true"); + expect(trigger.textContent).toBe("main"); + expect(trigger.title).toContain("直接在已打开的项目文件夹中工作"); + expect(screen.queryByText("项目目录")).toBeNull(); + expect(document.querySelector(".workspace-palette-identity > span")).toBeNull(); + expect(isolate).not.toHaveBeenCalled(); + expect(move).not.toHaveBeenCalled(); +}); + it("searches branches immediately and prevents switching an occupied branch", async () => { const { port } = draw(false); const move = vi.spyOn(port, "switchBranch"); diff --git a/desktop/frontend-next/src/ui/worktree-project.interaction.test.tsx b/desktop/frontend-next/src/ui/worktree-project.interaction.test.tsx index 3c92a3b66c..dd8ace0816 100644 --- a/desktop/frontend-next/src/ui/worktree-project.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/worktree-project.interaction.test.tsx @@ -48,15 +48,19 @@ it("closes the project's worktree panes when removing the grouped project", asyn expect(removeWorkspace).toHaveBeenCalledWith(root); }); -it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversation's directory when isolated=%s", async (isolated, label) => { +it.each([false, true])("only shows a directory badge when isolated=%s", async (isolated) => { const port = new MockPort(); const current = isolated ? copy : root; vi.spyOn(port, "workspaces").mockResolvedValue({ current, isolated, canSwitch: true, canIsolate: true, recents: [] }); - render( { render(); - expect(await screen.findByText(label)).toBeTruthy(); - expect(screen.getByText(label).getAttribute("title")).toBe(current); + onFind={vi.fn()} view="flow" rows={0} surfaces={0} onView={vi.fn()} />); }); + expect(screen.queryByText("项目目录")).toBeNull(); + if (isolated) { + expect(screen.getByText("独立工作树").getAttribute("title")).toBe(`在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。\n${current}`); + } else { + expect(screen.queryByText("独立工作树")).toBeNull(); + } }); it("ignores old checkout metadata when switching conversations before it arrives", async () => { @@ -68,9 +72,8 @@ it("ignores old checkout metadata when switching conversations before it arrives const props = { title: "工作", steer: 0, account: null, theme: "dark" as const, onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() }; const { rerender } = render(); - rerender(); - expect(await screen.findByText("原目录")).toBeTruthy(); + await act(async () => { rerender(); }); await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] })); - expect(screen.getByText("原目录").hidden).toBe(false); - expect(screen.queryByText("隔离目录")).toBeNull(); + expect(screen.queryByText("项目目录")).toBeNull(); + expect(screen.queryByText("独立工作树")).toBeNull(); }); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f4b8178a72..5841e7a589 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1008,9 +1008,17 @@ desktop profile. New projects start expanded. These display choices stay on this device; they do not change sessions or synchronize to another client. Ordinary Reasonix-managed worktree chats appear under their original project, -without a separate project row for each checkout. The chat header shows Local -checkout or Worktree, with its actual working directory available on hover; -worktree chats also carry a sidebar marker. +without a separate project row for each checkout. + +Only isolated worktrees carry a neutral Isolated worktree label in the chat +header and composer directory picker. Otherwise, the header has no directory +badge and the picker shows just the branch name. + +Hover for an explanation and the actual directory path. Worktrees separate code +directories; ports, databases, and other runtime resources may still be shared. + +Before sending, a selected worktree explains that creation starts when the +message is sent. Worktree chats also carry a sidebar marker. Reopening history uses that chat's working directory, while starting a chat from the project uses the original directory. Ordering, search, paging, pins diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 44deb9349d..7e2bad8a01 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -894,7 +894,11 @@ Studio 会在同一浏览器或桌面配置中记住侧边栏本机和远程项 已保存项目的操作菜单提供上移和下移。用键盘打开菜单后,方向键或 Home/End 可导航,Escape 会返回触发按钮。移动只改变侧栏顺序,不改变启动项目。 Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔离目录单列同名项目。 -会话顶部显示“原目录”或“隔离目录”,悬停可查看实际工作目录;侧栏也会标记隔离会话。 +只有独立工作树会在顶部和底部目录选择器显示中性色的“独立工作树”标识。 +普通目录不显示徽标,底部只显示分支名。 + +悬停可查看目录说明和实际路径;独立工作树仅分离代码目录,端口、数据库等运行资源仍可能共享。 +底部选择独立工作树但尚未发送时,说明会提示发送消息时才创建;侧栏也会标记隔离会话。 打开历史会话仍使用它原来的工作目录,在项目上新建会话则使用原目录。 排序、搜索、分页、置顶和归档覆盖整个项目。 diff --git a/docs/SESSION_WORKTREES.md b/docs/SESSION_WORKTREES.md index a66d87303e..e15514c5eb 100644 --- a/docs/SESSION_WORKTREES.md +++ b/docs/SESSION_WORKTREES.md @@ -9,9 +9,9 @@ reviewed: 2026-10-15 ## 中文 -在有提交记录的 Git 项目中新建会话,点击输入框内底部工具栏的「当前目录 · 分支」: +在有提交记录的 Git 项目中新建会话,点击输入框内底部工具栏的分支名: -1. 开启「新建 worktree」。 +1. 在「当前目录 / 独立工作树」中选择「独立工作树」。选择「当前目录」可返回直接使用项目文件夹的模式。 2. 点击「起始分支」,搜索并选择本地分支或已获取的远程分支。 3. 输入任务并发送。Reasonix 此时创建独立目录和自动命名的任务分支,再在其中开始对话。 @@ -25,9 +25,9 @@ reviewed: 2026-10-15 ## English -Start a new conversation in a Git project with a commit, then open **Current folder · branch** in the composer's bottom toolbar: +Start a new conversation in a Git project with a commit, then click the branch name in the composer's bottom toolbar: -1. Enable **New worktree**. +1. Choose **Isolated worktree** instead of **Current folder**. Select **Current folder** to return to working directly in the project folder. 2. Open **Base branch** and search local or fetched remote-tracking branches. 3. Send the task. Reasonix creates a separate directory and automatically named task branch before submitting the first message there. diff --git a/docs/workspace-preparation.md b/docs/workspace-preparation.md index a166133ece..e5fd1336d1 100644 --- a/docs/workspace-preparation.md +++ b/docs/workspace-preparation.md @@ -9,12 +9,14 @@ reviewed: 2026-10-17 ## User flow / 用户流程 -In a new conversation, **Configure workspace** offers **Use current folder** -and **New worktree**. The current-folder branch picker switches local branches +In a new conversation, **Configure workspace** offers **Current folder** and +**Isolated worktree** choices. + +In Current folder mode, the branch picker switches local branches immediately through the existing guarded branch API. Branches checked out in another worktree remain visible with their occupied path and cannot be selected. -新会话中的「配置工作目录」提供「使用当前目录」和「新建 worktree」。当前目录的 +新会话中的「配置工作目录」保留「当前目录 / 独立工作树」双选项,并标明选中项。选择当前目录时, 分支选择器通过既有受保护接口立即切换本地分支;其他 worktree 占用的分支显示 占用路径,并禁止切换。 diff --git a/release-notes/studio/unreleased/worktree-directory-labels.md b/release-notes/studio/unreleased/worktree-directory-labels.md new file mode 100644 index 0000000000..bc487ff4bc --- /dev/null +++ b/release-notes/studio/unreleased/worktree-directory-labels.md @@ -0,0 +1,5 @@ +普通目录会话不再显示顶部的“原目录”徽标,底部只显示分支名;独立工作树统一显示“独立工作树”,使用中性色并补充目录说明。工作目录配置仍保留“当前目录 / 独立工作树”双选项和选中标记。 + + + +Ordinary sessions no longer show the Local checkout header badge; the composer shows only the branch. Isolated worktrees use a consistent neutral label with explanatory tooltips. Workspace configuration retains both Current folder and Isolated worktree choices with a selection mark. From 6012926b9faec768a37ee716b4eddb44e8b2bf8c Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:54:03 +0800 Subject: [PATCH 18/37] fix(control): bind root budgets for explicit subagent skills Problem: Explicit slash, composer and CLI profile children could exceed a configured root token/wall budget because they bypassed the normal Agent turn. Root cause: Those Controller entries invoked skill runners without preparing the executor-owned scope; Goal skills installed observation without the spend limit. Synchronous profile calls also lacked turn admission serialization. Fix: Bind the existing Goal/root scope after blocking hooks for interactive skills. Reuse synchronous Controller admission and root preparation for CLI profile run/preview, retaining child pause and cancellation semantics. Verification: Actual Boot HTTP/file-read and Controller lifecycle regressions; exact old-owner overlay fails 13 named events with four passing controls. Complete 239-package coverage includes the retained original Boot 15m alarm and two disjoint remainder groups; four owner race suites and focused Boot race pass. Root vet, full lint, check and actionlint pass. Windows/Linux compile-only checks and the literal native gate contract pass locally. --- .github/workflows/ci.yml | 32 +++ docs/EXPLICIT_SKILL_ROOT_BUDGET.md | 79 ++++++++ .../boot/effect_skill_root_budget_test.go | 183 ++++++++++++++++++ .../session/control/skill_root_budget_test.go | 139 +++++++++++++ internal/session/control/turn_entry.go | 24 ++- internal/session/control/turn_orchestrator.go | 7 +- .../14025-explicit-skill-root-budget.md | 17 ++ 7 files changed, 467 insertions(+), 14 deletions(-) create mode 100644 docs/EXPLICIT_SKILL_ROOT_BUDGET.md create mode 100644 internal/assembly/boot/effect_skill_root_budget_test.go create mode 100644 internal/session/control/skill_root_budget_test.go create mode 100644 release-notes/studio/unreleased/14025-explicit-skill-root-budget.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19fb2134f9..9bcb951ee5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -639,6 +639,38 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: test (Windows explicit skill root budget) + if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + timeout-minutes: 5 + shell: bash + run: | + set -o pipefail + go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl" + jq -se ' + [.[] | select(.Test == null and .Action == "pass") | .Package] as $packages | + [.[] | select(.Test != null and .Action == "pass") | .Test] as $tests | + all(.[]; .Action != "skip" and .Action != "fail") and + (["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and + (["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task", + "TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope", + "TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session", + "TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero", + "TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget", + "TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview", + "TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips", + "TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero", + "TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0 + ' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl" + + - name: upload Windows explicit skill root budget inventory + if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: explicit-skill-root-budget-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: test (Windows root triage budget) if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' timeout-minutes: 5 diff --git a/docs/EXPLICIT_SKILL_ROOT_BUDGET.md b/docs/EXPLICIT_SKILL_ROOT_BUDGET.md new file mode 100644 index 0000000000..608d0192e3 --- /dev/null +++ b/docs/EXPLICIT_SKILL_ROOT_BUDGET.md @@ -0,0 +1,79 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-27 +--- + +# Explicit skill root budget / 显式技能根预算 + +## Behavior / 行为 + +Explicit subagent slash commands and composer selections share the executor's +existing token/wall root. Preparation occurs after checkpoint and blocking +prompt hooks. An active Goal uses its existing budget and usage recorder. + +显式子代理 slash 命令和 composer 选择共享 executor 已有的 token/wall 根预算。 +在检查点及可阻止提交的 hook 之后准备;活跃 Goal 使用已有预算和用量 recorder。 + +Synchronous CLI profile run and preview use the Controller's existing blocking +turn admission before root preparation. Busy, closed and draining controllers +refuse entry; Controller cancellation reaches the admitted profile. Existing +workspace activity and write-authority checks now apply to this entry too. + +同步 CLI profile run 和 preview 在准备根预算前走 Controller 已有同步准入。 +忙碌、关闭或 draining 时拒绝进入;Controller 取消传给已准入的 profile。 +这一入口也遵守已有工作区活动和写入 authority 检查。 + +## Lifetime and limits / 生命周期与边界 + +Same delivery scope retains accumulated spend. New tasks, sessions, scope IDs +and explicit budget resets start a fresh root. An inherited root remains the +owner; projecting an old context into a job retains the old counter without +copying its cancellation. Multiple selected skills share one root. + +同 delivery scope 保留累计用量;新任务、session、scope ID 和显式预算 reset +开始新根。继承的根保持所有权;旧 context 投射给 job 保留旧计数但不复制取消。 +一次选择的多个技能共享同一根。 + +Zero remains unlimited, including an explicit context override. Checks are +cooperative after tools and keep existing finalization. + +A paused child returns +its existing typed error; subsequent selected skills are not started after +that error. A later child may still perform its first tool round on an already +spent root. This is not request admission or a hard reservation. + +零额度仍不限,包括显式 context 覆盖。在工具后协作检查并保留已有收尾。 +暂停的 child 返回已有类型化错误,之后的选中技能不再启动;后续 child 仍可能 +在已耗尽的根上执行首个工具轮。这不是请求准入或硬预留。 + +The clock starts at preparation, not boot or earlier queueing. Local Agent +budgets remain stricter independent gates. Money, durable recovery, all +auxiliary consumers and physical process quiescence remain separate work. + +时钟从准备根开始,不含 boot 或更早排队。Agent 本地额度仍是独立的更严格 gate。 +金额、持久恢复、全部辅助消费者及物理进程静默仍是独立工作。 + +## Evidence / 证据 + +Boot HTTP/SSE tests exercise profile, preview, slash, composer multi-selection +and Goal against real file reads. Zero configuration and explicit zero remain +controls. + +Real Controller/Agent sequences cover root lifetime, old job +projection and cancellation/admission. These are constructed local tests, not +an observed production incident, frontend interaction or restart proof. + +Boot HTTP/SSE 测试通过真实文件读取覆盖 profile、preview、slash、composer +多选和 Goal,保留零配置与显式零值对照。真实 Controller/Agent 序列覆盖根寿命、 +旧 job 投射及取消/准入。这些是本机构造测试,不是线上事故、前端交互或重启证明。 + +No new API, configuration, wire or persisted schema is introduced. Model +selection, tool permissions and request construction stay with their owners. +The HTTP fixture checks stable child prefix/schema bytes within a run; it does +not measure live provider cache hit rate. The complete #14025 plan remains open. + +没有新增 API、配置、传输或持久化 schema。模型选择、工具权限和请求构造保留 +原有 owner。HTTP 夹具只核对同轮 child 前缀/schema 字节稳定,不测线上缓存命中率。 +完整 #14025 计划仍未闭环。 diff --git a/internal/assembly/boot/effect_skill_root_budget_test.go b/internal/assembly/boot/effect_skill_root_budget_test.go new file mode 100644 index 0000000000..51ef252450 --- /dev/null +++ b/internal/assembly/boot/effect_skill_root_budget_test.go @@ -0,0 +1,183 @@ +package boot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/runtime/agent" + "reasonix/internal/session/control" +) + +func TestEffectExplicitSkillRootBudget(t *testing.T) { + for _, tc := range []struct { + name string + limit int + wantReads int32 + }{ + {"profile", 1500, 2}, + {"preview", 1500, 2}, + {"slash", 1500, 2}, + {"chips", 3000, 2}, + {"goal", 0, 2}, + {"context_zero", 1500, 3}, + {"unconfigured", 0, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + home, root := statedBootHome(t), t.TempDir() + writeFile(t, root, "fixture.txt", "explicit skill budget fixture") + for _, name := range []string{"probe", "second"} { + writeFile(t, home, "skills/"+name+"/SKILL.md", "---\nname: "+name+"\ndescription: Read the fixture\nrunAs: subagent\nread-only: true\nallowed-tools: read_file\nmodel: "+name+"\n---\nRead fixture.txt and report it.\n") + } + var calls [2]atomic.Int32 + var reads, receipts atomic.Int32 + var observed sync.Map + var prefixes sync.Map + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string + Tools []json.RawMessage + Messages []struct { + Role string + Content json.RawMessage + ToolCallID string `json:"tool_call_id"` + } + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + for _, message := range req.Messages { + if message.Role == "tool" && strings.Contains(string(message.Content), "explicit skill budget fixture") { + if _, loaded := observed.LoadOrStore(message.ToolCallID, true); !loaded { + reads.Add(1) + } + } + } + if len(req.Tools) > 0 && len(req.Messages) > 0 { + stable, _ := json.Marshal(struct { + Prefix json.RawMessage + Tools []json.RawMessage + }{req.Messages[0].Content, req.Tools}) + previous, loaded := prefixes.LoadOrStore(req.Model, stable) + if loaded && !bytes.Equal(previous.([]byte), stable) { + t.Error("child prefix/schema changed") + } + } + i := 0 + if req.Model == "second" { + i = 1 + } + n := calls[i].Add(1) + w.Header().Set("Content-Type", "text/event-stream") + maxReads := int32(3) + if tc.name == "chips" && i == 0 { + maxReads = 1 + } + if len(req.Tools) > 0 && n <= maxReads { + args, _ := json.Marshal(map[string]string{"path": "fixture.txt"}) + payload, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": fmt.Sprintf("read-%d-%d", i, n), "type": "function", "function": map[string]any{"name": "read_file", "arguments": string(args)}}}}, "finish_reason": "tool_calls"}}}) + _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + } else { + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Fixture read complete.\"},\"finish_reason\":\"stop\"}]}\n\n") + } + _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":100,\"total_tokens\":1100}}\n\ndata: [DONE]\n\n") + })) + t.Cleanup(srv.Close) + goalLimit := 0 + if tc.name == "goal" { + goalLimit = 1500 + } + writeFile(t, root, "reasonix.toml", fmt.Sprintf(`default_model = "probe" +[agent] +task_token_budget = %d +goal_token_budget = %d +[codegraph] +enabled = false +[[providers]] +name = "probe" +kind = "openai" +model = "probe" +base_url = %q +api_key_env = "SKILL_ROOT_EFFECT_KEY" +[[providers]] +name = "second" +kind = "openai" +model = "second" +base_url = %q +api_key_env = "SKILL_ROOT_EFFECT_KEY" +`, tc.limit, goalLimit, srv.URL, srv.URL)) + t.Setenv("SKILL_ROOT_EFFECT_KEY", "test") + approveWorkspaceAt(t, home, root) + done := make(chan struct{}, 1) + ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.Usage && e.UsageSource == event.UsageSourceSubagent { + receipts.Add(1) + if e.Usage == nil || e.Usage.RequestCount != 1 || e.Usage.Estimated { + t.Errorf("usage=%+v", e.Usage) + } + } + if e.Kind == event.TurnDone { + select { + case done <- struct{}{}: + default: + } + } + })}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(ctrl.Close) + ctrl.EnsureSessionPath() + ctrl.SetToolApprovalMode(control.ToolApprovalYolo) + ctx := t.Context() + if tc.name == "context_zero" { + ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{}) + } + switch tc.name { + case "slash": + ctrl.Submit("/probe read the fixture") + case "goal": + ctrl.SetGoal("Read the fixture") + ctrl.Submit("/probe read the fixture") + case "chips": + ctrl.SubmitInvocationDisplay("/probe /second read the fixture", "read the fixture", []control.InvocationRequest{{Name: "probe", Kind: "subagent"}, {Name: "second", Kind: "subagent"}}) + default: + _, err = ctrl.RunSubagentProfile(ctx, "probe", "read the fixture", tc.name == "preview") + if tc.limit > 0 && tc.name != "context_zero" { + if pause, ok := agent.InspectRunPause(err); !ok || pause.Key != "token" { + t.Fatalf("pause=%+v err=%v", pause, err) + } + } else if err != nil { + t.Fatal(err) + } + } + if tc.name == "slash" || tc.name == "chips" || tc.name == "goal" { + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("skill turn did not finish") + } + } + if tc.name == "goal" && ctrl.GoalStatus() != control.GoalStatusBlocked { + t.Errorf("goal status=%s", ctrl.GoalStatus()) + } + if reads.Load() != tc.wantReads { + t.Errorf("real reads=%d, want %d; HTTP=%d/%d", reads.Load(), tc.wantReads, calls[0].Load(), calls[1].Load()) + } + if (tc.name == "slash" || tc.name == "chips") && receipts.Load() != calls[0].Load()+calls[1].Load() { + t.Errorf("receipts=%d HTTP=%d/%d", receipts.Load(), calls[0].Load(), calls[1].Load()) + } + }) + } +} diff --git a/internal/session/control/skill_root_budget_test.go b/internal/session/control/skill_root_budget_test.go new file mode 100644 index 0000000000..1baeab3939 --- /dev/null +++ b/internal/session/control/skill_root_budget_test.go @@ -0,0 +1,139 @@ +package control + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/contract/tool" + "reasonix/internal/ext/skill" + "reasonix/internal/runtime/agent" + "reasonix/internal/runtime/agent/testutil" + "reasonix/internal/state/sessionstore" +) + +func TestProfileRootBudgetLifetime(t *testing.T) { + for _, mode := range []string{"new_task", "same_scope", "new_scope", "reset", "new_session", "inherited", "context_zero"} { + t.Run(mode, func(t *testing.T) { + parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard) + var reads int + var oldContext context.Context + runner := func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) { + oldContext = ctx + reg := tool.NewRegistry() + reg.Add(fakeControlTool{name: "read_file"}) + var turns []testutil.Turn + for i := range 3 { + turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{ID: fmt.Sprintf("read-%d", i), Name: "read_file", Arguments: `{}`}}, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 100, RequestCount: 1}}) + } + turns = append(turns, testutil.Turn{Text: "done"}) + child := agent.New(testutil.NewMock("child", turns...), reg, sessionstore.NewSession("child"), agent.Options{}, event.FuncSink(func(e event.Event) { + if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" { + reads++ + } + })) + err := child.Run(ctx, task) + if _, paused := agent.InspectRunPause(err); paused { + err = nil + } + return "done", err + } + dir := t.TempDir() + c := New(Options{Runner: parent, Executor: parent, SkillRunner: runner, SessionDir: dir, SessionPath: filepath.Join(dir, "s.jsonl"), Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}}) + t.Cleanup(c.Close) + ctx := t.Context() + if mode != "new_task" && mode != "context_zero" { + ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "one", TaskText: "read"}) + } + if mode == "inherited" { + owner := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("outer"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard) + ctx = owner.PrepareRootTaskBudget(ctx) + } + if mode == "context_zero" { + ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{}) + } + if _, err := c.RunSubagentProfile(ctx, "probe", "read", false); err != nil { + t.Fatal(err) + } + first := reads + wantFirst, wantSecond := 2, 2 + if mode == "same_scope" || mode == "inherited" { + wantSecond = 1 + } + switch mode { + case "new_scope": + ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "two", TaskText: "next"}) + case "reset": + parent.ResetTaskBudget() + case "new_session": + parent.SetSession(sessionstore.NewSession("new")) + case "context_zero": + wantFirst, wantSecond = 3, 3 + } + old := oldContext + if _, err := c.RunSubagentProfile(ctx, "probe", "next", false); err != nil { + t.Fatal(err) + } + if first != wantFirst || reads-first != wantSecond { + t.Fatalf("reads=%d/%d want=%d/%d", first, reads-first, wantFirst, wantSecond) + } + if mode == "reset" { + // A projected managed job has independent cancellation but retains its old spend. + jobCtx := agent.InheritRootTokenBudget(t.Context(), old) + before := reads + if _, err := runner(jobCtx, skill.Skill{}, "old job", skill.SubagentRunOptions{}); err != nil { + t.Fatal(err) + } + if reads-before != 1 { + t.Fatalf("old job reads=%d", reads-before) + } + } + }) + } +} + +func TestProfileBudgetPreparationUsesTurnAdmission(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{}, event.Discard) + c := New(Options{Runner: parent, Executor: parent, Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}, SkillRunner: func(ctx context.Context, _ skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) { + if calls.Add(1) != 1 { + return "unexpected concurrent run", nil + } + close(entered) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-release: + return "released", nil + } + }}) + t.Cleanup(c.Close) + defer close(release) + done := make(chan error, 1) + go func() { _, err := c.RunSubagentProfile(t.Context(), "probe", "first", false); done <- err }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("runner did not enter") + } + _, second := c.RunSubagentProfile(t.Context(), "probe", "second", false) + if !errors.Is(second, ErrTurnRunning) || calls.Load() != 1 { + t.Errorf("second=%v calls=%d", second, calls.Load()) + } + c.Cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("cancel=%v", err) + } + case <-time.After(5 * time.Second): + t.Error("controller cancellation did not reach profile") + } +} diff --git a/internal/session/control/turn_entry.go b/internal/session/control/turn_entry.go index fb48259edd..7237534a67 100644 --- a/internal/session/control/turn_entry.go +++ b/internal/session/control/turn_entry.go @@ -297,15 +297,21 @@ func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, return "", fmt.Errorf("subagent skill runner is unavailable for %q", name) } - c.maybeSessionStart(ctx) - parentSession := c.parentSessionID() - ctx = agent.WithParentSession(ctx, parentSession) - ctx = jobs.WithSession(ctx, parentSession) - ctx, turnImgs := c.withTurnImages(ctx, task) - ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage) - ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage) - ctx = agent.WithSubagentDepth(ctx, 0) - answer, err := runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true}) + var answer string + err := c.runSynchronousTurn(ctx, nil, func(ctx context.Context) error { + c.maybeSessionStart(ctx) + parentSession := c.parentSessionID() + ctx = agent.WithParentSession(ctx, parentSession) + ctx = jobs.WithSession(ctx, parentSession) + ctx, turnImgs := c.withTurnImages(ctx, task) + ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage) + ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage) + ctx = agent.WithSubagentDepth(ctx, 0) + ctx = c.prepareTurnRootBudget(ctx) + var err error + answer, err = runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true}) + return err + }) if err != nil { return "", err } diff --git a/internal/session/control/turn_orchestrator.go b/internal/session/control/turn_orchestrator.go index 0de397328a..7c5025d1d2 100644 --- a/internal/session/control/turn_orchestrator.go +++ b/internal/session/control/turn_orchestrator.go @@ -82,13 +82,8 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk expectedContinuationEpoch := o.c.goals.continuationToken() images := o.c.resolveTurnImages(raw) ctx = o.c.withVisionRouting(agent.WithSubagentImageCandidates(ctx, images.candidates)) - // The skill turn's model requests count against the active goal's token - // budget, so bind a recorder for the span even though the sub-agent cannot - // call update_goal itself. if scopeID, goal, ok := o.c.goals.deliveryScope(); ok { ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: goal}) - recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken()) - o.c.goalUsageTee.setActiveRecorder(recorder) } startMessages := o.c.sessionMessageCount() err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode, images) @@ -151,6 +146,8 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s } ctx, marker = c.beginTurn(ctx, startMessages, true) ctx = c.announceAuthoredTurn(ctx, firstNonEmpty(raw, task), startMessages) + ctx = c.bindTurnScope(ctx, nil) + ctx = c.prepareTurnRootBudget(ctx) c.executor.LandAuthoredUserMessage(ctx, provider.Message{Role: provider.RoleUser, Content: input, Images: images.userImages, CreatedAt: time.Now().UnixMilli()}) for _, sk := range skills { diff --git a/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md b/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md new file mode 100644 index 0000000000..39e92b77ad --- /dev/null +++ b/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md @@ -0,0 +1,17 @@ +显式子代理技能调用遵守已有任务根预算。 + +## 修复 + +- slash、composer 多选及 Goal 技能调用共享已有 token/wall 根预算,保留零额度不限和原有收尾。关联 #14025。 +- CLI profile run/preview 复用同步回合准入及取消;忙碌、关闭、draining 和工作区 authority 检查同样适用。关联 #14025。 +- 本次不提供硬预留、根金额汇总或完整隔离恢复保障。关联 #14025。 + +--- + + + +## Fixed + +- Explicit slash, composer and Goal subagent skills share the existing token/wall root, preserving unlimited zero and finalization. Refs #14025. +- CLI profile run/preview use synchronous turn admission and cancellation, including busy, closed, draining and workspace authority checks. Refs #14025. +- Hard reservations, root money accounting and complete isolation/recovery remain outside this change. Refs #14025. From 8094bcb3a8d09387b259946e2d043c126c722096 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 01:40:28 -0700 Subject: [PATCH 19/37] fix(agent): retry a context summary once when its stream is reset --- .../agent/compact_summary_reset_test.go | 120 ++++++++++++++++++ .../runtime/agent/compact_summary_retry.go | 34 ++++- .../unreleased/14643-summary-reset-retry.md | 2 + 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 internal/runtime/agent/compact_summary_reset_test.go create mode 100644 release-notes/studio/unreleased/14643-summary-reset-retry.md diff --git a/internal/runtime/agent/compact_summary_reset_test.go b/internal/runtime/agent/compact_summary_reset_test.go new file mode 100644 index 0000000000..50bf982aab --- /dev/null +++ b/internal/runtime/agent/compact_summary_reset_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "reasonix/internal/contract/provider" + "reasonix/internal/runtime/agent/testutil" +) + +// resettingProvider fails the first resets summary requests the way a peer that +// went away mid-stream does, then answers. +type resettingProvider struct { + mu sync.Mutex + resets int + err error + calls int +} + +func (p *resettingProvider) Name() string { return "resetting" } + +func (p *resettingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { + p.mu.Lock() + p.calls++ + failing := p.calls <= p.resets + p.mu.Unlock() + ch := make(chan provider.Chunk, 3) + if failing { + ch <- provider.Chunk{Type: provider.ChunkError, Err: p.err} + } else { + ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "complete briefing")} + ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140, FinishReason: "stop"}} + } + close(ch) + return ch, nil +} + +func (p *resettingProvider) seen() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls } + +func connectionReset() error { + return provider.StreamInterrupt(errors.New("read stream: the peer closed the connection"), provider.StreamInterruptConnectionReset) +} + +func withNoResetBackoff(t *testing.T) { + t.Helper() + was := summaryResetBackoff + summaryResetBackoff = 0 + t.Cleanup(func() { summaryResetBackoff = was }) +} + +func TestASummaryCutByAConnectionResetIsRetriedOnce(t *testing.T) { + withNoResetBackoff(t) + p := &resettingProvider{resets: 1, err: connectionReset()} + a := truncationAgent(t, p, 4*summaryOutputMaxTokens) + + if err := prepareContext(context.Background(), a, CompactionTriggerManual); err != nil { + t.Fatalf("prepare = %v, want the retry after the reset to succeed", err) + } + if p.seen() != 2 { + t.Fatalf("summary requests = %d, want the reset one and one retry", p.seen()) + } + if got := latestDigest(a.sess.win.compactionState.Projection.Messages); !strings.Contains(got, "complete briefing") { + t.Fatalf("digest = %q, want the retried summary installed", got) + } +} + +func TestTheConnectionResetRetryIsBoundedAndKeepsItsIdentity(t *testing.T) { + withNoResetBackoff(t) + p := &resettingProvider{resets: 10, err: connectionReset()} + a := truncationAgent(t, p, 4*summaryOutputMaxTokens) + + err := prepareContext(context.Background(), a, CompactionTriggerManual) + if !errors.Is(err, errSummaryRequestFailed) || compactionFailureCode(err) != FailSummaryFailed { + t.Fatalf("prepare = %v, want the request-failed identity", err) + } + if provider.StreamInterruptReason(err) != provider.StreamInterruptConnectionReset { + t.Fatalf("the failure lost the reset it carried: %v", err) + } + if p.seen() != 2 { + t.Fatalf("summary requests = %d, want exactly one retry", p.seen()) + } +} + +func TestAFailureThatIsNotAResetIsNotRetried(t *testing.T) { + withNoResetBackoff(t) + p := &resettingProvider{resets: 10, err: errors.New("the endpoint refused the request")} + a := truncationAgent(t, p, 4*summaryOutputMaxTokens) + + if err := prepareContext(context.Background(), a, CompactionTriggerManual); !errors.Is(err, errSummaryRequestFailed) { + t.Fatalf("prepare = %v, want the request-failed identity", err) + } + if p.seen() != 1 { + t.Fatalf("summary requests = %d, want one: only a reset is worth asking again", p.seen()) + } +} + +func TestCancellingDuringTheResetBackoffStopsTheRetry(t *testing.T) { + was := summaryResetBackoff + summaryResetBackoff = time.Hour + t.Cleanup(func() { summaryResetBackoff = was }) + p := &resettingProvider{resets: 10, err: connectionReset()} + a := truncationAgent(t, p, 4*summaryOutputMaxTokens) + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(50 * time.Millisecond); cancel() }() + + done := make(chan error, 1) + go func() { done <- prepareContext(ctx, a, CompactionTriggerManual) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the retry kept waiting after the run was cancelled") + } + if p.seen() != 1 { + t.Fatalf("summary requests = %d, want no retry once cancelled", p.seen()) + } +} diff --git a/internal/runtime/agent/compact_summary_retry.go b/internal/runtime/agent/compact_summary_retry.go index 43da5050af..349c87ec20 100644 --- a/internal/runtime/agent/compact_summary_retry.go +++ b/internal/runtime/agent/compact_summary_retry.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "reasonix/internal/contract/event" "reasonix/internal/contract/provider" @@ -25,12 +26,17 @@ func (e *summaryTruncation) Error() string { func (e *summaryTruncation) Unwrap() error { return errSummaryOutputTruncated } +// summaryResetBackoff is the wait before the one retry of a summary whose +// stream the peer reset. +var summaryResetBackoff = 2 * time.Second + var errSummaryPrefixNotDigest = fmt.Errorf("reused prefix: %w", errSummaryNotDigest) -// A summary gets at most one independent-shape fallback and one output-cap retry. +// A summary gets at most one independent-shape fallback, one output-cap retry +// and one retry after the peer reset its stream. // Every attempt records its own spend; the returned usage is their aggregate. func (a *contextWindow) summarizeOnce(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) { - allowPrefix, retried := true, false + allowPrefix, retried, reset := true, false, false limit, above := summaryOutputMaxTokens, 0 var total *provider.Usage for { @@ -39,6 +45,14 @@ func (a *contextWindow) summarizeOnce(ctx context.Context, fold []provider.Messa if ctx.Err() != nil { return summary, total, err } + if !reset && provider.StreamInterruptReason(err) == provider.StreamInterruptConnectionReset { + reset = true + if !sleepUnlessDone(ctx, summaryResetBackoff) { + return summary, total, err + } + a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress}) + continue + } if allowPrefix && (errors.Is(err, errSummaryPrefixNotDigest) || errors.Is(err, provider.ErrSummaryToolsUnsupported)) { allowPrefix = false } else { @@ -77,3 +91,19 @@ func (a *contextWindow) retryTruncatedSummary(ctx context.Context, fold []provid a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress}) return a.summarizeAtMode(ctx, fold, instructions, summaryRetryOutputMaxTokens, cut.Cap, allowPrefix) } + +// sleepUnlessDone waits d and reports whether it ran to the end: false when ctx +// ended first. +func sleepUnlessDone(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return ctx.Err() == nil + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/release-notes/studio/unreleased/14643-summary-reset-retry.md b/release-notes/studio/unreleased/14643-summary-reset-retry.md new file mode 100644 index 0000000000..5a3b250009 --- /dev/null +++ b/release-notes/studio/unreleased/14643-summary-reset-retry.md @@ -0,0 +1,2 @@ +- 修复:压缩上下文时如果摘要请求的连接被对方中途重置(比如本地网关重启),会自动重试一次,不再直接失败。关联 #14643。 +- Fixed: when the connection of a context-summary request is reset mid-stream (for example a local gateway restarting), compaction now retries once instead of failing at once. Refs #14643. From f41b9d4131c061c380286f3940b7dc0cfc70b296 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 02:10:00 -0700 Subject: [PATCH 20/37] fix(desktop): opening settings no longer walks the disk for the storage total --- desktop/frontend-next/src/ui/Settings.tsx | 7 ++- .../ui/settings-machine.interaction.test.tsx | 4 +- ...ettings-storage-tally.interaction.test.tsx | 44 +++++++++++++++ .../frontend-next/src/ui/storageTally.test.ts | 53 +++++++++++++++++++ desktop/frontend-next/src/ui/storageTally.ts | 29 ++++++++++ .../unreleased/14315-settings-storage-walk.md | 2 + 6 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 desktop/frontend-next/src/ui/settings-storage-tally.interaction.test.tsx create mode 100644 desktop/frontend-next/src/ui/storageTally.test.ts create mode 100644 desktop/frontend-next/src/ui/storageTally.ts create mode 100644 release-notes/studio/unreleased/14315-settings-storage-walk.md diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index 19699cfa5c..bd2aec71bb 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -11,6 +11,7 @@ import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } import { useSettingsSearch } from "./useSettingsSearch"; import { useSettingLanding } from "./useSettingLanding"; import { bytes, tokens as fmtTokens } from "../i18n/format"; +import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally"; import { SECTION_NAME } from "./prefsnav"; import { SettingsNav } from "./SettingsNav"; import { Group, SCOPE_SAID } from "./Group"; @@ -184,12 +185,14 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, let alive = true; setTally({}); const put = (k: Section, v: string) => { if (alive) setTally((prev) => ({ ...prev, [k]: v })); }; - homePort.storage().then((st) => put("storage", bytes(st.roots.reduce((n, r) => n + (r.bytes || 0), 0)))).catch(() => {}); + const known = cachedStorageTotal(homePort); + if (known !== undefined) put("storage", bytes(known)); + const walk = known !== undefined ? undefined : setTimeout(() => { storageTotal(homePort).then((n) => put("storage", bytes(n))).catch(() => {}); }, STORAGE_TALLY_DELAY); // The same window Usage itself opens on, or the list and the page it opens // would report two different totals for one word. homePort.usage(DEFAULT_DAYS).then((u) => put("usage", fmtTokens(u.tokens))).catch(() => {}); homePort.versions().then((v) => put("versions", v.current || "dev")).catch(() => {}); - return () => { alive = false; }; + return () => { alive = false; clearTimeout(walk); }; }, [homePort]); // Focus lands once, when the pane opens, and goes back where it came from on diff --git a/desktop/frontend-next/src/ui/settings-machine.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-machine.interaction.test.tsx index 6d35fc9c7a..e6caa9d1e6 100644 --- a/desktop/frontend-next/src/ui/settings-machine.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/settings-machine.interaction.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "./testkit"; import { Settings } from "./Settings"; @@ -42,7 +42,7 @@ it("reads machine totals locally but keeps project inventory on the active remot const localSkills = vi.spyOn(local, "skills"); render(settings(remote, local, "ext")); await screen.findByRole("heading", { name: "扩展" }); - expect(storage).toHaveBeenCalled(); + await waitFor(() => expect(storage).toHaveBeenCalled(), { timeout: 4000 }); expect(usage).toHaveBeenCalled(); expect(remoteStorage).not.toHaveBeenCalled(); expect(remoteUsage).not.toHaveBeenCalled(); diff --git a/desktop/frontend-next/src/ui/settings-storage-tally.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-storage-tally.interaction.test.tsx new file mode 100644 index 0000000000..e8f0b58c09 --- /dev/null +++ b/desktop/frontend-next/src/ui/settings-storage-tally.interaction.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import "./testkit"; +import { Settings } from "./Settings"; +import type { SessionStatus } from "../port/port"; +import { MockPort } from "../port/mock"; +import { MockHub } from "../port/mock_hub"; + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +const draw = (port: MockPort) => render( + {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}} + look={{} as never} onLook={() => {}} reloadThemes={() => {}} + onClose={vi.fn()} onChanged={() => {}} onError={() => {}} + account={null} accountUnread="" reloadAccount={() => {}} + />, +); + +it("does not walk the storage roots while the settings window is opening", async () => { + const port = new MockPort(); + const walk = vi.spyOn(port, "storage"); + draw(port); + await act(async () => { await vi.advanceTimersByTimeAsync(200); }); + expect(walk).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(walk).toHaveBeenCalledTimes(1); +}); + +it("does not walk them again when the window is reopened soon after", async () => { + const port = new MockPort(); + const walk = vi.spyOn(port, "storage"); + const first = draw(port); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + first.unmount(); + draw(port); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(walk).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/frontend-next/src/ui/storageTally.test.ts b/desktop/frontend-next/src/ui/storageTally.test.ts new file mode 100644 index 0000000000..b65d777da1 --- /dev/null +++ b/desktop/frontend-next/src/ui/storageTally.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { STORAGE_TALLY_TTL, cachedStorageTotal, storageTotal } from "./storageTally"; + +const port = (bytes = 100) => ({ storage: vi.fn(async () => ({ roots: [{ bytes: bytes - 40 }, { bytes: 40 }, {}] })) }); + +describe("the storage total on the settings list", () => { + it("walks the disk once for as long as the answer is fresh", async () => { + const p = port(); + let now = 1_000; + expect(await storageTotal(p as never, () => now)).toBe(100); + now += STORAGE_TALLY_TTL - 1; + expect(await storageTotal(p as never, () => now)).toBe(100); + expect(p.storage).toHaveBeenCalledTimes(1); + }); + + it("walks again once the answer has gone stale", async () => { + const p = port(); + let now = 1_000; + await storageTotal(p as never, () => now); + now += STORAGE_TALLY_TTL + 1; + await storageTotal(p as never, () => now); + expect(p.storage).toHaveBeenCalledTimes(2); + }); + + it("shares one walk between callers that ask while it runs", async () => { + const p = port(); + const [a, b] = await Promise.all([storageTotal(p as never, () => 5), storageTotal(p as never, () => 5)]); + expect([a, b]).toEqual([100, 100]); + expect(p.storage).toHaveBeenCalledTimes(1); + }); + + it("does not remember a failure", async () => { + const p = { storage: vi.fn().mockRejectedValueOnce(new Error("busy")).mockResolvedValue({ roots: [{ bytes: 7 }] }) }; + await expect(storageTotal(p as never, () => 1)).rejects.toThrow("busy"); + expect(await storageTotal(p as never, () => 2)).toBe(7); + expect(p.storage).toHaveBeenCalledTimes(2); + }); + + it("keeps a separate answer for each port", async () => { + const a = port(10); + const b = port(20); + expect(await storageTotal(a as never, () => 1)).toBe(10); + expect(await storageTotal(b as never, () => 1)).toBe(20); + }); + + it("can say what it already knows without asking the disk", async () => { + const p = port(); + expect(cachedStorageTotal(p, () => 1)).toBeUndefined(); + await storageTotal(p as never, () => 1); + expect(cachedStorageTotal(p, () => 2)).toBe(100); + expect(cachedStorageTotal(p, () => STORAGE_TALLY_TTL + 2)).toBeUndefined(); + }); +}); diff --git a/desktop/frontend-next/src/ui/storageTally.ts b/desktop/frontend-next/src/ui/storageTally.ts new file mode 100644 index 0000000000..152075bca3 --- /dev/null +++ b/desktop/frontend-next/src/ui/storageTally.ts @@ -0,0 +1,29 @@ +import type { AgentPort } from "../port/port"; + +export const STORAGE_TALLY_TTL = 10 * 60 * 1000; +export const STORAGE_TALLY_DELAY = 1500; + +type Taken = { at: number; total: number } | { walk: Promise }; + +const taken = new WeakMap(); + +export async function storageTotal(port: Pick, now: () => number = Date.now): Promise { + const held = taken.get(port); + if (held && "walk" in held) return held.walk; + if (held && now() - held.at < STORAGE_TALLY_TTL) return held.total; + const walk = port.storage().then((st) => st.roots.reduce((n, r) => n + (r.bytes || 0), 0)); + taken.set(port, { walk }); + try { + const total = await walk; + taken.set(port, { at: now(), total }); + return total; + } catch (e) { + taken.delete(port); + throw e; + } +} + +export function cachedStorageTotal(port: object, now: () => number = Date.now): number | undefined { + const held = taken.get(port); + return held && "at" in held && now() - held.at < STORAGE_TALLY_TTL ? held.total : undefined; +} diff --git a/release-notes/studio/unreleased/14315-settings-storage-walk.md b/release-notes/studio/unreleased/14315-settings-storage-walk.md new file mode 100644 index 0000000000..37bce34942 --- /dev/null +++ b/release-notes/studio/unreleased/14315-settings-storage-walk.md @@ -0,0 +1,2 @@ +- 修复:打开设置时不再每次遍历磁盘统计“存储”大小;这个数字延后读取,十分钟内再次打开沿用上次结果,设置打开更快。关联 #14315。 +- Fixed: opening Settings no longer walks the disk for the Storage total each time; it is read after the window is up and reused for ten minutes. Refs #14315. From 96cd01d6e2248d190137a89eae7e7ed71c592cbb Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 02:37:11 -0700 Subject: [PATCH 21/37] fix(agent): calls the host stops before they run carry a refusal identity the window words --- .../src/i18n/blocked_call_refusals.test.ts | 32 ++++++++++ desktop/frontend-next/src/i18n/en_kernel.ts | 4 ++ desktop/frontend-next/src/i18n/kernel.ts | 4 ++ desktop/frontend-next/src/ui/cards/outcome.ts | 4 ++ .../agent/blocked_call_identity_test.go | 63 +++++++++++++++++++ internal/runtime/agent/execute_batch.go | 16 ++++- internal/runtime/agent/shell_shape_gates.go | 24 ++++--- .../runtime/agent/workspace_mutation_test.go | 3 + .../14652-blocked-calls-in-chinese.md | 2 + 9 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 desktop/frontend-next/src/i18n/blocked_call_refusals.test.ts create mode 100644 internal/runtime/agent/blocked_call_identity_test.go create mode 100644 release-notes/studio/unreleased/14652-blocked-calls-in-chinese.md diff --git a/desktop/frontend-next/src/i18n/blocked_call_refusals.test.ts b/desktop/frontend-next/src/i18n/blocked_call_refusals.test.ts new file mode 100644 index 0000000000..183115bc83 --- /dev/null +++ b/desktop/frontend-next/src/i18n/blocked_call_refusals.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { boot, STORAGE } from "./index"; +import { say } from "./kernel"; +import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome"; +import type { Tool } from "../port/wire"; + +afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); }); + +// The calls the host stops before they run. The model keeps the English text; +// the person reading the card gets the reason in their own language. +const BLOCKED = [ + "shell.mixed_mutation_verification", + "shell.inline_interpreter_not_last", + "batch.skipped_after_failed_change", + "batch.check_after_stopped_call", +]; + +describe("calls the host stops before they run", () => { + for (const language of ["zh", "en"]) { + it(`are worded in ${language}, and the card shows that instead of the English line`, () => { + localStorage.setItem(STORAGE, language); + boot(); + for (const code of BLOCKED) { + const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool; + expect(say({ code }, ""), code).not.toBe(""); + expect(toolRefusalReason(tool), code).not.toBe(""); + expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe(""); + } + }); + } +}); diff --git a/desktop/frontend-next/src/i18n/en_kernel.ts b/desktop/frontend-next/src/i18n/en_kernel.ts index 4e9bbd08d2..55267ad7c2 100644 --- a/desktop/frontend-next/src/i18n/en_kernel.ts +++ b/desktop/frontend-next/src/i18n/en_kernel.ts @@ -13,6 +13,10 @@ export const EN_KERNEL: Record = { "该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed", "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up", "无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.", + "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.", + "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.", + "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.", + "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.", "无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.", "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.", "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.", diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index 7e8f01b29d..e9be998076 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -123,6 +123,10 @@ const SAID: Record = { "workspace.root_not_granted": "这个文件夹没有授权给当前工作区", "workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。", "workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。", + "shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。", + "shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。", + "batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。", + "batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。", "workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。", "video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。", "video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。", diff --git a/desktop/frontend-next/src/ui/cards/outcome.ts b/desktop/frontend-next/src/ui/cards/outcome.ts index 9610cfd1ba..27e6c55649 100644 --- a/desktop/frontend-next/src/ui/cards/outcome.ts +++ b/desktop/frontend-next/src/ui/cards/outcome.ts @@ -40,6 +40,10 @@ export function toolErrorDetail(tool: Tool): string { case "video.ffmpeg_missing": case "video.unsupported_container": case "video.too_large": + case "shell.mixed_mutation_verification": + case "shell.inline_interpreter_not_last": + case "batch.skipped_after_failed_change": + case "batch.check_after_stopped_call": if (toolRefusalReason(tool)) return ""; } return tool.err ?? ""; diff --git a/internal/runtime/agent/blocked_call_identity_test.go b/internal/runtime/agent/blocked_call_identity_test.go new file mode 100644 index 0000000000..dd42cb7511 --- /dev/null +++ b/internal/runtime/agent/blocked_call_identity_test.go @@ -0,0 +1,63 @@ +package agent + +import ( + "context" + "testing" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/contract/tool" + "reasonix/internal/state/sessionstore" +) + +func refusalOf(t *testing.T, a *Agent, id string) string { + t.Helper() + for _, msg := range a.sess.conversation.Snapshot() { + if msg.ToolCallID == id && msg.Role == provider.RoleTool { + if msg.ToolFailure == nil { + t.Fatalf("call %s carries no failure", id) + } + return msg.ToolFailure.RefusalCode + } + } + t.Fatalf("no result for call %s", id) + return "" +} + +func runBash(t *testing.T, command string) *Agent { + t.Helper() + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "bash", readOnly: false}) + prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ + {toolCallChunk("m1", "bash", `{"command":`+quoteJSON(command)+`}`), {Type: provider.ChunkDone}}, + {{Type: provider.ChunkText, Text: "ok"}, {Type: provider.ChunkDone}}, + }} + a := New(prov, reg, sessionstore.NewSession(""), Options{}, event.Discard) + if err := a.Run(context.Background(), "test"); err != nil { + t.Fatal(err) + } + return a +} + +func TestAMixedMutationAndVerificationBlockNamesItsIdentity(t *testing.T) { + a := runBash(t, "go generate ./... ; go test ./...") + if got := refusalOf(t, a, "m1"); got != CodeShellMixedMutationVerification { + t.Fatalf("refusal code = %q, want %q", got, CodeShellMixedMutationVerification) + } +} + +func TestANonTerminalInlineInterpreterBlockNamesItsIdentity(t *testing.T) { + a := runBash(t, `python3 -c 'open("x","w").write("y")' ; node verify_frontend_logic.js`) + if got := refusalOf(t, a, "m1"); got != CodeShellInlineInterpreterNotLast { + t.Fatalf("refusal code = %q, want %q", got, CodeShellInlineInterpreterNotLast) + } +} + +func TestTheBarrierLevelsNameTheirIdentity(t *testing.T) { + if got := barrierFull.refusalCode(); got != CodeBatchSkippedAfterFailedChange { + t.Fatalf("full barrier = %q, want %q", got, CodeBatchSkippedAfterFailedChange) + } + if got := barrierVerificationOnly.refusalCode(); got != CodeBatchCheckAfterStoppedCall { + t.Fatalf("verification-only barrier = %q, want %q", got, CodeBatchCheckAfterStoppedCall) + } +} diff --git a/internal/runtime/agent/execute_batch.go b/internal/runtime/agent/execute_batch.go index e7843b223a..5f8a865c67 100644 --- a/internal/runtime/agent/execute_batch.go +++ b/internal/runtime/agent/execute_batch.go @@ -36,6 +36,20 @@ func (l barrierLevel) covers(mutates, verification bool) bool { return false } +// Refusal identities of the calls the host stops before they run, so a window +// can say why in the reader's language while the model keeps the English text. +const ( + CodeBatchSkippedAfterFailedChange = "batch.skipped_after_failed_change" + CodeBatchCheckAfterStoppedCall = "batch.check_after_stopped_call" +) + +func (l barrierLevel) refusalCode() string { + if l == barrierVerificationOnly { + return CodeBatchCheckAfterStoppedCall + } + return CodeBatchSkippedAfterFailedChange +} + // message says which of the two happened: told a modification failed when none // ran, a model goes looking for a write it never made. func (l barrierLevel) message() string { @@ -376,7 +390,7 @@ func (a *Agent) barrierSkip(call provider.ToolCall, level barrierLevel) (toolOut ex.Verification = tool.ShellVerificationNotRun } } - return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg), execution: ex}, true + return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg), refusalCode: level.refusalCode(), execution: ex}, true } // batchFailureBarrier reports how far a finished call's failure reaches: a diff --git a/internal/runtime/agent/shell_shape_gates.go b/internal/runtime/agent/shell_shape_gates.go index b935fae34d..6bae730f44 100644 --- a/internal/runtime/agent/shell_shape_gates.go +++ b/internal/runtime/agent/shell_shape_gates.go @@ -6,6 +6,12 @@ import ( "reasonix/internal/safety/evidence" ) +// Refusal identities of the shell shapes the host refuses before running. +const ( + CodeShellMixedMutationVerification = "shell.mixed_mutation_verification" + CodeShellInlineInterpreterNotLast = "shell.inline_interpreter_not_last" +) + // applyShellShapeGates holds the deterministic command-shape contract: shapes a // later segment could use to hide an earlier failure, and the shape a declared // check needs for its exit status to mean anything. Ordinary mode blocks only @@ -51,19 +57,21 @@ func (a *Agent) applyShellShapeGates(ctx context.Context, plan *toolCallPlan) (t msg = evidence.ShellContractMixedDeliveryMessage(plan.evidenceArgs) } return toolOutcome{ - output: msg, - blocked: true, - errMsg: "blocked: mixed mutation and verification command", - execution: shellPreflightExecution(plan, true), + output: msg, + blocked: true, + errMsg: "blocked: mixed mutation and verification command", + refusalCode: CodeShellMixedMutationVerification, + execution: shellPreflightExecution(plan, true), }, true } if evidence.BashToolCallUsesNonTerminalInlineInterpreter(plan.evidenceArgs) { msg := evidence.ShellContractInlineNonTerminalMessage(plan.evidenceArgs) return toolOutcome{ - output: msg, - blocked: true, - errMsg: "blocked: non-terminal inline interpreter command", - execution: shellPreflightExecution(plan, false), + output: msg, + blocked: true, + errMsg: "blocked: non-terminal inline interpreter command", + refusalCode: CodeShellInlineInterpreterNotLast, + execution: shellPreflightExecution(plan, false), }, true } } diff --git a/internal/runtime/agent/workspace_mutation_test.go b/internal/runtime/agent/workspace_mutation_test.go index edf0954adb..be5a3c0982 100644 --- a/internal/runtime/agent/workspace_mutation_test.go +++ b/internal/runtime/agent/workspace_mutation_test.go @@ -154,4 +154,7 @@ func TestToolBeforeFailedWriterReplacementOpensDependencyBarrier(t *testing.T) { if len(batch.results) != 2 || !strings.Contains(batch.results[1], "skipped because an earlier modification") { t.Fatalf("dependency results = %+v", batch.results) } + if got := batch.outcomes[1].refusalCode; got != CodeBatchSkippedAfterFailedChange { + t.Fatalf("the skipped call's refusal code = %q, want %q", got, CodeBatchSkippedAfterFailedChange) + } } diff --git a/release-notes/studio/unreleased/14652-blocked-calls-in-chinese.md b/release-notes/studio/unreleased/14652-blocked-calls-in-chinese.md new file mode 100644 index 0000000000..6dbe871dbc --- /dev/null +++ b/release-notes/studio/unreleased/14652-blocked-calls-in-chinese.md @@ -0,0 +1,2 @@ +- 修复:命令或同一批调用被宿主拦截时(比如“修改和验证混在一起”“前面的修改失败,这一步跳过”),工具卡片现在用中文说明原因,不再显示英文的 blocked 提示。修复 #14652。 +- Fixed: when the host stops a command or a call in a batch before it runs, the tool card now explains why in your language instead of an English "blocked:" line. Fixes #14652. From dff7a9e7f3dd82f6bcb611b60f2f9a1cc387db76 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 26 Oct 2026 09:19:48 +0800 Subject: [PATCH 22/37] fix(stats): a total says how many calls it could not price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row already records why its money may be incomplete (`cost_estimated`, `incomplete_reason`), but RangeStats kept only a bool, so a total could say "this folded in an estimate" and never "of these calls, N were never priced, because …". Both read paths now count those calls and name the distinct reasons; the projection carries the reason as schema 8, which reindexes from the daily files; an old or reason-less row still counts, it just adds no reason to the vocabulary. --- desktop/frontend-next/src/i18n/en_usage.ts | 1 + desktop/frontend-next/src/port/usage.ts | 4 + desktop/frontend-next/src/ui/Usage.tsx | 7 +- .../src/ui/Usage.unpriced.test.tsx | 65 +++++++ internal/state/stats/query.go | 60 ++++++- internal/state/stats/unpriced_paths_test.go | 169 ++++++++++++++++++ internal/state/stats/unpriced_range_test.go | 27 +++ internal/state/stats/unpriced_total_test.go | 81 +++++++++ internal/state/stats/usage_catalog.go | 3 +- internal/state/usagecatalog/catalog.go | 27 ++- internal/state/usagecatalog/costview.go | 29 +++ .../state/usagecatalog/incomplete_test.go | 39 ++++ internal/state/usagecatalog/unpriced.go | 45 +++++ 13 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 desktop/frontend-next/src/ui/Usage.unpriced.test.tsx create mode 100644 internal/state/stats/unpriced_paths_test.go create mode 100644 internal/state/stats/unpriced_range_test.go create mode 100644 internal/state/stats/unpriced_total_test.go create mode 100644 internal/state/usagecatalog/unpriced.go diff --git a/desktop/frontend-next/src/i18n/en_usage.ts b/desktop/frontend-next/src/i18n/en_usage.ts index 3a7d6d4098..85af24deae 100644 --- a/desktop/frontend-next/src/i18n/en_usage.ts +++ b/desktop/frontend-next/src/i18n/en_usage.ts @@ -62,6 +62,7 @@ export const EN_USAGE: Record = { "花费": "Spend", "含估算": "includes estimates", "未定价调用未计入": "unpriced calls not included", + "其中 {n} 笔未定价": "{n} calls unpriced", "{day} 起 · {n} 天有记录": "from {day} · {n} days recorded", "这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.", "命中 {a} · 未命中 {b}": "{a} hit · {b} missed", diff --git a/desktop/frontend-next/src/port/usage.ts b/desktop/frontend-next/src/port/usage.ts index c232fc87ad..83edb1ddb7 100644 --- a/desktop/frontend-next/src/port/usage.ts +++ b/desktop/frontend-next/src/port/usage.ts @@ -78,6 +78,10 @@ export interface UsageReport { // Set when a counted call's money is unknown (no rate card, or a quote that // could not price its usage). cost is then a floor, not the bill. costIncomplete?: boolean; + // How many of those counted calls are under that floor, and the distinct + // reasons the rows recorded (the kernel's incomplete-reason vocabulary). + unpricedCalls?: number; + incompleteReasons?: string[]; active_days: number; top_model: string; top_provider: string; diff --git a/desktop/frontend-next/src/ui/Usage.tsx b/desktop/frontend-next/src/ui/Usage.tsx index 3ea251cb70..1738cbbe92 100644 --- a/desktop/frontend-next/src/ui/Usage.tsx +++ b/desktop/frontend-next/src/ui/Usage.tsx @@ -393,7 +393,12 @@ export function Usage({ port }: { port: AgentPort }) {
    {moneyList(report.cost)}
    {pricedFrom && {t("{day} 起 · {n} 天有记录", { day: pricedFrom.slice(5), n: priced.length })}} {report.costEstimated && {t("含估算")}} - {report.costIncomplete && {t("未定价调用未计入")}} + {report.costIncomplete && ( + + {t("未定价调用未计入")} + {report.unpricedCalls ? ` · ${t("其中 {n} 笔未定价", { n: report.unpricedCalls })}` : null} + + )}
    {report.estimateCost?.length ? (
    diff --git a/desktop/frontend-next/src/ui/Usage.unpriced.test.tsx b/desktop/frontend-next/src/ui/Usage.unpriced.test.tsx new file mode 100644 index 0000000000..8f2fbf51f8 --- /dev/null +++ b/desktop/frontend-next/src/ui/Usage.unpriced.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import "./testkit"; +import { boot, STORAGE } from "../i18n"; +import type { AgentPort, UsageReport } from "../port/port"; +import { Usage } from "./Usage"; + +// The kernel's total already knew its money was incomplete; this is the line +// that says how many calls it could not price. A total that names only the +// floor leaves the reader unable to ask which calls were under it. +const setLocale = (lang: "zh" | "en") => { localStorage.setItem(STORAGE, lang); boot(); }; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); setLocale("zh"); }); + +const REPORT: UsageReport = { + from: "2026-08-10", to: "2026-08-12", tokens: 3_000, requests: 9, turns: 6, cache_hit: 2_400, cache_miss: 600, + cost: [{ amount: "4", currency: "CNY" }], + // Four calls priced, five the range could not price, for two reasons. + costIncomplete: true, unpricedCalls: 5, incompleteReasons: ["missing_price_or_usage", "no_price"], + active_days: 3, top_model: "relay/a", top_provider: "relay", models: [], providers: [], daily: [], +}; + +async function open(report: UsageReport) { + const port = { usage: vi.fn().mockResolvedValue(report), providers: vi.fn().mockResolvedValue([]), models: vi.fn().mockResolvedValue([]) } as unknown as AgentPort; + const view = render(); + await waitFor(() => expect(view.container.querySelector(".uhero")).toBeTruthy()); + return view; +} + +// The scopes beside the figure: the caveats a person reads before trusting it. +function scopes(container: HTMLElement): string { + return Array.from(container.querySelectorAll(".uhero-scope")).map((el) => el.textContent ?? "").join(" | "); +} + +describe("the unpriced-call count on the usage page", () => { + it("says how many calls the total could not price, in Chinese", async () => { + const { container } = await open(REPORT); + expect(scopes(container)).toContain("未定价调用未计入"); + expect(scopes(container)).toContain("其中 5 笔未定价"); + }); + + it("says it in English too", async () => { + setLocale("en"); + const { container } = await open(REPORT); + expect(scopes(container)).toContain("unpriced calls not included"); + expect(scopes(container)).toContain("5 calls unpriced"); + }); + + it("shows nothing extra for a fully priced range, in either language", async () => { + const priced: UsageReport = { ...REPORT, costIncomplete: false, unpricedCalls: 0, incompleteReasons: undefined }; + for (const lang of ["zh", "en"] as const) { + setLocale(lang); + const { container, unmount } = await open(priced); + expect(scopes(container)).not.toContain(lang === "zh" ? "未定价" : "unpriced"); + unmount(); + } + }); + + it("does not invent a zero count for a payload that predates the field", async () => { + const { container } = await open({ ...REPORT, unpricedCalls: undefined }); + expect(scopes(container)).toContain("未定价调用未计入"); + expect(scopes(container)).not.toContain("其中 0 笔未定价"); + }); +}); diff --git a/internal/state/stats/query.go b/internal/state/stats/query.go index fdb23fa12b..1eaaa1ff2c 100644 --- a/internal/state/stats/query.go +++ b/internal/state/stats/query.go @@ -82,10 +82,15 @@ type RangeStats struct { CostEstimated bool `json:"costEstimated,omitempty"` // CostIncomplete marks a total that counted usage for at least one call // whose money is unknown. Cost is then a floor, not the whole bill. - CostIncomplete bool `json:"costIncomplete,omitempty"` - ActiveDays int `json:"active_days"` - TopModel string `json:"top_model"` - TopProvider string `json:"top_provider"` + CostIncomplete bool `json:"costIncomplete,omitempty"` + // UnpricedCalls is how many of the counted calls are under that floor. + UnpricedCalls int `json:"unpricedCalls,omitempty"` + // IncompleteReasons names why those calls were unpriced, sorted and + // deduplicated; a row with no recorded reason still counts above. + IncompleteReasons []string `json:"incompleteReasons,omitempty"` + ActiveDays int `json:"active_days"` + TopModel string `json:"top_model"` + TopProvider string `json:"top_provider"` // Series Daily []DailyTokens `json:"daily"` // Hourly is filled only when the range is one calendar day; the day @@ -126,10 +131,8 @@ func (w *Writer) Query(f SourceFilter) (RangeStats, error) { if catalog := manager.catalog.Load(); catalog != nil { days := daysInRange(f.From, f.To) if catalog.Ready(context.Background(), w.dir, days) { - rows, err := catalog.Query(context.Background(), f.From.Format(dayLayout), f.To.Format(dayLayout), f.Source, - usagecatalog.CostView(f.Currency)) - if err == nil { - return rangeStatsFromRollups(f, days, rows), nil + if out, ok := rangeStatsFromCatalog(f, days, catalog); ok { + return out, nil } } catalog.RequestReconcileDir(w.dir) @@ -139,6 +142,25 @@ func (w *Writer) Query(f SourceFilter) (RangeStats, error) { return w.queryJSONL(f) } +// rangeStatsFromCatalog reads a ready projection, then the unpriced facts the +// rollups do not carry. Both come from the same stored rows, so this answers +// what the exact read answers. +func rangeStatsFromCatalog(f SourceFilter, days []string, catalog *usagecatalog.Catalog) (RangeStats, bool) { + ctx := context.Background() + from, to := f.From.Format(dayLayout), f.To.Format(dayLayout) + rows, err := catalog.Query(ctx, from, to, f.Source, usagecatalog.CostView(f.Currency)) + if err != nil { + return RangeStats{}, false + } + calls, reasons, err := catalog.UnpricedInRange(ctx, from, to, f.Source) + if err != nil { + return RangeStats{}, false + } + out := rangeStatsFromRollups(f, days, rows) + out.UnpricedCalls, out.IncompleteReasons = calls, reasons + return out, true +} + func emptyRange(f SourceFilter) RangeStats { return RangeStats{ From: f.From.Format(dayLayout), @@ -167,6 +189,7 @@ func aggregateDays(recordsByDay map[string][]record, days []string, f SourceFilt modelTotals := map[string]int64{} providerTotals := map[string]int64{} totalCost := newCostBooks() + unpricedReasons := map[string]bool{} active := map[string]bool{} // day -> active for _, day := range days { recs := recordsByDay[day] @@ -222,8 +245,12 @@ func aggregateDays(recordsByDay map[string][]record, days []string, f SourceFilt totalCost.add(mode, currency, pricing.Amount(amount)) out.CostEstimated = out.CostEstimated || (mode == "" && rec.CostEstimated) } - if usagecatalog.CostIncomplete(rec.CostComplete, rec.Total, rec.CostAmount) { + if reason, unpriced := usagecatalog.UnpricedReason(rec.CostComplete, rec.Total, rec.CostAmount, rec.IncompleteReason); unpriced { out.CostIncomplete = true + out.UnpricedCalls++ + if reason != "" { + unpricedReasons[reason] = true + } } dayActive = dayActive || rec.Total > 0 || requests > 0 } @@ -246,6 +273,7 @@ func aggregateDays(recordsByDay map[string][]record, days []string, f SourceFilt dayRequests, dayTurns, dayCacheHit, dayCacheMiss)) } out.ActiveDays = len(active) + out.IncompleteReasons = sortedReasons(unpricedReasons) out.Cost, out.EstimateCost = moneySorted(totalCost.billed), moneySorted(totalCost.estimate) out.Models = modelsSorted(modelTotals) out.Providers = providersSorted(providerTotals) @@ -456,6 +484,20 @@ func moneySorted(tally map[string]pricing.Amount) []pricing.Money { return out } +// sortedReasons renders the distinct reasons in a stable order so a repeated +// query returns the same JSON. No unnamed reason enters the list. +func sortedReasons(seen map[string]bool) []string { + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for reason := range seen { + out = append(out, reason) + } + sort.Strings(out) + return out +} + // matchesSource reports whether a record's source label passes the filter. // An empty filter or "all" matches every source. func matchesSource(recSource, filter string) bool { diff --git a/internal/state/stats/unpriced_paths_test.go b/internal/state/stats/unpriced_paths_test.go new file mode 100644 index 0000000000..e44b324c1c --- /dev/null +++ b/internal/state/stats/unpriced_paths_test.go @@ -0,0 +1,169 @@ +package stats + +// The same total is answered by two read paths: the exact JSONL query and the +// projection rollup. These guards pin that they count the same calls, that a +// fully priced range counts none, and what an old or reason-less row counts as. + +import ( + "reflect" + "testing" + + "reasonix/internal/base/testenv" +) + +// Two read paths answer this total: the exact JSONL query and the projection +// rollup. One path losing the count is how a total would disagree with itself +// depending on which projection happened to be ready. +func TestBothReadPathsAgreeOnTheUnpricedCount(t *testing.T) { + dir := testenv.TempDir(t) + lines := []map[string]any{ + {"ts": "2026-09-01T01:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 100, "requests": 1, "cost_amount": "1.50", "cost_currency": "CNY", "cost_complete": true}, + {"ts": "2026-09-01T02:00:00Z", "model": "relay/new-model", "source": "cli", + "total": 40, "requests": 1, "cost_complete": false, "incomplete_reason": "missing_price_or_usage"}, + {"ts": "2026-09-02T01:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 20, "requests": 1}, + {"ts": "2026-09-02T02:00:00Z", "model": "relay/new-model", "source": "cli", + "total": 10, "requests": 1, "cost_complete": false, "incomplete_reason": "no_price"}, + // Request-only failure: no tokens, so no call to price was made. + {"ts": "2026-09-02T03:00:00Z", "model": "relay/new-model", "source": "cli", "requests": 1}, + } + filter := rangeFromDays(t, "2026-09-01", "2026-09-30") + fromLive := queryLiveProjection(t, dir, filter, lines...) + fromFiles, err := NewWriter(dir).Query(filter) + if err != nil { + t.Fatal(err) + } + fromProjection := queryProjection(t, dir, filter) + + wantReasons := []string{"missing_price_or_usage", "no_price"} + paths := map[string]RangeStats{"JSONL": fromFiles, "live projection": fromLive, "rebuilt projection": fromProjection} + for name, got := range paths { + if got.UnpricedCalls != 3 { + t.Errorf("%s: unpricedCalls = %d, want 3", name, got.UnpricedCalls) + } + if !reflect.DeepEqual(got.IncompleteReasons, wantReasons) { + t.Errorf("%s: incompleteReasons = %v, want %v", name, got.IncompleteReasons, wantReasons) + } + if !got.CostIncomplete { + t.Errorf("%s: costIncomplete = false, want true", name) + } + if !reflect.DeepEqual(got, fromFiles) { + t.Errorf("%s disagrees with the exact read\nfiles: %+v\n%s: %+v", name, fromFiles, name, got) + } + } +} + +// bothReadPaths reads one two-day fixture through the exact files and a +// rebuilt projection: a one-day range would never consult a rollup. +func bothReadPaths(t *testing.T, dir string) (RangeStats, RangeStats) { + t.Helper() + filter := rangeFromDays(t, "2026-08-09", "2026-08-11") + fromFiles, err := NewWriter(dir).Query(filter) + if err != nil { + t.Fatal(err) + } + return fromFiles, queryProjection(t, dir, filter) +} + +// A fully priced range reports no unpriced calls and names no reason: the +// count must not be invented to make the note appear. +func TestAFullyPricedRangeCountsNoUnpricedCalls(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "model": "deepseek/x", "source": "cli", + "total": 100, "requests": 1, "cost_amount": "2", "cost_currency": "USD", "cost_complete": true}, + map[string]any{"ts": "2026-08-10T02:00:00Z", "model": "deepseek/x", "source": "cli", + "total": 50, "requests": 1, "cost_amount": "0", "cost_currency": "USD", "cost_complete": true}, + ) + got := queryAll(t, dir) + if got.UnpricedCalls != 0 || len(got.IncompleteReasons) != 0 || got.CostIncomplete { + t.Fatalf("fully priced range = %d unpriced / reasons %v / costIncomplete %v, want zero and none", + got.UnpricedCalls, got.IncompleteReasons, got.CostIncomplete) + } + fromFiles, fromProjection := bothReadPaths(t, dir) + for name, got := range map[string]RangeStats{"JSONL": fromFiles, "projection": fromProjection} { + if got.UnpricedCalls != 0 || len(got.IncompleteReasons) != 0 { + t.Errorf("%s: fully priced range = %d unpriced / reasons %v, want zero and none", + name, got.UnpricedCalls, got.IncompleteReasons) + } + } +} + +// An old row with no cost fact is unpriced, and it is the case the quote path +// already names "no_price". It is counted, never silently skipped. +func TestAnOldRowCountsAsUnpricedWithTheExistingReason(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "model": "deepseek/x", "source": "cli", "total": 100, "requests": 1}, + ) + fromFiles, fromProjection := bothReadPaths(t, dir) + for name, got := range map[string]RangeStats{"JSONL": fromFiles, "projection": fromProjection} { + if got.UnpricedCalls != 1 { + t.Errorf("%s: unpricedCalls = %d, want the old row counted", name, got.UnpricedCalls) + } + if !reflect.DeepEqual(got.IncompleteReasons, []string{"no_price"}) { + t.Errorf("%s: incompleteReasons = %v, want the existing vocabulary [no_price]", name, got.IncompleteReasons) + } + } +} + +// An estimate flag is not an unpriced call: an estimated rate with complete +// coverage priced the call, and the estimate caveat is a different note. +func TestAnEstimatedButCompleteQuoteIsNotUnpriced(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "model": "deepseek/x", "source": "cli", + "total": 100, "requests": 1, "cost_amount": "2", "cost_currency": "USD", + "cost_complete": true, "cost_estimated": true}, + ) + fromFiles, fromProjection := bothReadPaths(t, dir) + for name, got := range map[string]RangeStats{"JSONL": fromFiles, "projection": fromProjection} { + if !got.CostEstimated { + t.Errorf("%s: costEstimated = false, want the estimate caveat kept", name) + } + if got.UnpricedCalls != 0 || len(got.IncompleteReasons) != 0 { + t.Errorf("%s: an estimated but complete quote counts as %d unpriced / reasons %v", + name, got.UnpricedCalls, got.IncompleteReasons) + } + } +} + +// Neither a completed turn nor a request-only failure is a priced call: the +// count follows the same rule as costIncomplete. +func TestATurnMarkerAndARequestOnlyRowAreNotUnpricedCalls(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "source": "cli", "turn": true}, + map[string]any{"ts": "2026-08-10T02:00:00Z", "model": "relay/x", "source": "cli", "requests": 1}, + map[string]any{"ts": "2026-08-10T03:00:00Z", "model": "deepseek/x", "source": "cli", + "total": 10, "requests": 1, "cost_amount": "0.5", "cost_currency": "USD", "cost_complete": true}, + ) + fromFiles, fromProjection := bothReadPaths(t, dir) + for name, got := range map[string]RangeStats{"JSONL": fromFiles, "projection": fromProjection} { + if got.UnpricedCalls != 0 || len(got.IncompleteReasons) != 0 || got.CostIncomplete { + t.Errorf("%s: turn/request-only rows read as %d unpriced / reasons %v / incomplete %v", + name, got.UnpricedCalls, got.IncompleteReasons, got.CostIncomplete) + } + } +} + +// An unpriced row that recorded no reason is still counted. The count must not +// depend on the reason being known, and an unknown reason is not an empty +// reason in the list, which would read as a named one. +func TestAnUnpricedRowWithoutAReasonIsStillCounted(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "model": "deepseek/x", "source": "cli", + "total": 100, "requests": 1, "cost_amount": "0", "cost_currency": "USD", "cost_complete": false}, + ) + fromFiles, fromProjection := bothReadPaths(t, dir) + for name, got := range map[string]RangeStats{"JSONL": fromFiles, "projection": fromProjection} { + if got.UnpricedCalls != 1 { + t.Errorf("%s: unpricedCalls = %d, want the row counted even without a reason", name, got.UnpricedCalls) + } + if len(got.IncompleteReasons) != 0 { + t.Errorf("%s: incompleteReasons = %v, want no reason invented for an unknown one", name, got.IncompleteReasons) + } + } +} diff --git a/internal/state/stats/unpriced_range_test.go b/internal/state/stats/unpriced_range_test.go new file mode 100644 index 0000000000..5f22726d7c --- /dev/null +++ b/internal/state/stats/unpriced_range_test.go @@ -0,0 +1,27 @@ +package stats + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestATotalCarriesItsUnpricedCount reads the query result the way a client +// does — through the JSON it emits — so the fact is pinned at the wire, not in +// a struct field a later change could quietly stop filling. +func TestATotalCarriesItsUnpricedCount(t *testing.T) { + out, err := json.Marshal(RangeStats{ + CostIncomplete: true, + UnpricedCalls: 2, + IncompleteReasons: []string{"missing_price_or_usage", "no_price"}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(out) + for _, want := range []string{`"unpricedCalls":2`, `"incompleteReasons":["missing_price_or_usage","no_price"]`} { + if !strings.Contains(got, want) { + t.Fatalf("a total does not say how many calls it could not price: %s missing from %s", want, got) + } + } +} diff --git a/internal/state/stats/unpriced_total_test.go b/internal/state/stats/unpriced_total_test.go new file mode 100644 index 0000000000..071448949a --- /dev/null +++ b/internal/state/stats/unpriced_total_test.go @@ -0,0 +1,81 @@ +package stats + +// A range total said its money might be incomplete (costIncomplete), but never +// how many calls it could not price nor why. These guards pin the count, the +// vocabulary of the reasons, and that both read paths compute them alike. + +import ( + "encoding/json" + "reflect" + "testing" + + "reasonix/internal/base/testenv" +) + +// totalPayload is the aggregate's own facts as the panel reads them, without +// the series and breakdowns other guards pin. +func totalPayload(t *testing.T, r RangeStats) map[string]any { + t.Helper() + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(b, &payload); err != nil { + t.Fatal(err) + } + delete(payload, "daily") + delete(payload, "models") + delete(payload, "providers") + return payload +} + +// The gap: a range that priced some calls and could not price others carried +// only costIncomplete. The exact old payload said "the floor exists" and +// nothing said how many calls were under it or why. +func TestARangeTotalSaysHowManyCallsItCouldNotPrice(t *testing.T) { + dir := testenv.TempDir(t) + writeDay(t, dir, "2026-08-10", + map[string]any{"ts": "2026-08-10T01:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 100, "requests": 1, "cost_amount": "2", "cost_currency": "USD", "cost_complete": true}, + // A quote that said it could not price the call, and named why. + map[string]any{"ts": "2026-08-10T02:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 50, "requests": 1, "cost_complete": false, "incomplete_reason": "missing_price_or_usage"}, + map[string]any{"ts": "2026-08-10T03:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 30, "requests": 1, "cost_complete": false, "incomplete_reason": "no_price"}, + // Written before the cost fields existed: no cost fact at all. + map[string]any{"ts": "2026-08-10T04:00:00Z", "model": "deepseek/deepseek-chat", "source": "cli", + "total": 20, "requests": 1}, + map[string]any{"ts": "2026-08-10T05:00:00Z", "source": "cli", "turn": true}, + ) + got := queryAll(t, dir) + payload := totalPayload(t, got) + t.Logf("total payload = %s", mustJSON(t, payload)) + + want := map[string]any{ + "from": "2026-08-01", "to": "2026-08-31", + "tokens": float64(200), "requests": float64(4), "turns": float64(1), + "cache_hit": float64(0), "cache_miss": float64(0), + "cost": []any{map[string]any{"amount": "2", "currency": "USD"}}, + "costIncomplete": true, + // Three calls had no money fact: two quotes that said so, one row that + // predates the cost fields. Two distinct reasons stand behind them. + "unpricedCalls": float64(3), + "incompleteReasons": []any{"missing_price_or_usage", "no_price"}, + "active_days": float64(1), + "top_model": "deepseek/deepseek-chat", + "top_provider": "deepseek", + } + if !reflect.DeepEqual(payload, want) { + t.Fatalf("total payload = %s\nwant %s", mustJSON(t, payload), mustJSON(t, want)) + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/state/stats/usage_catalog.go b/internal/state/stats/usage_catalog.go index fcd25837b5..237e43226b 100644 --- a/internal/state/stats/usage_catalog.go +++ b/internal/state/stats/usage_catalog.go @@ -157,9 +157,10 @@ func usageEntry(day string, r record) usagecatalog.Entry { if r.Turn { turns = 1 } + reason, incomplete := usagecatalog.UnpricedReason(r.CostComplete, r.Total, r.CostAmount, r.IncompleteReason) return usagecatalog.Entry{Day: day, Source: r.Source, ModelRef: r.ModelRef, Provider: providerOf(r.ModelRef), Prompt: r.Prompt, Completion: r.Completion, Reasoning: r.Reasoning, CacheHit: r.CacheHit, CacheMiss: r.CacheMiss, Total: r.Total, Requests: r.Requests, Turns: turns}.WithCost(usagecatalog.Priced{ Amount: r.CostAmount, Currency: r.CostCurrency, CNY: r.ValuationCNY, USD: r.ValuationUSD, Estimated: r.CostEstimated, - Incomplete: usagecatalog.CostIncomplete(r.CostComplete, r.Total, r.CostAmount), BillingMode: r.BillingMode}) + Incomplete: incomplete, Reason: reason, BillingMode: r.BillingMode}) } diff --git a/internal/state/usagecatalog/catalog.go b/internal/state/usagecatalog/catalog.go index bff4cf9643..c3efad71e4 100644 --- a/internal/state/usagecatalog/catalog.go +++ b/internal/state/usagecatalog/catalog.go @@ -25,7 +25,9 @@ import ( // SchemaVersion names the projection file, so a bump retires the old one // rather than migrating it: every column here is replayable from the JSONL. -const SchemaVersion = 7 +// Version 8 adds the reason a row was unpriced; opening it reindexes the daily +// files, so an older projection's rows regain their reason from the source. +const SchemaVersion = 8 type AppendReceipt struct { Path string @@ -56,6 +58,9 @@ type Entry struct { // CostIncomplete marks a row that counted usage but whose money is // unknown, so a total folding it in is a floor, not a bill. CostIncomplete bool + // IncompleteReason names why, in the quote's vocabulary; empty when the + // row is priced or its reason was never recorded. + IncompleteReason string // BillingMode is the mode the quote was made in; a plan's estimate is kept // apart from billed cost. BillingMode string @@ -177,7 +182,7 @@ CREATE TABLE usage_records( prompt INTEGER NOT NULL,completion INTEGER NOT NULL,reasoning INTEGER NOT NULL,cache_hit INTEGER NOT NULL, cache_miss INTEGER NOT NULL,total INTEGER NOT NULL,requests INTEGER NOT NULL,turns INTEGER NOT NULL, cost INTEGER NOT NULL DEFAULT 0,cost_currency TEXT NOT NULL DEFAULT '',cost_estimated INTEGER NOT NULL DEFAULT 0, - cost_incomplete INTEGER NOT NULL DEFAULT 0, + cost_incomplete INTEGER NOT NULL DEFAULT 0,incomplete_reason TEXT NOT NULL DEFAULT '', valuation_cny INTEGER,valuation_usd INTEGER,billing_mode TEXT NOT NULL DEFAULT '', PRIMARY KEY(file_path,byte_offset) ); @@ -194,6 +199,7 @@ CREATE TABLE usage_cost( PRIMARY KEY(day,source,model_ref,view,currency,mode) ); CREATE INDEX idx_usage_rollups_range ON usage_rollups(day,source,model_ref); +CREATE INDEX idx_usage_records_unpriced ON usage_records(day,source,cost_incomplete,incomplete_reason); ` func migrations() []projectiondb.Migration { @@ -393,10 +399,11 @@ func insertRecord(ctx context.Context, tx *sql.Tx, receipt AppendReceipt, entry } _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO usage_records(file_path,byte_offset,byte_length,line_hash,day,source, model_ref,provider,prompt,completion,reasoning,cache_hit,cache_miss,total,requests,turns,cost,cost_currency,cost_estimated, - cost_incomplete,valuation_cny,valuation_usd,billing_mode) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + cost_incomplete,incomplete_reason,valuation_cny,valuation_usd,billing_mode) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, receipt.Path, receipt.Offset, receipt.Length, receipt.LineHash, entry.Day, entry.Source, entry.ModelRef, entry.Provider, entry.Prompt, entry.Completion, entry.Reasoning, entry.CacheHit, entry.CacheMiss, entry.Total, entry.Requests, entry.Turns, - entry.Cost, entry.CostCurrency, estimated, incomplete, valuationArg(entry, "CNY"), valuationArg(entry, "USD"), entry.BillingMode) + entry.Cost, entry.CostCurrency, estimated, incomplete, entry.IncompleteReason, + valuationArg(entry, "CNY"), valuationArg(entry, "USD"), entry.BillingMode) return err } @@ -480,9 +487,11 @@ type rawRecord struct { CostCurrency string `json:"cost_currency"` CostEstimated bool `json:"cost_estimated"` CostComplete *bool `json:"cost_complete"` - ValuationCNY string `json:"valuation_cny"` - ValuationUSD string `json:"valuation_usd"` - BillingMode string `json:"billing_mode"` + // The reason the quote recorded, if any. + IncompleteReason string `json:"incomplete_reason"` + ValuationCNY string `json:"valuation_cny"` + ValuationUSD string `json:"valuation_usd"` + BillingMode string `json:"billing_mode"` } func providerOf(model string) string { @@ -497,12 +506,12 @@ func entryFromRaw(day string, raw rawRecord) Entry { if raw.Turn { turns = 1 } - incomplete := CostIncomplete(raw.CostComplete, raw.Total, raw.CostAmount) + reason, incomplete := UnpricedReason(raw.CostComplete, raw.Total, raw.CostAmount, raw.IncompleteReason) return Entry{Day: day, Source: raw.Source, ModelRef: raw.ModelRef, Provider: providerOf(raw.ModelRef), Prompt: raw.Prompt, Completion: raw.Completion, Reasoning: raw.Reasoning, CacheHit: raw.CacheHit, CacheMiss: raw.CacheMiss, Total: raw.Total, Requests: raw.Requests, Turns: turns}.WithCost(Priced{Amount: raw.CostAmount, Currency: raw.CostCurrency, CNY: raw.ValuationCNY, USD: raw.ValuationUSD, Estimated: raw.CostEstimated, - Incomplete: incomplete, BillingMode: raw.BillingMode}) + Incomplete: incomplete, Reason: reason, BillingMode: raw.BillingMode}) } // recordDays is every day the file's records were filed under, plus the day it diff --git a/internal/state/usagecatalog/costview.go b/internal/state/usagecatalog/costview.go index 1ac22ea7e4..e9c5138ea0 100644 --- a/internal/state/usagecatalog/costview.go +++ b/internal/state/usagecatalog/costview.go @@ -46,6 +46,9 @@ type Priced struct { // Incomplete says the quote could not price the row's usage: its money is // unknown, not zero. Incomplete bool + // Reason is the line's incomplete_reason, already normalized by the caller + // through UnpricedReason; empty when the row recorded none. + Reason string // BillingMode is the mode the quote was made in; empty reads as billed. BillingMode string } @@ -62,10 +65,36 @@ func CostIncomplete(costComplete *bool, total int, amount string) bool { return total > 0 && strings.TrimSpace(amount) == "" } +// ReasonNoPrice is the reason the quote path already records for a call with +// no price fact (event.EnsureCostQuote). A row written before the cost fields +// existed carries no reason at all; this names the case it is. +const ReasonNoPrice = "no_price" + +// UnpricedReason is CostIncomplete plus the reason behind it, in the +// vocabulary the quote already writes. An unpriced row that recorded no reason +// is still unpriced; only a row with no cost fact takes the existing +// ReasonNoPrice, and no reason is invented for the others. +func UnpricedReason(costComplete *bool, total int, amount, reason string) (string, bool) { + if !CostIncomplete(costComplete, total, amount) { + return "", false + } + if reason = strings.TrimSpace(reason); reason != "" { + return reason, true + } + if strings.TrimSpace(amount) == "" { + return ReasonNoPrice, true + } + return "", true +} + // WithCost attaches a line's quote. An amount that does not parse is dropped // rather than guessed: a missing total beats a wrong one. func (e Entry) WithCost(p Priced) Entry { e.Cost, e.CostCurrency, e.CostEstimated, e.CostIncomplete, e.BillingMode = 0, "", p.Estimated, p.Incomplete, p.BillingMode + e.IncompleteReason = "" + if p.Incomplete { + e.IncompleteReason = p.Reason + } if amount, ok := parseAmount(p.Amount); ok { e.Cost, e.CostCurrency = amount, pricing.NormalizeCurrency(p.Currency) } diff --git a/internal/state/usagecatalog/incomplete_test.go b/internal/state/usagecatalog/incomplete_test.go index a8bc08f573..ed49efeede 100644 --- a/internal/state/usagecatalog/incomplete_test.go +++ b/internal/state/usagecatalog/incomplete_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "testing" "reasonix/internal/base/testenv" @@ -45,3 +46,41 @@ func TestAnUnpricedRowSurvivesReconcileAsIncomplete(t *testing.T) { } } } + +// An unpriced row is indexed with the reason it recorded, and a row with no +// cost fact takes the product's existing "no_price". Repeated reconciles must +// answer the same count and vocabulary, not double the count. +func TestUnpricedReasonsAreIndexedOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := testenv.TempDir(t) + path := filepath.Join(dir, "2026-08-10.jsonl") + lines := []byte( + "{\"ts\":\"2026-08-10T10:00:00Z\",\"model\":\"deepseek/m\",\"source\":\"desktop\",\"total\":42,\"cost_complete\":false,\"incomplete_reason\":\"no_price\"}\n" + + "{\"ts\":\"2026-08-10T11:00:00Z\",\"model\":\"deepseek/m\",\"source\":\"desktop\",\"total\":7}\n" + + "{\"ts\":\"2026-08-10T12:00:00Z\",\"model\":\"deepseek/m\",\"source\":\"desktop\",\"total\":8,\"cost_amount\":\"1\",\"cost_currency\":\"USD\",\"cost_complete\":true}\n") + if err := os.WriteFile(path, lines, 0o600); err != nil { + t.Fatal(err) + } + catalog, err := Open(ctx, filepath.Join(testenv.TempDir(t), "usage.sqlite")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + + for pass := range 2 { // second pass re-derives the day + if err := catalog.ReconcileFile(ctx, path, "2026-08-10"); err != nil { + t.Fatalf("pass %d: %v", pass, err) + } + calls, reasons, err := catalog.UnpricedInRange(ctx, "2026-08-10", "2026-08-10", "desktop") + if err != nil { + t.Fatalf("pass %d: %v", pass, err) + } + if calls != 2 || !reflect.DeepEqual(reasons, []string{"no_price"}) { + t.Fatalf("pass %d: unpriced = %d / reasons %v, want 2 / [no_price]", pass, calls, reasons) + } + if calls, _, err = catalog.UnpricedInRange(ctx, "2026-08-10", "2026-08-10", "cli"); err != nil || calls != 0 { + t.Fatalf("pass %d: source filter read %d unpriced calls (err %v), want none", pass, calls, err) + } + } +} diff --git a/internal/state/usagecatalog/unpriced.go b/internal/state/usagecatalog/unpriced.go new file mode 100644 index 0000000000..f8f203e55c --- /dev/null +++ b/internal/state/usagecatalog/unpriced.go @@ -0,0 +1,45 @@ +package usagecatalog + +import ( + "context" + "sort" +) + +// UnpricedInRange counts the range's unpriced calls and names the distinct +// reasons their rows recorded, sorted. It reads the stored predicate, so it +// answers what the exact JSONL read answers. +func (c *Catalog) UnpricedInRange(ctx context.Context, fromDay, toDay, source string) (int, []string, error) { + where := `day>=? AND day<=? AND cost_incomplete=1` + args := []any{fromDay, toDay} + if source != "" && source != "all" { + where += ` AND source=?` + args = append(args, source) + } + rows, err := c.db.QueryContext(ctx, + `SELECT incomplete_reason,COUNT(*) FROM usage_records WHERE `+where+` GROUP BY incomplete_reason`, args...) + if err != nil { + return 0, nil, err + } + defer rows.Close() + var calls int + var reasons []string + for rows.Next() { + var reason string + var n int + if err := rows.Scan(&reason, &n); err != nil { + return 0, nil, err + } + calls += n + if reason != "" { + reasons = append(reasons, reason) + } + } + if err := rows.Err(); err != nil { + return 0, nil, err + } + // Sorted, deduplicated by the GROUP BY: a repeated query returns the same + // answer, and an unnamed reason stays out of the vocabulary rather than + // entering it as an empty string. + sort.Strings(reasons) + return calls, reasons, nil +} From c1321f9971eb7130fc520770198b24cb42057e1e Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 27 Oct 2026 11:03:38 +0800 Subject: [PATCH 23/37] docs(release-notes): record #14522 --- release-notes/studio/unreleased/14522-unpriced-calls-counted.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 release-notes/studio/unreleased/14522-unpriced-calls-counted.md diff --git a/release-notes/studio/unreleased/14522-unpriced-calls-counted.md b/release-notes/studio/unreleased/14522-unpriced-calls-counted.md new file mode 100644 index 0000000000..d3d03de24a --- /dev/null +++ b/release-notes/studio/unreleased/14522-unpriced-calls-counted.md @@ -0,0 +1,2 @@ +- 修复:用量总额现在会说明其中有几笔调用未定价、以及原因;未定价不再只由一个标志暗示。关联 #14522。 +- Fixed: a usage total now says how many of its calls were never priced, and why, instead of only carrying an incomplete flag. Refs #14522. From d7d0c8aeef6da972d08e94a046a9d75bcf6abd3d Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 26 Oct 2026 14:25:27 +0800 Subject: [PATCH 24/37] fix(acp): an inbox refusal says which refusal it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every inbox handler answered a store refusal with -32600 and prose, so a client could not tell "gone" from "wrong state" from "may already have run" — and one that retried on any error re-sent work the kernel refuses precisely because the turn may already have run. The HTTP frontend already names 14 conditions; ACP now answers the same names, with -32020 for "the queue refused this call" and -32021 for "the work may already have run", and leaves -32600 to a malformed frame alone. docs/ACP.md states the table and each refusal's class (terminal, retry-after-repair, do not retry). repolint's family-vs-writer check now reads both writers, so a sentinel added to the store cannot reach ACP unnamed. --- docs/ACP.md | 45 +++++ internal/frontend/acp/inbox.go | 86 +++++++++- internal/frontend/acp/inbox_test.go | 249 ++++++++++++++++++++++++++++ tools/repolint/inboxrefusal.go | 96 +++++++---- tools/repolint/inboxrefusal_test.go | 108 ++++++++++-- 5 files changed, 528 insertions(+), 56 deletions(-) create mode 100644 internal/frontend/acp/inbox_test.go diff --git a/docs/ACP.md b/docs/ACP.md index 936f8566b0..1952ac9961 100644 --- a/docs/ACP.md +++ b/docs/ACP.md @@ -375,6 +375,51 @@ names rather than constructing vendor method strings. bodies. A recovered inbox is paused; clients should let users inspect it before calling `setPaused` with `false`. +### When an inbox call is refused + +A refused inbox call answers with an implementation-defined JSON-RPC code and a +`data.code` a client branches on; `message` stays Reasonix's English, which +clients still show. The identities are the HTTP frontend's, so one vocabulary +names the queue's conditions on both surfaces. + +```json +{"error": {"code": -32020, "message": "inbox item not found", "data": {"code": "inbox.not_found"}}} +``` + +`-32020` is "the queue refused this call" and `-32021` is "the work may already +have run". The class is the client's instruction: + +- **terminal** — the same call cannot start succeeding; repair the client's view + instead of retrying it; +- **retry-after-repair** — the same call succeeds once the named condition is + repaired; +- **do not retry** — the work may already have run, so retrying could run it + twice. + +| `data.code` | JSON-RPC | Class | Refusal | +| --- | --- | --- | --- | +| `inbox.not_found` | `-32020` | terminal | No item with this id is in the queue. Read `list` and drop it. | +| `inbox.invalid_state` | `-32020` | terminal | This item's state does not allow this verb. Read `list` for the state, then pick a verb that fits. | +| `inbox.retry_may_repeat` | `-32021` | do not retry | Its block code is not `steer_unapplied`, so the turn may already have run. Retrying could run the work twice. | +| `inbox.item_too_large` | `-32020` | retry-after-repair | The body is over the single-item limit. Send a shorter one. | +| `inbox.capacity_items` | `-32020` | retry-after-repair | The queue holds its maximum count. Delete pending work, then retry. | +| `inbox.capacity_bytes` | `-32020` | retry-after-repair | The queue holds its maximum bytes. Delete pending work, then retry. | +| `inbox.paused` | `-32020` | retry-after-repair | The queue is paused. Resume it where the pause is one a client may lift; a host-set pause is for the person to inspect first, then retry. | +| `inbox.unrecovered` | `-32020` | retry-after-repair | The queue could not be read, so its contents are unknown. Inspect the queue directory before resuming. | +| `inbox.closed` | `-32020` | terminal | This queue is closed. Open the session again. | +| `inbox.schema_readonly` | `-32020` | terminal | A newer Reasonix wrote the queue and this build may read it only. Upgrade to write it. | +| `inbox.foreign_run` | `-32020` | terminal | Another run owns this item. Change it from the session that owns it. | +| `inbox.idempotency_conflict` | `-32020` | terminal | The key already belongs to different input. The same body still refuses; new input needs a new key. | +| `steer.already_applied` | `-32020` | terminal | The steer reached the turn before this call did. Nothing is lost and nothing is left to do. | +| `inbox.empty` | `-32602` | terminal | The request carries no text. Send a non-empty body. | + +Three answers are not queue refusals: + +- `-32602` with no `data.code`: the request cannot address a queue at all — an + unknown `sessionId`, or params that do not parse; +- `-32600`: a malformed JSON-RPC frame (no method, or not an object); +- `-32603`: the queue, or the session's path to it, failed rather than refused. + ## Goal pause and resume extension Selecting `goal` with `session/set_mode` always drafts a **new** Goal: the next diff --git a/internal/frontend/acp/inbox.go b/internal/frontend/acp/inbox.go index 85be7bdd3e..80290fee66 100644 --- a/internal/frontend/acp/inbox.go +++ b/internal/frontend/acp/inbox.go @@ -3,12 +3,80 @@ package acp import ( "context" "encoding/json" + "errors" "strings" "reasonix/internal/session/control" "reasonix/internal/state/sessioninbox" ) +// Implementation-defined JSON-RPC codes for an inbox refusal. The identities +// under data.code are the HTTP frontend's; these two codes say which of the two +// answers a client must act on — a call it may repair and make again, or one +// whose work may already have run. +const ( + ErrInboxRefused = -32020 + ErrInboxMayHaveRun = -32021 +) + +// InboxRefusalData is the data member of an inbox refusal: the same dotted +// identity the HTTP frontend sends, so one vocabulary names both surfaces. +type InboxRefusalData struct { + Code string `json:"code"` +} + +// refuseInbox answers one inbox call with the documented pair: the JSON-RPC +// code for the class, data.code for the condition, and the kernel's English as +// the message, which clients still show. +func refuseInbox(rpcCode int, dataCode, message string) *RPCError { + return &RPCError{Code: rpcCode, Message: message, Data: InboxRefusalData{Code: dataCode}} +} + +// inboxRefusal names every condition the queue can refuse an inbox call with. +// A sentinel with no case here reached the client as "your request was +// malformed", so a client could not tell a gone item from a broken frame, nor +// stop itself retrying work that may already have run. repolint reads this +// switch against the store's exported family, the way it reads the HTTP writer. +func inboxRefusal(err error) *RPCError { + switch { + case errors.Is(err, control.ErrSteerApplied): + return refuseInbox(ErrInboxRefused, "steer.already_applied", err.Error()) + case errors.Is(err, sessioninbox.ErrItemTooLarge): + return refuseInbox(ErrInboxRefused, "inbox.item_too_large", err.Error()) + case errors.Is(err, sessioninbox.ErrCapacityItems): + return refuseInbox(ErrInboxRefused, "inbox.capacity_items", err.Error()) + case errors.Is(err, sessioninbox.ErrCapacityBytes): + return refuseInbox(ErrInboxRefused, "inbox.capacity_bytes", err.Error()) + case errors.Is(err, sessioninbox.ErrInvalidState): + return refuseInbox(ErrInboxRefused, "inbox.invalid_state", err.Error()) + case errors.Is(err, sessioninbox.ErrRetryMayRepeat): + // Raised precisely when the item's block code is not RetrySafe, which is + // the one answer a client must never retry blindly. + return refuseInbox(ErrInboxMayHaveRun, "inbox.retry_may_repeat", err.Error()) + case errors.Is(err, sessioninbox.ErrForeignRun): + return refuseInbox(ErrInboxRefused, "inbox.foreign_run", err.Error()) + case errors.Is(err, sessioninbox.ErrPaused): + return refuseInbox(ErrInboxRefused, "inbox.paused", err.Error()) + case errors.Is(err, sessioninbox.ErrUnrecovered): + return refuseInbox(ErrInboxRefused, "inbox.unrecovered", err.Error()) + case errors.Is(err, sessioninbox.ErrNotFound): + return refuseInbox(ErrInboxRefused, "inbox.not_found", err.Error()) + case errors.Is(err, sessioninbox.ErrIdempotencyConflict): + return refuseInbox(ErrInboxRefused, "inbox.idempotency_conflict", err.Error()) + case errors.Is(err, sessioninbox.ErrSchemaReadonly): + return refuseInbox(ErrInboxRefused, "inbox.schema_readonly", err.Error()) + case errors.Is(err, sessioninbox.ErrClosed): + return refuseInbox(ErrInboxRefused, "inbox.closed", err.Error()) + case errors.Is(err, sessioninbox.ErrEmpty): + return refuseInbox(ErrInvalidParams, "inbox.empty", err.Error()) + default: + // Not a refusal this surface can name: the queue or the session's path + // to it failed. That is a fault, and saying so is more honest than + // blaming the request. + return &RPCError{Code: ErrInternal, Message: err.Error()} + } +} + func (s *service) sessionAPI(sess *acpSession) (control.EditorAPI, error) { if sess == nil { return nil, &RPCError{Code: ErrInvalidParams, Message: "unknown session"} @@ -36,7 +104,7 @@ func (s *service) sessionInboxEnqueue(_ context.Context, raw json.RawMessage) (a } text := strings.TrimSpace(p.Text) if text == "" { - return nil, &RPCError{Code: ErrInvalidParams, Message: sessionInboxEnqueueMethod + ": empty text"} + return nil, refuseInbox(ErrInvalidParams, "inbox.empty", sessionInboxEnqueueMethod+": empty text") } intent := sessioninbox.IntentFollowup if strings.EqualFold(p.Intent, "steer") { @@ -60,7 +128,7 @@ func (s *service) sessionInboxEnqueue(_ context.Context, raw json.RawMessage) (a rec, err = api.EnqueueInbox(req) } if err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{ "itemId": rec.ItemID, @@ -96,7 +164,7 @@ func (s *service) sessionInboxGet(_ context.Context, raw json.RawMessage) (any, } meta, env, err := api.ReadInboxItem(p.ItemID) if err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"meta": meta, "envelope": env}, nil } @@ -113,7 +181,7 @@ func (s *service) sessionInboxUpdate(_ context.Context, raw json.RawMessage) (an text := strings.TrimSpace(p.Text) meta, err := api.UpdateInboxItem(p.ItemID, text, text, text) if err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return meta, nil } @@ -128,7 +196,7 @@ func (s *service) sessionInboxDelete(_ context.Context, raw json.RawMessage) (an return nil, err } if err := api.DeleteInboxItem(p.ItemID); err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"ok": true}, nil } @@ -143,7 +211,7 @@ func (s *service) sessionInboxMove(_ context.Context, raw json.RawMessage) (any, return nil, err } if err := api.MoveInboxItem(p.ItemID, p.ToIndex); err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"ok": true}, nil } @@ -158,7 +226,7 @@ func (s *service) sessionInboxSetPaused(_ context.Context, raw json.RawMessage) return nil, err } if err := api.SetInboxPaused(p.Paused); err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"paused": p.Paused}, nil } @@ -173,7 +241,7 @@ func (s *service) sessionInboxRetry(_ context.Context, raw json.RawMessage) (any return nil, err } if err := api.RetryInboxItem(p.ItemID); err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"ok": true}, nil } @@ -188,7 +256,7 @@ func (s *service) sessionInboxRefresh(_ context.Context, raw json.RawMessage) (a return nil, err } if err := api.RefreshInboxReferences(p.ItemID); err != nil { - return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()} + return nil, inboxRefusal(err) } return map[string]any{"ok": true}, nil } diff --git a/internal/frontend/acp/inbox_test.go b/internal/frontend/acp/inbox_test.go new file mode 100644 index 0000000000..965ed2a577 --- /dev/null +++ b/internal/frontend/acp/inbox_test.go @@ -0,0 +1,249 @@ +package acp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/event" + "reasonix/internal/session/control" + "reasonix/internal/state/sessioninbox" +) + +// requireRefusal holds one refusal to the pair the docs promise a client: the +// JSON-RPC code for its class, and the identity it branches on. The message +// stays: clients still show it. +func requireRefusal(t *testing.T, f frame, wantCode int, wantIdentity string) { + t.Helper() + if f.Error == nil { + t.Fatalf("response %s, want a refusal", f.Result) + } + var data struct { + Code string `json:"code"` + } + if len(f.Error.Data) > 0 { + if err := json.Unmarshal(f.Error.Data, &data); err != nil { + t.Fatalf("refusal data %s: %v", f.Error.Data, err) + } + } + if f.Error.Code != wantCode || data.Code != wantIdentity { + t.Fatalf("refusal = %s, want code %d and data.code %q", wireError(f), wantCode, wantIdentity) + } + if f.Error.Message == "" { + t.Fatalf("refusal carries no message; clients still show it: %s", wireError(f)) + } +} + +// wireError renders the error member as the client received it, so a failing +// expectation shows the payload a client had to branch on. +func wireError(f frame) string { + raw, err := json.Marshal(f.Error) + if err != nil { + return "" + } + return string(raw) +} + +// TestEveryInboxRefusalCarriesItsDocumentedCode holds each condition the queue +// declares to the pair docs/ACP.md promises. repolint holds the switch to the +// store's family; this holds every case to the identity it writes. +func TestEveryInboxRefusalCarriesItsDocumentedCode(t *testing.T) { + cases := []struct { + err error + code int + identity string + }{ + {control.ErrSteerApplied, ErrInboxRefused, "steer.already_applied"}, + {sessioninbox.ErrItemTooLarge, ErrInboxRefused, "inbox.item_too_large"}, + {sessioninbox.ErrCapacityItems, ErrInboxRefused, "inbox.capacity_items"}, + {sessioninbox.ErrCapacityBytes, ErrInboxRefused, "inbox.capacity_bytes"}, + {sessioninbox.ErrInvalidState, ErrInboxRefused, "inbox.invalid_state"}, + {sessioninbox.ErrRetryMayRepeat, ErrInboxMayHaveRun, "inbox.retry_may_repeat"}, + {sessioninbox.ErrForeignRun, ErrInboxRefused, "inbox.foreign_run"}, + {sessioninbox.ErrPaused, ErrInboxRefused, "inbox.paused"}, + {sessioninbox.ErrUnrecovered, ErrInboxRefused, "inbox.unrecovered"}, + {sessioninbox.ErrNotFound, ErrInboxRefused, "inbox.not_found"}, + {sessioninbox.ErrIdempotencyConflict, ErrInboxRefused, "inbox.idempotency_conflict"}, + {sessioninbox.ErrSchemaReadonly, ErrInboxRefused, "inbox.schema_readonly"}, + {sessioninbox.ErrClosed, ErrInboxRefused, "inbox.closed"}, + {sessioninbox.ErrEmpty, ErrInvalidParams, "inbox.empty"}, + } + seen := map[string]error{} + for _, tc := range cases { + // Wrapped, because that is how these arrive: the store or the controller + // adds which item it was about, and a switch reading the sentence + // instead of the sentinel stops matching right there. + got := inboxRefusal(fmt.Errorf("inbox item q1: %w", tc.err)) + data, ok := got.Data.(InboxRefusalData) + if !ok || data.Code != tc.identity || got.Code != tc.code { + t.Errorf("%v: refusal = %d/%q, want %d/%q", tc.err, got.Code, data.Code, tc.code, tc.identity) + } + if got.Message == "" { + t.Errorf("%v: refusal carries no message; clients still show it", tc.err) + } + if prev, dup := seen[data.Code]; dup { + t.Errorf("%q names both %v and %v; two conditions under one identity cannot be told apart", data.Code, prev, tc.err) + } + seen[data.Code] = tc.err + } +} + +// A failure this surface cannot name is not given an identity: a code would say +// the queue refused, when what happened is that it could not be read. +func TestAnUnnamedInboxFailureIsAFaultNotARefusal(t *testing.T) { + got := inboxRefusal(errors.New("disk went away")) + if got.Code != ErrInternal { + t.Errorf("code %d, want %d", got.Code, ErrInternal) + } + if got.Data != nil { + t.Errorf("data = %+v, want none", got.Data) + } +} + +// TestServeInboxRetryOnAMissingItemNamesTheRefusal is the probe that motivated +// the contract: the same answer -32600 as a malformed frame, no data.code, so a +// client could not tell "gone" from "your request was broken" — and one that +// retried on any error re-sent work the queue had already refused. +func TestServeInboxRetryOnAMissingItemNamesTheRefusal(t *testing.T) { + factory := &configurableFactory{dir: testenv.TempDir(t)} + client, stop := startServer(t, factory) + defer stop() + + sid := openInboxSession(t, client) + resp := client.call(t, sessionInboxRetryMethod, SessionInboxItemParams{SessionID: sid, ItemID: "no-such-item"}) + requireRefusal(t, resp, ErrInboxRefused, "inbox.not_found") +} + +// TestServeInboxRetryOnWorkThatMayHaveRunCarriesThatIdentity is the refusal a +// client must never retry blindly: the item's block code is not steer_unapplied, +// so the turn may already have run. It answers with its own JSON-RPC code, so a +// retry policy that knows only the classes still stops. +func TestServeInboxRetryOnWorkThatMayHaveRunCarriesThatIdentity(t *testing.T) { + factory := &configurableFactory{dir: testenv.TempDir(t)} + // The hook hands the test the live controller, which is how it reaches the + // queue on disk to put one item in the state a crashed run leaves behind. + captured := make(chan *control.Controller, 1) + factory.withCtrl = func(_ context.Context, sink event.Sink, _ string, _ SessionParams, ctrl *control.Controller) error { + captured <- ctrl + sink.Emit(event.Event{Kind: event.Text, Text: "done"}) + return nil + } + client, stop := startServer(t, factory) + defer stop() + + sid := openInboxSession(t, client) + promptCh := client.callAsync("session/prompt", SessionPromptParams{ + SessionID: sid, + Prompt: []ContentBlock{{Type: "text", Text: "first turn"}}, + }) + if _, resp := drainPrompt(t, client, promptCh); resp.Error != nil { + t.Fatalf("prompt: %+v", resp.Error) + } + ctrl := <-captured + + enqueued := client.call(t, sessionInboxEnqueueMethod, SessionInboxEnqueueParams{SessionID: sid, Text: "queued work"}) + if enqueued.Error != nil { + t.Fatalf("enqueue: %+v", enqueued.Error) + } + var rec struct { + ItemID string `json:"itemId"` + } + if err := json.Unmarshal(enqueued.Result, &rec); err != nil || rec.ItemID == "" { + t.Fatalf("enqueue result %s: %v", enqueued.Result, err) + } + + // The block code a transcript-snapshot failure leaves: not steer_unapplied, + // so RetrySafe is false and the queue says the work may already have run. + st, err := sessioninbox.Open(ctrl.SessionPath(), sessioninbox.Limits{}) + if err != nil { + t.Fatalf("open queue at %q: %v", ctrl.SessionPath(), err) + } + defer st.Close() + if err := st.SetStateCoded(rec.ItemID, sessioninbox.StateUncertain, sessioninbox.BlockSnapshotFailed, "turn completed but transcript snapshot failed"); err != nil { + t.Fatalf("set uncertain: %v", err) + } + + resp := client.call(t, sessionInboxRetryMethod, SessionInboxItemParams{SessionID: sid, ItemID: rec.ItemID}) + requireRefusal(t, resp, ErrInboxMayHaveRun, "inbox.retry_may_repeat") +} + +// TestServeInboxSuccessPathIsUnchanged is the control: a call that is not +// refused answers exactly what it answered before, with no error at all. +func TestServeInboxSuccessPathIsUnchanged(t *testing.T) { + factory := &configurableFactory{dir: testenv.TempDir(t)} + client, stop := startServer(t, factory) + defer stop() + + sid := openInboxSession(t, client) + resp := client.call(t, sessionInboxEnqueueMethod, SessionInboxEnqueueParams{SessionID: sid, Text: "queued work"}) + if resp.Error != nil { + t.Fatalf("enqueue: %+v", resp.Error) + } + var rec struct { + ItemID string `json:"itemId"` + Disposition string `json:"disposition"` + Position int `json:"position"` + } + if err := json.Unmarshal(resp.Result, &rec); err != nil { + t.Fatalf("enqueue result: %v", err) + } + if rec.ItemID == "" || rec.Disposition != "queued_followup" || rec.Position != 1 { + t.Fatalf("enqueue result = %+v, want the first queued follow-up", rec) + } + + list := client.call(t, sessionInboxListMethod, struct { + SessionID string `json:"sessionId"` + }{SessionID: sid}) + if list.Error != nil { + t.Fatalf("list: %+v", list.Error) + } + var snap struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + if err := json.Unmarshal(list.Result, &snap); err != nil { + t.Fatalf("list result: %v", err) + } + if len(snap.Items) != 1 || snap.Items[0].ID != rec.ItemID { + t.Fatalf("list items = %+v, want the item just enqueued", snap.Items) + } +} + +// TestAMalformedFrameKeepsTheInvalidRequestCode is the other control: -32600 +// still exists, and it still means what JSON-RPC says it means. +func TestAMalformedFrameKeepsTheInvalidRequestCode(t *testing.T) { + factory := &configurableFactory{dir: testenv.TempDir(t)} + var out bytes.Buffer + if err := Serve(context.Background(), strings.NewReader(`{"jsonrpc":"2.0"}`+"\n"), &out, factory, AgentInfo{Name: "reasonix-test", Version: "0"}); err != nil { + t.Fatalf("serve: %v", err) + } + var got frame + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("frame %q: %v", out.String(), err) + } + if got.Error == nil || got.Error.Code != ErrInvalidRequest { + t.Fatalf("malformed frame = %s, want code %d", out.String(), ErrInvalidRequest) + } +} + +func openInboxSession(t *testing.T, client *rpcClient) string { + t.Helper() + if resp := client.call(t, "initialize", InitializeParams{ProtocolVersion: 1}); resp.Error != nil { + t.Fatalf("initialize: %+v", resp.Error) + } + resp := client.call(t, "session/new", SessionNewParams{Cwd: testenv.TempDir(t)}) + if resp.Error != nil { + t.Fatalf("session/new: %+v", resp.Error) + } + var nr SessionNewResult + if err := json.Unmarshal(resp.Result, &nr); err != nil || nr.SessionID == "" { + t.Fatalf("session/new result %s: %v", resp.Result, err) + } + return nr.SessionID +} diff --git a/tools/repolint/inboxrefusal.go b/tools/repolint/inboxrefusal.go index 262ef50e2a..a8fc64fcce 100644 --- a/tools/repolint/inboxrefusal.go +++ b/tools/repolint/inboxrefusal.go @@ -15,41 +15,71 @@ import ( // one that carries a sentence. const ( inboxFamilyFile = "internal/state/sessioninbox/types.go" - inboxWriterFile = "internal/frontend/serve/inbox.go" - inboxWriterFunc = "writeInboxError" + + // Both frontends answer inbox calls from the same family, so both writers + // are named here. A writer that moved would otherwise compare clean against + // an empty set, which is the failure this check exists to catch. + inboxHTTPWriterFile = "internal/frontend/serve/inbox.go" + inboxHTTPWriterFunc = "writeInboxError" + inboxACPWriterFile = "internal/frontend/acp/inbox.go" + inboxACPWriterFunc = "inboxRefusal" ) +// inboxWriter is one frontend's refusal writer and the calls that carry an +// identity in that frontend's shape: the HTTP helpers take the dotted code as an +// argument, and the ACP one returns it as data beside the JSON-RPC code. +type inboxWriter struct { + file string + fn string + // coded names the functions a case must call, one argument of which is the + // dotted identity. A code assembled at runtime is one neither side can read. + coded map[string]bool +} + // The writers that give a refusal a dotted code. writeErr is deliberately not // among them: it renders an identity a deeper layer assigned and otherwise // falls back to prose, which is the diagnostic path, not an answer a frontend // can say in the reader's language. -var codedWriters = map[string]bool{"refuse": true, "busy": true, "refusal": true, "Refusal": true, "saveFailed": true} +var inboxWriters = []inboxWriter{ + { + file: inboxHTTPWriterFile, + fn: inboxHTTPWriterFunc, + coded: map[string]bool{"refuse": true, "busy": true, "refusal": true, "Refusal": true, "saveFailed": true}, + }, + { + file: inboxACPWriterFile, + fn: inboxACPWriterFunc, + coded: map[string]bool{"refuseInbox": true}, + }, +} -// checkInboxRefusalParity holds the inbox's HTTP boundary to the store's error -// family. A sentinel with no case of its own is answered as an internal fault — -// three of them were — and one whose case writes prose reaches the frontend as -// English nobody translated. Both are the same defect: the condition is known -// where it happens and unnamed by the time anyone has to act on it. +// checkInboxRefusalParity holds each frontend's inbox boundary to the store's +// error family. A sentinel with no case of its own is answered as an internal +// fault — three were on HTTP, and every one of them was on ACP — and one whose +// case writes prose reaches the client as English nobody translated. Both are +// the same defect: a name lost between the kernel and the client. func checkInboxRefusalParity(root string) []Finding { family, err := inboxSentinels(root) if err != nil { return []Finding{{inboxFamilyFile, 1, ruleRefusalPath, err.Error(), 1}} } - coded, uncoded, line, err := inboxHandledSentinels(root) - if err != nil { - return []Finding{{inboxWriterFile, 1, ruleRefusalPath, err.Error(), 1}} - } - var out []Finding - for _, name := range family { - switch { - case coded[name]: - case uncoded[name]: - out = append(out, Finding{inboxWriterFile, line, ruleRefusalPath, fmt.Sprintf( - "%s is refused without a code: the frontend gets an English sentence it cannot translate or branch on", name), 1}) - default: - out = append(out, Finding{inboxWriterFile, line, ruleRefusalPath, fmt.Sprintf( - "%s has no case in %s, so a known refusal is answered as an internal fault", name, inboxWriterFunc), 1}) + for _, w := range inboxWriters { + coded, uncoded, line, err := inboxHandledSentinels(root, w) + if err != nil { + out = append(out, Finding{w.file, 1, ruleRefusalPath, err.Error(), 1}) + continue + } + for _, name := range family { + switch { + case coded[name]: + case uncoded[name]: + out = append(out, Finding{w.file, line, ruleRefusalPath, fmt.Sprintf( + "%s is refused without a code in %s: the frontend gets an English sentence it cannot translate or branch on", name, w.fn), 1}) + default: + out = append(out, Finding{w.file, line, ruleRefusalPath, fmt.Sprintf( + "%s has no case in %s, so a known refusal is answered as an internal fault", name, w.fn), 1}) + } } } return out @@ -85,12 +115,12 @@ func inboxSentinels(root string) ([]string, error) { return out, nil } -// inboxHandledSentinels splits the switch's cases by what their body writes. +// inboxHandledSentinels splits the writer's cases by what their body writes. // Which sentinel a case is about comes from the selector it names, never from // the code string beside it — the two can disagree, and then the guard would be // reading the wrong one of them. -func inboxHandledSentinels(root string) (coded, uncoded map[string]bool, line int, err error) { - file, fset, err := parseRepoFile(root, inboxWriterFile) +func inboxHandledSentinels(root string, w inboxWriter) (coded, uncoded map[string]bool, line int, err error) { + file, fset, err := parseRepoFile(root, w.file) if err != nil { return nil, nil, 0, err } @@ -98,7 +128,7 @@ func inboxHandledSentinels(root string) (coded, uncoded map[string]bool, line in found := false for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Name.Name != inboxWriterFunc { + if !ok || fn.Name.Name != w.fn { continue } found = true @@ -109,7 +139,7 @@ func inboxHandledSentinels(root string) (coded, uncoded map[string]bool, line in return true } named := caseSentinels(clause.List) - writes := writesACode(clause.Body) + writes := writesACode(clause.Body, w.coded) for _, name := range named { if writes { coded[name] = true @@ -121,7 +151,7 @@ func inboxHandledSentinels(root string) (coded, uncoded map[string]bool, line in }) } if !found { - return nil, nil, 0, fmt.Errorf("%s no longer declares %s: this check has lost its subject", inboxWriterFile, inboxWriterFunc) + return nil, nil, 0, fmt.Errorf("%s no longer declares %s: this check has lost its subject", w.file, w.fn) } return coded, uncoded, line, nil } @@ -147,8 +177,8 @@ func caseSentinels(list []ast.Expr) []string { // writesACode reports whether the branch answers with an identity. The code has // to be a literal at the call: that is also what the frontend's own catalogue // guard reads, so a code assembled at runtime is one neither side can check. -func writesACode(body []ast.Stmt) bool { - coded := false +func writesACode(body []ast.Stmt, coded map[string]bool) bool { + writes := false for _, stmt := range body { ast.Inspect(stmt, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) @@ -156,18 +186,18 @@ func writesACode(body []ast.Stmt) bool { return true } id, ok := call.Fun.(*ast.Ident) - if !ok || !codedWriters[id.Name] { + if !ok || !coded[id.Name] { return true } for _, arg := range call.Args { if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, ".") { - coded = true + writes = true } } return true }) } - return coded + return writes } func parseRepoFile(root, rel string) (*ast.File, *token.FileSet, error) { diff --git a/tools/repolint/inboxrefusal_test.go b/tools/repolint/inboxrefusal_test.go index 62adda247c..a6c0dc6d08 100644 --- a/tools/repolint/inboxrefusal_test.go +++ b/tools/repolint/inboxrefusal_test.go @@ -8,9 +8,10 @@ import ( "testing" ) -// The tree's boundary names every condition today. This is also the check's own -// regression: both sides are matched by shape, so moving the family or renaming -// the writer fails here rather than passing with two empty sets. +// The tree's boundaries name every condition today, in both frontends. This is +// also the check's own regression: both sides are matched by shape, so moving +// the family or renaming a writer fails here rather than passing with two empty +// sets. func TestInboxRefusalParityIsCleanInTree(t *testing.T) { if got := checkInboxRefusalParity("../.."); len(got) != 0 { t.Fatalf("checkInboxRefusalParity = %v, want none", got) @@ -24,6 +25,14 @@ func TestInboxRefusalParityIsCleanInTree(t *testing.T) { if !slices.Contains(family, "ErrNotFound") { t.Fatalf("family = %v, want ErrNotFound among them", family) } + // A spec that names a writer this check cannot find answers "lost its + // subject" and files the finding against the writer, so a clean run above + // is evidence both were read. + for _, w := range inboxWriters { + if _, _, _, err := inboxHandledSentinels("../..", w); err != nil { + t.Fatalf("%s: %v", w.file, err) + } + } } func TestInboxRefusalParityNamesWhatWasLeftUnnamed(t *testing.T) { @@ -50,19 +59,67 @@ func writeInboxError(w http.ResponseWriter, err error) { writeErr(w, 500, err) } } +`, + `package acp + +func inboxRefusal(err error) error { + switch { + case errors.Is(err, sessioninbox.ErrGone): + return refuseInbox(ErrInboxRefused, "inbox.gone", err.Error()) + case errors.Is(err, sessioninbox.ErrHeld): + return refuseInbox(ErrInboxRefused, "inbox.held", err.Error()) + default: + return &RPCError{Code: ErrInternal, Message: err.Error()} + } +} `) got := checkInboxRefusalParity(root) - if len(got) != 2 { - t.Fatalf("findings = %v, want 2", got) + if len(got) != 3 { + t.Fatalf("findings = %v, want 3", got) } // The two failures are different instructions — one branch says the wrong // thing, the other says nothing — so a count that merged them would leave // the reader guessing which. - if !strings.Contains(got[0].Msg, "ErrHeld") || !strings.Contains(got[0].Msg, "without a code") { - t.Errorf("first finding = %q, want ErrHeld refused without a code", got[0].Msg) + if !strings.Contains(got[0].Msg, "ErrHeld") || !strings.Contains(got[0].Msg, "without a code") || got[0].File != inboxHTTPWriterFile { + t.Errorf("first finding = %+v, want ErrHeld refused without a code by %s", got[0], inboxHTTPWriterFile) } - if !strings.Contains(got[1].Msg, "ErrSealed") || !strings.Contains(got[1].Msg, "no case") { - t.Errorf("second finding = %q, want ErrSealed having no case", got[1].Msg) + if !strings.Contains(got[1].Msg, "ErrSealed") || !strings.Contains(got[1].Msg, "no case") || got[1].File != inboxHTTPWriterFile { + t.Errorf("second finding = %+v, want ErrSealed having no HTTP case", got[1]) + } + // The ACP writer is read too: a sentinel it does not name at all is the + // defect this check was extended for. + if !strings.Contains(got[2].Msg, "ErrSealed") || !strings.Contains(got[2].Msg, inboxACPWriterFunc) || got[2].File != inboxACPWriterFile { + t.Errorf("third finding = %+v, want ErrSealed having no case in %s", got[2], inboxACPWriterFunc) + } +} + +// A case that answers without an identity is the same defect as a missing case: +// the client cannot tell this refusal from the fault that also carries prose. +func TestInboxRefusalParityNamesACaselessIdentity(t *testing.T) { + root := fakeInboxTree(t, + "package sessioninbox\n\nimport \"errors\"\n\nvar ErrGone = errors.New(\"gone\")\n", + `package serve + +func writeInboxError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, sessioninbox.ErrGone): + refuse(w, 409, "inbox.gone", err.Error(), nil) + } +} +`, + `package acp + +func inboxRefusal(err error) error { + switch { + case errors.Is(err, sessioninbox.ErrGone): + return &RPCError{Code: ErrInboxRefused, Message: err.Error()} + } + return nil +} +`) + got := checkInboxRefusalParity(root) + if len(got) != 1 || got[0].File != inboxACPWriterFile || !strings.Contains(got[0].Msg, "without a code") { + t.Fatalf("findings = %+v, want one naming the ACP case that writes no identity", got) } } @@ -71,17 +128,40 @@ func writeInboxError(w http.ResponseWriter, err error) { func TestInboxRefusalParitySaysWhenItLostItsSubject(t *testing.T) { root := fakeInboxTree(t, "package sessioninbox\n", - "package serve\n\nfunc somethingElse() {}\n") + "package serve\n\nfunc somethingElse() {}\n", + "package acp\n\nfunc alsoSomethingElse() {}\n") got := checkInboxRefusalParity(root) - if len(got) != 1 || !strings.Contains(got[0].Msg, "lost its subject") { - t.Fatalf("findings = %v, want one saying the subject is gone", got) + if len(got) != 1 || !strings.Contains(got[0].Msg, "lost its subject") || got[0].File != inboxFamilyFile { + t.Fatalf("findings = %+v, want one saying the family is gone", got) + } + + root = fakeInboxTree(t, + "package sessioninbox\n\nimport \"errors\"\n\nvar ErrGone = errors.New(\"gone\")\n", + "package serve\n\nfunc somethingElse() {}\n", + `package acp + +func inboxRefusal(err error) error { + switch { + case errors.Is(err, sessioninbox.ErrGone): + return refuseInbox(ErrInboxRefused, "inbox.gone", err.Error()) + } + return nil +} +`) + got = checkInboxRefusalParity(root) + if len(got) != 1 || !strings.Contains(got[0].Msg, "lost its subject") || got[0].File != inboxHTTPWriterFile { + t.Fatalf("findings = %+v, want one saying the HTTP writer is gone", got) } } -func fakeInboxTree(t *testing.T, family, writer string) string { +func fakeInboxTree(t *testing.T, family, httpWriter, acpWriter string) string { t.Helper() root := t.TempDir() - for rel, body := range map[string]string{inboxFamilyFile: family, inboxWriterFile: writer} { + for rel, body := range map[string]string{ + inboxFamilyFile: family, + inboxHTTPWriterFile: httpWriter, + inboxACPWriterFile: acpWriter, + } { path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) From 03b02684791a583dc8c0a591709d02b511b50f1f Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 27 Oct 2026 11:03:39 +0800 Subject: [PATCH 25/37] docs(release-notes): record #14551 --- .../studio/unreleased/14551-acp-inbox-refusal-codes.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 release-notes/studio/unreleased/14551-acp-inbox-refusal-codes.md diff --git a/release-notes/studio/unreleased/14551-acp-inbox-refusal-codes.md b/release-notes/studio/unreleased/14551-acp-inbox-refusal-codes.md new file mode 100644 index 0000000000..82ca0206b2 --- /dev/null +++ b/release-notes/studio/unreleased/14551-acp-inbox-refusal-codes.md @@ -0,0 +1,2 @@ +- 修复:ACP 的 inbox 拒绝现在带机器码——`-32020` 队列拒绝(终端或可修复),`-32021` 该工作可能已执行过、不要重试。关联 #14551。 +- Fixed: ACP inbox refusals now carry machine codes — `-32020` for a queue refusal, `-32021` for work that may already have run and must not be retried. Refs #14551. From eeb0e27878bdbf3d0e8b2b272dabef0c13b9cfce Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 04:00:05 -0700 Subject: [PATCH 26/37] fix(compaction): word the dropped-turn notices in the window's language The two notices a fold leaves when user turns survive only through the summary carried no code, so a Chinese window showed the English fallback. They now carry a code and a typed payload like the other coded notices. --- desktop/frontend-next/src/i18n/en_settings.ts | 2 ++ .../src/i18n/notice_wording.test.ts | 4 ++++ .../frontend-next/src/i18n/notice_wording.ts | 3 +++ desktop/frontend-next/src/i18n/notices.ts | 2 ++ .../frontend-next/src/i18n/turns_dropped.ts | 16 +++++++++++++++ internal/contract/event/notice_codes.go | 4 ++++ internal/contract/event/notice_payload.go | 20 ++++++++++++++++++- internal/runtime/agent/compact_user_turns.go | 10 ++++------ .../runtime/agent/compact_user_turns_test.go | 8 ++++++++ .../14651-compaction-dropped-turns-notice.md | 2 ++ 10 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 desktop/frontend-next/src/i18n/turns_dropped.ts create mode 100644 release-notes/studio/unreleased/14651-compaction-dropped-turns-notice.md diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 068bf0211b..82f650896a 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -756,6 +756,8 @@ export const EN_SETTINGS: Record = { "摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。": "The summary request used {in} input and {out} output tokens, {hit} from cache.", "无需压缩:{why}": "Nothing to compact: {why}", "压缩失败:{why}": "Compaction failed: {why}", + "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。": "{n} earlier message(s) of yours were too large to keep whole and now survive only through the summary. Prefix a message with [[keep]] to hold it verbatim.", + "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。": "{n} message(s) you marked [[keep]] went past the room [[keep]] has, so the oldest now survive only through the summary.", "子代理「{task}」在等 {by} 先结束:{why}": "Sub-agent \"{task}\" is waiting for {by} to finish: {why}", "另一个子代理": "another sub-agent", "两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace", diff --git a/desktop/frontend-next/src/i18n/notice_wording.test.ts b/desktop/frontend-next/src/i18n/notice_wording.test.ts index 6f7b5b4bb0..65ca5d8790 100644 --- a/desktop/frontend-next/src/i18n/notice_wording.test.ts +++ b/desktop/frontend-next/src/i18n/notice_wording.test.ts @@ -17,6 +17,8 @@ const PAYLOAD: Record = { compaction_telemetry: JSON.stringify({ mode: "summarized", source_tokens: 122525, projection_tokens: 61192, input_tokens: 40710, output_tokens: 8186, cache_hit_tokens: 256, request_count: 1 }), context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }), subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }), + user_turns_dropped: JSON.stringify({ count: 5, tokens: 1578, budget: 8000 }), + kept_turns_folded: JSON.stringify({ count: 2, tokens: 900, budget: 4000 }), display_currency: "USD", compact_declined: undefined, compact_failed: "summary_failed", @@ -70,6 +72,8 @@ describe("notice wording", () => { ["job_stalled", '{"id":"j","quietSeconds":"900"}'], ["job_stalled", '{"id":"j","quietSeconds":0}'], ["compaction_telemetry", '{"mode":"summarized"}'], + ["user_turns_dropped", '{"count":0}'], + ["kept_turns_folded", "not json"], ["compaction_telemetry", '{"mode":"","source_tokens":1,"projection_tokens":1}'], ["compaction_telemetry", '{"mode":"summarized","source_tokens":"122525","projection_tokens":1}'], ["compact_failed", "no_such_reason"], diff --git a/desktop/frontend-next/src/i18n/notice_wording.ts b/desktop/frontend-next/src/i18n/notice_wording.ts index d10fa4e854..168bb9df81 100644 --- a/desktop/frontend-next/src/i18n/notice_wording.ts +++ b/desktop/frontend-next/src/i18n/notice_wording.ts @@ -7,6 +7,7 @@ import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped"; import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered"; import { JOB_FAILED, JOB_FINISHED, JOB_STARTED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice"; import { NOTICE_TEXT } from "./notices"; +import { KEPT_TURNS_FOLDED, USER_TURNS_DROPPED, turnsDroppedVars } from "./turns_dropped"; import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant"; export type NoticeVars = Record; @@ -30,6 +31,8 @@ const VARS: Record NoticeVars | undefine [COMPACTION_TELEMETRY]: compactionNoticeVars, [CONTEXT_COMPOSITION]: contextCompositionVars, [SUBAGENT_WAITING]: subagentWaitingVars, + [USER_TURNS_DROPPED]: turnsDroppedVars, + [KEPT_TURNS_FOLDED]: turnsDroppedVars, display_currency: (detail) => ({ mode: detail || "auto" }), compact_declined: compactReason("compact_declined"), compact_failed: compactReason("compact_failed"), diff --git a/desktop/frontend-next/src/i18n/notices.ts b/desktop/frontend-next/src/i18n/notices.ts index e7017985aa..eae4a78659 100644 --- a/desktop/frontend-next/src/i18n/notices.ts +++ b/desktop/frontend-next/src/i18n/notices.ts @@ -51,6 +51,8 @@ export const NOTICE_TEXT: Record = { compact_declined: "无需压缩:{why}", compact_failed: "压缩失败:{why}", context_composition: "上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}", + user_turns_dropped: "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。", + kept_turns_folded: "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。", subagent_waiting: "子代理「{task}」在等 {by} 先结束:{why}", compact_held: "自动压缩暂缓,上次尝试没有完成:{why}", permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)", diff --git a/desktop/frontend-next/src/i18n/turns_dropped.ts b/desktop/frontend-next/src/i18n/turns_dropped.ts new file mode 100644 index 0000000000..d7bc8bc572 --- /dev/null +++ b/desktop/frontend-next/src/i18n/turns_dropped.ts @@ -0,0 +1,16 @@ +export const USER_TURNS_DROPPED = "user_turns_dropped"; +export const KEPT_TURNS_FOLDED = "kept_turns_folded"; + +// The typed payload these notices carry as their detail, read as the variables +// their sentences are worded from. A detail that is not one reads as none, so the +// notice keeps the kernel's English. +export function turnsDroppedVars(detail: string | undefined): Record | undefined { + if (!detail) return undefined; + let p: { count?: unknown }; + try { + p = JSON.parse(detail) as { count?: unknown }; + } catch { + return undefined; + } + return typeof p.count === "number" && p.count > 0 ? { n: String(p.count) } : undefined; +} diff --git a/internal/contract/event/notice_codes.go b/internal/contract/event/notice_codes.go index 8a9cb61d53..741b6c5bbf 100644 --- a/internal/contract/event/notice_codes.go +++ b/internal/contract/event/notice_codes.go @@ -117,6 +117,10 @@ const ( NoticeCodeInboxUnrecovered = "inbox_unrecovered" // A context fold finished; Detail is the CompactionNotice payload, its text the English fallback. NoticeCodeCompactionTelemetry = "compaction_telemetry" + // A fold left earlier user turns only in the summary; Detail is the TurnsDropped payload, its text the English fallback. + NoticeCodeUserTurnsDropped = "user_turns_dropped" + // A fold left turns marked [[keep]] only in the summary; Detail is the TurnsDropped payload, its text the English fallback. + NoticeCodeKeptTurnsFolded = "kept_turns_folded" // A background job started; Detail is the JobNotice payload, its text the English fallback. NoticeCodeJobStarted = "job_started" // A background job ended; Detail is the JobNotice payload, its text the English fallback. diff --git a/internal/contract/event/notice_payload.go b/internal/contract/event/notice_payload.go index 66be8a1b5c..8cca5435f2 100644 --- a/internal/contract/event/notice_payload.go +++ b/internal/contract/event/notice_payload.go @@ -7,7 +7,7 @@ import "encoding/json" func DetailIsPayload(code string) bool { return code == NoticeCodeUnappliedSteer || code == NoticeCodeExtensionSkipped || code == NoticeCodeInboxRecovered || code == NoticeCodeInboxUnrecovered || - code == NoticeCodePermissionRulesDormant || code == NoticeCodeSubagentWaiting || code == NoticeCodeContextComposition || code == NoticeCodeCompactionTelemetry || code == NoticeCodeJobStarted || code == NoticeCodeJobFinished || code == NoticeCodeJobKilled || code == NoticeCodeJobFailed || code == NoticeCodeJobStalled + code == NoticeCodePermissionRulesDormant || code == NoticeCodeSubagentWaiting || code == NoticeCodeContextComposition || code == NoticeCodeCompactionTelemetry || code == NoticeCodeUserTurnsDropped || code == NoticeCodeKeptTurnsFolded || code == NoticeCodeJobStarted || code == NoticeCodeJobFinished || code == NoticeCodeJobKilled || code == NoticeCodeJobFailed || code == NoticeCodeJobStalled } // ExtensionSkipReasonNoLiveSidecar: the extension's companion process is not running. @@ -242,6 +242,24 @@ func (p CompactionNotice) Encode() string { return string(raw) } +// TurnsDropped is the Detail payload of NoticeCodeUserTurnsDropped and +// NoticeCodeKeptTurnsFolded: how many user turns the fold left to the summary, +// their estimated tokens, and the budget they exceeded. +type TurnsDropped struct { + Count int `json:"count"` + Tokens int `json:"tokens"` + Budget int `json:"budget"` +} + +// Encode renders the payload as the notice's Detail. +func (p TurnsDropped) Encode() string { + raw, err := json.Marshal(p) + if err != nil { + return "" + } + return string(raw) +} + // DecodeCompactionNotice reads the payload back; ok is false for a Detail that // is not one, so a caller keeps the kernel's English. func DecodeCompactionNotice(detail string) (CompactionNotice, bool) { diff --git a/internal/runtime/agent/compact_user_turns.go b/internal/runtime/agent/compact_user_turns.go index 900060da06..0be75c0978 100644 --- a/internal/runtime/agent/compact_user_turns.go +++ b/internal/runtime/agent/compact_user_turns.go @@ -75,20 +75,18 @@ func (a *contextWindow) keptUserTurnsBudget() int { // hatch is only useful to someone told it exists at the moment it is needed. func (a *contextWindow) noticeDroppedUserTurns(ret userTurnRetention) { if ret.MarkedDropped > 0 { - a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, + a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: event.NoticeCodeKeptTurnsFolded, Text: fmt.Sprintf("%s you marked [[keep]] %s past the room [[keep]] has, so the oldest now survive only through the summary.", pluralTurns(ret.MarkedDropped), wereOrWas(ret.MarkedDropped)), - Detail: fmt.Sprintf("compaction folded %d [[keep]] turn(s) (~%d tokens) past the marked-turn budget of %d", - ret.MarkedDropped, ret.MarkedDroppedTokens, a.keptMarkedBudget())}) + Detail: event.TurnsDropped{Count: ret.MarkedDropped, Tokens: ret.MarkedDroppedTokens, Budget: a.keptMarkedBudget()}.Encode()}) } if ret.Dropped == 0 { return } - a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, + a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: event.NoticeCodeUserTurnsDropped, Text: fmt.Sprintf("%s of yours %s too large to keep whole and now survive only through the summary. Prefix a turn with [[keep]] to hold it verbatim.", pluralTurns(ret.Dropped), wereOrWas(ret.Dropped)), - Detail: fmt.Sprintf("compaction dropped %d user turn(s) (~%d tokens) past the retention budget of %d", - ret.Dropped, ret.DroppedTokens, a.keptUserTurnsBudget())}) + Detail: event.TurnsDropped{Count: ret.Dropped, Tokens: ret.DroppedTokens, Budget: a.keptUserTurnsBudget()}.Encode()}) } func pluralTurns(n int) string { diff --git a/internal/runtime/agent/compact_user_turns_test.go b/internal/runtime/agent/compact_user_turns_test.go index 86b4d16a58..f536bd43ce 100644 --- a/internal/runtime/agent/compact_user_turns_test.go +++ b/internal/runtime/agent/compact_user_turns_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "reasonix/internal/state/sessionstore" "strings" "testing" @@ -63,6 +64,13 @@ func TestCompactionReportsDroppedUserTurns(t *testing.T) { if notice.Level != event.LevelWarn { t.Errorf("dropped-turn notice level = %v, want warn", notice.Level) } + if notice.Code != event.NoticeCodeUserTurnsDropped || !event.DetailIsPayload(notice.Code) { + t.Errorf("dropped-turn notice code = %q, want %q whose Detail is its payload", notice.Code, event.NoticeCodeUserTurnsDropped) + } + var p event.TurnsDropped + if err := json.Unmarshal([]byte(notice.Detail), &p); err != nil || p.Count != 1 || p.Tokens == 0 || p.Budget == 0 { + t.Errorf("payload = %+v err=%v from %q, want one dropped turn with its tokens and budget", p, err, notice.Detail) + } tele, ok := telemetryNotice(events) if !ok { t.Fatal("compaction telemetry carries no user-turn retention counts") diff --git a/release-notes/studio/unreleased/14651-compaction-dropped-turns-notice.md b/release-notes/studio/unreleased/14651-compaction-dropped-turns-notice.md new file mode 100644 index 0000000000..65ba56e416 --- /dev/null +++ b/release-notes/studio/unreleased/14651-compaction-dropped-turns-notice.md @@ -0,0 +1,2 @@ +- 修复:压缩后“较早的消息过长,只通过摘要保留”的提示在中文界面下也用中文显示。修复 #14651。 +- Fixed: the "earlier messages were too large to keep whole" notice after a compaction now reads in the window's language. Fixes #14651. From 336ca9cc30e0ed1c20b4584eef92439883fcc19d Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 17:43:59 +0800 Subject: [PATCH 27/37] fix(sessionstore): persist removal of legacy v4 conversations --- .../frontend/serve/hub_tree_remove_v4_test.go | 90 ++++++++++++++ .../state/sessionstore/legacy_listings.go | 9 +- internal/state/sessionstore/save.go | 5 +- .../state/sessionstore/session_v4_import.go | 9 +- .../state/sessionstore/session_v4_removal.go | 67 +++++++++++ .../sessionstore/session_v4_removal_test.go | 112 ++++++++++++++++++ .../studio/unreleased/14632-session-delete.md | 2 + 7 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 internal/frontend/serve/hub_tree_remove_v4_test.go create mode 100644 internal/state/sessionstore/session_v4_removal.go create mode 100644 internal/state/sessionstore/session_v4_removal_test.go create mode 100644 release-notes/studio/unreleased/14632-session-delete.md diff --git a/internal/frontend/serve/hub_tree_remove_v4_test.go b/internal/frontend/serve/hub_tree_remove_v4_test.go new file mode 100644 index 0000000000..126547fa31 --- /dev/null +++ b/internal/frontend/serve/hub_tree_remove_v4_test.go @@ -0,0 +1,90 @@ +package serve + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "reasonix/internal/state/sessionstore" + "reasonix/internal/state/sessionv4/v4fixture" +) + +func TestRemoveV4SessionStaysRemoved(t *testing.T) { + for _, imported := range []bool{false, true} { + name := "unopened" + if imported { + name = "imported" + } + t.Run(name, func(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + t.Setenv("REASONIX_STATE_HOME", t.TempDir()) + root := t.TempDir() + rememberWorkspace(root) + dir := SessionDirFor(root) + f := v4fixture.New(t) + f.Root = filepath.Join(filepath.Dir(dir), "sessions-v4") + source := f.Session("desktop-000000000000000000000001", 3) + f.Batch(source, v4fixture.Ended, v4fixture.Event{Kind: "message/complete", Payload: v4fixture.Msg("a", "user", "legacy conversation")}) + sourceLog := filepath.Join(source, "events.frames") + before, err := os.ReadFile(sourceLog) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "v4-desktop-000000000000000000000001.jsonl") + if imported { + if _, err := sessionstore.LoadSession(path); err != nil { + t.Fatal(err) + } + } + kept := filepath.Join(dir, "keep.jsonl") + writeSessionAt(t, kept) + h := NewHub(HubOptions{}) + t.Cleanup(h.Shutdown) + srv := httptest.NewServer(operatorHandler(h)) + defer srv.Close() + deadline := time.Now().Add(5 * time.Second) + for { + tree := hubGet[[]treeWorkspace](t, srv, "/tree?progressive=1") + if len(tree) == 1 && tree[0].CountsComplete != nil && *tree[0].CountsComplete && len(tree[0].Sessions) == 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("listing did not settle: %+v", tree) + } + time.Sleep(10 * time.Millisecond) + } + resp := postRemoveSession(t, srv, path) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("remove status = %d", resp.StatusCode) + } + check := func(server *httptest.Server) { + t.Helper() + for _, route := range []string{"/tree", "/tree?progressive=1"} { + tree := hubGet[[]treeWorkspace](t, server, route) + if len(tree) != 1 || len(tree[0].Sessions) != 1 || tree[0].Sessions[0].Path != kept { + t.Errorf("%s after removal = %+v, want only %s", route, tree, kept) + } + } + } + check(srv) + h.Shutdown() + restarted := NewHub(HubOptions{}) + t.Cleanup(restarted.Shutdown) + again := httptest.NewServer(operatorHandler(restarted)) + defer again.Close() + check(again) + if _, err := sessionstore.LoadSession(path); !os.IsNotExist(err) { + t.Errorf("removed legacy session reopened: %v", err) + } + after, err := os.ReadFile(sourceLog) + if err != nil || !bytes.Equal(before, after) { + t.Errorf("read-only legacy source changed: %v", err) + } + }) + } +} diff --git a/internal/state/sessionstore/legacy_listings.go b/internal/state/sessionstore/legacy_listings.go index 55ebca5a67..07772b3612 100644 --- a/internal/state/sessionstore/legacy_listings.go +++ b/internal/state/sessionstore/legacy_listings.go @@ -125,6 +125,9 @@ func (s *LegacyListings) Snapshot(dir string) ([]SessionInfo, ListingStatus) { continue } info := legacySessionInfo(dir, row.source, row.listing, row.state == "ready") + if !IsVisibleSession(info.Path) { + continue + } info.MetadataState = row.state rows = append(rows, info) } @@ -311,7 +314,11 @@ func (s *LegacyListings) scan(ctx context.Context, dir string) error { return ctx.Err() } id := entry.Name() - if !entry.IsDir() || !isV4SessionID(id) || sessionArtifactExists(v4ImportPath(dir, id)) { + if !entry.IsDir() || !isV4SessionID(id) { + continue + } + path := v4ImportPath(dir, id) + if !IsVisibleSession(path) || sessionArtifactExists(path) { continue } source, openErr := sessionv4.Open(filepath.Join(v4RootFor(dir), id)) diff --git a/internal/state/sessionstore/save.go b/internal/state/sessionstore/save.go index a2f12aa5d5..dd11412b33 100644 --- a/internal/state/sessionstore/save.go +++ b/internal/state/sessionstore/save.go @@ -1507,6 +1507,9 @@ func ClearCleanupPending(sessionPath string) error { if path == "" { return nil } + if err := preserveV4Removal(sessionPath); err != nil { + return err + } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } @@ -1526,7 +1529,7 @@ func IsCleanupPending(sessionPath string) bool { // IsVisibleSession reports whether a persisted session should appear on normal // user/agent-facing list, restore, and retrieval surfaces. func IsVisibleSession(sessionPath string) bool { - return strings.TrimSpace(sessionPath) != "" && !IsCleanupPending(sessionPath) + return strings.TrimSpace(sessionPath) != "" && !IsCleanupPending(sessionPath) && !isV4SessionRemoved(sessionPath) } // ListCleanupPending returns delayed-cleanup markers left in dir. A missing diff --git a/internal/state/sessionstore/session_v4_import.go b/internal/state/sessionstore/session_v4_import.go index be0238a2f4..2ac3998a1b 100644 --- a/internal/state/sessionstore/session_v4_import.go +++ b/internal/state/sessionstore/session_v4_import.go @@ -82,6 +82,9 @@ func v4SessionFor(path string) (sessionv4.Session, bool) { // conversation is imported there on first open, and brought up to date when // 1.x has continued it and this copy has not. func PrepareSessionPath(path string) error { + if v4RemovalPath(path) != "" && !IsVisibleSession(path) { + return &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist} + } s, ok := v4SessionFor(path) if !ok { return nil @@ -288,7 +291,11 @@ func withV4Sessions(dir string, out []SessionInfo) []SessionInfo { defer r.flush(context.Background(), dir, false) for _, source := range sessionv4.List(root) { id := source.Manifest.SessionID - if !isV4SessionID(id) || sessionArtifactExists(v4ImportPath(dir, id)) { + if !isV4SessionID(id) { + continue + } + path := v4ImportPath(dir, id) + if !IsVisibleSession(path) || sessionArtifactExists(path) { continue } listing, err := r.resolve(context.Background(), dir, source, readLegacyTranscript) diff --git a/internal/state/sessionstore/session_v4_removal.go b/internal/state/sessionstore/session_v4_removal.go new file mode 100644 index 0000000000..be581af162 --- /dev/null +++ b/internal/state/sessionstore/session_v4_removal.go @@ -0,0 +1,67 @@ +package sessionstore + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "reasonix/internal/base/fileutil" +) + +// The source belongs to 1.x; native deletion must outlive cleanup without +// changing that store or allowing its next discovery to import the row again. +func v4RemovalPath(sessionPath string) string { + name, ok := strings.CutSuffix(filepath.Base(sessionPath), ".jsonl") + id, prefixed := strings.CutPrefix(name, v4ImportPrefix) + if !ok || !prefixed || !isV4SessionID(id) || v4RootFor(filepath.Dir(sessionPath)) == "" { + return "" + } + return strings.TrimSuffix(sessionPath, ".jsonl") + ".v4-removed" +} + +func isV4SessionRemoved(sessionPath string) bool { + path := v4RemovalPath(sessionPath) + if path == "" { + return false + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return false + } + defer root.Close() + _, err = root.Stat(filepath.Base(path)) + return err == nil +} + +func preserveV4Removal(sessionPath string) error { + path := v4RemovalPath(sessionPath) + if path == "" { + return nil + } + root, err := os.OpenRoot(filepath.Dir(sessionPath)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer root.Close() + raw, err := root.ReadFile(filepath.Base(CleanupPendingPath(sessionPath))) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var meta CleanupPendingMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return err + } + switch meta.Operation { + case "remove", "delete", "clear": + return fileutil.AtomicWriteFile(path, raw, 0o644) + default: + return nil + } +} diff --git a/internal/state/sessionstore/session_v4_removal_test.go b/internal/state/sessionstore/session_v4_removal_test.go new file mode 100644 index 0000000000..9d86477f5a --- /dev/null +++ b/internal/state/sessionstore/session_v4_removal_test.go @@ -0,0 +1,112 @@ +package sessionstore + +import ( + "os" + "path/filepath" + "testing" +) + +func TestV4RemovalSurvivesCleanup(t *testing.T) { + for _, operation := range []string{"remove", "delete", "clear", ForkPendingOperation} { + t.Run(operation, func(t *testing.T) { + _, _, dir := v4Project(t) + path := v4ImportPath(dir, v4TestID) + if err := MarkCleanupPending(path, operation); err != nil { + t.Fatal(err) + } + rows, err := ListSessions(dir) + if err != nil || len(rows) != 0 { + t.Fatalf("pending rows = %+v, %v", rows, err) + } + if _, err := LoadSession(path); !os.IsNotExist(err) { + t.Fatalf("pending import = %v", err) + } + if err := ClearCleanupPending(path); err != nil { + t.Fatal(err) + } + rows, err = ListSessions(dir) + if err != nil { + t.Fatal(err) + } + if operation == ForkPendingOperation { + if len(rows) != 1 || rows[0].Path != path { + t.Fatalf("published session disappeared: %+v", rows) + } + if _, err := LoadSession(path); err != nil { + t.Fatal(err) + } + } else { + if len(rows) != 0 { + t.Fatalf("removed session rediscovered: %+v", rows) + } + if _, err := LoadSession(path); !os.IsNotExist(err) { + t.Fatalf("removed import = %v", err) + } + } + }) + } +} + +func TestV4RemovalPublicationFailureKeepsCleanupPending(t *testing.T) { + _, _, dir := v4Project(t) + path := v4ImportPath(dir, v4TestID) + if err := MarkCleanupPending(path, "remove"); err != nil { + t.Fatal(err) + } + blocked := v4RemovalPath(path) + if err := os.MkdirAll(filepath.Join(blocked, "held"), 0700); err != nil { + t.Fatal(err) + } + if err := ClearCleanupPending(path); err == nil { + t.Fatal("cleared cleanup despite failing to persist deletion") + } + if !IsCleanupPending(path) { + t.Fatal("lost cleanup retry after publication failure") + } + if err := os.RemoveAll(blocked); err != nil { + t.Fatal(err) + } + if err := ClearCleanupPending(path); err != nil { + t.Fatal(err) + } + if IsVisibleSession(path) { + t.Fatal("completed retry exposed the legacy source") + } +} + +func TestV4RemovalDoesNotFollowMarkersOutsideSessionDirectory(t *testing.T) { + for _, pending := range []bool{false, true} { + name := "removed" + if pending { + name = "pending" + } + t.Run(name, func(t *testing.T) { + _, _, dir := v4Project(t) + path := v4ImportPath(dir, v4TestID) + outside := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outside, []byte(`{"operation":"remove","createdAt":1}`), 0600); err != nil { + t.Fatal(err) + } + marker := v4RemovalPath(path) + if pending { + marker = CleanupPendingPath(path) + } + if err := os.Symlink(outside, marker); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if pending { + if err := ClearCleanupPending(path); err == nil { + t.Fatal("read a cleanup marker outside the session directory") + } + if !IsCleanupPending(path) { + t.Fatal("lost pending cleanup after rejecting an external marker") + } + if _, err := os.Lstat(v4RemovalPath(path)); !os.IsNotExist(err) { + t.Fatalf("published removal from external data: %v", err) + } + } else if !IsVisibleSession(path) { + t.Fatal("external marker controlled session visibility") + } + }) + } +} diff --git a/release-notes/studio/unreleased/14632-session-delete.md b/release-notes/studio/unreleased/14632-session-delete.md new file mode 100644 index 0000000000..035c889dd2 --- /dev/null +++ b/release-notes/studio/unreleased/14632-session-delete.md @@ -0,0 +1,2 @@ +- 修复:删除来自 Reasonix 1.x 的会话后,同一会话不再重新出现在侧栏;删除状态会在重启后保留,且不会修改 1.x 的原始存储。删除记录的读取限制在本机会话目录内。修复 #14632。 +- Fixed: deleted 1.x conversations stay out of the sidebar after restart. The original store stays unchanged; marker reads stay within the native session directory. Fixes #14632. From 180d3a71eb7cc79414ce4b7980438326b7915af6 Mon Sep 17 00:00:00 2001 From: Lancy Date: Tue, 27 Oct 2026 20:03:17 +0800 Subject: [PATCH 28/37] feat(studio): remember last submitted feedback contact --- desktop/frontend-next/README.md | 9 ++++ .../src/ui/Feedback.interaction.test.tsx | 51 ++++++++++++++++++- desktop/frontend-next/src/ui/FeedbackForm.tsx | 3 +- desktop/frontend-next/src/ui/feedbackdraft.ts | 20 ++++++-- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/desktop/frontend-next/README.md b/desktop/frontend-next/README.md index 0c0ab42fca..a5b71e3e7d 100644 --- a/desktop/frontend-next/README.md +++ b/desktop/frontend-next/README.md @@ -7,6 +7,15 @@ reviewed: 2026-09-22 # Reasonix Studio frontend +## Feedback contact + +- Studio prefills new reports with the contact value from the last successful + submission, stored in this device's local browser storage. +- The field stays editable; submitting it empty clears the remembered value. + Failed submissions do not replace it. +- An unsent draft takes precedence while the page is open. +- No feedback body or screenshots are persisted by this preference. + ## Tests `pnpm test` runs one worker per test file on the threads pool. Threads rather diff --git a/desktop/frontend-next/src/ui/Feedback.interaction.test.tsx b/desktop/frontend-next/src/ui/Feedback.interaction.test.tsx index eee9eb232c..0f2101e6b2 100644 --- a/desktop/frontend-next/src/ui/Feedback.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/Feedback.interaction.test.tsx @@ -11,7 +11,10 @@ import { HttpError, type AgentPort } from "../port/port"; import { FEEDBACK_CODE, type FeedbackEnv } from "../port/feedback"; import { host } from "../port/host"; -beforeEach(dropDraft); +beforeEach(() => { + localStorage.clear(); + dropDraft(); +}); afterEach(() => { cleanup(); @@ -59,6 +62,52 @@ async function fill(text = "侧栏在缩放窗口后丢失选中项", nick = "ad } describe("feedback form", () => { + it("remembers submitted contact across fresh forms and lets an empty submission clear it", async () => { + const contact = () => screen.getByRole("textbox", { name: /联系方式/ }); + setup(); + await fill(); + await userEvent.type(contact(), "ada@example.com"); + await userEvent.click(send()); + await screen.findByText("已收到你的反馈"); + cleanup(); + dropDraft(); + setup(); + await fill(); + expect(contact().value).toBe("ada@example.com"); + await userEvent.clear(contact()); + await userEvent.click(send()); + await screen.findByText("已收到你的反馈"); + cleanup(); + dropDraft(); + setup(); + await ready(); + expect(contact().value).toBe(""); + }); + + it("keeps an unsent draft ahead of remembered contact without persisting a failed submission", async () => { + const contact = () => screen.getByRole("textbox", { name: /联系方式/ }); + const { port } = setup(); + await fill(); + await userEvent.type(contact(), "saved@example.com"); + await userEvent.click(send()); + await screen.findByText("已收到你的反馈"); + await userEvent.click(screen.getByRole("button", { name: "再写一条" })); + await userEvent.type(body(), "another report"); + await userEvent.clear(contact()); + vi.spyOn(port, "sendFeedback").mockRejectedValueOnce(new Error("offline")); + await userEvent.click(send()); + await screen.findByRole("button", { name: "重试发送" }); + cleanup(); + setup(); + await ready(); + expect(contact().value).toBe(""); + cleanup(); + dropDraft(); + setup(); + await ready(); + expect(contact().value).toBe("saved@example.com"); + }); + it("holds Send back until there is text and a nickname, and counts UTF-8 bytes", async () => { setup(); await ready(); diff --git a/desktop/frontend-next/src/ui/FeedbackForm.tsx b/desktop/frontend-next/src/ui/FeedbackForm.tsx index 3b0ff10c7d..fd59251444 100644 --- a/desktop/frontend-next/src/ui/FeedbackForm.tsx +++ b/desktop/frontend-next/src/ui/FeedbackForm.tsx @@ -8,7 +8,7 @@ import { host } from "../port/host"; import type { AgentPort } from "../port/port"; import { CopyButton } from "./CopyButton"; import { StudioIcon } from "./StudioIcon"; -import { dropDraft, heldDraft, holdDraft } from "./feedbackdraft"; +import { dropDraft, heldDraft, holdDraft, rememberContact } from "./feedbackdraft"; import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure"; import { FeedbackShot } from "./FeedbackShot"; import { LazyMarkdown } from "./LazyMarkdown"; @@ -210,6 +210,7 @@ export function FeedbackForm({ port, pasteRoot, active, onMine, onClose, onFile, ...(shellMemory.length > 0 ? { memory: shellMemory } : {}), }); shotEpoch.current++; + rememberContact(contact.trim()); dropDraft(); setSent(receipt); setPhase("sent"); diff --git a/desktop/frontend-next/src/ui/feedbackdraft.ts b/desktop/frontend-next/src/ui/feedbackdraft.ts index 739423fea4..4a6686619f 100644 --- a/desktop/frontend-next/src/ui/feedbackdraft.ts +++ b/desktop/frontend-next/src/ui/feedbackdraft.ts @@ -10,10 +10,16 @@ export interface FeedbackDraft { const EMPTY: FeedbackDraft = { category: "bug", body: "", contact: "", shots: [] }; -let held: FeedbackDraft = EMPTY; +const CONTACT_KEY = "rx-feedback-contact"; +let held: FeedbackDraft | null = null; export function heldDraft(): FeedbackDraft { - return held; + if (held) return held; + try { + return { ...EMPTY, contact: localStorage.getItem(CONTACT_KEY) ?? "" }; + } catch { + return EMPTY; + } } export function holdDraft(next: FeedbackDraft): void { @@ -21,5 +27,13 @@ export function holdDraft(next: FeedbackDraft): void { } export function dropDraft(): void { - held = EMPTY; + held = null; +} + +export function rememberContact(contact: string): void { + try { + localStorage.setItem(CONTACT_KEY, contact); + } catch { + // Unavailable storage must not turn an accepted report into a failed send. + } } From 6161a95f5c080de8160fb6bcd7421a981c97e19f Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 03:40:34 -0700 Subject: [PATCH 29/37] fix(session): open the newest part of a history too large to replay A session whose message payload passes the replay bound could not be opened and the window offered no way forward. The refusal now carries a code, and the window offers to continue from the most recent part as a new session; the original files are only read. --- desktop/frontend-next/src/actions.ts | 1 + desktop/frontend-next/src/i18n/en.ts | 3 + desktop/frontend-next/src/i18n/en_kernel.ts | 2 + desktop/frontend-next/src/i18n/kernel.ts | 2 + desktop/frontend-next/src/port/mock.ts | 4 + desktop/frontend-next/src/port/port.ts | 6 +- .../frontend-next/src/port/session_tail.ts | 5 + desktop/frontend-next/src/port/sse.ts | 3 + desktop/frontend-next/src/state/toasts.ts | 8 + .../ui/ToastStack.note.interaction.test.tsx | 19 +- desktop/frontend-next/src/ui/ToastStack.tsx | 1 + .../src/ui/useOpenPane.tail.test.tsx | 60 ++++++ desktop/frontend-next/src/ui/useOpenPane.ts | 25 ++- desktop/frontend-next/vite.config.ts | 2 +- internal/frontend/serve/refuse_common.go | 1 + internal/frontend/serve/resume.go | 41 ++-- internal/frontend/serve/routes.go | 1 + internal/frontend/serve/session_tail.go | 40 ++++ internal/frontend/serve/session_tail_test.go | 37 ++++ internal/session/control/port.go | 1 + internal/session/control/session_tail.go | 54 +++++ internal/session/control/session_tail_test.go | 104 ++++++++++ .../state/sessionstore/session_dag_read.go | 74 ++++++- internal/state/sessionstore/session_events.go | 36 +++- .../sessionstore/session_replay_reader.go | 7 - internal/state/sessionstore/session_tail.go | 107 ++++++++++ .../state/sessionstore/session_tail_test.go | 185 ++++++++++++++++++ .../14660-open-oversized-session-tail.md | 2 + 28 files changed, 795 insertions(+), 36 deletions(-) create mode 100644 desktop/frontend-next/src/port/session_tail.ts create mode 100644 desktop/frontend-next/src/ui/useOpenPane.tail.test.tsx create mode 100644 internal/frontend/serve/session_tail.go create mode 100644 internal/frontend/serve/session_tail_test.go create mode 100644 internal/session/control/session_tail.go create mode 100644 internal/session/control/session_tail_test.go create mode 100644 internal/state/sessionstore/session_tail.go create mode 100644 internal/state/sessionstore/session_tail_test.go create mode 100644 release-notes/studio/unreleased/14660-open-oversized-session-tail.md diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index fd071cd227..ff66c5bd3a 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -296,6 +296,7 @@ export const ACTIONS: UIAction[] = [ { id: "toast.report", kind: "view", target: "none", proof: "interaction" }, { id: "errbar.report", kind: "view", target: "none", proof: "interaction" }, { id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" }, + { id: "toast.act", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "model.group", kind: "view", target: "entity", proof: "interaction" }, { id: "model.group-clear", kind: "view", target: "none", proof: "interaction" }, { id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 5f863c0b71..5370394c09 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -577,6 +577,9 @@ export const EN: Record = { // ── 会话树 ─────────────────────────────────────────────────────── "新会话": "New session", + "这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。": "This session's history is over the safe read limit, so it was not opened (the file is intact and unchanged). You can carry only its most recent part over into a new session.", + "从最近的内容继续": "Continue from the most recent part", + "已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。": "Saved the most recent part as a new session; {n} earlier messages were not carried over. The original session file is unchanged.", "标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.", "打开项目文件夹": "Open project folder", "新会话 {n}": "New session {n}", diff --git a/desktop/frontend-next/src/i18n/en_kernel.ts b/desktop/frontend-next/src/i18n/en_kernel.ts index 55267ad7c2..e98291e73d 100644 --- a/desktop/frontend-next/src/i18n/en_kernel.ts +++ b/desktop/frontend-next/src/i18n/en_kernel.ts @@ -481,6 +481,8 @@ export const EN_KERNEL: Record = { "打不开这个会话": "Could not open that conversation", "该路径位于会话目录之外": "That path is outside the conversations directory", "没有这个会话": "No such conversation", + "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动": "This conversation's history is over the safe read limit, so it was not opened; the file is intact and unchanged", + "没能取出这个会话最近的内容": "Could not take the most recent part of this conversation", "该地址不接受此种请求方式": "That address does not accept this request method", "请求体必须是 application/json": "The request body must be application/json", "这台服务器未开放权限编辑": "This server does not open permission editing", diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index e9be998076..87c9a513c2 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -174,6 +174,8 @@ const SAID: Record = { "session.open_failed": "打不开这个会话", "session.outside_dir": "该路径位于会话目录之外", "session.unknown": "没有这个会话", + "session.replay_limit": "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动", + "session.tail_failed": "没能取出这个会话最近的内容", "request.method_not_allowed": "该地址不接受此种请求方式", "request.bad_content_type": "请求体必须是 application/json", "permissions.editing_disabled": "这台服务器未开放权限编辑", diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index 2d407e4d5e..c8b521be5c 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -416,6 +416,10 @@ export class MockPort extends MockWorkspace implements AgentPort { return `/mock/branch-${turn}.jsonl`; } + async continueFromTail(path: string): Promise<{ path: string; dropped: number }> { + return { path: path.replace(/\.jsonl$/, "-tail.jsonl"), dropped: 12 }; + } + // The second prompt onwards is scripted to have run bash, so mock mode can // show the consent stage the real kernel demands on partial coverage. async prepareRewind(turn: number, scope: RewindScope): Promise { diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index ed32db9293..5781ffb3ce 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -1,8 +1,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition"; export type * from "./composition"; -import { DeliveryError, HttpError } from "./http_error"; -import type { DeliveryFault } from "./http_error"; +import { DeliveryError, HttpError, type DeliveryFault } from "./http_error"; import type { SideProgress } from "./side"; /** What `subscribe` hands back: the disposer, and where the transport has one, a @@ -32,6 +31,7 @@ import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } fro import type { SubagentRun, SubagentTranscript } from "./subagent"; import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version"; import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace"; +import type { SessionTailPort } from "./session_tail"; // The port is one contract; its subjects each keep their own file, the way the // wire and the layers below already do. This is where a reader still finds @@ -183,7 +183,7 @@ export interface TrayPrefs { closeToTray: boolean; } -export interface AgentPort extends WorkspacePreviewPort { +export interface AgentPort extends WorkspacePreviewPort, SessionTailPort { providerSetup(): Promise; saveProviderKey(apiKey: string): Promise; // The models that answer the job asked about; "chat" when none is named. diff --git a/desktop/frontend-next/src/port/session_tail.ts b/desktop/frontend-next/src/port/session_tail.ts new file mode 100644 index 0000000000..543f98b9cb --- /dev/null +++ b/desktop/frontend-next/src/port/session_tail.ts @@ -0,0 +1,5 @@ +export interface SessionTailPort { + // Copies the newest part of a session too large to open into a new session; + // dropped is how many earlier messages were left out. The source is untouched. + continueFromTail(path: string): Promise<{ path: string; dropped: number }>; +} diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index 4fa0a75e27..f8eebe1070 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -476,6 +476,9 @@ export class SsePort extends SseWorkspace implements AgentPort { async branchAt(turn: number, name: string) { return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path; } + continueFromTail(path: string) { + return this.post0<{ path: string; dropped: number }>("/session/tail", { path }); + } availableUndo() { return this.get("/rewind/undo"); } undoRewind(transactionId: string) { diff --git a/desktop/frontend-next/src/state/toasts.ts b/desktop/frontend-next/src/state/toasts.ts index c49f8c7855..d5c0a84654 100644 --- a/desktop/frontend-next/src/state/toasts.ts +++ b/desktop/frontend-next/src/state/toasts.ts @@ -8,6 +8,8 @@ export interface Toast { text: string; // A note tells the person what the window did on its own; it is not a failure. tone?: "note"; + // A way out the notice offers; running it dismisses the notice. + action?: { label: string; run: () => void }; } export const MAX_TOASTS = 3; @@ -33,6 +35,12 @@ export function pushNote(text: string): void { publish([...toasts, { id: ++serial, text, tone: "note" as const }].slice(-MAX_TOASTS)); } +/** A note with one button that does what the note offers. */ +export function pushOffer(text: string, action: { label: string; run: () => void }): void { + if (!text.trim() || toasts.some((toast) => toast.text === text)) return; + publish([...toasts, { id: ++serial, text, tone: "note" as const, action }].slice(-MAX_TOASTS)); +} + export function dismissToast(id: number): void { publish(toasts.filter((toast) => toast.id !== id)); } diff --git a/desktop/frontend-next/src/ui/ToastStack.note.interaction.test.tsx b/desktop/frontend-next/src/ui/ToastStack.note.interaction.test.tsx index 691a373584..782b940e4c 100644 --- a/desktop/frontend-next/src/ui/ToastStack.note.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/ToastStack.note.interaction.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { clearToasts, pushNote, pushToast } from "../state/toasts"; +import { clearToasts, pushNote, pushOffer, pushToast } from "../state/toasts"; import { ToastStack } from "./ToastStack"; globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; @@ -35,4 +35,21 @@ describe("a note in the window's corner", () => { expect(failure.getAttribute("role")).toBe("alert"); expect(failure.querySelector('[data-action="toast.report"]')).not.toBeNull(); }); + + it("runs what a note offers when its button is pressed, and then goes away", () => { + const run = vi.fn(); + render( {}} />); + act(() => pushOffer("太大了", { label: "取最近的内容", run })); + const note = screen.getByText("太大了").closest(".toast")!; + expect(note.getAttribute("role")).toBe("status"); + fireEvent.click(note.querySelector('[data-action="toast.act"]')!); + expect(run).toHaveBeenCalledTimes(1); + expect(screen.queryByText("太大了")).toBeNull(); + }); + + it("offers no button when the note has nothing to offer", () => { + render( {}} />); + act(() => pushNote("fyi")); + expect(document.querySelector('[data-action="toast.act"]')).toBeNull(); + }); }); diff --git a/desktop/frontend-next/src/ui/ToastStack.tsx b/desktop/frontend-next/src/ui/ToastStack.tsx index 255a2ad52c..fedb9af21a 100644 --- a/desktop/frontend-next/src/ui/ToastStack.tsx +++ b/desktop/frontend-next/src/ui/ToastStack.tsx @@ -23,6 +23,7 @@ export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook = {toasts.map((toast) => toast.tone === "note" ? (
    {toast.text} + {toast.action && }
    ) : ( diff --git a/desktop/frontend-next/src/ui/useOpenPane.tail.test.tsx b/desktop/frontend-next/src/ui/useOpenPane.tail.test.tsx new file mode 100644 index 0000000000..72ad1141aa --- /dev/null +++ b/desktop/frontend-next/src/ui/useOpenPane.tail.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { clearToasts, readToasts } from "../state/toasts"; +import { HttpError } from "../port/http_error"; +import type { RuntimeView } from "../port/hub"; +import type { AgentPort } from "../port/port"; +import { useOpenPane } from "./useOpenPane"; + +beforeEach(() => { localStorage.clear(); clearToasts(); }); +afterEach(() => clearToasts()); + +const blank = { id: "r0", root: "/w", name: "w", sessionPath: "" } as RuntimeView; + +function window(resume: (path: string) => Promise) { + const continueFromTail = vi.fn(async (path: string) => ({ path: path.replace(".jsonl", "-tail.jsonl"), dropped: 9 })); + const panePorts = new Map([["r0", { resume: vi.fn(resume), continueFromTail } as unknown as AgentPort]]); + const sw = { path: "", pending: false, opening: () => false, begin: () => 1, current: () => true, landed: () => {}, end: () => {}, cancel: () => {} }; + const hook = renderHook(() => + useOpenPane({ + hub: { open: vi.fn(), close: vi.fn() } as never, runtimes: [blank], panePorts, active: "r0", + reportsRef: { current: {} }, runsRef: { current: {} }, + setRuntimes: vi.fn(), setActive: vi.fn(), setTakeover: vi.fn(), reloadPanes: async () => {}, sw, + }), + ); + return { continueFromTail, resume: panePorts.get("r0")!.resume as ReturnType, open: (path: string) => hook.result.current({ root: "/w", sessionPath: path }) }; +} + +const overLimit = (path: string) => path.endsWith("big.jsonl") + ? Promise.reject(new HttpError(413, "too large", { code: "session.replay_limit" })) + : Promise.resolve(); + +describe("a session whose history is over the safe read limit", () => { + it("offers its recent part instead of failing, and opens that part as a new session", async () => { + const w = window(overLimit); + await act(async () => void (await w.open("/w/big.jsonl"))); + const [offer] = readToasts(); + expect(offer.action?.label).toBe("从最近的内容继续"); + expect(w.continueFromTail).not.toHaveBeenCalled(); + + await act(async () => void offer.action?.run()); + expect(w.continueFromTail).toHaveBeenCalledWith("/w/big.jsonl"); + expect(w.resume).toHaveBeenLastCalledWith("/w/big-tail.jsonl"); + expect(readToasts().map((n) => n.text).join("\n")).toContain("9 条没有带入"); + }); + + it("still fails for any other refusal", async () => { + const w = window(() => Promise.reject(new HttpError(400, "bad", { code: "session.bad_path" }))); + await expect(act(async () => void (await w.open("/w/big.jsonl")))).rejects.toBeInstanceOf(HttpError); + expect(readToasts()).toHaveLength(0); + }); + + it("says why when the recent part cannot be taken either", async () => { + const w = window(overLimit); + w.continueFromTail.mockRejectedValueOnce(new HttpError(500, "x", { code: "session.tail_failed" })); + await act(async () => void (await w.open("/w/big.jsonl"))); + await act(async () => void readToasts()[0].action?.run()); + expect(readToasts().map((n) => n.text).join("\n")).toContain("没能取出这个会话最近的内容"); + }); +}); diff --git a/desktop/frontend-next/src/ui/useOpenPane.ts b/desktop/frontend-next/src/ui/useOpenPane.ts index 997ba489cb..bb82040991 100644 --- a/desktop/frontend-next/src/ui/useOpenPane.ts +++ b/desktop/frontend-next/src/ui/useOpenPane.ts @@ -6,7 +6,7 @@ import { t } from "../i18n"; import { openSessionLimit } from "../state/prefs"; import { forgetNewTaskPreset, newTaskComposition } from "../state/newTaskPreset"; import { HttpError } from "../port/http_error"; -import { pushNote, pushToast } from "../state/toasts"; +import { pushNote, pushOffer, pushToast } from "../state/toasts"; import { reason } from "../i18n/kernel"; import type { SessionSwitch } from "./sessionswitch"; @@ -113,6 +113,28 @@ export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runs [hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setTakeover], ); + // A history over the safe read limit cannot open whole; the newest part of it + // can, as a new session, and the original file is never touched. + const offerTail = (req: { root?: string; sessionPath?: string }) => { + const source = runtimes.find((rt) => !rt.host && rt.root === req.root && panePorts.has(rt.id)) ?? runtimes.find((rt) => panePorts.has(rt.id)); + const port = source && panePorts.get(source.id); + const path = req.sessionPath; + if (!port || !path) return false; + pushOffer(t("这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。"), { + label: t("从最近的内容继续"), + run: () => void (async () => { + try { + const tail = await port.continueFromTail(path); + await openPane({ root: req.root, sessionPath: tail.path }); + pushNote(t("已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。", { n: tail.dropped })); + } catch (e) { + pushToast(reason(e)); + } + })(), + }); + return true; + }; + const flights = useRef(new Map>()); const openPane = useCallback( async (req: { root?: string; sessionPath?: string; agentComposition?: string }) => { @@ -138,6 +160,7 @@ export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runs if (!placed.changed) return sw.end(ticket); } catch (e) { sw.end(ticket); + if (wanted && e instanceof HttpError && e.reason?.code === "session.replay_limit" && offerTail(req)) return; throw e; } void reloadPanes().finally(() => sw.end(ticket)); diff --git a/desktop/frontend-next/vite.config.ts b/desktop/frontend-next/vite.config.ts index abd61f04c5..8275d1e2e6 100644 --- a/desktop/frontend-next/vite.config.ts +++ b/desktop/frontend-next/vite.config.ts @@ -18,7 +18,7 @@ const ROUTES = [ "/events", "/history", "/status", "/balance", "/submit", "/cancel", "/approve", "/answer", "/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents", "/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/workspace-trust", "/preset", - "/model", "/default-model", "/default-mode", "/effort", "/new", "/sessions", "/delete-session", "/provider-setup", + "/model", "/default-model", "/default-mode", "/effort", "/new", "/sessions", "/session/tail", "/delete-session", "/provider-setup", "/tools", "/present-files", "/inbox", "/trajectory", "/mcp", "/skills", "/complete", "/prompt", "/workspace", "/capability-scope", "/providers", "/decision-models", "/roles", "/account", "/backups", "/hooks", "/memory", "/network", "/shell", "/todos", diff --git a/internal/frontend/serve/refuse_common.go b/internal/frontend/serve/refuse_common.go index f11f85e59a..e416a1b49c 100644 --- a/internal/frontend/serve/refuse_common.go +++ b/internal/frontend/serve/refuse_common.go @@ -26,6 +26,7 @@ const ( codeSessionBadPath = "session.bad_path" codeSessionPathUnreadable = "session.path_unreadable" codeSessionUnknown = "session.unknown" + codeSessionReplayLimit = "session.replay_limit" codeSessionOutside = "session.outside_dir" codeSessionActive = "busy.session_active" codeSwitchModel = "busy.switch_model" diff --git a/internal/frontend/serve/resume.go b/internal/frontend/serve/resume.go index 782905139b..3cab9458ea 100644 --- a/internal/frontend/serve/resume.go +++ b/internal/frontend/serve/resume.go @@ -40,44 +40,56 @@ func (s *Server) resume(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// resumeInto binds this runtime to the transcript at path, answering with the -// status a refusal deserves so both the HTTP handler and a hub opening a -// session in a new pane refuse for the same reasons. -func (s *Server) resumeInto(path string) (int, error) { - defer backgroundwork.Shared.Foreground()() +// sessionFileInDir resolves path to a transcript inside the session directory, +// answering with the status a refusal deserves. +func (s *Server) sessionFileInDir(path string) (string, int, error) { dir := s.ctl().SessionDir() if dir == "" { - return http.StatusBadRequest, refusal(http.StatusBadRequest, "session.disabled", errors.New("sessions disabled"), nil) + return "", http.StatusBadRequest, refusal(http.StatusBadRequest, "session.disabled", errors.New("sessions disabled"), nil) } absDir, err := filepath.Abs(dir) if err != nil { - return http.StatusBadRequest, refusal(http.StatusBadRequest, codeSessionBadPath, errors.New("invalid session dir"), nil) + return "", http.StatusBadRequest, refusal(http.StatusBadRequest, codeSessionBadPath, errors.New("invalid session dir"), nil) } realDir, dirErr := filepath.EvalSymlinks(absDir) absPath, err := filepath.Abs(strings.TrimSpace(path)) name := filepath.Base(absPath) if err != nil || !store.IsSessionTranscriptName(name) { - return http.StatusBadRequest, refusal(http.StatusBadRequest, codeSessionBadPath, errors.New("invalid session path"), nil) + return "", http.StatusBadRequest, refusal(http.StatusBadRequest, codeSessionBadPath, errors.New("invalid session path"), nil) } // A 1.x v4 conversation the list offered becomes a transcript here first. if absPath, err = prepareResumeSessionPath(absDir, absPath); err != nil { - return http.StatusBadRequest, fmt.Errorf("open 1.x session: %w", err) + return "", http.StatusBadRequest, fmt.Errorf("open 1.x session: %w", err) } // A transcript that is gone lands here: the delete took the file, and the // list the click came from was drawn before it did. realPath, err := filepath.EvalSymlinks(absPath) if err != nil { - return pathUnresolved(err) + status, err := pathUnresolved(err) + return "", status, err } // A transcript that exists cannot be inside a directory that does not. if errors.Is(dirErr, fs.ErrNotExist) { - return http.StatusForbidden, refusal(http.StatusForbidden, codeSessionOutside, errors.New("path outside session dir"), nil) + return "", http.StatusForbidden, refusal(http.StatusForbidden, codeSessionOutside, errors.New("path outside session dir"), nil) } if dirErr != nil { - return pathUnresolved(dirErr) + status, err := pathUnresolved(dirErr) + return "", status, err } if realPath == realDir || !strings.HasPrefix(realPath, realDir+string(os.PathSeparator)) { - return http.StatusForbidden, refusal(http.StatusForbidden, codeSessionOutside, errors.New("path outside session dir"), nil) + return "", http.StatusForbidden, refusal(http.StatusForbidden, codeSessionOutside, errors.New("path outside session dir"), nil) + } + return realPath, 0, nil +} + +// resumeInto binds this runtime to the transcript at path, answering with the +// status a refusal deserves so both the HTTP handler and a hub opening a +// session in a new pane refuse for the same reasons. +func (s *Server) resumeInto(path string) (int, error) { + defer backgroundwork.Shared.Foreground()() + realPath, status, err := s.sessionFileInDir(path) + if err != nil { + return status, err } if sessionstore.IsCleanupPending(realPath) { return http.StatusBadRequest, refusal(http.StatusBadRequest, "session.pending_cleanup", errors.New("session is pending cleanup"), nil) @@ -108,6 +120,9 @@ func (s *Server) resumeInto(path string) (int, error) { // The lease already moved to the target; re-point it at the session the // controller still owns (best-effort). _ = s.rebindSessionLease(s.ctl().SessionPath()) + if errors.Is(err, sessionstore.ErrSessionReplayLimitExceeded) { + return http.StatusRequestEntityTooLarge, refusal(http.StatusRequestEntityTooLarge, codeSessionReplayLimit, err, map[string]any{"path": realPath}) + } return http.StatusBadRequest, fmt.Errorf("load session: %w", err) } if hook := resumeBindHookForTest; hook != nil { diff --git a/internal/frontend/serve/routes.go b/internal/frontend/serve/routes.go index 68b2fdc047..4f4c9ec16a 100644 --- a/internal/frontend/serve/routes.go +++ b/internal/frontend/serve/routes.go @@ -63,6 +63,7 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /new", s.newSession) mux.HandleFunc("POST /rewind", s.rewind) mux.HandleFunc("POST /branch/at", s.branchAt) + mux.HandleFunc("POST /session/tail", s.sessionTail) mux.HandleFunc("POST /rewind/prepare", s.rewindPrepare) mux.HandleFunc("POST /rewind/commit", s.rewindCommit) mux.HandleFunc("POST /rewind/undo", s.rewindUndo) diff --git a/internal/frontend/serve/session_tail.go b/internal/frontend/serve/session_tail.go new file mode 100644 index 0000000000..31af9973f1 --- /dev/null +++ b/internal/frontend/serve/session_tail.go @@ -0,0 +1,40 @@ +package serve + +import ( + "encoding/json" + "errors" + "net/http" + + "reasonix/internal/state/sessionstore" +) + +// sessionTail copies the newest part of a session too large to open into a new +// one and answers with its path; the source file is only read. +func (s *Server) sessionTail(w http.ResponseWriter, r *http.Request) { + var body struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" { + missingField(w, "path") + return + } + realPath, status, err := s.sessionFileInDir(body.Path) + if err != nil { + writeErr(w, status, err) + return + } + s.bindMu.Lock() + defer s.bindMu.Unlock() + path, dropped, err := s.ctl().ForkSessionTail(realPath) + switch { + case errors.Is(err, sessionstore.ErrSessionReplayLimitExceeded): + refuse(w, http.StatusRequestEntityTooLarge, codeSessionReplayLimit, "session cannot be read even in part", nil) + case err != nil: + refuse(w, http.StatusInternalServerError, "session.tail_failed", err.Error(), nil) + default: + writeJSON(w, struct { + Path string `json:"path"` + Dropped int `json:"dropped"` + }{Path: path, Dropped: dropped}) + } +} diff --git a/internal/frontend/serve/session_tail_test.go b/internal/frontend/serve/session_tail_test.go new file mode 100644 index 0000000000..e06505b59c --- /dev/null +++ b/internal/frontend/serve/session_tail_test.go @@ -0,0 +1,37 @@ +package serve + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/base/testenv" + "reasonix/internal/session/control" +) + +func tailServer(t *testing.T) *Server { + t.Helper() + dir := testenv.TempDir(t) + ctrl := control.New(control.Options{WorkspaceRoot: testenv.TempDir(t), SessionDir: dir, SessionPath: filepath.Join(dir, "s.jsonl")}) + t.Cleanup(ctrl.Close) + return &Server{ctrl: ctrl} +} + +func TestSessionTailNeedsAPath(t *testing.T) { + rec := httptest.NewRecorder() + tailServer(t).sessionTail(rec, httptest.NewRequest(http.MethodPost, "/session/tail", strings.NewReader(`{}`))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("a request with no path = %d, want 400", rec.Code) + } +} + +func TestSessionTailRefusesAPathOutsideTheSessionDirectory(t *testing.T) { + outside := filepath.Join(testenv.TempDir(t), "20261027-100000.000000000-m.jsonl") + rec := httptest.NewRecorder() + tailServer(t).sessionTail(rec, httptest.NewRequest(http.MethodPost, "/session/tail", strings.NewReader(`{"path":"`+outside+`"}`))) + if rec.Code == http.StatusOK || rec.Code == http.StatusInternalServerError { + t.Fatalf("a path outside the session directory = %d, want a refusal", rec.Code) + } +} diff --git a/internal/session/control/port.go b/internal/session/control/port.go index 0bce39bc2c..424521a22f 100644 --- a/internal/session/control/port.go +++ b/internal/session/control/port.go @@ -176,6 +176,7 @@ type SessionHistory interface { CommitFileRevert(planID string, resolution checkpoint.ConflictResolution) (checkpoint.RewindResult, error) Branch(name string) (string, error) BranchAt(turn int, name string) (string, error) + ForkSessionTail(sourcePath string) (path string, dropped int, err error) Branches() ([]sessionstore.BranchInfo, error) BranchTreeText() string SwitchBranch(ref string) (sessionstore.BranchInfo, error) diff --git a/internal/session/control/session_tail.go b/internal/session/control/session_tail.go new file mode 100644 index 0000000000..061e7ad552 --- /dev/null +++ b/internal/session/control/session_tail.go @@ -0,0 +1,54 @@ +package control + +import ( + "fmt" + + "reasonix/internal/contract/provider" + "reasonix/internal/state/sessionstore" +) + +// ForkSessionTail copies the newest part of the history at sourcePath into a +// new session and answers with its path and how many messages were left out. +// The source is only read, so a history too large to open stays whole. +func (c *Controller) ForkSessionTail(sourcePath string) (string, int, error) { + if c.sessionDir == "" { + return "", 0, fmt.Errorf("%w: sessions are not persisted here", ErrBranchUnavailable) + } + tail, err := sessionstore.LoadSessionTail(sourcePath) + if err != nil { + return "", 0, err + } + child := sessionstore.NewSession("") + child.Messages = tail.Messages + if tail.Dropped > 0 { + noteOmittedHistory(child.Messages, tail.Dropped) + } + path := sessionstore.NewSessionPath(c.sessionDir, c.label) + preview, turns := sessionstore.SessionPreviewFromMessages(child.Messages) + meta := sessionstore.BranchMeta{ + ParentID: sessionstore.BranchID(sourcePath), WorkspaceRoot: c.workspaceRoot, + Preview: preview, Turns: turns, SchemaVersion: sessionstore.BranchMetaCountsVersion, + Model: c.ModelRef(), AgentPreset: c.AgentPreset(), Mode: "agent", + } + if src, ok, err := sessionstore.LoadBranchMeta(sourcePath); err == nil && ok { + meta.Name, meta.WorkspaceRoot = src.Name, src.WorkspaceRoot + if src.Model != "" { + meta.Model, meta.AgentPreset, meta.Mode = src.Model, src.AgentPreset, src.Mode + } + } + if err := saveConversationBranch(path, child, meta, func() error { return nil }); err != nil { + return "", 0, err + } + return path, tail.Dropped, nil +} + +// noteOmittedHistory tells the model, in the first user message it will see, +// that earlier turns were not carried over. +func noteOmittedHistory(msgs []provider.Message, dropped int) { + for i := range msgs { + if msgs[i].Role == provider.RoleUser { + msgs[i].Content = fmt.Sprintf("[Host note: this conversation continues from its most recent part; %d earlier messages were too large to carry over and are not shown to you.]\n\n%s", dropped, msgs[i].Content) + return + } + } +} diff --git a/internal/session/control/session_tail_test.go b/internal/session/control/session_tail_test.go new file mode 100644 index 0000000000..2465c5136c --- /dev/null +++ b/internal/session/control/session_tail_test.go @@ -0,0 +1,104 @@ +package control + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "testing" + + "reasonix/internal/contract/provider" + "reasonix/internal/state/sessionstore" + "reasonix/internal/state/store" +) + +func TestForkSessionTailOpensWhatTheWholeHistoryCannot(t *testing.T) { + c, _, _ := runTwoTurns(t) + src := sessionstore.NewSessionPath(c.sessionDir, c.label) + writeOversizedEventLog(t, src) + if _, err := sessionstore.LoadSession(src); !errors.Is(err, sessionstore.ErrSessionReplayLimitExceeded) { + t.Fatalf("LoadSession = %v, want the replay limit", err) + } + before := hashSessionFiles(t, src) + + path, dropped, err := c.ForkSessionTail(src) + if err != nil { + t.Fatal(err) + } + if dropped == 0 || path == src { + t.Fatalf("path %q dropped %d, want a new session that left the old part out", path, dropped) + } + loaded, err := sessionstore.LoadSession(path) + if err != nil { + t.Fatalf("the fork does not open: %v", err) + } + last := loaded.Messages[len(loaded.Messages)-1] + if last.Content != "newest question" { + t.Fatalf("last message = %.30q, want the newest kept", last.Content) + } + first := firstUser(loaded.Messages) + if !strings.Contains(first, "earlier messages were too large to carry over") { + t.Fatalf("the model is not told history was left out: %.120q", first) + } + meta, ok, err := sessionstore.LoadBranchMeta(path) + if err != nil || !ok || meta.ParentID != sessionstore.BranchID(src) { + t.Fatalf("meta = %+v ok=%v err=%v, want the oversized session as parent", meta, ok, err) + } + if after := hashSessionFiles(t, src); after != before { + t.Fatal("reading the tail changed the oversized session's files") + } +} + +func firstUser(msgs []provider.Message) string { + for _, m := range msgs { + if m.Role == provider.RoleUser { + return m.Content + } + } + return "" +} + +func hashSessionFiles(t *testing.T, path string) [sha256.Size]byte { + t.Helper() + h := sha256.New() + for _, p := range append([]string{path}, store.SessionSidecarFiles(path)...) { + b, err := os.ReadFile(p) + if err == nil { + h.Write([]byte(p)) + h.Write(b) + } + } + return [sha256.Size]byte(h.Sum(nil)) +} + +// writeOversizedEventLog is a native log whose message payload passes the +// replay bound by appends of 1 MiB each, the way a long session grows past it. +func writeOversizedEventLog(t *testing.T, sessionPath string) { + t.Helper() + f, err := os.Create(store.SessionEventLog(sessionPath)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + enc := json.NewEncoder(f) + emit := func(rec map[string]any) { + rec["schema_version"] = 1 + if err := enc.Encode(rec); err != nil { + t.Fatal(err) + } + } + emit(map[string]any{"type": "replace", "revision": 1, "messages": []any{map[string]any{"role": "system", "content": "system prompt"}}}) + body := strings.Repeat("x", 1<<20) + index := 1 + for i := range 70 { + role := "user" + if i%2 == 1 { + role = "assistant" + } + emit(map[string]any{"type": "append", "revision": i + 2, "base_revision": i + 1, "message_index": index, "messages": []any{map[string]any{"role": role, "content": fmt.Sprintf("%02d %s", i, body)}}}) + index++ + } + emit(map[string]any{"type": "append", "revision": 72, "base_revision": 71, "message_index": index, "messages": []any{map[string]any{"role": "user", "content": "newest question"}}}) +} diff --git a/internal/state/sessionstore/session_dag_read.go b/internal/state/sessionstore/session_dag_read.go index 2a882771bb..15d28444de 100644 --- a/internal/state/sessionstore/session_dag_read.go +++ b/internal/state/sessionstore/session_dag_read.go @@ -291,9 +291,19 @@ func (st *sessionDAGState) selectedHead() string { // patches and redactions applied, and the head's system message first. A // chain that reaches a parent the log no longer holds starts there. func (st *sessionDAGState) materialize(f io.ReaderAt, headID string, limits sessionReplayLimits) ([]provider.Message, error) { + refs, _, err := st.chain(headID, limits) + if err != nil { + return nil, err + } + return readDAGPayloads(f, st.path, refs, limits) +} + +// chain is the payloads of one head in order, the head's system message first. +// With a tail budget it keeps the newest that fit and counts the rest as dropped. +func (st *sessionDAGState) chain(headID string, limits sessionReplayLimits) (refs []sessionDAGPayload, dropped int, err error) { h := st.heads[headID] if h == nil { - return nil, nil + return nil, 0, nil } var ids []string seen := map[string]bool{} @@ -307,7 +317,7 @@ func (st *sessionDAGState) materialize(f io.ReaderAt, headID string, limits sess id = n.parent } slices.Reverse(ids) - refs := make([]sessionDAGPayload, 0, len(ids)+1) + refs = make([]sessionDAGPayload, 0, len(ids)+1) for _, id := range ids { m := st.nodes[id].payload if p, ok := st.patches[id]; ok { @@ -325,16 +335,23 @@ func (st *sessionDAGState) materialize(f io.ReaderAt, headID string, limits sess refs = append([]sessionDAGPayload{*h.system}, refs...) } } + if limits.tailBudget > 0 { + refs, dropped = tailOfDAGPayloads(refs, limits.tailBudget) + } var payloadBytes int64 for _, ref := range refs { payloadBytes += ref.end - ref.start - if err := checkSessionPayloadBytes(st.path, payloadBytes); err != nil { - return nil, err + if err := limits.payloadErr(st.path, payloadBytes); err != nil { + return nil, 0, err } } + return refs, dropped, nil +} + +func readDAGPayloads(f io.ReaderAt, path string, refs []sessionDAGPayload, limits sessionReplayLimits) ([]provider.Message, error) { msgs := make([]provider.Message, 0, len(refs)) for _, ref := range refs { - m, err := ref.read(f, st.path, limits) + m, err := ref.read(f, path, limits) if err != nil { return nil, err } @@ -343,6 +360,53 @@ func (st *sessionDAGState) materialize(f io.ReaderAt, headID string, limits sess return msgs, nil } +// tailOfDAGPayloads keeps the system prompt and the newest payloads within +// budget, starting the rest at a user message when one follows the cut. +func tailOfDAGPayloads(refs []sessionDAGPayload, budget int64) ([]sessionDAGPayload, int) { + start := 0 + if len(refs) > 0 && refs[0].role == provider.RoleSystem { + start = 1 + } + var total int64 + for _, r := range refs { + total += r.end - r.start + } + cut := start + for cut < len(refs)-1 && total > budget { + total -= refs[cut].end - refs[cut].start + cut++ + } + if cut == start { + return refs, 0 + } + for next := cut; next < len(refs); next++ { + if refs[next].role == provider.RoleUser { + cut = next + break + } + } + return slices.Delete(refs, start, cut), cut - start +} + +func loadSessionDAGTail(sessionPath string, limits sessionReplayLimits) (SessionTail, error) { + path := store.SessionEventLog(sessionPath) + f, err := os.Open(path) + if err != nil { + return SessionTail{}, err + } + defer f.Close() + st, err := replaySessionDAG(f, path, limits) + if err != nil { + return SessionTail{}, err + } + refs, dropped, err := st.chain(st.selectedHead(), limits) + if err != nil { + return SessionTail{}, err + } + msgs, err := readDAGPayloads(f, path, refs, limits) + return SessionTail{Messages: msgs, Dropped: dropped}, err +} + // loadSessionDAGMessages is the transcript a schema 2 log's selected head // holds. func loadSessionDAGMessages(sessionPath string, limits sessionReplayLimits) ([]provider.Message, bool, error) { diff --git a/internal/state/sessionstore/session_events.go b/internal/state/sessionstore/session_events.go index f07c02d260..5dc5a66a22 100644 --- a/internal/state/sessionstore/session_events.go +++ b/internal/state/sessionstore/session_events.go @@ -86,6 +86,22 @@ type sessionReplayLimits struct { maxRecords int maxMessages int maxCollectionItems int + // maxPayloadBytes bounds the message bytes one replay holds; 0 is the default. + maxPayloadBytes int64 + // tailBudget, when set, keeps only the newest messages within it instead of + // refusing a history that exceeds maxPayloadBytes. + tailBudget int64 +} + +func (l sessionReplayLimits) payloadErr(path string, n int64) error { + limit := l.maxPayloadBytes + if limit == 0 { + limit = sessionReplayMaxPayloadBytes + } + if n > limit { + return sessionReplayLimitError(path, "message_payload_bytes", n, limit) + } + return nil } var defaultSessionReplayLimits = sessionReplayLimits{ @@ -176,6 +192,9 @@ func sessionEventLogOversized(logSize, contentBytes int64, msgs []provider.Messa type sessionEventReplay struct { msgs []provider.Message payloadBytes int64 + // dropped counts the messages a tail read left out after the system prompt. + dropped int + sizes []int64 // collectionItems counts the elements in every JSON array nested below a // live message. Keeping this alongside msgs bounds slices such as tool calls, // images, memory citations, and interrupted-turn recovery metadata without @@ -269,9 +288,12 @@ func replaySessionEventRecords(path string, limits sessionReplayLimits) (session } switch rec.Type { case sessionEventTypeReplace: - if err := checkSessionPayloadBytes(path, int64(len(rec.Messages))); err != nil { - return replay, err + if limits.tailBudget == 0 { + if err := limits.payloadErr(path, int64(len(rec.Messages))); err != nil { + return replay, err + } } + replay.dropped, replay.sizes = 0, nil msgs, collectionItems, err := decodeSessionEventMessages(path, rec.Messages, 0, 0, limits) if err == nil { collectionItems, err = resolveSessionEventImages(path, msgs, rec.ImageBlobs, collectionItems, limits) @@ -287,13 +309,16 @@ func replaySessionEventRecords(path string, limits sessionReplayLimits) (session replay.payloadBytes = int64(len(rec.Messages)) replay.collectionItems = collectionItems replay.times = make([]time.Time, len(replay.msgs)) + replay.trimToTail(limits) case sessionEventTypeAppend: - if rec.MessageIndex != len(replay.msgs) { + if rec.MessageIndex != replay.dropped+len(replay.msgs) { replay.damaged = true return replay, nil } - if err := checkSessionPayloadBytes(path, replay.payloadBytes+int64(len(rec.Messages))); err != nil { - return replay, err + if limits.tailBudget == 0 { + if err := limits.payloadErr(path, replay.payloadBytes+int64(len(rec.Messages))); err != nil { + return replay, err + } } msgs, collectionItems, err := decodeSessionEventMessages( path, rec.Messages, len(replay.msgs), replay.collectionItems, limits, @@ -314,6 +339,7 @@ func replaySessionEventRecords(path string, limits sessionReplayLimits) (session for range msgs { replay.times = append(replay.times, rec.CreatedAt) } + replay.trimToTail(limits) default: return replay, fmt.Errorf("decode session event log %s: unsupported event type %q", path, rec.Type) } diff --git a/internal/state/sessionstore/session_replay_reader.go b/internal/state/sessionstore/session_replay_reader.go index 826e75cd10..8e25f1de9d 100644 --- a/internal/state/sessionstore/session_replay_reader.go +++ b/internal/state/sessionstore/session_replay_reader.go @@ -104,10 +104,3 @@ func (r *sessionRecordReader) next() ([]byte, int64, error) { } return r.buffer, start, nil } - -func checkSessionPayloadBytes(path string, n int64) error { - if n > sessionReplayMaxPayloadBytes { - return sessionReplayLimitError(path, "message_payload_bytes", n, sessionReplayMaxPayloadBytes) - } - return nil -} diff --git a/internal/state/sessionstore/session_tail.go b/internal/state/sessionstore/session_tail.go new file mode 100644 index 0000000000..935197fabb --- /dev/null +++ b/internal/state/sessionstore/session_tail.go @@ -0,0 +1,107 @@ +package sessionstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + + "reasonix/internal/contract/provider" + "reasonix/internal/state/store" +) + +// SessionTail is the newest part of a history too large to replay whole. +// Messages starts with the system prompt when the history had one; Dropped is +// how many messages after it were left out. +type SessionTail struct { + Messages []provider.Message + Dropped int +} + +// trimToTail drops the oldest messages after the system prompt until the rest +// fit tailBudget, then any that would leave the tail starting mid-turn. +func (r *sessionEventReplay) trimToTail(limits sessionReplayLimits) { + if limits.tailBudget == 0 { + return + } + if len(r.sizes) > len(r.msgs) { + r.sizes = r.sizes[:0] + } + for _, m := range r.msgs[len(r.sizes):] { + b, _ := json.Marshal(m) + r.sizes = append(r.sizes, int64(len(b))) + } + var total int64 + for _, n := range r.sizes { + total += n + } + start := 0 + if len(r.msgs) > 0 && r.msgs[0].Role == provider.RoleSystem { + start = 1 + } + cut := start + for cut < len(r.msgs)-1 && total > limits.tailBudget { + total -= r.sizes[cut] + cut++ + } + if cut > start { + next := cut + for next < len(r.msgs) && r.msgs[next].Role != provider.RoleUser { + next++ + } + if next < len(r.msgs) { + cut = next + } else { + for cut > start && r.msgs[cut].Role == provider.RoleTool { + cut-- + } + } + r.dropped += cut - start + r.msgs = slices.Delete(r.msgs, start, cut) + r.sizes = slices.Delete(r.sizes, start, cut) + r.times = slices.Delete(r.times, start, cut) + } + total = 0 + for _, n := range r.sizes { + total += n + } + r.payloadBytes = total + r.collectionItems = 0 +} + +// loadSessionTailWithLimits reads the newest messages of a history within +// budget bytes, whatever its total size. Nothing on disk is changed. +func loadSessionTailWithLimits(sessionPath string, limits sessionReplayLimits, budget int64) (SessionTail, error) { + limits.tailBudget = budget + probe, err := probeSessionEventLogWithLimits(sessionPath, limits) + if err != nil { + return SessionTail{}, err + } + switch { + case probe.futureSchema: + return SessionTail{}, fmt.Errorf("session event log for %s uses schema %d; this build supports up to %d", sessionPath, probe.schemaVersion, sessionEventImageBlobsSchemaVersion) + case probe.dag: + return loadSessionDAGTail(sessionPath, limits) + case probe.native && probe.size > 0: + replay, err := replaySessionEventLogWithLimits(store.SessionEventLog(sessionPath), limits) + if err != nil { + return SessionTail{}, err + } + if replay.damaged { + return SessionTail{}, errors.New("session event log is damaged; its tail cannot be read safely") + } + return SessionTail{Messages: replay.msgs, Dropped: replay.dropped}, nil + } + return SessionTail{}, os.ErrNotExist +} + +// SessionTailBudgetBytes is how much of a history too large to open a recovery +// branch carries over. +const SessionTailBudgetBytes = int64(16 << 20) + +// LoadSessionTail reads the newest SessionTailBudgetBytes of the history at +// sessionPath without changing any file. +func LoadSessionTail(sessionPath string) (SessionTail, error) { + return loadSessionTailWithLimits(sessionPath, defaultSessionReplayLimits, SessionTailBudgetBytes) +} diff --git a/internal/state/sessionstore/session_tail_test.go b/internal/state/sessionstore/session_tail_test.go new file mode 100644 index 0000000000..4d84259835 --- /dev/null +++ b/internal/state/sessionstore/session_tail_test.go @@ -0,0 +1,185 @@ +package sessionstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/provider" + "reasonix/internal/state/store" +) + +func writeNativeAppendLog(t *testing.T, bodyBytes, count int) (path string, log []byte) { + t.Helper() + path = filepath.Join(testenv.TempDir(t), "20261027-100000.000000000-deepseek-flash.jsonl") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + var b strings.Builder + emit := func(rec map[string]any) { + rec["schema_version"] = 1 + line, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + b.Write(line) + b.WriteByte('\n') + } + emit(map[string]any{"type": "replace", "revision": 1, "messages": []any{map[string]any{"role": "system", "content": "system prompt"}}}) + body := strings.Repeat("x", bodyBytes) + for i := range count { + emit(map[string]any{"type": "append", "revision": i + 2, "base_revision": i + 1, "message_index": 1 + i, "messages": []any{map[string]any{"role": "user", "content": fmt.Sprintf("%03d%s", i, body)}}}) + } + log = []byte(b.String()) + if err := os.WriteFile(store.SessionEventLog(path), log, 0o600); err != nil { + t.Fatal(err) + } + return path, log +} + +func TestAHistoryOverThePayloadBoundStillHasATail(t *testing.T) { + path, log := writeNativeAppendLog(t, 100, 12) + limits := defaultSessionReplayLimits + limits.maxPayloadBytes = 600 + + if _, _, _, err := loadSessionMessagesWithLimits(path, limits); err == nil { + t.Fatal("the full history loaded under a bound it exceeds") + } + tail, err := loadSessionTailWithLimits(path, limits, 600) + if err != nil { + t.Fatal(err) + } + if len(tail.Messages) < 3 || tail.Dropped == 0 { + t.Fatalf("tail = %d messages, %d dropped; want a bounded recent part and the count left out", len(tail.Messages), tail.Dropped) + } + if tail.Messages[0].Role != provider.RoleSystem { + t.Fatalf("first message = %q, want the system prompt kept", tail.Messages[0].Role) + } + last := tail.Messages[len(tail.Messages)-1].Content + if !strings.HasPrefix(last, "011") { + t.Fatalf("last message = %q, want the newest", last) + } + prev := tail.Messages[len(tail.Messages)-2].Content + if !strings.HasPrefix(prev, "010") { + t.Fatalf("the part kept is not contiguous: %q before %q", prev, last) + } + if len(tail.Messages)-1+tail.Dropped != 12 { + t.Fatalf("kept %d + dropped %d does not account for the 12 messages", len(tail.Messages)-1, tail.Dropped) + } + if got, err := os.ReadFile(store.SessionEventLog(path)); err != nil || string(got) != string(log) { + t.Fatalf("the log changed while its tail was read: err=%v", err) + } +} + +func TestAHistoryThatFitsHasNothingLeftOut(t *testing.T) { + path, _ := writeNativeAppendLog(t, 50, 4) + tail, err := loadSessionTailWithLimits(path, defaultSessionReplayLimits, 1<<20) + if err != nil { + t.Fatal(err) + } + if tail.Dropped != 0 || len(tail.Messages) != 5 { + t.Fatalf("tail = %d messages, %d dropped, want all 5 and none dropped", len(tail.Messages), tail.Dropped) + } +} + +func TestADAGHistoryOverTheBoundHasATailToo(t *testing.T) { + path := writeReplayMemoryFixture(t, 65, 1<<20, false) + if _, _, _, err := loadSessionMessagesWithLimits(path, defaultSessionReplayLimits); err == nil { + t.Fatal("the full history loaded under a bound it exceeds") + } + tail, err := loadSessionTailWithLimits(path, defaultSessionReplayLimits, 8<<20) + if err != nil { + t.Fatal(err) + } + if tail.Dropped < 50 || len(tail.Messages) > 12 { + t.Fatalf("tail = %d messages, %d dropped, want a recent part of about 8 MiB", len(tail.Messages), tail.Dropped) + } + if got := tail.Messages[len(tail.Messages)-1].Content; got != "done" { + t.Fatalf("last message = %q, want the newest", got) + } +} + +func writeNativeLog(t *testing.T, recs []map[string]any) string { + t.Helper() + path := filepath.Join(testenv.TempDir(t), "20261027-100000.000000000-deepseek-flash.jsonl") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + var b strings.Builder + for _, rec := range recs { + rec["schema_version"] = 1 + line, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + b.Write(line) + b.WriteByte('\n') + } + if err := os.WriteFile(store.SessionEventLog(path), []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func msg(role, content string) map[string]any { + return map[string]any{"role": role, "content": content} +} + +func TestATailAfterAReplaceRecordCountsOnlyWhatFollowsIt(t *testing.T) { + body := strings.Repeat("x", 100) + var folded []any + folded = append(folded, msg("system", "s")) + for i := range 6 { + folded = append(folded, msg("user", fmt.Sprintf("u%d %s", i, body)), msg("assistant", fmt.Sprintf("a%d %s", i, body))) + } + path := writeNativeLog(t, []map[string]any{ + {"type": "replace", "revision": 1, "messages": []any{msg("system", strings.Repeat("y", 3000)), msg("user", "old")}}, + {"type": "replace", "revision": 2, "messages": folded}, + {"type": "append", "revision": 3, "base_revision": 2, "message_index": len(folded), "messages": []any{msg("user", "newest")}}, + }) + limits := defaultSessionReplayLimits + tail, err := loadSessionTailWithLimits(path, limits, 600) + if err != nil { + t.Fatal(err) + } + if got := tail.Messages[len(tail.Messages)-1].Content; got != "newest" { + t.Fatalf("last message = %q, want the newest", got) + } + var held int + for _, m := range tail.Messages[1:] { + raw, _ := json.Marshal(m) + held += len(raw) + } + if held > 600 || held < 450 { + t.Fatalf("the tail holds %d bytes against a budget of 600: a size recorded for the replaced history was counted again", held) + } + if len(tail.Messages)-1+tail.Dropped != len(folded) { + t.Fatalf("kept %d + dropped %d does not account for the %d messages after the system prompt", len(tail.Messages)-1, tail.Dropped, len(folded)) + } +} + +func TestATailNeverOpensOnAToolResult(t *testing.T) { + recs := []map[string]any{ + {"type": "replace", "revision": 1, "messages": []any{msg("system", "s"), msg("user", "one long job")}}, + } + index := 2 + for i := range 8 { + recs = append(recs, map[string]any{"type": "append", "revision": i + 2, "base_revision": i + 1, "message_index": index, + "messages": []any{msg("assistant", strings.Repeat("c", 300)), msg("tool", strings.Repeat("r", 50))}}) + index += 2 + } + path := writeNativeLog(t, recs) + for budget := int64(300); budget < 2000; budget += 25 { + tail, err := loadSessionTailWithLimits(path, defaultSessionReplayLimits, budget) + if err != nil { + t.Fatal(err) + } + if first := tail.Messages[1]; first.Role == "tool" { + t.Fatalf("budget %d: the tail opens on a tool result with no call before it", budget) + } + } +} diff --git a/release-notes/studio/unreleased/14660-open-oversized-session-tail.md b/release-notes/studio/unreleased/14660-open-oversized-session-tail.md new file mode 100644 index 0000000000..452885a35c --- /dev/null +++ b/release-notes/studio/unreleased/14660-open-oversized-session-tail.md @@ -0,0 +1,2 @@ +- 修复:历史超过读取上限的会话不再只报英文错误,会说明文件完好,并可「从最近的内容继续」另存为新会话。修复 #14660。 +- Fixed: a session over the read limit no longer stops at an English error; the file stays intact and "Continue from the most recent part" saves its newest part as a new session. Fixes #14660. From e0728253bb70c38afd79aa8595742c64fa3e1435 Mon Sep 17 00:00:00 2001 From: lancy69 <297596661+lancy69@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:53:59 +0800 Subject: [PATCH 30/37] feat(studio): add an opt-in global show/hide shortcut Own one disabled-by-default shortcut in the local Electron profile, with immediate authorized IPC and visible registration failures. Preserve old registration and saved state when rebind or persistence fails; restore at ready and unregister during quit. Keep browser/network viewers unsupported and in-app bindings unchanged. Cover lifecycle, authorization, failure rollback, bounded native input and settings neighbours; add a real Linux/X11 OS-key smoke and transparent platform limits. Fixes #8290. Refs #7623; stopped-app launching is not supplied. --- desktop/README.md | 47 ++++++ desktop/electron/package.json | 3 +- desktop/electron/src/globalshortcut.js | 65 ++++++++ desktop/electron/src/main.js | 21 ++- desktop/electron/src/prefs.js | 19 ++- desktop/electron/src/preload.js | 2 + desktop/electron/test/globalshortcut.test.mjs | 150 ++++++++++++++++++ desktop/electron/test/globalshortcut_live.js | 115 ++++++++++++++ desktop/electron/test/smoke.js | 2 +- desktop/frontend-next/src/actions.ts | 1 + desktop/frontend-next/src/i18n/en.ts | 1 - desktop/frontend-next/src/i18n/en_settings.ts | 17 ++ desktop/frontend-next/src/port/host.ts | 24 +++ .../src/port/host_shortcut.test.ts | 24 +++ desktop/frontend-next/src/ui/Appearance.tsx | 2 + .../src/ui/GlobalShortcutSection.tsx | 89 +++++++++++ desktop/frontend-next/src/ui/KeysSheet.tsx | 2 +- .../src/ui/accelerator-label.test.ts | 17 ++ .../ui/global-shortcut.interaction.test.tsx | 142 +++++++++++++++++ .../ui/graphics-search.interaction.test.tsx | 10 +- desktop/frontend-next/src/ui/keys.ts | 7 + desktop/frontend-next/src/ui/prefsnav.tsx | 1 + .../frontend-next/src/ui/useSettingsSearch.ts | 6 +- .../studio/unreleased/8290-global-shortcut.md | 6 + 24 files changed, 756 insertions(+), 17 deletions(-) create mode 100644 desktop/electron/src/globalshortcut.js create mode 100644 desktop/electron/test/globalshortcut.test.mjs create mode 100644 desktop/electron/test/globalshortcut_live.js create mode 100644 desktop/frontend-next/src/port/host_shortcut.test.ts create mode 100644 desktop/frontend-next/src/ui/GlobalShortcutSection.tsx create mode 100644 desktop/frontend-next/src/ui/accelerator-label.test.ts create mode 100644 desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx create mode 100644 release-notes/studio/unreleased/8290-global-shortcut.md diff --git a/desktop/README.md b/desktop/README.md index f595371346..3c00b38097 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -72,6 +72,38 @@ go run ./cmd/reasonix serve # from the repository root cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths ``` +## Global show/hide shortcut + +In the desktop shell, **Settings → Appearance → Global show / hide shortcut** +offers one opt-in binding, disabled by default. + +1. Click **Record shortcut**, then press a modifier and key combination. + Escape cancels recording. +2. Review the platform-native label, then **Apply shortcut** to register it. +3. **Clear shortcut** disables it immediately. + +You can also edit the Electron accelerator directly (up to 128 characters). +The shell requires Control, Command, Alt or Super, even through IPC or when +restored from disk. Shift may accompany one of them but cannot qualify alone. + +The shortcut hides a focused Studio window; otherwise it restores, shows and +focuses the existing window. It does not change in-app shortcuts or tray settings. + +The primary Electron instance owns the binding and stores it in its local shell +profile. Changes apply immediately; invalid or unavailable bindings leave the +previous saved and registered binding intact. + +Startup registration failures are shown in settings rather than reported as +success. An unavailable binding may be occupied, reserved by the OS, denied +permission, or unsupported by the desktop. + +Native Wayland environments may not support registration with this Electron +backend; no portal authorization or cross-platform success is implied. + +The binding exists only while Studio is running. It cannot launch a stopped +application and no external daemon is installed. Browser and network viewers +cannot configure a remote machine's global shortcut. + ## Building and testing ```sh @@ -79,6 +111,21 @@ make studio-test # go test ./... for this modul cd frontend-next && pnpm typecheck && pnpm test && pnpm build ``` +The Linux/X11 and macOS shortcut smoke drives the real Studio window, chord +capture, and OS key events. After building the host and frontend, run +`pnpm shortcut-live` in `electron/`. macOS uses System Events and requires +Accessibility/Automation permission for the launching process. + +Use isolated `REASONIX_HOME` and `XDG_CONFIG_HOME` directories; the smoke seeds +a test provider at an unreachable loopback port. + +- On Linux/X11, `REASONIX_XDOTOOL` names `xdotool`. +- On Linux/X11, `REASONIX_XMESSAGE` optionally names `xmessage` for the separate-app focus check. +- `REASONIX_SCREENSHOTS` optionally names the screenshot output directory. + +This test requires a working display. Windows and Wayland are not covered by +this native smoke; successful builds do not establish native shortcut behavior. + Packaging runs through electron-builder; `release-studio.yml` drives it on all three platforms. Linux ships a `.deb` because that is what makes Studio self-updating there. See `docs/STUDIO_RELEASE.md`. diff --git a/desktop/electron/package.json b/desktop/electron/package.json index 63547321ff..e905c0cc60 100644 --- a/desktop/electron/package.json +++ b/desktop/electron/package.json @@ -10,11 +10,12 @@ "start": "electron .", "build:host": "node packaging/build-host.js", "build:linux-payload": "node packaging/linux-payload.js", - "test": "node --test test/unit.mjs test/linux-packaging.test.mjs", + "test": "node --test test/unit.mjs test/linux-packaging.test.mjs test/globalshortcut.test.mjs", "build:app": "electron-builder --dir", "build:dist": "electron-builder", "smoke": "electron test/smoke.js", "browser-live": "electron test/browser_live.js", + "shortcut-live": "electron test/globalshortcut_live.js", "browser-resize-live": "electron test/browser_resize_live.js", "browser-fit-live": "electron test/browser_fit_live.js", "browser-fit-pages": "electron test/browser_fit_pages.js", diff --git a/desktop/electron/src/globalshortcut.js b/desktop/electron/src/globalshortcut.js new file mode 100644 index 0000000000..a3d6787d5f --- /dev/null +++ b/desktop/electron/src/globalshortcut.js @@ -0,0 +1,65 @@ +"use strict"; + +const PREF_KEY = "shell-global-shortcut"; +const MAX_BINDING_LENGTH = 128; + +function createGlobalShortcut({ globalShortcut, binding = "", save, windowOf, showWindow }) { + let state = { binding, status: binding ? "unavailable" : "disabled" }; + let closed = false; + const press = () => { + if (closed) return; + const win = windowOf(); + if (win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused()) win.hide(); + else showWindow(); + }; + function register(value) { + try { + return globalShortcut.register(value, press) ? "" : "unavailable"; + } catch { + return "invalid"; + } + } + function apply(value, persist) { + if (closed) return { ...state, error: "quitting" }; + if (typeof value !== "string" || value.length > MAX_BINDING_LENGTH) return { ...state, error: "invalid" }; + const next = value.trim(); + if (next && !next.split("+").slice(0, -1).some(part => /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|Alt|Option|AltGr|Super|Meta)$/i.test(part))) { + return { ...state, error: "invalid" }; + } + if (next === state.binding && state.status === "registered") return { ...state }; + if (next) { + const error = register(next); + if (error) return { ...state, error }; + } + try { + if (persist) save(next); + } catch { + if (next) globalShortcut.unregister(next); + return { ...state, error: "save" }; + } + if (state.status === "registered" && state.binding !== next) globalShortcut.unregister(state.binding); + state = { binding: next, status: next ? "registered" : "disabled" }; + return { ...state }; + } + return { + start() { + const result = apply(binding, false); + if (result.error) state = { binding, status: result.error }; + return { ...state }; + }, + read: () => ({ ...state }), + apply: value => apply(value, true), + close() { + closed = true; + if (state.status === "registered") globalShortcut.unregister(state.binding); + }, + }; +} + +function registerGlobalShortcutIPC(ipcMain, fromWindow, shortcutOf) { + const authorized = event => fromWindow(event) && event.senderFrame === event.sender.mainFrame; + ipcMain.handle("shell:global-shortcut", event => authorized(event) ? shortcutOf()?.read() ?? { binding: "", status: "unavailable" } : null); + ipcMain.handle("shell:set-global-shortcut", (event, value) => authorized(event) ? shortcutOf()?.apply(value) ?? { binding: "", status: "unavailable" } : null); +} + +module.exports = { PREF_KEY, MAX_BINDING_LENGTH, createGlobalShortcut, registerGlobalShortcutIPC }; diff --git a/desktop/electron/src/main.js b/desktop/electron/src/main.js index 40f4950337..429db1e2fa 100644 --- a/desktop/electron/src/main.js +++ b/desktop/electron/src/main.js @@ -1,5 +1,5 @@ "use strict"; -const { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron"); +const { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron"); const { relaunchForOzonePlatform } = require("./ozone"); // Before the instance lock: this process must hold nothing its relaunch needs. @@ -31,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol"); const { BrowserViews } = require("./browserviews"); const { startBrowserRelay } = require("./browserrelay"); const { groundFor, themeSourceFor } = require("./ground"); -const { loadPrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs"); +const { loadPrefs, savePrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs"); +const { PREF_KEY: SHORTCUT_PREF, createGlobalShortcut, registerGlobalShortcutIPC } = require("./globalshortcut"); const { stateFile, openWindow } = require("./windowstate"); const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics"); const { processMemoryHandler } = require("./procmem"); @@ -90,6 +91,7 @@ let browserRelay = null; let reload = null; let powerGuard = null; let logs = null; +let windowShortcut = null; let handshaken = false; let starting = null; @@ -407,6 +409,7 @@ nativeTheme.on("updated", () => { }); ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event)); +registerGlobalShortcutIPC(ipcMain, fromWindow, () => windowShortcut); const showTextContextMenu = textContextMenuHandler(Menu, fromWindow, () => origin, uiLang); ipcMain.handle("text:context-menu", showTextContextMenu); ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event)); @@ -554,7 +557,18 @@ if (!primary) { } app.whenReady().then(() => { - if (!primary) return; + if (!primary || quitting) return; + windowShortcut = createGlobalShortcut({ + globalShortcut, + binding: savedPrefs[SHORTCUT_PREF] ?? "", + save: binding => { + const file = prefsFile(app.getPath("userData")); + savePrefs(file, { ...loadPrefs(file), [SHORTCUT_PREF]: binding }); + }, + windowOf: () => win, + showWindow, + }); + windowShortcut.start(); installApplicationMenu(uiLang); boot().catch((err) => { console.error("reasonix-studio:", err.message); @@ -601,6 +615,7 @@ module.exports = { current: () => ({ win, tray, client, origin }) }; const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`)); app.on("before-quit", event => { quitting = true; + windowShortcut?.close(); logs.shell.line("shell: quitting"); drainCua(event); browserRelay?.stop(); diff --git a/desktop/electron/src/prefs.js b/desktop/electron/src/prefs.js index 3b40c0e924..023d6b48d6 100644 --- a/desktop/electron/src/prefs.js +++ b/desktop/electron/src/prefs.js @@ -1,6 +1,7 @@ "use strict"; const fs = require("node:fs"); const path = require("node:path"); +const { PREF_KEY: SHORTCUT_PREF } = require("./globalshortcut"); // The page's own preferences, kept past the origin that stored them. The kernel // listens on a fresh loopback port each launch and localStorage is keyed by @@ -20,13 +21,12 @@ function pageSleepAfterMs(prefs) { return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity; } -// Anything that is not one of the page's own string preferences is dropped, in -// both directions: a hand-edited file or a page bug cannot widen what is kept. +// Only page string preferences and the shell-owned shortcut survive a disk read. function pick(value) { const out = {}; if (!value || typeof value !== "object" || Array.isArray(value)) return out; for (const [key, v] of Object.entries(value)) { - if (key.startsWith(PREFIX) && typeof v === "string") out[key] = v; + if ((key.startsWith(PREFIX) || key === SHORTCUT_PREF) && typeof v === "string") out[key] = v; } return out; } @@ -54,12 +54,15 @@ function savePrefs(file, prefs) { // after these handlers exist. function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) { ipcMain.on("prefs:load", (event) => { - event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {}; + event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {}; }); ipcMain.on("prefs:save", (event, prefs) => { if (fromWindow(event)) { try { - savePrefs(fileOf(), prefs); + const current = loadPrefs(fileOf()); + const next = pickPagePrefs(prefs); + if (current[SHORTCUT_PREF] !== undefined) next[SHORTCUT_PREF] = current[SHORTCUT_PREF]; + savePrefs(fileOf(), next); onSave(); } catch { // A preference that did not save is one relaunch of lost state, not a @@ -70,4 +73,10 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) { }); } +function pickPagePrefs(prefs) { + const page = pick(prefs); + delete page[SHORTCUT_PREF]; + return page; +} + module.exports = { PREFIX, PAGE_SLEEP_MS, pageSleepAfterMs, prefsFile, pick, loadPrefs, savePrefs, registerPrefs }; diff --git a/desktop/electron/src/preload.js b/desktop/electron/src/preload.js index 25dfb2c8ec..edecce4280 100644 --- a/desktop/electron/src/preload.js +++ b/desktop/electron/src/preload.js @@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld("reasonixHost", { showTextContextMenu: (request) => ipcRenderer.invoke("text:context-menu", request), // What the shell's graphics are running as this launch. graphics: () => ipcRenderer.invoke("shell:graphics"), + globalShortcut: () => ipcRenderer.invoke("shell:global-shortcut"), + setGlobalShortcut: (binding) => ipcRenderer.invoke("shell:set-global-shortcut", binding), // The window's processes as type and two sizes, for the memory a report may carry. processMemory: () => ipcRenderer.invoke("shell:process-memory"), openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)), diff --git a/desktop/electron/test/globalshortcut.test.mjs b/desktop/electron/test/globalshortcut.test.mjs new file mode 100644 index 0000000000..95492c6331 --- /dev/null +++ b/desktop/electron/test/globalshortcut.test.mjs @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const require = createRequire(import.meta.url); +const { createGlobalShortcut, registerGlobalShortcutIPC, PREF_KEY } = require("../src/globalshortcut"); +const { savePrefs, loadPrefs, registerPrefs } = require("../src/prefs"); + +function fixture(binding = "") { + const registered = new Map(); + const saved = []; + const win = { visible: true, focused: true, minimized: false, destroyed: false, + isVisible() { return this.visible; }, isFocused() { return this.focused; }, + isMinimized() { return this.minimized; }, isDestroyed() { return this.destroyed; }, + hide() { this.visible = false; this.focused = false; }, + }; + let shown = 0; + let failSave = false; + const api = { + register(key, callback) { + if (key === "Control+invalid") throw new TypeError("invalid accelerator"); + if (key === "Control+occupied") return false; + registered.set(key, callback); return true; + }, + unregister(key) { registered.delete(key); }, + }; + const shortcut = createGlobalShortcut({ globalShortcut: api, binding, + save(value) { if (failSave) throw new Error("disk full"); saved.push(value); }, + windowOf: () => win, showWindow() { shown++; win.visible = true; win.focused = true; win.minimized = false; }, + }); + return { shortcut, registered, saved, win, get shown() { return shown; }, failSave() { failSave = true; } }; +} + +test("starts disabled without claiming an OS binding and restores a saved binding before any renderer", () => { + const off = fixture(); + assert.deepEqual(off.shortcut.start(), { binding: "", status: "disabled" }); + assert.equal(off.registered.size, 0); + const on = fixture("Control+Space"); + assert.deepEqual(on.shortcut.start(), { binding: "Control+Space", status: "registered" }); + assert.equal(on.registered.size, 1); + assert.deepEqual(on.saved, []); +}); + +test("bare keys and Shift-only chords cannot claim machine-wide input through apply or restored preferences", () => { + for (const binding of ["C", "Space", "F9", "Plus", " Shift ", "Shift+A", "Shift+Space"]) { + const restored = fixture(binding); + assert.deepEqual(restored.shortcut.start(), { binding, status: "invalid" }); + assert.equal(restored.registered.size, 0); + const f = fixture("Control+Space"); f.shortcut.start(); + assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error: "invalid" }); + assert.deepEqual([...f.registered.keys()], ["Control+Space"]); + assert.deepEqual(f.saved, []); + } + for (const modifier of ["CommandOrControl", "CmdOrCtrl", "Command", "Cmd", "Control", "Ctrl", "Alt", "Option", "AltGr", "Super", "Meta"]) { + const f = fixture(); + assert.equal(f.shortcut.apply(`${modifier}+Space`).status, "registered"); + assert.equal(f.shortcut.apply(`${modifier}+Shift+Space`).status, "registered"); + } +}); + +test("focused hides; unfocused, hidden and minimized show through the existing path", () => { + for (const changed of [{}, { focused: false }, { visible: false, focused: false }, { minimized: true }]) { + const f = fixture("Control+Space"); f.shortcut.start(); Object.assign(f.win, changed); + f.registered.get("Control+Space")(); + assert.equal(f.shown, Object.keys(changed).length ? 1 : 0); + assert.equal(f.win.visible, Object.keys(changed).length > 0); + } +}); + +test("failed rebind preserves both registration and saved choice, successful replacement removes only the old binding", () => { + const f = fixture("Control+Space"); f.shortcut.start(); + for (const [binding, error] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"], [42, "invalid"]]) { + assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error }); + assert.deepEqual([...f.registered.keys()], ["Control+Space"]); + assert.deepEqual(f.saved, []); + } + assert.deepEqual(f.shortcut.apply(" Control+Shift+Space "), { binding: "Control+Shift+Space", status: "registered" }); + assert.deepEqual([...f.registered.keys()], ["Control+Shift+Space"]); + assert.deepEqual(f.saved, ["Control+Shift+Space"]); + f.shortcut.apply(""); + assert.equal(f.registered.size, 0); + assert.deepEqual(f.saved, ["Control+Shift+Space", ""]); +}); + +test("disk failure rolls back the prospective registration, including disabling", () => { + const f = fixture("Control+Space"); f.shortcut.start(); f.failSave(); + for (const value of ["Control+Shift+Space", ""]) { + assert.deepEqual(f.shortcut.apply(value), { binding: "Control+Space", status: "registered", error: "save" }); + assert.deepEqual([...f.registered.keys()], ["Control+Space"]); + } +}); + +test("startup failure is honest and may be replaced or disabled", () => { + for (const [binding, status] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"]]) { + const f = fixture(binding); + assert.deepEqual(f.shortcut.start(), { binding, status }); + assert.equal(f.registered.size, 0); + assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" }); + } +}); + +test("quit unregisters its binding, suppresses queued presses and refuses new registration", () => { + const f = fixture("Control+Space"); f.shortcut.start(); + const queued = f.registered.get("Control+Space"); + f.shortcut.close(); f.shortcut.close(); queued(); + assert.equal(f.registered.size, 0); assert.equal(f.shown, 0); assert.equal(f.win.visible, true); + assert.equal(f.shortcut.apply("Control+Shift+Space").error, "quitting"); +}); + +test("only the authorized Studio main frame can read or immediately apply a shortcut", () => { + const f = fixture(); f.shortcut.start(); + const handlers = new Map(); const sender = { mainFrame: {} }; + registerGlobalShortcutIPC({ handle: (key, fn) => handlers.set(key, fn) }, event => event.sender === sender, () => f.shortcut); + for (const event of [{ sender: { mainFrame: {} }, senderFrame: {} }, { sender, senderFrame: {} }]) { + assert.equal(handlers.get("shell:global-shortcut")(event), null); + assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space"), null); + } + assert.equal(f.registered.size, 0); + const event = { sender, senderFrame: sender.mainFrame }; + assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space").status, "registered"); + assert.equal(handlers.get("shell:global-shortcut")(event).binding, "Control+Space"); + assert.deepEqual(handlers.get("shell:set-global-shortcut")(event, " ".repeat(128) + "Control+Space"), { binding: "Control+Space", status: "registered", error: "invalid" }); + assert.deepEqual([...f.registered.keys()], ["Control+Space"]); + assert.deepEqual(f.saved, ["Control+Space"]); +}); + +test("page preference flushing cannot overwrite the shell-owned saved binding", t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-shortcut-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, "window-prefs.json"); + savePrefs(file, { [PREF_KEY]: "Control+Space", "rx-lang": "en" }); + const handlers = {}; + registerPrefs({ on: (key, fn) => handlers[key] = fn }, () => file, () => true); + const event = {}; + handlers["prefs:load"](event); + assert.deepEqual(event.returnValue, { "rx-lang": "en" }); + handlers["prefs:save"](event, { "rx-lang": "zh", [PREF_KEY]: "occupied" }); + assert.deepEqual(loadPrefs(file), { "rx-lang": "zh", [PREF_KEY]: "Control+Space" }); +}); + +test("oversized restored preferences never reach native registration", () => { + const binding = "Control+" + "x".repeat(128); + const f = fixture(binding); + assert.deepEqual(f.shortcut.start(), { binding, status: "invalid" }); + assert.equal(f.registered.size, 0); + assert.deepEqual(f.saved, []); + assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" }); +}); diff --git a/desktop/electron/test/globalshortcut_live.js b/desktop/electron/test/globalshortcut_live.js new file mode 100644 index 0000000000..34d2fec543 --- /dev/null +++ b/desktop/electron/test/globalshortcut_live.js @@ -0,0 +1,115 @@ +"use strict"; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync, spawn } = require("node:child_process"); +const { app, globalShortcut } = require("electron"); +const { wait } = require("./livekit"); + +const home = process.env.REASONIX_HOME; +if (!home) throw new Error("Use an isolated REASONIX_HOME for the native shortcut smoke"); +fs.mkdirSync(home, { recursive: true }); +fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n'); +fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n"); +const { current } = require("../src/main"); +const input = process.env.REASONIX_XDOTOOL || "xdotool"; +const binding = "Control+Shift+F9"; +let neighbour; + +async function until(read, description) { + const end = Date.now() + 40000; + while (Date.now() < end) { + if (await read()) return; + await wait(100); + } + throw new Error(`Timed out: ${description}`); +} + +async function run() { + await until(() => current().win && !current().win.webContents.isLoading(), "Studio window"); + const win = current().win; + const js = source => win.webContents.executeJavaScript(source); + await until(() => js('!!document.querySelector(".chrome")'), "real Studio workbench"); + await until(() => js('!document.getElementById("boot")'), "startup overlay finishes"); + assert.equal(await js('window.reasonixHost.globalShortcut().then(value => value.status)'), "disabled"); + await js('document.querySelector(\'[data-action="chrome.settings"]\').click()'); + await until(() => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'), "settings navigation"); + await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()'); + await until(() => js('!!document.querySelector("#set-global-shortcut input:not(:disabled)")'), "shortcut settings"); + const out = process.env.REASONIX_SCREENSHOTS; + async function screenshots(stage) { + if (!out) return; + fs.mkdirSync(out, { recursive: true }); + for (const width of [1280, 390]) { + win.setSize(1280, 1000); + win.webContents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height: 1000 }, viewSize: { width, height: 1000 }, deviceScaleFactor: 1, scale: 1 }); + for (const mode of ["light", "dark"]) { + await js(`document.querySelector('[data-action="appearance.scheme"][data-value="${mode}"]').click()`); + await js('document.querySelector("#set-global-shortcut").scrollIntoView({block:"center"})'); + await wait(350); + const png = await win.webContents.capturePage({ x: 0, y: 0, width, height: 1000 }); + fs.writeFileSync(path.join(out, `${stage}-${mode}-${width}.png`), png.toPNG()); + } + } + win.webContents.disableDeviceEmulation(); + } + await screenshots("before-disabled"); + await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="record"]\').click()'); + win.webContents.sendInputEvent({ type: "keyDown", keyCode: "F9", modifiers: ["control", "shift"] }); + win.webContents.sendInputEvent({ type: "keyUp", keyCode: "F9", modifiers: ["control", "shift"] }); + await until(() => js(`document.querySelector("#set-global-shortcut input").value === "${binding}"`), "captured modifier chord"); + await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="apply"]\').click()'); + await until(() => js('window.reasonixHost.globalShortcut().then(value => value.status === "registered")'), "real OS registration"); + assert.equal(globalShortcut.isRegistered(binding), true); + console.log(`ok: real Studio settings capture/apply registered the native ${process.platform} binding`); + await screenshots("after-registered"); + if (process.platform === "darwin") { + execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of first application process whose unix id is ${process.pid} to true`], { timeout: 10000 }); + } + win.show(); win.focus(); + await until(() => win.isFocused(), "focused Studio window"); + const press = () => process.platform === "darwin" + ? execFileSync("osascript", ["-e", 'tell application "System Events" to key code 101 using {control down, shift down}'], { timeout: 10000 }) + : execFileSync(input, ["key", "--clearmodifiers", "ctrl+shift+F9"]); + press(); + await until(() => !win.isVisible(), "OS key hides focused Studio"); + press(); + await until(() => win.isVisible() && win.isFocused(), "OS key restores hidden Studio"); + console.log("ok: real OS key hides the focused window and restores it from hidden"); + win.minimize(); + await until(() => win.isMinimized(), "minimized Studio"); + press(); + await until(() => !win.isMinimized() && win.isVisible() && win.isFocused(), "OS key restores minimized Studio"); + console.log("ok: real OS key restores and focuses the minimized window"); + if (process.env.REASONIX_XMESSAGE) { + neighbour = spawn(process.env.REASONIX_XMESSAGE, ["-title", "Reasonix shortcut smoke neighbour", "-buttons", "OK", "Separate native application"], { stdio: "ignore" }); + await until(() => { + try { return execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim(); } catch { return false; } + }, "external application window"); + const id = execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim().split("\n")[0]; + execFileSync(input, ["windowfocus", "--sync", id]); + await until(() => !win.isFocused() && win.isVisible(), "external application takes focus"); + press(); + await until(() => win.isFocused() && win.isVisible(), "OS key raises Studio from another app"); + console.log("ok: real OS key raises visible, unfocused Studio while a separate native application has focus"); + neighbour.kill(); + } + assert.equal(globalShortcut.register("Control+Shift+F10", () => {}), true); + const failed = await js('window.reasonixHost.setGlobalShortcut("Control+Shift+F10")'); + assert.deepEqual(failed, { binding, status: "registered", error: "unavailable" }); + globalShortcut.unregister("Control+Shift+F10"); + const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "window-prefs.json"), "utf8")); + assert.equal(saved["shell-global-shortcut"], binding); + assert.equal((await js('window.reasonixHost.setGlobalShortcut("C")')).error, "invalid"); + assert.equal(globalShortcut.isRegistered("C"), false); + assert.equal((await js('window.reasonixHost.setGlobalShortcut("invalid shortcut value")')).error, "invalid"); + assert.equal((await js('window.reasonixHost.setGlobalShortcut("")')).status, "disabled"); + assert.equal(globalShortcut.isRegistered(binding), false); + console.log("ok: native conflict/invalid entries preserve prior binding and disabling unregisters it"); +} + +run().then(() => { app.quit(); }).catch(error => { + neighbour?.kill(); + console.error(error.stack || error); + app.exit(1); +}); diff --git a/desktop/electron/test/smoke.js b/desktop/electron/test/smoke.js index 083865c3e3..590cc1f52f 100644 --- a/desktop/electron/test/smoke.js +++ b/desktop/electron/test/smoke.js @@ -84,7 +84,7 @@ async function run() { placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')), }))()`); - const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"]; + const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"]; check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge); check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie); check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals); diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index fd071cd227..f0ced9f76b 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -109,6 +109,7 @@ export const ACTIONS: UIAction[] = [ { id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" }, + { id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" }, { id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.page-sleep", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 5f863c0b71..3edd43fbfc 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -966,7 +966,6 @@ export const EN: Record = { "返回正文": "Back to the conversation", "完成": "Done", "快捷键": "Keyboard shortcuts", - "下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。": "These work anywhere in the window. Where a chord shows ⌘, Windows and Linux use Ctrl.", "在对话里查找": "Find in the conversation", "搜索会话": "Search conversations", "在提问之间移动": "Move between your questions", diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 068bf0211b..eca7ae8e87 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -2,6 +2,23 @@ // alone — one catalogue is still the model, and this file is read as its // continuation: same keying by Chinese source text, same grouping by screen. export const EN_SETTINGS: Record = { + "全局显示 / 隐藏快捷键": "Global show / hide shortcut", + "默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。": "Off by default. While Studio is running, hide its focused window or show and focus it otherwise. Does not launch a stopped app or change in-app shortcuts.", + "快捷键组合": "Shortcut binding", + "点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。": "Click Record and press a modifier and key combination, or edit the shortcut directly. Esc cancels recording; Clear immediately disables the shortcut. Wayland or OS-reserved bindings may not be supported.", + "请按修饰键与按键组合…": "Press a modifier and key combination…", + "录制快捷键": "Record shortcut", + "应用快捷键": "Apply shortcut", + "清除快捷键": "Clear shortcut", + "全局快捷键已关闭": "Global shortcut disabled", + "已注册:{value}": "Registered: {value}", + "快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。": "Invalid accelerator. Use a modifier and key combination, e.g. CommandOrControl+Shift+Space.", + "系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。": "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop.", + "当前环境不支持全局快捷键。": "Global shortcuts are not supported in this environment.", + "快捷键未能保存;原设置保持不变。": "Could not save the shortcut; the previous setting is unchanged.", + "Studio 正在退出,未更改快捷键。": "Studio is quitting; the shortcut was not changed.", + "无法读取或更改 shell 快捷键状态。请重新打开设置后再试。": "Could not read or change shell shortcut status. Reopen settings to try again.", + "下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。": "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only.", "加载中…": "Loading…", "AI 文件交付卡片": "AI file delivery cards", "启用 AI 文件交付卡片": "Enable AI file delivery cards", diff --git a/desktop/frontend-next/src/port/host.ts b/desktop/frontend-next/src/port/host.ts index e144b1c7e5..b62a21094d 100644 --- a/desktop/frontend-next/src/port/host.ts +++ b/desktop/frontend-next/src/port/host.ts @@ -24,6 +24,14 @@ export interface GraphicsInfo { compositing: string; } +export interface GlobalShortcutInfo { + binding: string; + status: "disabled" | "registered" | "invalid" | "unavailable" | "unsupported"; + error?: "invalid" | "unavailable" | "save" | "quitting"; +} + +const unsupportedShortcut: GlobalShortcutInfo = { binding: "", status: "unsupported" }; + export interface TextContextMenuRequest { editable: boolean; selected: boolean; @@ -52,6 +60,8 @@ export interface HostPort { captureWindow(): Promise; /** How the shell draws this launch; null where it has no say, as in a tab. */ graphics(): Promise; + globalShortcut(): Promise; + setGlobalShortcut(binding: string): Promise; /** The shell's own processes as a type and two sizes each, for the memory a * report may carry; empty where there is no shell to ask, as in a tab. */ processMemory(): Promise; @@ -181,6 +191,8 @@ interface ElectronBridge { closeWindow(): Promise; captureWindow?(): Promise; graphics?(): Promise; + globalShortcut?(): Promise; + setGlobalShortcut?(binding: string): Promise; processMemory?(): Promise; openExternal(url: string): Promise; setUnreadBadge?(unread: boolean): Promise; @@ -250,6 +262,12 @@ class ElectronHost implements HostPort { graphics() { return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null); } + globalShortcut() { + return this.api.globalShortcut?.().then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut); + } + setGlobalShortcut(binding: string) { + return this.api.setGlobalShortcut?.(binding).then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut); + } processMemory() { return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]); } @@ -358,6 +376,12 @@ class BrowserHost implements HostPort { graphics() { return Promise.resolve(null); } + globalShortcut() { + return Promise.resolve(unsupportedShortcut); + } + setGlobalShortcut() { + return Promise.resolve(unsupportedShortcut); + } processMemory() { return Promise.resolve([]); } diff --git a/desktop/frontend-next/src/port/host_shortcut.test.ts b/desktop/frontend-next/src/port/host_shortcut.test.ts new file mode 100644 index 0000000000..80c9271340 --- /dev/null +++ b/desktop/frontend-next/src/port/host_shortcut.test.ts @@ -0,0 +1,24 @@ +import { afterEach, expect, it, vi } from "vitest"; + +// Host selection is cached at module load/use; each test needs a fresh module. + +afterEach(() => vi.unstubAllGlobals()); + +it("does not grant browser or network viewers a host-machine shortcut", async () => { + vi.resetModules(); + vi.stubGlobal("window", {}); + const { host } = await import("./host"); + expect(await host().globalShortcut()).toEqual({ binding: "", status: "unsupported" }); + expect(await host().setGlobalShortcut("CommandOrControl+Shift+Space")).toEqual({ binding: "", status: "unsupported" }); +}); + +it("asks the local Electron shell immediately, without browser storage", async () => { + vi.resetModules(); + const state = { binding: "Control+Shift+Space", status: "registered" }; + const apply = vi.fn(async () => state); + vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "linux", globalShortcut: async () => state, setGlobalShortcut: apply } }); + const { host } = await import("./host"); + expect(await host().globalShortcut()).toEqual(state); + expect(await host().setGlobalShortcut(state.binding)).toEqual(state); + expect(apply).toHaveBeenCalledWith(state.binding); +}); diff --git a/desktop/frontend-next/src/ui/Appearance.tsx b/desktop/frontend-next/src/ui/Appearance.tsx index 59a88c53d8..9b5695c99b 100644 --- a/desktop/frontend-next/src/ui/Appearance.tsx +++ b/desktop/frontend-next/src/ui/Appearance.tsx @@ -22,6 +22,7 @@ import { SessionQuickSection } from "./SessionQuickSection"; import { NavEntriesSection } from "./NavEntriesSection"; import { NavRailSection } from "./NavRailSection"; import { GraphicsSection } from "./GraphicsSection"; +import { GlobalShortcutSection } from "./GlobalShortcutSection"; import { SendShortcut } from "./SendShortcut"; import { KeysSheet } from "./KeysSheet"; import { SessionTabsSetting } from "./SessionTabsSetting"; @@ -561,6 +562,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, + diff --git a/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx b/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx new file mode 100644 index 0000000000..df7042d346 --- /dev/null +++ b/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { t } from "../i18n"; +import { host, type GlobalShortcutInfo } from "../port/host"; +import { Group } from "./Group"; +import { acceleratorLabel } from "./keys"; + +const STATUS = { + disabled: "全局快捷键已关闭", + registered: "已注册:{value}", + invalid: "快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。", + unavailable: "系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。", + unsupported: "当前环境不支持全局快捷键。", +}; +const ERRORS = { + invalid: STATUS.invalid, + unavailable: STATUS.unavailable, + save: "快捷键未能保存;原设置保持不变。", + quitting: "Studio 正在退出,未更改快捷键。", +}; + +function capturedBinding(event: KeyboardEvent): string { + if (event.nativeEvent.isComposing || event.repeat || event.getModifierState("AltGraph")) return ""; + const modifiers = [ + event.ctrlKey && "Control", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Super", + ].filter(Boolean); + if (!event.ctrlKey && !event.altKey && !event.metaKey) return ""; + const names: Record = { " ": "Space", "+": "Plus", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right" }; + const punctuation: Record = { Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Backquote: "`", Minus: "-", Equal: "=" }; + const position = /^(Key[A-Z]|Digit[0-9])$/.test(event.code) ? event.code.replace(/^(Key|Digit)/, "") : punctuation[event.code]; + const key = names[event.key] ?? (/^[a-z0-9]$/i.test(event.key) ? event.key.toUpperCase() : position ?? event.key); + if (!/^(Space|Plus|Up|Down|Left|Right|Enter|Escape|Backspace|Delete|Insert|Home|End|PageUp|PageDown|F([1-9]|1[0-9]|2[0-4])|[A-Z0-9,./;'\[\]\\`=-])$/.test(key)) return ""; + return [...modifiers, key].join("+"); +} + +export function GlobalShortcutSection() { + const [now, setNow] = useState(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const [recording, setRecording] = useState(false); + const input = useRef(null); + useEffect(() => { + let live = true; + host().globalShortcut().then(value => { + if (live) { setNow(value); setDraft(value.binding); } + }).catch(() => live && setFailed(true)); + return () => { live = false; }; + }, []); + if (!host().inShell() || now?.status === "unsupported") return null; + async function apply(binding: string) { + setBusy(true); + setFailed(false); + try { + const value = await host().setGlobalShortcut(binding); + setNow(value); + if (!value.error) setDraft(value.binding); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + } + return ( + +
    { event.preventDefault(); void apply(draft); }}> + + {draft &&

    {acceleratorLabel(draft)}

    } +

    {t("点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。")}

    +
    + + + +
    +
    + {now &&

    {t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}

    } + {now?.error &&

    {t(ERRORS[now.error])}

    } + {failed &&

    {t("无法读取或更改 shell 快捷键状态。请重新打开设置后再试。")}

    } +
    + ); +} diff --git a/desktop/frontend-next/src/ui/KeysSheet.tsx b/desktop/frontend-next/src/ui/KeysSheet.tsx index 06d8bf466a..31058eb249 100644 --- a/desktop/frontend-next/src/ui/KeysSheet.tsx +++ b/desktop/frontend-next/src/ui/KeysSheet.tsx @@ -29,7 +29,7 @@ export function KeysSheet() { {KEYS.map(([keys, what]) => (
    diff --git a/desktop/frontend-next/src/ui/accelerator-label.test.ts b/desktop/frontend-next/src/ui/accelerator-label.test.ts new file mode 100644 index 0000000000..fe5573238f --- /dev/null +++ b/desktop/frontend-next/src/ui/accelerator-label.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; + +afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); }); + +it.each([ + ["Macintosh", "⌘⇧Space", "⌃⌥↑"], + ["Windows", "Ctrl+Shift+Space", "Ctrl+Alt+↑"], + ["Linux", "Ctrl+Shift+Space", "Ctrl+Alt+↑"], +])("presents accelerators in %s notation", async (userAgent, primary, explicit) => { + vi.stubGlobal("navigator", { userAgent }); + vi.resetModules(); + // Platform detection happens at module load, after this test supplies the agent. + const { acceleratorLabel } = await import("./keys"); + expect(acceleratorLabel("CommandOrControl+Shift+Space")).toBe(primary); + expect(acceleratorLabel("Control+Alt+Up")).toBe(explicit); + expect(acceleratorLabel("")).toBe(""); +}); diff --git a/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx b/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx new file mode 100644 index 0000000000..2231ad1d18 --- /dev/null +++ b/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { boot, STORAGE, t } from "../i18n"; +import type { GlobalShortcutInfo } from "../port/host"; +import { acceleratorLabel } from "./keys"; + +const fixture = vi.hoisted(() => ({ shell: true, now: { binding: "", status: "disabled" } as GlobalShortcutInfo, apply: vi.fn(), read: vi.fn() })); +vi.mock("../port/host", () => ({ host: () => ({ inShell: () => fixture.shell, globalShortcut: fixture.read, setGlobalShortcut: fixture.apply }) })); +import { GlobalShortcutSection } from "./GlobalShortcutSection"; + +beforeEach(() => { + localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot(); + fixture.shell = true; fixture.now = { binding: "", status: "disabled" }; + fixture.read.mockImplementation(async () => fixture.now); + fixture.apply.mockReset(); +}); +afterEach(cleanup); + +it("is absent in a browser and cannot configure a remote host", async () => { + fixture.shell = false; fixture.now = { binding: "", status: "unsupported" }; + render(); + await Promise.resolve(); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(fixture.apply).not.toHaveBeenCalled(); +}); + +it("applies immediately through the shell, then disables without localStorage state", async () => { + fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" })); + render(); + await screen.findByText(t("全局快捷键已关闭")); + await userEvent.type(screen.getByRole("textbox"), "Control+Shift+Space"); + await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") })); + expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space"); + expect((await screen.findByRole("status")).textContent).toContain(acceleratorLabel("Control+Shift+Space")); + expect(localStorage.length).toBe(1); + await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") })); + expect(fixture.apply).toHaveBeenLastCalledWith(""); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe(""); + expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭")); +}); + +it.each(["invalid", "unavailable", "save"] as const)("shows %s rebind failure beside the still-active old binding", async error => { + fixture.now = { binding: "Control+Space", status: "registered" }; + fixture.apply.mockResolvedValue({ ...fixture.now, error }); + render(); + await screen.findByText(t("已注册:{value}", { value: acceleratorLabel("Control+Space") })); + await userEvent.clear(screen.getByRole("textbox")); + await userEvent.type(screen.getByRole("textbox"), "occupied"); + await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") })); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space")); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("occupied"); +}); + +it("reports startup failure and lets the person retry the saved binding", async () => { + fixture.now = { binding: "Control+Space", status: "unavailable" }; + fixture.apply.mockResolvedValue({ binding: "Control+Space", status: "registered" }); + render(); + await screen.findByText(t("系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。")); + await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") })); + expect(fixture.apply).toHaveBeenCalledWith("Control+Space"); + expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space")); +}); + +it("does not call an IPC transport failure a successful registration", async () => { + fixture.apply.mockRejectedValue(new Error("shell unavailable")); + render(); + await screen.findByText(t("全局快捷键已关闭")); + await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") })); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭")); +}); + +it("records a modifier chord without submitting it, presents it, and clears it", async () => { + fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" })); + render(); + await screen.findByText(t("全局快捷键已关闭")); + await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") })); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(document.activeElement).toBe(input); + expect(input.readOnly).toBe(true); + fireEvent.keyDown(input, { key: "c", code: "KeyC" }); + fireEvent.keyDown(input, { key: "A", code: "KeyA", shiftKey: true }); + fireEvent.keyDown(input, { key: " ", code: "Space", shiftKey: true }); + expect(input.value).toBe(""); + expect(input.readOnly).toBe(true); + fireEvent.keyDown(input, { key: "Control", ctrlKey: true }); + expect(input.value).toBe(""); + fireEvent.keyDown(input, { key: " ", code: "Space", ctrlKey: true, shiftKey: true }); + expect(input.value).toBe("Control+Shift+Space"); + expect(input.readOnly).toBe(false); + expect(screen.getByText(acceleratorLabel("Control+Shift+Space")).tagName).toBe("KBD"); + expect(fixture.apply).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") })); + expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space"); + await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") })); + expect(fixture.apply).toHaveBeenLastCalledWith(""); + expect(input.value).toBe(""); +}); + +it("can cancel capture with Escape or Tab without changing the existing choice", async () => { + fixture.now = { binding: "Control+Space", status: "registered" }; + render(); + await screen.findByRole("status"); + const input = screen.getByRole("textbox") as HTMLInputElement; + for (const key of ["Escape", "Tab"]) { + await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") })); + await userEvent.keyboard(`{${key}}`); + expect(input.readOnly).toBe(false); + expect(input.value).toBe("Control+Space"); + } + expect(fixture.apply).not.toHaveBeenCalled(); +}); + +it("records Command, Option, navigation and plus keys without Electron grammar", async () => { + render(); + await screen.findByRole("status"); + const input = screen.getByRole("textbox") as HTMLInputElement; + for (const [key, binding] of [["ArrowUp", "Alt+Super+Up"], ["+", "Alt+Super+Plus"]]) { + await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") })); + fireEvent.keyDown(input, { key, altKey: true, metaKey: true }); + expect(input.value).toBe(binding); + } + expect(fixture.apply).not.toHaveBeenCalled(); +}); + +it.each([ + [{ key: "˚", code: "KeyK", altKey: true }, "Alt+K"], + [{ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }, "Control+Shift+1"], + [{ key: "?", code: "Slash", ctrlKey: true, shiftKey: true }, "Control+Shift+/"], +])("records a chord when the layout produces $key", async (event, binding) => { + render(); + await screen.findByRole("status"); + await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") })); + const input = screen.getByRole("textbox") as HTMLInputElement; + fireEvent.keyDown(input, event); + expect(input.value).toBe(binding); + expect(fixture.apply).not.toHaveBeenCalled(); +}); diff --git a/desktop/frontend-next/src/ui/graphics-search.interaction.test.tsx b/desktop/frontend-next/src/ui/graphics-search.interaction.test.tsx index 1521c676c6..34f2cdf86f 100644 --- a/desktop/frontend-next/src/ui/graphics-search.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/graphics-search.interaction.test.tsx @@ -13,7 +13,7 @@ import type { SessionStatus } from "../port/port"; const answer: { now: GraphicsInfo | null } = { now: null }; vi.mock("../port/host", async (original) => { const real = await original(); - return { ...real, host: () => ({ ...real.host(), graphics: () => Promise.resolve(answer.now) }) }; + return { ...real, host: () => Object.assign(Object.create(real.host()), { graphics: () => Promise.resolve(answer.now) }) }; }); afterEach(() => { @@ -23,7 +23,7 @@ afterEach(() => { boot(); }); -async function search(q: string) { +async function search(q: string, anchor = "graphics") { render(); await act(async () => {}); await userEvent.type(screen.getByRole("textbox", { name: t("搜索设置") }), q); - return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === "graphics"); + return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === anchor); } it("does not offer the graphics setting in a browser tab", async () => { @@ -45,3 +45,7 @@ it("offers the graphics setting where the shell answers", async () => { answer.now = { launchedOff: false, savedOff: false, compositing: "enabled" }; expect(await search("gpu")).toBe(true); }); + +it("does not offer a local shell global shortcut in a browser or network viewer", async () => { + expect(await search("global", "global-shortcut")).toBe(false); +}); diff --git a/desktop/frontend-next/src/ui/keys.ts b/desktop/frontend-next/src/ui/keys.ts index 8f39047ac4..0612ce1a14 100644 --- a/desktop/frontend-next/src/ui/keys.ts +++ b/desktop/frontend-next/src/ui/keys.ts @@ -15,6 +15,13 @@ export function chord(key: string): string { return mac ? `⌘${key}` : `Ctrl ${key}`; } +export function acceleratorLabel(binding: string): string { + const names: Record = mac + ? { CommandOrControl: "⌘", CmdOrCtrl: "⌘", Command: "⌘", Cmd: "⌘", Control: "⌃", Ctrl: "⌃", Alt: "⌥", Option: "⌥", Shift: "⇧", Super: "⌘", Meta: "⌘", Space: "Space", Up: "↑", Down: "↓", Left: "←", Right: "→" } + : { CommandOrControl: "Ctrl", CmdOrCtrl: "Ctrl", Control: "Ctrl", Command: "Super", Cmd: "Super", Meta: "Super", Option: "Alt", Up: "↑", Down: "↓", Left: "←", Right: "→" }; + return binding.split("+").map(part => names[part] ?? part).join(mac ? "" : "+"); +} + // asksDelete reads the key that deletes whatever holds focus: Delete everywhere, // and ⌘⌫ on macOS, whose main key sends Backspace. export function asksDelete(ev: { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean { diff --git a/desktop/frontend-next/src/ui/prefsnav.tsx b/desktop/frontend-next/src/ui/prefsnav.tsx index 41756de27e..8b701a80d2 100644 --- a/desktop/frontend-next/src/ui/prefsnav.tsx +++ b/desktop/frontend-next/src/ui/prefsnav.tsx @@ -319,6 +319,7 @@ export const SETTINGS: SettingEntry[] = [ { section: "appearance", anchor: "legacy-sessions", title: "1.x 旧会话", scope: "machine", apply: "immediate", keywords: ["1.x", "旧会话", "迁移", "隐藏", "legacy", "sessions", "sidebar"] }, { section: "appearance", anchor: "live-grace", title: "进行中列表保留已完成会话", scope: "machine", apply: "immediate", keywords: ["进行中", "完成", "保留", "过滤", "running", "grace", "sidebar"] }, { section: "appearance", anchor: "send-shortcut", title: "发送快捷键", scope: "machine", apply: "immediate", keywords: ["发送", "回车", "换行", "enter", "ctrl", "cmd"] }, + { section: "appearance", anchor: "global-shortcut", title: "全局显示 / 隐藏快捷键", scope: "machine", apply: "immediate", keywords: ["global", "show", "hide", "shortcut", "keyboard", "全局", "显示", "隐藏"] }, { section: "appearance", anchor: "keys", title: "快捷键", scope: "machine", apply: "immediate", keywords: ["shortcut", "keyboard", "ctrl", "cmd", "esc", "home", "查找", "搜索", "运行分析", "工作台"] }, { section: "appearance", anchor: "esc-stop", title: "Esc 停止任务", scope: "machine", apply: "immediate", keywords: ["esc", "停止", "中断", "确认", "误触", "stop"] }, { section: "appearance", anchor: "page-sleep", title: "空闲网页休眠", scope: "machine", apply: "immediate", keywords: ["休眠", "睡眠", "空闲", "内存", "收起", "browser", "page", "sleep", "memory"] }, diff --git a/desktop/frontend-next/src/ui/useSettingsSearch.ts b/desktop/frontend-next/src/ui/useSettingsSearch.ts index 7ac8725a12..e5a3431f57 100644 --- a/desktop/frontend-next/src/ui/useSettingsSearch.ts +++ b/desktop/frontend-next/src/ui/useSettingsSearch.ts @@ -2,16 +2,18 @@ import { useMemo } from "react"; import { SETTINGS, settingMatches } from "./prefsnav"; import type { Section } from "./prefsnav"; import { useShellGraphics } from "./GraphicsSection"; +import { host } from "../port/host"; /** The settings a query finds. `shown` decides which sections can be shown and * `shownKey` is what changes its answer; the graphics entry exists only where * the shell answers for graphics. */ export function useSettingsSearch(query: string, shown: (id: Section) => boolean, shownKey: unknown) { const shell = useShellGraphics(); + const localShell = host().inShell(); return useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return null; - return SETTINGS.filter((e) => !e.hidden && shown(e.section) && (e.anchor !== "graphics" || shell !== null) && settingMatches(e, q)); + return SETTINGS.filter((e) => !e.hidden && shown(e.section) && (e.anchor !== "graphics" || shell !== null) && (e.anchor !== "global-shortcut" || localShell) && settingMatches(e, q)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, shownKey, shell]); + }, [query, shownKey, shell, localShell]); } diff --git a/release-notes/studio/unreleased/8290-global-shortcut.md b/release-notes/studio/unreleased/8290-global-shortcut.md new file mode 100644 index 0000000000..c1c44bb2f2 --- /dev/null +++ b/release-notes/studio/unreleased/8290-global-shortcut.md @@ -0,0 +1,6 @@ +- 新增:桌面设置提供默认关闭的全局显示 / 隐藏快捷键,立即注册并显示失败状态;绑定保存在本机 shell 配置中,仅 Studio 运行时有效。关联 #8290。 +- 限制:不会启动已退出的应用;浏览器与网络查看器不能配置远程主机快捷键。关联 #7623。 +- Added: an opt-in global show/hide shortcut in desktop settings, with immediate registration and visible failure status. The binding belongs to the local shell profile. Refs #8290. +- Limits: works only while Studio runs, without launching a stopped app. Browser and network viewers cannot configure a remote host shortcut. Refs #7623. +- 改进:快捷键支持按键录制、本机符号显示与清除;shell 拒绝没有主修饰键或仅有 Shift 的绑定。关联 #8290。 +- Improved: record a key combination with platform-native labels and Clear; the shell requires Control, Command, Alt or Super, not Shift alone. Refs #8290. From d21e4bebbf71f0883b41d75947f1c9e40817d4b7 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Tue, 27 Oct 2026 01:30:51 -0700 Subject: [PATCH 31/37] feat(desktop): long settings sections split into pages in the navigation --- desktop/frontend-next/perf/web-access.mjs | 1 + desktop/frontend-next/src/actions.ts | 1 + desktop/frontend-next/src/i18n/en_settings.ts | 8 ++ desktop/frontend-next/src/styles/app.css | 4 +- desktop/frontend-next/src/ui/Settings.tsx | 14 ++- desktop/frontend-next/src/ui/SettingsNav.tsx | 21 +++- .../src/ui/SettingsPageStyle.tsx | 16 +++ .../src/ui/navrail.interaction.test.tsx | 3 +- .../frontend-next/src/ui/settingPages.test.ts | 34 ++++++ desktop/frontend-next/src/ui/settingPages.ts | 36 ++++++ .../ui/settings-pages.interaction.test.tsx | 104 ++++++++++++++++++ .../ui/settings-scroll.interaction.test.tsx | 2 +- .../ui/settings-search.interaction.test.tsx | 2 +- .../studio/unreleased/14625-settings-pages.md | 2 + 14 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 desktop/frontend-next/src/ui/SettingsPageStyle.tsx create mode 100644 desktop/frontend-next/src/ui/settingPages.test.ts create mode 100644 desktop/frontend-next/src/ui/settingPages.ts create mode 100644 desktop/frontend-next/src/ui/settings-pages.interaction.test.tsx create mode 100644 release-notes/studio/unreleased/14625-settings-pages.md diff --git a/desktop/frontend-next/perf/web-access.mjs b/desktop/frontend-next/perf/web-access.mjs index 98df34562f..3f7059870e 100644 --- a/desktop/frontend-next/perf/web-access.mjs +++ b/desktop/frontend-next/perf/web-access.mjs @@ -23,6 +23,7 @@ for (const scheme of ["light", "dark"]) { await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click()); await page.waitForSelector(".prefs-nav button"); await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click()); + await page.evaluate(() => document.getElementById("prefs-tools-web")?.click()); await page.waitForSelector("#set-web-access"); const r = await page.evaluate(() => { const g = document.querySelector("#set-web-access"); diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index ff66c5bd3a..f6a4c8e8ff 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -594,6 +594,7 @@ export const ACTIONS: UIAction[] = [ { id: "appearance.effects", kind: "view", target: "none", proof: "browser" }, { id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" }, { id: "settings.section", kind: "navigation", target: "none", proof: "interaction" }, + { id: "settings.page", kind: "navigation", target: "none", proof: "interaction" }, // Finding a setting changes what is on screen and nothing else: it reaches // no kernel state, and the jump it offers is settings.section, because // arriving from a search is the same arrival as arriving from the nav. diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index a40d835b08..11791f612d 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -1006,6 +1006,14 @@ export const EN_SETTINGS: Record = { "关闭窗口后在托盘中继续运行": "Keep running in the tray when the window is closed", "关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序": "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely", "本轮执行": "This turn", + "批准与规则": "Approval and rules", + "沙箱与执行": "Sandbox and execution", + "联网与浏览器": "Web and browser", + "子代理与协作": "Sub-agents and collaboration", + "外观与主题": "Appearance and theme", + "会话显示": "Conversation display", + "导航与侧栏": "Navigation and sidebar", + "窗口与性能": "Window and performance", "运行环境": "Where it runs", "记录与用量": "What it remembers", "这台机器": "This machine", diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 66f4f7118e..fe894e861e 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -2512,7 +2512,7 @@ background: var(--overlay); font-weight: var(--w-med); } .prefs-nav button[aria-selected="true"] svg { color: var(--text) } - .prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } + .prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } .prefs-nav button[data-open] { font-weight: var(--w-med) } .prefs-nav button.prefs-page { min-height: 30px; padding-left: 34px } .prefs-nav .nv { flex: 0 99 auto; min-width: 0; max-width: 74px; overflow: hidden; text-overflow: ellipsis; @@ -2643,7 +2643,7 @@ } .prefs-nav::-webkit-scrollbar { display: none } .prefs-nav .navsec, .prefs-nav .nv { display: none } - .prefs-nav button { flex: none; min-height: 32px; padding: 6px 10px } + .prefs-nav button, .prefs-nav button.prefs-page { flex: none; min-height: 32px; padding: 6px 10px } .prefs-nav button::before { left: 50%; top: auto; bottom: 0; width: 0; height: 2px; transform: translateX(-50%); transition: width var(--t-card) var(--ease-spring); diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index bd2aec71bb..4d3f62d38f 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -14,6 +14,8 @@ import { bytes, tokens as fmtTokens } from "../i18n/format"; import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally"; import { SECTION_NAME } from "./prefsnav"; import { SettingsNav } from "./SettingsNav"; +import { SettingsPageStyle } from "./SettingsPageStyle"; +import { firstPage, pageOf } from "./settingPages"; import { Group, SCOPE_SAID } from "./Group"; import { PlanApproval, DefaultMode } from "./PlanApproval"; import { PeersSection } from "./PeersSection"; @@ -93,6 +95,11 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, const localPort = localWorkspacePort ?? homePort; const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2); const [at, setAt] = useState
    ((openedSection as Section) || "session"); + const [pages, setPages] = useState>>(() => { + const first = pageOf((openedSection as Section) || "session", openedAnchor); + return first ? { [(openedSection as Section) || "session"]: first } : {}; + }); + const pageNow = pages[at] ?? firstPage(at); // 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到 // 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。 const [query, setQuery] = useState(""); @@ -316,6 +323,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, const go = (section: Section, anchor: string) => { navigation.leave(() => { setAt(section); + const target = pageOf(section, anchor); + if (target) setPages((p) => ({ ...p, [section]: target })); setQuery(""); setLanded(anchor); if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed"); @@ -401,9 +410,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
    - { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} /> + { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} page={pageNow} onPage={(section, page) => navigation.leave(() => { setLanded(""); setPages((p) => ({ ...p, [section]: page })); })} /> -
    +
    @@ -415,6 +424,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, {/* Keyed on the section: replacing this column is the navigation, and it is the one thing here whose mount really is the cause. */}
    + {/* Every refused write on every page lands in one state, so it is said in one place. Copied onto the pages somebody remembered, it diff --git a/desktop/frontend-next/src/ui/SettingsNav.tsx b/desktop/frontend-next/src/ui/SettingsNav.tsx index 80c839951f..5f2f1444cf 100644 --- a/desktop/frontend-next/src/ui/SettingsNav.tsx +++ b/desktop/frontend-next/src/ui/SettingsNav.tsx @@ -2,6 +2,7 @@ import { Fragment } from "react"; import { t } from "../i18n"; import { ICON, NAV, type Section } from "./prefsnav"; import { arrowTabs } from "./tablist"; +import { pagesOf } from "./settingPages"; import { UpdateDot } from "./UpdateDot"; interface Props { @@ -11,9 +12,11 @@ interface Props { danger: (id: Section) => boolean; disabled: boolean; onSelect: (id: Section) => void; + page?: string; + onPage?: (section: Section, page: string) => void; } -export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: Props) { +export function SettingsNav({ at, values, shown, danger, disabled, onSelect, page, onPage }: Props) { return (