From a154562a89846c944c13fcd460f8da463354f434 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:19:58 -0700 Subject: [PATCH 1/9] fix(native-chat): show a reply cut off by an Orca crash or quit like a finished turn, with one explanation (#25043) * fix(native-chat): read a crash-cut reply like a finished turn, with one explanation A reply that an Orca crash or restart cut off said it failed three times: the turn bar read "Failed after N", the chat's notice row said Claude stopped, and the sidebar dot stayed red after the chat was read. The turn bar now reads "Worked for N" for a proven crash/restart cut, the notice row stays the one explanation, and the sidebar card's dot and agent rows read failed only until the user has visited the chat (the same acknowledgement that un-bolds the row), then read done. A failure, a user's Stop, a replaced turn and an unconfirmed end keep their labels. The stored outcome is unchanged. * fix(native-chat): explain a turn a quit or eviction cut, once A turn cut off by quitting Orca, an idle eviction or a teardown recorded the same outcome as a crash-cut turn but wrote no notice row, so with the turn bar now reading "Worked for N" nothing in the chat said it stopped. The host stop's settle now writes the existing providerExited notice for the latest turn when it ends as news (no person's Stop decided it): in the same write as the host's own turn end, or right after the adapter's. It is keyed by the turn, and skipped when an error row already explains that turn, so a retry or an earlier exit row never leaves two. * test(native-chat): narrow the turn scope and add the seen map in the crash-cut tests * fix(native-chat): derive a cut turn's one notice on read instead of writing it at stop time A turn cut short when the agent stopped without anyone asking now reads "Worked for N", so it needs a row saying it stopped. Writing that row only on the quit and eviction paths missed journals written before this change, a quit that died between its drain and its settle, and any future stop cause. The transcript now derives it from the journal on desktop and phone alike: a root turn that ended interrupted with no verdict and no row about the stop gets one notice in the provider-exit row's words. A stored exit row, matched by its exit fact or its writer's identity rather than its tone, stays the explanation, and so does the restart continuation's own outcome note, so a refused resume says it once. The host's stop path is back to what it was. * fix(sidebar): read a cut-short turn's red mark from its acknowledgement on every surface A turn cut short with nobody asking reads failed only until the user has seen it. The previous revision passed an optional "seen" flag to each caller, so any surface that did not pass it, the chat's own tab dot among them, stayed red beside a sidebar that read Done. The verdict's display now takes the acknowledgement as part of what it reads: the entry's stateStartedAt beside the time the user last acknowledged it, both required, judged by one shared rule. Each surface joins it once where it builds its rows: the sidebar's agent rows (and so the notes send menu), the workspace card summary, the terminal tab bar, Cmd-J recent rows, and Activity threads. Failures, Stops, replaced turns and unproven ends keep their marks as before; the phone, which has no acknowledgement record, keeps the unseen reading. * refactor(attention): use the one acknowledgement rule where it was copied Auto-acknowledgement, the Activity unread count, dashboard row buckets and notification acknowledgement each spelled the same "acknowledged at or after the current state began" comparison; they now call the shared rule. * test(mobile): type the cut-turn notice test's client and hook holder * test: pin an older host's exit row by its writer and the sidebar rows' acknowledgement join * test(sidebar): re-read the card and the tab when only an acknowledgement changes * fix(native-chat): keep a cut turn's notice through a resume that carries on A resume after a quit writes its "asked this agent to continue" note after its own message, so counting that note as the cut's explanation removed the notice once the resume went on, and made it flash away under automatic resume. Only the notes that say the chat was not carried on (refused, not connected, not confirmed) stand in for the notice now. A row about the whole conversation now explains a cut turn only when no other turn lies between them, and a failed start's row, which is about a start, never does. The host writers and the rule share the row identity prefixes, with the contract that a new row explaining a stop carries the provider-exited fact or one of them. * fix(sidebar): judge a cut as seen by the main agent's clock when a subagent holds the row A subagent can keep a row working after its main agent was cut, and the row's clock then predates the cut, so a look at the working chat counted as having seen the cut and no red mark showed. The mark now compares the acknowledgement with the later of the row's and the main agent's clocks; auto-acknowledgement of the chat on screen reads the same clock, and its stamp covers it, so looking at the chat still clears the mark. Bold rows, dashboard buckets, notifications and unread counts keep the row's clock. * fix(native-chat): tie a conversation-wide exit row to a cut only with no message sent since A send after a quit's cut whose new agent died before its turn opened leaves a provider-exit row about the whole conversation. That row is about the send, not the earlier cut, so the cut kept no explanation. An exit row now explains a preceding cut only when no message was sent in between; the restart notes, which follow the continuation's own message, still need only no turn between. The notes that say a resume did not carry the chat on are now told apart from the continued note by their tone ('error' or 'warning'), which every host that wrote them has set, instead of by their words. * fix(native-chat): keep an owner's proven death the explanation of its cut after a send A chat read before the startup reconcile settles its cut turn unverifiable; if the user then sends a message, the reconcile proves the old agent dead and writes its row about the conversation after that message. The row names the old owner's death, never the send, so a message since no longer detaches it from the cut. Only a provider-exit row, which a later start can write, still needs no message sent since. * test(sidebar): give the activity-status store mock the acknowledgement map The card summary now reads acknowledgedAgentsByPaneKey; this test's hand-built store state lacked it, so every summary read threw. * refactor: move the seen-gated red mark out of this change This change now keeps only the cut turn's label and its one derived notice. The red mark that clears once the user has seen a cut turn moves to its own change, so each can land alone; until it lands, the sidebar, tab bar, Cmd-J, Activity and the notes send menu read a cut turn as failed, as before. * test(native-chat): pin that a restart note after a continuation's turn leaves the cut's notice * test(native-chat): keep a cut turn's notice beside a later message drawn as not sent --- .../MobileNativeChatTurnStatus.test.ts | 5 +- ...red-agent-session.cut-turn-notice.test.tsx | 132 ++++++ .../use-mobile-structured-agent-session.ts | 20 +- ...ctured-agent-session-close-verdict.test.ts | 45 +- ...tured-agent-session-crash-turn-end.test.ts | 35 +- ...gent-session-dead-generation-settlement.ts | 5 +- ...agent-session-recovered-turn-clock.test.ts | 8 +- ...ured-agent-session-restart-continuation.ts | 6 +- ...ent-session-restart-failure-filing.test.ts | 25 + ...ssion-restart-interruption-test-harness.ts | 27 +- ...d-agent-session-stop-event-restart.test.ts | 5 +- ...tructured-agent-session-unexpected-exit.ts | 3 +- ...MessageList.interrupted-turn-fold.test.tsx | 5 +- ...red-agent-session.cut-turn-notice.test.tsx | 97 ++++ .../use-structured-agent-session.ts | 14 +- src/shared/agent-session-stop-row-identity.ts | 12 + .../native-chat-cut-turn-notice.test.ts | 427 ++++++++++++++++++ src/shared/native-chat-cut-turn-notice.ts | 212 +++++++++ src/shared/native-chat-turn-status.test.ts | 22 +- src/shared/native-chat-turn-status.ts | 5 +- 20 files changed, 1066 insertions(+), 44 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx create mode 100644 src/shared/agent-session-stop-row-identity.ts create mode 100644 src/shared/native-chat-cut-turn-notice.test.ts create mode 100644 src/shared/native-chat-cut-turn-notice.ts diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b2..a0393996cec 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + // The crash's notice row explains the cut; the turn bar reads like any finished turn. + it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Failed after 12s']) + expect(labels(crashed.root)).toEqual(['Worked for 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 00000000000..37a29458c07 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,132 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 3 }, + nextCursor: { epoch: 'epoch-1', sequence: 4 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 3 }, + hasOlder: false, + hasNewer: false + } + } +} + +describe('useMobileStructuredAgentSession transcript', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + let listener: ((value: unknown) => void) | null = null + const client: Pick = { + sendRequest: vi.fn(async (): Promise => ({ + id: 'response-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) + } + const rendered: { hook: ReturnType | null } = { + hook: null + } + function Harness(): null { + rendered.hook = useMobileStructuredAgentSession({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + client: client as RpcClient, + sessionId: 'session-1', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: vi.fn() + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + + act(() => listener?.(snapshot(ITEMS))) + + await vi.waitFor(() => + expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + ) + // The turn bars place rows by the same items, so the notice joins the cut turn. + expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ae688ab9072..b0ecfe9832c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, @@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no + // row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, state.items] + ) const messages = useMemo( // Off: the phone hands a rejected message back to its composer, so a row would show it twice. () => - projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { rejectedInPlace: false }), - [state.items, state.submissions] + [transcriptItems, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) - const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) + const turnTiming = useMobileStructuredAgentTurnTiming( + { ...state, items: transcriptItems }, + turnId + ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 33ef8080c03..b1922b913ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -12,6 +12,7 @@ import { import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -121,9 +122,19 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - return { turn, settled } + const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key + // Every error row a reader's transcript shows, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + return { turn, settled, label, notices } } +/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ +const ONE_NOTICE = [ + 'Codex stopped while this response was in progress. You can continue in this conversation.' +] + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter' }) + expect(label).toBe('interruptedAfter') + // The person's own close explains itself. + expect(notices).toEqual([]) }) it('leaves a close the user did not aim at this chat as news', async () => { @@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + // News for the sidebar; the turn reads like a finished one beside its one notice. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + const { label, notices } = await settledTurn() + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { @@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => { await host.collaboratorsForTests().lifetime.idleSweep.tick() expect(closeCalls).toBe(1) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) + expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -309,9 +320,10 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it('leaves a crash the provider saw before the user closed the chat as news', async () => { @@ -319,9 +331,10 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it.each(['user-close', 'evict'] as const)( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e237615089..d31a79e5f54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-adapter' import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -216,6 +217,34 @@ describe('a turn a crash cut short mid-tool', () => { expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) }) + // The turn bar reads like a finished turn, so this row is the one place the chat says why. + it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + const { items } = await host.journalSnapshot(SESSION) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( + (item) => (item.body.kind === 'status' ? [item.body] : []) + ) + expect(statusRows).toEqual([ + expect.objectContaining({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + failure: expect.objectContaining({ kind: 'providerExited' }), + tone: 'error' + }) + ]) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'workedFor', duration: '27s' }) + }) + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. let alive = true @@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed, then the folded "Failed after 27s". + // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'failedAfter', duration: '27s' }) + ).toEqual({ key: 'workedFor', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba51..f0372c8e58d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,6 +4,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // Named by the death it explains, so a retry after a partly written settle adds no second row. identity: { provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e..0857e3565d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and - // must never show the done tick of a finished turn. + // The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a + // finished turn, since the chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Failed after N, marked failed, for a turn cut off by %s', + 'reads Worked for N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -221,7 +221,7 @@ describe('a turn recovery settled after its host went away', () => { ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Failed after 1s' + 'Worked for 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 27c09bc2bbe..c748748ccd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -22,6 +22,7 @@ import { type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -120,7 +121,10 @@ function restartNoteWriter( return } await session.journal.appendItem( - { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { + provider: 'orca', + clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}` + }, { kind: 'status', text, ...(tone ? { tone } : {}) }, // About the conversation, not any turn in it. { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index b1cbc83a48e..4602630960a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -1,12 +1,15 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { interruptedRestart, + QUIT_CUT_NOTICE, + readerNotes, statusNotes, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' @@ -43,6 +46,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) expect(await statusNotes(host)).toEqual([]) + // No note took over, so the cut turn keeps its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the @@ -51,6 +56,8 @@ it('says so in the chat when the agent cannot start for the continuation', async const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + // Before the action, the quit's cut reads with its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -61,6 +68,24 @@ it('says so in the chat when the agent cannot start for the continuation', async text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) + // The refusal note is now the one explanation: the cut is not said a second time beside it. + expect(await readerNotes(host)).toEqual(await statusNotes(host)) +}) + +// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in +// for the cut: its note is written after its own message, so the cut keeps its notice throughout. +it("keeps the quit's notice through a continuation that carries the chat on", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined } + ]) }) // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index fe97834a189..59d478cbb40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,4 +1,8 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -177,11 +181,30 @@ export function throwAfterContinuationAccepted(): void { } export async function statusNotes(host: StructuredAgentSessionHost) { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + return notesOf((await host.journalSnapshot(SESSION)).items) +} + +/** The notes a reader's transcript shows, the cut turn's derived notice included. */ +export async function readerNotes(host: StructuredAgentSessionHost) { + return notesOf( + withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, { + agentName: 'Codex' + }) + ) +} + +function notesOf(items: readonly AgentJournalRenderItem[]) { + return items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] ) } +/** The one row the quit's cut turn reads with when nothing else explains it. */ +export const QUIT_CUT_NOTICE = { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' +} + /** A continuation the host refuses because the user's own message was accepted first: another * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015..50056eedd03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - it('reads Failed after N, marked failed, when nobody stopped it', async () => { + // The notice row is the one explanation; the turn bar reads like a finished turn. + it('reads Worked for N, marked failed, when nobody stopped it', async () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) + expect(label).toMatch(/^Worked for /) expect(mark).toBe('failed') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 232b101e603..5a0b2f6de30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -193,5 +194,5 @@ function logExitFailure( } function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` + return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx index 13f98dd4249..acce19bcf52 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx @@ -112,7 +112,8 @@ function session(): NativeChatLiveSession { } describe('NativeChatMessageList folded turn headers', () => { - it('says a turn a crash cut off failed and a stopped turn was interrupted, where a finished one worked', () => { + // A crash-cut turn reads like a finished one; the chat's notice row is what says it stopped. + it('says a crash-cut turn worked like a finished one and a stopped turn was interrupted', () => { render( { .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) expect(headers).toEqual([ ['Worked for 12s', 'false'], - ['Failed after 12s', 'false'], + ['Worked for 12s', 'false'], ['Interrupted after 12s', 'false'] ]) // The turn's detail stays inside the fold. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 00000000000..fcb474f2acc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const items: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => null), + supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: vi.fn(), + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +describe('useStructuredAgentSession transcript', () => { + it('shows a cut turn no row explains with its one notice, placed in that turn', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + // The list places rows by the same items, so the notice joins the cut turn. + expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 46571fcf54f..0f126fe0559 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,6 +38,8 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -164,8 +166,16 @@ export function useStructuredAgentSession(args: { }), [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] ) + // What the transcript reads: the journal plus the one notice a cut turn with no row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(transportState.journalItems, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, transportState.journalItems] + ) const messages = useStructuredAgentSessionMessages( - transportState.journalItems, + transcriptItems, transcriptOutbox, transportState.submissions, queuedMessageIds @@ -200,7 +210,7 @@ export function useStructuredAgentSession(args: { { command } ) }), - journalItems: transportState.journalItems, + journalItems: transcriptItems, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/shared/agent-session-stop-row-identity.ts b/src/shared/agent-session-stop-row-identity.ts new file mode 100644 index 00000000000..d314003a3f9 --- /dev/null +++ b/src/shared/agent-session-stop-row-identity.ts @@ -0,0 +1,12 @@ +// The identities of the rows a host writes about a stop, shared with the readers that must not say +// the same stop twice (`native-chat-cut-turn-notice`). +// +// Contract: a new row that explains a stop must carry the providerExited fact or one of these +// prefixes, or a client derives a second notice beside it. + +/** The exit row the unexpected-exit settle writes, keyed by the child that exited. */ +export const PROVIDER_EXIT_ROW_PREFIX = 'provider-exit:' +/** The exit row a reopen or acquire writes for a generation found dead. */ +export const STALE_SESSION_ROW_PREFIX = 'stale-session:' +/** The note the restart continuation leaves about its outcome. */ +export const RESTART_CONTINUATION_ROW_PREFIX = 'restart-continuation:' diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts new file mode 100644 index 00000000000..09fc37ba577 --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalSubmission, + AgentJournalRenderItem, + AgentJournalTurnOutcome, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from './agent-session-restart-continuation' +import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const NOTICE = + 'Codex stopped while this response was in progress. You can continue in this conversation.' +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + /** Null for a host that predates scopes. */ + turnScope: AgentJournalTurnScope | null = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +const user = (itemId: string, scope: AgentJournalTurnScope | null = THREAD) => + item(itemId, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }, scope) +const reply = (itemId: string, scope: AgentJournalTurnScope | null) => + item( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + scope + ) + +type Ending = { + state: 'running' | 'completed' | 'interrupted' | 'unverifiable' + outcome?: AgentJournalTurnOutcome +} +const CUT: Ending = { state: 'interrupted' } + +function turn( + itemId: string, + userItemId: string, + ending: Ending, + scope: AgentJournalTurnScope | null = THREAD +) { + return item( + itemId, + { + kind: 'turn', + turnId: itemId, + userItemId, + startedAt: 1_000, + ...(ending.state === 'running' ? {} : { completedAt: 2_000 }), + ...ending + }, + scope + ) +} + +/** A row the host wrote, by the identity its writer gives it. */ +function hostRow( + clientMessageId: string, + body: AgentJournalItemBody, + scope: AgentJournalTurnScope | null +) { + return item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) +} + +/** What the crash path's exit row says, from its fact. */ +const exitWords = (): AgentJournalItemBody => ({ + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error' +}) +const exitRow = (clientMessageId: string, scope: AgentJournalTurnScope | null) => + hostRow(clientMessageId, exitWords(), scope) +/** A note the restart continuation leaves about its outcome, about the conversation. */ +const restartNote = (text: string, tone?: 'error' | 'warning') => + hostRow( + `restart-continuation:s:${text.length}`, + { kind: 'status', text, ...(tone ? { tone } : {}) }, + THREAD + ) + +function notices(items: readonly AgentJournalRenderItem[]) { + return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => + entry.itemId.includes('cut-turn-notice') + ? [ + { + index, + text: entry.body.kind === 'status' ? entry.body.text : null, + scope: entry.turnScope + } + ] + : [] + ) +} + +describe('withNativeChatCutTurnNotices', () => { + it('gives a cut turn no row explains one notice in the exit row words, after its last row', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + const notice = derived[3]! + expect(notice.body).toMatchObject({ tone: 'error', failure: { kind: 'providerExited' } }) + // Placed by journal position too, so any reader that sorts keeps it under the turn. + expect(notice.sequence).toBe(items[2]!.sequence) + expect(notice.sequenceIndex).toBeGreaterThan(0) + }) + + it('adds nothing where the crash path already wrote its exit row, scoped or about the conversation', () => { + for (const [id, scope] of [ + ['stale-session:s:death-3-2000', inTurn('t1')], + ['provider-exit:s:3:gen', inTurn('t1')], + ['stale-session:s:death-3-2000', THREAD] + ] as const) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + exitRow(id, scope) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + + // A host from before failure facts wrote the same rows with only their words. + it("counts an older host's exit row by its writer, with no exit fact", () => { + for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow(id, { kind: 'status', text: NOTICE, tone: 'error' }, inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + + it('counts an exit row by its exit fact, whatever wrote it', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + item('other-writer', exitWords(), inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it("still explains a cut whose only error row is the agent's own, not the stop's", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow( + 'provider-frame:permission_denied', + { kind: 'status', text: 'Permission denied: Bash', tone: 'error' }, + inTurn('t1') + ) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + it('says nothing of a Stop, a replaced turn, a failure, an unproven end, a finished or a running turn', () => { + for (const ending of [ + { state: 'interrupted', outcome: 'cancellation' }, + { state: 'interrupted', outcome: 'superseded' }, + { state: 'interrupted', outcome: 'failure' }, + { state: 'completed', outcome: 'failure' }, + { state: 'unverifiable' }, + { state: 'completed', outcome: 'success' }, + { state: 'completed' }, + { state: 'running' } + ] satisfies Ending[]) { + const items = [user('u1'), turn('t1', 'u1', ending), reply('a1', inTurn('t1'))] + expect(withNativeChatCutTurnNotices(items), JSON.stringify(ending)).toBe(items) + } + }) + + it("says nothing of a subagent's cut turn: the conversation's own turn speaks for it", () => { + const items = [ + user('u1'), + turn('t1', 'u1', { state: 'completed', outcome: 'success' }), + { ...turn('sub', 'u1', CUT, inTurn('t1')), agentId: 'sub-agent' } + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it('explains every cut turn in the history no row explains, each once', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', CUT), + reply('a2', inTurn('t2')), + exitRow('provider-exit:s:4:gen', inTurn('t2')), + user('u3'), + turn('t3', 'u3', CUT) + ] + expect(notices(items)).toEqual([ + { index: 3, text: NOTICE, scope: inTurn('t1') }, + // A turn with no rows of its own takes the notice after its record. + { index: 10, text: NOTICE, scope: inTurn('t3') } + ]) + }) + + // A quit, then a resume that did not carry the chat on: the note says the cut was not continued, + // which is what the notice would say, so it stands alone. + it.each([ + [AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, 'error'], + [AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error'], + [AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning'] + ] as const)( + 'takes the restart note "%s" as the one explanation of the cut it follows', + (text, tone) => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('continuation'), + restartNote(text, tone) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + } + ) + + // The note of a continuation that went on lands after its own message and turn. The cut keeps its + // notice before and after it, so nothing flashes away under an automatic resume. + it('keeps the notice once a continuation carries the chat on', () => { + const cut = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1'))] + const continuing = [...cut, user('u2'), turn('t2', 'u2', { state: 'running' })] + const continued = [...continuing, restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + + for (const items of [cut, continuing, continued]) { + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + } + // Its note carries no tone, so even with no turn recorded before it, it does not stand in. + const noteOnly = [...cut, user('u2'), restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + expect(notices(noteOnly)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // An unconfirmed continuation whose turn opened before its warning: the warning is about that + // turn, so the cut keeps its own notice beside it. + it('keeps the notice when a restart note follows a turn the continuation opened', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'running' }), + restartNote(AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning') + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // A send after the cut, whose new agent died before its turn opened: that exit is about the send. + it('lets no exit row about the conversation explain a cut a message was sent after', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('provider-exit:s:6:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // A row about the conversation speaks for the cut it follows, never across a turn that finished. + it('lets a later exit row about the conversation explain no cut a finished turn came after', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }), + exitRow('provider-exit:s:5:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // Read before the startup reconcile, the cut settled unverifiable; the user sent u2; the reconcile + // then proved the old owner dead and wrote its row about the conversation after u2. That row is + // about the cut, not the send, so it stays the one explanation. + it("takes an owner's proven death as the explanation of the cut though a message was sent since", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('stale-session:s:death-3-2000', THREAD) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it("does not take a failed start's row as the explanation of an earlier cut", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + item( + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen-2')), + exitWords(), + THREAD + ) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + it('places the notice in its turn, for desktop and phone alike', () => { + const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), user('u2')] + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const messages = projectStructuredAgentSessionMessages(derived, [], [], { + rejectedInPlace: true + }) + const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions: [] }) + + const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) + expect(messages[notice]?.blocks).toEqual([ + expect.objectContaining({ type: 'text', text: NOTICE, tone: 'error' }) + ]) + expect(turnKeys[notice]).toBe('u1') + }) + + // A message Orca accepted and then failed to deliver after the cut is drawn in place as not sent, + // in no turn; the cut keeps its notice in its own turn, and the exit row about that send does + // not stand in for it. + it('keeps the notice in its turn beside a later message drawn as not sent', () => { + const sent = agentJournalSubmissionKey('client-u2') + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user(sent), + exitRow('provider-exit:s:7:gen', THREAD) + ] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'client-u2', + fence: 2, + payloadFingerprint: 'fp-u2', + dispatchState: 'rejected', + providerItemId: null, + reason: NOTICE, + submittedAt: 50, + resolvedAt: 60 + } + ] + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const messages = projectStructuredAgentSessionMessages(derived, [], submissions, { + rejectedInPlace: true + }) + const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions }) + + const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) + expect(turnKeys[notice]).toBe('u1') + expect(messages.find((message) => message.id === sent)).toMatchObject({ unsent: true }) + }) + + it('reads a journal from a host that states no scope, without making it look like one that does', () => { + const items = [ + user('u1', null), + turn('t1', 'u1', CUT, null), + reply('a1', null), + user('u2', null) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: undefined }]) + expect(hostStatesTurnScopes(derived)).toBe(false) + }) + + it('reads the older status-row turn record, and keeps each notice the same object across reads', () => { + const record = item( + 't1', + { + kind: 'status', + text: '', + turnLifecycle: { + turnId: 't1', + userItemId: 'u1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + } + }, + null + ) + const items = [user('u1', null), record] + + const first = withNativeChatCutTurnNotices(items) + const second = withNativeChatCutTurnNotices([...items]) + + expect(first).toHaveLength(3) + expect(second[2]).toBe(first[2]) + }) +}) diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts new file mode 100644 index 00000000000..ddf48636308 --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -0,0 +1,212 @@ +// The one row that says a turn was cut short when the agent stopped without anyone asking (a crash, +// a quit, an eviction, a teardown). Derived on read from the journal, never stored: the turn record +// is the durable fact, so a journal written before this rule, a quit that died mid-settle and any +// future stop cause all read the same. A row the host already wrote about the stop stays the +// explanation, so nothing is said twice. Shared by desktop and mobile, whose transcripts must agree. + +import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' +import { agentJournalItemKey, parseAgentJournalItemKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' +import { agentTurnVerdict } from './agent-turn-outcome' +import { + PROVIDER_EXIT_ROW_PREFIX, + RESTART_CONTINUATION_ROW_PREFIX, + STALE_SESSION_ROW_PREFIX +} from './agent-session-stop-row-identity' +import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' +import { hostStatesTurnScopes } from './native-chat-turn-membership' + +const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' + +type CutTurnNoticeContext = Pick + +function rootTurnVerdict( + item: AgentJournalRenderItem +): ReturnType | 'none' { + const turn = readAgentJournalTurn(item.body) + return turn && isRootAgentJournalItem(item) + ? agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) + : 'none' +} + +/** Root turns that ended interrupted with no verdict, so nobody asked for the stop. */ +function isCutRootTurn(item: AgentJournalRenderItem): boolean { + return rootTurnVerdict(item) === 'interruption' +} + +/** What a row says about a stop, matched by what it states or who wrote it, never its tone alone: + * an agent's own error row in the turn (a denied permission, a refusal) says nothing about the stop. + * `exit`: the agent stopped. `owner-death`: a reopen proved the old agent dead, which is about the + * cut whatever was sent since. `not-continued`: a resume after a restart did not carry the chat on, + * which the restart note marks with a tone ('error' refused or not connected, 'warning' unconfirmed); + * a continuation that went on writes its note with none. A failed start's row is about a start. */ +function stopExplanation( + item: AgentJournalRenderItem +): 'exit' | 'owner-death' | 'not-continued' | null { + if ( + item.body.kind !== 'status' || + readAgentJournalTurn(item.body) || + isStructuredAgentSessionStartFailureRow(item.itemId) + ) { + return null + } + const identity = parseAgentJournalItemKey(item.itemId) + const clientMessageId = identity?.provider === 'orca' ? identity.clientMessageId : '' + if (clientMessageId.startsWith(STALE_SESSION_ROW_PREFIX)) { + return 'owner-death' + } + if ( + readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited' || + clientMessageId.startsWith(PROVIDER_EXIT_ROW_PREFIX) + ) { + return 'exit' + } + const { tone } = item.body + return clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && + (tone === 'error' || tone === 'warning') + ? 'not-continued' + : null +} + +/** + * The cut turns some row already explains: one scoped to the turn, or one about the conversation + * (or from a host that states no scope) that follows the cut turn closely enough to be about it. + * An exit row is about the cut only with no message sent since, which a later start would be + * answering. An owner's proven death, and a restart note that follows the continuation's own + * message, are about the cut until another turn begins. + */ +function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { + const explained = new Set() + let cutNoSendSince: string | null = null + let cutNoTurnSince: string | null = null + for (const item of items) { + const verdict = rootTurnVerdict(item) + if (verdict !== 'none') { + cutNoSendSince = cutNoTurnSince = verdict === 'interruption' ? item.itemId : null + continue + } + if (item.body.kind === 'message' && item.body.role === 'user' && isRootAgentJournalItem(item)) { + cutNoSendSince = null + continue + } + const explanation = stopExplanation(item) + if (explanation === null) { + continue + } + const scope = item.turnScope + const preceding = explanation === 'exit' ? cutNoSendSince : cutNoTurnSince + if (scope?.kind === 'turn') { + explained.add(scope.turnItemId) + } else if (preceding !== null) { + explained.add(preceding) + } + } + return explained +} + +/** The turn's last row, which the notice follows: one scoped to it, or by journal order on a host + * that states no scope. A new root turn ends the order-read run. */ +function lastRowOfTurn( + items: readonly AgentJournalRenderItem[], + turnIndex: number, + statesScopes: boolean +): number { + const turnItemId = items[turnIndex]!.itemId + let last = turnIndex + for (let index = turnIndex + 1; index < items.length; index += 1) { + const item = items[index]! + if (statesScopes) { + if (item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItemId) { + last = index + } + continue + } + if (readAgentJournalTurn(item.body) && isRootAgentJournalItem(item)) { + break + } + if (!(item.body.kind === 'message' && item.body.role === 'user')) { + last = index + } + } + return last +} + +const noticeCache = new WeakMap< + AgentJournalRenderItem, + { + after: AgentJournalRenderItem + statesScopes: boolean + agentName: string | undefined + notice: AgentJournalRenderItem + } +>() + +function cutTurnNotice( + turnItem: AgentJournalRenderItem, + after: AgentJournalRenderItem, + statesScopes: boolean, + context: CutTurnNoticeContext +): AgentJournalRenderItem { + const cached = noticeCache.get(turnItem) + if ( + cached?.after === after && + cached.statesScopes === statesScopes && + cached.agentName === context.agentName + ) { + return cached.notice + } + const notice: AgentJournalRenderItem = { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${CUT_TURN_NOTICE_ROW}${turnItem.itemId}` + }), + revision: 0, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error' + }, + // Just after the turn's last row and before anything the journal wrote next. + sequence: after.sequence, + sequenceIndex: (after.sequenceIndex ?? 0) + 0.5, + observedAt: readAgentJournalTurn(turnItem.body)?.completedAt ?? after.observedAt, + // A scope on a journal that states none would change how every other row is placed. + ...(statesScopes ? { turnScope: { kind: 'turn' as const, turnItemId: turnItem.itemId } } : {}) + } + noticeCache.set(turnItem, { after, statesScopes, agentName: context.agentName, notice }) + return notice +} + +/** + * The journal as the transcript reads it: each root turn cut short with nobody asking, and no row + * saying so, gets one notice in the words of the provider-exit row, right after the turn's last row. + * Returns `items` itself when no turn needs one. + */ +export function withNativeChatCutTurnNotices( + items: readonly AgentJournalRenderItem[], + context: CutTurnNoticeContext = {} +): readonly AgentJournalRenderItem[] { + const explained = explainedCutTurns(items) + const statesScopes = hostStatesTurnScopes(items) + const noticesAfter = new Map() + items.forEach((item, index) => { + if (isCutRootTurn(item) && !explained.has(item.itemId)) { + const last = lastRowOfTurn(items, index, statesScopes) + const notice = cutTurnNotice(item, items[last]!, statesScopes, context) + noticesAfter.set(last, [...(noticesAfter.get(last) ?? []), notice]) + } + }) + if (noticesAfter.size === 0) { + return items + } + return items.flatMap((item, index) => [item, ...(noticesAfter.get(index) ?? [])]) +} diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3b70e57bdaf..08557aef9a0 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -42,9 +42,9 @@ describe('describeNativeChatTurnStatus', () => { it.each([ ['cancellation', 'interruptedAfter'], ['superseded', 'interruptedAfter'], - // A turn anything but the user cut short reads as a failure does. - ['interruption', 'failedAfter'], ['failure', 'failedAfter'], + // A crash or restart cut it off: the turn reads finished and the chat's notice row says why. + ['interruption', 'workedFor'], ['success', 'workedFor'], ['unconfirmed', 'workedFor'], [undefined, 'workedFor'] @@ -108,6 +108,24 @@ describe('formatNativeChatTurnStatusLabel', () => { 'Worked for 3m 4s' ) }) + + // The phone's turn bar renders through this function, so it reads the same as desktop. + it('reads a crash-cut turn as worked and a Stop as interrupted in English', () => { + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'interruption' + }) + ).toBe('Worked for 3m 42s') + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'cancellation' + }) + ).toBe('Interrupted after 3m 42s') + }) }) describe('reduceNativeChatTurnTiming', () => { diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index f1dbea00947..446c489c350 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -42,7 +42,8 @@ export function describeNativeChatTurnStatus({ workedSeconds?: number | null elapsedSeconds: number /** How the settled turn ended: a user's Stop or a newer request's replacement reads interrupted, - * and a failure or a turn anything else cut short reads failed. */ + * and a failure reads failed. A turn cut short when the agent stopped without anyone asking (a + * crash, a quit, an eviction) reads like a finished one: the chat's notice row says it stopped. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' @@ -61,9 +62,9 @@ function settledTurnStatusKey( case 'superseded': return 'interruptedAfter' case 'failure': - case 'interruption': return 'failedAfter' case 'success': + case 'interruption': case 'unconfirmed': case undefined: return 'workedFor' From 2b0ce175145ca6779dcc0dfaa1a8300658b0e6e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:07:23 -0700 Subject: [PATCH 2/9] Show provider credit balances alongside usage limits (#25408) Display provider credit and currency balances separately from rate limits. Preserve quotas when balance data is unavailable and translate complete labels. Supersedes #9363; credit to @mmarabel for the original implementation. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- ...roxy-guarded-fetch-call-site-audit.test.ts | 2 +- .../claude-fetcher-extra-usage.test.ts | 302 ++++++++++++++++++ .../rate-limits/claude-oauth-usage-request.ts | 8 + src/main/rate-limits/claude-usage-credits.ts | 127 ++++++++ src/main/rate-limits/claude-usage-result.ts | 4 +- src/main/rate-limits/codex-credit-balance.ts | 29 ++ .../codex-fetcher-credit-balance.test.ts | 137 ++++++++ .../codex-rate-limit-window-classification.ts | 6 + .../rate-limits/codex-rpc-rate-limit-probe.ts | 3 + .../opencode-go-status-parsing.test.ts | 72 +++++ .../rate-limits/opencode-go-status-parsing.ts | 57 +++- .../opencode-go-usage-fetcher.test.ts | 253 ++++++++++++++- .../rate-limits/opencode-go-usage-fetcher.ts | 95 +++++- ...opencode-go-usage-source-selection.test.ts | 3 + .../opencode-go-usage-source-selection.ts | 3 +- .../status-bar/StatusBarProviderSegment.tsx | 46 +++ .../provider-extra-usage-section.tsx | 158 +++++++++ .../provider-segment-balance.test.tsx | 212 ++++++++++++ .../src/components/status-bar/tooltip.test.ts | 276 +++++++++++++++- .../src/components/status-bar/tooltip.tsx | 35 +- src/renderer/src/i18n/locales/en.json | 21 ++ src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../provider-credit-balance-locales.test.ts | 95 ++++++ src/shared/credit-count-format.test.ts | 14 + src/shared/credit-count-format.ts | 6 + src/shared/currency-format.test.ts | 30 ++ src/shared/currency-format.ts | 21 ++ src/shared/rate-limit-types.ts | 28 ++ 32 files changed, 2013 insertions(+), 35 deletions(-) create mode 100644 src/main/rate-limits/claude-fetcher-extra-usage.test.ts create mode 100644 src/main/rate-limits/claude-usage-credits.ts create mode 100644 src/main/rate-limits/codex-credit-balance.ts create mode 100644 src/main/rate-limits/codex-fetcher-credit-balance.test.ts create mode 100644 src/renderer/src/components/status-bar/provider-extra-usage-section.tsx create mode 100644 src/renderer/src/components/status-bar/provider-segment-balance.test.tsx create mode 100644 src/renderer/src/i18n/provider-credit-balance-locales.test.ts create mode 100644 src/shared/credit-count-format.test.ts create mode 100644 src/shared/credit-count-format.ts create mode 100644 src/shared/currency-format.test.ts create mode 100644 src/shared/currency-format.ts diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index b92b266f427..53d22bad37f 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' // means a call site was added, removed, or moved: re-audit the file and update the count. const AUDITED_NON_NET_FETCH_CALLS = new Map([ // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. - ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + ['main/rate-limits/opencode-go-usage-fetcher.ts', 3], // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. ['main/rate-limits/minimax/minimax-request-context.ts', 2], diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts new file mode 100644 index 00000000000..fb56512bd8c --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +function oauthPrep(): ClaudeRuntimeAuthPreparation { + return { + configDir: '/Users/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } +} + +async function fetchUsageResponse(body: string) { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) + return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) +} + +describe('fetchClaudeRateLimits extra usage', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('maps the usage-credits spend object into a capped balance in major units', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 5000, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 2.5, + enabled: true, + balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 } + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: 10, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + }) + + it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 0, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 0, + enabled: false, + disabled_reason: 'out_of_credits', + balance: null + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 0, + spendLimit: 2000, + spentPercent: 0, + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits' + } + }) + }) + + it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + is_enabled: true, + monthly_limit: 200000, + used_credits: 5000, + utilization: 2.5, + currency: 'EUR', + decimal_places: 2 + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true + } + }) + }) + it('keeps a missing balance unknown while preserving a known cap', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spendLimit: 2000, + currencyCode: 'EUR' + }) + }) + + it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true } + }) + ) + expect(result.extraUsage).toMatchObject({ + balance: amount / 100, + currencyCode: 'EUR', + spendLimit: null + }) + }) + + it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('keeps nullable legacy spend unknown despite a supplied percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: 10 + }) + }) + + it('derives legacy percentage when the response percentage overflows', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('does not turn an overflowing legacy spend into a known amount', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: null + }) + }) + + it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => { + const result = await fetchUsageResponse( + `{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}` + ) + expect(result.extraUsage ?? null).toBeNull() + }) + + it.each([-1, 0.5, 309])( + 'omits malformed legacy precision %s rather than displaying zero', + async (exponent) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + monthly_limit: 200000, + used_credits: 5000, + decimal_places: exponent, + currency: 'EUR', + is_enabled: true + } + }) + ) + expect(result.extraUsage ?? null).toBeNull() + } + ) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..b3bca9298aa 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -4,6 +4,11 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { createOAuthUsageError } from './claude-oauth-usage-error' import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { abortedClaudeRateLimitResult } from './claude-usage-result' +import { + mapClaudeExtraUsage, + type ClaudeOAuthExtraUsage, + type ClaudeOAuthSpend +} from './claude-usage-credits' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const API_TIMEOUT_MS = 10_000 @@ -22,6 +27,8 @@ type OAuthUsageResponse = { fable_weekly?: ClaudeUsageWindowInput fable_seven_day?: ClaudeUsageWindowInput seven_day_fable?: ClaudeUsageWindowInput + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage limits?: OAuthUsageLimit[] | null } @@ -90,6 +97,7 @@ export async function fetchClaudeOAuthUsage( session: mapClaudeUsageWindow(data.five_hour, 300), weekly: mapClaudeUsageWindow(data.seven_day, 10080), fableWeekly: mapFableWeeklyWindow(data), + extraUsage: mapClaudeExtraUsage(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-usage-credits.ts b/src/main/rate-limits/claude-usage-credits.ts new file mode 100644 index 00000000000..9f01fd63064 --- /dev/null +++ b/src/main/rate-limits/claude-usage-credits.ts @@ -0,0 +1,127 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' + +// Money is reported as minor units plus an exponent (e.g. 200000 @ exp 2 = €2000). +type OAuthMoney = { + amount_minor?: number + currency?: string + exponent?: number +} + +// Optional richer spend fields are retained for response compatibility. +export type ClaudeOAuthSpend = { + used?: OAuthMoney + limit?: OAuthMoney + percent?: number + enabled?: boolean + disabled_reason?: string | null + cap?: { money?: OAuthMoney | null } | null + balance?: OAuthMoney | number | null +} + +// Legacy shape kept as a fallback for older responses that only send `extra_usage`. +export type ClaudeOAuthExtraUsage = { + is_enabled?: boolean + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + currency?: string + decimal_places?: number + disabled_reason?: string | null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function moneyToMajor(money: OAuthMoney | number | null | undefined): number | null { + if (typeof money === 'number') { + return Number.isFinite(money) ? money : null + } + if (!money || typeof money.amount_minor !== 'number' || !Number.isFinite(money.amount_minor)) { + return null + } + const exponent = typeof money.exponent === 'number' ? money.exponent : 2 + if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) { + return null + } + return money.amount_minor / 10 ** exponent +} + +// Prefer the richer `spend` object; fall back to the legacy `extra_usage` shape. +export function mapClaudeExtraUsage(data: { + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage +}): ExtraUsageBalance | null { + return mapSpend(data.spend) ?? mapLegacyExtraUsage(data.extra_usage) +} + +function mapSpend(spend: ClaudeOAuthSpend | undefined): ExtraUsageBalance | null { + if (!spend) { + return null + } + const spent = moneyToMajor(spend.used) + const spendLimit = moneyToMajor(spend.limit) ?? moneyToMajor(spend.cap?.money) + const balance = moneyToMajor(spend.balance) + // Nothing worth showing when the account has neither a configured cap nor a balance. + if (spendLimit === null && balance === null) { + return null + } + const spentPercent = + typeof spend.percent === 'number' && Number.isFinite(spend.percent) + ? clampPercent(spend.percent) + : spendLimit !== null && spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance, + unit: 'currency', + currencyCode: + spend.used?.currency ?? + spend.limit?.currency ?? + spend.cap?.money?.currency ?? + (typeof spend.balance === 'object' && spend.balance !== null + ? spend.balance.currency + : undefined) ?? + 'USD', + enabled: spend.enabled === true, + disabledReason: spend.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} + +function mapLegacyExtraUsage(extra: ClaudeOAuthExtraUsage | undefined): ExtraUsageBalance | null { + if (!extra) { + return null + } + const spendLimit = moneyToMajor({ + amount_minor: extra.monthly_limit ?? undefined, + exponent: extra.decimal_places + }) + if (spendLimit === null) { + return null + } + const spent = moneyToMajor({ + amount_minor: extra.used_credits ?? undefined, + exponent: extra.decimal_places + }) + const spentPercent = + typeof extra.utilization === 'number' && Number.isFinite(extra.utilization) + ? clampPercent(extra.utilization) + : spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance: null, + unit: 'currency', + currencyCode: extra.currency?.trim() || 'USD', + enabled: extra.is_enabled === true, + disabledReason: extra.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index aea522e673b..a29cb38d4aa 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -112,7 +112,9 @@ export function mergeClaudeUsageWindows( ...primary, session: primary.session ?? supplement.session, weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, + // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. + extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null } } diff --git a/src/main/rate-limits/codex-credit-balance.ts b/src/main/rate-limits/codex-credit-balance.ts new file mode 100644 index 00000000000..ece52f2df64 --- /dev/null +++ b/src/main/rate-limits/codex-credit-balance.ts @@ -0,0 +1,29 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' +import type { CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' + +// Codex's pay-as-you-go credit balance, reported alongside the rate windows. +// `balance` is a plain credit count (string), not a currency amount. +type RpcCredits = NonNullable + +// Codex credits are a unitless count, not currency. Only surface the balance +// when the account actually has credits (or unlimited) so accounts that never +// bought any don't get an empty "0 credits" row. +export function mapCodexCredits(raw: RpcCredits | null | undefined): ExtraUsageBalance | null { + if (!raw || (raw.hasCredits !== true && raw.unlimited !== true)) { + return null + } + const parsed = + typeof raw.balance === 'string' && raw.balance.trim() !== '' ? Number(raw.balance) : raw.balance + if ((typeof parsed !== 'number' || !Number.isFinite(parsed)) && raw.unlimited !== true) { + return null + } + const balance = typeof parsed === 'number' && Number.isFinite(parsed) ? Math.max(0, parsed) : 0 + return { + balance, + unit: 'credits', + unlimited: raw.unlimited === true, + enabled: raw.hasCredits === true || raw.unlimited === true, + disabledReason: null, + resetsAt: null + } +} diff --git a/src/main/rate-limits/codex-fetcher-credit-balance.test.ts b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts new file mode 100644 index 00000000000..780687c9a09 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) +vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function parseRpcRequest(line: string): { id?: number; method?: string } { + const parsed: unknown = JSON.parse(line) + if (typeof parsed !== 'object' || parsed === null) { + return {} + } + return { + id: 'id' in parsed && typeof parsed.id === 'number' ? parsed.id : undefined, + method: 'method' in parsed && typeof parsed.method === 'string' ? parsed.method : undefined + } +} + +// Like the real app-server, the fake dies on stdin EOF or a signal. +class FakeRpcChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + exitCode: number | null = null + rateLimits: unknown = null + readonly kill = vi.fn(() => { + this.exitNow() + return true + }) + readonly stdin = Object.assign(new EventEmitter(), { + end: vi.fn(() => this.exitNow()), + write: vi.fn((line: string) => { + const request = parseRpcRequest(line) + if (request.method === 'initialize') { + this.reply(request.id, {}) + } + if (request.method === 'account/rateLimits/read') { + this.reply(request.id, { rateLimits: this.rateLimits }) + } + }) + }) + + private reply(id: number | undefined, result: unknown): void { + setTimeout(() => { + this.stdout.emit('data', Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`)) + }, 0) + } + + private exitNow(): void { + this.exitCode = 0 + this.emit('exit', 0, null) + } +} + +async function fetchWithRateLimits(rateLimits: unknown) { + const child = new FakeRpcChild() + child.rateLimits = rateLimits + childSpawnMock.mockReturnValue(child) + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + return resultPromise +} + +describe('fetchCodexRateLimits credit balance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('maps the RPC credits object into a Codex credit-count balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '500' } + }) + + expect(result.extraUsage).toMatchObject({ + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true + }) + }) + + it('omits the Codex balance when the account has no credits', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + }) + + expect(result.extraUsage ?? null).toBeNull() + }) + it.each([undefined, null, '', ' ', 'invalid', 'Infinity', '1e999'])( + 'omits unavailable credit balance %s despite hasCredits', + async (balance) => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance } + }) + expect(result.extraUsage ?? null).toBeNull() + } + ) + + it('preserves an explicitly reported zero credit balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance: '0' } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: false, balance: 0 }) + }) + + it('keeps the unlimited indication without requiring a numeric balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: true, balance: null } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: true, enabled: true }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-window-classification.ts b/src/main/rate-limits/codex-rate-limit-window-classification.ts index cdf667b6306..a19363bed8a 100644 --- a/src/main/rate-limits/codex-rate-limit-window-classification.ts +++ b/src/main/rate-limits/codex-rate-limit-window-classification.ts @@ -13,6 +13,12 @@ export type CodexRateWindowSnapshot = { export type CodexRateLimitWindowsSnapshot = { primary?: CodexRateWindowSnapshot | null secondary?: CodexRateWindowSnapshot | null + /** Pay-as-you-go credit balance, when the app-server reports one. */ + credits?: { + hasCredits?: boolean + unlimited?: boolean + balance?: string | number + } | null } type MappableCodexRateWindowSnapshot = CodexRateWindowSnapshot & { usedPercent: number } diff --git a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts index ededfa9c349..0c59403c35f 100644 --- a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts @@ -12,6 +12,7 @@ import { CODEX_WEEKLY_WINDOW_MINUTES, type CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' +import { mapCodexCredits } from './codex-credit-balance' import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' import { mapCodexRateLimitWindow } from './codex-rate-limit-window-mapper' @@ -244,12 +245,14 @@ export function readCodexRateLimitsViaRpc( const wrapper = message.result as RpcRateLimitsResponse | undefined const classified = classifyCodexRateLimitWindows(wrapper?.rateLimits) const credits = mapRpcRateLimitResetCredits(wrapper?.rateLimitResetCredits) + const extraUsage = mapCodexCredits(wrapper?.rateLimits?.credits) settle( { provider: 'codex', session: mapCodexRateLimitWindow(classified.session, CODEX_SESSION_WINDOW_MINUTES), weekly: mapCodexRateLimitWindow(classified.weekly, CODEX_WEEKLY_WINDOW_MINUTES), ...(credits !== undefined ? { rateLimitResetCredits: credits } : {}), + ...(extraUsage ? { extraUsage } : {}), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts index 1116dcad71b..b77f12f80c8 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.test.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + isOpenCodeGoExplicitNoAccessPayload, + parseOpenCodeGoBillingStatusPayload, parseOpenCodeGoStatusPayload, parseOpenCodeGoUsageApiPayload } from './opencode-go-status-parsing' @@ -120,6 +122,76 @@ describe('parseOpenCodeGoStatusPayload', () => { }) }) +describe('parseOpenCodeGoBillingStatusPayload', () => { + it('maps the verified PAYG micro-cent string into USD major units', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + ).toBe(27.86781005) + }) + + it('preserves zero and negative balances', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + ).toBe(0) + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + ).toBe(-1.25) + }) + + it.each([ + { billingMode: 'credit', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'seat', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'invoiceable', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: 100000000 }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: '1.5' }, + { + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '9007199254740992' + } + ])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => { + expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull() + }) + + it('fails closed for malformed and non-object bodies', () => { + expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull() + }) +}) + +describe('isOpenCodeGoExplicitNoAccessPayload', () => { + it('accepts only explicit JSON null or access:null without an error verdict', () => { + expect(isOpenCodeGoExplicitNoAccessPayload('null')).toBe(true) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":null}')).toBe(true) + expect( + isOpenCodeGoExplicitNoAccessPayload('{"access":null,"error":{"type":"AuthError"}}') + ).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":{}}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{not json')).toBe(false) + }) +}) + describe('parseOpenCodeGoUsageApiPayload', () => { it('clamps an out-of-range percent and tolerates a missing resetsAt', () => { const parsed = parseOpenCodeGoUsageApiPayload( diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts index cc98fd477ed..c4635792fc9 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -1,4 +1,4 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, RateLimitWindow } from '../../shared/rate-limit-types' const SESSION_WINDOW_MINUTES = 300 const WEEKLY_WINDOW_MINUTES = 10_080 @@ -100,6 +100,61 @@ export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWin } } +// Console BillingStatus uses integer-string LedgerMicroCents (100,000,000 per USD). +export function parseOpenCodeGoBillingStatusPayload(text: string): number | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + if ( + !isRecord(payload) || + payload.billingMode !== 'prepaid' || + payload.mode !== 'pay-as-you-go' || + typeof payload.balanceMicroCents !== 'string' || + !/^-?\d+$/.test(payload.balanceMicroCents) + ) { + return null + } + const microCents = Number(payload.balanceMicroCents) + return Number.isSafeInteger(microCents) ? microCents / 100_000_000 : null +} + +export function makeOpenCodeGoZenBalance( + balance: number | null, + disabledReason: string | null = null +): ExtraUsageBalance { + return { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: balance !== null, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } +} + +export function isOpenCodeGoExplicitNoAccessPayload(text: string): boolean { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return false + } + try { + const payload: unknown = JSON.parse(text) + return ( + payload === null || (isRecord(payload) && payload.access === null && !('error' in payload)) + ) + } catch { + return false + } +} + export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { return null diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 9e07f83f472..0c65c2fb788 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -15,6 +15,7 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status' +const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status' const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/ function makeResponse(body: string, status = 200): Response { @@ -259,8 +260,10 @@ describe('fetchOpenCodeGoRateLimits', () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -343,6 +346,254 @@ describe('fetchOpenCodeGoRateLimits', () => { }) }) + it.each(['discovery', 'override'])('accepts an org_ workspace from %s', async (source) => { + netFetchMock.mockImplementation(async (url) => { + if (String(url).startsWith('https://opencode.ai/_server')) { + return makeResponse('id: "org_CONSOLE123"') + } + return url === CONSOLE_STATUS_URL + ? makeJsonResponse(STATUS_WITH_MONTHLY) + : makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + }) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=placeholder', + source === 'override' ? 'org_CONSOLE123' : undefined + ) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage?.balance).toBe(27.86781005) + expect(netFetchMock).toHaveBeenCalledWith( + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ 'x-org-id': 'org_CONSOLE123' }) + }) + ) + }) + + it('enriches the selected cookie workspace with its verified PAYG Zen balance', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_OVERRIDE123', + Accept: 'application/json' + }) + }) + ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') + expect(result.extraUsage).toEqual({ + balance: 27.86781005, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }) + }) + + it.each([ + [ + 'unsupported mode', + makeJsonResponse({ billingMode: 'seat', mode: 'pay-as-you-go' }), + 'billing-unavailable' + ], + ['billing failure', makeResponse('unavailable', 503), 'refresh-failed'] + ])( + 'keeps valid quota when optional billing has an %s', + async (_label, billingResponse, disabledReason) => { + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(billingResponse) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason }) + ) + } + ) + + it('returns a verified balance-only snapshot for explicit access:null', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse({ access: null })).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result).toEqual( + expect.objectContaining({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + error: null, + status: 'ok', + extraUsage: expect.objectContaining({ balance: 0, currencyCode: 'USD' }) + }) + ) + }) + + it('returns a verified balance-only snapshot for an explicit JSON null status', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(null)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session).toBeNull() + expect(result.extraUsage).toEqual(expect.objectContaining({ balance: -1.25 })) + }) + + it('aborts billing before returning a generic malformed-usage error', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse({ access: {} })) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + setTimeout( + () => + resolve( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '100000000' + }) + ), + 100 + ) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(0) + const abortedBeforeBillingResponse = billingSignal?.aborted + await vi.advanceTimersByTimeAsync(100) + const result = await resultPromise + + expect(abortedBeforeBillingResponse).toBe(true) + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('prefers a later valid Go workspace over an earlier balance-only candidate', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse('id: "wrk_FIRST" id: "wrk_SECOND"')) + .mockResolvedValueOnce(makeJsonResponse({ access: null })) + .mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '900000000' + }) + ) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(10) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'refresh-failed' }) + ) + expect(netFetchMock).toHaveBeenNthCalledWith( + 4, + CONSOLE_STATUS_URL, + expect.objectContaining({ headers: expect.objectContaining({ 'x-org-id': 'wrk_SECOND' }) }) + ) + }) + + it('aborts and settles an optional billing timeout before clearing the cookie jar', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(5_000) + const result = await resultPromise + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(billingSignal?.aborted).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('aborts and settles billing before continuing after a non-ok usage response', async () => { + let billingSignal: AbortSignal | undefined + let billingSettled = false + netFetchMock + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener( + 'abort', + () => { + billingSettled = true + reject(new Error('aborted')) + }, + { once: true } + ) + }) + }) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('error') + expect(result.error).toContain('Usage fetch failed (401)') + expect(billingSignal?.aborted).toBe(true) + expect(billingSettled).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) @@ -400,7 +651,7 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') - expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledTimes(2) expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( CONSOLE_STATUS_URL, diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 451d2f316bc..d8ebf61d17d 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,17 +1,24 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' +import { + isOpenCodeGoExplicitNoAccessPayload, + makeOpenCodeGoZenBalance, + parseOpenCodeGoBillingStatusPayload, + parseOpenCodeGoStatusPayload +} from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` +const OPENCODE_BILLING_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/billing/status` const API_TIMEOUT_MS = 15_000 +const OPTIONAL_BILLING_TIMEOUT_MS = 5_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. @@ -59,12 +66,9 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { } function parseWorkspaceIds(text: string): string[] { - // Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output. - // Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a - // more specific regex with word boundaries avoids picking up unrelated - // object properties that might match a generic ID pattern. + // Console uses org_/wrk_; wk_ remains accepted for older workspace responses. const ids: string[] = [] - const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g + const workspaceIdRegex = /\bid\s*:\s*["']((?:org|wrk|wk)_[a-zA-Z0-9]+)["']/g for (const match of text.matchAll(workspaceIdRegex)) { const id = match[1] if (id && !ids.includes(id)) { @@ -126,6 +130,35 @@ export async function fetchOpenCodeGoRateLimits( } } +function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string) { + const abortController = new AbortController() + const promise = (async (): Promise => { + const timeout = setTimeout(() => abortController.abort(), OPTIONAL_BILLING_TIMEOUT_MS) + try { + const response = await openCodeSession.fetch(OPENCODE_BILLING_STATUS_URL, { + method: 'GET', + headers: { + Accept: 'application/json', + Origin: OPENCODE_BASE_URL, + Referer: `${OPENCODE_BASE_URL}/console/${workspaceId}/billing`, + 'x-org-id': workspaceId + }, + signal: abortController.signal + }) + if (!response.ok) { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } + const balance = parseOpenCodeGoBillingStatusPayload(await response.text()) + return makeOpenCodeGoZenBalance(balance, balance === null ? 'billing-unavailable' : null) + } catch { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } finally { + clearTimeout(timeout) + } + })() + return { promise, abort: () => abortController.abort() } +} + function makeOpenCodeError(error: unknown): ProviderRateLimits { return { provider: 'opencode-go', @@ -147,14 +180,14 @@ async function fetchOpenCodeGoRateLimitsWithSession( const override = workspaceIdOverride?.trim() if (override) { - if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) { + if (!/^(org|wrk|wk)_[A-Za-z0-9]+$/.test(override)) { return { provider: 'opencode-go', session: null, weekly: null, monthly: null, updatedAt: Date.now(), - error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$', + error: 'Invalid workspace ID format: must match ^(org|wrk|wk)_[A-Za-z0-9]+$', status: 'error' } } @@ -221,9 +254,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( // Why: /workspace//go now 302s to console login. Usage is JSON at // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' + let balanceOnlyResult: ProviderRateLimits | null = null for (const candidateId of ids) { + let billingTask: ReturnType | null = null try { - const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { + const statusPromise = openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { Accept: 'application/json', @@ -233,8 +268,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - + billingTask = startOptionalBillingFetch(openCodeSession, candidateId) + const statusRes = await statusPromise if (!statusRes.ok) { + billingTask.abort() + await billingTask.promise lastError = statusRes.status === 401 ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' @@ -242,25 +280,56 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) + const statusText = await statusRes.text() + const parsed = parseOpenCodeGoStatusPayload(statusText) + const explicitNoAccess = isOpenCodeGoExplicitNoAccessPayload(statusText) + if (!parsed && !explicitNoAccess) { + billingTask.abort() + await billingTask.promise + lastError = 'Could not parse usage data' + continue + } + + const balance = await billingTask.promise if (parsed) { return { provider: 'opencode-go', session: parsed.session, weekly: parsed.weekly, monthly: parsed.monthly, + extraUsage: balance, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data' + if (balance.balance !== null) { + balanceOnlyResult ??= { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + extraUsage: balance, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } else { + lastError = 'Could not parse usage data' + } } catch (err) { + if (billingTask) { + billingTask.abort() + await billingTask.promise + } const message = err instanceof Error ? err.message : 'Unknown error' lastError = message } } + if (balanceOnlyResult) { + return balanceOnlyResult + } return { provider: 'opencode-go', session: null, diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index 64b7ac622ca..44cc775afa4 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -66,6 +66,9 @@ describe('fetchOpenCodeGoUsage', () => { expect(result.status).toBe('ok') expect(result.session).toEqual(WINDOWS.session) expect(result.usageMetadata?.credentialSource).toBe('environment') + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'api-key-source' }) + ) }) it('passes the settings override down as the highest-precedence tier', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index c38fc1dc844..5973d8c1e91 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -4,7 +4,7 @@ import { resolveOpenCodeGoApiKey, type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' -import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import { makeOpenCodeGoZenBalance, type OpenCodeGoUsageWindows } from './opencode-go-status-parsing' import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, @@ -52,6 +52,7 @@ function usageResult( session: windows.session, weekly: windows.weekly, monthly: windows.monthly, + extraUsage: makeOpenCodeGoZenBalance(null, 'api-key-source'), updatedAt: Date.now(), error: null, status: 'ok', diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index a0be19112c7..f9a484ede41 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,6 +6,8 @@ import { type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { formatCurrencyAmount } from '../../../../shared/currency-format' +import { formatCreditCount } from '../../../../shared/credit-count-format' import { ProviderIcon, USAGE_URGENT_PERCENT, @@ -262,6 +264,43 @@ function VerboseProviderUsage({ ) } +// A plan spends into its overage balance only once an included window is +// exhausted. Treat ~100% as capped to tolerate provider rounding. +const CAP_THRESHOLD_PERCENT = 99.5 + +// Why: only reveal the compact balance once a capped window can spend it. +function isExtraUsageActive(p: ProviderRateLimits): boolean { + if ( + !p.extraUsage || + !p.extraUsage.enabled || + (p.extraUsage.unit === 'currency' && + (p.extraUsage.balance === null || p.extraUsage.balance <= 0)) + ) { + return false + } + return [p.session, p.weekly, p.monthly, p.fableWeekly].some( + (w) => w != null && clampUsedPercent(w.usedPercent) >= CAP_THRESHOLD_PERCENT + ) +} + +function formatCompactExtraUsage(balance: ProviderRateLimits['extraUsage']): string { + if (!balance) { + return '' + } + if (balance.unit === 'credits') { + return balance.unlimited + ? translate('auto.components.status.bar.StatusBar.4025a6f62f', 'Unlimited') + : translate('auto.components.status.bar.StatusBar.a95969101f', '{{value0}} credits', { + value0: formatCreditCount(balance.balance) + }) + } + return balance.balance === null + ? '' + : translate('auto.components.status.bar.StatusBar.4fba7dc1e7', '{{value0}} bal', { + value0: formatCurrencyAmount(balance.balance, balance.currencyCode) + }) +} + export function ProviderSegment({ p, compact, @@ -320,6 +359,7 @@ export function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' + const showBalance = isExtraUsageActive(p) return ( @@ -339,6 +379,12 @@ export function ProviderSegment({ showLabel={!compact} /> ) : null} + {showBalance && p.extraUsage ? ( + <> + · + {formatCompactExtraUsage(p.extraUsage)} + + ) : null} {isStale && } ) diff --git a/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx b/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx new file mode 100644 index 00000000000..3eabad4bb5e --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx @@ -0,0 +1,158 @@ +import type { ExtraUsageBalance, ProviderRateLimits } from '../../../../shared/rate-limit-types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' +import { formatCreditCount } from '../../../../shared/credit-count-format' +import { formatCurrencyAmount } from '../../../../shared/currency-format' +import { translate } from '@/i18n/i18n' +import { formatUsagePercentageLabel } from './usage-percentage-label' + +export const USAGE_WARNING_PERCENT = 60 +export const USAGE_URGENT_PERCENT = 80 + +export function barColor(usedPct: number): string { + if (usedPct < USAGE_WARNING_PERCENT) { + return 'bg-muted-foreground/40' + } + if (usedPct < USAGE_URGENT_PERCENT) { + return 'bg-yellow-500' + } + return 'bg-red-500' +} + +export function getExtraUsageLabel(provider: ProviderRateLimits['provider']): string { + if (provider === 'claude') { + return translate('auto.components.status.bar.tooltip.7404abbece', 'Usage credits') + } + if (provider === 'opencode-go') { + return translate('auto.components.status.bar.tooltip.fbc80d8be2', 'Zen balance') + } + if (provider === 'codex') { + return translate('auto.components.status.bar.tooltip.f21b2ba897', 'Credits') + } + return translate('auto.components.status.bar.tooltip.c03c61f53f', 'Balance') +} + +function renderUncappedBalanceLine(balance: ExtraUsageBalance): string { + if (balance.unit === 'credits') { + if (balance.unlimited) { + return translate('auto.components.status.bar.tooltip.56c0d70577', 'Unlimited') + } + return translate( + 'auto.components.status.bar.tooltip.87b5bda4d3', + '{{value0}} credits available', + { value0: formatCreditCount(balance.balance) } + ) + } + if (balance.balance === null) { + return '' + } + const currencyText = formatCurrencyAmount(balance.balance, balance.currencyCode) + return translate('auto.components.status.bar.tooltip.f6a27a3c0a', '{{value0}} available', { + value0: currencyText + }) +} + +export function ProviderExtraUsageSection({ + balance, + provider, + textClass, + mutedClass, + faintClass, + emptyBarClass, + usagePercentageDisplay +}: { + balance: ExtraUsageBalance + provider: ProviderRateLimits['provider'] + textClass: string + mutedClass: string + faintClass: string + emptyBarClass: string + usagePercentageDisplay: UsagePercentageDisplay +}): React.JSX.Element | null { + const label = getExtraUsageLabel(provider) + if (balance.unit === 'credits') { + return ( +
+
{label}
+
{renderUncappedBalanceLine(balance)}
+
+ ) + } + + if (provider === 'opencode-go' && balance.balance === null) { + const status = + balance.disabledReason === 'refresh-failed' + ? translate('auto.components.status.bar.tooltip.e740f92596', 'Refresh failed') + : translate('auto.components.status.bar.tooltip.1292d4f2ee', 'Unavailable') + return ( +
+
{label}
+
{status}
+ {balance.disabledReason === 'api-key-source' ? ( +
+ {translate( + 'auto.components.status.bar.provider.extra.usage.section.apiKeyBalanceUnavailable', + 'Balance is not included in this usage response.' + )} +
+ ) : null} +
+ ) + } + + const capped = + balance.spendLimit !== null && balance.spentPercent !== null && balance.spent !== null + const balanceText = + balance.balance === null ? null : formatCurrencyAmount(balance.balance, balance.currencyCode) + if (!capped) { + const limitText = + balance.spendLimit === null + ? null + : formatCurrencyAmount(balance.spendLimit, balance.currencyCode) + const limitLabel = + limitText === null + ? null + : translate( + 'auto.components.status.bar.provider.extra.usage.section.135d51c19f', + 'Limit {{value0}}', + { value0: limitText } + ) + if (balanceText === null && limitLabel === null) { + return null + } + return ( +
+
{label}
+
+ {balanceText === null ? limitLabel : renderUncappedBalanceLine(balance)} +
+
+ ) + } + + const spentPct = clampUsedPercent(balance.spentPercent ?? 0) + const spent = formatCurrencyAmount(balance.spent ?? 0, balance.currencyCode) + const limit = formatCurrencyAmount(balance.spendLimit ?? 0, balance.currencyCode) + return ( +
+
{label}
+
+
+
+
+ {`${spent} / ${limit}`} + {formatUsagePercentageLabel(spentPct, usagePercentageDisplay)} +
+ {balanceText !== null ? ( +
+ {translate('auto.components.status.bar.tooltip.473d45cd0f', 'Balance {{value0}}', { + value0: balanceText + })} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx b/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx new file mode 100644 index 00000000000..e176ca44987 --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx @@ -0,0 +1,212 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +const compactMessages = vi.hoisted(() => ({ balance: '' })) + +afterEach(() => { + compactMessages.balance = '' +}) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = + _key === 'auto.components.status.bar.StatusBar.4fba7dc1e7' && compactMessages.balance + ? compactMessages.balance + : fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +function openCodeGo(sessionUsedPercent: number, balance = 12.4): ProviderRateLimits { + return { + provider: 'opencode-go', + session: { + usedPercent: sessionUsedPercent, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function claudeUnknownBalance(): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function codexCredits(sessionUsedPercent: number): ProviderRateLimits { + return { + provider: 'codex', + session: { + usedPercent: sessionUsedPercent, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true, + disabledReason: null, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function claudeDisabledCredits(): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 80, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'currency', + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits', + spent: 0, + spendLimit: 2000, + spentPercent: 0, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('ProviderSegment extra-usage balance token', () => { + it.each([ + ['Spanish', 'saldo {{value0}}', 'saldo $12.40'], + ['French', 'solde {{value0}}', 'solde $12.40'] + ])('lets %s put the balance label before its amount', async (_locale, message, expected) => { + compactMessages.balance = message + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain(expected) + expect(markup).not.toContain('{{value0}}') + }) + + it('reveals the remaining balance once a plan window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('$12.40') + expect(markup).toContain('bal') + }) + + it.each([0, -1.25])( + 'hides a depleted currency balance of %s from the compact segment', + async (balance) => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('bal') + expect(markup).not.toContain('$0.00') + expect(markup).not.toContain('-$1.25') + } + ) + + it('hides the balance while plan windows still have headroom', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('$12.40') + expect(markup).not.toContain('bal') + }) + + it('does not reveal an unavailable balance as zero when a window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('bal') + }) + + it('does not reveal a disabled balance even when a window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('bal') + }) + + it('reveals a Codex credit count (not a currency amount) when a window caps', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('500 credits') + expect(markup).not.toContain('$500') + expect(markup).not.toContain('bal') + }) +}) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index f08f1e4a1eb..6f928745dbb 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -28,6 +28,7 @@ import { formatResetCreditExpiry, formatResetCountdown, getProviderUsageErrorMessage, + getExtraUsageLabel, getProviderUsageStatusLabel, getWindowSections, ProviderIcon, @@ -461,6 +462,277 @@ describe('getWindowSections', () => { }) }) +describe('getExtraUsageLabel', () => { + it('names the balance per provider', () => { + expect(getExtraUsageLabel('claude')).toBe('Usage credits') + expect(getExtraUsageLabel('opencode-go')).toBe('Zen balance') + expect(getExtraUsageLabel('codex')).toBe('Credits') + expect(getExtraUsageLabel('gemini')).toBe('Balance') + }) +}) + +describe('ProviderPanel extra-usage rendering', () => { + it('renders the Claude usage-credits cap as a spent/limit meter plus balance', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 10, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€2,000.00') + expect(markup).toContain('€50.00') + expect(markup).toContain('% used') + expect(markup).toContain('Balance €10.00') + }) + + it('renders a legacy spend cap without presenting an unavailable balance as zero', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€50.00 / €2,000.00') + expect(markup).not.toContain('Balance €0.00') + }) + + it('renders a known legacy cap without inventing spend, percent, balance, or a meter', () => { + const p = provider({ + status: 'ok', + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: 2000, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('Limit €2,000.00') + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('% used') + expect(markup).not.toContain('h-[6px]') + }) + + it('keeps a known cap as a caption when spend is unknown even if a percentage is present', () => { + const p = provider({ + status: 'ok', + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: 2000, + spentPercent: 10, + resetsAt: null + } + }) + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + expect(markup).toContain('Limit €2,000.00') + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('% used') + expect(markup).not.toContain('h-[6px]') + }) + + it('renders a disabled, out-of-credits cap with a zero balance', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'currency', + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits', + spent: 0, + spendLimit: 2000, + spentPercent: 0, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€0.00 / €2,000.00') + expect(markup).toContain('Balance €0.00') + }) + + it('renders an uncapped OpenCode Go balance as a plain available amount', () => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 12.4, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain('$12.40') + expect(markup).toContain('available') + }) + + it.each([ + [0, '$0.00'], + [-1.25, '-$1.25'] + ])('keeps a depleted Zen balance of %s visible in the popover', (balance, formatted) => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain(formatted) + expect(markup).toContain('available') + }) + + it.each([ + ['refresh-failed', 'Refresh failed'], + ['billing-unavailable', 'Unavailable'], + ['api-key-source', 'Balance is not included in this usage response.'] + ])('shows an unknown Zen balance honestly for %s', (disabledReason, message) => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'USD', + enabled: false, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain(message) + expect(markup).toContain('Session') + expect(markup).not.toContain('$0.00') + expect(markup).not.toMatch(/\$[\d,.]+ available/) + }) + + it('renders a Codex credit count as a plain "N credits available" line', () => { + const p = provider({ + provider: 'codex', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Credits') + expect(markup).toContain('500 credits available') + // A credit count is not a currency amount. + expect(markup).not.toContain('$500') + }) + + it('renders unlimited Codex credits as "Unlimited"', () => { + const p = provider({ + provider: 'codex', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'credits', + unlimited: true, + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Unlimited') + }) + + it('omits the balance row when no extra usage is reported', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).not.toContain('Usage credits') + expect(markup).not.toContain('Zen balance') + }) +}) + describe('ProviderPanel reset rendering', () => { it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() @@ -594,7 +866,7 @@ describe('barColor', () => { describe('ProviderIcon', () => { it('renders the Antigravity agent icon for the antigravity provider', () => { - const markup = renderToStaticMarkup(ProviderIcon({ provider: 'antigravity' })) + const markup = renderToStaticMarkup(createElement(ProviderIcon, { provider: 'antigravity' })) expect(markup).toContain('data-agent-icon="antigravity"') }) @@ -602,7 +874,7 @@ describe('ProviderIcon', () => { // Why: the icon must travel to the status bar / tooltip unchanged so the // user recognises the brand. We pin it to an with a non-empty // resource URL and aria-hidden so the icon stays purely decorative. - const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + const markup = renderToStaticMarkup(createElement(ProviderIcon, { provider: 'minimax' })) expect(markup.startsWith(' ))} + {p.extraUsage ? ( + + ) : null} + {p.error ? ( { + it.each(Object.entries(catalogs))( + '%s omits copied English balance entries', + async (locale, { catalog }) => { + const instance = createInstance() + await instance.init({ lng: locale, resources: { [locale]: { translation: catalog } } }) + + for (const [suffix] of balanceMessages) { + expect(instance.getResource(locale, 'translation', balanceKey(suffix))).toBeUndefined() + } + } + ) + + it.each(Object.entries(catalogs))( + '%s falls back and interpolates balance copy at runtime', + async (locale, { catalog, compactBalanceMessage }) => { + const instance = createInstance() + await instance.init({ + lng: locale, + fallbackLng: 'en', + resources: { en: { translation: enRuntimeRequired }, [locale]: { translation: catalog } }, + interpolation: { escapeValue: false } + }) + + for (const [suffix, defaultValue] of balanceMessages) { + expect(instance.t(balanceKey(suffix), { defaultValue, value0: '12.50' })).toBe( + defaultValue.replace('{{value0}}', '12.50') + ) + } + expect( + instance.t(balanceKey('tooltip.87b5bda4d3'), { + defaultValue: '{{value0}} credits available', + value0: 500 + }) + ).toBe('500 credits available') + expect( + instance.t(balanceKey('StatusBar.4fba7dc1e7'), { + defaultValue: '{{value0}} bal', + value0: '12.50' + }) + ).toBe(compactBalanceMessage) + } + ) + + it('retains every English source balance message', async () => { + const instance = createInstance() + await instance.init({ lng: 'en', resources: { en: { translation: en } } }) + + for (const [suffix, value] of balanceMessages) { + expect(instance.getResource('en', 'translation', balanceKey(suffix))).toBe(value) + } + expect(instance.getResource('en', 'translation', balanceKey('StatusBar.4fba7dc1e7'))).toBe( + '{{value0}} bal' + ) + }) +}) diff --git a/src/shared/credit-count-format.test.ts b/src/shared/credit-count-format.test.ts new file mode 100644 index 00000000000..07ab4554252 --- /dev/null +++ b/src/shared/credit-count-format.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { formatCreditCount } from './credit-count-format' + +describe('formatCreditCount', () => { + it('uses the viewer locale for grouped credit counts', () => { + const expected = new Intl.NumberFormat(undefined, { maximumFractionDigits: 20 }).format(1234) + expect(formatCreditCount(1234)).toBe(expected) + }) + + it('normalizes invalid and negative counts', () => { + expect(formatCreditCount(Number.NaN)).toBe('0') + expect(formatCreditCount(-5)).toBe('0') + }) +}) diff --git a/src/shared/credit-count-format.ts b/src/shared/credit-count-format.ts new file mode 100644 index 00000000000..14cfde62bfb --- /dev/null +++ b/src/shared/credit-count-format.ts @@ -0,0 +1,6 @@ +const CREDIT_COUNT_FORMATTER = new Intl.NumberFormat(undefined, { maximumFractionDigits: 20 }) + +export function formatCreditCount(count: number): string { + const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0 + return CREDIT_COUNT_FORMATTER.format(safeCount) +} diff --git a/src/shared/currency-format.test.ts b/src/shared/currency-format.test.ts new file mode 100644 index 00000000000..b0c5cd6f174 --- /dev/null +++ b/src/shared/currency-format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { formatCurrencyAmount } from './currency-format' + +describe('formatCurrencyAmount', () => { + it('formats USD amounts with the dollar symbol', () => { + // Force a stable locale-independent expectation by asserting substrings. + const formatted = formatCurrencyAmount(12.4, 'USD') + expect(formatted).toMatch(/12[.,]40/) + expect(formatted).toContain('$') + }) + + it('formats EUR amounts with the euro symbol and thousands separator', () => { + const formatted = formatCurrencyAmount(2000, 'EUR') + expect(formatted).toContain('€') + expect(formatted).toMatch(/2[.,]000/) + }) + + it('falls back to code + amount for a malformed currency code', () => { + // A non-3-letter code makes Intl.NumberFormat throw, exercising the fallback. + expect(formatCurrencyAmount(9.5, 'ZZ')).toBe('ZZ 9.50') + }) + + it('defaults blank currency codes to USD', () => { + expect(formatCurrencyAmount(1, ' ')).toContain('$') + }) + + it('treats non-finite amounts as zero', () => { + expect(formatCurrencyAmount(Number.NaN, 'USD')).toMatch(/0[.,]00/) + }) +}) diff --git a/src/shared/currency-format.ts b/src/shared/currency-format.ts new file mode 100644 index 00000000000..c8779246986 --- /dev/null +++ b/src/shared/currency-format.ts @@ -0,0 +1,21 @@ +/** + * Format a major-unit amount (e.g. 12.4) as a localized currency string + * (e.g. "$12.40"). Shared by the usage popover and the compact status-bar + * balance token so both render identically. + * + * Why: `Intl.NumberFormat` throws on an unrecognized currency code (the code + * comes from a provider API we don't control), so fall back to a plain + * " " rendering rather than crashing the status bar. + */ +export function formatCurrencyAmount(amount: number, currencyCode: string): string { + const safeAmount = Number.isFinite(amount) ? amount : 0 + const code = currencyCode.trim() || 'USD' + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code + }).format(safeAmount) + } catch { + return `${code} ${safeAmount.toFixed(2)}` + } +} diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7f69b33264b..0a9abf64ab1 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -11,6 +11,32 @@ export type RateLimitWindow = { export type ProviderRateLimitStatus = 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +type ExtraUsageBalanceBase = { + enabled: boolean + disabledReason: string | null + resetsAt: number | null +} + +type CurrencyExtraUsageBalance = ExtraUsageBalanceBase & { + unit: 'currency' + /** Null when the current balance is unavailable. */ + balance: number | null + currencyCode: string + spent: number | null + spendLimit: number | null + spentPercent: number | null +} + +type CreditExtraUsageBalance = ExtraUsageBalanceBase & { + unit: 'credits' + balance: number + unlimited: boolean +} + +// Why: currency and unitless credits have different metadata; the discriminator +// prevents consumers from inventing dummy currency or spend-limit values. +export type ExtraUsageBalance = CurrencyExtraUsageBalance | CreditExtraUsageBalance + export type RateLimitBucket = RateLimitWindow & { name: string } @@ -67,6 +93,8 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go, Grok unified billing, Cursor plan pools), null if not available. */ monthly?: RateLimitWindow | null + /** Overage / pay-as-you-go balance the plan spends into once its windows cap. */ + extraUsage?: ExtraUsageBalance | null /** Named per-model buckets (Gemini models, Cursor plan pools). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ From c53b6f235ae59bb1365eab48ad91f3dcf69675a4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:00:20 -0700 Subject: [PATCH 3/9] Edit CSV tables directly and remember column widths (#25442) * Add direct CSV table editing and persistent column widths * Preserve pending CSV edits across concurrent saves and panes * Keep CSV input stable during autosave and table commands * Cancel delayed menu paste after CSV pane focus changes * Prove delayed CSV menu paste starts before focus changes --- .../editor/CsvColumnResizeHandle.tsx | 5 +- .../src/components/editor/CsvGrid.tsx | 200 ++++++++-- .../src/components/editor/CsvGridCell.tsx | 106 +++++ .../src/components/editor/CsvPagedViewer.tsx | 20 + .../src/components/editor/CsvTableToolbar.tsx | 193 +++++++++ .../editor/CsvViewer.editing.test.tsx | 365 ++++++++++++++++++ .../src/components/editor/CsvViewer.tsx | 136 ++++++- .../editor/EditorEditFileSurface.tsx | 7 + .../components/editor/EditorViewToggle.tsx | 4 +- .../components/editor/csv-cell-clipboard.ts | 90 +++++ .../components/editor/csv-cell-selection.ts | 64 +++ .../editor/csv-column-width-limits.ts | 2 + .../csv-column-width-preferences.test.ts | 77 ++++ .../editor/csv-column-width-preferences.ts | 62 +++ .../src/components/editor/csv-file-limits.ts | 1 + .../components/editor/csv-grid-interaction.ts | 26 ++ .../components/editor/csv-grid-keyboard.ts | 115 ++++++ .../src/components/editor/csv-inspection.ts | 32 ++ .../src/components/editor/csv-parse.ts | 12 +- .../editor/csv-pending-save.test.ts | 127 ++++++ .../components/editor/csv-serialization.ts | 30 ++ .../components/editor/csv-source-record.ts | 12 + .../editor/csv-table-edit-budget.ts | 7 + .../editor/csv-table-interactions.test.ts | 70 ++++ .../components/editor/csv-text-cell-edits.ts | 81 ++++ .../editor/csv-text-document.test.ts | 147 +++++++ .../components/editor/csv-text-document.ts | 197 ++++++++++ .../editor/editor-csv-file-content.ts | 4 +- .../editor/editor-pending-flush.test.ts | 44 +++ .../components/editor/editor-pending-flush.ts | 32 +- .../components/editor/editor-save-queue.ts | 20 +- .../editor/file-view-preference-storage.ts | 69 ++++ .../editor/pdf-scale-preference-storage.ts | 82 +--- ...use-editor-content-change-handler.test.tsx | 48 +++ .../use-editor-content-change-handler.ts | 5 +- .../src/components/editor/useCsvClipboard.ts | 124 ++++++ .../components/editor/useCsvColumnWidths.ts | 39 ++ .../components/editor/useCsvPendingCell.ts | 93 +++++ .../components/editor/useCsvTableEditor.ts | 255 ++++++++++++ src/renderer/src/components/ui/textarea.tsx | 8 +- src/renderer/src/i18n/locales/en.json | 25 +- tests/e2e/csv-paged-preview.spec.ts | 11 + tests/e2e/csv-table-editing.spec.ts | 329 ++++++++++++++++ tests/e2e/csv-url-links.spec.ts | 16 +- 44 files changed, 3242 insertions(+), 150 deletions(-) create mode 100644 src/renderer/src/components/editor/CsvGridCell.tsx create mode 100644 src/renderer/src/components/editor/CsvTableToolbar.tsx create mode 100644 src/renderer/src/components/editor/CsvViewer.editing.test.tsx create mode 100644 src/renderer/src/components/editor/csv-cell-clipboard.ts create mode 100644 src/renderer/src/components/editor/csv-cell-selection.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-limits.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-preferences.test.ts create mode 100644 src/renderer/src/components/editor/csv-column-width-preferences.ts create mode 100644 src/renderer/src/components/editor/csv-file-limits.ts create mode 100644 src/renderer/src/components/editor/csv-grid-interaction.ts create mode 100644 src/renderer/src/components/editor/csv-grid-keyboard.ts create mode 100644 src/renderer/src/components/editor/csv-inspection.ts create mode 100644 src/renderer/src/components/editor/csv-pending-save.test.ts create mode 100644 src/renderer/src/components/editor/csv-serialization.ts create mode 100644 src/renderer/src/components/editor/csv-source-record.ts create mode 100644 src/renderer/src/components/editor/csv-table-edit-budget.ts create mode 100644 src/renderer/src/components/editor/csv-table-interactions.test.ts create mode 100644 src/renderer/src/components/editor/csv-text-cell-edits.ts create mode 100644 src/renderer/src/components/editor/csv-text-document.test.ts create mode 100644 src/renderer/src/components/editor/csv-text-document.ts create mode 100644 src/renderer/src/components/editor/editor-pending-flush.test.ts create mode 100644 src/renderer/src/components/editor/file-view-preference-storage.ts create mode 100644 src/renderer/src/components/editor/use-editor-content-change-handler.test.tsx create mode 100644 src/renderer/src/components/editor/useCsvClipboard.ts create mode 100644 src/renderer/src/components/editor/useCsvColumnWidths.ts create mode 100644 src/renderer/src/components/editor/useCsvPendingCell.ts create mode 100644 src/renderer/src/components/editor/useCsvTableEditor.ts create mode 100644 tests/e2e/csv-table-editing.spec.ts diff --git a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx b/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx index ec2bca0f629..0efdb568c70 100644 --- a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx +++ b/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef } from 'react' import { translate } from '@/i18n/i18n' - -export const CSV_MIN_COLUMN_WIDTH = 48 -export const CSV_MAX_COLUMN_WIDTH = 1200 +import { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' +export { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' export function CsvColumnResizeHandle({ index, diff --git a/src/renderer/src/components/editor/CsvGrid.tsx b/src/renderer/src/components/editor/CsvGrid.tsx index 0f6eee7df75..9afe01942ad 100644 --- a/src/renderer/src/components/editor/CsvGrid.tsx +++ b/src/renderer/src/components/editor/CsvGrid.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { CsvColumnResizeHandle } from './CsvColumnResizeHandle' -import { CsvCellValue } from './CsvCellValue' +import { CsvGridCell } from './CsvGridCell' +import type { CsvGridInteraction } from './csv-grid-interaction' +import type { CsvColumnWidths } from './csv-column-width-preferences' const ROW_HEIGHT = 28 // Keep the scroll surface below Chromium's layout extent limit. @@ -16,7 +18,10 @@ export function CsvGrid({ sampleRows, getRow, onVisibleRows, - onOpenUrl + onOpenUrl, + interaction, + columnWidths, + onColumnWidthsChange }: { header: string[] rowCount: number @@ -25,14 +30,23 @@ export function CsvGrid({ getRow: (index: number) => string[] | undefined onVisibleRows?: (first: number, last: number) => void onOpenUrl?: (url: string, event: React.MouseEvent) => void + interaction?: CsvGridInteraction + columnWidths?: CsvColumnWidths + onColumnWidthsChange?: (widths: CsvColumnWidths) => void }): React.JSX.Element { const scrollRef = useRef(null) + const gridRef = useRef(null) + const gridId = useId() + const dragging = useRef(false) + const scrolledFocusVersion = useRef(-1) + const focusGrid = (): void => gridRef.current?.focus({ preventScroll: true }) const [requestedWindowStart, setWindowStart] = useState(0) const windowStart = Math.min( requestedWindowStart, Math.floor(Math.max(0, rowCount - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS ) - const [widthOverrides, setWidthOverrides] = useState>({}) + const [localWidths, setLocalWidths] = useState({}) + const widthOverrides = columnWidths ?? localWidths const rowNumberWidth = Math.max(48, String(rowCount).length * 8 + 16) const widths = useMemo(() => { const result = Array.from({ length: columnCount }, () => 80) @@ -60,7 +74,7 @@ export function CsvGrid({ paddingStart: rowNumberWidth, overscan: 2 }) - useLayoutEffect(() => columns.measure(), [columns, widths]) + useLayoutEffect(() => columns.measure(), [columns, widths, columnWidths]) const virtualRows = rows.getVirtualItems() const virtualColumns = columns.getVirtualItems() const first = virtualRows[0]?.index @@ -72,12 +86,82 @@ export function CsvGrid({ }, [first, last, windowStart, onVisibleRows]) const resize = useCallback( (index: number, width: number) => { - setWidthOverrides((previous) => ({ ...previous, [index]: width })) + const next = { ...widthOverrides, [index]: width } + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } columns.resizeItem(index, width) }, - [columns] + [columns, onColumnWidthsChange, widthOverrides] ) - const reset = useCallback((index: number) => resize(index, widths[index] ?? 80), [resize, widths]) + const reset = (index: number): void => { + const next = { ...widthOverrides } + delete next[index] + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } + columns.resizeItem(index, widths[index] ?? 80) + } + const active = interaction?.selection?.focus + useLayoutEffect(() => { + if (!active || scrolledFocusVersion.current === interaction?.focusVersion) { + return + } + if (interaction?.editing) { + scrolledFocusVersion.current = interaction.focusVersion + return + } + const targetWindow = + Math.floor(Math.max(0, active.row - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS + if (targetWindow !== windowStart) { + setWindowStart(targetWindow) + return + } + if (active.row > 0) { + rows.scrollToIndex(active.row - 1 - windowStart, { align: 'auto' }) + } else { + scrollRef.current?.scrollTo({ top: 0 }) + } + columns.scrollToIndex(active.column, { align: 'auto' }) + scrolledFocusVersion.current = interaction?.focusVersion ?? -1 + }, [active, interaction?.editing, interaction?.focusVersion, rows, columns, windowStart]) + useEffect(() => { + const finish = (): void => { + dragging.current = false + } + window.addEventListener('pointerup', finish) + window.addEventListener('pointercancel', finish) + window.addEventListener('blur', finish) + return () => { + window.removeEventListener('pointerup', finish) + window.removeEventListener('pointercancel', finish) + window.removeEventListener('blur', finish) + } + }, []) + const pointerCell = (event: React.PointerEvent): { row: number; column: number } | null => { + if ( + !(event.target instanceof Element) || + event.target.closest('a,textarea,[role="separator"]') + ) { + return null + } + const cell = event.target.closest('[data-csv-row]') + if (!cell) { + return null + } + const row = Number(cell.getAttribute('data-csv-row')) + const column = Number(cell.getAttribute('data-csv-column')) + return Number.isInteger(row) && Number.isInteger(column) ? { row, column } : null + } + const activeMounted = + active && + virtualColumns.some((column) => column.index === active.column) && + (active.row === 0 || virtualRows.some((row) => row.index + windowStart + 1 === active.row)) + const totalWidth = columns.getTotalSize() const gridTemplate = `${rowNumberWidth}px ${Math.max(0, (virtualColumns[0]?.start ?? rowNumberWidth) - rowNumberWidth)}px ${virtualColumns.map((column) => `${column.size}px`).join(' ')} ${Math.max(0, totalWidth - (virtualColumns.at(-1)?.end ?? rowNumberWidth))}px` const moveWindow = (start: number): void => { @@ -90,9 +174,65 @@ export function CsvGrid({ ref={scrollRef} data-testid="csv-scroll" className="relative min-h-0 flex-1 overflow-auto scrollbar-editor font-mono text-xs" + onScroll={(event) => { + if (!interaction?.editing) { + return + } + const position = interaction.editing.position + const cell = gridRef.current + ?.querySelector( + `[data-csv-row="${position.row}"][data-csv-column="${position.column}"]` + ) + ?.getBoundingClientRect() + const viewport = event.currentTarget.getBoundingClientRect() + // Caret visibility can scroll a clipped input while the user is still typing. + if ( + cell && + cell.bottom > viewport.top + (position.row ? ROW_HEIGHT : 0) && + cell.top < viewport.bottom && + cell.right > viewport.left && + cell.left < viewport.right + ) { + return + } + if (!interaction.commit()) { + if (position.row > 0) { + rows.scrollToIndex(position.row - 1 - windowStart, { align: 'auto' }) + } + columns.scrollToIndex(position.column, { align: 'auto' }) + } + }} >
{ + const cell = pointerCell(event) + if (!interaction || !cell || event.button !== 0) { + return + } + event.preventDefault() + if (interaction.select(cell.row, cell.column, event.shiftKey)) { + dragging.current = true + focusGrid() + } + }} + onPointerMove={(event) => { + const cell = pointerCell(event) + if (interaction && dragging.current && cell) { + interaction.select(cell.row, cell.column, true) + } + }} aria-rowcount={rowCount + 1} aria-colcount={columnCount + 1} className="relative min-w-full" @@ -113,23 +253,23 @@ export function CsvGrid({