diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index a1ede116873..da6104bbff9 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 00000000000..6ad54c489c2 --- /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/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts index e87dbcc598c..d18d7736a7b 100644 --- a/apps/desktop/e2e/side-chat-followups.spec.ts +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -115,6 +115,9 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ 'first follow-up', 'second follow-up', 'retract this follow-up', ]); + // The final row can still be optimistic. Wait for its Host queue revision + // before opening an edit whose CAS base that admission would invalidate. + await expect(queued.getByRole('button', { name: '编辑', exact: true }).last()).toBeEnabled(); await queued.getByRole('button', { name: '编辑', exact: true }).first().click(); const edit = queued.getByRole('textbox', { name: '编辑', exact: true }); await edit.fill('edited first follow-up'); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5b1b54c1f7a..c7044743268 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -74,6 +74,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", @@ -1561,6 +1562,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 bf762c4b3b1..775dab3db15 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 00000000000..5449785d3f1 --- /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 00000000000..36d4afff971 --- /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 040f2ffb069..1fa784bb155 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 00000000000..1145c13c663 --- /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 fa1e688410e..1de24885fe1 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,7 +78,6 @@ function handlers(queryUsage: (input: UsageQueryInput) => Promise {}, }); return handlers; } 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 32caa63f34a..463921339f2 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 958ffbc26ad..f73c1785e6d 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 { @@ -1800,8 +1801,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 4e8ff6e9170..379c4fee9ce 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, @@ -155,6 +153,11 @@ import { type TurnMessageSubmitResult, type WorkspaceProjection, } from "@maka/runtime-host/protocol"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../shared/desktop-pricing.js"; const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); @@ -226,13 +229,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; @@ -240,39 +236,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; @@ -682,7 +645,7 @@ export class DesktopRuntimeHostClient { mutation: input.mutation, }); const reconciliationTarget = createPricingReconciliationTarget( - input.base, + input.base.entries, request.mutation, ); let result: OperationOutput<"pricing.mutate">; @@ -719,6 +682,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); + } + async listSessions(): Promise { this.#assertOpen(); try { @@ -1786,7 +1770,7 @@ export class DesktopRuntimeHostClient { try { const snapshot = await this.loadPricingSnapshot(); return { - kind: pricingTargetMatchesSnapshot(target, snapshot) + kind: pricingReconciliationTargetMatches(target, snapshot.entries) ? "synchronized" : "review_required", reason, @@ -2033,51 +2017,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 00000000000..9d52d449e24 --- /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 cf475b7ec39..e56f61d9b19 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 8f8136b28b4..2d3226472f4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -197,6 +197,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, @@ -1574,6 +1579,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 27faf2becab..3fa6281c213 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -239,6 +239,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, @@ -3473,6 +3478,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 5260750e57e..e7a0bd56036 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'; @@ -77,6 +79,7 @@ export function createDesktopFeatureServices() { sessionBundle: createDesktopSessionBundleServices(), sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), + usagePricing: createDesktopUsagePricingServices(), workbar: createDesktopWorkbarServices(), }; } @@ -103,7 +106,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 d0ecb07827f..5675c30a27a 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 00000000000..a0b0705237b --- /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 bb59e3d7851..461ec90bf18 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 1ef853d3671..768a796b85f 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 00000000000..9c8ac0a056d --- /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 00000000000..0759a3414fd --- /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 00000000000..22db6fc2488 --- /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 213daed90c5..48414475f00 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 882e0f3bf4f..301796ae7c7 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 00000000000..746de28cd8c --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -0,0 +1,641 @@ +/* + * 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 { 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); + 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. + + + 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 3c428130335..cff9ee5f049 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 00000000000..eaa72608d84 --- /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 281878a2804..6f2f1368c24 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 00000000000..5cd6e67f22e --- /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 bd7cc9199a2..7cd5ab0ab8a 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1184,7 +1184,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 a4979eff6ae..b0cd2e801cc 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 00000000000..8c05daeb3a1 --- /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 00000000000..d4f6c5333a6 --- /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 00000000000..e706191fa72 --- /dev/null +++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx @@ -0,0 +1,345 @@ +/* + * 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).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).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 034bf29df85..3133baf309e 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, @@ -365,7 +367,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, }; @@ -386,7 +387,6 @@ const emptyUsageStats: UsageStats = { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; @@ -1735,6 +1735,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: [ @@ -1911,6 +1938,7 @@ function renderedLinkColors(renderedLink: HTMLElement) { type SettingsStoryProps = { reopenable?: boolean; section: SettingsSection; + pricingServices?: UsagePricingServices; connections?: LlmConnection[]; defaultSlug?: string | null; openProviderCatalog?: boolean; @@ -2007,6 +2035,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) { + {open && } + @@ -2666,6 +2696,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 0966f36be84..a3baa268e33 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.1` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 290 files — blocker 0, reimplementation 0, polish 4, aligned 286. +**Totals:** 292 files — blocker 0, reimplementation 0, polish 4, aligned 288. ## Exclusions (explicit) @@ -104,8 +104,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 e562e8c6a64..5c6c2d8e715 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -75,8 +75,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/packages/cli/src/__tests__/acp-session-event-mapper.test.ts b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts index 380d08edc4b..7b496dc57d2 100644 --- a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -551,6 +551,51 @@ describe('ACP Session event mapper', () => { assert.equal(notifications.length, 1); }); + for (const status of ['approved', 'conflict', 'denied'] as const) { + test(`sandbox cards preserve the ${status} application outcome`, async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'sandbox', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'sandbox_boundary', + justification: 'Fetch dependencies', + expansion: { network: { enabled: true } }, + }, + }; + const decision = status === 'denied' ? 'deny' : 'allow'; + await mapper.pendingInteraction(pending); + await mapper.resolvedInteraction( + { + ...pending, + revision: 2, + status: 'answered', + outcome: { kind: 'sandbox_boundary_decision', decision, status, committedAt: 2 }, + }, + pending, + ); + const update = toolUpdate(notifications.at(-1)!); + assert.equal(update.status, status === 'approved' ? 'completed' : 'failed'); + assert.deepEqual((update._meta?.maka as { interaction: unknown } | undefined)?.interaction, { + id: pending.interactionId, + kind: 'sandbox_boundary', + status: 'answered', + decision, + outcomeStatus: status, + }); + const count = notifications.length; + await mapper.finishTools(pending.turnId); + assert.equal(notifications.length, count, 'settlement must preserve the applied outcome'); + }); + } + test('interaction updates preserve Host closure reasons without reopening terminal tools', async () => { const notifications: SessionNotification[] = []; const mapper = eventMapper(notifications); diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 4737f8ca53f..1dc1ea7fd21 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -1049,6 +1049,208 @@ describe('ACP Session registry', () => { } }); + for (const scenario of [ + 'closure', + 'approved', + 'query-failed', + 'cancel', + 'cancel-signal', + 'slow-notification', + 'failed-notification', + 'cancel-stalled-notification', + ] as const) { + test(`Turn settlement waits for interaction readback (${scenario})`, async () => { + const sessionId = `session-interaction-readback-${scenario}`; + const turn = runningTurn(sessionId, 'turn-readback'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const readback = deferred(); + const permission = deferred(); + const delivery = deferred(); + const abort = new AbortController(); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'sandbox-readback', + sessionId, + turnId: turn.turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'sandbox_boundary', + justification: 'Fetch dependencies', + expansion: { network: { enabled: true } }, + }, + }; + const closed: InteractionSnapshot = { + ...pending, + revision: 2, + status: 'closed', + outcome: { kind: 'closure', reason: 'turn_terminal', committedAt: 2 }, + }; + let queries = 0; + let permissionOpened = false; + let resolutionDeliveryStarted = false; + const notifications: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + subscription.setRoot(turn); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'interaction.query') + return ++queries === 1 ? pending : readback.promise; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'fetch dependencies' }] }, + { + ...promptContext(notifications), + signal: abort.signal, + notify: async (notification) => { + if (notification.update.sessionUpdate === 'tool_call_update') { + resolutionDeliveryStarted = true; + if (scenario === 'failed-notification') throw new Error('Resolution delivery failed'); + if (scenario === 'slow-notification' || scenario === 'cancel-stalled-notification') + await delivery.promise; + } + notifications.push(notification); + }, + interactions: { + capabilities: {}, + createElicitation: async () => { + throw new Error('Unexpected elicitation'); + }, + requestPermission: async () => { + permissionOpened = true; + return permission.promise; + }, + }, + }, + ); + let settled = false; + void prompt.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await waitFor(() => subscription.snapshot.rootTurn?.turnId === turn.turnId); + subscription.project({ interactions: { pending: [pending] } }); + await waitFor(() => permissionOpened); + // One authoritative projection both removes the interaction and ends + // its Turn. The canonical outcome query is intentionally still pending. + subscription.project({ + rootTurn: completedTurn(sessionId, turn.turnId), + interactions: { pending: [] }, + }); + await waitFor(() => queries === 2); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false, 'end_turn must wait for canonical interaction readback'); + + if (scenario === 'cancel' || scenario === 'cancel-signal') { + if (scenario === 'cancel-signal') abort.abort(); + else await registry.cancel({ sessionId }); + await waitFor(() => settled); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + const count = notifications.length; + readback.reject(new Error('Readback failed after cancellation')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(notifications.length, count, 'cancelled prompts suppress late readback'); + } else if (scenario === 'query-failed') { + readback.reject(new Error('Interaction readback unavailable')); + await assert.rejects(prompt, (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.deepEqual(error.data, { + source: 'adapter', + code: 'interaction_failed', + kind: 'sandbox_boundary', + interactionId: pending.interactionId, + }); + return true; + }); + } else { + readback.resolve( + scenario === 'approved' + ? { + ...pending, + revision: 2, + status: 'answered', + outcome: { + kind: 'sandbox_boundary_decision', + decision: 'allow', + status: 'approved', + committedAt: 2, + }, + } + : closed, + ); + if (scenario === 'failed-notification') { + await assert.rejects(prompt, (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.deepEqual(error.data, { + source: 'adapter', + code: 'interaction_failed', + kind: 'sandbox_boundary', + interactionId: pending.interactionId, + }); + return true; + }); + return; + } + if (scenario === 'slow-notification' || scenario === 'cancel-stalled-notification') { + await waitFor(() => resolutionDeliveryStarted); + assert.equal(settled, false, 'readback must be delivered before end_turn'); + if (scenario === 'cancel-stalled-notification') { + await registry.cancel({ sessionId }); + await waitFor(() => settled); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + delivery.reject(new Error('Delivery failed after cancellation')); + return; + } + delivery.resolve(); + } + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + const update = notifications.at(-1)?.update; + assert.equal(update?.sessionUpdate, 'tool_call_update'); + if (update?.sessionUpdate !== 'tool_call_update') return; + assert.equal(update.status, scenario === 'approved' ? 'completed' : 'failed'); + const meta = update._meta?.maka as { + hostStatus?: string; + interaction: { status: string; reason?: string }; + }; + assert.equal(meta.hostStatus, undefined, 'a resolved sandbox card is not interrupted'); + assert.equal(meta.interaction.status, scenario === 'approved' ? 'answered' : 'closed'); + if (scenario === 'closure') { + assert.equal(meta.interaction.reason, 'turn_terminal'); + assert.match(JSON.stringify(update.content), /turn_terminal/); + } + } + } finally { + readback.resolve(closed); + delivery.resolve(); + await registry.dispose(); + await prompt.catch(() => undefined); + } + }); + } + test('an externally resolved interaction cannot clear a cancelled Turn fence before settlement', async () => { const sessionId = 'session-same-turn-interaction-cancel'; const subscription = new FakeSubscription(continuitySnapshot(sessionId)); diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 1347f2f9fb9..3e661f2d9a4 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -73,6 +73,7 @@ import { import { AcpSessionEventMapper } from './session-event-mapper.js'; import { mapAcpPromptContent, publishAcpPromptAttachments } from './prompt-content.js'; import { AcpSessionInteractions, type AcpInteractionClient } from './session-interactions.js'; +import { whileActive } from './active-promise.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; const ADMISSION_QUERY_MAX_ATTEMPTS = 5; @@ -124,6 +125,7 @@ interface ActiveAcpPrompt { readonly turnId: string; readonly mapper: AcpSessionEventMapper; readonly waiters: Set<() => void>; + readonly interactionReconciliations: Set>; attachment?: RuntimeHostSessionChannel; transcript?: ReturnType; readonly projectionAbort: AbortController; @@ -270,6 +272,7 @@ export class AcpSessionRegistry { signal: projectionAbort.signal, }), waiters: new Set(), + interactionReconciliations: new Set(), projectionAbort, reconciliationAbort, dispatchStarted: false, @@ -413,6 +416,16 @@ export class AcpSessionRegistry { if (active.cancelled) return this.#cancelledStopReason(active); if (terminalStatus === 'completed') await this.#reconcilePrompt(active, true); else active.reconciliationAbort.abort(); + // A terminal snapshot can remove pending interactions before their + // canonical outcomes have been queried. Keep this prompt and its tool + // mapper alive until those Turn-scoped reads and deliveries settle. + while (!active.projectionAbort.signal.aborted && active.interactionReconciliations.size > 0) { + await whileActive( + Promise.all([...active.interactionReconciliations]), + active.projectionAbort.signal, + ); + } + if (active.cancelled) return this.#cancelledStopReason(active); if (active.projectionFailure) throw active.projectionFailure; await active.mapper.finishTools(active.turnId, terminalStatus); await active.mapper.flush(); @@ -731,14 +744,18 @@ export class AcpSessionRegistry { void interactions.pending(pending); }, onInteractionResolved: (pending) => { - if ( - !interactions.fencesTurn(pending.turnId) && - ![...(this.#activePrompts.get(sessionId) ?? [])].some( - (active) => active.turnId === pending.turnId && active.dispatchStarted, - ) - ) - return; - void interactions.resolved(pending); + const prompts = [...(this.#activePrompts.get(sessionId) ?? [])].filter( + (active) => + active.turnId === pending.turnId && active.dispatchStarted && !active.finished, + ); + if (!interactions.fencesTurn(pending.turnId) && prompts.length === 0) return; + const reconciliation = interactions.resolved(pending); + for (const active of prompts) { + active.interactionReconciliations.add(reconciliation); + void reconciliation.finally(() => + active.interactionReconciliations.delete(reconciliation), + ); + } }, onTranscriptSettlement: (turnId) => { for (const active of this.#activePrompts.get(sessionId) ?? []) { diff --git a/packages/cli/src/acp/tool-event-mapper.ts b/packages/cli/src/acp/tool-event-mapper.ts index f7739f75d20..964fb71461d 100644 --- a/packages/cli/src/acp/tool-event-mapper.ts +++ b/packages/cli/src/acp/tool-event-mapper.ts @@ -191,13 +191,14 @@ export class AcpToolEventMapper { status: resolved.status, ...(outcome.kind === 'closure' ? { reason: outcome.reason } : {}), ...('decision' in outcome ? { decision: outcome.decision } : {}), + ...(outcome.kind === 'sandbox_boundary_decision' ? { outcomeStatus: outcome.status } : {}), ...('action' in outcome ? { action: outcome.action } : {}), }; if (!tool.terminal) { if (pending.request.kind === 'sandbox_boundary') { tool.terminal = true; tool.status = - outcome.kind === 'sandbox_boundary_decision' && outcome.decision === 'allow' + outcome.kind === 'sandbox_boundary_decision' && outcome.status === 'approved' ? 'completed' : 'failed'; } else tool.status = 'in_progress'; diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 9557416bacf..7717164feb2 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -662,12 +662,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 @@ -698,6 +692,12 @@ export interface UsageActivityPage { } export interface UsageScreen extends UsageStats, UsageActivityPage { + 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 00000000000..c24af30921f --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json @@ -0,0 +1,5 @@ +{ + "epoch": 166, + "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 37ceb87aec5..98100d21042 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 20be8381a21..593b37172bc 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/patches/@astryxdesign+core+0.6.1.patch b/patches/@astryxdesign+core+0.6.1.patch index 8885848081c..e0dd67a9644 100644 --- a/patches/@astryxdesign+core+0.6.1.patch +++ b/patches/@astryxdesign+core+0.6.1.patch @@ -586,6 +586,18 @@ index b56c9d5..463f665 100644 id: `${id}-children`, role: "group", "aria-labelledby": `${id}-label`, +diff --git a/node_modules/@astryxdesign/core/dist/Typeahead/Typeahead.js b/node_modules/@astryxdesign/core/dist/Typeahead/Typeahead.js +--- a/node_modules/@astryxdesign/core/dist/Typeahead/Typeahead.js ++++ b/node_modules/@astryxdesign/core/dist/Typeahead/Typeahead.js +@@ -357,6 +357,8 @@ + inputId: inputId, + ariaDescribedBy: ariaDescribedBy, + ariaLabelledBy: ariaLabelledBy, ++ "aria-invalid": status?.type === 'error' ? 'true' : undefined, ++ "aria-required": isRequired && !isOptional ? 'true' : undefined, + onChangeQuery: onChangeQuery, + onOpenChange: onOpenChange, + debounceMs: debounceMs, diff --git a/node_modules/@astryxdesign/core/dist/astryx.css b/node_modules/@astryxdesign/core/dist/astryx.css index f7c1792..0180bb3 100644 --- a/node_modules/@astryxdesign/core/dist/astryx.css @@ -1365,6 +1377,18 @@ index 6c581d4..d3a9857 100644 {hasChildren && !isCollapsed && (
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 ?? [])];