diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index 45a531d1c4f..b0177884b57 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -171,7 +171,9 @@ describe('a failed ACP turn says why', () => { const prompt = lane.openPrompt('c1', 1000) f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) - f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1003)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1003) + ) f.apply( lane.notification( '_x.ai/session/prompt_complete', diff --git a/src/main/claude/claude-context-usage-test-support.ts b/src/main/claude/claude-context-usage-test-support.ts index 659c01c4591..64f8911f247 100644 --- a/src/main/claude/claude-context-usage-test-support.ts +++ b/src/main/claude/claude-context-usage-test-support.ts @@ -22,9 +22,17 @@ import { claudeTurnLifecycleIdentity } from './claude-turn-lifecycle-item' export function journal({ deferred = false } = {}) { const queued: (() => void)[] = [] const run = (write: () => void): void => { - if (deferred) { queued.push(write) } else { write() } + if (deferred) { + queued.push(write) + } else { + write() + } + } + const flush = (): void => { + while (queued.length) { + queued.shift()!() + } } - const flush = (): void => { while (queued.length) { queued.shift()!() } } const clock = { now: 0 } const appends: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] const rows = new Map() @@ -37,7 +45,9 @@ export function journal({ deferred = false } = {}) { revision: (existing?.revision ?? 0) + 1, sequence: existing?.sequence ?? rows.size + 1, observedAt: existing?.observedAt ?? options?.observedAt ?? clock.now, - ...options, turnScope: existing?.turnScope ?? options.turnScope, body + ...options, + turnScope: existing?.turnScope ?? options.turnScope, + body }) } const scans = { count: 0 } @@ -59,14 +69,20 @@ export function journal({ deferred = false } = {}) { ) => { run(() => { const resolved = resolve(bound) - if (resolved) { appendItem(resolved.identity, resolved.body, options) } + if (resolved) { + appendItem(resolved.identity, resolved.body, options) + } }) return { accepted: true } } const stopState = createJournalReducerState('orca-session', 'test') - const queueStop = (turnId: string, at: number): void => run(() => { - stopState.queuePauseMarks.latestStop = { sequence: rows.size + 1, event: { reason: 'user-stop', turnId, at } } - }) + const queueStop = (turnId: string, at: number): void => + run(() => { + stopState.queuePauseMarks.latestStop = { + sequence: rows.size + 1, + event: { reason: 'user-stop', turnId, at } + } + }) const sink: StructuredAgentSessionEventSink = { journalStopDecidesTurn: (turnId, at) => personStopDecidesTurn(stopState, turnId, at), appendItem: (...args) => run(() => appendItem(...args)), @@ -159,7 +175,10 @@ export const turnIdentity = (turnId: string) => claudeTurnLifecycleIdentity('claude-session', turnId) /** Every turn the CLI runs opens with an init naming the main model as `modelUsage` keys it. */ -export function setup({ init = 'claude-fable-5-1[1m]', deferred = false }: { init?: string | null; deferred?: boolean } = {}) { +export function setup({ + init = 'claude-fable-5-1[1m]', + deferred = false +}: { init?: string | null; deferred?: boolean } = {}) { const state = journal({ deferred }) const translator = createClaudeJournalTranslator({ sink: state.sink, coalesceMs: 0 }) const requests: ClaudeContextReportTarget[] = [] diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index b7e6b133eec..55a9eedf5fa 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -103,12 +103,25 @@ export function journalClaudeMessage( } const openOutputTurn = (): void => ctx.turn.ensureOpen(message, source, observedAt) const messageBody = claudeMessageBody(outputEnvelope) - const ordinaryFailure = typeof message.api_error === 'string' && message.api_error !== 'usage_limit_reached' - ? { kind: 'status' as const, tone: 'error' as const, ...agentSessionFailureWords({ kind: 'providerRejected' }, { agentName: 'Claude', provider: 'claude', surface: 'row' }) } : null - const terminal = envelope.role === 'assistant' && producerRef === null && + const ordinaryFailure = + typeof message.api_error === 'string' && message.api_error !== 'usage_limit_reached' + ? { + kind: 'status' as const, + tone: 'error' as const, + ...agentSessionFailureWords( + { kind: 'providerRejected' }, + { agentName: 'Claude', provider: 'claude', surface: 'row' } + ) + } + : null + const terminal = + envelope.role === 'assistant' && + producerRef === null && (typeof message.api_error === 'string' || message.error === 'rate_limit') - if (envelope.role === 'assistant' && - (messageBody || typeof message.api_error === 'string' || message.error === 'rate_limit')) { + if ( + envelope.role === 'assistant' && + (messageBody || typeof message.api_error === 'string' || message.error === 'rate_limit') + ) { openOutputTurn() } const usageLimit = ctx.usageLimits.assistant(message, ctx.turn) @@ -116,9 +129,9 @@ export function journalClaudeMessage( ctx.authenticationFailures.assistant(message) ?? usageLimit ?? ctx.threadGoal.report(message, claudeMessageIdentity(envelope), observedAt) ?? - messageBody ?? ordinaryFailure - const terminalIdentity = terminal && ctx.turn.identity && usageLimit - ? ctx.turn.identity : null + messageBody ?? + ordinaryFailure + const terminalIdentity = terminal && ctx.turn.identity && usageLimit ? ctx.turn.identity : null const identity = (body && envelope.role === 'assistant' ? ctx.streamedBlocks.reconcile(envelope)?.identity diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 88b5a2bd2ea..de90e41dff0 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -191,7 +191,9 @@ export class ClaudeOpenTurn { /** End the open turn, if one is open, and clear the live activity line. The * context facts the end brings ride the same revision. */ settle(end: ClaudeTurnEnd, contextUsage?: AgentSessionContextUsage): void { - const quotaEnd = this.current !== null && end.outcome === undefined && + const quotaEnd = + this.current !== null && + end.outcome === undefined && this.usageLimitEvidence?.cause === 'quota' this.suppressReopenOnFailure(quotaEnd) // Every settle is a provider cycle ending (result, idle, child exit). @@ -234,12 +236,10 @@ export class ClaudeOpenTurn { if (quotaEnd && refineClaudeQuotaTurnEnd(this.deps.sink, item.identity, write)) { return } - writeAgentJournalTurnRow( - this.deps.sink, - { identity: item.identity }, - write, - { publish: false, options: item.options } - ) + writeAgentJournalTurnRow(this.deps.sink, { identity: item.identity }, write, { + publish: false, + options: item.options + }) // Keyed apart, so this never replaces the start's publication while it still waits to run. this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey }) } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 74df8eb43b1..0e7ff10993d 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -73,7 +73,9 @@ export function journalClaudeResult( return } const failure = claudeResultFailure(message, leftToStop) - const usageLimit = settlesTurn ? usageLimits.result(message, failure !== null, usageEvidence) : null + const usageLimit = settlesTurn + ? usageLimits.result(message, failure !== null, usageEvidence) + : null if (usageLimit && endedTurnIdentity) { writeClaudeTerminalFailure(sink, endedTurnIdentity, usageLimit) } @@ -84,11 +86,15 @@ export function journalClaudeResult( streamedBlocks.clear() streamedText.settle() } - const goal = threadGoal.settle(message, { - backgroundWork: goalBackgroundWork(), - stoppedByPerson: leftToStop, - turnStartedAt: endedTurnStartedAt - }, observedAt) + const goal = threadGoal.settle( + message, + { + backgroundWork: goalBackgroundWork(), + stoppedByPerson: leftToStop, + turnStartedAt: endedTurnStartedAt + }, + observedAt + ) if (goal) { sink.appendItem(goal.identity, goal.body, { turnScope: endedTurnScope }) sink.publish() diff --git a/src/main/claude/claude-terminal-failure.ts b/src/main/claude/claude-terminal-failure.ts index 1afb24d1d5a..648e0bd9f2e 100644 --- a/src/main/claude/claude-terminal-failure.ts +++ b/src/main/claude/claude-terminal-failure.ts @@ -35,7 +35,12 @@ export function writeClaudeTerminalFailure( return } const reservedBytes = Buffer.byteLength(JSON.stringify({ failureIdentity, body }), 'utf8') + 8192 - revise.call(sink, reservedBytes, (journal) => mergeClaudeTerminalFailure(journal, identity, body), options) + revise.call( + sink, + reservedBytes, + (journal) => mergeClaudeTerminalFailure(journal, identity, body), + options + ) } function mergeClaudeTerminalFailure( @@ -49,17 +54,28 @@ function mergeClaudeTerminalFailure( const next = body.kind === 'status' ? readAgentSessionFailureFact(body.failure) : undefined const previousDetail = prior?.detail?.text const incomingDetail = next?.detail?.text - const detail = previousDetail && incomingDetail && !previousDetail.includes(incomingDetail) - ? `${previousDetail}\n${incomingDetail}` : previousDetail ?? incomingDetail ?? '' - const merged = prior?.kind === 'usageLimitReached' && next?.kind === 'usageLimitReached' - ? { ...body, ...agentSessionFailureWords({ - ...prior, ...next, - usageLimit: next.usageLimit ?? prior.usageLimit, - detail: providerDiagnostic(detail, 'person') - }, { agentName: 'Claude', provider: 'claude', surface: 'row' }) } - : body + const detail = + previousDetail && incomingDetail && !previousDetail.includes(incomingDetail) + ? `${previousDetail}\n${incomingDetail}` + : (previousDetail ?? incomingDetail ?? '') + const merged = + prior?.kind === 'usageLimitReached' && next?.kind === 'usageLimitReached' + ? { + ...body, + ...agentSessionFailureWords( + { + ...prior, + ...next, + usageLimit: next.usageLimit ?? prior.usageLimit, + detail: providerDiagnostic(detail, 'person') + }, + { agentName: 'Claude', provider: 'claude', surface: 'row' } + ) + } + : body return JSON.stringify(merged) === JSON.stringify(old) - ? null : { identity: failureIdentity, body: merged } + ? null + : { identity: failureIdentity, body: merged } } // Refine only the ending row after its quota and any person Stop have landed. @@ -75,18 +91,33 @@ export function refineClaudeQuotaTurnEnd( const lifecycle = write.lifecycle const target = { identity } const reservedBytes = agentJournalTurnRowReservedBytes(target, write) - revise.call(sink, reservedBytes, (journal) => { - const itemId = agentJournalItemKey(identity) - const row = journal.item(agentJournalItemKey(claudeTerminalFailureIdentity(itemId))) - const quota = row?.body.kind === 'status' && isRootAgentJournalItem(row) && - row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === itemId && - readAgentSessionFailureFact(row.body.failure)?.kind === 'usageLimitReached' - const stopped = lifecycle.completedAt !== undefined && - sink.journalStopDecidesTurn?.(lifecycle.turnId, lifecycle.completedAt) - const outcome = quota ? (stopped ? 'cancellation' : 'failure') : undefined - return resolveAgentJournalTurnRowWrite(journal, target, { - ...write, lifecycle: { ...lifecycle, ...(outcome ? { outcome } : {}) } - }, reservedBytes) - }, { turnScope: { kind: 'thread' } }) + revise.call( + sink, + reservedBytes, + (journal) => { + const itemId = agentJournalItemKey(identity) + const row = journal.item(agentJournalItemKey(claudeTerminalFailureIdentity(itemId))) + const quota = + row?.body.kind === 'status' && + isRootAgentJournalItem(row) && + row.turnScope?.kind === 'turn' && + row.turnScope.turnItemId === itemId && + readAgentSessionFailureFact(row.body.failure)?.kind === 'usageLimitReached' + const stopped = + lifecycle.completedAt !== undefined && + sink.journalStopDecidesTurn?.(lifecycle.turnId, lifecycle.completedAt) + const outcome = quota ? (stopped ? 'cancellation' : 'failure') : undefined + return resolveAgentJournalTurnRowWrite( + journal, + target, + { + ...write, + lifecycle: { ...lifecycle, ...(outcome ? { outcome } : {}) } + }, + reservedBytes + ) + }, + { turnScope: { kind: 'thread' } } + ) return true } diff --git a/src/main/claude/claude-thread-goal-journaling.test.ts b/src/main/claude/claude-thread-goal-journaling.test.ts index f117ee156e5..3153ca81ecd 100644 --- a/src/main/claude/claude-thread-goal-journaling.test.ts +++ b/src/main/claude/claude-thread-goal-journaling.test.ts @@ -81,59 +81,66 @@ function goalSink( } describe('Claude /goal journaling', () => { - it.each([false, true])('journals the goal and its completion before adjacent idle (deferred %s)', (deferred) => { - const { frame, reply, result, goals, flush } = goalSink(null, undefined, deferred) - reply('goal-set', '', `Goal set: ${OBJECTIVE}`) - reply('work-1', 'claude-haiku-4-5', 'Working on it.') - // A turn cap stops Claude before the goal passes: the goal stays open and gains the time. - result( - 'result-1', - { subtype: 'error_max_turns', is_error: true, terminal_reason: 'max_turns' }, - 5_000 - ) - frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) - reply('work-2', 'claude-haiku-4-5', 'done') - result('result-2', { subtype: 'success', is_error: false, terminal_reason: 'completed' }, 3_000) - frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) - flush() + it.each([false, true])( + 'journals the goal and its completion before adjacent idle (deferred %s)', + (deferred) => { + const { frame, reply, result, goals, flush } = goalSink(null, undefined, deferred) + reply('goal-set', '', `Goal set: ${OBJECTIVE}`) + reply('work-1', 'claude-haiku-4-5', 'Working on it.') + // A turn cap stops Claude before the goal passes: the goal stays open and gains the time. + result( + 'result-1', + { subtype: 'error_max_turns', is_error: true, terminal_reason: 'max_turns' }, + 5_000 + ) + frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) + reply('work-2', 'claude-haiku-4-5', 'done') + result( + 'result-2', + { subtype: 'success', is_error: false, terminal_reason: 'completed' }, + 3_000 + ) + frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) + flush() - expect(goals()).toEqual([ - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, - body: expect.objectContaining({ - text: `Goal set: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ - objective: OBJECTIVE, - status: 'active', - timeUsedSeconds: 0 - }) - } - }) - }, - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, - body: expect.objectContaining({ - text: `Goal set: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 6 }) - } - }) - }, - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'result-2:goal' }, - body: expect.objectContaining({ - text: `Goal complete: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ status: 'complete', timeUsedSeconds: 10 }) - } - }) - } - ]) - }) + expect(goals()).toEqual([ + { + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, + body: expect.objectContaining({ + text: `Goal set: ${OBJECTIVE}`, + threadGoal: { + state: 'set', + goal: expect.objectContaining({ + objective: OBJECTIVE, + status: 'active', + timeUsedSeconds: 0 + }) + } + }) + }, + { + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, + body: expect.objectContaining({ + text: `Goal set: ${OBJECTIVE}`, + threadGoal: { + state: 'set', + goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 6 }) + } + }) + }, + { + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'result-2:goal' }, + body: expect.objectContaining({ + text: `Goal complete: ${OBJECTIVE}`, + threadGoal: { + state: 'set', + goal: expect.objectContaining({ status: 'complete', timeUsedSeconds: 10 }) + } + }) + } + ]) + } + ) // Seen live: a Stop pressed while Claude ran the goal's check ended the turn with a clean result. it('keeps the goal open when a person stops the turn, even on a clean result', () => { diff --git a/src/main/claude/claude-usage-limits.test.ts b/src/main/claude/claude-usage-limits.test.ts index 45ee1520724..48ec8744723 100644 --- a/src/main/claude/claude-usage-limits.test.ts +++ b/src/main/claude/claude-usage-limits.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { latestNativeChatUsageLimitCut } from '../../shared/native-chat-usage-limit-cut' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' -import { adapterFor, fakeClaude, identityFor, PROVIDER_SESSION_ID, USER_MESSAGE } from './claude-structured-session-test-support' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + USER_MESSAGE +} from './claude-structured-session-test-support' import { assistantFrame, frame, setup, userFrame } from './claude-context-usage-test-support' // Seconds, as Claude sends them; two hours out so no reset passes mid-test. @@ -61,60 +67,97 @@ function messageTexts(t: ReturnType): string[] { } describe('a Claude usage limit', () => { - it.each([false, true])('keeps one limit row across a result and idle (quota first %s)', (quotaFirst) => { - const t = setup() - if (!quotaFirst) { t.handle(userFrame('turn-a', 1_000)) } - t.handle(rateLimitEvent('rejected', 1_100)) - if (!quotaFirst) { - t.handle(frame({ ...assistantFrame('first', 1_150, 20).message, user_message_uuid: 'turn-a' }, 1_150)) - } - t.handle(frame({ - ...refusedFrame('a', 1_200, LIMIT_TEXT).message, - user_message_uuid: undefined, - api_error: 'usage_limit_reached' - }, 1_200)) - t.handle(failedResult('a', 1_300, 'Request failed')) - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1_301)) - // The CLI throttles an unchanged rejection: the next turn carries none. - t.handle(userFrame('turn-b', 2_000)) - t.handle(refusedFrame('b', 2_200, LIMIT_TEXT)) - t.handle(failedResult('b', 2_300, LIMIT_TEXT)) - - const limitRow = { - kind: 'status', - tone: 'warning', - text: 'Claude hit its session limit. Send a message once it resets to continue.', - failure: { - kind: 'usageLimitReached', - usageLimit: { window: 'session', resetsAt: RESETS_AT_S * 1000 }, - detail: { text: LIMIT_TEXT, audience: 'person' } + it.each([false, true])( + 'keeps one limit row across a result and idle (quota first %s)', + (quotaFirst) => { + const t = setup() + if (!quotaFirst) { + t.handle(userFrame('turn-a', 1_000)) } + t.handle(rateLimitEvent('rejected', 1_100)) + if (!quotaFirst) { + t.handle( + frame( + { ...assistantFrame('first', 1_150, 20).message, user_message_uuid: 'turn-a' }, + 1_150 + ) + ) + } + t.handle( + frame( + { + ...refusedFrame('a', 1_200, LIMIT_TEXT).message, + user_message_uuid: undefined, + api_error: 'usage_limit_reached' + }, + 1_200 + ) + ) + t.handle(failedResult('a', 1_300, 'Request failed')) + t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1_301)) + // The CLI throttles an unchanged rejection: the next turn carries none. + t.handle(userFrame('turn-b', 2_000)) + t.handle(refusedFrame('b', 2_200, LIMIT_TEXT)) + t.handle(failedResult('b', 2_300, LIMIT_TEXT)) + + const limitRow = { + kind: 'status', + tone: 'warning', + text: 'Claude hit its session limit. Send a message once it resets to continue.', + failure: { + kind: 'usageLimitReached', + usageLimit: { window: 'session', resetsAt: RESETS_AT_S * 1000 }, + detail: { text: LIMIT_TEXT, audience: 'person' } + } + } + expect(statusRows(t)).toEqual([ + { + ...limitRow, + failure: { + ...limitRow.failure, + detail: { text: `${LIMIT_TEXT}\nRequest failed`, audience: 'person' } + } + }, + limitRow + ]) + expect(messageTexts(t)).not.toContain(LIMIT_TEXT) + t.translator.dispose() } - expect(statusRows(t)).toEqual([ - { ...limitRow, failure: { ...limitRow.failure, - detail: { text: `${LIMIT_TEXT}\nRequest failed`, audience: 'person' } } }, - limitRow - ]) - expect(messageTexts(t)).not.toContain(LIMIT_TEXT) - t.translator.dispose() - }) + ) it('keeps a removed-media notice before later work and its usage-limit warning', () => { const t = setup({ deferred: true }) const removed = 'API Error: the image could not be processed and was removed' t.handle(userFrame('turn-a', 1000)) - t.handle(frame({ ...refusedFrame('media', 1100, removed).message, - api_error: 'media_removed', error: undefined, user_message_uuid: 'turn-a' }, 1100)) + t.handle( + frame( + { + ...refusedFrame('media', 1100, removed).message, + api_error: 'media_removed', + error: undefined, + user_message_uuid: 'turn-a' + }, + 1100 + ) + ) t.handle(assistantFrame('answer', 1200, 20)) - t.handle(frame({ ...refusedFrame('a', 1300, LIMIT_TEXT).message, - api_error: 'usage_limit_reached' }, 1300)) + t.handle( + frame( + { ...refusedFrame('a', 1300, LIMIT_TEXT).message, api_error: 'usage_limit_reached' }, + 1300 + ) + ) t.handle(failedResult('a', 1400, LIMIT_TEXT)) t.flush() - const visible = t.items().flatMap(({ body }) => - body.kind === 'message' && body.role === 'assistant' - ? body.blocks.flatMap((block) => block.type === 'text' ? [block.text] : []) - : body.kind === 'status' && body.failure?.kind === 'usageLimitReached' ? ['quota'] : [] - ) + const visible = t + .items() + .flatMap(({ body }) => + body.kind === 'message' && body.role === 'assistant' + ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : body.kind === 'status' && body.failure?.kind === 'usageLimitReached' + ? ['quota'] + : [] + ) expect(visible).toEqual([removed, 'answer', 'quota']) t.translator.dispose() }) @@ -126,21 +169,47 @@ describe('a Claude usage limit', () => { t.handle(refusedFrame('limited', 1_200, LIMIT_TEXT)) // No result: a replacement turn must not inherit the previous refusal. t.handle(userFrame('turn-credits', 2_000)) - t.handle(frame({ - ...refusedFrame('credits', 2_100, 'This model requires usage credits').message, - api_error: 'model_requires_usage_credits' - }, 2_100)) - t.handle(frame({ - ...failedResult('credits', 2_200, 'This model requires usage credits').message, - terminal_reason: 'api_error', api_error_status: 429 - }, 2_200)) + t.handle( + frame( + { + ...refusedFrame('credits', 2_100, 'This model requires usage credits').message, + api_error: 'model_requires_usage_credits' + }, + 2_100 + ) + ) + t.handle( + frame( + { + ...failedResult('credits', 2_200, 'This model requires usage credits').message, + terminal_reason: 'api_error', + api_error_status: 429 + }, + 2_200 + ) + ) expect(statusRows(t).filter((row) => row.failure?.kind === 'usageLimitReached')).toHaveLength(1) expect(statusRows(t).some((row) => row.text.includes('requires usage credits'))).toBe(true) t.handle(userFrame('turn-context', 2300)) - t.handle(frame({ ...refusedFrame('context', 2400, 'Prompt is too long').message, - error: 'invalid_request' }, 2400)) - t.handle(frame({ ...failedResult('context', 2500, 'Prompt is too long').message, - terminal_reason: 'blocking_limit', api_error_status: null }, 2500)) + t.handle( + frame( + { + ...refusedFrame('context', 2400, 'Prompt is too long').message, + error: 'invalid_request' + }, + 2400 + ) + ) + t.handle( + frame( + { + ...failedResult('context', 2500, 'Prompt is too long').message, + terminal_reason: 'blocking_limit', + api_error_status: null + }, + 2500 + ) + ) expect(statusRows(t).at(-1)).toMatchObject({ tone: 'error', text: 'Prompt is too long' }) t.translator.dispose() }) @@ -150,8 +219,12 @@ describe('a Claude usage limit', () => { try { const t = setup() t.handle(userFrame('turn-typed', 1000)) - t.handle(frame({ ...refusedFrame('typed', 1100, LIMIT_TEXT).message, - api_error: 'usage_limit_reached' }, 1100)) + t.handle( + frame( + { ...refusedFrame('typed', 1100, LIMIT_TEXT).message, api_error: 'usage_limit_reached' }, + 1100 + ) + ) t.handle(failedResult('typed', 1200, LIMIT_TEXT)) t.handle(rateLimitEvent('rejected', 1300)) clock.mockReturnValue(RESETS_AT_S * 1000 + 1000) @@ -183,81 +256,154 @@ describe('a Claude usage limit', () => { }) }) -const quota = (uuid: string, stamp?: string) => frame({ - ...refusedFrame(uuid, 1100, LIMIT_TEXT).message, - user_message_uuid: stamp, api_error: 'usage_limit_reached' -}, 1100) +const quota = (uuid: string, stamp?: string) => + frame( + { + ...refusedFrame(uuid, 1100, LIMIT_TEXT).message, + user_message_uuid: stamp, + api_error: 'usage_limit_reached' + }, + 1100 + ) -it.each(['idle', 'exit'] as const)('keeps quota failure and Continue through a deferred resultless %s', (ending) => { - const t = setup({ deferred: true }) - t.handle(userFrame('owner', 1000)) - t.handle(quota('refusal', 'owner')) - if (ending === 'idle') { - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) - } else { - t.handle({ type: 'ended', sessionId: 'orca-session', reason: 'Provider exited', observedAt: 1200 }) +it.each(['idle', 'exit'] as const)( + 'keeps quota failure and Continue through a deferred resultless %s', + (ending) => { + const t = setup({ deferred: true }) + t.handle(userFrame('owner', 1000)) + t.handle(quota('refusal', 'owner')) + if (ending === 'idle') { + t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) + } else { + t.handle({ + type: 'ended', + sessionId: 'orca-session', + reason: 'Provider exited', + observedAt: 1200 + }) + } + t.flush() + expect(t.turnRow('owner')?.body).toMatchObject({ + state: ending === 'idle' ? 'completed' : 'interrupted', + outcome: 'failure' + }) + expect(latestNativeChatUsageLimitCut(t.items(), [])).not.toBeNull() + t.translator.dispose() } - t.flush() - expect(t.turnRow('owner')?.body).toMatchObject({ - state: ending === 'idle' ? 'completed' : 'interrupted', outcome: 'failure' - }) - expect(latestNativeChatUsageLimitCut(t.items(), [])).not.toBeNull() - t.translator.dispose() -}) +) -it.each(['before', 'after', 'child', 'ordinary', 'success', 'other', 'superseded'] as const)('preserves %s precedence over resultless quota inference', (mode) => { - const t = setup({ deferred: true }) - t.handle(userFrame('owner', 1000)) - if (mode !== 'ordinary') { - t.handle(frame({ ...quota('refusal', 'owner').message, - ...(mode === 'child' ? { parent_tool_use_id: 'child-tool' } : {}) }, 1100)) +it.each(['before', 'after', 'child', 'ordinary', 'success', 'other', 'superseded'] as const)( + 'preserves %s precedence over resultless quota inference', + (mode) => { + const t = setup({ deferred: true }) + t.handle(userFrame('owner', 1000)) + if (mode !== 'ordinary') { + t.handle( + frame( + { + ...quota('refusal', 'owner').message, + ...(mode === 'child' ? { parent_tool_use_id: 'child-tool' } : {}) + }, + 1100 + ) + ) + } + if (mode === 'other') { + t.handle( + frame( + { + ...refusedFrame('other', 1150, 'Requires credits').message, + api_error: 'model_requires_usage_credits', + user_message_uuid: 'owner' + }, + 1150 + ) + ) + } + if (mode === 'before') { + t.queueStop('owner', 1150) + expect(t.sink.journalStopDecidesTurn?.('owner', 1200)).toBe(false) + } + if (mode === 'success') { + t.handle(frame({ type: 'result', subtype: 'success', is_error: false, result: 'done' }, 1200)) + } else if (mode === 'superseded') { + t.handle(userFrame('newer', 1200)) + } else if (mode === 'before') { + t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) + } else { + t.handle({ + type: 'ended', + sessionId: 'orca-session', + reason: 'Provider exited', + observedAt: 1200 + }) + } + if (mode === 'after') { + t.queueStop('owner', 1300) + } + t.flush() + const outcome = + mode === 'before' + ? 'cancellation' + : mode === 'after' + ? 'failure' + : mode === 'success' + ? 'success' + : mode === 'superseded' + ? 'superseded' + : undefined + const body = t.turnRow('owner')?.body + expect(body?.kind === 'turn' ? body.outcome : null).toEqual(outcome) + expect(latestNativeChatUsageLimitCut(t.items(), []) !== null).toBe(mode === 'after') + t.translator.dispose() } - if (mode === 'other') { - t.handle(frame({ ...refusedFrame('other', 1150, 'Requires credits').message, - api_error: 'model_requires_usage_credits', user_message_uuid: 'owner' }, 1150)) - } - if (mode === 'before') { - t.queueStop('owner', 1150) - expect(t.sink.journalStopDecidesTurn?.('owner', 1200)).toBe(false) - } - if (mode === 'success') { - t.handle(frame({ type: 'result', subtype: 'success', is_error: false, result: 'done' }, 1200)) - } else if (mode === 'superseded') { - t.handle(userFrame('newer', 1200)) - } else if (mode === 'before') { - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) - } else { - t.handle({ type: 'ended', sessionId: 'orca-session', reason: 'Provider exited', observedAt: 1200 }) - } - if (mode === 'after') { t.queueStop('owner', 1300) } - t.flush() - const outcome = mode === 'before' ? 'cancellation' : mode === 'after' ? 'failure' - : mode === 'success' ? 'success' : mode === 'superseded' ? 'superseded' : undefined - const body = t.turnRow('owner')?.body - expect(body?.kind === 'turn' ? body.outcome : null).toEqual(outcome) - expect(latestNativeChatUsageLimitCut(t.items(), []) !== null).toBe(mode === 'after') - t.translator.dispose() -}) +) it('retains a newly accepted pre-echo failure after acquisition has consumed its exact waiter', async () => { const t = setup() t.translator.dispose() const claude = fakeClaude({ replayUuid: null }) const adapter = adapterFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'quota-test', events: t.sink }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'quota-test', + events: t.sink + }) const connection = claude.connections[0]! - const emit = (message: Record) => connection.handlers.onMessage?.({ - parent_tool_use_id: null, ...message, session_id: PROVIDER_SESSION_ID + const emit = (message: Record) => + connection.handlers.onMessage?.({ + parent_tool_use_id: null, + ...message, + session_id: PROVIDER_SESSION_ID + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + body: USER_MESSAGE, + fence: 7 }) - await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'first', body: USER_MESSAGE, fence: 7 }) emit({ ...connection.sent[0], uuid: 'owner' }) emit(quota('refusal', 'owner').message) emit({ ...failedResult('owner', 1200, LIMIT_TEXT).message, user_message_uuid: 'owner' }) - t.sink.appendItem({ provider: 'orca', clientMessageId: 'new-send' }, USER_MESSAGE, { turnScope: { kind: 'thread' } }) - await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'new-send', body: USER_MESSAGE, fence: 7 }) + t.sink.appendItem({ provider: 'orca', clientMessageId: 'new-send' }, USER_MESSAGE, { + turnScope: { kind: 'thread' } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'new-send', + body: USER_MESSAGE, + fence: 7 + }) const sent = connection.sent.at(-1)! - emit({ type: 'result', uuid: 'new-result', user_message_uuid: sent.uuid, - subtype: 'error_during_execution', is_error: true, result: 'Fresh request rejected' }) + emit({ + type: 'result', + uuid: 'new-result', + user_message_uuid: sent.uuid, + subtype: 'error_during_execution', + is_error: true, + result: 'Fresh request rejected' + }) expect(t.items().some((item) => item.itemId === agentJournalSubmissionKey('new-send'))).toBe(true) expect(statusRows(t).some((row) => row.text.includes('Fresh request rejected'))).toBe(true) await adapter.closeSession('session-1') diff --git a/src/main/claude/claude-usage-limits.ts b/src/main/claude/claude-usage-limits.ts index cee66cf9b2d..28242ac88c6 100644 --- a/src/main/claude/claude-usage-limits.ts +++ b/src/main/claude/claude-usage-limits.ts @@ -69,9 +69,7 @@ export class ClaudeUsageLimits { const root = message.parent_tool_use_id == null const decision = !typed && root ? (turn?.usageLimitEvidence?.cause ?? cause) : cause const row = - decision !== 'other' && refused - ? this.row(messageText(message), decision === 'quota') - : null + decision !== 'other' && refused ? this.row(messageText(message), decision === 'quota') : null if (root && turn?.isOpen && (typed || (refused && decision === 'untyped'))) { turn.usageLimitEvidence = { cause: row ? 'quota' : cause } } @@ -137,10 +135,7 @@ export class ClaudeUsageLimits { return { ...(window ? { window } : {}), ...(resetsAt ? { resetsAt } : {}) } } - private row( - providerText: string, - typed = false - ): ClaudeUsageLimitRow | null { + private row(providerText: string, typed = false): ClaudeUsageLimitRow | null { const limit = this.limit() if (!limit && !typed) { return null diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index f9907dfd9ed..073183fbc4e 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -158,9 +158,10 @@ export class CodexJournalGenericFrames { this.fallbackSequence += 1 const identity = { provider: 'orca' as const, - clientMessageId: translated.body.failure?.kind === 'usageLimitReached' && frameTurnId - ? `terminal-failure:codex:${JSON.stringify([threadId, frameTurnId])}` - : `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}` + clientMessageId: + translated.body.failure?.kind === 'usageLimitReached' && frameTurnId + ? `terminal-failure:codex:${JSON.stringify([threadId, frameTurnId])}` + : `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}` } const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem diff --git a/src/main/codex/codex-usage-limits.test.ts b/src/main/codex/codex-usage-limits.test.ts index 818fbb2c650..a190c4295b9 100644 --- a/src/main/codex/codex-usage-limits.test.ts +++ b/src/main/codex/codex-usage-limits.test.ts @@ -67,18 +67,28 @@ describe('a Codex usage limit', () => { const limits = new CodexUsageLimits() const observe = (rateLimits: Record) => limits.observe('notification:account/rateLimits/updated', { rateLimits }) - const failure = (code = 'usageLimitExceeded') => limits.failure({ - error: { codexErrorInfo: code, message: CODEX_WORDS } + const failure = (code = 'usageLimitExceeded') => + limits.failure({ + error: { codexErrorInfo: code, message: CODEX_WORDS } + }) + observe({ + limitId: 'codex', + primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: RESETS_AT_S } + }) + observe({ + limitId: null, + primary: { usedPercent: 100, windowDurationMins: 43200, resetsAt: RESETS_AT_S + 3600 } }) - observe({ limitId: 'codex', primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: RESETS_AT_S } }) - observe({ limitId: null, primary: { usedPercent: 100, windowDurationMins: 43200, resetsAt: RESETS_AT_S + 3600 } }) expect(failure()?.usageLimit).toEqual({ resetsAt: (RESETS_AT_S + 3600) * 1000 }) observe({ secondary: { usedPercent: 100, windowDurationMins: 10080 } }) expect(failure('rateLimitExceeded')?.usageLimit).toBeUndefined() - observe({ secondary: null, planType: 'free', primary: { usedPercent: 100, resetsAt: RESETS_AT_S } }) + observe({ + secondary: null, + planType: 'free', + primary: { usedPercent: 100, resetsAt: RESETS_AT_S } + }) expect(failure()?.usageLimit).toEqual({ resetsAt: RESETS_AT_S * 1000 }) observe({ primary: null }) expect(failure()?.usageLimit).toBeUndefined() }) - }) diff --git a/src/main/codex/codex-usage-limits.ts b/src/main/codex/codex-usage-limits.ts index 820ec233783..130ff752330 100644 --- a/src/main/codex/codex-usage-limits.ts +++ b/src/main/codex/codex-usage-limits.ts @@ -14,7 +14,10 @@ import { const RATE_LIMITS_UPDATED = 'notification:account/rateLimits/updated' const USAGE_LIMIT_ERRORS = ['usageLimitExceeded', 'rateLimitExceeded'] -type CodexAccountRateLimits = CodexRateLimitWindowsSnapshot & { rateLimitReachedType?: unknown; planType?: unknown } +type CodexAccountRateLimits = CodexRateLimitWindowsSnapshot & { + rateLimitReachedType?: unknown + planType?: unknown +} function record(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : null @@ -68,16 +71,33 @@ export class CodexUsageLimits { * when any spent window gave no reset. */ private limit(): AgentSessionUsageLimit | null { const monthlyPlan = this.snapshot.planType === 'free' || this.snapshot.planType === 'go' - const spent = ([['primary', monthlyPlan ? 43200 : 300], ['secondary', 10080]] as const).flatMap(([position, fallback]) => { + const spent = ( + [ + ['primary', monthlyPlan ? 43200 : 300], + ['secondary', 10080] + ] as const + ).flatMap(([position, fallback]) => { const raw = this.snapshot[position] - if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent) || raw.usedPercent < 100) { + if ( + !raw || + typeof raw.usedPercent !== 'number' || + !Number.isFinite(raw.usedPercent) || + raw.usedPercent < 100 + ) { return [] } - const duration = raw.windowDurationMins === undefined || raw.windowDurationMins === null - ? fallback : raw.windowDurationMins - const window = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 - ? duration >= 43200 ? undefined : duration >= 10080 ? 'weekly' as const : 'session' as const - : undefined + const duration = + raw.windowDurationMins === undefined || raw.windowDurationMins === null + ? fallback + : raw.windowDurationMins + const window = + typeof duration === 'number' && Number.isFinite(duration) && duration > 0 + ? duration >= 43200 + ? undefined + : duration >= 10080 + ? ('weekly' as const) + : ('session' as const) + : undefined return [{ window, resetsAt: raw.resetsAt }] }) const reached = this.snapshot.rateLimitReachedType diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index 146fc6d853c..db9b4b51f60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -556,7 +556,6 @@ describe('an id reused across commands', () => { }) }) - describe('source-bound ordinary Continue', () => { async function limitedTurn(id: string) { const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal @@ -565,20 +564,34 @@ describe('source-bound ordinary Continue', () => { } const identity = { provider: 'orca' as const, clientMessageId: id } const turnItemId = agentJournalItemKey(identity) - await journal.appendItem(identity, { - kind: 'turn', turnId: id, state: 'completed', outcome: 'failure', - startedAt: NOW, completedAt: NOW - }, { fence: 1, turnScope: { kind: 'thread' } }) - await journal.appendItem({ provider: 'orca', clientMessageId: `failure-${id}` }, { - kind: 'status', - ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) - }, { fence: 1, turnScope: { kind: 'turn', turnItemId } }) + await journal.appendItem( + identity, + { + kind: 'turn', + turnId: id, + state: 'completed', + outcome: 'failure', + startedAt: NOW, + completedAt: NOW + }, + { fence: 1, turnScope: { kind: 'thread' } } + ) + await journal.appendItem( + { provider: 'orca', clientMessageId: `failure-${id}` }, + { + kind: 'status', + ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId } } + ) return { journal, turnItemId } } function params(source: string, id = opId(), options?: Record) { const fields = { - body: hostTestMessage('Continue where you left off.'), delivery: 'queue-if-active' as const, - usageLimitContinuationOfTurnItemId: source, ...(options ? { options } : {}) + body: hostTestMessage('Continue where you left off.'), + delivery: 'queue-if-active' as const, + usageLimitContinuationOfTurnItemId: source, + ...(options ? { options } : {}) } return { ...fields, envelope: rig.envelope(fields, 'agentSession.send', id) } } @@ -589,7 +602,8 @@ describe('source-bound ordinary Continue', () => { const first = params(turnItemId) const second = params(turnItemId) const answers = await Promise.all([ - rig.host.send(CALLER, first), rig.host.send({ callerKey: 'phone' }, second) + rig.host.send(CALLER, first), + rig.host.send({ callerKey: 'phone' }, second) ]) expect(answers[0]).toMatchObject({ ok: true, replayed: false }) expect(answers[1]).toMatchObject(superseded) @@ -597,8 +611,10 @@ describe('source-bound ordinary Continue', () => { expect(journal.queuedMessages.list()).toHaveLength(0) await rig.send('newer ordinary message').result expect(await rig.host.send(CALLER, first)).toMatchObject({ ok: true, replayed: true }) - for (const changed of [params('another-turn', first.envelope.clientOperationId), - params(turnItemId, first.envelope.clientOperationId, { model: 'other' })]) { + for (const changed of [ + params('another-turn', first.envelope.clientOperationId), + params(turnItemId, first.envelope.clientOperationId, { model: 'other' }) + ]) { expect(await rig.host.send(CALLER, changed)).toMatchObject(CONFLICT) } expect(journal.submissions()).toHaveLength(2) @@ -619,16 +635,22 @@ describe('source-bound ordinary Continue', () => { try { const body = hostTestMessage('backlog') const row = await journal.queuedMessages.insert({ - messageId: opId(), body, fingerprint: agentSessionSendBodyFingerprint(SESSION, body), + messageId: opId(), + body, + fingerprint: agentSessionSendBodyFingerprint(SESSION, body), hostInstance: structuredAgentSessionHostInstance() }) - journal.queuedMessages.editLeases.acquire({ callerKey: CALLER.callerKey, editId: 'edit' }, - { messageId: row.messageId, fingerprint: row.fingerprint }) + journal.queuedMessages.editLeases.acquire( + { callerKey: CALLER.callerKey, editId: 'edit' }, + { messageId: row.messageId, fingerprint: row.fingerprint } + ) const attempt = rig.host.send(CALLER, params(current.turnItemId)) release() expect(await attempt).toMatchObject(superseded) expect(journal.queuedMessages.list()).toHaveLength(1) expect(journal.submissions()).toHaveLength(1) - } finally { release() } + } finally { + release() + } }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 11fbee17d30..c29edceb3ee 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -1,6 +1,14 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor +} from '@testing-library/react' import { useRef, useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,11 +26,17 @@ const mocks = vi.hoisted(() => { }) vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) -vi.mock('@/lib/worker-terminal-takeover-report', () => ({ reportStructuredSessionUserInput: vi.fn() })) +vi.mock('@/lib/worker-terminal-takeover-report', () => ({ + reportStructuredSessionUserInput: vi.fn() +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ ensureRuntimeEnvironmentCompatible: async () => {} })) -vi.mock('@/lib/structured-agent-session-launch-label', () => ({ structuredAgentLabel: () => 'Codex' })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + ensureRuntimeEnvironmentCompatible: async () => {} +})) +vi.mock('@/lib/structured-agent-session-launch-label', () => ({ + structuredAgentLabel: () => 'Codex' +})) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call })) @@ -110,7 +124,9 @@ function Harness(props: Props): React.JSX.Element { const [composerError, setComposerError] = useState(null) const continuation = useNativeChatInterruptedContinuation({ reportComposerError: setComposerError, - focusComposer: () => { composerRef.current?.focus() }, + focusComposer: () => { + composerRef.current?.focus() + }, target: props.target ?? PAIRED, agent: 'codex', sessionId: 'session-1', @@ -118,7 +134,16 @@ function Harness(props: Props): React.JSX.Element { submissions: props.submissions ?? [], isWorking: props.isWorking ?? false, pending: props.pending ?? [], - transport: props.transport ?? { send: () => true, dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), optionsSurface, optionSnapshot: [], onError: () => {}, runtime: 'remote', sessionId: 'session-1', runtimeEnvironmentId: 'studio-mac' } + transport: props.transport ?? { + send: () => true, + dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), + optionsSurface, + optionSnapshot: [], + onError: () => {}, + runtime: 'remote', + sessionId: 'session-1', + runtimeEnvironmentId: 'studio-mac' + } }) return ( @@ -131,8 +156,10 @@ function Harness(props: Props): React.JSX.Element { compose {continuation.view.usageLimitTurnItemId === null ? null : ( - + )} {/* As the cut's own row shows it. */} {continuation.view.offeredTurnItemId === null ? null : ( @@ -319,29 +346,69 @@ describe('Continue on a reply an Orca stop cut off', () => { }) }) - it('keeps the draft through Continue admission, refusal, uncertainty and recorded-before-journal', async () => { const items: AgentJournalRenderItem[] = [ - { ...cutChat[0]!, body: { kind: 'turn', turnId: 'cut', state: 'completed', outcome: 'failure', startedAt: 1, completedAt: 5 } }, - { ...cutChat[1]!, body: { kind: 'status', ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) } } + { + ...cutChat[0]!, + body: { + kind: 'turn', + turnId: 'cut', + state: 'completed', + outcome: 'failure', + startedAt: 1, + completedAt: 5 + } + }, + { + ...cutChat[1]!, + body: { + kind: 'status', + ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) + } + } ] const scope = structuredAgentSessionDraftScopeKey('session-1') - const draft = { text: 'My unfinished request', images: [{ id: 'attached', path: '/tmp/photo.png' }] } + const draft = { + text: 'My unfinished request', + images: [{ id: 'attached', path: '/tmp/photo.png' }] + } updateNativeChatComposerDraft(scope, draft, 'immediate') const options = { model: 'gpt-current', effort: 'high' } const uploading = { id: 'upload', path: '', pending: true, pendingName: 'next.png' } const reveal = vi.fn() function LiveHarness() { const sends = useStructuredAgentSessionSends({ - sessionId: 'session-1', target: PAIRED, fence: 1, submissions: [], queuedMessageIds: [], - queue: { capability: 'unsupported', enabled: false }, historyLoaded: false, sendOptions: () => options + sessionId: 'session-1', + target: PAIRED, + fence: 1, + submissions: [], + queuedMessageIds: [], + queue: { capability: 'unsupported', enabled: false }, + historyLoaded: false, + sendOptions: () => options }) const transport: NativeChatStructuredComposerTransport = { - send: (text, images, request) => sends.send(text, images.map((image) => ({ path: image.path, previewUri: image.path })), request), - onSubmitted: reveal, onError: vi.fn(), dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), - optionsSurface, optionSnapshot: [], runtime: 'remote', sessionId: 'session-1', runtimeEnvironmentId: 'studio-mac' + send: (text, images, request) => + sends.send( + text, + images.map((image) => ({ path: image.path, previewUri: image.path })), + request + ), + onSubmitted: reveal, + onError: vi.fn(), + dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), + optionsSurface, + optionSnapshot: [], + runtime: 'remote', + sessionId: 'session-1', + runtimeEnvironmentId: 'studio-mac' } - return <>{uploading.pendingName} + return ( + <> + {uploading.pendingName} + + + ) } let answer = Promise.withResolvers() mocks.call.mockImplementation(() => answer.promise) @@ -360,14 +427,32 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde expect(screen.getByTestId('error')).toHaveTextContent('none') expect(continueButton()).toBeNull() await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - expect(mocks.call.mock.calls[0].slice(0, 3)).toMatchObject([PAIRED, 'agentSession.send', { - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Continue where you left off.' }] }, - options, usageLimitContinuationOfTurnItemId: TURN - }]) + expect(mocks.call.mock.calls[0].slice(0, 3)).toMatchObject([ + PAIRED, + 'agentSession.send', + { + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Continue where you left off.' }] + }, + options, + usageLimitContinuationOfTurnItemId: TURN + } + ]) expect(reveal).toHaveBeenCalledOnce() expect(reportStructuredSessionUserInput).toHaveBeenCalledWith('session-1', 'studio-mac') expect(emitNativeChatMessageSent).toHaveBeenCalledWith({ agent: 'codex', runtime: 'remote' }) - await act(async () => answer.resolve({ ok: false, refusal: { code: 'agent_session_operation_invalid', details: { reason: 'providerRejected' }, message: 'Refused' } })) + await act(async () => + answer.resolve({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'providerRejected' }, + message: 'Refused' + } + }) + ) await waitFor(() => expect(continueButton()).toBeInTheDocument()) expect(screen.getByTestId('error')).toHaveTextContent(FAILED) expect(readNativeChatComposerDraft(scope)).toMatchObject(draft) @@ -379,7 +464,9 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) expect(mocks.call.mock.calls[1][2]).not.toHaveProperty('usageLimitContinuationOfTurnItemId') await act(async () => answer.reject(new Error('connection lost'))) - await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent('Check the chat before trying again.')) + await waitFor(() => + expect(screen.getByTestId('error')).toHaveTextContent('Check the chat before trying again.') + ) expect(screen.getByTestId('error')).not.toHaveTextContent('was not sent') expect(readNativeChatComposerDraft(scope)).toMatchObject(draft) mocks.capability = 'supported' @@ -388,8 +475,14 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde fireEvent.click(continueButton()!) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) const id = mocks.call.mock.calls[2][2].envelope.clientOperationId - await act(async () => answer.resolve({ ok: true, fence: 1, cursor: { epoch: 'e', sequence: 5 }, - value: { clientMessageId: id, submission: { clientMessageId: id, dispatchState: 'accepted' } } })) + await act(async () => + answer.resolve({ + ok: true, + fence: 1, + cursor: { epoch: 'e', sequence: 5 }, + value: { clientMessageId: id, submission: { clientMessageId: id, dispatchState: 'accepted' } } + }) + ) // Accepted before the journal arrives: the recorded sender entry still owns availability. expect(continueButton()).toBeNull() expect(readNativeChatComposerDraft(scope)).toMatchObject(draft) @@ -399,18 +492,52 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde it('keeps the notice view stable while Continue invokes the latest committed transport', () => { const items: AgentJournalRenderItem[] = [ - { ...cutChat[0]!, body: { kind: 'turn', turnId: 'cut', state: 'completed', outcome: 'failure', startedAt: 1, completedAt: 5 } }, - { ...cutChat[1]!, body: { kind: 'status', ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) } } + { + ...cutChat[0]!, + body: { + kind: 'turn', + turnId: 'cut', + state: 'completed', + outcome: 'failure', + startedAt: 1, + completedAt: 5 + } + }, + { + ...cutChat[1]!, + body: { + kind: 'status', + ...agentSessionFailureWords({ kind: 'usageLimitReached' }, { surface: 'row' }) + } + } ] const first = vi.fn(() => true) const latest = vi.fn(() => true) const transport: NativeChatStructuredComposerTransport = { - send: first, dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), - optionsSurface, optionSnapshot: [], onError: () => {}, runtime: 'remote', sessionId: 'session-1', runtimeEnvironmentId: 'studio-mac' + send: first, + dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), + optionsSurface, + optionSnapshot: [], + onError: () => {}, + runtime: 'remote', + sessionId: 'session-1', + runtimeEnvironmentId: 'studio-mac' } - const input = { target: PAIRED, agent: 'codex' as const, sessionId: 'session-1', journalItems: items, - submissions: [], isWorking: false, pending: [], reportComposerError: vi.fn(), focusComposer: vi.fn(), transport } - const { result, rerender } = renderHook((props) => useNativeChatInterruptedContinuation(props), { initialProps: input }) + const input = { + target: PAIRED, + agent: 'codex' as const, + sessionId: 'session-1', + journalItems: items, + submissions: [], + isWorking: false, + pending: [], + reportComposerError: vi.fn(), + focusComposer: vi.fn(), + transport + } + const { result, rerender } = renderHook((props) => useNativeChatInterruptedContinuation(props), { + initialProps: input + }) const view = result.current.view rerender({ ...input, transport: { ...transport, send: latest } }) expect(result.current.view).toBe(view) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 1bd4437a7a9..0b93ca656ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -114,36 +114,43 @@ export function useNativeChatInterruptedContinuation(input: { transport.sendBlockedReason === undefined ? usageLimitCut.turnItemId : null - const continueUsageLimit = useCallback((hadFocus = false): void => { - if ( - usageLimitTurnItemId === null || - usageCapability === 'unknown' || - getStructuredAgentSessionPendingSends(sessionId).length > 0 - ) { - return - } - const turnItemId = usageLimitTurnItemId - setUsageFailure(null) - reportComposerError(null) - // Keeping the draft must not keep the previous send's error. - publishStructuredAgentSessionSends(sessionId, { notice: null }) - const transport = latestTransport.current - const admission = transport.send(NATIVE_CHAT_USAGE_LIMIT_CONTINUE_MESSAGE, [], { - callerKeepsText: true, - ...(usageCapability === 'supported' ? { usageLimitContinuationOfTurnItemId: turnItemId } : {}), - onSettled: (outcome) => { - if (outcome === 'returned' || outcome === 'unconfirmed') { - setUsageFailure({ turnItemId, unconfirmed: outcome === 'unconfirmed' }) + const continueUsageLimit = useCallback( + (hadFocus = false): void => { + if ( + usageLimitTurnItemId === null || + usageCapability === 'unknown' || + getStructuredAgentSessionPendingSends(sessionId).length > 0 + ) { + return + } + const turnItemId = usageLimitTurnItemId + setUsageFailure(null) + reportComposerError(null) + // Keeping the draft must not keep the previous send's error. + publishStructuredAgentSessionSends(sessionId, { notice: null }) + const transport = latestTransport.current + const admission = transport.send(NATIVE_CHAT_USAGE_LIMIT_CONTINUE_MESSAGE, [], { + callerKeepsText: true, + ...(usageCapability === 'supported' + ? { usageLimitContinuationOfTurnItemId: turnItemId } + : {}), + onSettled: (outcome) => { + if (outcome === 'returned' || outcome === 'unconfirmed') { + setUsageFailure({ turnItemId, unconfirmed: outcome === 'unconfirmed' }) + } + } + }) + if (admission === false) { + setUsageFailure({ turnItemId, unconfirmed: false }) + } else { + completeNativeChatStructuredUserAction(agent, transport, admission === true) + if (hadFocus) { + focusComposer() } } - }) - if (admission === false) { - setUsageFailure({ turnItemId, unconfirmed: false }) - } else { - completeNativeChatStructuredUserAction(agent, transport, admission === true) - if (hadFocus) { focusComposer() } - } - }, [usageLimitTurnItemId, usageCapability, sessionId, reportComposerError, focusComposer, agent]) + }, + [usageLimitTurnItemId, usageCapability, sessionId, reportComposerError, focusComposer, agent] + ) const continueNow = useCallback((): void => { if (offered === null) { return @@ -244,7 +251,9 @@ export function NativeChatInterruptedContinueButton({ type="button" size="xs" aria-describedby={explanationId} - onClick={(event) => onContinue(event.currentTarget === event.currentTarget.ownerDocument.activeElement)} + onClick={(event) => + onContinue(event.currentTarget === event.currentTarget.ownerDocument.activeElement) + } disabled={disabled} > diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx index c4139393cf8..9dd7f34e3f6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx @@ -47,9 +47,15 @@ afterAll(() => restoreViewport()) afterEach(cleanup) const continueUsageLimit = vi.fn() -const usageLimitView = { hostLabel: null, remoteHost: false, continueAvailable: false, - offeredTurnItemId: null, continueNow: vi.fn(), usageLimitTurnItemId: 'item-2', continueUsageLimit } - +const usageLimitView = { + hostLabel: null, + remoteHost: false, + continueAvailable: false, + offeredTurnItemId: null, + continueNow: vi.fn(), + usageLimitTurnItemId: 'item-2', + continueUsageLimit +} { const TURN_MS = 12_000 @@ -252,41 +258,80 @@ const usageLimitView = { hostLabel: null, remoteHost: false, continueAvailable: readPhase: 'ready' } - it.each(['claude', 'codex', 'grok'] as const)('keeps a collapsed %s quota failure and Continue visible', (agent) => { - continueUsageLimit.mockClear() - const items: AgentJournalRenderItem[] = [ - journalItem(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Start' }] }), - journalItem(2, { kind: 'turn', turnId: 'turn-1', userItemId: 'item-1', - state: 'completed', outcome: 'failure', startedAt: 1000, completedAt: 2000 }), - { ...journalItem(3, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Earlier work' }] }), - turnScope: { kind: 'turn', turnItemId: 'item-2' } }, - { ...journalItem(4, { kind: 'status', tone: 'warning', ...agentSessionFailureWords({ - kind: 'usageLimitReached', usageLimit: { window: 'session', resetsAt: Date.now() + 3600_000 }, - detail: { text: 'The provider refused this request.', audience: 'person' } - }, { surface: 'row' }) }), turnScope: { kind: 'turn', turnItemId: 'item-2' } } - ] - render( - - - - - - ) - expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Earlier work')).toBeNull() - expect(screen.getByText(/hit its session limit/)).toBeVisible() - expect(screen.getByText('Details')).toBeVisible() - const button = screen.getByRole('button', { name: 'Continue' }) - expect(button).toBeVisible() - fireEvent.click(button) - expect(continueUsageLimit).toHaveBeenCalledOnce() - }) + it.each(['claude', 'codex', 'grok'] as const)( + 'keeps a collapsed %s quota failure and Continue visible', + (agent) => { + continueUsageLimit.mockClear() + const items: AgentJournalRenderItem[] = [ + journalItem(1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Start' }] + }), + journalItem(2, { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'item-1', + state: 'completed', + outcome: 'failure', + startedAt: 1000, + completedAt: 2000 + }), + { + ...journalItem(3, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Earlier work' }] + }), + turnScope: { kind: 'turn', turnItemId: 'item-2' } + }, + { + ...journalItem(4, { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords( + { + kind: 'usageLimitReached', + usageLimit: { window: 'session', resetsAt: Date.now() + 3600_000 }, + detail: { text: 'The provider refused this request.', audience: 'person' } + }, + { surface: 'row' } + ) + }), + turnScope: { kind: 'turn', turnItemId: 'item-2' } + } + ] + render( + + + + + + ) + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.queryByText('Earlier work')).toBeNull() + expect(screen.getByText(/hit its session limit/)).toBeVisible() + expect(screen.getByText('Details')).toBeVisible() + const button = screen.getByRole('button', { name: 'Continue' }) + expect(button).toBeVisible() + fireEvent.click(button) + expect(continueUsageLimit).toHaveBeenCalledOnce() + } + ) // The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line // says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index bc8a661d75d..dc393e68429 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -459,11 +459,13 @@ describe('notice rows', () => { }) it('retains person-facing usage details without inventing a provider frame', () => { renderStatus({ - kind: 'status', tone: 'warning', + kind: 'status', + tone: 'warning', ...agentSessionFailureWords( agentSessionFailureFact('usageLimitReached', { detail: { audience: 'person', text: 'Your allowance resets tomorrow.' } - }), { surface: 'row' } + }), + { surface: 'row' } ) }) const disclosure = screen.getByText('Details').closest('details') diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index a0a9259ee47..015cdb6dc23 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -158,15 +158,17 @@ export function NativeChatNoticeRow({ sayAgentSessionFailureTranslated ) : failure && authSurface - ? agentSessionFailureSentence( - failure, - authSurface, - { agentName }, - sayAgentSessionFailureTranslated - ) - : block.text - const detail = failure?.detail?.audience === 'person' && failure.detail.text.trim() !== text.trim() - ? failure.detail.text : undefined + ? agentSessionFailureSentence( + failure, + authSurface, + { agentName }, + sayAgentSessionFailureTranslated + ) + : block.text + const detail = + failure?.detail?.audience === 'person' && failure.detail.text.trim() !== text.trim() + ? failure.detail.text + : undefined const tone = named ? 'notice' : look.kind === 'line' ? look.tone : null const Icon = tone === 'warning' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 5c1fb5f227e..91a0eaa10cf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -154,16 +154,9 @@ export function NativeChatStructuredSession( // already on screen: nothing in it can act, and its words say why once. const readFailedFinally = readFailure?.final === true const viewState = selectNativeChatViewState(session, { readRetries: !readFailedFinally }) - useNativeChatFontSize( - viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup, - rootRef - ) - const find = useNativeChatFind( - props.isVisible && props.isFocusedGroup, - rootRef, - composerRef, - submits.messageListRef - ) + const paneActive = props.isVisible && props.isFocusedGroup + useNativeChatFontSize(viewState.kind === 'ready' && paneActive, rootRef) + const find = useNativeChatFind(paneActive, rootRef, composerRef, submits.messageListRef) const appearanceStyle = useNativeChatStoreAppearanceStyle() const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( @@ -232,7 +225,8 @@ export function NativeChatStructuredSession( agent: props.agent, sessionId: props.sessionId, isWorking: controller.isWorking || hostHeld, - reportComposerError, focusComposer, + reportComposerError, + focusComposer, transport: structuredTransport }) diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 7d053d69b68..7ec41deb74a 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -47,7 +47,10 @@ const SENTENCES: Record string> = { sendOutcomeLost: () => translate('components.native-chat.writeNotice.sendOutcomeLost', COPY.sendOutcomeLost), continuationSuperseded: () => - translate('components.native-chat.writeNotice.continuationSuperseded', COPY.continuationSuperseded), + translate( + 'components.native-chat.writeNotice.continuationSuperseded', + COPY.continuationSuperseded + ), questionChanged: () => translate('components.native-chat.writeNotice.questionChanged', COPY.questionChanged), cancelNotSaved: () => diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts index 80b4fdae65c..e547ee2b401 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts @@ -137,11 +137,15 @@ describe('structured agent session message sender', () => { it('carries the options read at the send on its request, and nothing without them', async () => { const calls = deferredCalls() - const a = send('a', { options: { model: 'opus', effort: 'high' }, - usageLimitContinuationOfTurnItemId: 'limited-turn', callerKeepsText: true }) + const a = send('a', { + options: { model: 'opus', effort: 'high' }, + usageLimitContinuationOfTurnItemId: 'limited-turn', + callerKeepsText: true + }) await flush() expect(mocks.call.mock.calls[0][2]).toMatchObject({ - options: { model: 'opus', effort: 'high' }, usageLimitContinuationOfTurnItemId: 'limited-turn' + options: { model: 'opus', effort: 'high' }, + usageLimitContinuationOfTurnItemId: 'limited-turn' }) calls[0].resolve(okSubmission(a.clientMessageId, 'accepted')) await a.outcome diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts index 1206d01509d..9ecf4ddaf63 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -32,7 +32,8 @@ export type StructuredAgentSessionSendInput = { } export type StructuredAgentSessionSendRequest = Pick< - StructuredAgentSessionSendInput, 'callerKeepsText' | 'usageLimitContinuationOfTurnItemId' + StructuredAgentSessionSendInput, + 'callerKeepsText' | 'usageLimitContinuationOfTurnItemId' > & { onSettled?: (outcome: StructuredAgentSessionSendOutcome) => void } /** Takes the chat's one send slot, drawn as sending; null while another send of the chat is out. */