From e23081c5166a8f67af35c65589c9233282bd7bc6 Mon Sep 17 00:00:00 2001 From: ashishexee <144021866+ashishexee@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:16:48 +0530 Subject: [PATCH] fix(desktop): send provider currency in Meta to prevent session-start flip (#4546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar cost display flipped from ¥ (CNY) to $ (USD) when the first usage event arrived because sessionCurrency defaulted to ¥ in the frontend initialState, while DeepSeek's pricing is configured in USD. Now the Controller stores the provider's currency symbol (from entry.Price.Symbol()) at build time, and MetaForTab() includes it in the Meta event. The frontend reads meta.currency on session start and initializes sessionCurrency immediately — before any usage event. Fixes: - case "meta" sets sessionCurrency from meta.currency - case "reset" preserves sessionCurrency (was lost to initialState spread) - MetaForTab() nil-guards tab.Ctrl - sameMeta() includes currency in comparison --- desktop/app.go | 6 ++++ desktop/frontend/src/lib/types.ts | 1 + desktop/frontend/src/lib/useController.ts | 11 +++++-- desktop/tab_profile_test.go | 38 +++++++++++++++++++++++ internal/boot/boot.go | 1 + internal/control/controller.go | 11 +++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/desktop/app.go b/desktop/app.go index b96fbe236..b30efd23c 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -3403,6 +3403,7 @@ type Meta struct { TokenMode string `json:"tokenMode"` Goal string `json:"goal,omitempty"` GoalStatus string `json:"goalStatus,omitempty"` + Currency string `json:"currency,omitempty"` // display currency symbol from provider pricing (e.g. "$", "¥") } // Meta reports the model label, readiness, any startup error, the working @@ -3427,6 +3428,10 @@ func (a *App) MetaForTab(tabID string) Meta { tokenMode := currentTabTokenMode(tab) goal := currentTabGoal(tab) goalStatus := currentTabGoalStatus(tab) + var currency string + if tab.Ctrl != nil { + currency = tab.Ctrl.CurrencySymbol() + } return Meta{ Label: tab.Label, Ready: tab.Ready, @@ -3444,6 +3449,7 @@ func (a *App) MetaForTab(tabID string) Meta { TokenMode: tokenMode, Goal: goal, GoalStatus: goalStatus, + Currency: currency, } } diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index efc424434..229af3350 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -355,6 +355,7 @@ export interface Meta { tokenMode?: TokenMode; goal?: string; goalStatus?: GoalStatus; + currency?: string; // display currency symbol from provider pricing (e.g. "$", "¥") } export type CollaborationMode = "normal" | "plan" | "goal"; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 264ac7f09..72aa344f6 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -178,7 +178,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean { a.toolApprovalMode === b.toolApprovalMode && a.tokenMode === b.tokenMode && a.goal === b.goal && - a.goalStatus === b.goalStatus + a.goalStatus === b.goalStatus && + a.currency === b.currency ); } @@ -639,7 +640,11 @@ export function reducer(s: State, a: Action): State { }); return { ...s, items: finalized, running: false, turnActive: false, pendingPrompt, backgroundJobs, cancelRequested, cancellable, live: undefined, currentAssistant: undefined, approval: undefined, ask: undefined }; } - case "meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta }; + case "meta": { + if (sameMeta(s.meta, a.meta)) return s; + const sessionCurrency = a.meta?.currency || s.sessionCurrency; + return { ...s, meta: a.meta, sessionCurrency }; + } case "context": { const sessionTokens = typeof a.context.sessionTokens === "number" ? Math.max(0, a.context.sessionTokens) @@ -669,7 +674,7 @@ export function reducer(s: State, a: Action): State { case "local_notice": return { ...s, running: false, turnActive: false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: a.level, text: a.text }] }; case "clearApproval": return { ...s, approval: undefined, pendingPrompt: false }; case "clearAsk": return { ...s, ask: undefined, pendingPrompt: false }; - case "reset": return { ...initialState, meta: s.meta, context: { ...s.context, used: 0, sessionTokens: 0 }, balance: s.balance, effort: s.effort, jobs: s.jobs, sessionGen: s.sessionGen + 1 }; + case "reset": return { ...initialState, meta: s.meta, sessionCurrency: s.sessionCurrency, context: { ...s.context, used: 0, sessionTokens: 0 }, balance: s.balance, effort: s.effort, jobs: s.jobs, sessionGen: s.sessionGen + 1 }; case "event": return applyEvent(s, a.e); default: return s; } diff --git a/desktop/tab_profile_test.go b/desktop/tab_profile_test.go index 168059dde..7bd518545 100644 --- a/desktop/tab_profile_test.go +++ b/desktop/tab_profile_test.go @@ -446,6 +446,44 @@ func TestSetBypassPreservesPlanMode(t *testing.T) { } } +func TestMetaReportsCurrencyFromProvider(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + tab := testTab("a", t.TempDir()) + app.tabs = map[string]*WorkspaceTab{tab.ID: tab} + app.tabOrder = []string{tab.ID} + app.activeTabID = tab.ID + defer tab.Ctrl.Close() + + // Default: no pricing configured → empty currency. + meta := app.MetaForTab(tab.ID) + if meta.Currency != "" { + t.Fatalf("default currency = %q, want empty", meta.Currency) + } + + // Simulate a controller built with USD pricing (e.g. DeepSeek). + tab.Ctrl = control.New(control.Options{Label: "deepseek", CurrencySymbol: "$"}) + meta = app.MetaForTab(tab.ID) + if meta.Currency != "$" { + t.Fatalf("USD currency = %q, want $", meta.Currency) + } + + // Simulate a controller built with CNY pricing (e.g. MIMO). + tab.Ctrl = control.New(control.Options{Label: "mimo", CurrencySymbol: "¥"}) + meta = app.MetaForTab(tab.ID) + if meta.Currency != "¥" { + t.Fatalf("CNY currency = %q, want ¥", meta.Currency) + } + + // Nil controller (boot failed / tab still loading) → empty, no panic. + tab.Ctrl = nil + meta = app.MetaForTab(tab.ID) + if meta.Currency != "" { + t.Fatalf("nil ctrl currency = %q, want empty", meta.Currency) + } +} + func userConfigPathForTest() string { if dir, err := os.UserConfigDir(); err == nil { return dir + "/reasonix/reasonix.toml" diff --git a/internal/boot/boot.go b/internal/boot/boot.go index b13960aa9..5eb54542f 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -823,6 +823,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { DisableColdResumePrune: !cfg.ColdResumePruneEnabled(), Shell: shell, PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools, + CurrencySymbol: entry.Price.Symbol(), OnRemember: func(rule string) control.RememberResult { return rememberPermissionRule(root, rule) }, diff --git a/internal/control/controller.go b/internal/control/controller.go index d9b485556..d5fedb313 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -64,6 +64,7 @@ type Controller struct { label string modelRef string + currencySymbol string // display currency symbol from provider pricing (e.g. "$", "¥"); empty = default "¥" systemPrompt string sessionDir string host *plugin.Host @@ -287,6 +288,10 @@ type Options struct { // matches the agent's configured [tools.shell] choice. Zero value = auto. Shell sandbox.Shell Classifier autoPlanClassifier + // CurrencySymbol is the display currency symbol from the provider's pricing + // configuration (e.g. "$" for USD, "¥" for CNY). Empty means no pricing was + // configured and the frontend should use its default. + CurrencySymbol string // OnRemember, when set, is invoked with a new allow rule the user chose to // persist to disk (e.g. "Bash(go test:*)"). The callback is wired into the // permission Gate on EnableInteractiveApproval. @@ -334,6 +339,7 @@ func New(opts Options) *Controller { disableColdResumePrune: opts.DisableColdResumePrune, shell: opts.Shell, classifier: classifier, + currencySymbol: opts.CurrencySymbol, onRemember: opts.OnRemember, balanceURL: opts.BalanceURL, balanceKey: opts.BalanceKey, @@ -2581,6 +2587,11 @@ func (c *Controller) DisconnectMCPServer(name string) bool { // Label returns the human-readable model label, e.g. "deepseek-flash". func (c *Controller) Label() string { return c.label } +// CurrencySymbol returns the display currency symbol from the provider's +// pricing configuration (e.g. "$" for USD, "¥" for CNY). Empty means the +// provider had no pricing configured and the frontend should use its default. +func (c *Controller) CurrencySymbol() string { return c.currencySymbol } + // WorkspaceRoot returns the workspace root for this controller's session // (the directory that file-writers and @-references are scoped to). // Empty means no scoping is in effect.