chore(format): format the files main's new formatting check rejects (#30929)

#30863 made the static job fail on unformatted files; 22 files that landed
around it were never run through oxfmt. Formatter output only, plus one
shared paneActive local in NativeChatStructuredSession.tsx so formatting
does not push that file past max-lines.

Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-19 19:29:10 -04:00
committed by GitHub
co-authored by E2E Test
parent ad5ef0d944
commit 3f9e4ecc1b
22 changed files with 852 additions and 393 deletions
@@ -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',
@@ -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<string, AgentJournalRenderItem>()
@@ -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[] = []
+21 -8
View File
@@ -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
+7 -7
View File
@@ -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 })
}
+12 -6
View File
@@ -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()
+55 -24
View File
@@ -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
}
@@ -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', '<synthetic>', `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', '<synthetic>', `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', () => {
+264 -118
View File
@@ -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<typeof setup>): 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<string, unknown>) => connection.handlers.onMessage?.({
parent_tool_use_id: null, ...message, session_id: PROVIDER_SESSION_ID
const emit = (message: Record<string, unknown>) =>
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')
+2 -7
View File
@@ -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
@@ -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
+16 -6
View File
@@ -67,18 +67,28 @@ describe('a Codex usage limit', () => {
const limits = new CodexUsageLimits()
const observe = (rateLimits: Record<string, unknown>) =>
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()
})
})
+28 -8
View File
@@ -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<string, unknown> | 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
@@ -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<string, string>) {
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()
}
})
})
@@ -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<string | null>(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 (
<TooltipProvider delayDuration={0}>
@@ -131,8 +156,10 @@ function Harness(props: Props): React.JSX.Element {
compose
</button>
{continuation.view.usageLimitTurnItemId === null ? null : (
<NativeChatInterruptedContinueButton onContinue={continuation.view.continueUsageLimit}
disabled={continuation.view.usageLimitContinueDisabled} />
<NativeChatInterruptedContinueButton
onContinue={continuation.view.continueUsageLimit}
disabled={continuation.view.usageLimitContinueDisabled}
/>
)}
{/* 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 <><span>{uploading.pendingName}</span><Harness journalItems={items} transport={transport} pending={sends.pending} /></>
return (
<>
<span>{uploading.pendingName}</span>
<Harness journalItems={items} transport={transport} pending={sends.pending} />
</>
)
}
let answer = Promise.withResolvers<unknown>()
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)
@@ -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}
>
<Play />
@@ -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(
<TooltipProvider>
<NativeChatOrcaStopContext.Provider value={usageLimitView}>
<NativeChatMessageList
session={{ ...session, agent, status: 'ready', messages: projectStructuredAgentSessionMessages(items, [], []) }}
journalItems={items}
settledTurns={selectStructuredAgentSettledTurns(items)}
isWorking={false}
expandSignal={false}
/>
</NativeChatOrcaStopContext.Provider>
</TooltipProvider>
)
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(
<TooltipProvider>
<NativeChatOrcaStopContext.Provider value={usageLimitView}>
<NativeChatMessageList
session={{
...session,
agent,
status: 'ready',
messages: projectStructuredAgentSessionMessages(items, [], [])
}}
journalItems={items}
settledTurns={selectStructuredAgentSettledTurns(items)}
isWorking={false}
expandSignal={false}
/>
</NativeChatOrcaStopContext.Provider>
</TooltipProvider>
)
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.
@@ -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')
@@ -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'
@@ -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
})
@@ -47,7 +47,10 @@ const SENTENCES: Record<AgentSessionWriteNoticeSentence, () => 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: () =>
@@ -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
@@ -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. */