From b5d83e239024a4f370f79eda9c3c8ffe088e18e4 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 21 Sep 2026 13:23:14 +0800 Subject: [PATCH 1/2] feat(desktop): add host-scoped pricing override editor Replace the disconnected Pricing table with an overrides-only editor and searchable built-in catalog picker. Preserve reviewed CAS snapshots, drafts across Host changes, and explicit reconciliation without replaying uncertain writes. Rebuild the Pricing changes from #4164 on current main, keeping Usage pagination and wire compatibility. Localize catalog validation semantics to the editor and include focused, browser, IPC and Electron coverage. Generated-by: Claude Code Generated-by: Codex --- apps/desktop/e2e-budget.json | 4 + apps/desktop/e2e/settings-pricing.spec.ts | 68 + apps/desktop/renderer-architecture.json | 10 + .../desktop-session-projection.test.ts | 1 - .../src/main/__tests__/pricing-editor.test.ts | 1479 +++++++++++++++++ .../main/__tests__/pricing-view-model.test.ts | 136 ++ .../runtime-host-client-pricing.test.ts | 2 +- .../runtime-host-pricing-ipc-main.test.ts | 193 +++ .../runtime-host-usage-ipc-main.test.ts | 4 +- .../__tests__/usage-settings-view.test.ts | 4 +- apps/desktop/src/main/runtime-host-boot.ts | 3 +- apps/desktop/src/main/runtime-host-client.ts | 125 +- .../src/main/runtime-host-pricing-ipc-main.ts | 161 ++ .../src/main/runtime-host-usage-ipc-main.ts | 79 +- apps/desktop/src/preload/bridge-contract.d.ts | 13 + apps/desktop/src/preload/preload.ts | 33 + .../composition/desktop-feature-services.tsx | 7 +- .../src/renderer/features/usage/README.md | 68 +- .../usage/controller/pricing-controller.ts | 598 +++++++ .../src/renderer/features/usage/index.ts | 6 + .../src/renderer/features/usage/ports.ts | 12 + .../renderer/features/usage/pricing-ports.ts | 61 + .../usage/pricing-services-context.tsx | 33 + .../features/usage/pricing-view-model.ts | 148 ++ .../features/usage/services-context.tsx | 33 +- .../src/renderer/features/usage/testing.ts | 17 + .../features/usage/ui/pricing-editor.tsx | 652 ++++++++ .../features/usage/ui/usage-settings-view.tsx | 99 +- .../renderer/locales/settings-pricing-copy.ts | 359 ++++ .../renderer/locales/settings-usage-copy.ts | 29 +- .../desktop/create-usage-pricing-services.ts | 39 + .../renderer/settings/settings-surface.tsx | 2 +- .../renderer/settings/usage-settings-page.tsx | 6 + .../src/renderer/styles/settings/usage.css | 6 + .../src/shared/desktop-pricing-decode.ts | 109 ++ apps/desktop/src/shared/desktop-pricing.d.ts | 75 + .../settings/pricing-editor.stories.tsx | 347 ++++ .../settings/settings-pages.stories.tsx | 101 +- docs/astryx-surface-file-inventory.md | 4 +- docs/astryx-surface-file-inventory.paths | 2 + .../pr/pricing-overrides-only/after.png | Bin 0 -> 100046 bytes .../pr/pricing-overrides-only/before.png | Bin 0 -> 99608 bytes packages/core/src/settings.ts | 13 +- .../pricing-reconciliation-helpers.json | 5 + .../__tests__/usage-pricing-protocol.test.ts | 45 + .../src/protocol/usage-pricing.ts | 52 + scripts/storybook-visual-smoke.mjs | 64 +- 47 files changed, 5027 insertions(+), 280 deletions(-) create mode 100644 apps/desktop/e2e/settings-pricing.spec.ts create mode 100644 apps/desktop/src/main/__tests__/pricing-editor.test.ts create mode 100644 apps/desktop/src/main/__tests__/pricing-view-model.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts create mode 100644 apps/desktop/src/main/runtime-host-pricing-ipc-main.ts create mode 100644 apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts create mode 100644 apps/desktop/src/renderer/features/usage/pricing-ports.ts create mode 100644 apps/desktop/src/renderer/features/usage/pricing-services-context.tsx create mode 100644 apps/desktop/src/renderer/features/usage/pricing-view-model.ts create mode 100644 apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx create mode 100644 apps/desktop/src/renderer/locales/settings-pricing-copy.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts create mode 100644 apps/desktop/src/shared/desktop-pricing-decode.ts create mode 100644 apps/desktop/src/shared/desktop-pricing.d.ts create mode 100644 apps/desktop/stories/settings/pricing-editor.stories.tsx create mode 100644 docs/images/pr/pricing-overrides-only/after.png create mode 100644 docs/images/pr/pricing-overrides-only/before.png create mode 100644 packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index a1ede11687..da6104bbff 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -49,6 +49,10 @@ "tests": 5, "electron": "Git changes re-read on native window focus; native PTY ownership survives Session navigation and renderer replacement, while Stop/exit cross preload/main into the terminal view; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload." }, + "settings-pricing.spec.ts": { + "tests": 1, + "electron": "the installed contextBridge, main IPC handlers and selected-Host adapter carry Pricing writes and return authoritative values through a fresh preload after renderer reload; injected services and handler fakes cannot detect missing registrations, incorrect Host scope or serialization loss. Picker, tabs and focus are covered by the existing Pricing and Settings Storybook plays" + }, "settings.spec.ts": { "tests": 2, "electron": "Code Mode reads back Host settings through IPC; opening Settings commits a pending title rename to the Host, with workbar hide/restore asserted in that same Session." diff --git a/apps/desktop/e2e/settings-pricing.spec.ts b/apps/desktop/e2e/settings-pricing.spec.ts new file mode 100644 index 0000000000..6ad54c489c --- /dev/null +++ b/apps/desktop/e2e/settings-pricing.spec.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ensureSidebarExpanded, expect, test } from './fixtures'; + +// Verifies the installed Pricing contextBridge, IPC handlers and selected-Host +// adapter together. A missing handler, wrong Host scope or lost mutation/result +// serialization is invisible to injected Storybook services and handler fakes. +// The saved override must be readable through a new preload after renderer reload. +// Picker, tab scope and focus contracts live in the existing Pricing stories. +test('pricing writes cross preload and main and remain readable after renderer reload', async ({ + window: page, +}) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置' }).click(); + await page.getByRole('button', { name: '使用统计', exact: true }).click(); + await page + .getByRole('navigation', { name: '使用统计视图' }) + .getByRole('button', { name: '定价配置', exact: true }) + .click(); + + const modelKey = `e2e:pricing-ipc-${Date.now()}`; + const pricing = { modelKey, inputUsdPer1M: 1, outputUsdPer1M: 2 }; + const host = await page.evaluate(() => window.maka.runtimeHostProfiles.getDefaultHost()); + await page.getByRole('button', { name: '添加定价' }).click(); + const editor = page.getByRole('dialog', { name: '添加定价' }); + await editor.getByRole('button', { name: '模型不在列表中?手动输入' }).click(); + await editor.getByRole('textbox', { name: /模型键/ }).fill(modelKey); + await editor.getByRole('textbox', { name: /输入价格/ }).fill('1'); + await editor.getByRole('textbox', { name: /输出价格/ }).fill('2'); + await editor.getByRole('button', { name: '保存' }).click(); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.find((row) => row.pricing.modelKey === modelKey)?.pricing; + }, { host, modelKey })).toEqual(pricing); + + await page.reload(); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.find((row) => row.pricing.modelKey === modelKey)?.pricing; + }, { host, modelKey })).toEqual(pricing); + + const removed = await page.evaluate(async ({ host, modelKey }) => { + const base = await window.maka.settings.pricing.load(host); + return window.maka.settings.pricing.mutate(base, { kind: 'delete', modelKey }, host); + }, { host, modelKey }); + expect(removed.kind).toBe('saved'); + await expect.poll(() => page.evaluate(async ({ host, modelKey }) => { + const snapshot = await window.maka.settings.pricing.load(host); + return snapshot.entries.some((row) => row.pricing.modelKey === modelKey); + }, { host, modelKey })).toBe(false); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6bbde631df..f886fddbaf 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -73,6 +73,7 @@ "src/renderer/locales/settings-memory-copy.ts", "src/renderer/locales/settings-navigation-copy.ts", "src/renderer/locales/settings-preferences-copy.ts", + "src/renderer/locales/settings-pricing-copy.ts", "src/renderer/locales/settings-projects-copy.ts", "src/renderer/locales/settings-shared-copy.ts", "src/renderer/locales/settings-subagents-copy.ts", @@ -1527,6 +1528,15 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/settings-pricing-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/locales/settings-projects-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index bf762c4b3b..775dab3db1 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -232,7 +232,6 @@ test('projects only present Usage Session ids into the Desktop host namespace', byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; diff --git a/apps/desktop/src/main/__tests__/pricing-editor.test.ts b/apps/desktop/src/main/__tests__/pricing-editor.test.ts new file mode 100644 index 0000000000..5449785d3f --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -0,0 +1,1479 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { StrictMode, act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { createDefaultSettings } from '@maka/core/settings'; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../shared/desktop-pricing.js'; +import { + getPricingSettingsCopy, + PricingEditor, + UsagePricingServicesProvider, + UsageFeatureScope, + formatCache, + formatUsd, + type UsageHostRef, + type UsagePricingServices, +} from '../../renderer/features/usage/testing.js'; + +const copy = getPricingSettingsCopy('en'); + +const TEST_RUNTIME_HOST: UsageHostRef = { profileId: 'test-profile', hostId: 'test-host' }; + +const SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'epoch-1', + connectionId: 'conn-1', + revision: 5, + entries: [ + { source: 'builtin', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 2.5, outputUsdPer1M: 10 } }, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 2, outputUsdPer1M: 12 }, + }, + ], +}; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + CSS: (globalThis as { CSS?: unknown }).CSS, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +describe('PricingEditor', () => { + for (const action of ['edit', 'reset', 'add'] as const) { + it(`keeps the viewed CAS base when a background refresh finishes during ${action}`, async () => { + const refresh = deferred(); + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: SNAPSHOT.entries.map((row) => ({ + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { ...row.pricing, inputUsdPer1M: 99 }, + })), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : refresh.promise, + mutate: async () => ({ kind: 'review_required', reason: 'revision_conflict', snapshot: latest }), + }); + await clickWithoutSettling(buttonByLabel(harness.doc, copy.refresh)); + if (action === 'add') await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + else await click(buttonByLabel(harness.doc, action === 'edit' + ? copy.editAria('anthropic:claude') + : copy.resetAria('anthropic:claude'))); + await act(async () => refresh.resolve(latest)); + await click(buttonByText(openDialog(harness.doc)!, action === 'reset' ? copy.confirmReset : copy.save)); + + assert.equal(harness.mutations.length, 1, 'a stale Add reaches CAS instead of getting stuck as a duplicate'); + assert.deepEqual(harness.mutations[0]?.base, SNAPSHOT, 'refresh cannot silently authorize the newer revision'); + assert.match(openDialog(harness.doc)?.textContent ?? '', new RegExp(copy.conflictTitle)); + await act(async () => harness.root.unmount()); + }); + } + + it('retains input typed during a pending save and saves it only on the next submit', async () => { + const pending = deferred(); + const committed = { ...SNAPSHOT, revision: 6 }; + let writes = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ++writes === 1 ? pending.promise + : { kind: 'saved', disposition: 'committed', snapshot: committed }, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '123'); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'committed', snapshot: committed })); + + assert.ok(openDialog(harness.doc), 'the earlier save must not dismiss later input'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '123'); + assert.equal(harness.mutations.length, 1); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2); + assert.deepEqual(harness.mutations[1]?.base, committed); + assert.deepEqual(harness.mutations[1]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 123, outputUsdPer1M: 12 }, + }); + await act(async () => harness.root.unmount()); + }); + + it('submits the latest text when input and submit happen before a render', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + const input = inputByLabel(harness.doc, copy.inputLabel)!; + const form = openDialog(harness.doc)!.querySelector('form')!; + await act(async () => { + input.value = '7.25'; + reactProps(input).onChange?.({ target: input, defaultPrevented: false }); + form.dispatchEvent(new harness.doc.defaultView!.Event('submit', { bubbles: true, cancelable: true })); + }); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 7.25, outputUsdPer1M: 12 }, + }); + await act(async () => harness.root.unmount()); + }); + + for (const outcome of ['saved_refresh_failed', 'reconciliation_unavailable'] as const) { + it(`retains later input when ${outcome} reconciles the submitted Add`, async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: SNAPSHOT.entries.map((row) => row.source === 'builtin' + ? { ...row, source: 'custom', resetEffect: 'restore_builtin' } : row), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : committed, + mutate: async () => outcome === 'saved_refresh_failed' + ? { kind: outcome, disposition: 'committed' } + : { kind: outcome, reason: 'outcome_unknown' }, + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '123'); + await click(buttonByText(openDialog(harness.doc)!, copy.refresh)); + + assert.ok(openDialog(harness.doc), 'matching the submitted intent cannot discard a newer draft'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '123'); + assert.equal(openDialog(harness.doc)?.getAttribute('aria-label'), copy.editTitle, 'the saved Add becomes an Edit'); + await typeInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'another:model'); + assert.equal(harness.mutations.length, 1, 'reconciliation does not replay a write'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2, 'the retained draft can be submitted explicitly'); + assert.deepEqual(harness.mutations[1]?.base, committed); + assert.deepEqual(harness.mutations[1]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 123, outputUsdPer1M: 10 }, + }); + await act(async () => harness.root.unmount()); + }); + } + + it('retries a recovered draft load inside the modal without losing input', async () => { + const retry = deferred(); + const replacement = { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + let reads = 0; + const harness = await renderEditor({ load: async () => { + reads += 1; + if (reads === 1) return SNAPSHOT; + if (reads === 2) throw new Error('Host temporarily unavailable'); + return retry.promise; + } }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '7.25'); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + const dialog = openDialog(harness.doc)!; + assert.match(dialog.textContent ?? '', new RegExp(copy.loadFailedTitle)); + assertButtonDisabled(buttonByText(dialog, copy.reviewHostChange)); + await clickWithoutSettling(buttonByText(dialog, copy.retry)); + assert.match(dialog.textContent ?? '', new RegExp(copy.loading)); + await act(async () => retry.resolve(replacement)); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + assertButtonEnabled(buttonByText(dialog, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(dialog, copy.save)); + await act(async () => harness.root.unmount()); + }); + + for (const text of ['7.25', '7.']) { + it(`preserves unblurred rate text ${text} across a Host gate remount`, async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), text); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, text); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + } + + it('reviews a recovered Add against an existing override and preserves its rates', async () => { + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + hostEpoch: 'epoch-2', + revision: 1, + entries: SNAPSHOT.entries.map((row) => row.source === 'builtin' + ? { source: 'custom', resetEffect: 'restore_builtin', pricing: { ...row.pricing, inputUsdPer1M: 99 } } + : row), + }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : latest, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: latest }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await harness.hideView(); + await harness.rerender('replacement-host:epoch-2'); + assert.ok(openDialog(harness.doc)?.textContent?.includes('$99'), 'review shows the replacement Host price'); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '2.5'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1, 'reviewed Add must not remain duplicate-blocked'); + assert.deepEqual(harness.mutations[0]?.base, latest); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 2.5, outputUsdPer1M: 10 }, + }); + await act(async () => harness.root.unmount()); + }); + + it('rejects a negative rate without clamping it to a free price', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '-5'); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 0); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '-5'); + assert.ok(openDialog(harness.doc)?.textContent?.includes(copy.errorInvalidRate)); + await act(async () => harness.root.unmount()); + }); + + it('renders only the user overrides; built-ins are catalog-only', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + // The custom override is listed with its 自定义 source label. + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + assert.match(harness.container.textContent ?? '', new RegExp(copy.sourceCustomFallback)); + // The built-in is NOT rendered as a table row — it is reachable only through + // the Add flow's catalog picker — nor does the 内置 source label appear. + assert.doesNotMatch(harness.container.textContent ?? '', /openai:gpt-4o/); + assert.doesNotMatch(harness.container.textContent ?? '', /Built-in/); + assert.equal(harness.loadCalls(), 1); + // The Pricing tab loads against the settings-SELECTED Host it was handed — + // not the app's active Host (an omitted arg) — so it stays in lockstep with + // the rest of the settings page. + assert.deepEqual(harness.loadHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('the Add dialog offers a catalog picker with a manual-entry fallback', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + // Catalog mode is the default: no free-text model-key input, plus a toggle to + // manual entry (inferred without depending on the Typeahead's internal DOM). + assert.equal( + inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), + undefined, + 'no free-text model-key input in catalog mode', + ); + const toManual = buttonByText(harness.doc, copy.manualEntryToggle); + assert.ok(toManual, 'manual-entry toggle present in catalog mode'); + // Switching to manual reveals one exact model-key input + a toggle + // back to the catalog. + await click(toManual); + assert.ok(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'manual model-key input'); + assert.ok(buttonByText(harness.doc, copy.catalogToggle), 'catalog toggle present in manual mode'); + await act(async () => harness.root.unmount()); + }); + + it('selecting a built-in catalog model saves an override', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }, + SNAPSHOT.entries[1]!, + ], + }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + await click(buttonByText(harness.doc, copy.add)); + await setInput(inputByPlaceholder(harness.doc, copy.catalogPickerPlaceholder), 'openai'); + const option = Array.from(harness.doc.querySelectorAll('[role="option"]')).find( + (item) => item.textContent?.includes('openai:gpt-4o'), + ); + assert.ok(option, 'catalog search exposes the built-in model'); + await clickElement(option); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 1); + assert.deepEqual(harness.mutations[0]?.mutation, { + kind: 'upsert', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }); + await act(async () => harness.root.unmount()); + }); + + it('switching entry modes clears values that would otherwise be hidden', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:hidden'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.catalogToggle)); + await click(buttonByText(harness.doc, copy.save)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired)); + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); + + it('does not load host-scoped pricing when no Host is selected', async () => { + let loadInvoked = false; + const harness = await renderEditor({ + host: null, + load: async () => { + loadInvoked = true; + return SNAPSHOT; + }, + }); + // No selected Host: the controller resolves an empty state instead of + // reaching the bridge (which would otherwise fall back to the active Host). + assert.equal(loadInvoked, false); + assert.equal(harness.loadCalls(), 0); + // Without a loaded snapshot there is no CAS base, so Add is disabled rather + // than opening an editor whose save would silently no-op. + assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('keeps fresh authority when a StrictMode mount read settles after the replacement read', async () => { + const oldRead = deferred(); + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + strict: true, + load: async () => ++reads === 1 ? oldRead.promise : latest, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: latest }), + }); + assert.equal(harness.loadCalls(), 2, 'StrictMode restarts the initial load'); + await act(async () => oldRead.resolve({ ...SNAPSHOT, entries: [] })); + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.equal(harness.mutations[0]?.base.revision, 6); + await act(async () => harness.root.unmount()); + }); + + it('reset sends a delete against the loaded snapshot', async () => { + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + + const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')); + assert.ok(resetButton, 'reset button is present for a custom-with-fallback row'); + await click(resetButton); + + const confirmButton = buttonByText(harness.doc, copy.confirmReset); + assert.ok(confirmButton, 'confirm dialog exposes the reset action'); + await click(confirmButton); + + assert.equal(harness.mutations.length, 1); + const mutation = harness.mutations[0]!; + // The renderer carries the snapshot it loaded as the CAS base — same revision + // and Host stamp — never a freshly reloaded latest. + assert.deepEqual(mutation.base, SNAPSHOT); + assert.deepEqual(mutation.mutation, { kind: 'delete', modelKey: 'anthropic:claude' }); + // The mutation targets the same settings-selected Host as the load. + assert.deepEqual(harness.mutateHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('moves focus to Add after a successful reset removes its trigger row', async () => { + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')); + const addButton = buttonByText(harness.doc, copy.add); + assert.ok(resetButton); + assert.ok(addButton); + let focused: HTMLElement | null = null; + resetButton.focus = () => { focused = resetButton; }; + addButton.focus = () => { focused = addButton; }; + + await click(resetButton); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.equal(resetButton.isConnected, false, 'the reset trigger leaves with its override row'); + assert.equal(focused, addButton, 'the committed row removal falls back to the stable Add action'); + await act(async () => harness.root.unmount()); + }); + + it('a saved-but-refresh-failed outcome disables further writes', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + // #2015: the committed-but-unrefreshed list is now stale — it must not be + // shown as authoritative, so the previously-listed override is cleared until + // a successful refresh. + assert.doesNotMatch( + harness.container.querySelector('table')?.textContent ?? '', + /anthropic:claude/, + ); + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.emptyTitle), + 'discarded authority must not claim that there are no overrides'); + const addButton = buttonByText(harness.doc, copy.add); + assert.ok(addButton); + // A disabled control that carries its reason via tooltip stays focusable and + // marks itself with aria-disabled rather than the native disabled attribute + // (DESIGN.md §Fields), so the write-block reason stays discoverable. + assert.equal(addButton.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('keeps a saved reset reachable from its dialog until pricing refreshes', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the pending reset stays reachable after refresh failure'); + assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + assert.ok(buttonByText(dialog, copy.refresh), 'recovery is available inside the modal focus trap'); + assertButtonDisabled(buttonByText(dialog, copy.confirmReset)); + await act(async () => harness.root.unmount()); + }); + + it('keeps an upsert draft reachable and refreshes from inside the editor', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'acme:draft', inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : committed; + }, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:draft'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.save)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the draft stays open after the committed refresh failure'); + assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle)); + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:draft'); + const refresh = buttonByText(dialog, copy.refresh); + assert.ok(refresh, 'recovery is available inside the modal focus trap'); + await click(refresh); + + assert.equal(openDialog(harness.doc), undefined, 'a successful refresh completes the saved draft'); + assert.match(harness.container.textContent ?? '', /acme:draft/); + assert.equal(harness.loadCalls(), 2); + assert.equal(harness.mutations.length, 1, 'refresh never replays the upsert'); + await act(async () => harness.root.unmount()); + }); + + it('a reset conflict keeps the dialog and confirms again against fresh authority', async () => { + const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9 }; + let calls = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => { + calls += 1; + return calls === 1 + ? { kind: 'review_required', reason: 'revision_conflict', snapshot: latest } + : { kind: 'saved', disposition: 'committed', snapshot: latest }; + }, + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + // The conflict is surfaced inside the modal focus trap and the action label + // changes before an explicit second confirm — the mutation is never replayed + // blindly while the warning sits behind the dialog. + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'reset dialog stays open on conflict'); + assert.match(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + const confirmAgain = buttonByText(dialog, copy.reviewReset); + assert.ok(confirmAgain, 'the reset action calls out the required review'); + await click(confirmAgain); + + assert.equal(calls, 2); + // The second attempt carries the fresh authority (revision 9) as its base. + assert.equal(harness.mutations[1]?.base.revision, 9); + await act(async () => harness.root.unmount()); + }); + + it('an uncertain outcome blocks writes and explains the required recovery', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); + assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('reconciles an unavailable reset against the next authoritative refresh', async () => { + const latest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + SNAPSHOT.entries[0]!, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'anthropic:claude', + inputUsdPer1M: 7, + outputUsdPer1M: 8, + cacheReadUsdPer1M: 0, + cacheWriteUsdPer1M: 0.4, + }, + }, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : latest; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + await click(buttonByText(dialog, copy.refresh)); + + const text = harness.container.textContent ?? ''; + assert.match(text, new RegExp(copy.conflictTitleUnknown)); + assert.match(text, new RegExp(copy.sourceCustomFallback)); + for (const value of ['$7', '$8', '$0', '$0.4']) assert.ok(text.includes(value)); + assert.equal(harness.mutations.length, 1, 'refresh reconciles without replaying the reset'); + await act(async () => harness.root.unmount()); + }); + + it('clears a cancelled reset conflict before opening another editor', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'review_required', reason: 'revision_conflict', snapshot: SNAPSHOT }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + await click(buttonByText(harness.doc, copy.add)); + + const dialog = openDialog(harness.doc); + assert.ok(dialog); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + assert.ok(buttonByText(dialog, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('keeps a reset cancelled while its recovery read is pending closed', async () => { + const recovery = deferred(); + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : recovery.promise, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + try { + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(openDialog(harness.doc)!, copy.confirmReset)); + await clickWithoutSettling(buttonByText(openDialog(harness.doc)!, copy.refresh)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + assert.equal(openDialog(harness.doc), undefined); + assertButtonDisabled(buttonByText(harness.doc, copy.add)); + + await act(async () => recovery.resolve(latest)); + + assert.equal(Boolean(openDialog(harness.doc)), false, 'a recovery result cannot undo Cancel'); + assertButtonEnabled(buttonByText(harness.doc, copy.add)); + assert.equal(harness.mutations.length, 1, 'the cancelled reset is still reconciled, never replayed'); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + assert.doesNotMatch(openDialog(harness.doc)?.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + assert.ok(buttonByText(openDialog(harness.doc)!, copy.save)); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + it('does not carry a cancelled Add recovery into another model editor', async () => { + const latest = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : latest, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + try { + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(openDialog(harness.doc)!, copy.save)); + await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); + assertButtonDisabled(buttonByText(harness.doc, copy.add)); + await click(buttonByLabel(harness.doc, copy.refresh)); + assert.equal(harness.mutations.length, 1, 'refresh must not replay the cancelled Add'); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + + const dialog = openDialog(harness.doc)!; + assert.equal(inputByLabel(harness.doc, copy.modelKeyLabel)?.value, 'anthropic:claude'); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + await click(buttonByText(dialog, copy.save)); + assert.deepEqual(harness.mutations[1]?.base, latest); + assert.equal(harness.mutations[1]?.mutation.kind, 'upsert'); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + it('does not apply an earlier model conflict after the pending Add changes keys', async () => { + const pending = deferred(); + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => pending.promise, + }); + try { + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:first'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await typeInput(inputByLabel(harness.doc, copy.modelKeyLabel), 'acme:second'); + await act(async () => pending.resolve({ + kind: 'review_required', reason: 'revision_conflict', snapshot: { ...SNAPSHOT, revision: 6 }, + })); + + const dialog = openDialog(harness.doc)!; + assert.equal(inputByLabel(harness.doc, copy.modelKeyLabel)?.value, 'acme:second'); + assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitle)); + assert.ok(buttonByText(dialog, copy.save), 'the new key does not review the earlier key conflict'); + assert.equal(harness.mutations.length, 1); + } finally { + await act(async () => harness.root.unmount()); + } + }); + + it('finishes an unavailable upsert when the refreshed authority matches exactly', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { + modelKey: 'openai:gpt-4o', + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + }, + }, + SNAPSHOT.entries[1]!, + ], + }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : committed; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + await click(buttonByText(dialog, copy.refresh)); + + assert.equal(openDialog(harness.doc), undefined, 'the matched draft is complete'); + assert.equal(harness.mutations.length, 1, 'reconciliation never replays the upsert'); + await act(async () => harness.root.unmount()); + }); + + it('blocks form submission while an upsert still needs reconciliation', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + + await submitEditor(harness.doc); + + assert.equal(harness.mutations.length, 1, 'form submission must obey the same write blocker'); + const dialog = openDialog(harness.doc); + assert.ok(dialog, 'the unreconciled draft stays open'); + assert.match(dialog.textContent ?? '', /openai:gpt-4o/, 'the catalog selection stays visible'); + await act(async () => harness.root.unmount()); + }); + + it('does not mistake equal built-in rates for a reconciled custom override', async () => { + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return SNAPSHOT; + }, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + await click(buttonByText(harness.doc, copy.save)); + await click(buttonByLabel(harness.doc, copy.refresh)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitleUnknown)); + assert.ok(openDialog(harness.doc), 'the draft remains open for explicit review'); + assert.equal(harness.mutations.length, 1); + await act(async () => harness.root.unmount()); + }); + + it('associates required-field errors with their controls after an empty save', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + // Open the Add editor and submit it empty. + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.save)); + + // The required-field message renders, and at least one control is marked + // invalid — the DS wires aria-invalid + aria-describedby to the message, so + // the error is announced against its own field rather than floating free. + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired)); + const invalid = harness.doc.querySelector('[aria-invalid="true"]'); + assert.ok(invalid, 'an empty required field is marked aria-invalid'); + assert.ok( + invalid?.getAttribute('aria-describedby'), + 'the invalid field points at its error message via aria-describedby', + ); + + // No mutation is attempted while the draft is invalid. + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); + + for (const field of ['cacheRead', 'cacheWrite'] as const) { + it(`reveals a collapsed invalid ${field} field when saving`, async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + try { + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.cacheSection)); + const input = inputByLabel(harness.doc, field === 'cacheRead' ? copy.cacheReadLabel : copy.cacheWriteLabel); + await typeInput(input, 'invalid'); + await click(buttonByText(harness.doc, copy.cacheSection)); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 0); + assert.equal(input?.getAttribute('aria-invalid'), 'true'); + assert.equal(buttonByText(harness.doc, copy.cacheSection)?.getAttribute('aria-expanded'), 'true', + 'the field preventing save must be revealed'); + } finally { + await act(async () => harness.root.unmount()); + } + }); + } + + it('a Host generation change preserves the draft and requires review after reloading authority (P1.1)', async () => { + const nextSnapshot: DesktopPricingSnapshot = { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : nextSnapshot; + }, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: nextSnapshot }), + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + // A Host generation bump (new epoch) re-renders with a new generationKey. + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + // The draft remains visible, but the old mutation base is discarded. Even + // after the fresh snapshot loads, save stays disabled until explicit review. + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:new'); + assert.equal(harness.loadCalls(), 2, 'a fresh authority reload ran'); + assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await submitEditor(harness.doc); + assert.equal(harness.mutations.length, 0, 'form submission cannot bypass Host review'); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonEnabled(buttonByText(harness.doc, copy.save)); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1); + assert.deepEqual(harness.mutations[0]?.base, nextSnapshot); + await act(async () => harness.root.unmount()); + }); + + it('does not allow Host-change review until fresh authority loads', async () => { + const secondLoad = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : secondLoad.promise; + }, + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + + assert.match( + harness.container.querySelector('table')?.getAttribute('aria-label') ?? '', + new RegExp(copy.loading), + 'the replacement authority renders as loading immediately', + ); + assertButtonDisabled(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => { + secondLoad.resolve({ ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }); + await Promise.resolve(); + await Promise.resolve(); + }); + assertButtonEnabled(buttonByText(harness.doc, copy.reviewHostChange)); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('preserves the manual draft across the Settings Host gate unmount and rejects its old save', async () => { + const previousSave = deferred(); + const replacement = { ...SNAPSHOT, hostEpoch: 'epoch-2', connectionId: 'conn-2', revision: 1 }; + let reads = 0; + let writes = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : replacement, + mutate: async () => ++writes === 1 + ? previousSave.promise + : { kind: 'saved', disposition: 'committed', snapshot: replacement }, + }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:retained'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1.25'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2.75'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await harness.hideView(); + assert.equal(openDialog(harness.doc), undefined, 'the Settings gate actually unmounts the view'); + await harness.rerender('replacement-host:epoch-2'); + await act(async () => previousSave.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT })); + + assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:retained'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '1.25'); + assert.equal(inputByLabel(harness.doc, copy.outputLabel)?.value, '2.75'); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await click(buttonByText(harness.doc, copy.reviewHostChange)); + await click(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 2); + assert.deepEqual(harness.mutations[1]?.base, replacement); + await act(async () => harness.root.unmount()); + }); + + it('ignores an old-Host save result after the generation changes', async () => { + const pendingSave = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 + ? SNAPSHOT + : { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 }; + }, + mutate: async () => pendingSave.promise, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '3'); + await click(buttonByText(harness.doc, copy.save)); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`); + await act(async () => { + pendingSave.resolve({ + kind: 'saved', + disposition: 'committed', + snapshot: { ...SNAPSHOT, revision: 6 }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle)); + assert.equal(buttonByText(harness.doc, copy.save)?.textContent, copy.save); + assertButtonDisabled(buttonByText(harness.doc, copy.save)); + await act(async () => harness.root.unmount()); + }); + + it('ignores an old-Host save result when the Host event fences it before React re-renders', async () => { + const pendingSave = deferred(); + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => pendingSave.promise, + }); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + assert.equal(harness.mutations.length, 1, 'the save is in flight before the Host fence'); + assert.ok(buttonByText(harness.doc, copy.cancel), 'the edit dialog starts open'); + + // The settings Host event arrives before React can commit the replacement + // generation. Its synchronous fence must already make the in-flight result + // stale; otherwise this old Host can close the draft and claim success. + harness.fenceTarget(); + await act(async () => { + pendingSave.resolve({ + kind: 'saved', + disposition: 'committed', + snapshot: { ...SNAPSHOT, revision: 6 }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.ok(buttonByText(harness.doc, copy.cancel), 'the old-Host result must not close the preserved draft'); + await act(async () => harness.root.unmount()); + }); + + it('does not dispatch a write after the Host event fences the still-visible editor', async () => { + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }), + }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + harness.fenceTarget(); + await click(buttonByText(harness.doc, copy.save)); + + assert.equal(harness.mutations.length, 0, 'the stale Host must receive no write'); + assert.ok(openDialog(harness.doc), 'the draft survives until the replacement Host renders'); + await act(async () => harness.root.unmount()); + }); + + it('submits once when the form is submitted twice before a render', async () => { + const pending = deferred(); + const harness = await renderEditor({ load: async () => SNAPSHOT, mutate: async () => pending.promise }); + await selectCatalogModel(harness.doc, 'openai:gpt-4o'); + const form = openDialog(harness.doc)?.querySelector('form'); + assert.ok(form); + await act(async () => { + const EventClass = harness.doc.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + assert.equal(harness.mutations.length, 1); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'unchanged', snapshot: SNAPSHOT })); + await act(async () => harness.root.unmount()); + }); + + it('a reload landing after a mutation does not overwrite the committed authority (P1.2)', async () => { + // The reset commits a claude-less authority; a refresh started earlier is + // still in flight and will resolve with the PRE-reset snapshot. + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const secondLoad = deferred(); + let loadCall = 0; + const harness = await renderEditor({ + load: async () => { + loadCall += 1; + return loadCall === 1 ? SNAPSHOT : secondLoad.promise; + }, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + // Kick off a manual refresh (reload #2) that stays pending. + await click(buttonByLabel(harness.doc, copy.refresh)); + // Reset the override; the mutation commits the fresh (claude-less) authority. + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + assert.doesNotMatch(harness.container.textContent ?? '', /anthropic:claude/, 'committed authority shown'); + // The stale in-flight refresh resolves with the pre-reset snapshot — it must + // be fenced, not resurrect the deleted override. + await act(async () => { + secondLoad.resolve(SNAPSHOT); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.doesNotMatch( + harness.container.textContent ?? '', + /anthropic:claude/, + 'a stale reload must not overwrite the committed authority', + ); + await act(async () => harness.root.unmount()); + }); + + it('clears an earlier refresh failure when a save returns fresh authority', async () => { + const refresh = deferred(); + const committed = { ...SNAPSHOT, revision: 6 }; + let reads = 0; + const harness = await renderEditor({ + load: async () => ++reads === 1 ? SNAPSHOT : refresh.promise, + mutate: async () => ({ kind: 'saved', disposition: 'unchanged', snapshot: committed }), + }); + await click(buttonByLabel(harness.doc, copy.refresh)); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await act(async () => refresh.reject(new Error('read disconnected'))); + await click(buttonByText(harness.doc, copy.save)); + + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.loadFailedTitle)); + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + assertButtonEnabled(buttonByText(harness.doc, copy.add)); + await act(async () => harness.root.unmount()); + }); + + it('an Add conflict whose key now exists converts to Edit so the second save upserts (P1.3)', async () => { + const conflictLatest: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 7, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'acme:new', inputUsdPer1M: 9, outputUsdPer1M: 9 }, + }, + ], + }; + let calls = 0; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => { + calls += 1; + return calls === 1 + ? { kind: 'review_required', reason: 'revision_conflict', snapshot: conflictLatest } + : { kind: 'saved', disposition: 'committed', snapshot: conflictLatest }; + }, + }); + // Add a brand-new key via the manual fallback. + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + // First save → conflict: the same key was added elsewhere. + await click(buttonByText(harness.doc, copy.save)); + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitle)); + // The Add converted to an Edit locked on the key, so the explicit second save + // upserts (not silently blocked by the duplicate check). + await click(buttonByText(harness.doc, copy.reviewSave)); + assert.equal(calls, 2, 'the second save was allowed'); + assert.equal(harness.mutations.length, 2); + assert.equal(harness.mutations[0]!.mutation.kind, 'upsert'); + assert.deepEqual(harness.mutations[1]!.base, conflictLatest); + const second = harness.mutations[1]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } }; + assert.equal(second.pricing.modelKey, 'acme:new'); + await act(async () => harness.root.unmount()); + }); + + it('adding a built-in key that is not yet overridden is a new override, not a duplicate', async () => { + const committed: DesktopPricingSnapshot = { + ...SNAPSHOT, + revision: 6, + entries: [ + ...SNAPSHOT.entries, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 5, outputUsdPer1M: 15 }, + }, + ], + }; + const harness = await renderEditor({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }), + }); + // Enter a key that exists as a BUILT-IN (openai:gpt-4o, catalog-only) but is + // not yet overridden. Overriding it is an upsert, not a duplicate — the + // catalog picker offers exactly these built-ins, so this must save (a full + // built-in ∪ overrides union check would wrongly block it). + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'openai:gpt-4o'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '5'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '15'); + await click(buttonByText(harness.doc, copy.save)); + + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate)); + assert.equal(harness.mutations.length, 1); + const mutation = harness.mutations[0]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } }; + assert.equal(mutation.kind, 'upsert'); + assert.equal(mutation.pricing.modelKey, 'openai:gpt-4o'); + await act(async () => harness.root.unmount()); + }); + + it('rejects a key that already has a custom override as a duplicate', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + await click(buttonByText(harness.doc, copy.manualEntryToggle)); + // 'anthropic:claude' already has a custom override row — re-adding it is a + // duplicate; the user is told to edit the existing row instead. + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'anthropic:claude'); + await setInput(inputByLabel(harness.doc, copy.inputLabel), '1'); + await setInput(inputByLabel(harness.doc, copy.outputLabel), '2'); + await click(buttonByText(harness.doc, copy.save)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate)); + assert.equal(harness.mutations.length, 0); + await act(async () => harness.root.unmount()); + }); +}); + +describe('pricing display formatting', () => { + const copy = getPricingSettingsCopy('en'); + + it('round-trips positive rates without collapsing to $0 or losing precision', () => { + assert.equal(formatUsd(2.5), '$2.5'); + assert.equal(formatUsd(10), '$10'); + // A small positive rate keeps its digits — never rounded to `$0`. + assert.equal(formatUsd(0.075), '$0.075'); + assert.equal(formatUsd(1.23456789), '$1.23456789'); + // An explicit zero rate (e.g. a free local model) is a real `$0`. + assert.equal(formatUsd(0), '$0'); + }); + + it('keeps an omitted cache rate distinct from an explicit zero', () => { + assert.equal(formatCache(undefined, copy), copy.cacheNotSet); + assert.equal(formatCache(0, copy), '$0'); + assert.equal(formatCache(0.3, copy), '$0.3'); + }); +}); + +async function renderEditor(options: { + load: () => Promise; + mutate?: ( + base: DesktopPricingSnapshot, + mutation: DesktopPricingMutationInput['mutation'], + ) => Promise; + // Omitted → the default selected Host; `null` → no Host selected. + host?: UsageHostRef | null; + strict?: boolean; +}) { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia, scrollTo: () => {} }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + getComputedStyle: (element: Element) => ({ + color: (element as HTMLElement).style?.color || 'currentColor', + }) as CSSStyleDeclaration, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + // Astryx Dialog probes `CSS.supports` during layout; linkedom has no CSS. + CSS: { supports: () => false, escape: (value: string) => value }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const runtimeHost = options.host === null ? undefined : options.host ?? TEST_RUNTIME_HOST; + let loadCalls = 0; + const loadHosts: UsageHostRef[] = []; + const mutateHosts: UsageHostRef[] = []; + const mutations: DesktopPricingMutationInput[] = []; + // Host resolution lives in the preload/adapter for the *selected* Host, threaded + // to the feature as a prop — so the feature-facing services take the Host as + // their first argument (they never resolve it themselves). + const services: UsagePricingServices = { + loadPricing: async (host) => { + loadHosts.push(host); + loadCalls += 1; + return options.load(); + }, + mutatePricing: async (host, base, mutation) => { + mutateHosts.push(host); + mutations.push({ base, mutation }); + return ( + options.mutate?.(base, mutation) ?? + Promise.reject(new Error('mutate is not used by this test')) + ); + }, + }; + + const container = document.querySelector('#root'); + assert.ok(container); + // linkedom's has no showModal/close; Astryx Dialog/AlertDialog call + // them on mount. Patch the element prototype so modal dialogs can render. + const dialogProto = Object.getPrototypeOf(document.createElement('dialog')) as { + showModal?: () => void; + close?: () => void; + }; + dialogProto.showModal = function showModal(this: { open?: boolean }) { + this.open = true; + }; + dialogProto.close = function close(this: { open?: boolean }) { + this.open = false; + }; + const root = createRoot(container); + const defaultGenerationKey = runtimeHost + ? `${runtimeHost.profileId}:${runtimeHost.hostId}:e1` + : 'no-host'; + let currentGenerationKey: string | null = defaultGenerationKey; + function renderTree(generationKey: string, showView = true): void { + currentGenerationKey = generationKey; + const editor = createElement(PricingEditor, { + describeError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + target: runtimeHost + ? { + host: runtimeHost, + generationKey, + isCurrent: () => currentGenerationKey === generationKey, + } + : null, + }); + const scope = createElement(UsageFeatureScope, { + targetKey: generationKey, + services: { + loadUsageStats: async () => null, + updateUsageSettings: async () => createDefaultSettings().usage, + }, + loadErrorTitle: 'Usage load failed', + describeError: String, + children: showView ? editor : null, + }); + const provided = createElement(UsagePricingServicesProvider, { services, children: scope }); + const toasted = createElement(ToastProvider, { children: provided }); + const localized = createElement(AstryxLocaleProvider, { children: toasted }); + const tree = createElement(LocaleProvider, { locale: 'en', children: localized }); + root.render(options.strict ? createElement(StrictMode, null, tree) : tree); + } + await act(async () => { + renderTree(defaultGenerationKey); + await Promise.resolve(); + await Promise.resolve(); + }); + async function rerender(generationKey: string): Promise { + await act(async () => { + renderTree(generationKey); + await Promise.resolve(); + await Promise.resolve(); + }); + } + return { + doc: document as unknown as Document, + container, + root: root as Root, + rerender, + hideView: async () => { + await act(async () => renderTree(currentGenerationKey ?? defaultGenerationKey, false)); + }, + fenceTarget: () => { + currentGenerationKey = null; + }, + loadCalls: () => loadCalls, + loadHosts, + mutateHosts, + mutations, + }; +} + +async function click(button: HTMLButtonElement | undefined) { + assert.ok(button, 'expected a clickable button'); + await act(async () => { + activateButton(button); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function submitEditor(doc: Document): Promise { + const form = openDialog(doc)?.querySelector('form'); + assert.ok(form, 'expected the editor form'); + await act(async () => { + const EventClass = doc.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function clickWithoutSettling(button: HTMLButtonElement | undefined) { + assert.ok(button, 'expected a clickable button'); + await act(async () => { + activateButton(button); + await Promise.resolve(); + }); +} + +function activateButton(button: HTMLButtonElement): void { + button.click(); + // linkedom has no default form activation. Model that step for controller + // tests; the Pricing Populated browser story proves actual Enter submission. + if (button.getAttribute('type') !== 'submit' || button.disabled || button.getAttribute('aria-disabled') === 'true') return; + const formId = button.getAttribute('form'); + const form = formId ? button.ownerDocument.getElementById(formId) : button.closest('form'); + assert.ok(form, 'a submit button must belong to a form'); + const EventClass = button.ownerDocument.defaultView!.Event; + form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true })); +} + +async function clickElement(element: HTMLElement | undefined) { + assert.ok(element, 'expected a clickable element'); + await act(async () => { + element.click(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function selectCatalogModel(doc: Document, modelKey: string): Promise { + await click(buttonByText(doc, copy.add)); + await setInput(inputByPlaceholder(doc, copy.catalogPickerPlaceholder), modelKey); + const option = Array.from(doc.querySelectorAll('[role="option"]')).find( + (item) => item.textContent?.includes(modelKey), + ); + await clickElement(option); +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function inputByPlaceholder(doc: Document, placeholder: string): HTMLInputElement | undefined { + return doc.querySelector(`input[placeholder="${placeholder}"]`) ?? undefined; +} + +function inputByLabel(doc: Document, label: string): HTMLInputElement | undefined { + const labelElement = Array.from(doc.querySelectorAll('label')).find((element) => element.textContent?.includes(label)); + return Array.from(doc.querySelectorAll('input')).find((input) => + (labelElement?.getAttribute('for') === input.id && input.id !== '') || + input.getAttribute('aria-label') === label || + input.getAttribute('aria-labelledby')?.split(' ').some((id) => doc.getElementById(id)?.textContent?.includes(label)), + ); +} + +function reactProps(input: HTMLInputElement): { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + onBlur?: (event: { target: HTMLInputElement }) => void; +} { + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on the input'); + return (input as unknown as Record)[propsKey] as ReturnType; +} + +/** Typing and blur are separate: Host events and writes can finish during input. */ +async function typeInput(input: HTMLInputElement | undefined, value: string): Promise { + assert.ok(input, 'expected an input to fill'); + await act(async () => { + input.value = value; + reactProps(input).onChange?.({ target: input, defaultPrevented: false }); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function setInput(input: HTMLInputElement | undefined, value: string): Promise { + await typeInput(input, value); + assert.ok(input); + await act(async () => { + reactProps(input).onBlur?.({ target: input }); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function buttonByText(root: ParentNode, text: string): HTMLButtonElement | undefined { + return Array.from(root.querySelectorAll('button')).find( + (button) => (button.textContent ?? '').trim() === text, + ); +} + +function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefined { + return ( + doc.querySelector(`button[aria-label="${label}"]`) ?? undefined + ); +} + +function openDialog(doc: Document): HTMLDialogElement | undefined { + return Array.from(doc.querySelectorAll('dialog')).find((dialog) => dialog.open); +} + +function assertButtonDisabled(button: HTMLButtonElement | undefined): void { + assert.ok(button, 'expected button'); + assert.ok(button.disabled || button.getAttribute('aria-disabled') === 'true'); +} + +function assertButtonEnabled(button: HTMLButtonElement | undefined): void { + assert.ok(button, 'expected button'); + assert.equal(button.disabled, false); + assert.notEqual(button.getAttribute('aria-disabled'), 'true'); +} diff --git a/apps/desktop/src/main/__tests__/pricing-view-model.test.ts b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts new file mode 100644 index 0000000000..36d4afff97 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + draftFromPricing, + validatePricingDraft, + type PricingDraft, +} from "../../renderer/features/usage/testing.js"; + +const EMPTY: PricingDraft = { + modelKey: "", + input: "", + output: "", + cacheRead: "", + cacheWrite: "", +}; + +test("prefilled drafts round-trip precision, omitted cache and explicit zero", () => { + const pricing = { + modelKey: "acme:coder-v2", + inputUsdPer1M: 0.000000123456789, + outputUsdPer1M: 2.123456789012345, + cacheReadUsdPer1M: 0, + }; + const { draft, cacheOpen } = draftFromPricing(pricing); + assert.equal(cacheOpen, true); + assert.equal(draft.cacheRead, "0"); + assert.equal(draft.cacheWrite, ""); + assert.deepEqual(validatePricingDraft(draft, { mode: "add", existingKeys: [] }).config, pricing); + assert.equal(draftFromPricing({ ...pricing, cacheReadUsdPer1M: undefined }).cacheOpen, false); +}); + +test("validatePricingDraft add flags an empty model key", () => { + const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.modelKey, "required"); + assert.equal(result.errors.input, "required"); + assert.equal(result.errors.output, "required"); + assert.equal(result.hasErrors, true); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add flags a duplicate key against existing rows", () => { + const draft: PricingDraft = { ...EMPTY, modelKey: "openai:gpt-4o", input: "1", output: "2" }; + const result = validatePricingDraft(draft, { + mode: "add", + existingKeys: ["openai:gpt-4o"], + }); + assert.equal(result.errors.modelKey, "duplicate"); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add builds a canonical config; blank cache is omitted", () => { + const draft: PricingDraft = { + modelKey: " DeepInfra:org/Model:Preview ", + input: "0.8", + output: "2.4", + cacheRead: "", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.hasErrors, false); + assert.deepEqual(result.config, { + modelKey: "DeepInfra:org/Model:Preview", + inputUsdPer1M: 0.8, + outputUsdPer1M: 2.4, + }); + assert.equal(Object.hasOwn(result.config!, "cacheReadUsdPer1M"), false); +}); + +test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", () => { + const draft: PricingDraft = { + modelKey: "acme:coder-v2", + input: "1", + output: "2", + cacheRead: "0", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.config?.cacheReadUsdPer1M, 0); + assert.equal(Object.hasOwn(result.config!, "cacheWriteUsdPer1M"), false); +}); + +test("validatePricingDraft rejects a negative rate", () => { + const draft: PricingDraft = { ...EMPTY, modelKey: "a:b", input: "-1", output: "2" }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.input, "invalid_rate"); + assert.equal(result.config, null); +}); + +test("rate text validates decimal syntax without losing precision or coercing invalid input", () => { + const valid: PricingDraft = { ...EMPTY, modelKey: "a:b", input: ".125", output: "2e-7", cacheRead: "0" }; + assert.deepEqual(validatePricingDraft(valid, { mode: "add", existingKeys: [] }).config, { + modelKey: "a:b", inputUsdPer1M: 0.125, outputUsdPer1M: 2e-7, cacheReadUsdPer1M: 0, + }); + for (const text of ["-", ".", "1e", "0x10", "1.2.3", "Infinity", "NaN", "1e309", "1e-400", "-1e-400"]) { + const result = validatePricingDraft({ ...valid, input: text, cacheRead: text }, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.input, "invalid_rate", text); + assert.equal(result.errors.cacheRead, "invalid_rate", text); + assert.equal(result.config, null, text); + } +}); + +test("validatePricingDraft edit locks the key and ignores the draft key", () => { + const draft: PricingDraft = { + modelKey: "ignored", + input: "3", + output: "4", + cacheRead: "", + cacheWrite: "", + }; + const result = validatePricingDraft(draft, { + mode: "edit", + existingKeys: ["openai:gpt-4o"], + lockedModelKey: "openai:gpt-4o", + }); + assert.equal(result.hasErrors, false); + assert.equal(result.config?.modelKey, "openai:gpt-4o"); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts index 040f2ffb06..1fa784bb15 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts @@ -32,8 +32,8 @@ import type { import { DesktopRuntimeHostClient, DesktopRuntimeHostClientError, - type DesktopPricingSnapshot, } from '../runtime-host-client.js'; +import type { DesktopPricingSnapshot } from '../../shared/desktop-pricing.js'; test('restarts a paginated Pricing read instead of mixing revisions', async () => { const stale = builtin('provider:stale', 1); diff --git a/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts new file mode 100644 index 0000000000..1145c13c66 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Result } from "@maka/core/result"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../../shared/desktop-pricing.js"; +import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { registerRuntimeHostPricingIpc } from "../runtime-host-pricing-ipc-main.js"; +import { registerRuntimeHostUsageIpc } from "../runtime-host-usage-ipc-main.js"; + +function recordingIpc() { + const handlers = new Map(); + return { + handlers, + ipcMain: { + handle: (channel: string, listener: IpcHandler) => handlers.set(channel, listener), + handleReconnectableRead: (channel: string, listener: IpcHandler) => + handlers.set(channel, listener), + }, + }; +} + +const SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: "epoch-1", + connectionId: "conn-1", + revision: 7, + entries: [ + { source: "builtin", pricing: { modelKey: "openai:gpt-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 } }, + ], +}; + +test("pricing IPC registers the two capabilities and fences the legacy handlers", () => { + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostUsageIpc({ + ipcMain, + client: {} as unknown as DesktopRuntimeHostClient, + }); + registerRuntimeHostPricingIpc({ ipcMain, client: {} as unknown as DesktopRuntimeHostClient }); + + assert.ok(handlers.has("usage:pricing:load")); + assert.ok(handlers.has("usage:pricing:mutate")); + // Acceptance #12: the retired direct-Store routes must not coexist. + assert.equal(handlers.has("usage:pricing:list"), false); + assert.equal(handlers.has("usage:pricing:put"), false); + assert.equal(handlers.has("usage:pricing:reset"), false); +}); + +test("pricing load returns the full snapshot as a Result", async () => { + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { loadPricingSnapshot: async () => SNAPSHOT } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:load"); + assert.ok(handler); + const result = (await handler({} as never)) as Result; + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.revision === 7); + assert.ok(result.ok && result.data.entries.length === 1); +}); + +test("pricing mutate passes the renderer-supplied base straight through (no re-read)", async () => { + let received: DesktopPricingMutationInput | undefined; + let loadCalls = 0; + const outcome: DesktopPricingMutationOutcome = { + kind: "saved", + disposition: "committed", + snapshot: { ...SNAPSHOT, revision: 8 }, + }; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + loadPricingSnapshot: async () => { + loadCalls += 1; + return SNAPSHOT; + }, + applyPricingMutation: async (input: DesktopPricingMutationInput) => { + received = input; + return outcome; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, SNAPSHOT, { + kind: "upsert", + pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 }, + })) as Result; + + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.kind === "saved"); + // The base carries the revision the renderer was viewing — the handler must + // NOT reload the latest snapshot to synthesize a base (the retired-path bug). + assert.equal(received?.base.revision, 7); + assert.deepEqual(received?.base, SNAPSHOT); + assert.deepEqual(received?.mutation, { + kind: "upsert", + pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }); + assert.equal(loadCalls, 0); +}); + +test("pricing mutate rejects a malformed base as a failed Result", async () => { + let applyCalls = 0; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + applyPricingMutation: async () => { + applyCalls += 1; + return { kind: "saved_refresh_failed", disposition: "committed" } as const; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, { revision: "nope" }, { + kind: "delete", + modelKey: "acme:coder", + })) as Result; + + assert.equal(result.ok, false); + assert.equal(applyCalls, 0); +}); + +test("pricing mutate reconciles (no replay) when the dispatch outcome is unknown", async () => { + let reconciled: DesktopPricingMutationInput | undefined; + let reconciledReason: string | undefined; + const { handlers, ipcMain } = recordingIpc(); + registerRuntimeHostPricingIpc({ + ipcMain, + client: { + // The initial dispatch could not confirm its outcome on its own + // (likely-lost) connection — and it was a confirmed revision conflict. + applyPricingMutation: async () => + ({ kind: "reconciliation_unavailable", reason: "revision_conflict" }) as const, + // The reconciled-control path reloads fresh authority and compares intent + // WITHOUT re-dispatching the mutation, preserving the original reason. + reconcilePricingMutation: async ( + input: DesktopPricingMutationInput, + reason: "revision_conflict" | "outcome_unknown", + ) => { + reconciled = input; + reconciledReason = reason; + return { + kind: "review_required", + reason, + snapshot: { ...SNAPSHOT, revision: 8 }, + } as const; + }, + } as unknown as DesktopRuntimeHostClient, + }); + const handler = handlers.get("usage:pricing:mutate"); + assert.ok(handler); + + const result = (await handler({} as never, SNAPSHOT, { + kind: "delete", + modelKey: "acme:coder", + })) as Result; + + // The synchronous fallback runs dispatch → reconcile; the reconcile carries + // the renderer's base and the original reason (not a blanket "unknown"). + assert.equal(result.ok, true); + assert.ok(result.ok && result.data.kind === "review_required"); + assert.equal(reconciledReason, "revision_conflict"); + assert.deepEqual(reconciled?.base, SNAPSHOT); + assert.deepEqual(reconciled?.mutation, { kind: "delete", modelKey: "acme:coder" }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts index fa1e688410..ee71b01c33 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -78,12 +78,11 @@ function handlers(queryUsage: (input: UsageQueryInput) => Promise {}, }); return handlers; } const event = {} as Parameters[0]; -test('one screen call atomically supplies totals, complete groups, pricing, and only the first activity page', async () => { +test('one screen call supplies complete usage groups and the first activity page without legacy pricing', async () => { const calls: UsageQueryInput[] = []; const fixture = screen(); const h = handlers(async (input) => { @@ -102,6 +101,7 @@ test('one screen call atomically supplies totals, complete groups, pricing, and assert.equal(stats.logs[0]?.sessionName, 'Host title'); assert.equal(stats.navigation?.nextCursor, 'next'); assert.equal(stats.navigation?.revision, fixture.revision); + assert.equal('pricing' in stats, false, 'Pricing loads its own revisioned Host snapshot'); }); test('initial All load fixes its time bound before dispatch', async () => { let sent: UsageQueryInput | undefined; diff --git a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts index 32caa63f34..463921339f 100644 --- a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts +++ b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts @@ -61,7 +61,6 @@ function statsWithRequests(totalRequests: number): UsageStats { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; } @@ -153,6 +152,7 @@ function tree(opts: { ? createElement(UsageSettingsView, { settings: opts.settings.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }) : null, }), @@ -450,6 +450,7 @@ describe('Usage feature scope', () => { : createElement(UsageSettingsView, { settings: base.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }), }), }), @@ -517,6 +518,7 @@ describe('Usage feature scope', () => { children: createElement(UsageSettingsView, { settings: base.usage, describeError: (error: unknown) => String(error), + runtimeHost: undefined, }), }), }), diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index c61ce4ba32..463178a20a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -264,6 +264,7 @@ import { } from "./runtime-host-settings-ipc-main.js"; import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; +import { registerRuntimeHostPricingIpc } from "./runtime-host-pricing-ipc-main.js"; import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js"; import { resolveShellEnv } from "./shell-env.js"; import { @@ -1804,8 +1805,8 @@ function registerHostClientIpc( registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client, - sendToRenderer, }); + registerRuntimeHostPricingIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostWorkspaceIpc({ ipcMain: scopedIpc, client, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 91287fcd4d..95473cfed8 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -38,11 +38,7 @@ import type { RuntimePolicy, RuntimePolicyMutation, } from "@maka/core/runtime-policy"; -import { - canonicalPricingConfigsEqual, - comparePricingModelKeys, -} from "@maka/core/usage-stats/pricing"; -import type { PricingConfig } from "@maka/core/usage-stats/types"; +import { comparePricingModelKeys } from "@maka/core/usage-stats/pricing"; import { type ClientCapabilityProvider, type DecodedSessionTranscriptPage, @@ -67,6 +63,7 @@ import { } from "@maka/runtime-host/client"; import { ARTIFACT_INGEST_CHUNK_MAX_BYTES, + createPricingReconciliationTarget, decodePricingMutateInput, type ArtifactBinaryPreview, type ArtifactProjection, @@ -91,8 +88,9 @@ import { type OperationOutput, type PlanProjectionItem, type PlanQueryResult, - type PricingMutation, type PricingQueryResult, + pricingReconciliationTargetMatches, + type PricingReconciliationTarget, type ProjectCatalogMutateInput, type ProjectCatalogMutateResult, type ProjectCatalogProject, @@ -156,6 +154,11 @@ import { type WorkspaceProjection, type WorkspaceTarget, } from "@maka/runtime-host/protocol"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../shared/desktop-pricing.js"; const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); @@ -231,13 +234,6 @@ export interface DesktopRuntimeHostSession { close(): Promise; } -export interface DesktopPricingSnapshot { - readonly hostEpoch: string; - readonly connectionId: string; - readonly revision: number; - readonly entries: readonly EffectivePricingEntry[]; -} - export interface DesktopSkillCatalogSnapshot { readonly revision: SkillCatalogRevision; readonly view: SkillCatalogView; @@ -245,39 +241,6 @@ export interface DesktopSkillCatalogSnapshot { readonly workspace: WorkspaceProjection; } -export interface DesktopPricingMutationInput { - readonly base: DesktopPricingSnapshot; - readonly mutation: PricingMutation; -} - -export type DesktopPricingMutationOutcome = - | { - readonly kind: "saved"; - readonly disposition: "committed" | "unchanged"; - readonly snapshot: DesktopPricingSnapshot; - } - | { - readonly kind: "saved_refresh_failed"; - readonly disposition: "committed" | "unchanged"; - } - | { - readonly kind: "synchronized" | "review_required"; - readonly reason: "revision_conflict" | "outcome_unknown"; - readonly snapshot: DesktopPricingSnapshot; - } - | { - readonly kind: "reconciliation_unavailable"; - readonly reason: "revision_conflict" | "outcome_unknown"; - }; - -type PricingReconciliationTarget = - | { readonly kind: "upsert"; readonly pricing: Readonly } - | { - readonly kind: "delete"; - readonly modelKey: string; - readonly expected: "builtin" | "unpriced" | "no_override"; - }; - export class DesktopRuntimeHostClient { readonly #sessions = new Set(); #closeTask: Promise | undefined; @@ -687,7 +650,7 @@ export class DesktopRuntimeHostClient { mutation: input.mutation, }); const reconciliationTarget = createPricingReconciliationTarget( - input.base, + input.base.entries, request.mutation, ); let result: OperationOutput<"pricing.mutate">; @@ -724,6 +687,27 @@ export class DesktopRuntimeHostClient { } } + /** + * Reconcile a pricing write whose outcome the dispatching connection could not + * settle, run against a replacement Host by the Desktop reconciled-control IPC + * after a response-losing disconnect. It reloads fresh authority and compares + * the intended end state; it must never replay the write, and it deliberately + * skips the connection stale-guard because `base` legitimately belongs to the + * previous connection. + */ + async reconcilePricingMutation( + input: DesktopPricingMutationInput, + reason: "revision_conflict" | "outcome_unknown", + ): Promise { + this.#assertOpen(); + const request = decodePricingMutateInput({ + expectedRevision: input.base.revision, + mutation: input.mutation, + }); + const target = createPricingReconciliationTarget(input.base.entries, request.mutation); + return this.#reconcilePricingMutation(target, reason); + } + /** * Recall over this Host's own corpus. * @@ -1814,7 +1798,7 @@ export class DesktopRuntimeHostClient { try { const snapshot = await this.loadPricingSnapshot(); return { - kind: pricingTargetMatchesSnapshot(target, snapshot) + kind: pricingReconciliationTargetMatches(target, snapshot.entries) ? "synchronized" : "review_required", reason, @@ -2069,51 +2053,6 @@ function unstableProjection( ); } -function createPricingReconciliationTarget( - base: DesktopPricingSnapshot, - mutation: PricingMutation, -): PricingReconciliationTarget { - if (mutation.kind === "upsert") - return { kind: "upsert", pricing: mutation.pricing }; - const baseEntry = base.entries.find( - ({ pricing }) => pricing.modelKey === mutation.modelKey, - ); - const expected = - baseEntry?.source === "custom" - ? baseEntry.resetEffect === "restore_builtin" - ? "builtin" - : "unpriced" - : "no_override"; - return { kind: "delete", modelKey: mutation.modelKey, expected }; -} - -function pricingTargetMatchesSnapshot( - target: PricingReconciliationTarget, - snapshot: DesktopPricingSnapshot, -): boolean { - const current = snapshot.entries.find( - ({ pricing }) => pricing.modelKey === pricingTargetModelKey(target), - ); - if (target.kind === "upsert") { - return ( - current?.source === "custom" && - canonicalPricingConfigsEqual(current.pricing, target.pricing) - ); - } - switch (target.expected) { - case "builtin": - return current?.source === "builtin"; - case "unpriced": - return current === undefined; - case "no_override": - return current === undefined || current.source === "builtin"; - } -} - -function pricingTargetModelKey(target: PricingReconciliationTarget): string { - return target.kind === "upsert" ? target.pricing.modelKey : target.modelKey; -} - function pricingEntriesAreCanonical( entries: readonly EffectivePricingEntry[], ): boolean { diff --git a/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts new file mode 100644 index 0000000000..9d52d449e2 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pricing Settings IPC — the renderer's two capabilities from #2015: + * + * - `usage:pricing:load` → one complete effective snapshot (built-in ∪ + * overrides), revision- and connection-stamped. + * - `usage:pricing:mutate` → apply one upsert/delete against the revision the + * renderer was viewing. + * + * The renderer round-trips the exact snapshot it loaded back as the CAS `base`; + * this handler passes it straight to the adapter and never re-reads the latest + * snapshot to synthesize a base (the bug in the retired `usage:pricing:put` + * path, which defeated conflict detection). CAS + reconciliation live entirely + * in `DesktopRuntimeHostClient.applyPricingMutation`. + */ + +import type { Result } from "@maka/core/result"; +import { + normalizePricingConfig, + normalizePricingModelKey, +} from "@maka/core/usage-stats/pricing"; +import type { PricingMutation } from "@maka/runtime-host/protocol"; +import { decodeDesktopPricingSnapshot } from "../shared/desktop-pricing-decode.js"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, +} from "../shared/desktop-pricing.js"; +import { + handleReconciledControl, + handleReconnectableRead, + rethrowReconnectableReadFailure, + type ReconnectableReadIpcMain, + tryReconnectableReadResult, +} from "./ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; + +interface RuntimeHostPricingIpcDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly client: DesktopRuntimeHostClient; +} + +type PricingMutateResult = Result; +type PricingReconcileReason = "revision_conflict" | "outcome_unknown"; +interface PricingReconcileContext { + readonly input: DesktopPricingMutationInput; + readonly reason: PricingReconcileReason; +} + +export function registerRuntimeHostPricingIpc( + deps: RuntimeHostPricingIpcDeps, +): void { + handleReconnectableRead(deps.ipcMain, "usage:pricing:load", () => + tryReconnectableReadResult( + () => deps.client.loadPricingSnapshot(), + "USAGE_PRICING_LOAD_FAILED", + ), + ); + // Reconciled control (like `goal:arm`): when the write's outcome is unknown + // (a response-losing disconnect), defer to the harness to wait for a + // replacement Host and reconcile against it — reload fresh authority and + // compare the intended end state, never replaying the mutation. The original + // conflict reason rides along so a confirmed revision conflict is not later + // reported as merely uncertain. + handleReconciledControl( + deps.ipcMain, + "usage:pricing:mutate", + { + dispatch: async (_event, base: unknown, mutation: unknown) => { + let input: DesktopPricingMutationInput; + try { + input = { + base: decodeDesktopPricingSnapshot(base), + mutation: decodePricingMutation(mutation), + }; + } catch (error) { + return { kind: "completed", value: mutateFailure(error) }; + } + try { + const outcome = await deps.client.applyPricingMutation(input); + // The adapter could not reload on its own (likely-lost) connection; + // wait for a replacement Host and reconcile there instead of + // returning "unavailable" immediately. + if (outcome.kind === "reconciliation_unavailable") { + return { kind: "reconcile", context: { input, reason: outcome.reason } }; + } + return { kind: "completed", value: { ok: true, data: outcome } }; + } catch (error) { + return { kind: "completed", value: mutateFailure(error) }; + } + }, + reconcile: async (context) => { + try { + return { + ok: true, + data: await deps.client.reconcilePricingMutation(context.input, context.reason), + }; + } catch (error) { + rethrowReconnectableReadFailure(error); + return mutateFailure(error); + } + }, + reconciliationUnavailable: async (context) => ({ + ok: true, + data: { kind: "reconciliation_unavailable", reason: context.reason }, + }), + }, + ); +} + +function mutateFailure(error: unknown): PricingMutateResult { + return { + ok: false, + error: { + code: "USAGE_PRICING_MUTATE_FAILED", + message: error instanceof Error ? error.message : String(error), + details: error, + }, + }; +} + +/** + * Shape-guard the renderer-supplied mutation for an early, user-facing error. + * The Host is still the authoritative validator — the adapter re-decodes this + * before dispatch — but rejecting a malformed payload here beats throwing deep + * inside the adapter. + */ +function decodePricingMutation(value: unknown): PricingMutation { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Pricing mutation must be an object"); + } + const record = value as Record; + if (record.kind === "upsert") { + const normalized = normalizePricingConfig(record.pricing); + if (!normalized.ok) throw new Error(normalized.error); + return { kind: "upsert", pricing: normalized.value }; + } + if (record.kind === "delete") { + const normalized = normalizePricingModelKey(record.modelKey); + if (!normalized.ok) throw new Error(normalized.error); + return { kind: "delete", modelKey: normalized.value }; + } + throw new Error('Pricing mutation kind must be "upsert" or "delete"'); +} diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index cf475b7ec3..e56f61d9b1 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -18,14 +18,8 @@ */ import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; -import { tryResult } from "@maka/core/result"; import type { UsageRange, UsageStats, UsageScreenQuery, UsageScreenFailure, UsageScreenRequest } from "@maka/core/settings"; -import { - normalizePricingConfig, - normalizePricingModelKey, -} from "@maka/core/usage-stats/pricing"; import type { - PricingConfig, UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; @@ -42,23 +36,12 @@ import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; interface RuntimeHostUsageIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; - readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, ): void { - let pricingMutationQueue: Promise = Promise.resolve(); - const enqueuePricingMutation = (operation: () => Promise): Promise => { - const result = pricingMutationQueue.then(operation); - pricingMutationQueue = result.then( - () => undefined, - () => undefined, - ); - return result; - }; - handleReconnectableRead( deps.ipcMain, "settings:usageStats", @@ -116,45 +99,7 @@ export function registerRuntimeHostUsageIpc( }; }, "USAGE_LOGS_FAILED"), ); - handleReconnectableRead(deps.ipcMain, "usage:pricing:list", () => - tryReconnectableReadResult(async () => { - const snapshot = await deps.client.loadPricingSnapshot(); - return snapshot.entries - .filter((entry) => entry.source === "custom") - .map((entry) => entry.pricing); - }, "USAGE_PRICING_LIST_FAILED"), - ); - deps.ipcMain.handle("usage:pricing:put", (_event, pricing: unknown) => - tryResult( - () => - enqueuePricingMutation(async () => { - const normalized = normalizePricingConfig(pricing); - if (!normalized.ok) throw new Error(normalized.error); - await applyPricingMutation(deps.client, { - kind: "upsert", - pricing: normalized.value, - }); - deps.sendToRenderer("usage:pricing:changed"); - return normalized.value; - }), - "USAGE_PRICING_PUT_FAILED", - ), - ); - deps.ipcMain.handle("usage:pricing:reset", (_event, modelKey: unknown) => - tryResult( - () => - enqueuePricingMutation(async () => { - const normalized = normalizePricingModelKey(modelKey); - if (!normalized.ok) throw new Error(normalized.error); - await applyPricingMutation(deps.client, { - kind: "delete", - modelKey: normalized.value, - }); - deps.sendToRenderer("usage:pricing:changed"); - }), - "USAGE_PRICING_RESET_FAILED", - ), - ); + } async function loadUsageStats( @@ -167,7 +112,7 @@ async function loadUsageStats( }}); if (result.kind === "screen_response_too_large") return result; if (result.kind !== "screen") throw invalidUsageProjection(); - const {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal, ...stats} = result.screen; + const {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal, pricing: _pricing, ...stats} = result.screen; return {...stats, navigation: {revision, queryIdentity, query: resolvedQuery, nextCursor, activityTotal}}; } @@ -223,26 +168,6 @@ function toToolQuery(query: UsageQuery) { }; } -async function applyPricingMutation( - client: DesktopRuntimeHostClient, - mutation: - | { readonly kind: "upsert"; readonly pricing: PricingConfig } - | { readonly kind: "delete"; readonly modelKey: string }, -): Promise { - const outcome = await client.applyPricingMutation({ - base: await client.loadPricingSnapshot(), - mutation, - }); - if ( - outcome.kind === "saved" || - outcome.kind === "saved_refresh_failed" || - outcome.kind === "synchronized" - ) { - return; - } - throw new Error("Pricing changed concurrently; reload it before retrying"); -} - function invalidUsageProjection(): Error { return new Error("Runtime Host returned an invalid Usage projection"); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 91cb3ae407..550960a754 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -193,6 +193,11 @@ import type { DesktopSessionSummary, DesktopSessionUpdateResult, } from '../shared/desktop-session-projection.js'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; import type { SessionCollaborationCancelResult, SessionCollaborationImportPhase, @@ -1620,6 +1625,14 @@ export interface MakaBridge { testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; usageStats(range?: UsageRange | Extract, host?: DesktopRuntimeHostRef, query?: UsageScreenQuery): Promise; + pricing: { + load(host: DesktopRuntimeHostRef): Promise; + mutate( + base: DesktopPricingSnapshot, + mutation: PricingMutation, + host: DesktopRuntimeHostRef, + ): Promise; + }; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 523085f800..17a330f5d2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -236,6 +236,11 @@ import { type TestProxyInput, } from '@maka/core/settings/network-settings'; import type { Result } from '@maka/core/result'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { McpConfigAddResult, @@ -3482,6 +3487,34 @@ const makaBridge = { if ('kind' in stats) return stats; return projectDesktopUsageStats(scope, stats); }, + pricing: { + // Load one complete effective snapshot (built-in ∪ overrides), stamped to + // its Host connection/revision; the renderer round-trips it as the CAS base. + async load(host: DesktopRuntimeHostRef): Promise { + const result = await invokeSelectedRuntimeHost>( + host, + 'usage:pricing:load', + ); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }, + // Apply one upsert/delete against the viewed revision (`base`). The adapter + // owns CAS + reconciliation; the outcome encodes committed/conflict/uncertain. + async mutate( + base: DesktopPricingSnapshot, + mutation: PricingMutation, + host: DesktopRuntimeHostRef, + ): Promise { + const result = await invokeSelectedRuntimeHost>( + host, + 'usage:pricing:mutate', + base, + mutation, + ); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }, + }, bots: { listStatuses(): Promise> { return ipcRenderer.invoke('settings:bots:listStatuses'); diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index ef81094374..7c639a5b31 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -37,6 +37,7 @@ import { SessionCollaborationServicesProvider } from '../features/session-collab import { SessionNavigationServicesProvider } from '../features/session-navigation'; import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; +import { UsagePricingServicesProvider } from '../features/usage'; import { WorkbarServicesProvider } from '../features/workbar'; import { OverlaysServicesProvider } from '../features/overlays/index.js'; import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services'; @@ -51,6 +52,7 @@ import { SessionBundleServicesProvider } from '../features/session-bundle'; import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; +import { createDesktopUsagePricingServices } from '../platform/desktop/create-usage-pricing-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; import { createDesktopOverlaysServices } from '../platform/desktop/create-overlays-services'; import { observeReactPerformanceMeasures } from '../platform/desktop/react-performance-measures'; @@ -86,6 +88,7 @@ export function createDesktopFeatureServices() { sessionBundle: createDesktopSessionBundleServices(), sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), + usagePricing: createDesktopUsagePricingServices(), workbar: createDesktopWorkbarServices(), }; } @@ -113,7 +116,9 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + {props.children} + diff --git a/apps/desktop/src/renderer/features/usage/README.md b/apps/desktop/src/renderer/features/usage/README.md index d0ecb07827..5675c30a27 100644 --- a/apps/desktop/src/renderer/features/usage/README.md +++ b/apps/desktop/src/renderer/features/usage/README.md @@ -37,12 +37,38 @@ to a thin wrapper). ## Boundary - `ports.ts` — `UsageServices`: `loadUsageStats(range, query)`, revision-checked - `loadUsageActivity(input)`, and `updateUsageSettings(patch)`. All narrow — the feature consumes only + `loadUsageActivity(input)`, and `updateUsageSettings(patch)`. The feature consumes only `UsageSettings`/`UsageStats`, never the whole `AppSettings`. +- `pricing-ports.ts` + `pricing-services-context.tsx` — the two Host-backed + Pricing capabilities: load one complete effective snapshot and apply one CAS + mutation against the settings-selected Host. +- `controller/pricing-controller.ts` — disposable Pricing authority, conflict, + mutation execution, and Host-generation fencing. An open editor/reset dialog + pins its viewed CAS snapshot; a list refresh cannot advance that write base or + its catalog/duplicate validation. A Host change or view remount recovers only + the scope-owned draft, reloads authority, and requires explicit review of the + new Host's price before the next save. Recovery errors and Retry stay inside + the editor. Review reclassifies Add/Edit against the new Host without changing + rate input. A save retains its submitted draft identity, so its result only + closes that draft; input typed while saving remains open for a later submit. + If reconciliation was temporarily unavailable, it retains that same attempt + and compares its exact intent with the next successful snapshot via the shared + pure reconciliation rules in `@maka/runtime-host/protocol`, without replaying it. + Each attempt also belongs to its dialog generation: cancellation or a different + model key detaches the dialog while authority reconciliation continues. A late + result cannot reopen a cancelled dialog or carry its conflict into another edit. - `services-context.tsx` — `UsageFeatureScope`, the persistent state owner (single complete snapshot with range/query labels, screen/page request tickets, fixed filter time bounds, Host invalidation, and visible stale/capacity failures), plus `useUsageServices()` - and `useUsageStats(range)`. + and `useUsageStats(range)`. It also keeps the Pricing editor's input (mode, + raw rate text, and cache-section state) through `usePricingEditorDraft()`, so + Settings' Host-keyed content and loading gate cannot discard the user's work. + Pricing snapshots and in-flight operations never persist in this scope. +- `pricing-view-model.ts` — validates raw decimal/scientific rate text at the + submission boundary, preserving partial input through remounts. Invalid and + negative rates remain visible for correction; blank cache prices are omitted + and an explicit zero remains zero. Astryx `TextInput` reports every keystroke; + `NumberInput`'s private, blur-committed pending text cannot satisfy this lifetime. - `ui/usage-settings-view.tsx` — the surface (overview + tabs + per-tab panels). A disposable view: it unmounts on a section change and reads the snapshot from the scope via `useUsageStats`, so leaving/returning re-displays the last @@ -50,13 +76,12 @@ to a thin wrapper). - `ui/usage-stats-table.tsx`, `ui/metric-card.tsx`, `controller/*` — feature-owned presentational + framework helpers (external-only deps). -## Wiring (one deviation from the composition-feature pattern, forced by the ratchet) +## Wiring -Unlike the composition-wired features, `settings-surface.tsx` is itself a frozen +Usage stats remain a transitional exception because `settings-surface.tsx` is a frozen legacy closure file, so it cannot import the feature or a `platform/` adapter, and usage stats are scoped to the *settings-selected* Runtime Host (a settings concept -the app-global composition root does not have). So there is **no `platform/desktop` -adapter / no composition registration — a transitional seam.** `settings-surface.tsx` +the app-global composition root does not have). `settings-surface.tsx` builds a host-bound `loadUsageStats` (via its existing `window.maka.settings.usageStats` call) plus an `updateUsageSettings` that projects the app-settings update down to `UsageSettings`, bundles them as `UsageServices`, and mounts the legacy shim @@ -65,13 +90,18 @@ call) plus an `updateUsageSettings` that projects the app-settings update down t survives a Skeleton/Banner state or a section change; the disposable `UsageSettingsPage` view is rendered in the section content slot and reads the scope via context. The scope takes a `host:epoch` `targetKey` as a **prop** (not a React -`key`): on a change it clears the snapshot and fences the in-flight load *in place*, -so a Host change never remounts the rest of the Settings surface. The Host-change +`key`): on a change it clears the snapshot and fences the in-flight load *in place*. +The scope survives even when Settings replaces its Host-keyed page content. The Host-change handler also calls the scope's imperative `fenceTarget()` *synchronously* (alongside the other Host-scoped resources), rejecting an in-flight old-Host load before React -re-renders the new target. When #4425's composition step lands, only this mounting -seam moves to `composition/desktop-feature-services.tsx` + a stateless -`platform/desktop` adapter — the scope stays feature-owned. +re-renders the new target. That same fence is exposed to the Pricing controller as +an `isCurrent` witness, so an old-Host mutation result cannot land in the event-to- +render gap. Pricing itself is already composition-wired through +`platform/desktop/create-usage-pricing-services.ts` and +`composition/desktop-feature-services.tsx`; only the selected Host is threaded +from the settings surface. When #4425's remaining composition step lands, only +the Usage-stats mounting seam moves to composition plus a stateless Desktop +adapter; the scope stays feature-owned. Copy is **not** a deviation: the view imports `getUsageSettingsCopy` + `UsageSettingsCopy` from `locales/settings-usage-copy.ts` directly. A feature import @@ -82,17 +112,11 @@ shim, since `settings-error-copy` is not a copy catalog. ## Follow-up -- Add a `SettingsSurface` integration test for the mount seam this PR moves. - `usage-settings-view.test.ts` mounts `UsageFeatureScope` + `UsageSettingsView` - directly and drives `fenceTarget()` / `targetKey` by hand; it does not load - `settings-surface.tsx`, so the surface's fence call sites - (`commitSelectedRuntimeHostProfile`, the generation-change handler) and the - `usageTargetKey` derivation are not exercised end-to-end. Those three lifecycle - obligations are exactly what a stale head had regressed with every test green, so - a surface-level test guarding them is the real coverage; it is deferred to keep - this extraction PR contained. -- Add the editable pricing tab (#2015 / PR #4164) as a feature-internal tab, - replacing the read-only pricing tab preserved here. +- The `UsagePricingHostSwitch` Settings story now exercises a real Host lifecycle + event before input blur, page unmount, profile selection, recovered draft, and + save against the replacement Host's CAS base. `usage-settings-view.test.ts` still drives the + stats scope's fence and target key directly; broader stats integration coverage + remains a follow-up. - De-duplicate the controllers. `controller/action-guard.ts` and `controller/optimistic-settings-draft.ts` are feature-local copies of the legacy `settings/` helpers (which keep ~9 consumers and their own tests). They are diff --git a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts new file mode 100644 index 0000000000..a0b0705237 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -0,0 +1,598 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing'; +import { + createPricingReconciliationTarget, + pricingReconciliationTargetMatches, + pricingReconciliationTargetModelKey, + type EffectivePricingEntry, + type PricingMutation, + type PricingReconciliationTarget, +} from '@maka/runtime-host/protocol'; +import { useUsagePricingServices } from '../pricing-services-context.js'; +import { usePricingEditorDraft } from '../services-context.js'; +import type { UsagePricingServices, UsagePricingTarget } from '../pricing-ports.js'; +import { getPricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; +import { useActionGuard } from './action-guard.js'; +import { + draftFromPricing, + validatePricingDraft, + type PricingDraft, + type PricingEditorDraft, +} from '../pricing-view-model.js'; + +// Derive the controller's authority/outcome types from its injected port so the +// port remains the test seam even though it is expressed with shared contracts. +type DesktopPricingSnapshot = Awaited>; +type DesktopPricingMutationOutcome = Awaited>; +type PricingOverride = Extract; + +interface PricingWriteAttempt { + readonly intent: PricingReconciliationTarget; + /** A cancelled dialog or a different model draft no longer owns this result. */ + readonly dialogGeneration: number; + /** Immutable input actually submitted, distinct from subsequent user edits. */ + readonly draft: PricingDraft | null; +} + +/** + * Write blockers from #2015: after a save whose post-commit reload failed, or an + * outcome we could not reconcile, further writes are disabled until a fresh + * snapshot loads. `conflict` keeps the draft and allows an explicit second save + * against the latest snapshot. + */ +export type PricingWriteState = + | { readonly kind: 'idle' } + | { + readonly kind: 'conflict'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly attempt: PricingWriteAttempt; + } + | { + readonly kind: 'refresh_failed'; + readonly attempt: PricingWriteAttempt; + } + | { + readonly kind: 'reconcile_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly attempt: PricingWriteAttempt; + }; + +const EMPTY_DRAFT: PricingDraft = { + modelKey: '', + input: '', + output: '', + cacheRead: '', + cacheWrite: '', +}; + +/** Reclassify against reviewed authority without replacing any rate input. */ +function reviewedEditorDraft( + current: PricingEditorDraft, + snapshot: DesktopPricingSnapshot, +): PricingEditorDraft { + const key = normalizePricingModelKey(current.draft.modelKey); + if (!key.ok) return current; + const row = snapshot.entries.find(({ pricing }) => pricing.modelKey === key.value); + if (row?.source === 'custom') { + return { ...current, mode: 'edit', draft: { ...current.draft, modelKey: key.value } }; + } + // An Edit whose override disappeared becomes Add; a catalog key that is no + // longer bundled stays visible as an exact manual key on the new Host. + return { + ...current, + mode: row?.source === 'builtin' && current.mode !== 'manual' ? 'catalog' : 'manual', + }; +} + +function validateEditorDraft(editor: PricingEditorDraft | null, existingKeys: readonly string[]) { + return validatePricingDraft(editor?.draft ?? EMPTY_DRAFT, { + mode: editor?.mode === 'edit' ? 'edit' : 'add', + existingKeys, + lockedModelKey: editor?.mode === 'edit' ? editor.draft.modelKey : undefined, + }); +} + +/** Owns disposable pricing authority and outcomes; the Usage scope keeps the draft. */ +export function usePricingController(props: { + readonly describeError: (error: unknown) => string; + /** Settings-selected Host plus its lifecycle generation (`host:epoch`). */ + readonly target: UsagePricingTarget | null; +}) { + const services = useUsagePricingServices(); + const { describeError } = props; + const locale = useUiLocale(); + const copy = getPricingSettingsCopy(locale); + const toast = useToast(); + + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editor, setScopeEditor] = usePricingEditorDraft(); + const editorRef = useRef(editor); + editorRef.current = editor; + // List refreshes never replace the snapshot the open dialog was based on. + // Only opening a dialog or explicitly reviewing an outcome adopts authority. + const [mutationBase, setMutationBase] = useState(null); + const draft = editor?.draft ?? EMPTY_DRAFT; + const cacheOpen = editor?.cacheOpen ?? false; + const [writeState, setWriteState] = useState({ kind: 'idle' }); + const dialogGenerationRef = useRef(0); + // A remounted view may recover a draft, but never its former mutation base. + const [needsReview, setNeedsReview] = useState(editor !== null); + const [pendingMutation, setPendingMutation] = useState(null); + const [resetTarget, setResetTarget] = useState(null); + const saving = pendingMutation === 'upsert'; + const resetBusy = pendingMutation === 'delete'; + const triggerRef = useRef(null); + const addButtonRef = useRef(null); + const focusRestorePendingRef = useRef(false); + + const guard = useActionGuard(); + // A single lifecycle generation fences Host replacement and unmount, + // including StrictMode's effect cleanup/restart. + const lifecycleRef = useRef(0); + // Authority sequence: bumped by a reload start (a newer reload supersedes an + // older one) AND by a committed mutation (`applyOutcome`). A reload captures + // it and drops its result if it changed while in flight — so a slow refresh + // started before a save can never land back on top of the saved authority, nor + // reset a `refresh_failed`/`reconcile` write-block to idle. + const reloadTicketRef = useRef(0); + const targetKey = props.target?.generationKey ?? 'no-host'; + const [renderedTargetKey, setRenderedTargetKey] = useState(targetKey); + + // Fence a changed Host during render, before an old asynchronous result can + // land in the event-to-effect gap. React immediately restarts this component + // with the new target key. Keep the editor draft, but discard all authority + // and require review after the replacement snapshot arrives. + if (targetKey !== renderedTargetKey) { + setRenderedTargetKey(targetKey); + lifecycleRef.current += 1; + reloadTicketRef.current += 1; + setSnapshot(null); + setMutationBase(null); + setLoading(true); + setLoadError(null); + setWriteState({ kind: 'idle' }); + setNeedsReview(editor !== null); + setResetTarget(null); + setPendingMutation(null); + guard.finish(); + } + + useEffect(() => () => { + lifecycleRef.current += 1; + reloadTicketRef.current += 1; + }, []); + + function isCurrent( + lifecycle: number, + target = props.target, + ): boolean { + return ( + lifecycleRef.current === lifecycle && + (target === null || target.isCurrent()) + ); + } + + function setEditor(update: SetStateAction) { + const next = typeof update === 'function' ? update(editorRef.current) : update; + if (next?.draft.modelKey.trim() !== editorRef.current?.draft.modelKey.trim()) { + dialogGenerationRef.current += 1; + // Retain unresolved writes for reconciliation, but a settled conflict + // belongs only to the model/dialog that submitted it. + setWriteState((current) => current.kind === 'conflict' ? { kind: 'idle' } : current); + } + // Record input synchronously, even if a pending write settles before React + // commits the input event. Async completions must see this newer draft. + editorRef.current = next; + setScopeEditor(next); + } + + async function reload(): Promise { + const host = props.target?.host; + const pendingWrite = + writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable' + ? writeState + : undefined; + const lifecycle = lifecycleRef.current; + const ticket = ++reloadTicketRef.current; + setLoading(true); + setLoadError(null); + // No selected Host: nothing Host-scoped to load. Resolve to an empty state + // (like the usage stats loader's no-Host path) rather than letting the bridge + // fall back to a *different* (active) Host than the settings page shows. + if (!host) { + if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) { + setSnapshot(null); + setLoadError(null); + setWriteState({ kind: 'idle' }); + setLoading(false); + } + return; + } + try { + const next = await services.loadPricing(host); + if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return; + setLoadError(null); + if (pendingWrite) { + applyOutcome( + { + kind: pricingReconciliationTargetMatches(pendingWrite.attempt.intent, next.entries) + ? 'synchronized' + : 'review_required', + snapshot: next, + reason: + pendingWrite.kind === 'reconcile_unavailable' + ? pendingWrite.reason + : 'revision_conflict', + }, + pendingWrite.attempt, + ); + } else { + setSnapshot(next); + } + } catch (error) { + if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return; + setLoadError(describeError(error)); + } finally { + if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) setLoading(false); + } + } + + // Load on mount and whenever the selected Host generation changes. Settings + // may also remount this view on a Host identity change or a loading gate. + // Either path reloads authority and keeps only the scope-owned user draft; + // lifecycle and target checks reject responses from the discarded view/Host. + useEffect(() => { + void reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetKey]); + + // Overrides-only surface (#2015 / maintainer direction on #2218): the table + // shows only the user's custom rows, and adding one picks from the built-in + // catalog. The Host collapses an overridden built-in into a single `custom` + // entry, so `catalogRows` is naturally the built-ins NOT yet overridden. + const overrideRows = useMemo( + () => snapshot?.entries.filter((row) => row.source === 'custom') ?? [], + [snapshot], + ); + const editorSnapshot = mutationBase ?? snapshot; + const catalogRows = useMemo( + () => editorSnapshot?.entries.filter((row) => row.source === 'builtin') ?? [], + [editorSnapshot], + ); + // Duplicate detection is over the OVERRIDES only (the visible list): picking or + // typing a built-in that is not yet overridden is a NEW override (an upsert), + // not a duplicate — only a key that already has a custom row is rejected + // ("edit its row instead"). Checking the full built-in ∪ overrides union here + // would wrongly flag every catalog pick (all built-ins) as a duplicate and + // block its save. + const overrideKeys = useMemo(() => editorSnapshot?.entries + .filter((row) => row.source === 'custom') + .map((row) => row.pricing.modelKey) ?? [], [editorSnapshot]); + const validation = useMemo( + () => validateEditorDraft(editor, overrideKeys), + [editor, overrideKeys], + ); + + const writesBlocked = + needsReview || + writeState.kind === 'refresh_failed' || + writeState.kind === 'reconcile_unavailable'; + + // Restore focus only after React has committed the dialog/row removal. A + // timer can run while the row action is still connected, focus that doomed + // trigger, and then leave focus on when the commit removes it. The + // layout effect observes the committed DOM and chooses the stable fallback + // when a successful reset/delete removed the opening row. + useLayoutEffect(() => { + if (!focusRestorePendingRef.current) return; + focusRestorePendingRef.current = false; + const trigger = triggerRef.current; + triggerRef.current = null; + if (trigger?.isConnected) trigger.focus(); + else addButtonRef.current?.focus(); + }, [editor, resetTarget]); + + // On a conflict, the fresh-authority row for whatever the user is editing or + // resetting — so the notice can show the latest value beside their draft + // rather than only claiming one exists. + const conflictLatestEntry = useMemo(() => { + if (writeState.kind !== 'conflict') return null; + const key = pricingReconciliationTargetModelKey(writeState.attempt.intent); + return editorSnapshot?.entries.find(({ pricing }) => pricing.modelKey === key) ?? null; + }, [writeState, editorSnapshot]); + + const hostReviewLatestEntry = needsReview + ? snapshot?.entries.find(({ pricing }) => pricing.modelKey === draft.modelKey.trim()) ?? null + : null; + + function restoreTriggerFocus() { + focusRestorePendingRef.current = true; + } + + function openAdd(trigger: HTMLElement | null) { + // No loaded authority (no selected Host, or a load still pending/failed) + // means a save would have no CAS base and silently no-op — so the editor + // must not open. The Add control is disabled for the same reason. + if (writesBlocked || guard.current || snapshot === null) return; + triggerRef.current = trigger; + setMutationBase(snapshot); + setEditor({ mode: 'catalog', draft: EMPTY_DRAFT, cacheOpen: false }); + } + + function openEdit(row: PricingOverride, trigger: HTMLElement | null) { + if (writesBlocked || guard.current || snapshot === null) return; + triggerRef.current = trigger; + setMutationBase(snapshot); + setEditor({ mode: 'edit', ...draftFromPricing(row.pricing) }); + } + + /** + * Pre-fill the open Add draft from a chosen built-in catalog row: the built-in + * price is the starting point for the override, and the cache section opens iff + * the built-in carries cache rates. Stays in add mode — the row is a built-in + * not yet overridden, so its key validates as a new override. + */ + function pickCatalogModel(row: EffectivePricingEntry) { + setEditor({ mode: 'catalog', ...draftFromPricing(row.pricing) }); + } + + function clearModel(mode: 'catalog' | 'manual' = 'catalog') { + setEditor({ mode, draft: EMPTY_DRAFT, cacheOpen: false }); + } + + function setCacheOpen(open: boolean) { + setEditor((current) => current ? { ...current, cacheOpen: open } : null); + } + + function reviewHostChange() { + if (!needsReview || loading || snapshot === null || !props.target?.isCurrent()) return; + setEditor((current) => current ? reviewedEditorDraft(current, snapshot) : null); + setMutationBase(snapshot); + setNeedsReview(false); + } + + function closeEditor() { + if (guard.current) return; + setEditor(null); + setMutationBase(null); + setNeedsReview(false); + if (writeState.kind === 'conflict') { + setWriteState({ kind: 'idle' }); + } + restoreTriggerFocus(); + } + + const setField = (key: K, value: PricingDraft[K]) => + setEditor((current) => current && !(current.mode === 'edit' && key === 'modelKey') + ? { ...current, draft: { ...current.draft, [key]: value } } + : current); + + function finishReconciledIntent( + attempt: PricingWriteAttempt, + latest: DesktopPricingSnapshot, + ): void { + if (attempt.intent.kind === 'delete') { + setResetTarget(null); + setMutationBase(null); + restoreTriggerFocus(); + return; + } + const current = editorRef.current; + if (!current) return; + if (current.draft === attempt.draft) { + setEditor(null); + setMutationBase(null); + restoreTriggerFocus(); + return; + } + // The completed write owns only its submitted input. Keep later edits open + // and promote a saved Add to Edit so its next explicit save stays usable. + if (current.draft.modelKey.trim() === pricingReconciliationTargetModelKey(attempt.intent)) { + setEditor(reviewedEditorDraft(current, latest)); + setMutationBase(latest); + // A different client can write again between our commit and its reload. + // The new draft must review that change before adopting it as its base. + if (!pricingReconciliationTargetMatches(attempt.intent, latest.entries)) { + setWriteState({ kind: 'conflict', reason: 'revision_conflict', attempt }); + } + } + } + + function restoreReconciledIntent( + attempt: PricingWriteAttempt, + latest: DesktopPricingSnapshot, + ): void { + const { intent } = attempt; + const key = pricingReconciliationTargetModelKey(intent); + const latestRow = latest.entries.find(({ pricing }) => pricing.modelKey === key); + if (intent.kind === 'upsert') { + const current = editorRef.current; + if (current?.draft.modelKey.trim() === key) { + setEditor(reviewedEditorDraft(current, latest)); + setMutationBase(latest); + } + return; + } + setMutationBase(latest); + if (latestRow?.source === 'custom') setResetTarget(latestRow); + } + + /** Adopt one settled outcome using the same authority and intent as a reload. */ + function applyOutcome( + outcome: DesktopPricingMutationOutcome, + attempt: PricingWriteAttempt, + ): void { + // Fence any reload that was in flight when this mutation committed, so a + // stale refresh can't overwrite the authority we're about to set (nor reset + // a write-block to idle). Clear its loading indicator too — the fenced + // reload's own `finally` will no longer run. + reloadTicketRef.current += 1; + setLoading(false); + if ('snapshot' in outcome) { + setSnapshot(outcome.snapshot); + setLoadError(null); + } + // Reconciliation still updates authority after Cancel. Only its original + // dialog may be closed, reopened for review, or given a conflict notice. + const ownsDialog = attempt.dialogGeneration === dialogGenerationRef.current; + switch (outcome.kind) { + case 'saved': + setWriteState({ kind: 'idle' }); + if (ownsDialog) finishReconciledIntent(attempt, outcome.snapshot); + toast.success(copy.saved, outcome.disposition === 'unchanged' ? copy.synchronized : undefined); + return; + case 'synchronized': + setWriteState({ kind: 'idle' }); + if (ownsDialog) finishReconciledIntent(attempt, outcome.snapshot); + toast.success(copy.synchronized); + return; + case 'review_required': + if (!ownsDialog) { + setWriteState({ kind: 'idle' }); + return; + } + // Adopt fresh authority into the list so it is no longer speculative, + // keep the draft, and require an explicit second save against `latest`. + setWriteState({ kind: 'conflict', reason: outcome.reason, attempt }); + // If this was an Add and the fresh authority now already has that key + // (added elsewhere), the duplicate check would leave `validation.config` + // null and silently block the required second save. Convert the Add into + // an Edit locked on that key so the explicit re-save upserts against the + // latest revision (the draft's rates are preserved). + restoreReconciledIntent(attempt, outcome.snapshot); + return; + case 'saved_refresh_failed': + // The write committed but the post-commit reload failed — the loaded list + // is now definitely stale. Drop it (#2015: show no speculative final + // list); retain both the draft and intended end state so an in-dialog + // refresh can confirm the committed write without replaying it. + setSnapshot(null); + setWriteState({ kind: 'refresh_failed', attempt }); + return; + case 'reconciliation_unavailable': + setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason, attempt }); + return; + } + } + + /** Every submit path shares the write blockers, CAS base, and lifecycle fence. */ + async function mutate(mutation: PricingMutation): Promise { + const base = mutationBase; + const target = props.target; + if (writesBlocked || !base || !target?.isCurrent()) return; + if (!guard.begin('write')) return; + const lifecycle = lifecycleRef.current; + const attempt: PricingWriteAttempt = { + intent: createPricingReconciliationTarget(base.entries, mutation), + dialogGeneration: dialogGenerationRef.current, + draft: mutation.kind === 'upsert' ? editorRef.current?.draft ?? null : null, + }; + setPendingMutation(mutation.kind); + try { + const outcome = await services.mutatePricing(target.host, base, mutation); + if (!isCurrent(lifecycle, target)) return; + applyOutcome(outcome, attempt); + } catch (error) { + if (isCurrent(lifecycle, target)) { + toast.error(mutation.kind === 'upsert' ? copy.saveFailed : copy.resetFailed, describeError(error)); + } + } finally { + if (isCurrent(lifecycle, target)) { + guard.finish(); + setPendingMutation(null); + } + } + } + + async function save(): Promise { + const current = editorRef.current; + if (!current) return; + const { config } = validateEditorDraft(current, overrideKeys); + if (config) await mutate({ kind: 'upsert', pricing: config }); + } + + function openReset( + row: PricingOverride, + trigger: HTMLElement | null, + ) { + if (writesBlocked || guard.current || snapshot === null) return; + dialogGenerationRef.current += 1; + triggerRef.current = trigger; + setMutationBase(snapshot); + setResetTarget(row); + } + + function cancelReset() { + if (guard.current) return; + dialogGenerationRef.current += 1; + setResetTarget(null); + setMutationBase(null); + if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' }); + restoreTriggerFocus(); + } + + async function confirmReset(): Promise { + if (resetTarget) await mutate({ kind: 'delete', modelKey: resetTarget.pricing.modelKey }); + } + + return { + copy, + addButtonRef, + loading, + loadError, + // A write needs a loaded snapshot as its CAS base; without one (no Host, or + // a load pending/failed) the Add flow is disabled rather than silently + // no-opping on save. + hasAuthority: snapshot !== null, + // Overrides-only table + catalog picker for the Add flow. + overrideRows, + catalogRows, + pickCatalogModel, + clearModel, + editor, + draft, + setField, + cacheOpen, + setCacheOpen, + validation, + writeState, + needsReview, + writesBlocked, + reviewHostChange, + conflictLatestEntry, + hostReviewLatestEntry, + saving, + resetTarget, + resetBusy, + reload, + openAdd, + openEdit, + closeEditor, + save, + openReset, + cancelReset, + confirmReset, + }; +} diff --git a/apps/desktop/src/renderer/features/usage/index.ts b/apps/desktop/src/renderer/features/usage/index.ts index bb59e3d785..461ec90bf1 100644 --- a/apps/desktop/src/renderer/features/usage/index.ts +++ b/apps/desktop/src/renderer/features/usage/index.ts @@ -23,3 +23,9 @@ export { UsageSettingsView } from './ui/usage-settings-view.js'; export { UsageFeatureScope, type UsageScopeHandle } from './services-context.js'; export type { UsageServices } from './ports.js'; +// The editable Pricing surface (#2015) is a Usage tab, but its services are +// assembled in `composition/desktop-feature-services.tsx` (not the legacy +// settings-surface that assembles `UsageServices`), so its bridge access stays +// out of the frozen legacy-AppShell closure. +export { UsagePricingServicesProvider } from './pricing-services-context.js'; +export type { UsagePricingServices } from './pricing-ports.js'; diff --git a/apps/desktop/src/renderer/features/usage/ports.ts b/apps/desktop/src/renderer/features/usage/ports.ts index 1ef853d367..768a796b85 100644 --- a/apps/desktop/src/renderer/features/usage/ports.ts +++ b/apps/desktop/src/renderer/features/usage/ports.ts @@ -19,6 +19,18 @@ import type { UsageRange, UsageSettings, UsageStats, UsageScreenQuery, UsageScreenRequest, UsageScreenFailure, UsageScreenResult } from '@maka/core/settings'; +/** + * Minimal Runtime Host identity the feature threads for Host-scoped reads/writes + * (pricing overrides are per-Host / root-scoped). It is structurally compatible + * with the preload `DesktopRuntimeHostRef`, so the legacy surface can pass its + * `selectedRuntimeHost` straight through as a prop — without the feature + * importing the preload type. + */ +export interface UsageHostRef { + readonly profileId: string; + readonly hostId: string; +} + // Dependency-inversion boundary for the Usage settings feature (issue #4425). // The feature controller owns draft/state and reads these ports; it never // touches `window.maka` or legacy settings helpers directly. Both are narrow — diff --git a/apps/desktop/src/renderer/features/usage/pricing-ports.ts b/apps/desktop/src/renderer/features/usage/pricing-ports.ts new file mode 100644 index 0000000000..9c8ac0a056 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-ports.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Dependency-inversion boundary for the editable Pricing surface (#2015), +// hosted inside the Usage settings feature (#4425). The pricing controller owns +// the draft/CAS state and reads these ports; it never touches `window.maka`. +// +// Unlike the range-scoped `UsageServices` (still assembled inline in the legacy +// `settings/settings-surface.tsx`), pricing services are assembled in +// `composition/desktop-feature-services.tsx` via a `platform/desktop` adapter — +// the composition ownership #4425 targets. Pricing is net-new, so routing its +// `window.maka.settings.pricing` bridge access through the platform adapter is +// what keeps a new bridge path out of the frozen legacy-AppShell closure files +// (the renderer-architecture ratchet forbids growing their bridge paths). +// +import type { PricingMutation } from '@maka/runtime-host/protocol'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../../shared/desktop-pricing.js'; +import type { UsageHostRef } from './ports.js'; + +export interface UsagePricingTarget { + readonly host: UsageHostRef; + readonly generationKey: string; + /** True only while this exact Host generation remains authoritative. */ + readonly isCurrent: () => boolean; +} + +export interface UsagePricingServices { + /** + * One complete effective pricing snapshot (built-in ∪ overrides) for the given + * Runtime Host. The `host` is the settings-*selected* Host (threaded from the + * legacy surface), not the app's active Host — pricing overrides are per-Host, + * so the Pricing tab must read/write the same Host as the rest of the settings + * page. The renderer round-trips the snapshot as the CAS base for a mutation. + */ + loadPricing(host: UsageHostRef): Promise; + /** Apply one pricing upsert/delete against the viewed snapshot (the CAS base). */ + mutatePricing( + host: UsageHostRef, + base: DesktopPricingSnapshot, + mutation: PricingMutation, + ): Promise; +} diff --git a/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx new file mode 100644 index 0000000000..0759a3414f --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { UsagePricingServices } from './pricing-ports.js'; + +// One app-root provider supplies the transport adapter. Each operation receives +// the settings-selected Host explicitly; the controller separately receives its +// generation key so it can fence stale work without remounting the provider. +const { Provider, useServices } = + createServicesContext('UsagePricingServicesProvider'); + +export const UsagePricingServicesProvider = Provider; + +export function useUsagePricingServices(): UsagePricingServices { + return useServices(); +} diff --git a/apps/desktop/src/renderer/features/usage/pricing-view-model.ts b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts new file mode 100644 index 0000000000..22db6fc248 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pricing draft conversion and field validation. The UI reads the Host's + * effective entries directly; only editable drafts need a different shape. + */ + +import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing'; +import type { PricingConfig } from '@maka/core/usage-stats/types'; + +export interface PricingDraft { + readonly modelKey: string; + /** Raw input, including incomplete decimals; an empty string means not set. */ + readonly input: string; + readonly output: string; + readonly cacheRead: string; + readonly cacheWrite: string; +} + +/** In-memory user input survives the Settings Host/loading gates remounting the view. */ +export interface PricingEditorDraft { + readonly mode: 'catalog' | 'manual' | 'edit'; + readonly draft: PricingDraft; + readonly cacheOpen: boolean; +} + +/** + * The editor draft pre-filled from an existing row — shared by the Edit flow and + * the Add flow's catalog pick. `cacheOpen` is true iff the row carries either + * cache rate (an explicit `0` counts; only `undefined` is "Not set"). + */ +export function draftFromPricing(pricing: PricingConfig): { + readonly draft: PricingDraft; + readonly cacheOpen: boolean; +} { + return { + draft: { + modelKey: pricing.modelKey, + input: String(pricing.inputUsdPer1M), + output: String(pricing.outputUsdPer1M), + cacheRead: pricing.cacheReadUsdPer1M === undefined ? '' : String(pricing.cacheReadUsdPer1M), + cacheWrite: pricing.cacheWriteUsdPer1M === undefined ? '' : String(pricing.cacheWriteUsdPer1M), + }, + cacheOpen: pricing.cacheReadUsdPer1M !== undefined || pricing.cacheWriteUsdPer1M !== undefined, + }; +} + +export type PricingRateErrorCode = 'required' | 'invalid_rate'; +export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate'; + +export interface PricingDraftErrors { + modelKey?: PricingKeyErrorCode; + input?: PricingRateErrorCode; + output?: PricingRateErrorCode; + cacheRead?: 'invalid_rate'; + cacheWrite?: 'invalid_rate'; +} + +export interface PricingDraftValidation { + readonly errors: PricingDraftErrors; + readonly hasErrors: boolean; + /** The canonical config to send, present iff `hasErrors` is false. */ + readonly config: PricingConfig | null; +} + +export function validatePricingDraft( + draft: PricingDraft, + options: { + readonly mode: 'add' | 'edit'; + readonly existingKeys: readonly string[]; + /** Required in edit mode — the fixed identity key. */ + readonly lockedModelKey?: string; + }, +): PricingDraftValidation { + const errors: PricingDraftErrors = {}; + + let modelKey: string | null = null; + if (options.mode === 'edit') { + modelKey = options.lockedModelKey ?? null; + } else { + const normalized = normalizePricingModelKey(draft.modelKey); + if (!normalized.ok) { + errors.modelKey = draft.modelKey.trim() === '' ? 'required' : 'key_too_long'; + } else if (options.existingKeys.includes(normalized.value)) { + errors.modelKey = 'duplicate'; + } else { + modelKey = normalized.value; + } + } + + const input = parseRate(draft.input); + const output = parseRate(draft.output); + const cacheRead = parseRate(draft.cacheRead); + const cacheWrite = parseRate(draft.cacheWrite); + if (input === null) errors.input = 'required'; + else if (!isValidRate(input)) errors.input = 'invalid_rate'; + if (output === null) errors.output = 'required'; + else if (!isValidRate(output)) errors.output = 'invalid_rate'; + if (cacheRead !== null && !isValidRate(cacheRead)) errors.cacheRead = 'invalid_rate'; + if (cacheWrite !== null && !isValidRate(cacheWrite)) errors.cacheWrite = 'invalid_rate'; + + const hasErrors = Object.keys(errors).length > 0; + const config: PricingConfig | null = + !hasErrors && modelKey !== null && input !== null && output !== null + ? { + modelKey, + inputUsdPer1M: input, + outputUsdPer1M: output, + ...(cacheRead !== null ? { cacheReadUsdPer1M: cacheRead } : {}), + ...(cacheWrite !== null ? { cacheWriteUsdPer1M: cacheWrite } : {}), + } + : null; + + return { errors, hasErrors, config }; +} + +function parseRate(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === '') return null; + // Accept decimal/scientific notation (including String()'s tiny rates), but + // never coerce malformed text, hexadecimal, or a negative price to zero. + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) return NaN; + const value = Number(trimmed); + // A nonzero rate below Number's range must not silently become free. + if (value === 0 && /[1-9]/.test(trimmed.split(/e/i)[0]!)) return NaN; + return value; +} + +function isValidRate(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} diff --git a/apps/desktop/src/renderer/features/usage/services-context.tsx b/apps/desktop/src/renderer/features/usage/services-context.tsx index 213daed90c..48414475f0 100644 --- a/apps/desktop/src/renderer/features/usage/services-context.tsx +++ b/apps/desktop/src/renderer/features/usage/services-context.tsx @@ -27,11 +27,14 @@ import { useRef, useState, type ReactNode, + type Dispatch, + type SetStateAction, } from 'react'; import { useMountedRef, useToast } from '@maka/ui'; import { resolveUsageRange } from '@maka/core/model-call-usage-projection'; import type { UsageRange, UsageStats, UsageScreenQuery } from '@maka/core/settings'; import type { UsageServices } from './ports.js'; +import type { PricingEditorDraft } from './pricing-view-model.js'; interface UsageSnapshot { readonly installation: number; @@ -46,6 +49,10 @@ interface UsageScopeValue { readonly services: UsageServices; readonly snapshot: UsageSnapshot | null; readonly targetKey: string; + /** True only while the rendered Host generation is still authoritative. */ + isCurrentTarget(): boolean; + readonly pricingEditor: PricingEditorDraft | null; + readonly setPricingEditor: Dispatch>; readonly state: 'ready' | 'loading' | 'stale' | 'error'; readonly error: string | null; readonly paging: boolean; @@ -69,6 +76,9 @@ export const UsageFeatureScope = forwardRef< const toast = useToast(); const mountedRef = useMountedRef(); const [snapshot, setSnapshot] = useState(null); + const [pricingEditor, setPricingEditor] = useState(null); + const targetCurrentRef = useRef(true); + const renderedTargetKeyRef = useRef(props.targetKey); const [state, setState] = useState('ready'); const [error, setError] = useState(null); const [paging, setPaging] = useState(false); @@ -78,6 +88,10 @@ export const UsageFeatureScope = forwardRef< const pagingRef = useRef(false); const resolvedRef = useRef<{ range: UsageRange; query: UsageScreenQuery } | null>(null); const { targetKey, services, loadErrorTitle, describeError } = props; + const isCurrentTarget = useCallback( + () => targetCurrentRef.current && renderedTargetKeyRef.current === targetKey, + [targetKey], + ); const clear = () => { ticketRef.current += 1; blockedRef.current = true; @@ -90,6 +104,8 @@ export const UsageFeatureScope = forwardRef< }; if (targetKey !== renderedTargetKey) { setRenderedTargetKey(targetKey); + renderedTargetKeyRef.current = targetKey; + targetCurrentRef.current = true; clear(); } @@ -210,10 +226,15 @@ export const UsageFeatureScope = forwardRef< [snapshot, services, mountedRef, describeError], ); - useImperativeHandle(ref, () => ({ fenceTarget: clear })); + useImperativeHandle(ref, () => ({ + fenceTarget: () => { + targetCurrentRef.current = false; + clear(); + }, + })); const value = useMemo( - () => ({ services, snapshot, targetKey, state, error, paging, reload, loadMore }), - [services, snapshot, targetKey, state, error, paging, reload, loadMore], + () => ({ services, snapshot, targetKey, state, error, paging, reload, loadMore, isCurrentTarget, pricingEditor, setPricingEditor }), + [services, snapshot, targetKey, state, error, paging, reload, loadMore, isCurrentTarget, pricingEditor], ); return {props.children}; }); @@ -225,6 +246,12 @@ function useUsageScope(): UsageScopeValue { export function useUsageServices(): UsageServices { return useUsageScope().services; } +/** Only user input persists above the Host gate; pricing authority stays view-owned. */ +export function usePricingEditorDraft() { + const { pricingEditor, setPricingEditor } = useUsageScope(); + return [pricingEditor, setPricingEditor] as const; +} + export function useUsageStats(_range: UsageRange) { const { snapshot, ...scope } = useUsageScope(); return { diff --git a/apps/desktop/src/renderer/features/usage/testing.ts b/apps/desktop/src/renderer/features/usage/testing.ts index 882e0f3bf4..301796ae7c 100644 --- a/apps/desktop/src/renderer/features/usage/testing.ts +++ b/apps/desktop/src/renderer/features/usage/testing.ts @@ -17,4 +17,21 @@ * under the License. */ +// Test-only entry point for the Usage feature (issue #4425 / #2015). External +// test consumers import feature internals through this barrel so the +// renderer-architecture ratchet's "features import via index/testing/stories" +// rule stays satisfied. + +export { + draftFromPricing, + validatePricingDraft, + type PricingDraft, +} from './pricing-view-model.js'; +export { PricingEditor, formatCache, formatUsd } from './ui/pricing-editor.js'; +export { UsagePricingServicesProvider } from './pricing-services-context.js'; +export { UsageFeatureScope } from './services-context.js'; +export type { UsagePricingServices } from './pricing-ports.js'; +export type { UsageHostRef } from './ports.js'; +export { getPricingSettingsCopy } from '../../locales/settings-pricing-copy.js'; + export {useUsageStats} from './services-context.js'; diff --git a/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx new file mode 100644 index 0000000000..101b6f8713 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -0,0 +1,652 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { EmptyState, Heading, Skeleton, Text } from '@astryxdesign/core'; +import { Collapsible } from '@astryxdesign/core/Collapsible'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Typeahead, createStaticSource, type SearchableItem } from '@astryxdesign/core/Typeahead'; +import { Banner, Button, HStack, TextInput, VStack } from '@maka/ui'; +import { ICON_SIZE, BarChart3, Pencil, Plus, RefreshCcw, RotateCcw, Search, Trash2 } from '@maka/ui/icons'; +import type { PricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; +import { usePricingController } from '../controller/pricing-controller.js'; +import type { UsagePricingTarget } from '../pricing-ports.js'; +import type { PricingDraftErrors } from '../pricing-view-model.js'; +import { UsageStatsTable, type UsageColumn } from './usage-stats-table.js'; + +/** A built-in catalog row as a Typeahead item (its `label` is the model key). */ +type CatalogItem = SearchableItem<{ row: EffectivePricingEntry }>; + +export function PricingEditor(props: { + readonly describeError: (error: unknown) => string; + readonly target: UsagePricingTarget | null; +}) { + const c = usePricingController({ + describeError: props.describeError, + target: props.target, + }); + const { copy } = c; + + const columns: UsageColumn[] = [ + { header: copy.headers[0], width: 300 }, + { header: copy.headers[1], width: 152 }, + // Rate columns carry a "… / 1M" header; the shared table's default numeric + // width (88) truncates the wider ones (esp. the cache headers, and even more + // so in English: "Cache read / 1M" / "Cache write / 1M"). Size each to show + // its full header — the panel is wide enough that this adds no scroll. + { header: copy.headers[2], numeric: true, width: 116 }, + { header: copy.headers[3], numeric: true, width: 116 }, + { header: copy.headers[4], numeric: true, width: 140 }, + { header: copy.headers[5], numeric: true, width: 140 }, + { header: copy.actionsHeader, width: 104 }, + ]; + + // The table shows only the user's overrides (#2015 / #2218 direction). The + // ~1.4k built-in catalog is never rendered as a table — it is reachable only + // through the Add flow's Typeahead picker. + const rows = c.overrideRows.map((row) => [ + row.pricing.modelKey, + pricingSourceLabel(row, copy), + formatUsd(row.pricing.inputUsdPer1M), + formatUsd(row.pricing.outputUsdPer1M), + formatCache(row.pricing.cacheReadUsdPer1M, copy), + formatCache(row.pricing.cacheWriteUsdPer1M, copy), + c.openEdit(row, trigger)} + onReset={(trigger) => c.openReset(row, trigger)} + />, + ]); + + return ( + + + + {copy.title} + {copy.subtitle} + + + + ); +} + +function PricingEditorDialog(props: { + controller: ReturnType; +}) { + const c = props.controller; + const { copy, draft, validation, editor } = c; + const formId = useId(); + const dialogRef = usePricingDialogFocus(); + const isEdit = editor?.mode === 'edit'; + const title = isEdit ? copy.editTitle : copy.addTitle; + // Show field errors only after a save attempt so a fresh Add form is quiet. + const [submitAttempts, setSubmitAttempts] = useState(0); + const catalogInvalid = submitAttempts > 0 && validation.errors.modelKey !== undefined; + // Typeahead exposes its field ref, but no native input props. Apply Pricing's + // required/error semantics to the combobox before the dialog focuses errors. + const bindCatalogField = useCallback((field: HTMLDivElement | null) => { + const input = field?.querySelector('[role="combobox"]'); + if (!input) return; + input.setAttribute('aria-required', 'true'); + if (catalogInvalid) input.setAttribute('aria-invalid', 'true'); + else input.removeAttribute('aria-invalid'); + }, [catalogInvalid]); + useLayoutEffect(() => { + if (submitAttempts > 0) dialogRef.current?.querySelector('[aria-invalid="true"]')?.focus(); + }, [submitAttempts, dialogRef]); + // Selection is the draft's identity, including while a Host reload has no + // catalog yet. Keeping a second selected-item state lets the two drift. + const picked = useMemo( + () => draft.modelKey ? { id: draft.modelKey, label: draft.modelKey } : null, + [draft.modelKey], + ); + const catalogSource = useMemo( + () => + createStaticSource( + c.catalogRows.map((row) => ({ id: row.pricing.modelKey, label: row.pricing.modelKey, auxiliaryData: { row } })), + ), + [c.catalogRows], + ); + + function close() { + if (!c.saving) c.closeEditor(); + } + function submit() { + // Count each explicit submission so repeated Save clicks focus the error + // immediately; later typing must never consume a deferred focus request. + setSubmitAttempts((count) => count + 1); + if (validation.errors.cacheRead || validation.errors.cacheWrite) c.setCacheOpen(true); + void c.save(); + } + const fieldStatus = (message: string | undefined) => + submitAttempts > 0 && message ? ({ type: 'error' as const, message }) : undefined; + const errorMessage = (code: PricingDraftErrors[keyof PricingDraftErrors]): string | undefined => + code === undefined + ? undefined + : code === 'required' + ? copy.errorRequired + : code === 'invalid_rate' + ? copy.errorInvalidRate + : code === 'key_too_long' + ? copy.errorKeyTooLong + : copy.errorDuplicate; + + return ( + { + if (!open) close(); + }} + aria-label={title} + purpose="form" + width={480} + maxHeight="calc(100dvh - 64px)" + > + { if (!open) close(); }} />} + content={ + + { event.preventDefault(); submit(); }}> + {isEdit ? ( + // Editing an existing override: the key is fixed, shown read-only. + c.setField('modelKey', value)} + label={copy.modelKeyLabel} + isReadOnly + width="100%" + /> + ) : editor?.mode === 'catalog' ? ( + // Add via the built-in catalog: Typeahead renders only the top + // matches (never the ~1.4k-row list), and a pick pre-fills the + // built-in price. Its `value.label` is the model key it commits. + + + ref={bindCatalogField} + label={copy.catalogPickerLabel} + isRequired + searchSource={catalogSource} + value={picked} + onChange={(item) => { + if (item) { + c.pickCatalogModel(item.auxiliaryData!.row); + } else { + c.clearModel(); + } + }} + placeholder={copy.catalogPickerPlaceholder} + emptySearchResultsText={copy.catalogEmptyResults} + startIcon={ + ); +} + +/** Keep focus in an open dialog when a pending action disables its button or + * review removes the focused control. Preserve any focus the user moved. */ +function usePricingDialogFocus() { + const ref = useRef(null); + useLayoutEffect(() => { + const dialog = ref.current; + if (dialog?.open && dialog.ownerDocument.activeElement === dialog.ownerDocument.body) { + dialog.focus(); + } + }); + return ref; +} + +function PricingWriteNotice(props: { + writeState: ReturnType['writeState']; + latestEntry: EffectivePricingEntry | null; + copy: PricingSettingsCopy; + onRefresh(): void; + refreshBusy: boolean; +}) { + const { writeState, latestEntry, copy } = props; + switch (writeState.kind) { + case 'conflict': { + // An `outcome_unknown` conflict is uncertain, not a confirmed external + // change — it must not be described as one. + const uncertain = writeState.reason === 'outcome_unknown'; + const latest = ` ${latestPricingDescription(latestEntry, copy)}`; + return ( + + ); + } + case 'refresh_failed': + return ( + } + /> + ); + case 'reconcile_unavailable': + return ( + } + /> + ); + case 'idle': + return null; + } +} + +function latestPricingDescription(entry: EffectivePricingEntry | null, copy: PricingSettingsCopy): string { + return entry ? copy.conflictLatest( + pricingSourceLabel(entry, copy), + formatUsd(entry.pricing.inputUsdPer1M), + formatUsd(entry.pricing.outputUsdPer1M), + formatCache(entry.pricing.cacheReadUsdPer1M, copy), + formatCache(entry.pricing.cacheWriteUsdPer1M, copy), + ) : copy.latestUnpriced; +} + +/** Skeleton rows that mirror the real table's column count for a zero-shift load. + * Height 16 (a DESIGN.md-allowed bar height) and a small row count matching the + * overrides surface's typical ready state (a handful of custom rows). */ +function pricingSkeletonRows(columnCount: number): Array> { + return Array.from({ length: 3 }, () => + Array.from({ length: columnCount }, (_unused, column) => ( + + )), + ); +} + +function pricingSourceLabel(row: EffectivePricingEntry, copy: PricingSettingsCopy): string { + if (row.source === 'builtin') return copy.sourceBuiltin; + return row.resetEffect === 'restore_builtin' ? copy.sourceCustomFallback : copy.sourceCustomOnly; +} + +// Display formatting must round-trip the canonical value without losing +// precision, and a positive rate must never render as `$0` (#2015). Raw +// interpolation uses JS shortest-round-trip `Number.toString`, so `2.5` stays +// `$2.5` and `0.075` stays `$0.075` — never `.toFixed`-collapsed to `$0`. +export function formatUsd(value: number): string { + return `$${value}`; +} + +// An omitted cache rate ("not set", no cache charge) stays distinct from an +// explicit `0` (#2015): only `undefined` maps to the not-set copy. +export function formatCache(value: number | undefined, copy: PricingSettingsCopy): string { + return value === undefined ? copy.cacheNotSet : `$${value}`; +} diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx index 3c42813033..cff9ee5f04 100644 --- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx @@ -33,6 +33,7 @@ import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings' import { estimatedUsageCost, hasUnavailableUsage } from '@maka/core/usage-ledger-merge'; import { Button, TextInput, Selector, Switch, useToast, useUiLocale, useMountedRef, Banner } from '@maka/ui'; import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; +import { PricingEditor } from './pricing-editor.js'; import { getUsageSettingsCopy, type UsageSettingsCopy, @@ -42,6 +43,7 @@ import { UsageStatsTable } from './usage-stats-table.js'; import { useActionGuard } from '../controller/action-guard.js'; import { useOptimisticSettingsDraft } from '../controller/optimistic-settings-draft.js'; import { useUsageServices, useUsageStats } from '../services-context.js'; +import type { UsageHostRef } from '../ports.js'; type UsageActiveTab = UsageSettings['activeTab']; @@ -60,6 +62,8 @@ const EMPTY_USAGE_LOGS: UsageStats['logs'] = []; export function UsageSettingsView(props: { settings: UsageSettings; describeError(error: unknown): string; + /** Settings-selected Runtime Host, threaded to the Pricing tab (per-Host overrides). */ + runtimeHost: UsageHostRef | undefined; onOpenSession?(sessionId: string): void; }) { const services = useUsageServices(); @@ -68,7 +72,7 @@ export function UsageSettingsView(props: { const toast = useToast(); const persistedUsage = props.settings; // A retained complete result stays bound to its original query until replacement. - const { stats, reload, targetKey, state, error, paging, loadMore, screenVersion } = useUsageStats(persistedUsage.range); + const { stats, reload, targetKey, state, error, paging, loadMore, screenVersion, isCurrentTarget } = useUsageStats(persistedUsage.range); const [refreshing, setRefreshing] = useState(false); const usageRefreshGuard = useActionGuard<'refresh'>(); const { @@ -110,12 +114,11 @@ export function UsageSettingsView(props: { ); }, [stats, usageDraft.status, normalizedModelFilter]); - const tabCounts: Record = { + const tabCounts: Record, number> = { requests: stats?.navigation?.activityTotal ?? stats?.logs.length ?? 0, providers: stats?.byProvider.length ?? 0, models: stats?.byModel.length ?? 0, tools: stats?.byTool.length ?? 0, - pricing: stats?.pricing.length ?? 0, }; function updateUsage(patch: Partial): Promise { @@ -154,7 +157,7 @@ export function UsageSettingsView(props: { return ( <> - {state === 'stale' || state === 'error' ? ( + {usageDraft.activeTab !== 'pricing' && (state === 'stale' || state === 'error') ? ( ) : null} - {usageIncomplete ? ( + {usageIncomplete && usageDraft.activeTab !== 'pricing' ? ( ) : null} -
-
- void setRange(value as UsageRange)} - > - {(['24h', '7d', '30d', 'all'] as const).map((value, index) => ( - - ))} - -
+ {/* #2015 acceptance #2: the Pricing tab is not time-scoped, so the Usage + range/summary toolbar is hidden there — the date range cannot be + mistaken for a Pricing scope. */} + {usageDraft.activeTab !== 'pricing' ? ( +
+
+ void setRange(value as UsageRange)} + > + {(['24h', '7d', '30d', 'all'] as const).map((value, index) => ( + + ))} + +
-
- - - - +
+ + + + +
-
+ ) : null}
@@ -225,7 +233,7 @@ export function UsageSettingsView(props: { {tabCounts.providers}} /> {tabCounts.models}} /> {tabCounts.tools}} /> - {tabCounts.pricing}} /> +
@@ -277,7 +285,12 @@ export function UsageSettingsView(props: { {usageDraft.activeTab === 'pricing' ? (
- +
) : null}
@@ -498,22 +511,6 @@ function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsC ); } -function UsagePricingPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) { - return ( - [row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])} - empty={{ Icon: BarChart3, title: props.copy.tables.noPricing, body: props.copy.tables.pricingEmptyBody }} - /> - ); -} - // ── Request-log cell helpers ──────────────────────────────────────────────── function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: UsageSettingsCopy) { diff --git a/apps/desktop/src/renderer/locales/settings-pricing-copy.ts b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts new file mode 100644 index 0000000000..eaa72608d8 --- /dev/null +++ b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts @@ -0,0 +1,359 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +/** User-visible copy for the editable Pricing settings surface. */ +export type PricingSettingsCopy = { + title: string; + subtitle: string; + refresh: string; + add: string; + addNeedsSnapshot: string; + loading: string; + loadFailedTitle: string; + loadFailedBody: string; + retry: string; + emptyTitle: string; + emptyBody: string; + tableAria: string; + // catalog picker (Add flow) + catalogPickerLabel: string; + catalogPickerPlaceholder: string; + catalogEmptyResults: string; + manualEntryToggle: string; + catalogToggle: string; + builtinPrefillHint: string; + headers: readonly [string, string, string, string, string, string]; + actionsHeader: string; + sourceBuiltin: string; + sourceCustomFallback: string; + sourceCustomOnly: string; + cacheNotSet: string; + edit: string; + reset: string; + delete: string; + editAria(modelKey: string): string; + resetAria(modelKey: string): string; + deleteAria(modelKey: string): string; + // editor + addTitle: string; + editTitle: string; + modelKeyLabel: string; + modelKeyPlaceholder: string; + keyHelp: string; + inputLabel: string; + outputLabel: string; + rateHelp: string; + cacheSection: string; + cacheReadLabel: string; + cacheWriteLabel: string; + cacheHelp: string; + cancel: string; + save: string; + // field errors + errorRequired: string; + errorInvalidRate: string; + errorKeyTooLong: string; + errorDuplicate: string; + // outcomes + saved: string; + synchronized: string; + conflictTitle: string; + conflictTitleUnknown: string; + conflictBody: string; + conflictBodyUnknown: string; + conflictLatest( + source: string, + input: string, + output: string, + cacheRead: string, + cacheWrite: string, + ): string; + latestUnpriced: string; + reviewSave: string; + hostChangedTitle: string; + hostChangedBody: string; + reviewHostChange: string; + refreshFailedTitle: string; + refreshFailedBody: string; + reconcileTitle: string; + reconcileBody: string; + writeBlockedReason: string; + saveFailed: string; + // reset / delete confirm + resetTitle: string; + resetBody(modelKey: string): string; + deleteTitle: string; + deleteBody(modelKey: string): string; + confirmReset: string; + confirmDelete: string; + reviewReset: string; + reviewDelete: string; + resetFailed: string; +}; + +const SETTINGS_PRICING_COPY = { + 'zh-CN': { + title: '定价配置', + subtitle: + '美元 / 每百万 token。用于新激活的模型调用;进行中的运行沿用其开始时的价格。历史费用不会重算,最终以供应商结算为准。', + refresh: '刷新', + add: '添加定价', + addNeedsSnapshot: '需先加载定价后才能添加。', + loading: '正在加载定价…', + loadFailedTitle: '无法加载定价', + loadFailedBody: '读取运行时主机的定价快照失败,请重试。', + retry: '重试', + emptyTitle: '暂无自定义定价', + emptyBody: '尚未覆盖任何模型价格。点击「添加定价」,从内置目录中选择一个模型。', + tableAria: '自定义模型定价表', + catalogPickerLabel: '选择模型', + catalogPickerPlaceholder: '搜索模型名…', + catalogEmptyResults: '无匹配的内置模型', + manualEntryToggle: '模型不在列表中?手动输入', + catalogToggle: '从目录选择', + builtinPrefillHint: '已按内置价预填,可按需修改。', + headers: ['模型', '来源', '输入 / 1M', '输出 / 1M', '缓存读 / 1M', '缓存写 / 1M'], + actionsHeader: '操作', + sourceBuiltin: '内置', + sourceCustomFallback: '自定义 · 可回退', + sourceCustomOnly: '仅自定义', + cacheNotSet: '未设置(Maka 估算不计缓存费用)', + edit: '编辑', + reset: '重置', + delete: '删除', + editAria: (modelKey: string) => `编辑「${modelKey}」定价`, + resetAria: (modelKey: string) => `重置「${modelKey}」定价`, + deleteAria: (modelKey: string) => `删除「${modelKey}」定价`, + addTitle: '添加定价', + editTitle: '编辑定价', + modelKeyLabel: '模型键', + modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5', + keyHelp: '粘贴用量记录中的精确运行时查找键(区分大小写,不要用连接别名)。', + inputLabel: '输入价格', + outputLabel: '输出价格', + rateHelp: '美元 / 每百万 token;0 表示免费(如本地模型)。', + cacheSection: '缓存价格(可选)', + cacheReadLabel: '缓存读取', + cacheWriteLabel: '缓存写入', + cacheHelp: '留空表示未设置(不计缓存费用),与显式填 0 不同。', + cancel: '取消', + save: '保存', + errorRequired: '必填', + errorInvalidRate: '请输入有效价格(≥ 0)', + errorKeyTooLong: '模型键过长(上限 128 字符)', + errorDuplicate: '该模型已在列表中,请直接编辑对应行', + saved: '定价已保存', + synchronized: '当前定价已与你的修改一致', + conflictTitle: '定价已被其他修改更新', + conflictTitleUnknown: '无法确认上次修改的结果', + conflictBody: '该模型的价格已被其他修改更新。请核对最新值后,基于最新版本再次保存。', + conflictBodyUnknown: '上次修改可能已生效、也可能未生效。请核对最新值后,再决定是否基于最新版本重新保存。', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `当前最新:${source};输入 ${input} / 输出 ${output} / 缓存读 ${cacheRead} / 缓存写 ${cacheWrite}`, + latestUnpriced: '当前主机尚未设置该模型的价格。', + reviewSave: '核对并保存', + hostChangedTitle: '运行时主机已变化', + hostChangedBody: '草稿已保留。请等待新主机的定价加载完成,核对后再继续保存。', + reviewHostChange: '已核对新主机定价', + refreshFailedTitle: '已保存,但无法加载最新定价', + refreshFailedBody: '保存已完成,但未能读取最新定价。请刷新后再进行修改。', + reconcileTitle: '无法确认结果', + reconcileBody: '未能确认这次修改的结果。请刷新定价后再进行修改。', + writeBlockedReason: '需先刷新最新定价后才能修改。', + saveFailed: '保存定价失败', + resetTitle: '重置定价', + resetBody: (modelKey: string) => `将删除「${modelKey}」的自定义价格,恢复为内置定价。`, + deleteTitle: '删除定价', + deleteBody: (modelKey: string) => + `将删除「${modelKey}」的定价;新激活的调用将变为未定价(不计入 Maka 的费用估算,与显式填 0 不同),进行中的运行沿用其开始时的快照。`, + confirmReset: '重置', + confirmDelete: '删除', + reviewReset: '核对并重置', + reviewDelete: '核对并删除', + resetFailed: '操作失败', + }, + 'zh-TW': { + title: '定價設定', + subtitle: + '美元 / 每百萬 token。用於新啟用的模型呼叫;進行中的執行沿用其開始時的價格。歷史費用不會重算,最終以供應商結算為準。', + refresh: '重新整理', + add: '新增定價', + addNeedsSnapshot: '需先載入定價後才能新增。', + loading: '正在載入定價…', + loadFailedTitle: '無法載入定價', + loadFailedBody: '讀取執行時主機的定價快照失敗,請重試。', + retry: '重試', + emptyTitle: '暫無自訂定價', + emptyBody: '尚未覆寫任何模型價格。點選「新增定價」,從內建目錄中選擇一個模型。', + tableAria: '自訂模型定價表', + catalogPickerLabel: '選擇模型', + catalogPickerPlaceholder: '搜尋模型名稱…', + catalogEmptyResults: '無相符的內建模型', + manualEntryToggle: '模型不在清單中?手動輸入', + catalogToggle: '從目錄選擇', + builtinPrefillHint: '已依內建價格預填,可視需要修改。', + headers: ['模型', '來源', '輸入 / 1M', '輸出 / 1M', '快取讀 / 1M', '快取寫 / 1M'], + actionsHeader: '操作', + sourceBuiltin: '內建', + sourceCustomFallback: '自訂 · 可回退', + sourceCustomOnly: '僅自訂', + cacheNotSet: '未設定(Maka 估算不計快取費用)', + edit: '編輯', + reset: '重設', + delete: '刪除', + editAria: (modelKey: string) => `編輯「${modelKey}」定價`, + resetAria: (modelKey: string) => `重設「${modelKey}」定價`, + deleteAria: (modelKey: string) => `刪除「${modelKey}」定價`, + addTitle: '新增定價', + editTitle: '編輯定價', + modelKeyLabel: '模型鍵', + modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5', + keyHelp: '貼上用量記錄中的精確執行時查找鍵(區分大小寫,請勿使用連線別名)。', + inputLabel: '輸入價格', + outputLabel: '輸出價格', + rateHelp: '美元 / 每百萬 token;0 表示免費(如本機模型)。', + cacheSection: '快取價格(選填)', + cacheReadLabel: '快取讀取', + cacheWriteLabel: '快取寫入', + cacheHelp: '留空表示未設定(不計快取費用),與明確填入 0 不同。', + cancel: '取消', + save: '儲存', + errorRequired: '必填', + errorInvalidRate: '請輸入有效價格(≥ 0)', + errorKeyTooLong: '模型鍵過長(上限 128 個字元)', + errorDuplicate: '該模型已在清單中,請直接編輯對應的項目', + saved: '定價已儲存', + synchronized: '目前定價已與你的修改一致', + conflictTitle: '定價已被其他修改更新', + conflictTitleUnknown: '無法確認上次修改的結果', + conflictBody: '該模型的價格已被其他修改更新。請核對最新值後,基於最新版本再次儲存。', + conflictBodyUnknown: '上次修改可能已生效,也可能未生效。請核對最新值後,再決定是否基於最新版本重新儲存。', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `目前最新:${source};輸入 ${input} / 輸出 ${output} / 快取讀 ${cacheRead} / 快取寫 ${cacheWrite}`, + latestUnpriced: '目前主機尚未設定該模型的價格。', + reviewSave: '核對並儲存', + hostChangedTitle: '執行時主機已變更', + hostChangedBody: '草稿已保留。請等待新主機的定價載入完成,核對後再繼續儲存。', + reviewHostChange: '已核對新主機定價', + refreshFailedTitle: '已儲存,但無法載入最新定價', + refreshFailedBody: '儲存已完成,但無法讀取最新定價。請重新整理後再進行修改。', + reconcileTitle: '無法確認結果', + reconcileBody: '無法確認這次修改的結果。請重新整理定價後再進行修改。', + writeBlockedReason: '需先重新整理最新定價後才能修改。', + saveFailed: '儲存定價失敗', + resetTitle: '重設定價', + resetBody: (modelKey: string) => `將刪除「${modelKey}」的自訂價格,還原為內建定價。`, + deleteTitle: '刪除定價', + deleteBody: (modelKey: string) => + `將刪除「${modelKey}」的定價;新啟用的呼叫將變為未定價(不計入 Maka 的費用估算,與明確填入 0 不同),進行中的執行沿用其開始時的快照。`, + confirmReset: '重設', + confirmDelete: '刪除', + reviewReset: '核對並重設', + reviewDelete: '核對並刪除', + resetFailed: '操作失敗', + }, + en: { + title: 'Pricing', + subtitle: + 'USD per 1M tokens. Applies to newly activated model work; an active run keeps its starting prices. Historical costs are not recalculated. Provider billing is authoritative.', + refresh: 'Refresh', + add: 'Add price', + addNeedsSnapshot: 'Pricing must load before you can add an override.', + loading: 'Loading pricing…', + loadFailedTitle: 'Could not load pricing', + loadFailedBody: 'Reading the Runtime Host pricing snapshot failed. Try again.', + retry: 'Retry', + emptyTitle: 'No custom pricing', + emptyBody: + 'You haven’t overridden any model prices yet. Click "Add price" and pick a model from the built-in catalog.', + tableAria: 'Custom model pricing table', + catalogPickerLabel: 'Select model', + catalogPickerPlaceholder: 'Search models…', + catalogEmptyResults: 'No matching built-in models', + manualEntryToggle: 'Model not listed? Enter it manually', + catalogToggle: 'Choose from catalog', + builtinPrefillHint: 'Pre-filled with the built-in price; adjust as needed.', + headers: ['Model', 'Source', 'Input / 1M', 'Output / 1M', 'Cache read / 1M', 'Cache write / 1M'], + actionsHeader: 'Actions', + sourceBuiltin: 'Built-in', + sourceCustomFallback: 'Custom · has fallback', + sourceCustomOnly: 'Custom-only', + cacheNotSet: 'Not set (no cache charge in Maka estimates)', + edit: 'Edit', + reset: 'Reset', + delete: 'Delete', + editAria: (modelKey: string) => `Edit pricing for ${modelKey}`, + resetAria: (modelKey: string) => `Reset pricing for ${modelKey}`, + deleteAria: (modelKey: string) => `Delete pricing for ${modelKey}`, + addTitle: 'Add price', + editTitle: 'Edit price', + modelKeyLabel: 'Model key', + modelKeyPlaceholder: 'e.g. anthropic:claude-sonnet-4-5', + keyHelp: + 'Paste the exact Runtime lookup key from your usage records (case-sensitive; not the connection slug).', + inputLabel: 'Input price', + outputLabel: 'Output price', + rateHelp: 'USD per 1M tokens; 0 means free (e.g. local models).', + cacheSection: 'Cache prices (optional)', + cacheReadLabel: 'Cache read', + cacheWriteLabel: 'Cache write', + cacheHelp: 'Leave blank for "Not set" (no cache charge) — distinct from an explicit 0.', + cancel: 'Cancel', + save: 'Save', + errorRequired: 'Required', + errorInvalidRate: 'Enter a valid price (≥ 0)', + errorKeyTooLong: 'Model key is too long (128 characters max)', + errorDuplicate: 'This model is already listed — edit its row instead', + saved: 'Pricing saved', + synchronized: 'Pricing already matches your change', + conflictTitle: 'Pricing changed elsewhere', + conflictTitleUnknown: "Couldn't confirm the last change", + conflictBody: "This model's price was changed elsewhere. Review the latest value, then save again against the latest revision.", + conflictBodyUnknown: 'The last change may or may not have applied. Review the latest value, then decide whether to save again against the latest revision.', + conflictLatest: (source, input, output, cacheRead, cacheWrite) => + `Latest: ${source}; input ${input} / output ${output} / cache read ${cacheRead} / cache write ${cacheWrite}`, + latestUnpriced: 'This model has no price on the current Host.', + reviewSave: 'Review & save', + hostChangedTitle: 'Runtime Host changed', + hostChangedBody: 'Your draft was preserved. Wait for pricing from the new Host, then review it before saving.', + reviewHostChange: 'Reviewed new Host pricing', + refreshFailedTitle: 'Saved, but the latest pricing could not be loaded', + refreshFailedBody: 'The save completed but the latest prices could not be loaded. Refresh before changing pricing again.', + reconcileTitle: "Couldn't confirm the result", + reconcileBody: "The result of this change could not be confirmed. Reload pricing before changing it again.", + writeBlockedReason: 'Refresh the latest pricing before making changes.', + saveFailed: 'Failed to save pricing', + resetTitle: 'Reset pricing', + resetBody: (modelKey: string) => `This removes the custom price for ${modelKey} and restores its built-in pricing.`, + deleteTitle: 'Delete pricing', + deleteBody: (modelKey: string) => + `This deletes pricing for ${modelKey}; newly activated work becomes unpriced (excluded from Maka's cost estimates — distinct from an explicit $0), while an active run keeps its starting snapshot.`, + confirmReset: 'Reset', + confirmDelete: 'Delete', + reviewReset: 'Review & reset', + reviewDelete: 'Review & delete', + resetFailed: 'Action failed', + }, +} satisfies UiCatalog; + +export function getPricingSettingsCopy(locale: UiLocale): PricingSettingsCopy { + return SETTINGS_PRICING_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index 281878a280..6f2f1368c2 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -29,11 +29,11 @@ export type UsageSettingsCopy = { summaryOnly: string; showDetails: string; filteredEmpty: string; filteredEmptyHelp: string; requestEmpty: string; costUnavailable: string; incompleteTitle: string; incompleteBody: string; tables: { - providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string; - providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[]; - noPricing: string; modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string; + providersAria: string; modelsAria: string; toolsAria: string; requestsAria: string; + providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; requestHeaders: string[]; + modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; - toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; + toolEmptyTitle: string; toolEmptyBody: string; }; }; @@ -51,14 +51,13 @@ const SETTINGS_USAGE_COPY = { costUnavailable: '费用未知', incompleteTitle: '统计可能不完整', incompleteBody: '部分记录未能读取、尚未纳入统计或超出展示上限,实际用量可能高于此处显示。', tables: { - providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计活动记录表', + providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', requestsAria: '使用统计活动记录表', providerHeaders: ['供应商', '调用', 'Token', '费用'], modelHeaders: ['模型', '调用', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], - pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], - noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止', + requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], + modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止', providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型调用后,这里会按供应商聚合调用数、Token 与费用。', modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型调用后,这里会按模型聚合调用数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', - pricingEmptyBody: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。', }, }, 'zh-TW': { @@ -74,14 +73,13 @@ const SETTINGS_USAGE_COPY = { incompleteBody: '部分記錄可能無法讀取、尚未納入統計或超出顯示上限,實際用量可能高於此處顯示。', showDetails: '顯示明細', filteredEmpty: '沒有符合篩選條件的請求記錄', filteredEmptyHelp: '調整或清除篩選條件後可檢視全部請求記錄。', requestEmpty: '暫無請求記錄', tables: { - providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', pricingAria: '使用統計定價設定表', requestsAria: '使用統計請求記錄表', + providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', requestsAria: '使用統計請求記錄表', providerHeaders: ['供應商', '請求', 'Token', '費用'], modelHeaders: ['模型', '請求', 'Token', '費用'], toolHeaders: ['工具', '呼叫', '成功', '錯誤', '平均耗時'], - pricingHeaders: ['供應商', '模型', '輸入 / 1M', '輸出 / 1M'], requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'], - noPricing: '暫無定價覆蓋設定', modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止', + requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'], + modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止', providerEmptyTitle: '暫無供應商用量', providerEmptyBody: '完成一次模型請求後,這裡會按供應商聚合請求數、Token 與費用。', modelEmptyTitle: '暫無模型用量', modelEmptyBody: '完成一次模型請求後,這裡會按模型聚合請求數、Token 與費用。', toolEmptyTitle: '暫無工具呼叫', toolEmptyBody: '智慧體呼叫工具後,這裡會按工具聚合呼叫次數、成功、錯誤與平均耗時。', - pricingEmptyBody: '未設定定價覆蓋時,費用按內建模型定價表結算;在此可為特定模型登記自訂價格。', }, }, en: { @@ -97,14 +95,13 @@ const SETTINGS_USAGE_COPY = { costUnavailable: 'Cost unavailable', incompleteTitle: 'These numbers may be incomplete', incompleteBody: 'Some records could not be read, are not folded in yet, or exceed the display limit, so real usage may be higher than shown.', tables: { - providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage activity log', + providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', requestsAria: 'Usage activity log', providerHeaders: ['Provider', 'Calls', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Calls', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'], - pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], - noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted', + requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], + modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted', providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model call, provider call counts, tokens, and costs appear here.', modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model call, call counts, tokens, and costs appear here by model.', toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.', - pricingEmptyBody: 'Without pricing overrides, costs use the built-in model pricing table. Add custom prices here for specific models.', }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts new file mode 100644 index 0000000000..5cd6e67f22 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { UsagePricingServices } from '../../features/usage'; + +export type DesktopUsagePricingBridge = Pick; + +// Desktop adapter for the editable Pricing surface (#2015). It owns the only +// `window.maka.settings.pricing` bridge access, keeping it in the platform zone +// so no new bridge path lands in a frozen legacy-closure file. The `host` is the +// settings-selected Runtime Host threaded from the feature, so pricing reads and +// writes target the same Host as the rest of the settings page. The pricing +// bridge requires that Host explicitly, so this path cannot fall back to the +// app's active Host. +export function createDesktopUsagePricingServices( + bridge: DesktopUsagePricingBridge = window.maka, +): UsagePricingServices { + return { + loadPricing: (host) => bridge.settings.pricing.load(host), + mutatePricing: (host, base, mutation) => bridge.settings.pricing.mutate(base, mutation, host), + }; +} diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index cd6a16f458..f013404501 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1185,7 +1185,7 @@ function SettingsPageBody(props: { case 'usage': // State lives in the persistent `UsageScopeMount` above the loading gate; // this view is disposable and reads it from context. - return ; + return ; case 'bot-chat': return ( settingsActionErrorMessage(error, locale)} + runtimeHost={props.runtimeHost} onOpenSession={props.onOpenSession} /> diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css index a4979eff6a..b0cd2e801c 100644 --- a/apps/desktop/src/renderer/styles/settings/usage.css +++ b/apps/desktop/src/renderer/styles/settings/usage.css @@ -95,6 +95,12 @@ min-width: 0; } +/* Astryx stacks own pricing layout; allow their table and heading to shrink. */ +.settingsPricing, +.settingsPricingHeading { + min-width: 0; +} + /* 任务 column: the session-name link truncates inside its fixed column width and surfaces the full name via the button tooltip. StyleX owns the button's own layout; these rules only cap its width and ellipsis the label text (descendant diff --git a/apps/desktop/src/shared/desktop-pricing-decode.ts b/apps/desktop/src/shared/desktop-pricing-decode.ts new file mode 100644 index 0000000000..8c05daeb3a --- /dev/null +++ b/apps/desktop/src/shared/desktop-pricing-decode.ts @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Runtime guard for the Pricing Settings types declared in `desktop-pricing.d.ts`. +// It is imported only by the Main IPC layer (never the renderer), so it stays a +// plain `.ts` outside the renderer-root/legacy-AppShell closure the architecture +// ratchet tracks — the types themselves are declaration-only for that reason. + +import { + comparePricingModelKeys, + validateCanonicalPricingConfig, +} from '@maka/core/usage-stats/pricing'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; +import type { DesktopPricingSnapshot } from './desktop-pricing.js'; + +export class DesktopPricingSnapshotDecodeError extends Error { + constructor(message: string) { + super(`Invalid pricing snapshot: ${message}`); + this.name = 'DesktopPricingSnapshotDecodeError'; + } +} + +/** + * Validate a renderer-supplied `base` snapshot at the Main IPC boundary. The + * renderer must not synthesize `revision`/`hostEpoch`/`connectionId`; this only + * proves the shape it round-trips is well formed. A well-formed but *foreign* + * base (wrong Host epoch/connection) is still rejected downstream by the + * adapter's stale guard, and a merely stale revision degrades to a + * `revision_conflict` — both intended, not errors. + */ +export function decodeDesktopPricingSnapshot(value: unknown): DesktopPricingSnapshot { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DesktopPricingSnapshotDecodeError('snapshot must be an object'); + } + const record = value as Record; + if (typeof record.hostEpoch !== 'string' || record.hostEpoch === '') { + throw new DesktopPricingSnapshotDecodeError('hostEpoch must be a non-empty string'); + } + if (typeof record.connectionId !== 'string' || record.connectionId === '') { + throw new DesktopPricingSnapshotDecodeError('connectionId must be a non-empty string'); + } + if ( + typeof record.revision !== 'number' || + !Number.isInteger(record.revision) || + record.revision < 0 + ) { + throw new DesktopPricingSnapshotDecodeError('revision must be a non-negative integer'); + } + if (!Array.isArray(record.entries)) { + throw new DesktopPricingSnapshotDecodeError('entries must be an array'); + } + const entries = record.entries.map(decodeEffectivePricingEntry); + for (let index = 1; index < entries.length; index += 1) { + if ( + comparePricingModelKeys( + entries[index - 1]!.pricing.modelKey, + entries[index]!.pricing.modelKey, + ) !== -1 + ) { + throw new DesktopPricingSnapshotDecodeError('entries must be in canonical key order'); + } + } + return { + hostEpoch: record.hostEpoch, + connectionId: record.connectionId, + revision: record.revision, + entries, + }; +} + +function decodeEffectivePricingEntry(value: unknown): EffectivePricingEntry { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DesktopPricingSnapshotDecodeError('entry must be an object'); + } + const record = value as Record; + const pricing = validateCanonicalPricingConfig(record.pricing); + if (!pricing.ok) { + throw new DesktopPricingSnapshotDecodeError(`entry pricing is invalid (${pricing.error})`); + } + if (record.source === 'builtin') { + return { pricing: pricing.value, source: 'builtin' }; + } + if (record.source === 'custom') { + if ( + record.resetEffect !== 'restore_builtin' && + record.resetEffect !== 'become_unpriced' + ) { + throw new DesktopPricingSnapshotDecodeError('custom entry has an invalid resetEffect'); + } + return { pricing: pricing.value, source: 'custom', resetEffect: record.resetEffect }; + } + throw new DesktopPricingSnapshotDecodeError('entry source must be "builtin" or "custom"'); +} diff --git a/apps/desktop/src/shared/desktop-pricing.d.ts b/apps/desktop/src/shared/desktop-pricing.d.ts new file mode 100644 index 0000000000..d4f6c5333a --- /dev/null +++ b/apps/desktop/src/shared/desktop-pricing.d.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Cross-boundary Pricing Settings *types* shared by the Desktop adapter (main), +// the preload bridge, and the renderer. They live here — not in `main/` — so the +// renderer and preload can name them without importing the Runtime Host client. +// +// This is a declaration-only file (`.d.ts`) on purpose: the preload bridge +// contract (`bridge-contract.d.ts`) reaches these types from the renderer's +// import graph, and a declaration file is excluded from the legacy-AppShell / +// renderer-root transitive closure the architecture ratchet tracks (a runtime +// `.ts` in `shared/` would be pulled in as new closure debt). The runtime guard +// that validates a round-tripped snapshot lives beside it in +// `desktop-pricing-decode.ts`, imported only by the Main IPC layer. +// +// The adapter (`runtime-host-client.ts`) is the sole owner of the snapshot's +// `revision`/`hostEpoch`/`connectionId`; the renderer only ever round-trips a +// snapshot it loaded back as the CAS `base`. + +import type { EffectivePricingEntry, PricingMutation } from '@maka/runtime-host/protocol'; + +/** One revision-consistent page of effective pricing, stamped to its Host connection. */ +export interface DesktopPricingSnapshot { + readonly hostEpoch: string; + readonly connectionId: string; + readonly revision: number; + readonly entries: readonly EffectivePricingEntry[]; +} + +export interface DesktopPricingMutationInput { + readonly base: DesktopPricingSnapshot; + readonly mutation: PricingMutation; +} + +/** + * Every terminal state of a pricing mutation. `saved`/`synchronized`/ + * `review_required` carry a fresh authoritative snapshot; `saved_refresh_failed` + * and `reconciliation_unavailable` cannot, so the renderer keeps its draft and + * disables further writes until it can reload. + */ +export type DesktopPricingMutationOutcome = + | { + readonly kind: 'saved'; + readonly disposition: 'committed' | 'unchanged'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'saved_refresh_failed'; + readonly disposition: 'committed' | 'unchanged'; + } + | { + readonly kind: 'synchronized' | 'review_required'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly snapshot: DesktopPricingSnapshot; + } + | { + readonly kind: 'reconciliation_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + }; diff --git a/apps/desktop/stories/settings/pricing-editor.stories.tsx b/apps/desktop/stories/settings/pricing-editor.stories.tsx new file mode 100644 index 0000000000..da078b5583 --- /dev/null +++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx @@ -0,0 +1,347 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { ToastProvider } from '@maka/ui'; +import { createDefaultSettings } from '@maka/core/settings'; +import { + PricingEditor, + UsagePricingServicesProvider, + UsageFeatureScope, + type UsageHostRef, + type UsagePricingServices, +} from '../../src/renderer/features/usage/testing'; +import type { DesktopPricingSnapshot } from '../../src/shared/desktop-pricing'; + +// The Pricing tab (#2015 / PR #4164) is per-Host: it loads against the settings- +// SELECTED Runtime Host threaded to it as a prop, not the app's active Host. A +// concrete Host is required — with none selected the tab shows its no-Host state +// (covered by the feature unit tests, not a reachable settings-surface state). +const STORY_HOST: UsageHostRef = { profileId: 'story-profile', hostId: 'story-host' }; +const GENERATION_KEY = `${STORY_HOST.profileId}:${STORY_HOST.hostId}:e1`; +const USAGE_SERVICES = { + loadUsageStats: async () => null, + updateUsageSettings: async () => createDefaultSettings().usage, +}; + +const describeError = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +// A revision-consistent effective snapshot: two user overrides (可回退 = +// restore_builtin, and become_unpriced = Delete) — the only rows the +// overrides-only table shows — plus two built-ins that feed the Add flow's +// catalog picker (never the table). One override is a free local model (0/0) so +// the zero-rate formatting renders. Sources/reset effects are the raw Host +// fields — the editor derives the labels and action set, so this fixture shows +// the classification rather than asserting it. +const MIXED_SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'story-epoch', + connectionId: 'story-connection', + revision: 7, + entries: [ + { source: 'builtin', pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1.25, outputUsdPer1M: 10 } }, + { + source: 'builtin', + pricing: { + modelKey: 'anthropic:claude-opus-4', + inputUsdPer1M: 15, + outputUsdPer1M: 75, + cacheReadUsdPer1M: 1.5, + cacheWriteUsdPer1M: 18.75, + }, + }, + { + source: 'custom', + resetEffect: 'restore_builtin', + pricing: { modelKey: 'zai:glm-4.7', inputUsdPer1M: 0.6, outputUsdPer1M: 2.2 }, + }, + { + source: 'custom', + resetEffect: 'become_unpriced', + pricing: { modelKey: 'local:qwen3-coder', inputUsdPer1M: 0, outputUsdPer1M: 0 }, + }, + ], +}; + +const EMPTY_SNAPSHOT: DesktopPricingSnapshot = { + hostEpoch: 'story-epoch', + connectionId: 'story-connection', + revision: 1, + entries: [], +}; + +function pricingServices( + load: UsagePricingServices['loadPricing'], + mutate: UsagePricingServices['mutatePricing'] = async () => { + throw new Error('This pricing story does not configure writes'); + }, +): UsagePricingServices { + return { loadPricing: load, mutatePricing: mutate }; +} + +function EditablePricingPanel() { + const [services] = useState(() => { + let snapshot = MIXED_SNAPSHOT; + return pricingServices(async () => snapshot, async (_host, _base, mutation) => { + // This browser fixture supports editing its existing custom rows and + // deleting the local-only row. Host CAS/reconciliation has lower-tier tests. + if (mutation.kind === 'upsert') { + snapshot = { + ...snapshot, revision: snapshot.revision + 1, + entries: snapshot.entries.map((row) => row.pricing.modelKey === mutation.pricing.modelKey + ? { ...row, pricing: mutation.pricing } : row), + }; + } else { + snapshot = { + ...snapshot, revision: snapshot.revision + 1, + entries: snapshot.entries.filter((row) => row.pricing.modelKey !== mutation.modelKey), + }; + } + return { kind: 'saved', disposition: 'committed', snapshot }; + }); + }); + return ; +} + +function PricingTabPanel(props: { services: UsagePricingServices }) { + return ( + + + + {/* The Usage → 定价配置 tab panel wrapper the surface really renders the + editor inside. The surrounding settings-surface chrome (modal, nav + sidebar, the centered content column that bounds this width) is + exercised by Product/Settings/Pages; this story isolates the tab's + own content, capped at a representative content-column width so the + table is not reviewed stretched to the full 1280 render frame. */} +
+
+ true }} + /> +
+
+
+
+
+ ); +} + +const meta = { + title: 'Product/Settings/Pricing', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Real path: 设置 → 使用统计 → 定价配置 on a Host with a couple of user overrides. +// The overrides-only table shows the custom rows (自定义, with Reset or Delete per +// their reset effect); the built-in catalog is reached only via the Add picker. +export const Populated: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText('内置', { exact: true })).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '0.75'); + // The smoke runner also repeats this with real Enter input. userEvent's + // implicit-submit implementation does not resolve an external form button. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await waitFor(() => expect(canvas.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(canvas.getByText('$0.75', { exact: true })).toBeVisible(); + await expect(canvas.getByRole('button', { name: '编辑「zai:glm-4.7」定价' })).toHaveFocus(); + + // Deletion removes the opening action, so focus must return to stable Add. + await userEvent.click(canvas.getByRole('button', { name: '删除「local:qwen3-coder」定价' })); + const deletion = within(await canvas.findByRole('alertdialog', { name: '删除定价' })); + await userEvent.click(deletion.getByRole('button', { name: '删除' })); + await waitFor(() => expect(canvas.queryByRole('alertdialog')).not.toBeInTheDocument()); + await expect(canvas.queryByText('local:qwen3-coder', { exact: true })).not.toBeInTheDocument(); + await expect(canvas.getByRole('button', { name: '添加定价' })).toHaveFocus(); + }, +}; + +// Real path: 设置 → 使用统计 → 定价配置 on a Host with no user overrides yet — the +// common default (built-ins are never listed). The table area shows the +// overrides-empty state prompting the user to Add one from the catalog. +export const Empty: Story = { + render: () => EMPTY_SNAPSHOT)} />, +}; + +// Real path: 设置 → 使用统计 → 定价配置 on first open, before the Host's pricing +// snapshot resolves. The table reserves its geometry with skeleton rows so real +// rows land with no layout shift. +export const Loading: Story = { + render: () => ( + new Promise(() => {}))} /> + ), +}; + +// Real path: 设置 → 使用统计 → 定价配置 when reading the Host's pricing snapshot +// fails. The panel shows a load-failed empty state with a Retry action instead +// of the table. +export const LoadFailed: Story = { + render: () => ( + { + throw new Error('Runtime Host pricing snapshot unreachable'); + })} + /> + ), +}; + +// Real path: Add price → manual fallback. The form accepts the exact Runtime +// lookup key as one copy/paste-safe value instead of making the user reconstruct +// it from separate provider/model fields. +export const ManualExactKey: Story = { + render: () => MIXED_SNAPSHOT)} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const add = await canvas.findByRole('button', { name: '添加定价' }); + await userEvent.click(add); + let dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + const model = dialog.getByRole('combobox'); + await expect(model).toHaveAttribute('aria-required', 'true'); + await expect(model).not.toHaveAttribute('aria-invalid', 'true'); + // An empty catalog submission must focus the model before the empty rates. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(model).toHaveAttribute('aria-invalid', 'true'); + await expect(model).toHaveAccessibleDescription(/必填/); + await expect(model).toHaveFocus(); + await userEvent.type(dialog.getByRole('textbox', { name: /输入价格/ }), '1'); + const output = dialog.getByRole('textbox', { name: /输出价格/ }); + await userEvent.type(output, '2'); + await expect(output).toHaveFocus(); + // With valid rates, the model is the only error and still receives focus. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(model).toHaveFocus(); + await userEvent.type(model, 'openai:gpt-5'); + await userEvent.click(await within(document.body).findByRole('option', { name: 'openai:gpt-5' })); + await expect(model).not.toHaveAttribute('aria-invalid', 'true'); + await expect(dialog.getByRole('textbox', { name: /输入价格/ })).toHaveValue('1.25'); + await userEvent.click(dialog.getByRole('button', { name: '取消' })); + await waitFor(() => expect(canvas.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(add).toHaveFocus(); + await userEvent.click(add); + dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' })); + await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:coder-v3'); + }, +}; + +// Real path: Edit an override → enter an invalid cache price → collapse cache +// prices → Save. The invalid field must become visible and receive focus. +export const Validation: Story = { + render: () => MIXED_SNAPSHOT)} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialog = within(await canvas.findByRole('dialog', { name: '编辑定价' })); + const cacheToggle = dialog.getByRole('button', { name: /缓存价格/ }); + await userEvent.click(cacheToggle); + const cacheRead = dialog.getByRole('textbox', { name: /缓存读取/ }); + await userEvent.type(cacheRead, 'invalid'); + await userEvent.click(cacheToggle); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(cacheRead).toHaveAttribute('aria-invalid', 'true'); + await expect(cacheRead).toBeVisible(); + await waitFor(() => expect(cacheRead).toHaveFocus()); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheRead).toHaveFocus(); + const output = dialog.getByRole('textbox', { name: /输出价格/ }); + await userEvent.clear(output); + await userEvent.type(output, '23'); + await expect(output).toHaveValue('23'); + await expect(output).toHaveFocus(); + await expect(cacheRead).toHaveValue('invalid'); + // Leave the visual state at the error, as another explicit Save would. + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(cacheRead).toHaveFocus(); + }, +}; + +// Real path: Edit an override → another client changes its prices → Save. The +// Host returns the current snapshot, while the user's draft remains for review. +export const Conflict: Story = { + render: () => MIXED_SNAPSHOT, async () => ({ + kind: 'review_required', + reason: 'revision_conflict', + snapshot: { + ...MIXED_SNAPSHOT, + revision: 8, + entries: MIXED_SNAPSHOT.entries.map((row) => row.pricing.modelKey === 'zai:glm-4.7' + ? { ...row, pricing: { ...row.pricing, inputUsdPer1M: 7, outputUsdPer1M: 8, cacheReadUsdPer1M: 0, cacheWriteUsdPer1M: 0.4 } } + : row), + }, + }))} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '1.25'); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(await dialog.findByText('定价已被其他修改更新')).toBeVisible(); + await expect(input).toHaveValue('1.25'); + const latest = dialog.getByText(/当前最新:/); + await expect(latest).toHaveTextContent('$7'); + await expect(latest).toHaveTextContent('$0.4'); + await expect(dialog.getByRole('button', { name: '核对并保存' })).not.toHaveAttribute('aria-disabled', 'true'); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + }, +}; + +// Real path: Edit an override → the dispatched write loses its result and the +// recovery read fails. The open draft offers Refresh and blocks further writes. +export const Uncertain: Story = { + render: () => MIXED_SNAPSHOT, async () => ({ + kind: 'reconciliation_unavailable', reason: 'outcome_unknown', + }))} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await userEvent.clear(input); + await userEvent.type(input, '1.25'); + await userEvent.click(dialog.getByRole('button', { name: '保存' })); + await expect(await dialog.findByText('无法确认结果')).toBeVisible(); + await expect(input).toHaveValue('1.25'); + await expect(dialog.getByRole('button', { name: '保存' })).toHaveAttribute('aria-disabled', 'true'); + await expect(dialog.getByRole('button', { name: '刷新' })).toBeVisible(); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + }, +}; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index c40202e4c5..7626490b9f 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -73,6 +73,8 @@ import type { PetPackManifestV1 } from '@maka/core/pet'; import { SettingsSurface } from '../../src/renderer/settings/settings-surface'; import { ConnectionSettingsServicesProvider } from '../../src/renderer/features/connection-settings'; import { RuntimeHostManagementServicesProvider } from '../../src/renderer/features/runtime-host-management'; +import { UsagePricingServicesProvider, type UsagePricingServices } from '../../src/renderer/features/usage'; +import type { DesktopPricingMutationInput } from '../../src/shared/desktop-pricing'; import { SessionBundleServicesProvider, type SessionBundleServices, @@ -369,7 +371,6 @@ const usageStats: UsageStats = { }, { tool: 'Bash', calls: 120, success: 118, errors: 2, avgDurationMs: 840 }, ], - pricing: [{ provider: 'zai-coding-plan', model: 'glm-4.7', inputPerMTokUsd: 0, outputPerMTokUsd: 0 }], provenance: STORY_USAGE_PROVENANCE, }; @@ -390,7 +391,6 @@ const emptyUsageStats: UsageStats = { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; @@ -1746,6 +1746,33 @@ const withUsageLongTailBridge = withUsageStoryBridge(usageStats, { activeTab: 'requests', }); +const settingsPricingServices: UsagePricingServices = { + loadPricing: async (host) => ({ + hostEpoch: `epoch-${host.hostId}`, + connectionId: `connection-${host.hostId}`, + revision: 1, + entries: [], + }), + mutatePricing: async () => { throw new Error('Pricing writes are not configured in this story'); }, +}; + +const withUsagePricingHostsBridge = withScopedMakaBridge({ + ...makaBridge, + runtimeHostProfiles: generationStoryRuntimeHostProfilesBridge, + settings: { + ...makaBridge.settings, + get: async () => mergeSettings(createDefaultSettings(), { usage: { activeTab: 'pricing' } }), + }, +}); +let pricingStoryMutation: (DesktopPricingMutationInput & { hostId: string }) | undefined; +const hostSwitchPricingServices: UsagePricingServices = { + ...settingsPricingServices, + mutatePricing: async (host, base, mutation) => { + pricingStoryMutation = { hostId: host.hostId, base, mutation }; + return { kind: 'saved_refresh_failed', disposition: 'committed' }; + }, +}; + const subagentStorySettings = mergeSettings(createDefaultSettings(), { subagents: { presets: [ @@ -1922,6 +1949,7 @@ function renderedLinkColors(renderedLink: HTMLElement) { type SettingsStoryProps = { reopenable?: boolean; section: SettingsSection; + pricingServices?: UsagePricingServices; connections?: LlmConnection[]; defaultSlug?: string | null; openProviderCatalog?: boolean; @@ -2018,6 +2046,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) { + {open && } + @@ -2677,6 +2707,73 @@ export const UsageEmpty: Story = { decorators: [withUsageEmptyBridge], render: () => , }; + +// Real path: Settings → Usage → Pricing → Add, then select another Host. +// Settings keys its content by Host identity; the user draft must outlive that +// real remount and require review against the replacement pricing authority. +export const UsagePricingHostSwitch: Story = { + decorators: [withUsagePricingHostsBridge], + render: () => { + resetGenerationStoryBridge(runtimeHostProfilesWithRemote); + pricingStoryMutation = undefined; + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const navigation = await canvas.findByRole('navigation', { name: '使用统计视图' }); + await userEvent.click(within(navigation).getByRole('button', { name: '定价配置' })); + await expect(canvas.queryByRole('group', { name: '使用统计范围与刷新' })).not.toBeInTheDocument(); + await expect(canvas.queryByRole('group', { name: '使用统计汇总指标' })).not.toBeInTheDocument(); + const add = await canvas.findByRole('button', { name: '添加定价' }); + await waitFor(() => expect(add).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(add); + const dialog = within(await canvas.findByRole('dialog', { name: '添加定价' })); + await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' })); + await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:host-draft'); + await userEvent.type(dialog.getByRole('textbox', { name: /输入价格/ }), '1.25'); + await userEvent.type(dialog.getByRole('textbox', { name: /输出价格/ }), '2.75'); + // The lifecycle event arrives while output still has focus, before blur. + const oldInput = dialog.getByRole('textbox', { name: /输出价格/ }); + await expect(oldInput).toHaveFocus(); + + // An offline event removes the active Host and unmounts the entire page. + // The Settings selector then allows switching to the still-ready Remote. + const listener = generationStoryProfileListener; + if (!listener) throw new Error('Settings did not subscribe to Host lifecycle changes'); + listener({ + epoch: 'pricing-local-offline', profileId: 'local', profileName: 'Local', + profileKind: 'local', profileAccess: 'owner', readiness: 'unavailable', isDefault: true, + }); + await waitFor(() => expect(oldInput.isConnected).toBe(false)); + await userEvent.click(canvas.getByRole('combobox', { name: 'Runtime Host' })); + await userEvent.click(await within(document.body).findByRole('option', { name: 'Remote' })); + const restoredDialog = await canvas.findByRole('dialog', { name: '添加定价' }); + const restored = within(restoredDialog); + await expect(restored.getByRole('textbox', { name: /^模型键/ })).toHaveValue('acme:host-draft'); + await expect(restored.getByRole('textbox', { name: /输入价格/ })).toHaveValue('1.25'); + await expect(restored.getByRole('textbox', { name: /输出价格/ })).toHaveValue('2.75'); + const save = restored.getByRole('button', { name: '保存' }); + await expect(save).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(save); + await expect(pricingStoryMutation).toBeUndefined(); + const review = restored.getByRole('button', { name: '已核对新主机定价' }); + await waitFor(() => expect(review).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(review); + await expect(restoredDialog.contains(document.activeElement)).toBe(true); + await userEvent.click(save); + await waitFor(() => expect(pricingStoryMutation?.hostId).toBe('storybook-remote-host')); + await expect(pricingStoryMutation?.base.hostEpoch).toBe('epoch-storybook-remote-host'); + await expect(pricingStoryMutation?.mutation).toEqual({ + kind: 'upsert', pricing: { modelKey: 'acme:host-draft', inputUsdPer1M: 1.25, outputUsdPer1M: 2.75 }, + }); + await restored.findByText('已保存,但无法加载最新定价'); + await expect(restoredDialog.contains(document.activeElement)).toBe(true); + await userEvent.click(restored.getByRole('button', { name: '取消' })); + await waitFor(() => expect(restoredDialog.isConnected).toBe(false)); + await expect(canvas.getByRole('button', { name: '添加定价' })).toHaveFocus(); + await expect(canvas.queryByText('暂无自定义定价')).not.toBeInTheDocument(); + }, +}; // Real path: 设置 → 使用统计 → 供应商统计, with traffic from one provider. export const UsageSingleProvider: Story = { decorators: [withUsageSingleProviderBridge], diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ef64cbdf30..75681463a3 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 292 files — blocker 0, reimplementation 0, polish 4, aligned 288. +**Totals:** 294 files — blocker 0, reimplementation 0, polish 4, aligned 290. ## Exclusions (explicit) @@ -106,8 +106,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/usage/pricing-services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/ui/metric-card.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx` | other | Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading, Layout, LayoutContent, LayoutFooter, Skeleton, Text, TextInput, Typeahead, VStack | aligned — uses Astryx (Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading) | aligned | | `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, Pagination, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, Pagination, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab) | aligned | | `apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx` | other | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned | | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fdf83719a3..87f8013858 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -77,8 +77,10 @@ apps/desktop/src/renderer/features/session-settings/services-context.tsx apps/desktop/src/renderer/features/task-entry/services-context.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +apps/desktop/src/renderer/features/usage/pricing-services-context.tsx apps/desktop/src/renderer/features/usage/services-context.tsx apps/desktop/src/renderer/features/usage/ui/metric-card.tsx +apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx apps/desktop/src/renderer/features/workbar/services-context.tsx diff --git a/docs/images/pr/pricing-overrides-only/after.png b/docs/images/pr/pricing-overrides-only/after.png new file mode 100644 index 0000000000000000000000000000000000000000..96726d130f396285ecb2b60832aef260bf06228e GIT binary patch literal 100046 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 * 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 literal 0 HcmV?d00001 diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 910df91cd0..330ade42ac 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -654,12 +654,6 @@ export interface UsageStats { errors: number; avgDurationMs: number; }>; - pricing: Array<{ - provider: string; - model: string; - inputPerMTokUsd: number; - outputPerMTokUsd: number; - }>; /** * Coverage/legacy/unreadable/pending accounting behind these totals, so the * page can qualify a cost that reads low (unpriced/unreadable/pending) rather @@ -690,6 +684,13 @@ export interface UsageActivityPage { } export interface UsageScreen extends UsageStats, UsageActivityPage { + /** Retained for wire compatibility; Desktop Pricing loads its own Host snapshot. */ + pricing: Array<{ + provider: string; + model: string; + inputPerMTokUsd: number; + outputPerMTokUsd: number; + }>; activityTotal: number; query: UsageScreenQuery; } diff --git a/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json new file mode 100644 index 0000000000..ccd0ec6621 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json @@ -0,0 +1,5 @@ +{ + "epoch": 170, + "files": ["packages/runtime-host/src/protocol/usage-pricing.ts"], + "reason": "Adds shared pure pricing reconciliation types and helpers without changing any protocol codec, validation rule, operation payload, or wire message shape. Older and newer peers exchange identical pricing frames." +} diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 37ceb87aec..98100d2104 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -41,6 +41,9 @@ import { decodeUsageQueryInput, encodePricingQueryResult, encodeProtocolMessage, + createPricingReconciliationTarget, + pricingReconciliationTargetMatches, + pricingReconciliationTargetModelKey, PRICING_PAGE_MAX_BYTES, PRICING_PAGE_MAX_ITEMS, RUNTIME_HOST_MAX_MESSAGE_BYTES, @@ -63,6 +66,48 @@ const CONNECTION_CONTEXT: ConnectionContext = { }; describe('Usage/Pricing protocol', () => { + test('pricing reconciliation captures reset semantics and exact custom provenance', () => { + const builtin: EffectivePricingEntry = { + source: 'builtin', + pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1, outputUsdPer1M: 2 }, + }; + const resettable = customPricingEntry('anthropic:claude', 'restore_builtin'); + const upsert = createPricingReconciliationTarget([builtin, resettable], { + kind: 'upsert', + pricing: builtin.pricing, + }); + assert.equal(pricingReconciliationTargetModelKey(upsert), 'openai:gpt-5'); + assert.equal( + pricingReconciliationTargetMatches(upsert, [builtin, resettable]), + false, + 'equal bundled rates are not a committed custom override', + ); + assert.equal( + pricingReconciliationTargetMatches(upsert, [ + { ...builtin, source: 'custom', resetEffect: 'restore_builtin' }, + resettable, + ]), + true, + ); + + const reset = createPricingReconciliationTarget([builtin, resettable], { + kind: 'delete', + modelKey: resettable.pricing.modelKey, + }); + assert.deepEqual(reset, { + kind: 'delete', + modelKey: 'anthropic:claude', + expected: 'builtin', + }); + assert.equal( + pricingReconciliationTargetMatches(reset, [ + builtin, + { source: 'builtin', pricing: resettable.pricing }, + ]), + true, + ); + }); + test('decodes exact bounded usage queries', () => { assert.deepEqual( decodeUsageQueryInput({ diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts index 20be8381a2..593b37172b 100644 --- a/packages/runtime-host/src/protocol/usage-pricing.ts +++ b/packages/runtime-host/src/protocol/usage-pricing.ts @@ -24,6 +24,7 @@ import { assertUsageScreenResult, } from './usage-screen.js'; import { + canonicalPricingConfigsEqual, comparePricingModelKeys, normalizePricingModelKey, validateCanonicalPricingConfig, @@ -306,6 +307,57 @@ export type PricingMutation = | { readonly kind: 'upsert'; readonly pricing: PricingConfig } | { readonly kind: 'delete'; readonly modelKey: string }; +export type PricingReconciliationTarget = + | { readonly kind: 'upsert'; readonly pricing: Readonly } + | { + readonly kind: 'delete'; + readonly modelKey: string; + readonly expected: 'builtin' | 'unpriced' | 'no_override'; + }; + +/** Capture the intended end state before dispatch, while the CAS base is known. */ +export function createPricingReconciliationTarget( + baseEntries: readonly EffectivePricingEntry[], + mutation: PricingMutation, +): PricingReconciliationTarget { + if (mutation.kind === 'upsert') return { kind: 'upsert', pricing: mutation.pricing }; + const baseEntry = baseEntries.find(({ pricing }) => pricing.modelKey === mutation.modelKey); + const expected = + baseEntry?.source === 'custom' + ? baseEntry.resetEffect === 'restore_builtin' + ? 'builtin' + : 'unpriced' + : 'no_override'; + return { kind: 'delete', modelKey: mutation.modelKey, expected }; +} + +/** Compare a later authoritative projection with a previously captured target. */ +export function pricingReconciliationTargetMatches( + target: PricingReconciliationTarget, + entries: readonly EffectivePricingEntry[], +): boolean { + const current = entries.find( + ({ pricing }) => pricing.modelKey === pricingReconciliationTargetModelKey(target), + ); + if (target.kind === 'upsert') { + return ( + current?.source === 'custom' && canonicalPricingConfigsEqual(current.pricing, target.pricing) + ); + } + switch (target.expected) { + case 'builtin': + return current?.source === 'builtin'; + case 'unpriced': + return current === undefined; + case 'no_override': + return current === undefined || current.source === 'builtin'; + } +} + +export function pricingReconciliationTargetModelKey(target: PricingReconciliationTarget): string { + return target.kind === 'upsert' ? target.pricing.modelKey : target.modelKey; +} + export interface PricingMutateInput { readonly expectedRevision: number; readonly mutation: PricingMutation; diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs index 3d05c2628d..8e81e32ca4 100644 --- a/scripts/storybook-visual-smoke.mjs +++ b/scripts/storybook-visual-smoke.mjs @@ -157,6 +157,17 @@ export function catalogJobs( const jobs = Object.values(entries) .filter((entry) => entry?.type === 'story' && typeof entry.id === 'string') .flatMap((entry) => { + // Reuse the Pricing states at both widths so validation/recovery banners + // and footer actions are exercised in a narrow window, without duplicate stories. + if (entry.id.startsWith('product-settings-pricing--')) { + return [RENDER_VIEWPORT, { width: 480, height: 720 }].map((viewport) => ({ + storyId: entry.id, + colorScheme: 'light', + forcedColors: 'none', + palette: 'default', + viewport, + })); + } // These diagnostics must wrap in both locales at the reading measure // and in a narrow Desktop window; toolbar defaults cover neither matrix. if (entry.id === 'product-shell-official-appshell--long-system-notes') { @@ -212,7 +223,7 @@ export function storyViewport(storyId) { export function jobLabel(job) { const forcedColors = job.forcedColors === 'active' ? '/forced-colors' : ''; - const scenario = job.locale ? `/${job.locale}/${job.viewport.width}px` : ''; + const scenario = `${job.locale ? `/${job.locale}` : ''}${job.viewport ? `/${job.viewport.width}px` : ''}`; return `${job.storyId} (${job.colorScheme}/${job.palette}${forcedColors}${scenario})`; } @@ -258,6 +269,57 @@ export async function smokeStory(page, baseUrl, job, options = {}) { } } + if (job.storyId === 'product-settings-pricing--populated' && browserFailures.length === 0) { + // userEvent implements implicit submit by querying descendants of form, + // missing the Pricing dialog's associated footer button. Real key input + // catches a broken form association without simulating submit ourselves. + const edit = page.getByRole('button', { name: '编辑「zai:glm-4.7」定价' }); + await edit.click(); + const dialog = page.getByRole('dialog', { name: '编辑定价' }); + const input = dialog.getByRole('textbox', { name: /输入价格/ }); + await input.fill('0.875'); + await input.press('Enter'); + await dialog.waitFor({ state: 'hidden' }); + await page.getByRole('cell', { name: '$0.875', exact: true }).waitFor({ state: 'visible' }); + } + + if (job.storyId.startsWith('product-settings-pricing--')) { + const geometry = await page.evaluate(() => { + const failures = []; + if (document.documentElement.scrollWidth > window.innerWidth) + failures.push('page overflows horizontally'); + const dialog = document.querySelector('dialog[open]'); + if (dialog) { + const rect = dialog.getBoundingClientRect(); + if ( + rect.left < 0 || + rect.right > window.innerWidth || + rect.top < 0 || + rect.bottom > window.innerHeight + ) + failures.push('dialog extends beyond the viewport'); + for (const button of dialog.querySelectorAll('.astryx-layout-footer button')) { + const action = button.getBoundingClientRect(); + if ( + action.left < rect.left || + action.right > rect.right || + action.top < rect.top || + action.bottom > rect.bottom + ) + failures.push('footer action is clipped'); + } + const focusedInvalid = dialog.querySelector('[aria-invalid="true"]:focus'); + if (focusedInvalid) { + const input = focusedInvalid.getBoundingClientRect(); + if (input.top < rect.top || input.bottom > rect.bottom) + failures.push('focused invalid field is clipped'); + } + } + return failures; + }); + browserFailures.push(...geometry); + } + const result = await page.evaluate(() => { const root = document.querySelector('#storybook-root'); const failures = [...(window.__makaStorybookSmoke?.failures ?? [])]; From 17e0d3db35aa01ee3f66bcbc5d8c03ae37668dd1 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Wed, 30 Sep 2026 14:07:42 +0800 Subject: [PATCH 2/2] test(desktop): validate pricing guards with ablation experiments Add regressions for same-Host disposal, refresh ordering, malformed pagination, reset reconciliation and native Host-review focus. Remove three locally redundant bookkeeping steps validated independently and together; archive the reproducible 33-variant experiment. Generated-by: Codex --- .../src/main/__tests__/pricing-editor.test.ts | 48 +- .../runtime-host-client-pricing.test.ts | 35 + apps/desktop/src/main/runtime-host-client.ts | 6 +- .../usage/controller/pricing-controller.ts | 4 - .../settings/pricing-editor.stories.tsx | 42 +- docs/archive/pricing-ablation-2026-09-30.json | 2775 +++++++++++++++++ docs/archive/pricing-ablation-2026-09-30.md | 115 + .../experiments/pricing-ablation-browser.mjs | 144 + .../experiments/pricing-ablation-reporter.mjs | 36 + scripts/experiments/pricing-ablation.mjs | 530 ++++ scripts/experiments/pricing-client.probes.ts | 81 + scripts/experiments/pricing-editor.probes.ts | 89 + 12 files changed, 3889 insertions(+), 16 deletions(-) create mode 100644 docs/archive/pricing-ablation-2026-09-30.json create mode 100644 docs/archive/pricing-ablation-2026-09-30.md create mode 100644 scripts/experiments/pricing-ablation-browser.mjs create mode 100644 scripts/experiments/pricing-ablation-reporter.mjs create mode 100644 scripts/experiments/pricing-ablation.mjs create mode 100644 scripts/experiments/pricing-client.probes.ts create mode 100644 scripts/experiments/pricing-editor.probes.ts diff --git a/apps/desktop/src/main/__tests__/pricing-editor.test.ts b/apps/desktop/src/main/__tests__/pricing-editor.test.ts index 6ed66453d2..dbaecbedb1 100644 --- a/apps/desktop/src/main/__tests__/pricing-editor.test.ts +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -443,7 +443,7 @@ describe('PricingEditor', () => { await click(buttonByText(harness.doc, copy.confirmReset)); assert.equal(resetButton.isConnected, false, 'the reset trigger leaves with its override row'); - assert.equal(focused, addButton, 'the committed row removal falls back to the stable Add action'); + assert.ok(focused === addButton, 'the committed row removal falls back to the stable Add action'); await act(async () => harness.root.unmount()); }); @@ -526,7 +526,7 @@ describe('PricingEditor', () => { assert.ok(refresh, 'recovery is available inside the modal focus trap'); await click(refresh); - assert.equal(openDialog(harness.doc), undefined, 'a successful refresh completes the saved draft'); + assert.equal(openDialog(harness.doc) === undefined, true, 'a successful refresh completes the saved draft'); assert.match(harness.container.textContent ?? '', /acme:draft/); assert.equal(harness.loadCalls(), 2); assert.equal(harness.mutations.length, 1, 'refresh never replays the upsert'); @@ -650,7 +650,7 @@ describe('PricingEditor', () => { await click(buttonByText(openDialog(harness.doc)!, copy.confirmReset)); await clickWithoutSettling(buttonByText(openDialog(harness.doc)!, copy.refresh)); await click(buttonByText(openDialog(harness.doc)!, copy.cancel)); - assert.equal(openDialog(harness.doc), undefined); + assert.equal(openDialog(harness.doc) === undefined, true); assertButtonDisabled(buttonByText(harness.doc, copy.add)); await act(async () => recovery.resolve(latest)); @@ -752,7 +752,7 @@ describe('PricingEditor', () => { assert.ok(dialog, 'the unreconciled draft stays open'); await click(buttonByText(dialog, copy.refresh)); - assert.equal(openDialog(harness.doc), undefined, 'the matched draft is complete'); + assert.equal(openDialog(harness.doc) === undefined, true, 'the matched draft is complete'); assert.equal(harness.mutations.length, 1, 'reconciliation never replays the upsert'); await act(async () => harness.root.unmount()); }); @@ -919,7 +919,7 @@ describe('PricingEditor', () => { await setInput(inputByLabel(harness.doc, copy.outputLabel), '2.75'); await clickWithoutSettling(buttonByText(harness.doc, copy.save)); await harness.hideView(); - assert.equal(openDialog(harness.doc), undefined, 'the Settings gate actually unmounts the view'); + assert.equal(openDialog(harness.doc) === undefined, true, 'the Settings gate actually unmounts the view'); await harness.rerender('replacement-host:epoch-2'); await act(async () => previousSave.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT })); @@ -1480,3 +1480,41 @@ function assertButtonEnabled(button: HTMLButtonElement | undefined): void { assert.equal(button.disabled, false); assert.notEqual(button.getAttribute('aria-disabled'), 'true'); } + + +it('a disposed view cannot dismiss the recovered draft on the same Host', async () => { + const pending = deferred(); + const harness = await renderEditor({ load: async () => SNAPSHOT, mutate: async () => pending.promise }); + try { + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + await typeInput(inputByLabel(harness.doc, copy.inputLabel), '7.25'); + await clickWithoutSettling(buttonByText(harness.doc, copy.save)); + await harness.hideView(); + await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e1`); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + await act(async () => pending.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT })); + assert.ok(openDialog(harness.doc), 'a disposed controller must not clear the scope-owned draft'); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '7.25'); + } finally { await act(async () => harness.root.unmount()); } +}); + +for (const staleFails of [false, true]) { + it(`newer refresh wins when the older read ${staleFails ? 'fails' : 'succeeds'}`, async () => { + const stale = deferred(), fresh = deferred(); + const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9, entries: SNAPSHOT.entries.map(row => ({...row, pricing: {...row.pricing,inputUsdPer1M: 987}})) }; + let reads = 0; + const harness = await renderEditor({load: async () => ++reads === 1 ? SNAPSHOT : reads === 2 ? stale.promise : fresh.promise }); + try { + const refresh = buttonByLabel(harness.doc, copy.refresh)!; + // Two requests dispatched before React commits the loading indicator. + await act(async () => { activateButton(refresh); activateButton(refresh); }); + assert.equal(harness.loadCalls(), 3); + await act(async () => fresh.resolve(latest)); + await act(async () => { if(staleFails) stale.reject(new Error('stale read disconnected')); else stale.resolve(SNAPSHOT); }); + assert.ok(harness.container.textContent?.includes('$987'), 'the newest authority must remain visible'); + assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.loadFailedTitle)); + await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude'))); + assert.equal(inputByLabel(harness.doc, copy.inputLabel)?.value, '987'); + } finally { await act(async () => harness.root.unmount()); } + }); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts index 1fa784bb15..6ace6271a3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts @@ -465,3 +465,38 @@ function pricing(modelKey: string, inputUsdPer1M: number) { cacheWriteUsdPer1M: inputUsdPer1M * 1.5, }; } + + +for (const offsets of [[0], [1, 1], [1, 0], [2, 1]]) { + test(`rejects non-progressing Pricing offsets ${offsets.join(' -> ')} before dispatch`, async () => { + const responses = offsets.map((nextOffset, index) => page(1, index === 0 ? 0 : offsets[index-1]!, [builtin(`provider:model-${index}`, 1)], nextOffset)); + const { client, requests } = clientWithResponses(responses); + await assert.rejects(() => client.loadPricingSnapshot(), (error: unknown) => error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable'); + assert.equal(requests.length, offsets.length, 'invalid continuation must not cross the connection'); + }); +} + +test('assembles three strictly progressing Pricing pages', async () => { + const entries = [builtin('provider:a', 1), builtin('provider:b', 2), builtin('provider:c', 3)]; + const { client, requests } = clientWithResponses([page(8,0,[entries[0]!],1),page(8,1,[entries[1]!],2),page(8,2,[entries[2]!],null)]); + assert.deepEqual((await client.loadPricingSnapshot()).entries, entries); + assert.equal(requests.length, 3); +}); + +test('rejects a duplicated key at a Pricing page seam', async () => { + const { client } = clientWithResponses([page(1,0,[builtin('provider:a',1)],1),page(1,1,[builtin('provider:a',2)],null)]); + await assert.rejects(() => client.loadPricingSnapshot(), (error: unknown) => error instanceof DesktopRuntimeHostClientError && error.code === 'pricing_unstable'); +}); + +test('a missing row does not confirm restoration of bundled pricing', async () => { + const { client, requests } = clientWithResponses([ + {kind:'revision_conflict', expectedRevision:1, actualRevision:2}, + page(2,0,[],null), + ]); + const result = await client.applyPricingMutation({ + base: snapshot('host-current',1,[custom('provider:reset',2,'restore_builtin')]), + mutation:{kind:'delete',modelKey:'provider:reset'}, + }); + assert.equal(result.kind,'review_required'); + assert.equal(requests.filter(request=>request.operation==='pricing.mutate').length,1); +}); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7653cf7e60..99ca31a961 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1750,17 +1750,17 @@ export class DesktopRuntimeHostClient { ); } const entries = [...first.entries]; - const offsets = new Set([0]); let page: Extract = first; while (page.nextOffset !== null) { const offset = page.nextOffset; - if (offset <= page.offset || offsets.has(offset)) { + // Every accepted page matches its requested offset, so strict progress + // also excludes every previously visited offset without a history set. + if (offset <= page.offset) { throw new DesktopRuntimeHostClientError( "pricing_unstable", "Runtime Host repeated a Pricing page offset", ); } - offsets.add(offset); const next = await this.request("pricing.query", { kind: "continue", revision: first.revision, diff --git a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts index a0b0705237..107dcc86ce 100644 --- a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -233,7 +233,6 @@ export function usePricingController(props: { try { const next = await services.loadPricing(host); if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return; - setLoadError(null); if (pendingWrite) { applyOutcome( { @@ -378,9 +377,6 @@ export function usePricingController(props: { setEditor(null); setMutationBase(null); setNeedsReview(false); - if (writeState.kind === 'conflict') { - setWriteState({ kind: 'idle' }); - } restoreTriggerFocus(); } diff --git a/apps/desktop/stories/settings/pricing-editor.stories.tsx b/apps/desktop/stories/settings/pricing-editor.stories.tsx index da078b5583..c29cb22ad8 100644 --- a/apps/desktop/stories/settings/pricing-editor.stories.tsx +++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { ToastProvider } from '@maka/ui'; @@ -121,12 +121,13 @@ function EditablePricingPanel() { return ; } -function PricingTabPanel(props: { services: UsagePricingServices }) { +function PricingTabPanel(props: { services: UsagePricingServices; generationKey?: string }) { + const generationKey = props.generationKey ?? GENERATION_KEY; return ( true }} + target={{ host: STORY_HOST, generationKey, isCurrent: () => true }} />
@@ -345,3 +346,36 @@ export const Uncertain: Story = { await expect(dialogElement.contains(document.activeElement)).toBe(true); }, }; + +// Model the asynchronous Host event while the native modal makes outside UI inert. +const hostReplacement = new EventTarget(); +function HostReviewPricingPanel() { + const [generation, setGeneration] = useState(1); + const [services] = useState(() => pricingServices(async () => MIXED_SNAPSHOT)); + useEffect(() => { + const replace = () => setGeneration((current) => current + 1); + hostReplacement.addEventListener('replace', replace); + return () => hostReplacement.removeEventListener('replace', replace); + }, []); + return ; +} + +// Real path: edit a price → selected Host reconnects → review its fresh pricing. +// The focused review action disappears; only a real browser exposes the resulting +// native focus loss. State/recovery semantics are covered by the component tests. +export const HostReview: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: '编辑「zai:glm-4.7」定价' })); + const dialogElement = await canvas.findByRole('dialog', { name: '编辑定价' }); + const dialog = within(dialogElement); + hostReplacement.dispatchEvent(new Event('replace')); + const review = await dialog.findByRole('button', { name: '已核对新主机定价' }); + await waitFor(() => expect(review).not.toHaveAttribute('aria-disabled', 'true')); + await userEvent.click(review); + await waitFor(() => expect(review).not.toBeInTheDocument()); + await expect(dialogElement.contains(document.activeElement)).toBe(true); + await expect(dialog.getByRole('textbox', { name: /输入价格/ })).toHaveValue('0.6'); + }, +}; diff --git a/docs/archive/pricing-ablation-2026-09-30.json b/docs/archive/pricing-ablation-2026-09-30.json new file mode 100644 index 0000000000..9c011947d5 --- /dev/null +++ b/docs/archive/pricing-ablation-2026-09-30.json @@ -0,0 +1,2775 @@ +{ + "sourceTree": "2f5459c0fd178790dee756f54316b56027f065c9", + "node": "v24.14.0", + "lockHash": "4a06931c25a19added5aa29e0754352862208cfcbd470aec5bc4891eee42d475", + "sourceHashes": { + "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed", + "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c", + "apps/desktop/src/renderer/features/usage/pricing-view-model.ts": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e", + "apps/desktop/src/main/runtime-host-client.ts": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a", + "packages/runtime-host/src/protocol/usage-pricing.ts": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977", + "apps/desktop/src/main/__tests__/pricing-editor.test.ts": "e738de8eb19984a77782fb36a2d9b0716b06c3d15c935ac666b31a49617e9f89", + "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts": "b74f54f0b82dadab344eab05baf83e25ab16231c32334cf1eae1cda33ededea0" + }, + "suite": [ + "apps/desktop/dist/main/__tests__/pricing-editor.test.js", + "apps/desktop/dist/main/__tests__/pricing-view-model.test.js", + "apps/desktop/dist/main/__tests__/runtime-host-client-pricing.test.js", + "apps/desktop/dist/main/__tests__/runtime-host-pricing-ipc-main.test.js", + "apps/desktop/dist/main/__tests__/runtime-host-usage-ipc-main.test.js", + "apps/desktop/dist/main/__tests__/usage-settings-view.test.js", + "packages/runtime-host/dist/__tests__/usage-pricing-protocol.test.js" + ], + "variants": [ + { + "id": "baseline", + "question": "Unmodified merged control", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5397, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/baseline/result.json", + "rawSha256": "13969f0cf4107c2e265c666a6394991c75484509a19f3ab7bc323047420116a6", + "browserFailures": [], + "finalVerdict": "survived" + }, + { + "id": "latest-cas-base", + "question": "Can every save adopt the current list revision?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 3, + "passed": 111, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 3, + "passed": 121, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5366, + "assertionFailures": [ + "keeps the viewed CAS base when a background refresh finishes during edit", + "keeps the viewed CAS base when a background refresh finishes during reset", + "keeps the viewed CAS base when a background refresh finishes during add" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "97301432dbab9680a4d7cbfcf25b768ba4cc83ae731fe4a315d1a7df819df1ef" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/latest-cas-base/result.json", + "rawSha256": "21c04df93146a873f8c948924dbc2f11ef5a305af979a36733592864e0c22a99", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "unpinned-validation", + "question": "Can an open editor validate against background-refreshed rows?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5369, + "assertionFailures": [ + "keeps the viewed CAS base when a background refresh finishes during add" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "878b086de4d9899edf3333b7cc143ddf9dc30f48ca8925d295716c972d9cf376" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/unpinned-validation/result.json", + "rawSha256": "bf2fab45fc580fb05a1b419c4110ab25fc356635098d65de842e4eb10709420f", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-host-witness", + "question": "Can render lifecycle alone fence the pre-render Host event gap?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 2, + "passed": 112, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5162, + "assertionFailures": [ + "ignores an old-Host save result when the Host event fences it before React re-renders", + "does not dispatch a write after the Host event fences the still-visible editor" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "93b0f64528cec8ff5d8ff243eed9f28088c92a6da776ca2c2c82a7f269c311fb" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-host-witness/result.json", + "rawSha256": "d5298e04cfdc127aa6d4bb02692a5eb752ca034d132afe84d2867748ccb88849", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-lifecycle-fence", + "question": "Can the target witness alone handle StrictMode and view disposal?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5365, + "assertionFailures": [ + "ablation: a disposed view cannot dismiss the recovered draft on the same Host" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "9759132f63c4267d8083cdfd4298fb64679da0b4f523cdffee8235e1c9cdadee" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-lifecycle-fence/result.json", + "rawSha256": "ed4c361e13645a41249dcbdc800c9e61f33753e19eb0855917bf7c25ad98adcc", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-mutation-read-fence", + "question": "May a refresh started before a mutation land afterward?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 6238, + "assertionFailures": [ + "a reload landing after a mutation does not overwrite the committed authority (P1.2)" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "762efa8b6ef92f99dd2c5bc2b4e794ac7976819053a68485fb7a03eee97f64c1" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-mutation-read-fence/result.json", + "rawSha256": "eb7514aa5c490d0caf01528e234cb4ef40de7f7a76dc1a8748bd3b605c4fb7ac", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-read-order", + "question": "Can same-generation refreshes share a ticket?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5524, + "assertionFailures": [ + "ablation: newer refresh wins when the older read succeeds", + "ablation: newer refresh wins when the older read fails" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "1bf9fc0610490622d5f5c2e73c4bc5786d5449dfef6c8c6ed92c7fdf07009c20" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-read-order/result.json", + "rawSha256": "31c835fbbe007bf28064fcbb750ddce8504f348fe25cec4f2f4b60ab61b0811f", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-persistent-draft", + "question": "May editor input live only as long as the disposable view?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 5, + "passed": 109, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 6, + "passed": 118, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5130, + "assertionFailures": [ + "preserves unblurred rate text 7.25 across a Host gate remount", + "preserves unblurred rate text 7. across a Host gate remount", + "reviews a recovered Add against an existing override and preserves its rates", + "preserves the manual draft across the Settings Host gate unmount and rejects its old save", + "ablation: a disposed view cannot dismiss the recovered draft on the same Host" + ], + "otherFailures": ["retries a recovered draft load inside the modal without losing input"], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "c1a36d61909086f30a7ea496bd8148b3cb63f1022b239c17da926cb6b03f6abc" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/confirmed/runs/no-persistent-draft/result.json", + "rawSha256": "e25450116b56098b3a6adf86484acaebddb2f8cc7baf984661e08b66a1acdfb8", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-sync-input-ref", + "question": "May submit and late outcomes wait for React to commit input?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5088, + "assertionFailures": ["submits the latest text when input and submit happen before a render"], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "2b28eb70a55bfcefdc2727e1c9b371056a003df3c14018f4682f0c4f09938f5a" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-sync-input-ref/result.json", + "rawSha256": "f39e4f3d62f1f81021b8166f5bf795e6f7813933990fa66d22a30cb63274ad43", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "close-newer-input", + "question": "May successful writes dismiss input typed after dispatch?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 3, + "passed": 111, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 3, + "passed": 121, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5359, + "assertionFailures": [ + "retains input typed during a pending save and saves it only on the next submit", + "retains later input when saved_refresh_failed reconciles the submitted Add", + "retains later input when reconciliation_unavailable reconciles the submitted Add" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "7fa204b789e82c8ceb365648ea442c3c97866f054dae4c86d33a7519b50afb21" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/close-newer-input/result.json", + "rawSha256": "b07103569cc3f7ffba7f2ef10b725d9dd5fb8213128e81e4bc9f96d6c9bf65cb", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-dialog-ownership", + "question": "May late reconciliation control a cancelled or replaced dialog?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 2, + "passed": 112, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5416, + "assertionFailures": [ + "keeps a reset cancelled while its recovery read is pending closed", + "does not apply an earlier model conflict after the pending Add changes keys" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "9befdad463bf400a221539ca901ec133eb86527f61f54ade8c968d7cbe8d5ea7" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-dialog-ownership/result.json", + "rawSha256": "232d772074e42a4bb971869d037af586c97b591dbebc31966dac71bcac7a79f4", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-host-review", + "question": "May a recovered draft save without new-Host review?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 7, + "passed": 107, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 7, + "passed": 117, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5052, + "assertionFailures": [ + "retries a recovered draft load inside the modal without losing input", + "preserves unblurred rate text 7.25 across a Host gate remount", + "preserves unblurred rate text 7. across a Host gate remount", + "a Host generation change preserves the draft and requires review after reloading authority (P1.1)", + "does not allow Host-change review until fresh authority loads", + "preserves the manual draft across the Settings Host gate unmount and rejects its old save", + "ignores an old-Host save result after the generation changes" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "2b2f124fd36497f705ac3860942c7f5ead7b62fd5badbadd69e41ad2897ceca6" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-host-review/result.json", + "rawSha256": "e0a194499345d4a8f58a47d0963b92ec53ae01b13096b617f1ebfe0acae6e624", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-unknown-write-block", + "question": "May a second write proceed before uncertain outcome reconciliation?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 5, + "passed": 109, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 5, + "passed": 119, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5164, + "assertionFailures": [ + "keeps a saved reset reachable from its dialog until pricing refreshes", + "an uncertain outcome blocks writes and explains the required recovery", + "keeps a reset cancelled while its recovery read is pending closed", + "does not carry a cancelled Add recovery into another model editor", + "blocks form submission while an upsert still needs reconciliation" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "b0c7081e69621a5492f0a8631b4026105f2ad554711d6a8cf1620d0f8b3056d2" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-unknown-write-block/result.json", + "rawSha256": "0146e2022651eda041f11502f4199d9db1403b431f4c93e5339d6e43dabe0444", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-write-guard", + "question": "Can pending React state alone prevent same-tick duplicate submission?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5200, + "assertionFailures": ["submits once when the form is submitted twice before a render"], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "d85362817d124f70896f804065384c5a7eeb27dc797394c58d69d28d9d359098" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-write-guard/result.json", + "rawSha256": "3335262967ca12b444624cd311f06d58b49ad238db6da43fc4dc0d84a147c9f8", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "all-catalog-duplicates", + "question": "Are built-in rows duplicates when adding an override?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 10, + "passed": 104, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 10, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5090, + "assertionFailures": [ + "keeps the viewed CAS base when a background refresh finishes during add", + "retains later input when saved_refresh_failed reconciles the submitted Add", + "retains later input when reconciliation_unavailable reconciles the submitted Add", + "selecting a built-in catalog model saves an override", + "does not carry a cancelled Add recovery into another model editor", + "finishes an unavailable upsert when the refreshed authority matches exactly", + "blocks form submission while an upsert still needs reconciliation", + "does not mistake equal built-in rates for a reconciled custom override", + "submits once when the form is submitted twice before a render", + "adding a built-in key that is not yet overridden is a new override, not a duplicate" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "f0674d513c7817100bfd2c3648d6f72db132a1f06f6a79ff03f5dc09c7e9df85" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/all-catalog-duplicates/result.json", + "rawSha256": "9688a7e62fe0dea2a6fc65abf53a43ee0a2b9b4eccbda197c637992320d6897e", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-rate-syntax", + "question": "Can Number coercion replace decimal syntax validation?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5095, + "assertionFailures": [ + "rate text validates decimal syntax without losing precision or coercing invalid input" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": true, + "hash": "e3f9f8e12490578ab4332614bbd8640ba85c5969951cf290c40b533465219076" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-rate-syntax/result.json", + "rawSha256": "2232751b71fbd17015cdd59a47725e7418d3cc6f7faded06c0a3cf7d6cd99eda", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-underflow-check", + "question": "Can nonzero subnormal text silently become zero?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5060, + "assertionFailures": [ + "rate text validates decimal syntax without losing precision or coercing invalid input" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": true, + "hash": "2ad1012a4a113f7d9be0e355ad1f2ec62844a8be97388b1733c80621615adbe6" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-underflow-check/result.json", + "rawSha256": "0562515dce7580f478fd5f9d35e67922e08dca15f64ace822f9dc320a1c74d0b", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "negative-rates", + "question": "Can finite negative rates be admitted?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 2, + "passed": 112, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5186, + "assertionFailures": [ + "rejects a negative rate without clamping it to a free price", + "validatePricingDraft rejects a negative rate" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": true, + "hash": "c03660f634c52f4be04b12dc36e462234ef9fe4f9504fe92a6579b91a6f996a2" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/negative-rates/result.json", + "rawSha256": "62298b4387191974b150b3e74a4ea543cd8b0c53efc4270b96728d705a4260a7", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "blank-is-zero", + "question": "Are blank optional cache rates equivalent to explicit zero?", + "initialVerdict": "invalid-incomplete", + "initialCounts": { + "tests": 87, + "failed": 12, + "passed": 75, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 47, + "suites": 2 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 13, + "passed": 111, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5232, + "assertionFailures": [ + "retains input typed during a pending save and saves it only on the next submit", + "submits the latest text when input and submit happen before a render", + "retains later input when saved_refresh_failed reconciles the submitted Add", + "retains later input when reconciliation_unavailable reconciles the submitted Add", + "reviews a recovered Add against an existing override and preserves its rates", + "selecting a built-in catalog model saves an override", + "keeps an upsert draft reachable and refreshes from inside the editor", + "finishes an unavailable upsert when the refreshed authority matches exactly", + "prefilled drafts round-trip precision, omitted cache and explicit zero", + "validatePricingDraft add flags an empty model key", + "validatePricingDraft add builds a canonical config; blank cache is omitted", + "validatePricingDraft keeps an explicit 0 cache rate distinct from blank", + "rate text validates decimal syntax without losing precision or coercing invalid input" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": true, + "hash": "53129f3f289a1f1a0abf53b556778873ba27e03493cb9cd12fd5a2e1d0ef1ea7" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/blank-is-zero/result.json", + "rawSha256": "ff72a18cf80c4e631fb3adb8c231ea1c4a0b0619eeaf5321be1f23ee77944ea5", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-custom-provenance", + "question": "Is equal built-in pricing enough to confirm an upsert?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 3, + "passed": 111, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 3, + "passed": 121, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5204, + "assertionFailures": [ + "does not mistake equal built-in rates for a reconciled custom override", + "requires custom provenance when an upsert equals the bundled values", + "pricing reconciliation captures reset semantics and exact custom provenance" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": true, + "hash": "bbf8975adf7abd97298f4575355c2a37198c210c664462033653793d2c060e3a" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-custom-provenance/result.json", + "rawSha256": "70dabfffc4061ecc7733371ed5bf52d35152e32d5eca24916c3ada5238876b92", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "reset-means-no-override", + "question": "Can reset-to-builtin and become-unpriced share one success predicate?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5155, + "assertionFailures": [ + "ablation: a missing row does not confirm restoration of bundled pricing" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": true, + "hash": "70641692928c95cc051aeafbcb04806281b83fc14b1b694bccc7a071f37b887b" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/reset-means-no-override/result.json", + "rawSha256": "788f683a897b3c582ec52430b28241263db4085bd05382852072fe9fc0118bd1", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-connection-identity", + "question": "Is Host epoch alone enough to identify a write base?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5345, + "assertionFailures": [ + "rejects a Pricing mutation from a previous connection with the same Host Epoch" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": true, + "hash": "369e47fb4d2c8b730e74b9d425aadc0a2ad2c8683940faf3bfef2068864082e0" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-connection-identity/result.json", + "rawSha256": "bb8df0923730f6f1409807d6da7784eefea93de69ae1816a89d7acfa34b92373", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-page-canonicality", + "question": "May pages contain unordered or duplicate model keys?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 1, + "passed": 113, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5574, + "assertionFailures": [ + "rejects duplicate or non-canonical keys split across Pricing pages", + "ablation: rejects a duplicated key at a Pricing page seam" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": true, + "hash": "624f3ab593fd25407b1174ebd52999109ebabdaf629ebd75995460484bb49f93" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-page-canonicality/result.json", + "rawSha256": "9a1a92b26170af5a38cacc46f67d24b242a8c2e1b656f73f5b1d56de062e008f", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-page-progress-check", + "question": "Does response correlation alone stop offset cycles?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 4, + "passed": 120, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 6173, + "assertionFailures": [ + "ablation: rejects non-progressing Pricing offsets 0 before dispatch", + "ablation: rejects non-progressing Pricing offsets 1 -> 1 before dispatch", + "ablation: rejects non-progressing Pricing offsets 1 -> 0 before dispatch", + "ablation: rejects non-progressing Pricing offsets 2 -> 1 before dispatch" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": true, + "hash": "9d22b2a97d500fb4572fb6c327abeee02c59e32e72ba6a8d1478b09d58ee5a7a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-page-progress-check/result.json", + "rawSha256": "f8906f535f4cadd5fde1bd2f5217b9f54db5152c733e5bf5ff107aec9e189327", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-stable-focus-fallback", + "question": "Does the removed row trigger suffice for focus restoration?", + "initialVerdict": "invalid-incomplete", + "initialCounts": { + "tests": 84, + "failed": 1, + "passed": 83, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 47, + "suites": 2 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 1, + "passed": 123, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 6017, + "assertionFailures": ["moves focus to Add after a successful reset removes its trigger row"], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "4096e13398f618e05449723b6cee54c9dd4f23ccb1ef496c7d9651562f8f24b0" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-stable-focus-fallback/result.json", + "rawSha256": "ec00a189c425ea5991a800415447f2712c5e3f5934291b343dfd8b6af3db57e7", + "browserFailures": ["populated"], + "finalVerdict": "killed-browser" + }, + { + "id": "no-reveal-cache-error", + "question": "Can invalid collapsed cache fields remain hidden after Save?", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 2, + "passed": 112, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 2, + "passed": 122, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5828, + "assertionFailures": [ + "reveals a collapsed invalid cacheRead field when saving", + "reveals a collapsed invalid cacheWrite field when saving" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": true, + "hash": "1806b4d447a33a1419817951e25920b2a3e12217c76bde6e2b11a753bcc9cb41" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-reveal-cache-error/result.json", + "rawSha256": "47a2896c16c9118c19184b50245df032cf7b96552070c6503c573010b184eff4", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-catalog-aria-binding", + "question": "Does Astryx now propagate combobox validation without the Pricing patch?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5418, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": true, + "hash": "b93440332f87760c2a9d0e2fc27b571f335406e8ac7ab23c1fc8109a3ef1b76b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-catalog-aria-binding/result.json", + "rawSha256": "7f16fe0f090675e16abaa1042f21ca7859bc90e8573bc78cf48671013263b819", + "browserFailures": ["manual-exact-key"], + "finalVerdict": "killed-browser" + }, + { + "id": "no-dialog-focus-repair", + "question": "Does native dialog focus handle disabled or removed focused controls?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5173, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": true, + "hash": "e2a7c00d95d283415a75661ad8d08657264438908b0b840753d34c5e78f09f94" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-dialog-focus-repair/result.json", + "rawSha256": "4cc7bd5397ee5dd5460d71d7346c301ab8e0f243cb36e4e782f0f5830ecf4100", + "browserFailures": ["host-review"], + "finalVerdict": "killed-browser" + }, + { + "id": "no-offset-history", + "question": "Is visited-offset storage redundant with strictly increasing offsets?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5241, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": false, + "hash": "b9d40a3dfa066363fbf4742f34db6c8c932be5cbb50677dced1f4cbf20b618ed" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": true, + "hash": "96150a0efd7be4f81cf7f2796aebe6c74be3a7596a1d759b093dd405b1be341a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-offset-history/result.json", + "rawSha256": "f9f30b3cf081376249019f7012fdfb391f61873a4c58f1c3ede4ddf54ed895ba", + "browserFailures": [], + "finalVerdict": "survived" + }, + { + "id": "one-load-error-clear", + "question": "Can the same refresh clear its error just once?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 4904, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "3d5cf33612535a8c666a2e58e6f8a3c29929c608a5d4f55fa6b79076aa410f81" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/one-load-error-clear/result.json", + "rawSha256": "7b0556170cfa67d9ad83c123c35cd112e30e13ca0306f985b3828059e53dd8c3", + "browserFailures": [], + "finalVerdict": "survived" + }, + { + "id": "one-close-conflict-clear", + "question": "Does setEditor(null) already retire the settled conflict?", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 4929, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "aa54a99e3502732eae2ace57e02a44808c29353d000c9d673cf9abe1de26d384" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/one-close-conflict-clear/result.json", + "rawSha256": "39089709f8eadc58ae980f6da05bdfd524d74b39562b7b6ded957dc964b2d459", + "browserFailures": [], + "finalVerdict": "survived" + }, + { + "id": "combined-simplification", + "question": "no-offset-history + one-load-error-clear + one-close-conflict-clear", + "initialVerdict": "survived", + "initialCounts": { + "tests": 114, + "failed": 0, + "passed": 114, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "survived", + "counts": { + "tests": 124, + "failed": 0, + "passed": 124, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 4963, + "assertionFailures": [], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "2aa8f8d230b0d32a5a5ecf830ba43c0b745ce8c8abe108446b1847b738aa7f15" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": true, + "hash": "96150a0efd7be4f81cf7f2796aebe6c74be3a7596a1d759b093dd405b1be341a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/combined-simplification/result.json", + "rawSha256": "de08888b2a8aaf32620a6c909959bdd6a1b2a7dbfe36c30ed6c08eb1e17234be", + "browserFailures": [], + "finalVerdict": "survived" + }, + { + "id": "no-host-fences", + "question": "no-host-witness + no-lifecycle-fence", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 4, + "passed": 110, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 5, + "passed": 119, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5189, + "assertionFailures": [ + "preserves the manual draft across the Settings Host gate unmount and rejects its old save", + "ignores an old-Host save result after the generation changes", + "ignores an old-Host save result when the Host event fences it before React re-renders", + "does not dispatch a write after the Host event fences the still-visible editor", + "ablation: a disposed view cannot dismiss the recovered draft on the same Host" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "0be5c2039d409fefb5b8f48c568b4d2a3c98a853c151f46ea1a27323c5b3f3b6" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-host-fences/result.json", + "rawSha256": "52b332bbc8d085975b68b89750128fd4e9dea6aa40b241bfbbf1331b7e6ab774", + "browserFailures": [], + "finalVerdict": "killed" + }, + { + "id": "no-async-ownership", + "question": "no-dialog-ownership + close-newer-input", + "initialVerdict": "killed", + "initialCounts": { + "tests": 114, + "failed": 5, + "passed": 109, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 48, + "suites": 4 + }, + "verdict": "killed", + "counts": { + "tests": 124, + "failed": 5, + "passed": 119, + "cancelled": 0, + "skipped": 0, + "todo": 0, + "topLevel": 58, + "suites": 4 + }, + "durationMs": 5231, + "assertionFailures": [ + "retains input typed during a pending save and saves it only on the next submit", + "retains later input when saved_refresh_failed reconciles the submitted Add", + "retains later input when reconciliation_unavailable reconciles the submitted Add", + "keeps a reset cancelled while its recovery read is pending closed", + "does not apply an earlier model conflict after the pending Add changes keys" + ], + "otherFailures": [], + "loaded": [ + { + "path": "apps/desktop/src/main/__tests__/pricing-editor.test.ts", + "changed": true, + "hash": "8398f10488ccbf75e5e2559cafc10faf718f76d24adf9f777f0a00b2d324891b" + }, + { + "path": "apps/desktop/src/renderer/features/usage/pricing-view-model.ts", + "changed": false, + "hash": "8020af2a69266d9e615c4e560bb0edec6fdb69dd6ed9003270763c211a44790e" + }, + { + "path": "apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx", + "changed": false, + "hash": "dcd03f5472d9297b1194387f8c3995c2d1b32da68716546d9c0a8fe8471b9d8c" + }, + { + "path": "apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts", + "changed": true, + "hash": "903cfac199e633bdf73210e33f0cb73aa4b8bf279561ce7b7649b0f452aebf4c" + }, + { + "path": "packages/runtime-host/src/protocol/usage-pricing.ts", + "changed": false, + "hash": "c1dfbb382f2e92170b982582d4cef07eb0e95ebe76f7347fb64bb78f6d6fe977" + }, + { + "path": "apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts", + "changed": true, + "hash": "cbc6ff2abb1d7fc4c830b28245e6cac4000f8cf4647fa6ee895d5fea35f787ce" + }, + { + "path": "apps/desktop/src/main/runtime-host-client.ts", + "changed": false, + "hash": "97660978d487774c9145d0528a6863baba6c9988bccf40ecda11d7761d12655a" + } + ], + "rawResult": "perf-results/pricing-ablation-20260930/augmented/runs/no-async-ownership/result.json", + "rawSha256": "bf09b24e0109080a7d593a0155608da34ecbc1bddd0b03c8d7787c48a62229c4", + "browserFailures": [], + "finalVerdict": "killed" + } + ], + "browser": [ + { + "variant": "baseline", + "story": "host-review", + "width": 1280, + "failures": [], + "focusInDialog": true, + "finished": true, + "probeFailures": [], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/baseline/initial-results.json" + }, + { + "variant": "baseline", + "story": "host-review", + "width": 1280, + "failures": [], + "focusInDialog": true, + "finished": true, + "probeFailures": [], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/baseline/results.json" + }, + { + "variant": "no-catalog-aria-binding", + "story": "manual-exact-key", + "width": 1280, + "failures": [ + "Error: [product-settings-pricing--manual-exact-key (light/default/zh-CN/1280px)] console.error: kL: expect(element).toHaveAttribute(\"aria-required\", \"true\") // element.getAttribute(\"aria-required\") === \"true\"\n\nExpected the element to have attribute:\n aria-required=\"true\"\nReceived:\n null\n at Proxy.a (http://127.0.0.1:54727/assets/iframe-DlST7vAK.js:116:674)\n at Proxy. (http://127.0.0.1:54727/assets/iframe-DlST7vAK.js:90:403)\n at Proxy.methodWrapper (http://127.0.0.1:54727/assets/iframe-DlST7vAK.js:54:8430)\n at play (http://127.0.0.1:54727/assets/pricing-editor.stories-BdC9ncGn.js:56:32144)\n at async rK.runPhase (http://127.0.0.1:54727/assets/iframe-DlST7vAK.js:904:607)\n at async rK.render (http://127.0.0.1:54727/assets/iframe-DlST7vAK.js:904:3467); playFunctionThrewException: expect(element).toHaveAttribute(\"aria-required\", \"true\") // element.getAttribute(\"aria-required\") === \"true\"\n\nExpected the element to have attribute:\n aria-required=\"true\"\nReceived:\n null" + ], + "focusInDialog": true, + "finished": false, + "probeFailures": [ + "playFunctionThrewException: expect(element).toHaveAttribute(\"aria-required\", \"true\") // element.getAttribute(\"aria-required\") === \"true\"\n\nExpected the element to have attribute:\n aria-required=\"true\"\nReceived:\n null" + ], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/no-catalog-aria-binding/results.json" + }, + { + "variant": "no-dialog-focus-repair", + "story": "conflict", + "width": 1280, + "failures": [], + "focusInDialog": true, + "finished": true, + "probeFailures": [], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/no-dialog-focus-repair/initial-results.json" + }, + { + "variant": "no-dialog-focus-repair", + "story": "uncertain", + "width": 1280, + "failures": [], + "focusInDialog": true, + "finished": true, + "probeFailures": [], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/no-dialog-focus-repair/initial-results.json" + }, + { + "variant": "no-dialog-focus-repair", + "story": "host-review", + "width": 1280, + "failures": [ + "Error: [product-settings-pricing--host-review (light/default/zh-CN/1280px)] console.error: AssertionError: expected false to be true // Object.is equality\n at play (http://127.0.0.1:55117/assets/pricing-editor.stories-alsVpdeT.js:56:36610)\n at async rK.runPhase (http://127.0.0.1:55117/assets/iframe-JwoX27rS.js:904:607)\n at async rK.render (http://127.0.0.1:55117/assets/iframe-JwoX27rS.js:904:3467); playFunctionThrewException: expected false to be true // Object.is equality; visible dialog does not own focus after rendering" + ], + "focusInDialog": false, + "finished": false, + "probeFailures": [ + "playFunctionThrewException: expected false to be true // Object.is equality" + ], + "rawResult": "perf-results/pricing-ablation-20260930/browser-static/no-dialog-focus-repair/results.json" + }, + { + "variant": "no-stable-focus-fallback", + "story": "populated", + "width": 1280, + "failures": [ + "Error: [product-settings-pricing--populated (light/default/zh-CN/1280px)] console.error: kL: expect(element).toHaveFocus()\n\nExpected element with focus:\n \n \n \n \n \n \n \n \n \n 添加定价\n \n \n \n\nReceived element with focus:\n ········\n ········\n
······\n
·····\n ········\n
········\n ······\n ·····\n ········\n
······\n
·····\n ········\n
······\n
·········\n \n
\n
········\n