mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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:
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-18
@@ -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 />
|
||||
|
||||
+83
-38
@@ -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: () =>
|
||||
|
||||
+7
-3
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user