diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4c1562b27..b22fb30692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -700,6 +700,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 @@ -761,6 +793,34 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: test (Windows local cost quote) + if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + shell: bash + run: | + set -o pipefail + go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl" + jq -se ' + ["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak", + "TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown", + "TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks", + "TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak", + "TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required | + [.[] | select(.Test != null and .Action == "pass") | .Test] as $passed | + [.[] | select(.Test == null and .Action == "pass") | .Package] as $packages | + all(.[]; .Action != "fail" and .Action != "skip") and + (($required - $passed) | length == 0) and + ((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0) + ' "$RUNNER_TEMP/local-cost-quote.jsonl" + + - name: upload Windows local cost quote inventory + if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: local-cost-quote-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/local-cost-quote.jsonl + if-no-files-found: error + retention-days: 7 + - name: test (Windows estimated usage quotes) if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' timeout-minutes: 5 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/assets/installer.nsh b/desktop/electron/assets/installer.nsh index 3e91fef848..bd91e1b23e 100644 --- a/desktop/electron/assets/installer.nsh +++ b/desktop/electron/assets/installer.nsh @@ -121,3 +121,77 @@ ${EndIf} Pop $R9 !macroend + +# Closing a running copy before an install, an update or an uninstall. The +# uninstaller runs from inside the install folder, so the process counting skips +# this process by id: matching on the folder alone would find the uninstaller +# itself and end it. +!macro rxScript + FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w + FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n" + FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n" + FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n" + FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n" + FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n" + FileClose $0 +!macroend + +!macro rxFind + nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2` + Pop $R0 +!macroend + +!macro rxKill + nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2` + Pop $0 +!macroend + +!macro rxKillElevated + nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"` + Pop $0 +!macroend + +!macro customCheckAppRunning + InitPluginsDir + System::Call 'kernel32::GetCurrentProcessId() i .R2' + !insertmacro rxScript + !insertmacro rxFind + ${if} $R0 == 0 + ${ifNot} ${isUpdated} + ${andIfNot} ${Silent} + DetailPrint "$(appClosing)" + ${endIf} + StrCpy $R1 0 + rxCloseLoop: + IntOp $R1 $R1 + 1 + !insertmacro rxKill + Sleep 1000 + !insertmacro rxFind + ${if} $R0 != 0 + Goto rxClosed + ${endIf} + ${if} $R1 == 4 + ${andIfNot} ${Silent} + DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..." + !insertmacro rxKillElevated + Sleep 1000 + !insertmacro rxFind + ${if} $R0 != 0 + Goto rxClosed + ${endIf} + ${endIf} + ${if} $R1 < 6 + Goto rxCloseLoop + ${endIf} + StrCmp $LANGUAGE 2052 rxZh + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry + Quit + rxZh: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry + Quit + rxRetry: + StrCpy $R1 0 + Goto rxCloseLoop + rxClosed: + ${endIf} +!macroend 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/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/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/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/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/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/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/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/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/actions.ts b/desktop/frontend-next/src/actions.ts index fd071cd227..ce499a5871 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" }, @@ -296,6 +297,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" }, @@ -593,6 +595,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/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.ts b/desktop/frontend-next/src/i18n/en.ts index 5f863c0b71..d0aefdaea9 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}", @@ -603,8 +606,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", @@ -755,9 +760,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.", @@ -966,7 +968,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_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts index 87ff2d54e8..95def6f912 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", @@ -112,6 +108,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/i18n/en_kernel.ts b/desktop/frontend-next/src/i18n/en_kernel.ts index 4e9bbd08d2..e98291e73d 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.", @@ -477,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/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 068bf0211b..cde8da6056 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", @@ -127,6 +144,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", @@ -756,6 +774,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", @@ -1003,6 +1023,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", @@ -1352,6 +1380,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/i18n/en_usage.ts b/desktop/frontend-next/src/i18n/en_usage.ts index 3a7d6d4098..1d544b483a 100644 --- a/desktop/frontend-next/src/i18n/en_usage.ts +++ b/desktop/frontend-next/src/i18n/en_usage.ts @@ -31,6 +31,14 @@ export const EN_USAGE: Record = { "美元 USD": "US dollar (USD)", "无法读取币种设置。": "Could not read the currency setting.", "用量与成本": "Usage & cost", + "上传量": "Upload volume", + "发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。": + "The size of the request bodies sent to model providers (without the connection's own overhead, and without downloads). Every request carries the whole conversation, so it depends on how long the context is and how many requests are made; cache hits save cost, not bandwidth.", + "还没有记录:从这个版本起开始统计。": "Nothing recorded yet: counting starts with this version.", + "今天上传": "Uploaded today", + "近 {n} 天上传": "Uploaded in the last {n} days", + "{size},{n} 次请求,平均每次 {avg}": "{size} in {n} requests, {avg} each on average", + "{size}({share})": "{size} ({share})", "本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。": "Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.", "正在读取记录…": "Reading the records…", @@ -62,6 +70,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/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index 7e8f01b29d..87c9a513c2 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 这些常见封装。", @@ -170,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/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/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/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/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index 2d407e4d5e..3623569380 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -3,7 +3,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import type { JobOutput, PlanAction } from "./session"; import { approvalKind } from "./mock_approval"; import { HttpError } from "./port"; -import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port"; +import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, UploadReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port"; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace"; import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture"; @@ -11,7 +11,7 @@ import { MockExecutionHold, mockExecutionGraph } from "./mock_graph"; import { mockStorage, mockStoragePlan } from "./mock_storage"; import { MEMORIES, mockMemoryCatalog } from "./mock_memory"; import { mockModels } from "./mock_models"; -import { mockUsage } from "./mock_usage"; +import { mockUploadUsage, mockUsage } from "./mock_usage"; export class MockPort extends MockWorkspace implements AgentPort { private listeners = new Set<(ev: WireEvent) => void>(); @@ -112,9 +112,9 @@ export class MockPort extends MockWorkspace implements AgentPort { private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m })); - async usage(query: number | UsageQuery): Promise { - return mockUsage(query); - } + async usage(query: number | UsageQuery): Promise { return mockUsage(query); } + + uploadUsage(days: number): Promise { return Promise.resolve(mockUploadUsage(days)); } async memories(): Promise { return mockMemoryCatalog(this.mem); @@ -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/mock_usage.ts b/desktop/frontend-next/src/port/mock_usage.ts index 0ac7b37595..deb0e36590 100644 --- a/desktop/frontend-next/src/port/mock_usage.ts +++ b/desktop/frontend-next/src/port/mock_usage.ts @@ -1,4 +1,4 @@ -import type { UsageHour, UsageQuery, UsageReport } from "./port"; +import type { UploadReport, UsageHour, UsageQuery, UsageReport } from "./port"; import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory"; import { DEFAULT_USAGE_DAYS } from "./usage"; @@ -108,3 +108,20 @@ export function mockUsage(query: number | UsageQuery): UsageReport { ], }; } + +// A fortnight of uploads with quiet days, two providers and one heavy day. +export function mockUploadUsage(days: number): UploadReport { + const shown = Math.min(days, 14); + const daily = Array.from({ length: shown }, (_, i) => ({ + day: new Date(Date.now() - (shown - 1 - i) * DAY_MS).toISOString().slice(0, 10), + bytes: [0, 18e6, 240e6, 410e6, 95e6, 0, 0, 120e6, 760e6, 330e6, 40e6, 0, 210e6, 150e6][i] ?? 0, + })); + const total = daily.reduce((n, d) => n + d.bytes, 0); + return { + from: daily[0]?.day ?? "", to: daily[daily.length - 1]?.day ?? "", bytes: total, requests: 1280, daily, + providers: [ + { provider: "deepseek", bytes: Math.round(total * 0.86), requests: 1100, percent: 86 }, + { provider: "relay-work", bytes: Math.round(total * 0.14), requests: 180, percent: 14 }, + ], + }; +} 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/port.ts b/desktop/frontend-next/src/port/port.ts index ed32db9293..10159330b6 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 @@ -20,10 +19,10 @@ export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adju import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook"; import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp"; import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory"; -import type { UsageQuery, UsageReport } from "./usage"; +import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage"; export { DEFAULT_USAGE_DAYS } from "./usage"; export type { MemoryEdit } from "./memory"; -export type { Money, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage"; +export type { Money, UploadDay, UploadProvider, UploadReport, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage"; import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model"; import type { NetworkProbe, NetworkSettings } from "./network"; import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session"; @@ -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, UploadUsagePort { 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/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/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..8b41bbe8c6 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -1,6 +1,6 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import { PLAN_ACTIONS, type PlanAction } from "./session"; -import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; +import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port"; import { SseWorkspace } from "./sse_workspace"; import type { StoragePlan, StorageQuery, StorageState } from "./storage"; @@ -83,6 +83,9 @@ export class SsePort extends SseWorkspace implements AgentPort { } return this.get("/usage?" + q); } + uploadUsage(days: number) { + return this.get(`/usage/upload?days=${days}`); + } memories() { return this.get("/memory"); } @@ -476,6 +479,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/port/usage.ts b/desktop/frontend-next/src/port/usage.ts index c232fc87ad..89223ab548 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; @@ -88,3 +92,30 @@ export interface UsageReport { models: UsageModel[]; providers: UsageProvider[]; } + +export interface UploadDay { + day: string; + bytes: number; +} + +export interface UploadProvider { + provider: string; + bytes: number; + requests: number; + percent: number; +} + +// What model providers were sent: request bodies as they went out, before the +// connection adds its own overhead. Downloads are not counted. +export interface UploadReport { + from: string; + to: string; + bytes: number; + requests: number; + daily: UploadDay[]; + providers: UploadProvider[]; +} + +export interface UploadUsagePort { + uploadUsage(days: number): Promise; +} 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/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 42b34e7eda..bcc12bff4d 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. @@ -342,6 +343,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/TOKENS.md b/desktop/frontend-next/src/styles/TOKENS.md index 405a0b35f8..93861e7e2a 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 @@ -348,26 +350,53 @@ 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 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/app.css b/desktop/frontend-next/src/styles/app.css index 18e851d127..50bb2e3830 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); } @@ -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) } @@ -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 } } @@ -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); @@ -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/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/studio.css b/desktop/frontend-next/src/styles/studio.css index e78d7481d1..eb7f56aff0 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 { @@ -1298,7 +1299,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); } @@ -1422,14 +1423,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); } @@ -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, @@ -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/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/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/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..669ca0b46f 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"; @@ -378,13 +379,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, -
- - - {t("更多个性化")} - {look.wallpaper ? t("已设置自定义壁纸") : t("壁纸与背景位置")} - -

{t("壁纸")}

@@ -422,6 +416,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, {look.wallpaper && (
-
@@ -561,6 +556,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, + 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.tsx b/desktop/frontend-next/src/ui/Composer.tsx index d5b68ecde5..12293b6929 100644 --- a/desktop/frontend-next/src/ui/Composer.tsx +++ b/desktop/frontend-next/src/ui/Composer.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { t } from "../i18n"; -import { UNCONFIGURED_REF } from "./ConnectNote"; import { reason } from "../i18n/kernel"; import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep"; import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port"; @@ -11,7 +10,7 @@ import { WorktreeChip } from "./WorktreeChip"; import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup"; import { WorktreeSetupPage } from "./WorktreeSetupPage"; import { Policy } from "./Policy"; -import { chosenGroups, groupHeadings, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu"; +import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu"; import { ModelGroupChips } from "./ModelGroupChips"; import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort"; import { CompletionMenu, useCompletion } from "./Completion"; @@ -44,6 +43,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 +79,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 +217,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 @@ -389,9 +389,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus, // composer answering for an endpoint that never spoke. const declared = efforts.length > 0; const forcedThinking = forcesThinkingFor(models, status?.modelRef); - const modelLb = status?.modelRef === UNCONFIGURED_REF ? t("未连接") - : models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim() - || (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—"); + const modelLb = modelLabel(models, status); // 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 // shelf says nothing can change while only one control waits on the kernel. 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/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/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/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/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)} { + 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..c265e94ebb 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 @@ -19,8 +19,10 @@ const ROLES: [RoleKey, string, string, Answers][] = [ ["decision", "决策", "system_one 询问的后端", "decision"], ]; +const shown = (m: ModelEntry) => 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; +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 +92,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,10 +219,11 @@ 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/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/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index 19699cfa5c..4d3f62d38f 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -11,8 +11,11 @@ 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 { SettingsPageStyle } from "./SettingsPageStyle"; +import { firstPage, pageOf } from "./settingPages"; import { Group, SCOPE_SAID } from "./Group"; import { PlanApproval, DefaultMode } from "./PlanApproval"; import { PeersSection } from "./PeersSection"; @@ -92,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(""); @@ -184,12 +192,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 @@ -313,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"); @@ -398,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 })); })} /> -
    +
    @@ -412,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 (
    {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/desktop/frontend-next/src/ui/UsageSettings.tsx b/desktop/frontend-next/src/ui/UsageSettings.tsx index b2f14ce00d..bcc88000e8 100644 --- a/desktop/frontend-next/src/ui/UsageSettings.tsx +++ b/desktop/frontend-next/src/ui/UsageSettings.tsx @@ -3,6 +3,7 @@ import { t } from "../i18n"; import type { AgentPort } from "../port/port"; import { DisplayCurrency } from "./DisplayCurrency"; import { Group } from "./Group"; +import { UploadUsage } from "./UploadUsage"; import { Usage } from "./Usage"; // The report is keyed on the currency choice: its amounts are read once, so a @@ -18,6 +19,7 @@ export function UsageSettings({ port, onChanged }: { port: AgentPort; onChanged: > + ); } 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/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/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/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/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. + } } 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/modelmenu.test.ts b/desktop/frontend-next/src/ui/modelmenu.test.ts index d47b440745..9d94c10085 100644 --- a/desktop/frontend-next/src/ui/modelmenu.test.ts +++ b/desktop/frontend-next/src/ui/modelmenu.test.ts @@ -1,7 +1,8 @@ import { expect, it } from "vitest"; import type { ModelEntry } from "../port/port"; import { accountKey } from "./vendors"; -import { modelMenu } from "./modelmenu"; +import { modelLabel, modelMenu } from "./modelmenu"; +import { UNCONFIGURED_REF } from "./ConnectNote"; it("groups the composer model menu by saved account order", () => { const models: ModelEntry[] = [ @@ -91,3 +92,18 @@ it("puts a relay's own mark on its heading and leaves its model rows to the lett expect(items.find((it) => it.header)?.mark).toBe("openrouter"); expect(items.find((it) => it.value === "or/x")?.mark).toBeUndefined(); }); + +it("names the session's model by alias, then short form, then the id without its provider", () => { + const models: ModelEntry[] = [ + { ref: "relay/grp/aliased", provider: "relay", vendor: "relay.example", model: "grp/aliased", modelAlias: " 快模型 ", modelShort: "aliased" }, + { ref: "relay/grp/short", provider: "relay", vendor: "relay.example", model: "grp/short", modelShort: "short" }, + { ref: "relay/plain", provider: "relay", vendor: "relay.example", model: "plain" }, + ]; + expect(modelLabel(models, { modelRef: "relay/grp/aliased" })).toBe("快模型"); + expect(modelLabel(models, { modelRef: "relay/grp/short" })).toBe("short"); + expect(modelLabel(models, { modelRef: "relay/plain" })).toBe("plain"); + expect(modelLabel(models, { modelRef: "gone/other/model", label: "x" })).toBe("other/model"); + expect(modelLabel(models, { label: "from-status" })).toBe("from-status"); + expect(modelLabel(models, null)).toBe("—"); + expect(modelLabel(models, { modelRef: UNCONFIGURED_REF })).toBe("未连接"); +}); diff --git a/desktop/frontend-next/src/ui/modelmenu.ts b/desktop/frontend-next/src/ui/modelmenu.ts index 7784d8574e..4b131966c4 100644 --- a/desktop/frontend-next/src/ui/modelmenu.ts +++ b/desktop/frontend-next/src/ui/modelmenu.ts @@ -3,6 +3,7 @@ import type { ModelEntry } from "../port/port"; import type { MenuItem } from "./Menu"; import { groupVendors } from "./Models"; import { t } from "../i18n"; +import { UNCONFIGURED_REF } from "./ConnectNote"; import { KIND_LABEL } from "./vendors"; import { orderAccounts } from "../state/providerorder"; @@ -13,6 +14,14 @@ export function modelTooltip(models: ModelEntry[], current: string | undefined, return account ? `${account.label} / ${label}` : current ?? label; } +// modelLabel is how the composer names the session's model: the alias, else the +// entry's short form, else the id without its provider. +export function modelLabel(models: ModelEntry[], status: { modelRef?: string; label?: string } | null | undefined): string { + if (status?.modelRef === UNCONFIGURED_REF) return t("未连接"); + const m = models.find((x) => x.ref === status?.modelRef); + return m?.modelAlias?.trim() || m?.modelShort || (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—"); +} + // Every account gets a heading with its endpoint: the provider name is the // config's word for an entry, not the user's for an endpoint, and two doors onto // one account share it. Rows carry the wire format beside the model name. @@ -27,7 +36,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/desktop/frontend-next/src/ui/navrail.interaction.test.tsx b/desktop/frontend-next/src/ui/navrail.interaction.test.tsx index 5e33a38ef8..dd9853427f 100644 --- a/desktop/frontend-next/src/ui/navrail.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/navrail.interaction.test.tsx @@ -60,7 +60,8 @@ const toggleSidebar = () => userEvent.click(document.querySelector( async function openAppearance() { await userEvent.click(document.querySelector('[data-action="chrome.settings"]')!); - await userEvent.click(await screen.findByRole("tab", { name: /外观/ })); + await userEvent.click(await screen.findByRole("tab", { name: /^外观/ })); + await userEvent.click(await screen.findByRole("tab", { name: /导航与侧栏/ })); await screen.findByRole("group", { name: "图标栏" }); } const choice = (name: string) => screen.getByRole("button", { name }); diff --git a/desktop/frontend-next/src/ui/prefsnav.tsx b/desktop/frontend-next/src/ui/prefsnav.tsx index 41756de27e..971a07c9e2 100644 --- a/desktop/frontend-next/src/ui/prefsnav.tsx +++ b/desktop/frontend-next/src/ui/prefsnav.tsx @@ -304,6 +304,7 @@ export const SETTINGS: SettingEntry[] = [ { section: "versions", anchor: "sponsor", title: "赞助", scope: "machine", apply: "none", keywords: ["捐助", "支付宝", "微信支付", "收款码", "paypal", "donate", "sponsor", "support", "alipay", "wechat"] }, { section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] }, { section: "usage", anchor: "currency", title: "费用显示币种", scope: "machine", apply: "immediate", keywords: ["币种", "货币", "人民币", "美元", "currency", "CNY", "USD", "汇率"] }, + { section: "usage", anchor: "upload-usage", title: "上传量", scope: "machine", apply: "none", keywords: ["流量", "上传", "带宽", "请求", "upload", "traffic", "bandwidth"] }, { section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] }, { section: "storage", anchor: "storage", title: "存储", scope: "machine", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] }, { section: "advanced", anchor: "elsewhere", title: "本版本尚未提供", scope: "machine", apply: "none", keywords: ["配置文件"] }, @@ -319,6 +320,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/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/desktop/frontend-next/src/ui/settingPages.test.ts b/desktop/frontend-next/src/ui/settingPages.test.ts new file mode 100644 index 0000000000..e407de7758 --- /dev/null +++ b/desktop/frontend-next/src/ui/settingPages.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { SETTINGS } from "./prefsnav"; +import { PAGES, pageOf, firstPage, hiddenAnchors } from "./settingPages"; + +describe("the pages of a long settings section", () => { + const paged = Object.keys(PAGES) as (keyof typeof PAGES)[]; + + it("has at least two pages wherever it has any", () => { + for (const section of paged) expect(PAGES[section]!.length, section).toBeGreaterThanOrEqual(2); + }); + + it("files every setting of a paged section on exactly one page", () => { + for (const section of paged) { + const anchors = SETTINGS.filter((e) => e.section === section).map((e) => e.anchor); + const filed = PAGES[section]!.flatMap((p) => p.anchors); + expect([...filed].sort(), `${section}: every row on one page, no page naming a row it does not have`).toEqual([...anchors].sort()); + } + }); + + it("answers which page an anchor is on, and the first page of a section", () => { + expect(pageOf("tools", "sandbox")).toBe("sandbox"); + expect(pageOf("tools", "approval")).toBe(firstPage("tools")); + expect(pageOf("session", "preset")).toBeUndefined(); + expect(firstPage("session")).toBeUndefined(); + }); + + it("hides everything a page does not own and nothing it does", () => { + const hidden = hiddenAnchors("tools", "sandbox"); + expect(hidden).toContain("approval"); + expect(hidden).not.toContain("sandbox"); + expect(hidden).not.toContain("shell"); + expect(hiddenAnchors("session", "anything")).toEqual([]); + }); +}); diff --git a/desktop/frontend-next/src/ui/settingPages.ts b/desktop/frontend-next/src/ui/settingPages.ts new file mode 100644 index 0000000000..95d1a63d88 --- /dev/null +++ b/desktop/frontend-next/src/ui/settingPages.ts @@ -0,0 +1,36 @@ +import type { Section } from "./prefsnav"; + +export interface SettingPage { + id: string; + label: string; + anchors: readonly string[]; +} + +export const PAGES: Partial> = { + tools: [ + { id: "approval", label: "批准与规则", anchors: ["approval", "plan-approval", "external-origin", "rules", "tool-list", "present-files"] }, + { id: "sandbox", label: "沙箱与执行", anchors: ["sandbox", "shell"] }, + { id: "web", label: "联网与浏览器", anchors: ["web-access", "browser-tools"] }, + { id: "subagents", label: "子代理与协作", anchors: ["subagent-claims", "subagent-steer", "write-lease"] }, + ], + appearance: [ + { id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance", "graphics"] }, + { id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] }, + { id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "folding"] }, + { id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "global-shortcut", "keys", "esc-stop"] }, + { id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep"] }, + ], +}; + +export const pagesOf = (section: Section): readonly SettingPage[] => PAGES[section] ?? []; + +export const firstPage = (section: Section): string | undefined => PAGES[section]?.[0]?.id; + +export const pageOf = (section: Section, anchor: string): string | undefined => + PAGES[section]?.find((p) => p.anchors.includes(anchor))?.id; + +export const hiddenAnchors = (section: Section, page: string): string[] => { + const pages = PAGES[section]; + if (!pages) return []; + return pages.filter((p) => p.id !== page).flatMap((p) => [...p.anchors]); +}; 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-pages.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-pages.interaction.test.tsx new file mode 100644 index 0000000000..1a264991f0 --- /dev/null +++ b/desktop/frontend-next/src/ui/settings-pages.interaction.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Settings } from "./Settings"; +import type { SessionStatus } from "../port/port"; +import { MockPort } from "../port/mock"; +import { MockHub } from "../port/mock_hub"; + +afterEach(cleanup); + +function draw(at?: string) { + render( + {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}} + look={{} as never} onLook={() => {}} reloadThemes={() => {}} + onClose={vi.fn()} onChanged={() => {}} onError={() => {}} + account={null} accountUnread="" reloadAccount={() => {}} + />, + ); +} + +const shown = (id: string) => { + const el = document.querySelector(`[data-setting="${id}"]`); + return !!el && getComputedStyle(el).display !== "none"; +}; + +describe("pages inside the tools section", () => { + it("opens on its first page and shows only what is filed there", async () => { + draw("tools"); + await screen.findByRole("tab", { name: /批准与规则/ }); + expect(shown("approval")).toBe(true); + expect(shown("sandbox")).toBe(false); + expect(shown("web-access")).toBe(false); + }); + + it("moves to another page from the navigation and back", async () => { + draw("tools"); + await userEvent.click(await screen.findByRole("tab", { name: /沙箱与执行/ })); + await waitFor(() => expect(shown("sandbox")).toBe(true)); + expect(shown("approval")).toBe(false); + await userEvent.click(screen.getByRole("tab", { name: /批准与规则/ })); + await waitFor(() => expect(shown("approval")).toBe(true)); + expect(shown("sandbox")).toBe(false); + }); + + it("starts a newly chosen page at the top", async () => { + draw("tools"); + await screen.findByRole("tab", { name: /沙箱与执行/ }); + screen.getByRole("tabpanel").scrollTop = 900; + await userEvent.click(screen.getByRole("tab", { name: /沙箱与执行/ })); + await waitFor(() => expect(shown("sandbox")).toBe(true)); + expect(screen.getByRole("tabpanel").scrollTop).toBe(0); + }); + + it("lands on the page a deep link names", async () => { + draw("tools:sandbox"); + await waitFor(() => expect(shown("sandbox")).toBe(true)); + expect(shown("approval")).toBe(false); + expect(screen.getByRole("tab", { name: /沙箱与执行/ }).getAttribute("aria-selected")).toBe("true"); + }); + + it("lands on the page a search result is on", async () => { + draw("tools"); + await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "内置浏览器"); + await userEvent.click(await screen.findByRole("option", { name: /内置浏览器/ })); + await waitFor(() => expect(shown("browser-tools")).toBe(true)); + expect(shown("approval")).toBe(false); + }); + + it("gives a short section no pages", async () => { + draw("session"); + await waitFor(() => expect(shown("preset")).toBe(true)); + expect(document.querySelectorAll(".prefs-page")).toHaveLength(0); + expect(shown("preset")).toBe(true); + }); +}); + +describe("pages inside the appearance section", () => { + it("splits it into pages and keeps each setting on one", async () => { + draw("appearance"); + await screen.findByRole("tab", { name: /导航与侧栏/ }); + expect(shown("mode")).toBe(true); + expect(shown("send-shortcut")).toBe(false); + await userEvent.click(screen.getByRole("tab", { name: /快捷键/ })); + await waitFor(() => expect(shown("send-shortcut")).toBe(true)); + expect(shown("mode")).toBe(false); + }); +}); + +describe("what wraps a setting leaves with it", () => { + it("hides the disclosure around a setting together with the setting", async () => { + draw("appearance:navrail"); + await waitFor(() => expect(shown("navrail")).toBe(true)); + const style = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n"); + expect(style).toContain('details:has(> [data-setting="wallpaper"])'); + expect(style).toContain('details:has(> [data-setting="advanced-appearance"])'); + }); +}); diff --git a/desktop/frontend-next/src/ui/settings-scroll.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-scroll.interaction.test.tsx index a4477025b3..6cb22f4a12 100644 --- a/desktop/frontend-next/src/ui/settings-scroll.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/settings-scroll.interaction.test.tsx @@ -35,7 +35,7 @@ it.each([ it("starts a keyboard-selected category at the top and keeps focus on its tab", async () => { draw("tools"); - const current = screen.getByRole("tab", { name: /^工具与权限/ }); + const current = screen.getByRole("tab", { name: /子代理与协作/ }); current.focus(); screen.getByRole("tabpanel").scrollTop = 1600; await userEvent.keyboard("{ArrowDown}"); diff --git a/desktop/frontend-next/src/ui/settings-search.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-search.interaction.test.tsx index a7c06a488f..a8c9337e7a 100644 --- a/desktop/frontend-next/src/ui/settings-search.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/settings-search.interaction.test.tsx @@ -54,7 +54,7 @@ describe("finding a setting", () => { expect(block("present-files")?.previousElementSibling?.getAttribute("data-setting")).toBe("approval"); expect((await screen.findByRole("switch", { name: "启用 AI 文件交付卡片" })).getAttribute("aria-checked")).toBe("false"); expect(block("present-files")?.querySelector("[data-apply]")?.getAttribute("data-apply")).toBe("on-open"); - expect(screen.getByRole("switch", { name: "启用内置浏览器" })).toBeTruthy(); + expect(screen.queryByRole("switch", { name: "启用内置浏览器" })).toBeNull(); }); it("finds the mode picker visibility switch on the persona management page", async () => { const { find, block } = draw(); 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/desktop/frontend-next/src/ui/theme.test.ts b/desktop/frontend-next/src/ui/theme.test.ts index 5e13b5438d..bda7416604 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"); @@ -262,3 +276,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/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/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/desktop/frontend-next/src/ui/useTabClose.ts b/desktop/frontend-next/src/ui/useTabClose.ts index 55c9a4cb4a..6547d51cc3 100644 --- a/desktop/frontend-next/src/ui/useTabClose.ts +++ b/desktop/frontend-next/src/ui/useTabClose.ts @@ -51,6 +51,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(); }); 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/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/desktop/go.mod b/desktop/go.mod index 1e563254e3..a261e01936 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -32,7 +32,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/text v0.43.0 // indirect modernc.org/libc v1.79.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/desktop/go.sum b/desktop/go.sum index 77c409d718..c9492e1bca 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -66,8 +66,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= 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/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/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/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/docs/LOCAL_COST_QUOTE.md b/docs/LOCAL_COST_QUOTE.md new file mode 100644 index 0000000000..5056f1af32 --- /dev/null +++ b/docs/LOCAL_COST_QUOTE.md @@ -0,0 +1,79 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-28 +--- + +# Local task cost quotes / 本地任务费用报价 + +## Behavior / 行为 + +An Agent's existing task cost gate consumes the same occurrence-time quote +sent to accounting sinks. Vendor schedules, configured billing identity and +the original price-book currency come from that quote's owner. Delivery reuses +the quote; it does not read the clock or price the request a second time. + +Agent 的既有任务费用门禁消费与记账事件相同的发生时刻报价。供应商费率时段、 +配置的计费身份和原价目表币种由报价 owner 决定。事件交付复用该报价,不再次 +读取时钟或重新定价。 + +At the repository's existing peak-rate fixture, a CNY 30 local limit now stops +after the first CNY 36 tool round, then performs the existing finalization +request. Previously the gate counted CNY 18 and allowed a second tool round. +The off-peak and custom-relay controls keep their own rates. + +在仓库已有高峰费率 fixture 中,30 CNY 的本地限额会在首个 36 CNY 工具轮后 +停止,再执行既有收尾请求。旧门禁按 18 CNY 累计,因此会允许第二个工具轮。 +离峰与自定义 relay 对照仍使用各自费率。 + +## Accounting boundary / 记账边界 + +| Surface | Behavior / 行为 | +| --- | --- | +| Accumulation | Fixed-point `pricing.Amount` per original currency / 按原币种用固定点金额累计 | +| Display-currency preference | Does not select the spend bucket / 不选择支出累计桶 | +| Model changes currency | Keep separate buckets; compare the current quoted currency / 保留分币种桶,比较当前报价币种 | +| Mixed-currency scalar telemetry | Current bucket only, completeness false / 仅当前桶,完整性为 false | +| Missing or estimated usage | Preserve known subtotal and incomplete coverage / 保留已知小计与不完整状态 | +| Explicit reported zero | Retain the existing known-zero local reading / 保留既有已报告零值语义 | +| Nil or legacy sink | Quote only its supplied rate card; no catalog guess / 只使用提供的价目表,不猜测官方目录 | +| New task, reset and session lifetime | Existing budget lifecycle / 沿用既有预算生命周期 | + +`UsageQuoter` is an optional internal Go sink capability forwarded by the +standard wrappers. `CostQuoteSink` owns it. The normal Agent loop consumes a +snapshot of the quote before downstream sinks can modify the emitted value. + +The public cost sample remains a float compatibility projection of fixed-point +money; arithmetic no longer sums legacy floating-point costs. + +`UsageQuoter` 是标准包装器转发的可选内部 Go sink 能力,由 `CostQuoteSink` +拥有。普通 Agent 循环在下游 sink 可修改事件之前保留报价快照。公开 cost sample +仍是固定点金额的浮点兼容投影;累计运算不再累加旧浮点费用。 + +No configuration, persisted schema, wire field, provider request, cache prefix, +permission or cancellation authority is added. Already-recorded totals are not +repaired. Custom wrappers must forward the optional quoting capability to retain +their authoritative source; legacy fallback does not infer vendor identity. + +不新增配置、持久化 schema、wire 字段、provider 请求、缓存前缀、权限或取消权限。 +不追溯修复已记录统计。自定义包装器必须转发可选报价能力,才能保留权威来源; +旧 sink 的回退不推断供应商身份。 + +This is a local after-tool soft gate. In-flight work and finalization can exceed +the limit. Shared root money, auxiliary cost aggregation, currency reservation, +durable billing and process quiescence remain separate unfinished work. + +这是本地工具轮后的软门禁;在途工作与收尾仍可越过限额。共享根费用、辅助调用 +费用汇总、按币种预留、持久化账单和进程静默仍是独立的未完成工作。 + +## Verification / 验证 + +Real Agent and Boot assembly tests use scripted providers, actual fixture reads, +the production catalog-host mapping and a fixed quote clock. They cover peak, +off-peak and relay behavior. Unit tests cover quote reuse, wrapper forwarding, +legacy/nil sinks, original currency buckets and fixed-point thresholds. + +真实 Agent 与 Boot 装配测试使用脚本 provider、实际 fixture 读取、生产目录宿主 +映射与固定报价时钟,覆盖高峰、离峰和 relay 行为。单元测试覆盖报价复用、包装器 +转发、旧/nil sink、原币种桶及固定点阈值。这不是线上账单或真实供应商请求验证。 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/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/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/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/WORKTREE_SNAPSHOT_SECURITY.md b/docs/WORKTREE_SNAPSHOT_SECURITY.md new file mode 100644 index 0000000000..b2068d7705 --- /dev/null +++ b/docs/WORKTREE_SNAPSHOT_SECURITY.md @@ -0,0 +1,72 @@ +--- +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 不参与私有索引重建。 + +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 + 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. +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/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/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/go.mod b/go.mod index ab0d373291..a86db3e385 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/mod v0.42.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 diff --git a/go.sum b/go.sum index ff2df15510..44a784579c 100644 --- a/go.sum +++ b/go.sum @@ -167,8 +167,8 @@ golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/assembly/boot/costquote_wiring.go b/internal/assembly/boot/costquote_wiring.go index 54887ab97f..ad23e23c87 100644 --- a/internal/assembly/boot/costquote_wiring.go +++ b/internal/assembly/boot/costquote_wiring.go @@ -21,6 +21,7 @@ func quotedSink(cfg *config.Config, opts Options, trace *otlp.Exporter) (event.S quoted := otlp.NewSink(opts.Sink, trace, "reasonix") if opts.StatsSource.Valid() { quoted = stats.NewRecorder(quoted, opts.roots().StatsDir(), opts.StatsSource.String()) + stats.StartUploadMeter(opts.roots().StatsDir()) } return event.Sync(event.NewCostQuoteSink(quoted, ctx)), ctx } diff --git a/internal/assembly/boot/effect_local_cost_quote_test.go b/internal/assembly/boot/effect_local_cost_quote_test.go new file mode 100644 index 0000000000..aac132f941 --- /dev/null +++ b/internal/assembly/boot/effect_local_cost_quote_test.go @@ -0,0 +1,97 @@ +package boot + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/pricing" + "reasonix/internal/contract/provider" + "reasonix/internal/runtime/agent" + "reasonix/internal/session/control" +) + +type localCostQuoteProvider struct{ calls atomic.Int32 } + +func (*localCostQuoteProvider) Name() string { return "local-cost-quote" } +func (p *localCostQuoteProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { + n := p.calls.Add(1) + ch := make(chan provider.Chunk, 3) + if len(req.Tools) > 0 { + ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: browserCall(fmt.Sprint(n), "read_file", map[string]any{"path": "fixture.txt"})} + } else { + ch <- provider.Chunk{Type: provider.ChunkText, Text: "Finished."} + } + ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000, RequestCount: 1}} + ch <- provider.Chunk{Type: provider.ChunkDone} + close(ch) + return ch, nil +} + +func TestEffectLocalCostUsesOccurrenceQuote(t *testing.T) { + for _, mode := range []string{"peak", "offpeak", "relay"} { + t.Run(mode, func(t *testing.T) { + home, root := statedBootHome(t), t.TempDir() + p := &localCostQuoteProvider{} + kind := uniqueKind("local-cost-quote") + provider.Register(kind, func(provider.Config) (provider.Provider, error) { return p, nil }) + url := "https://api.deepseek.com" + if mode == "relay" { + url = "https://relay.example.invalid" + } + writeFile(t, root, "fixture.txt", "quoted spend fixture") + writeFile(t, root, "reasonix.toml", `default_model = "test" +[agent] +task_cost_budget = 30 +[codegraph] +enabled = false +[[providers]] +name = "test" +kind = "`+kind+`" +base_url = "`+url+`" +model = "deepseek-v4-pro" +price = { cache_hit = 0.15, input = 4.5, output = 13.5, currency = "CNY" } +`) + approveWorkspaceAt(t, home, root) + var reads, clockCalls int + var quotes []pricing.CostQuote + b := &builder{timer: newPhaseTimer(), compositionBinding: newLiveCompositionBinding} + runtime, err := b.build(t.Context(), Options{Home: home, WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" && strings.Contains(e.Tool.Output, "quoted spend fixture") { + reads++ + } + if e.Kind == event.Usage && e.CostQuote != nil { + quotes = append(quotes, *e.CostQuote) + } + })}) + if err != nil { + t.Fatal(err) + } + ctrl := runtime.Controller + t.Cleanup(ctrl.Close) + hour := 2 + if mode == "offpeak" { + hour = 15 + } + b.quoteContext.Now = func() time.Time { clockCalls++; return time.Date(2026, 8, 19, hour, 0, 0, 0, time.UTC) } + ctrl.SetToolApprovalMode(control.ToolApprovalYolo) + err = ctrl.Run(t.Context(), "read the fixture until the spend gate") + pause, ok := agent.InspectRunPause(err) + if !ok || pause.Key != "cost" { + t.Fatalf("pause=%+v %v", pause, err) + } + wantReads, wantCalls, wantCost := 1, int32(2), "72" + if mode != "peak" { + wantReads, wantCalls, wantCost = 2, 3, "54" + } + total := pricing.AggregateQuotes(quotes, "") + if reads != wantReads || p.calls.Load() != wantCalls || clockCalls != int(wantCalls) || total.Original.Amount != wantCost { + t.Fatalf("reads/calls/clocks/cost=%d/%d/%d/%s want%d/%d/%d/%s", reads, p.calls.Load(), clockCalls, total.Original.Amount, wantReads, wantCalls, wantCalls, wantCost) + } + }) + } +} 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/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/assembly/boot/upload_meter_effect_test.go b/internal/assembly/boot/upload_meter_effect_test.go new file mode 100644 index 0000000000..3c8666c6c0 --- /dev/null +++ b/internal/assembly/boot/upload_meter_effect_test.go @@ -0,0 +1,79 @@ +package boot + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/config" + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/contract/surface" + "reasonix/internal/model/openai" + "reasonix/internal/state/sessionstore" + "reasonix/internal/state/stats" +) + +// What a person reads on the usage page must be what the provider's server +// actually received, per provider, from a real assembled session. +func TestEffectModelRequestBytesReachTheUsageDirectory(t *testing.T) { + home, dir := statedBootHome(t), testenv.TempDir(t) + var received, requests atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + received.Add(int64(len(body))) + requests.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + adapter, err := openai.New(provider.Config{Name: "meter-wire", BaseURL: srv.URL, Model: "test", APIKey: "test", Extra: map[string]any{"protocol": "none"}}) + if err != nil { + t.Fatal(err) + } + kind := "boot-upload-meter-" + t.Name() + provider.Register(kind, func(provider.Config) (provider.Provider, error) { return adapter, nil }) + writeFile(t, dir, "reasonix.toml", fmt.Sprintf(` +default_model = "test-model" +[agent] +system_prompt = "BASE" +[[providers]] +name = "test-model" +kind = %q +model = "x" +`, kind)) + approveWorkspaceAt(t, home, dir) + + ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard, StatsSource: surface.CLI}) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label())) + for _, prompt := range []string{"first question", "second question"} { + if err := ctrl.Run(t.Context(), prompt); err != nil { + t.Fatal(err) + } + } + stats.FlushUploadMeter() + + now := time.Now() + got, err := stats.QueryUpload(config.RootsForHome(home).StatsDir(), now, now) + if err != nil { + t.Fatal(err) + } + if got.Requests != requests.Load() || got.Requests < 2 { + t.Fatalf("meter counted %d requests, server saw %d", got.Requests, requests.Load()) + } + if got.Bytes != received.Load() { + t.Fatalf("meter says %d bytes were sent, the server received %d", got.Bytes, received.Load()) + } + if len(got.Providers) != 1 || got.Providers[0].Provider != "meter-wire" { + t.Fatalf("providers = %+v, want every byte under the provider instance that sent it", got.Providers) + } +} 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 2d86b50fff..527251e689 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 @@ -140,7 +141,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/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/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/contract/provider/retry.go b/internal/contract/provider/retry.go index 48413aa53a..4ae516061a 100644 --- a/internal/contract/provider/retry.go +++ b/internal/contract/provider/retry.go @@ -356,6 +356,7 @@ func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOption return nil, fmt.Errorf("%s: build request: %w", opts.Provider, err) } recordRequestAttempt(ctx) + noteUpload(opts.Provider, req.ContentLength) resp, err := httpClient.Do(req) if err != nil { lastErr = fmt.Errorf("%s: request failed: %w", opts.Provider, err) diff --git a/internal/contract/provider/upload_observer.go b/internal/contract/provider/upload_observer.go new file mode 100644 index 0000000000..41d2d8e33e --- /dev/null +++ b/internal/contract/provider/upload_observer.go @@ -0,0 +1,29 @@ +package provider + +import "sync/atomic" + +// UploadObserver is told how many request-body bytes each send attempt puts on +// the wire, labelled with the provider instance that sent it. +type UploadObserver func(provider string, bytes int64) + +var uploadObserver atomic.Pointer[UploadObserver] + +// SetUploadObserver installs the one process-wide observer; nil removes it. +func SetUploadObserver(fn UploadObserver) { + if fn == nil { + uploadObserver.Store(nil) + return + } + uploadObserver.Store(&fn) +} + +// noteUpload reports a body of known length; an unknown length (-1) or no body +// reports nothing rather than a guess. +func noteUpload(provider string, contentLength int64) { + if contentLength <= 0 { + return + } + if fn := uploadObserver.Load(); fn != nil { + (*fn)(provider, contentLength) + } +} diff --git a/internal/contract/provider/upload_observer_test.go b/internal/contract/provider/upload_observer_test.go new file mode 100644 index 0000000000..74c870cda2 --- /dev/null +++ b/internal/contract/provider/upload_observer_test.go @@ -0,0 +1,66 @@ +package provider + +import ( + "bytes" + "context" + "net/http" + "strings" + "sync" + "testing" +) + +type uploadNote struct { + provider string + bytes int64 +} + +func observeUploads(t *testing.T) *[]uploadNote { + t.Helper() + var mu sync.Mutex + var got []uploadNote + SetUploadObserver(func(provider string, n int64) { + mu.Lock() + defer mu.Unlock() + got = append(got, uploadNote{provider, n}) + }) + t.Cleanup(func() { SetUploadObserver(nil) }) + return &got +} + +func TestSendWithRetryReportsEachAttemptsBodyToTheProvider(t *testing.T) { + notes := observeUploads(t) + body := strings.Repeat("x", 4096) + calls := 0 + client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return statusResp(http.StatusServiceUnavailable, nil), nil + } + return statusResp(http.StatusOK, nil), nil + })} + newReq := func(ctx context.Context) (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodPost, "http://x/y", bytes.NewReader([]byte(body))) + } + resp, err := SendWithRetry(context.Background(), client, SendOptions{Provider: "deepseek"}, newReq) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + want := []uploadNote{{"deepseek", 4096}, {"deepseek", 4096}} + if len(*notes) != len(want) || (*notes)[0] != want[0] || (*notes)[1] != want[1] { + t.Fatalf("uploads = %+v, want %+v: a retried attempt sends the body again, so it counts again", *notes, want) + } +} + +func TestARequestWithoutAKnownBodyReportsNothing(t *testing.T) { + notes := observeUploads(t) + client := &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { return statusResp(http.StatusOK, nil), nil })} + resp, err := SendWithRetry(context.Background(), client, SendOptions{Provider: "p"}, newDummyReq) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(*notes) != 0 { + t.Fatalf("uploads = %+v, want none for a request with no body", *notes) + } +} 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/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/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/frontend/serve/hub_settings.go b/internal/frontend/serve/hub_settings.go index 4ec9fb4364..00cd73fa44 100644 --- a/internal/frontend/serve/hub_settings.go +++ b/internal/frontend/serve/hub_settings.go @@ -5,6 +5,7 @@ import "net/http" func (s *Server) registerMachineSettingsRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /account", s.account) mux.HandleFunc("GET /usage", s.usage) + mux.HandleFunc("GET /usage/upload", s.uploadUsage) s.registerThemeRoutes(mux) s.registerAppearanceRoutes(mux) s.registerStorageRoutes(mux) 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/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/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/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/internal/frontend/serve/usage.go b/internal/frontend/serve/usage.go index 8067e6ed1d..c066ae518f 100644 --- a/internal/frontend/serve/usage.go +++ b/internal/frontend/serve/usage.go @@ -105,3 +105,18 @@ func (s *Server) usage(w http.ResponseWriter, r *http.Request) { } writeJSON(w, report) } + +// uploadUsage answers how much the model providers were sent over the window. +func (s *Server) uploadUsage(w http.ResponseWriter, r *http.Request) { + from, to, err := usageWindow(r) + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + report, err := stats.QueryUpload(config.StatsDir(), from, to) + if err != nil { + refuse(w, http.StatusInternalServerError, "internal.failed", "could not read the upload records", nil) + return + } + writeJSON(w, report) +} diff --git a/internal/frontend/serve/usage_test.go b/internal/frontend/serve/usage_test.go index 66d2637a1a..bf5fee4b54 100644 --- a/internal/frontend/serve/usage_test.go +++ b/internal/frontend/serve/usage_test.go @@ -163,3 +163,37 @@ func TestUsageReadsInTheSessionDisplayCurrency(t *testing.T) { t.Fatalf("CNY display = %+v, want the vendor's CNY price", got) } } + +func TestUploadUsageReadsWhatTheMeterWrote(t *testing.T) { + s := newListenerTestServer(t) + day := time.Now().Format("2006-01-02") + dir := filepath.Join(config.StatsDir(), "upload") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + file := `{"day":"` + day + `","providers":{"deepseek":{"bytes":2048,"requests":4},"relay":{"bytes":1024,"requests":1}}}` + if err := os.WriteFile(filepath.Join(dir, day+".test.json"), []byte(file), 0o600); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + s.uploadUsage(rec, httptest.NewRequest(http.MethodGet, "/usage/upload?days=7", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got stats.UploadStats + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Bytes != 3072 || got.Requests != 5 || len(got.Daily) != 7 || len(got.Providers) != 2 || got.Providers[0].Provider != "deepseek" { + t.Fatalf("report = %+v, want 3072 bytes in 5 requests over 7 days, deepseek first", got) + } +} + +func TestUploadUsageRefusesTheSameBadRangesAsUsage(t *testing.T) { + s := newListenerTestServer(t) + rec := httptest.NewRecorder() + s.uploadUsage(rec, httptest.NewRequest(http.MethodGet, "/usage/upload?days=0", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("days=0 status = %d, want 400", rec.Code) + } +} 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) + } +} diff --git a/internal/platform/worktree/candidate.go b/internal/platform/worktree/candidate.go index a09a86de11..aaf681b82d 100644 --- a/internal/platform/worktree/candidate.go +++ b/internal/platform/worktree/candidate.go @@ -176,8 +176,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 { @@ -199,6 +199,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) } @@ -240,9 +263,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/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/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/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/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/run_budget.go b/internal/runtime/agent/run_budget.go index 6177abb536..e21edf2674 100644 --- a/internal/runtime/agent/run_budget.go +++ b/internal/runtime/agent/run_budget.go @@ -6,6 +6,7 @@ import ( "time" "reasonix/internal/contract/event" + "reasonix/internal/contract/pricing" "reasonix/internal/contract/provider" ) @@ -40,14 +41,12 @@ func normalizeTaskBudget(b TaskBudget) TaskBudget { // one read and how long the model thought, and the failures worth stopping are // reported in hours and tokens, never in rounds. type runBudget struct { - started time.Time - rounds int - requests int - promptTokens int - outputTokens int - cost float64 - pricedRounds int - costIncomplete bool + started time.Time + rounds int + requests int + promptTokens int + outputTokens int + cost runCost // limit is configuration, not accumulation: it survives the reset that // starts a new task. limit TaskBudget @@ -58,21 +57,15 @@ type runBudget struct { // observe folds one round's provider usage into the turn's running total. // A round whose usage never arrived still counts as a round, so the axis never // reads cheaper than the turn actually was. -func (b *runBudget) observe(usage *provider.Usage, pricing *provider.Pricing) { +func (b *runBudget) observe(usage *provider.Usage, quote *pricing.CostQuote) { b.rounds++ + b.cost.observe(usage, quote) if usage == nil { - b.costIncomplete = true return } b.requests += usageRequestCount(usage) b.promptTokens += usage.PromptTokens b.outputTokens += usage.CompletionTokens - if pricing == nil || usage.Estimated { - b.costIncomplete = true - return - } - b.cost += pricing.Cost(usage) - b.pricedRounds++ } func (b *runBudget) elapsed() time.Duration { @@ -89,8 +82,8 @@ func (b *runBudget) totals() event.RunBudgetTotals { Requests: b.requests, PromptTokens: b.promptTokens, OutputTokens: b.outputTokens, - Cost: b.cost, - Priced: !b.costIncomplete && b.pricedRounds > 0, + Cost: b.cost.amount().Float64(), + Priced: b.cost.complete(), ElapsedMs: b.elapsed().Milliseconds(), } } @@ -104,8 +97,8 @@ func (b *runBudget) exceeded(limit TaskBudget) (axis, detail string) { return "token", fmt.Sprintf("task used %d tokens, reaching the %d budget", used, limit.Tokens) } } - if limit.Cost > 0 && b.pricedRounds > 0 && b.cost >= limit.Cost { - return "cost", fmt.Sprintf("task spend %.4f reached the %.4f budget", b.cost, limit.Cost) + if limit.Cost > 0 && b.cost.pricedRounds > 0 && b.cost.amount() >= max(pricing.Amount(1), pricing.NewAmountFromFloat(limit.Cost)) { + return "cost", fmt.Sprintf("task spend %.4f reached the %.4f budget", b.cost.amount().Float64(), limit.Cost) } if limit.Wall > 0 { if elapsed := b.elapsed(); elapsed >= limit.Wall { @@ -134,20 +127,23 @@ func (a *Agent) ResetTaskBudget() { } // observeRunBudget folds a round into both scopes and reports them. -func (a *Agent) observeRunBudget(state *turnRuntime, usage *provider.Usage) { +func (a *Agent) observeRunBudget(state *turnRuntime, usage *provider.Usage, quote *pricing.CostQuote) { if state == nil { return } state.budget.rootTokens.observe(usage) - state.budget.observe(usage, a.svc.pricing) + state.budget.observe(usage, quote) if a.task.budget.started.IsZero() { a.task.budget.started = state.budget.started } - a.task.budget.observe(usage, a.svc.pricing) + a.task.budget.observe(usage, quote) currency := "" if a.svc.pricing != nil { currency = a.svc.pricing.Symbol() } + if state.budget.cost.currency != "" { + currency = pricing.CurrencySymbol(state.budget.cost.currency) + } event.RecordRunBudget(a.svc.sink, event.RunBudgetSample{ Turn: state.budget.totals(), Task: a.task.budget.totals(), diff --git a/internal/runtime/agent/run_budget_completeness_test.go b/internal/runtime/agent/run_budget_completeness_test.go index 0a337d7a2f..972a749a03 100644 --- a/internal/runtime/agent/run_budget_completeness_test.go +++ b/internal/runtime/agent/run_budget_completeness_test.go @@ -5,6 +5,7 @@ import ( "testing" "reasonix/internal/contract/event" + "reasonix/internal/contract/pricing" "reasonix/internal/contract/provider" "reasonix/internal/contract/tool" "reasonix/internal/state/sessionstore" @@ -88,7 +89,8 @@ func TestTaskBudgetKnownCostStillLandsAfterMissingUsage(t *testing.T) { func TestRunBudgetExplicitZeroUsageIsPriced(t *testing.T) { var b runBudget - b.observe(&provider.Usage{}, &provider.Pricing{Input: 1, Output: 2}) + u := &provider.Usage{} + b.observe(u, budgetTestQuote(u, &provider.Pricing{Input: 1, Output: 2})) if got := b.totals(); !got.Priced || got.Cost != 0 || got.Rounds != 1 { t.Fatalf("totals = %+v, want reported zero usage to stay distinguishable from absent usage", got) } @@ -131,17 +133,22 @@ func TestTaskBudgetUsageCompletenessFollowsItsLifetime(t *testing.T) { func TestTaskBudgetCostUsesOnlyTheReportedSubtotal(t *testing.T) { var b runBudget price := &provider.Pricing{Input: 1} - b.observe(&provider.Usage{PromptTokens: 2_000_000, Estimated: true}, price) + observe := func(u *provider.Usage, p *provider.Pricing) { b.observe(u, budgetTestQuote(u, p)) } + observe(&provider.Usage{PromptTokens: 2_000_000, Estimated: true}, price) if axis, _ := b.exceeded(TaskBudget{Cost: 1}); axis != "" { t.Fatalf("axis = %q, an estimate cannot prove a cost crossing", axis) } - b.observe(&provider.Usage{PromptTokens: 500_000}, price) + observe(&provider.Usage{PromptTokens: 500_000}, price) b.observe(&provider.Usage{PromptTokens: 2_000_000}, nil) if got := b.totals(); got.Cost != 0.5 || got.Priced { t.Fatalf("totals = %+v, want only the known priced subtotal and incomplete coverage", got) } - b.observe(&provider.Usage{PromptTokens: 500_000}, price) + observe(&provider.Usage{PromptTokens: 500_000}, price) if axis, _ := b.exceeded(TaskBudget{Cost: 1}); axis != "cost" { t.Fatalf("axis = %q, unknown rounds must not erase a proven crossing", axis) } } + +func budgetTestQuote(u *provider.Usage, p *provider.Pricing) *pricing.CostQuote { + return event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: p}, nil) +} diff --git a/internal/runtime/agent/run_budget_test.go b/internal/runtime/agent/run_budget_test.go index 2978858679..acc3c20589 100644 --- a/internal/runtime/agent/run_budget_test.go +++ b/internal/runtime/agent/run_budget_test.go @@ -189,7 +189,7 @@ func TestRunBudgetIgnoresSinksThatDoNotOptIn(t *testing.T) { plain := event.FuncSink(func(event.Event) {}) a := &Agent{svc: agentServices{sink: plain}} state := &turnRuntime{} - a.observeRunBudget(state, &provider.Usage{PromptTokens: 5, RequestCount: 1}) + a.observeRunBudget(state, &provider.Usage{PromptTokens: 5, RequestCount: 1}, nil) if state.budget.rounds != 1 || state.budget.promptTokens != 5 { t.Fatalf("budget = %+v, want the round still accumulated locally", state.budget) } diff --git a/internal/runtime/agent/run_cost.go b/internal/runtime/agent/run_cost.go new file mode 100644 index 0000000000..b0eb587b09 --- /dev/null +++ b/internal/runtime/agent/run_cost.go @@ -0,0 +1,48 @@ +package agent + +import ( + "reasonix/internal/contract/pricing" + "reasonix/internal/contract/provider" +) + +type runCost struct { + amounts map[string]pricing.Amount + currency string + pricedRounds int + incomplete bool +} + +func (c *runCost) observe(usage *provider.Usage, quote *pricing.CostQuote) { + if quote != nil && quote.Original.Currency != "" { + c.currency = pricing.NormalizeCurrency(quote.Original.Currency) + } + if usage == nil || usage.Estimated || quote == nil || pricing.NormalizeCurrency(quote.Original.Currency) == "" || len(quote.OriginalTotals) > 0 { + c.incomplete = true + return + } + amount, err := pricing.NewAmountFromString(quote.Original.Amount) + if err != nil || amount < 0 { + c.incomplete = true + return + } + q := pricing.NormalizeQuote(*quote) + // An explicitly reported zero retains its known zero subtotal even though + // the quote layer classifies a no-token record as incomplete usage. + zero := usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.TotalTokens == 0 && + usage.CacheHitTokens == 0 && usage.CacheMissTokens == 0 && usage.CacheWriteTokens == 0 && usage.CacheWriteBilledTokens == 0 + if !q.CostComplete && !(zero && q.IncompleteReason == "missing_price_or_usage" && amount == 0) { + c.incomplete = true + return + } + if c.amounts == nil { + c.amounts = map[string]pricing.Amount{} + } + c.amounts[c.currency] = c.amounts[c.currency].Add(amount) + c.pricedRounds++ +} + +func (c *runCost) amount() pricing.Amount { return c.amounts[c.currency] } + +func (c *runCost) complete() bool { + return !c.incomplete && c.pricedRounds > 0 && len(c.amounts) == 1 +} diff --git a/internal/runtime/agent/run_cost_quote_test.go b/internal/runtime/agent/run_cost_quote_test.go new file mode 100644 index 0000000000..cc98b39913 --- /dev/null +++ b/internal/runtime/agent/run_cost_quote_test.go @@ -0,0 +1,62 @@ +package agent + +import ( + "context" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/contract/tool" + "reasonix/internal/state/sessionstore" +) + +type quotedBudgetProvider struct{ spendingProvider } + +func (p *quotedBudgetProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) { + in, err := p.spendingProvider.Stream(ctx, req) + if err != nil { + return nil, err + } + out := make(chan provider.Chunk, 4) + for c := range in { + if c.Type == provider.ChunkUsage { + c.Usage = &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000, RequestCount: 1} + } + out <- c + } + close(out) + return out, nil +} + +func TestLocalBudgetUsesOccurrenceQuote(t *testing.T) { + for _, peak := range []bool{false, true} { + name := "offpeak" + wantRounds := int32(3) + wantTotal := 54.0 + hour := 15 + if peak { + name = "peak" + wantRounds = 2 + wantTotal = 72 + hour = 2 + } + t.Run(name, func(t *testing.T) { + s := newBudgetSink() + reg := tool.NewRegistry() + reg.Add(readProbe{}) + p := "edBudgetProvider{spendingProvider: spendingProvider{max: 500}} + quoted := event.NewCostQuoteSink(s, &event.QuoteContext{Now: func() time.Time { return time.Date(2026, 8, 19, hour, 0, 0, 0, time.UTC) }, CatalogProviderForModel: func(string) string { return "deepseek" }}) + a := New(p, reg, sessionstore.NewSession("sys"), Options{ModelRef: "deepseek/deepseek-v4-pro", Pricing: &provider.Pricing{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"}, TaskBudget: TaskBudget{Cost: 30}}, quoted) + err := a.Run(t.Context(), "read until the spend gate") + pause, ok := InspectRunPause(err) + if !ok || pause.Key != "cost" { + t.Fatalf("pause=%+v %v", pause, err) + } + got := s.samples[len(s.samples)-1].Task.Cost + if p.rounds.Load() != wantRounds || got != wantTotal { + t.Fatalf("rounds/cost=%d/%g want%d/%g", p.rounds.Load(), got, wantRounds, wantTotal) + } + }) + } +} diff --git a/internal/runtime/agent/run_cost_test.go b/internal/runtime/agent/run_cost_test.go new file mode 100644 index 0000000000..664df75a31 --- /dev/null +++ b/internal/runtime/agent/run_cost_test.go @@ -0,0 +1,54 @@ +package agent + +import ( + "testing" + + "reasonix/internal/contract/pricing" + "reasonix/internal/contract/provider" +) + +func TestLocalCostKeepsOriginalCurrencyBuckets(t *testing.T) { + var b runBudget + u := &provider.Usage{PromptTokens: 1, RequestCount: 1} + observe := func(amount, currency string) { + q := &pricing.CostQuote{Original: pricing.Money{Amount: amount, Currency: currency}, CostComplete: true, Coverage: pricing.CoverageComplete, Selected: &pricing.Money{Amount: "999", Currency: "EUR"}} + b.observe(u, q) + q.Original.Amount = "999" + } + observe("0.4", "CNY") + observe("0.3", "USD") + if b.cost.amount() != pricing.NewAmountFromFloat(.3) || b.totals().Priced { + t.Fatalf("mixed totals=%+v", b.totals()) + } + if axis, _ := b.exceeded(TaskBudget{Cost: .5}); axis != "" { + t.Fatal("mixed currencies or selected display triggered gate") + } + observe("0.2", "CNY") + if axis, _ := b.exceeded(TaskBudget{Cost: .5}); axis != "cost" { + t.Fatal("known CNY subtotal did not cross") + } + if b.cost.amounts["USD"] != pricing.NewAmountFromFloat(.3) { + t.Fatal("currency switch lost prior bucket") + } +} + +func TestLocalCostFixedPointThresholdAndUnknown(t *testing.T) { + var b runBudget + u := &provider.Usage{PromptTokens: 1, RequestCount: 1} + for _, amount := range []string{"0.1", "0.2"} { + b.observe(u, &pricing.CostQuote{Original: pricing.Money{Amount: amount, Currency: "USD"}, CostComplete: true, Coverage: pricing.CoverageComplete}) + } + if axis, _ := b.exceeded(TaskBudget{Cost: .3}); axis != "cost" { + t.Fatal("decimal threshold missed") + } + b.observe(u, nil) + if axis, _ := b.exceeded(TaskBudget{Cost: .3}); axis != "cost" || b.totals().Priced { + t.Fatal("unknown usage erased known subtotal or claimed completeness") + } + var zero runBudget + z := &provider.Usage{} + zero.observe(z, budgetTestQuote(z, &provider.Pricing{Currency: "USD", Input: 1})) + if axis, _ := zero.exceeded(TaskBudget{Cost: 1e-12}); axis != "" { + t.Fatal("rounded threshold charged zero usage") + } +} diff --git a/internal/runtime/agent/run_loop.go b/internal/runtime/agent/run_loop.go index 95d0dbeb70..d8867c5bc9 100644 --- a/internal/runtime/agent/run_loop.go +++ b/internal/runtime/agent/run_loop.go @@ -298,8 +298,8 @@ func (a *Agent) runToolLoop(ctx context.Context, state *turnRuntime) error { prefixShape.BodyChain = streamed.bodyChain // what actually went out cacheDiagnostics := CompareShape(prevPrefixShape, prefixShape, usage, contentReasons) if err != nil { - a.emitTurnUsage(usage, &cacheDiagnostics, streamed.attemptID) - a.observeRunBudget(state, usage) + quote := a.emitTurnUsage(usage, &cacheDiagnostics, streamed.attemptID) + a.observeRunBudget(state, usage, quote) if msg, ok := finishReasonMessage(usage); ok { a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) } @@ -311,8 +311,8 @@ func (a *Agent) runToolLoop(ctx context.Context, state *turnRuntime) error { } a.sess.lastPrefixShape = prefixShape a.sess.haveLastPrefixShape = true - a.emitTurnUsage(usage, &cacheDiagnostics, streamed.attemptID) - a.observeRunBudget(state, usage) + quote := a.emitTurnUsage(usage, &cacheDiagnostics, streamed.attemptID) + a.observeRunBudget(state, usage, quote) // Classify the terminal before anything commits: a call the output limit // cut in half must reach neither history nor execution, and why it did // not is the host's to state, not the model's to infer. diff --git a/internal/runtime/agent/run_usage.go b/internal/runtime/agent/run_usage.go index a34399feaa..786952b756 100644 --- a/internal/runtime/agent/run_usage.go +++ b/internal/runtime/agent/run_usage.go @@ -2,6 +2,7 @@ package agent import ( "reasonix/internal/contract/event" + "reasonix/internal/contract/pricing" "reasonix/internal/contract/provider" ) @@ -251,9 +252,18 @@ func usageRequestCount(usage *provider.Usage) int { return 1 } -func (a *Agent) emitTurnUsage(usage *provider.Usage, cacheDiagnostics *CacheDiagnostics, attemptID string) { +func (a *Agent) emitTurnUsage(usage *provider.Usage, cacheDiagnostics *CacheDiagnostics, attemptID string) *pricing.CostQuote { + e := event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage, Pricing: a.svc.pricing, + UsageSource: a.usageSource, AttemptID: attemptID, CacheDiagnostics: cacheDiagnostics, + SessionHit: int(a.sess.cacheHit.Load()), SessionMiss: int(a.sess.cacheMiss.Load())} + e.CostQuote = event.QuoteUsage(a.svc.sink, e) + var accounted *pricing.CostQuote + if e.CostQuote != nil { + q := pricing.NormalizeQuote(*e.CostQuote) + accounted = &q + } if usage == nil || (usage.TotalTokens <= 0 && usage.RequestCount <= 0) { - return + return accounted } // lastUsage must stay as the latest single-request shape (set during // sampling recovery). Never overwrite it with a multi-attempt billable @@ -261,9 +271,6 @@ func (a *Agent) emitTurnUsage(usage *provider.Usage, cacheDiagnostics *CacheDiag if a.sess.output.lastUsage.Load() == nil && usage.PromptTokens > 0 { a.storeLatestRequestUsage(usage) } - a.svc.sink.Emit(event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage, Pricing: a.svc.pricing, - UsageSource: a.usageSource, - AttemptID: attemptID, - CacheDiagnostics: cacheDiagnostics, - SessionHit: int(a.sess.cacheHit.Load()), SessionMiss: int(a.sess.cacheMiss.Load())}) + a.svc.sink.Emit(e) + return accounted } 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/task_budget_gate_test.go b/internal/runtime/agent/task_budget_gate_test.go index 2a40fe8362..f79721bd60 100644 --- a/internal/runtime/agent/task_budget_gate_test.go +++ b/internal/runtime/agent/task_budget_gate_test.go @@ -122,8 +122,8 @@ func TestTaskBudgetAxesSetIndependently(t *testing.T) { // lands it. This is the promise the defaults make. func TestUnconfiguredBudgetNeverCrosses(t *testing.T) { b := runBudget{started: time.Now().Add(-8 * time.Hour)} - b.observe(&provider.Usage{PromptTokens: 50_000_000, CompletionTokens: 5_000_000, RequestCount: 1}, - &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2}) + u := &provider.Usage{PromptTokens: 50_000_000, CompletionTokens: 5_000_000, RequestCount: 1} + b.observe(u, budgetTestQuote(u, &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2})) if axis, detail := b.exceeded(normalizeTaskBudget(TaskBudget{})); axis != "" { t.Fatalf("unconfigured budget crossed %q (%s); nothing should stop by default", axis, detail) } diff --git a/internal/runtime/agent/taskstate_test.go b/internal/runtime/agent/taskstate_test.go index 7cad0192f4..6ca4aa986c 100644 --- a/internal/runtime/agent/taskstate_test.go +++ b/internal/runtime/agent/taskstate_test.go @@ -6,6 +6,7 @@ import ( "go/token" "testing" + "reasonix/internal/contract/pricing" "reasonix/internal/safety/evidence" ) @@ -26,7 +27,7 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) { checkpoint: evidence.DeliveryCheckpoint{ScopeID: "scope-1"}, ledger: ledger, outcome: evidence.NewOutcomeTracker(), - budget: runBudget{rounds: 4, requests: 9, cost: 1.5, limit: TaskBudget{}}, + budget: runBudget{rounds: 4, requests: 9, cost: runCost{amounts: map[string]pricing.Amount{"CNY": pricing.NewAmountFromFloat(1.5)}, currency: "CNY"}, limit: TaskBudget{}}, witness: map[string][]string{"tally.go": {"total += x"}}, origins: &originLedger{}, } @@ -43,7 +44,7 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) { if after.ledger != ledger { t.Error("ledger pointer replaced; tool contexts hold it for the length of a call") } - if after.budget.rounds != 0 || after.budget.requests != 0 || after.budget.cost != 0 { + if after.budget.rounds != 0 || after.budget.requests != 0 || after.budget.cost.amount() != 0 { t.Errorf("budget = %+v, want a fresh bill for the new task", after.budget) } if after.origins == nil || after.origins == before.origins { 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/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/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/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/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/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 68e6008783..2372e03571 100644 --- a/internal/state/mutation/registry.go +++ b/internal/state/mutation/registry.go @@ -195,7 +195,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/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/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/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/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_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/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/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/upload.go b/internal/state/stats/upload.go new file mode 100644 index 0000000000..0aa68c9ac2 --- /dev/null +++ b/internal/state/stats/upload.go @@ -0,0 +1,268 @@ +package stats + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "reasonix/internal/base/fileutil" + "reasonix/internal/contract/provider" +) + +const ( + uploadDirName = "upload" + uploadKeepDays = 400 + uploadFlushGap = 30 * time.Second +) + +// uploadCount is what one provider was sent in one day. +type uploadCount struct { + Bytes int64 `json:"bytes"` + Requests int64 `json:"requests"` +} + +// uploadFile is one process's tally for one day. Each process writes only its +// own file, so concurrent processes never overwrite each other's counts and a +// reader adds the files up. +type uploadFile struct { + Day string `json:"day"` + Providers map[string]uploadCount `json:"providers"` +} + +// uploadMeter tallies request-body bytes per provider and day for this process. +type uploadMeter struct { + dir string + instance string + now func() time.Time + mu sync.Mutex + days map[string]map[string]uploadCount + dirty map[string]bool +} + +func newUploadMeter(dir, instance string, now func() time.Time) *uploadMeter { + return &uploadMeter{dir: dir, instance: instance, now: now, + days: map[string]map[string]uploadCount{}, dirty: map[string]bool{}} +} + +// Add counts one request body sent by provider. +func (m *uploadMeter) Add(provider string, bytes int64) { + if bytes <= 0 { + return + } + day := m.now().Format(dayLayout) + m.mu.Lock() + defer m.mu.Unlock() + counts := m.days[day] + if counts == nil { + counts = map[string]uploadCount{} + m.days[day] = counts + } + c := counts[provider] + c.Bytes += bytes + c.Requests++ + counts[provider] = c + m.dirty[day] = true +} + +func (m *uploadMeter) path(day string) string { + return filepath.Join(m.dir, uploadDirName, day+"."+m.instance+".json") +} + +// Flush writes every day that changed since the last flush. +func (m *uploadMeter) Flush() error { + m.mu.Lock() + pending := map[string]uploadFile{} + for day := range m.dirty { + pending[day] = uploadFile{Day: day, Providers: maps.Clone(m.days[day])} + } + m.dirty = map[string]bool{} + m.mu.Unlock() + if len(pending) == 0 { + return nil + } + if err := os.MkdirAll(filepath.Join(m.dir, uploadDirName), 0o700); err != nil { + m.redirty(pending) + return err + } + var failed error + for day, file := range pending { + raw, err := json.Marshal(file) + if err == nil { + err = fileutil.AtomicWriteFile(m.path(day), raw, 0o600) + } + if err != nil { + failed = err + m.redirty(map[string]uploadFile{day: file}) + } + } + return failed +} + +func (m *uploadMeter) redirty(days map[string]uploadFile) { + m.mu.Lock() + defer m.mu.Unlock() + for day := range days { + m.dirty[day] = true + } +} + +// pruneBefore removes tally files for days before cutoff. +func (m *uploadMeter) pruneBefore(cutoff time.Time) { + entries, err := os.ReadDir(filepath.Join(m.dir, uploadDirName)) + if err != nil { + return + } + limit := cutoff.Format(dayLayout) + for _, e := range entries { + day, ok := uploadFileDay(e.Name()) + if ok && day < limit { + _ = os.Remove(filepath.Join(m.dir, uploadDirName, e.Name())) + } + } +} + +// uploadFileDay reads the day out of "..json". +func uploadFileDay(name string) (string, bool) { + day, rest, ok := strings.Cut(name, ".") + if !ok || !strings.HasSuffix(rest, ".json") { + return "", false + } + if _, err := time.Parse(dayLayout, day); err != nil { + return "", false + } + return day, true +} + +var uploadMeters = struct { + sync.Mutex + current *uploadMeter + ticking bool +}{} + +// StartUploadMeter begins counting the request bodies this process sends to +// model providers into dir. Counts reach disk every half minute and are not +// lost to a crash beyond that; asking again for the same dir changes nothing. +func StartUploadMeter(dir string) { + if dir == "" { + return + } + uploadMeters.Lock() + defer uploadMeters.Unlock() + if cur := uploadMeters.current; cur != nil { + if cur.dir == dir { + return + } + _ = cur.Flush() + } + m := newUploadMeter(dir, fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano()), time.Now) + m.pruneBefore(m.now().AddDate(0, 0, -uploadKeepDays)) + uploadMeters.current = m + provider.SetUploadObserver(m.Add) + if !uploadMeters.ticking { + uploadMeters.ticking = true + go func() { + tick := time.NewTicker(uploadFlushGap) + defer tick.Stop() + for range tick.C { + FlushUploadMeter() + } + }() + } +} + +// FlushUploadMeter writes the running meter's counts to disk now. +func FlushUploadMeter() { + uploadMeters.Lock() + cur := uploadMeters.current + uploadMeters.Unlock() + if cur != nil { + _ = cur.Flush() + } +} + +// UploadDay is one day's total in an upload series. +type UploadDay struct { + Day string `json:"day"` + Bytes int64 `json:"bytes"` +} + +// UploadProvider is one provider's share of the range. +type UploadProvider struct { + Provider string `json:"provider"` + Bytes int64 `json:"bytes"` + Requests int64 `json:"requests"` + Percent float64 `json:"percent"` +} + +// UploadStats is what model providers were sent over a range: request bodies +// as they went out, before the connection adds its own overhead. +type UploadStats struct { + From string `json:"from"` + To string `json:"to"` + Bytes int64 `json:"bytes"` + Requests int64 `json:"requests"` + Daily []UploadDay `json:"daily"` + Providers []UploadProvider `json:"providers"` +} + +// QueryUpload adds up every process's tally for the days in [from, to]. +func QueryUpload(dir string, from, to time.Time) (UploadStats, error) { + days := daysInRange(from, to) + out := UploadStats{Daily: []UploadDay{}, Providers: []UploadProvider{}} + if len(days) == 0 { + return out, nil + } + out.From, out.To = days[0], days[len(days)-1] + perDay := map[string]int64{} + perProvider := map[string]uploadCount{} + entries, err := os.ReadDir(filepath.Join(dir, uploadDirName)) + if err != nil && !os.IsNotExist(err) { + return out, err + } + for _, e := range entries { + day, ok := uploadFileDay(e.Name()) + if !ok || day < out.From || day > out.To { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, uploadDirName, e.Name())) + if err != nil { + continue + } + var file uploadFile + if json.Unmarshal(raw, &file) != nil { + continue + } + for name, c := range file.Providers { + perDay[day] += c.Bytes + total := perProvider[name] + total.Bytes += c.Bytes + total.Requests += c.Requests + perProvider[name] = total + out.Bytes += c.Bytes + out.Requests += c.Requests + } + } + for _, day := range days { + out.Daily = append(out.Daily, UploadDay{Day: day, Bytes: perDay[day]}) + } + for name, c := range perProvider { + share := UploadProvider{Provider: name, Bytes: c.Bytes, Requests: c.Requests} + if out.Bytes > 0 { + share.Percent = float64(c.Bytes) * 100 / float64(out.Bytes) + } + out.Providers = append(out.Providers, share) + } + sort.Slice(out.Providers, func(i, j int) bool { + if out.Providers[i].Bytes != out.Providers[j].Bytes { + return out.Providers[i].Bytes > out.Providers[j].Bytes + } + return out.Providers[i].Provider < out.Providers[j].Provider + }) + return out, nil +} diff --git a/internal/state/stats/upload_test.go b/internal/state/stats/upload_test.go new file mode 100644 index 0000000000..2a0f0f7947 --- /dev/null +++ b/internal/state/stats/upload_test.go @@ -0,0 +1,111 @@ +package stats + +import ( + "os" + "path/filepath" + "testing" + "time" + + "reasonix/internal/base/testenv" +) + +func uploadClock(day string) func() time.Time { + at, _ := time.ParseInLocation(dayLayout, day, time.Local) + return func() time.Time { return at.Add(10 * time.Hour) } +} + +func TestUploadsFromSeveralProcessesAddUpPerProviderAndDay(t *testing.T) { + dir := testenv.TempDir(t) + a := newUploadMeter(dir, "a", uploadClock("2026-10-26")) + b := newUploadMeter(dir, "b", uploadClock("2026-10-26")) + c := newUploadMeter(dir, "c", uploadClock("2026-10-27")) + a.Add("deepseek", 1000) + a.Add("deepseek", 500) + b.Add("deepseek", 2500) + b.Add("relay", 1000) + c.Add("deepseek", 5000) + for _, m := range []*uploadMeter{a, b, c} { + if err := m.Flush(); err != nil { + t.Fatal(err) + } + } + from, _ := time.ParseInLocation(dayLayout, "2026-10-25", time.Local) + to, _ := time.ParseInLocation(dayLayout, "2026-10-27", time.Local) + got, err := QueryUpload(dir, from, to) + if err != nil { + t.Fatal(err) + } + if got.Bytes != 10000 || got.Requests != 5 { + t.Fatalf("total = %d bytes in %d requests, want 10000 in 5", got.Bytes, got.Requests) + } + if len(got.Daily) != 3 || got.Daily[0].Day != "2026-10-25" || got.Daily[0].Bytes != 0 || got.Daily[1].Bytes != 5000 || got.Daily[2].Bytes != 5000 { + t.Fatalf("daily = %+v, want the quiet day kept as zero so the axis is stable", got.Daily) + } + if len(got.Providers) != 2 || got.Providers[0].Provider != "deepseek" || got.Providers[0].Bytes != 9000 || got.Providers[0].Requests != 4 { + t.Fatalf("providers = %+v, want deepseek first with 9000 bytes in 4 requests", got.Providers) + } + if p := got.Providers[0].Percent; p < 89.9 || p > 90.1 { + t.Fatalf("deepseek percent = %v, want 90", p) + } +} + +func TestUploadQueryLeavesOutDaysOutsideTheRange(t *testing.T) { + dir := testenv.TempDir(t) + m := newUploadMeter(dir, "a", uploadClock("2026-09-01")) + m.Add("p", 700) + if err := m.Flush(); err != nil { + t.Fatal(err) + } + from, _ := time.ParseInLocation(dayLayout, "2026-10-01", time.Local) + to, _ := time.ParseInLocation(dayLayout, "2026-10-27", time.Local) + got, err := QueryUpload(dir, from, to) + if err != nil || got.Bytes != 0 || len(got.Providers) != 0 { + t.Fatalf("got %+v err=%v, want nothing from September in an October range", got, err) + } +} + +func TestAMeterThatNeverUploadedLeavesNoFile(t *testing.T) { + dir := testenv.TempDir(t) + m := newUploadMeter(dir, "a", uploadClock("2026-10-27")) + if err := m.Flush(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, uploadDirName)); err == nil { + entries, _ := os.ReadDir(filepath.Join(dir, uploadDirName)) + if len(entries) != 0 { + t.Fatalf("an idle process wrote %d files", len(entries)) + } + } +} + +func TestOldUploadFilesAreRemovedWhenAMeterStarts(t *testing.T) { + dir := testenv.TempDir(t) + old := newUploadMeter(dir, "a", uploadClock("2024-01-01")) + old.Add("p", 1) + if err := old.Flush(); err != nil { + t.Fatal(err) + } + fresh := newUploadMeter(dir, "b", uploadClock("2026-10-27")) + fresh.pruneBefore(fresh.now().AddDate(0, 0, -uploadKeepDays)) + entries, _ := os.ReadDir(filepath.Join(dir, uploadDirName)) + if len(entries) != 0 { + t.Fatalf("%d files left after pruning, want the 2024 one gone", len(entries)) + } +} + +func TestTornOrForeignFilesInTheUploadDirectoryAreIgnored(t *testing.T) { + dir := testenv.TempDir(t) + m := newUploadMeter(dir, "a", uploadClock("2026-10-27")) + m.Add("p", 300) + if err := m.Flush(); err != nil { + t.Fatal(err) + } + up := filepath.Join(dir, uploadDirName) + _ = os.WriteFile(filepath.Join(up, "2026-10-27.torn.json"), []byte(`{"day":"2026-10-27","provi`), 0o600) + _ = os.WriteFile(filepath.Join(up, "notes.txt"), []byte("x"), 0o600) + day, _ := time.ParseInLocation(dayLayout, "2026-10-27", time.Local) + got, err := QueryUpload(dir, day, day) + if err != nil || got.Bytes != 300 { + t.Fatalf("got %+v err=%v, want the intact file's 300 bytes and no failure", got, err) + } +} 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..fddc908f6a 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 @@ -715,8 +724,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/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/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 +} 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 +} diff --git a/release-notes/studio/2.54.0.md b/release-notes/studio/2.54.0.md new file mode 100644 index 0000000000..754a0e8736 --- /dev/null +++ b/release-notes/studio/2.54.0.md @@ -0,0 +1,82 @@ +本版新增全局显示 / 隐藏快捷键、用量页的上传量、隐藏服务商共同前缀,并把设置拆成子页、壁纸移到外观,修复多处预算与界面问题。 + +## 新增 + +- 桌面设置提供默认关闭的全局显示 / 隐藏快捷键,支持按键录制与清除;仅 Studio 运行时有效,不会启动已退出的应用。关联 #8290。 +- 服务商设置里可以「隐藏共同前缀」,路由类服务给每个模型加的同一段前缀(如 group/)在各处显示时省略,只改显示。关联 #14573。 +- 设置 → 用量里多了「上传量」,显示今天和近 30 天发给模型服务商的请求内容大小、次数、平均每次多大,并按服务商列出。关联 #14670。 +- 反馈窗口会记住上一次成功提交的联系方式并自动填入,仍可修改,清空后不会再冒出旧的。关联 #14610。 +- 主题可以按深色 / 浅色分别设置导航、图标栏、顶栏、侧栏和输入框在背景图上的不透明度。关联 #14589。 +- 新增默认关闭的「连接检测」授权开关,只有你打开后才会向服务商发起后台检测请求。关联 #14620。 + +## 变更 + +- 设置里「模型偏好」的「看图」下拉框只列出已声明支持读图的模型;已选的旧模型仍会显示,并标明"未声明读图"。关联 #14418。 +- 「壁纸」及其选择、替换、移除、导入、浓度、压暗和焦点控件移到「外观」的独立区块,已选壁纸的预览直接显示。关联 #14470。 +- 待发送队列与输入框共用外框和底色,采用紧凑透明行与轻量图标操作;现有操作与快捷键保持不变。关联 #14497。 +- 较长的「工具与权限」和「外观」拆成几个子页,在左侧导航里直接点选;搜索或链接会自动切到设置所在的子页。关联 #14625。 +- 设置页文字统一成同一套样式,字号从 10 种减到 5 种,字重和灰色也收成固定几档。关联 #14625。 +- 关闭工作台里最后一个页面后,留下"新建页面"的启动器,不再把整列收起。关联 #14037。 +- 「柔和」对比度改为真正更低对比、仍达 AA 的内置配色,不再比默认更强。关联 #12019。 + +## 修复 + +- 打开设置时不再每次遍历磁盘统计「存储」大小;这个数字延后读取,十分钟内沿用上次结果。关联 #14315。 +- 用量总额会说明其中有几笔调用未定价、以及原因,不再只由一个标志暗示。关联 #14522。 +- 压缩上下文时摘要请求的连接被中途重置(比如本地网关重启),会自动重试一次,不再直接失败。关联 #14643。 +- 压缩后“较早的消息过长,只通过摘要保留”的提示在中文界面下也用中文显示。修复 #14651。 +- 命令或同一批调用被宿主拦截时,工具卡片用中文说明原因,不再显示英文的 blocked 提示。修复 #14652。 +- 历史超过读取上限的会话不再只报英文错误,会说明文件完好,并可「从最近的内容继续」另存为新会话。修复 #14660。 +- 删除来自 Reasonix 1.x 的会话后,同一会话不再重新出现在侧栏;删除状态重启后保留,且不修改 1.x 的原始存储。修复 #14632。 +- 在弹窗里放大查看截图时,按 Esc 只退出大图,不再关掉整个反馈弹窗。修复 #14609。 +- 点“继续派发”后,输入框上方“待发送已暂停”的提示会随之消失。修复 #14616。 +- 鼠标点开设置等弹窗后按 Esc 关闭,左侧图标栏按钮不再带着焦点环停在“悬停”样子。关联 #14624。 +- 工作区里有多个 Git 仓库时,改动列表不再撑出面板,有自己的滚动条。修复 #14628。 +- 文件工具栏的保存按钮保持紧凑,启用和禁用状态都清晰可读。关联 #14231。 +- 同一个网关拒绝 stream_options 的结论按进程只学一次,不再让每个新会话都多发一次失败请求。关联 #14606。 +- 显式子代理技能调用遵守已有任务根预算;恢复 Goal 时规划与执行一起获得新的预算切片;本地费用门禁采用发生时刻的权威报价;Windows 跟踪命令保留已发布的 Job Object owner。关联 #14025。 +- 文件大小和修改时间未变时,候选快照不再复用 Git 缓存中的旧内容,私有索引重建保留用户暂存区。关联 #14025。 +- ACP 的 inbox 拒绝带机器码:`-32020` 队列拒绝,`-32021` 该工作可能已执行、不要重试。关联 #14551。 + +--- + + + +## New + +- An opt-in global show/hide shortcut in desktop settings, with key recording and Clear; it works only while Studio runs and never launches a stopped app. Refs #8290. +- A provider can hide the prefix all its models share (such as group/ from a router) everywhere the model name is drawn; display only. Refs #14573. +- Settings → Usage has "Upload volume": request bodies sent to model providers today and over 30 days, count, average size, and a per-provider list. Refs #14670. +- The feedback form remembers the last successfully submitted contact and prefills it; it stays editable and a cleared field stays cleared. Refs #14610. +- Themes can set per-scheme (dark / light) opacity for the navigation, rail, chrome, side and composer surfaces over a background image. Refs #14589. +- A default-off connection-health consent setting: background provider checks run only after you turn it on. Refs #14620. + +## Changed + +- The vision row in Model preferences lists only models declared to read images; a model chosen earlier stays visible, marked as not declared. Refs #14418. +- Wallpaper, choose/change/remove, import, strength, dim and focus move to Appearance; the selected preview is visible directly. Refs #14470. +- Pending messages share the composer frame, with compact transparent rows and icon actions; existing queue actions and shortcuts are unchanged. Refs #14497. +- The long Tools & permissions and Appearance sections are split into pages in the left navigation; search and links open the page a setting is on. Refs #14625. +- Settings text follows one set of styles: 5 font sizes instead of 10, with fixed weights and grey tones. Refs #14625. +- Closing the last page in the workbench leaves the new-page launcher instead of folding the column away. Refs #14037. +- The Soft contrast level is a genuinely lower-contrast built-in palette that still meets AA, instead of reading stronger than the default. Refs #12019. + +## 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. +- A usage total says how many of its calls were never priced, and why, instead of only carrying an incomplete flag. Refs #14522. +- When the connection of a context-summary request is reset mid-stream, compaction retries once instead of failing at once. Refs #14643. +- The "earlier messages were too large to keep whole" notice after a compaction reads in the window's language. Fixes #14651. +- When the host stops a command or a call in a batch before it runs, the tool card explains why in your language instead of an English "blocked:" line. Fixes #14652. +- 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. +- Deleted 1.x conversations stay out of the sidebar after restart; the original store stays unchanged. Fixes #14632. +- Pressing Esc on an enlarged screenshot closes only the screenshot instead of the whole feedback dialog. Fixes #14609. +- The "queue paused" notice goes away once dispatch resumes instead of lingering. Fixes #14616. +- After a mouse-opened dialog is closed with Esc, the icon rail button no longer keeps a focus ring as if hovered. Refs #14624. +- With several Git repositories in a workspace, the workbench changes list no longer grows past the panel; it scrolls on its own. Fixes #14628. +- The file toolbar's Save button stays compact and readable in both enabled and disabled states. Refs #14231. +- An endpoint's refusal of stream_options is learned once per process, so each new session no longer sends one extra failing request. Refs #14606. +- Explicit subagent skills share the existing root budget, and resuming a Goal renews planner and executor budget together. Refs #14025. +- Local cost gates use the occurrence-time quote, and tracked Windows commands keep their published Job Object owner. Refs #14025. +- Candidate snapshots no longer reuse stale Git-cached content when size and mtime are unchanged, and private index rebuilds keep the user's staging area. Refs #14025. +- ACP inbox refusals carry machine codes: `-32020` for a queue refusal, `-32021` for work that may already have run and must not be retried. Refs #14551. diff --git a/release-notes/studio/unreleased/14025-goal-planner-budget-resume.md b/release-notes/studio/unreleased/14025-goal-planner-budget-resume.md deleted file mode 100644 index 3006d109ba..0000000000 --- a/release-notes/studio/unreleased/14025-goal-planner-budget-resume.md +++ /dev/null @@ -1,15 +0,0 @@ -Goal 预算耗尽后恢复时,规划与执行一起获得新切片。 - -## 修复 - -- 双模型 Goal 在显式预算耗尽后恢复时,也重置工具 planner 的本地支出,避免旧支出提前结束新规划。关联 #14025。 -- 保留累计统计和交付证据;手动暂停、其他停止原因与拒绝恢复不重置 planner 预算。关联 #14025。 - ---- - - - -## Fixed - -- Resuming a dual-model Goal after an explicit spend-budget stop also resets its tool planner's local spend, so old spend does not truncate renewed planning. Refs #14025. -- Cumulative statistics and delivery evidence remain. Manual pauses, other stop reasons and refused resumes preserve planner spend. Refs #14025. 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) 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 diff --git a/release-notes/studio/unreleased/14025-published-command-job-owner.md b/release-notes/studio/unreleased/14025-published-command-job-owner.md deleted file mode 100644 index e3fa9404bb..0000000000 --- a/release-notes/studio/unreleased/14025-published-command-job-owner.md +++ /dev/null @@ -1,17 +0,0 @@ -Windows 跟踪命令保留已发布的 Job Object owner。 - -## 修复 - -- 重复取消和收尾时保留原一次性 owner,避免重新调用按 PID 寻址的停止路径。关联 #14025。 -- 成功分配 Job Object 后不再启动额外 PID 扫描器。关联 #14025。 -- 无 Job Object、未跟踪命令及独立树追踪的旧边界仍在,不代表完整进程隔离已完成。关联 #14025。 - ---- - - - -## Fixed - -- Tracked Windows commands retain their published Job Object owner across repeated cancellation and reaping, avoiding a new PID-based stop attempt. Refs #14025. -- A successfully assigned Job Object replaces the auxiliary PID walker. Refs #14025. -- Existing non-job, untracked and standalone tree-tracking limits remain; full process isolation is not established by this change. Refs #14025. 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 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. diff --git a/release-notes/studio/unreleased/14609-escape-closes-innermost-layer.md b/release-notes/studio/unreleased/14609-escape-closes-innermost-layer.md deleted file mode 100644 index d32f0ccdc4..0000000000 --- a/release-notes/studio/unreleased/14609-escape-closes-innermost-layer.md +++ /dev/null @@ -1,2 +0,0 @@ -- 修复:在弹窗里放大查看截图时,按 Esc 只退出大图,不再把整个反馈弹窗一起关掉;所有“弹窗里再开一层”的地方都先关最里面一层。修复 #14609。 -- Fixed: pressing Esc on an enlarged screenshot now closes only the screenshot instead of the whole feedback dialog, and every nested layer closes innermost first. Fixes #14609. diff --git a/release-notes/studio/unreleased/14616-queue-hold-notice-clears.md b/release-notes/studio/unreleased/14616-queue-hold-notice-clears.md deleted file mode 100644 index 966e3a50ee..0000000000 --- a/release-notes/studio/unreleased/14616-queue-hold-notice-clears.md +++ /dev/null @@ -1,2 +0,0 @@ -- 修复:点“继续派发”后,输入框上方“待发送已暂停”的黄色提示会随之消失,不再残留。修复 #14616。 -- Fixed: the yellow "queue paused" notice now goes away once dispatch resumes instead of lingering. Fixes #14616. diff --git a/release-notes/studio/unreleased/14624-modal-focus-ring.md b/release-notes/studio/unreleased/14624-modal-focus-ring.md deleted file mode 100644 index 5bc08b8a75..0000000000 --- a/release-notes/studio/unreleased/14624-modal-focus-ring.md +++ /dev/null @@ -1,2 +0,0 @@ -- 修复:用鼠标点开设置等弹窗后按 Esc 关闭,左侧图标栏按钮不再带着焦点环和提示框停在“悬停”样子;键盘打开的仍保留焦点环。关联 #14624。 -- Fixed: after a mouse-opened dialog is closed with Esc, the icon rail button no longer keeps a focus ring and hint as if hovered. Refs #14624. diff --git a/release-notes/studio/unreleased/14625-settings-type-roles.md b/release-notes/studio/unreleased/14625-settings-type-roles.md deleted file mode 100644 index 0337c34ba5..0000000000 --- a/release-notes/studio/unreleased/14625-settings-type-roles.md +++ /dev/null @@ -1,2 +0,0 @@ -- 改进:设置页里的文字统一成同一套样式,字号从 10 种减到 5 种,字重和灰色也各收成固定几档,页面看起来更整齐。关联 #14625。 -- Changed: text on the settings pages now follows one set of styles: 5 font sizes instead of 10, and fixed weights and grey tones. Refs #14625. diff --git a/release-notes/studio/unreleased/14628-workbench-git-scroll.md b/release-notes/studio/unreleased/14628-workbench-git-scroll.md deleted file mode 100644 index 9890ce5b40..0000000000 --- a/release-notes/studio/unreleased/14628-workbench-git-scroll.md +++ /dev/null @@ -1,2 +0,0 @@ -- 修复:工作区里有多个 Git 仓库时,工作台的改动列表不再撑出面板,现在有自己的滚动条,下面的文件列表也不会被挤出去。修复 #14628。 -- Fixed: with several Git repositories in a workspace, the workbench changes list no longer grows past the panel; it scrolls on its own and the file list stays visible. Fixes #14628. diff --git a/release-notes/studio/unreleased/14658-installer-closes-running-studio.md b/release-notes/studio/unreleased/14658-installer-closes-running-studio.md new file mode 100644 index 0000000000..7c46621f72 --- /dev/null +++ b/release-notes/studio/unreleased/14658-installer-closes-running-studio.md @@ -0,0 +1,2 @@ +- 改进:安装升级时 Studio 还在运行,安装程序会连同内核一起关掉,必要时请求一次管理员权限;仍关不掉就用中文说明怎么退出。修复 #14658。 +- Changed: during install or upgrade the installer closes a running Studio with its kernel, asks once for administrator permission if needed, and says how to quit by hand. Fixes #14658. 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. 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) 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"},