mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
* fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim <sim@local>
154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
// What a rejection puts on the user's screen.
|
|
//
|
|
// The module is pure so this can be asserted directly instead of through the hook,
|
|
// which is the whole reason it was split out.
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
|
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
|
import {
|
|
dispatchWriteFailureReason,
|
|
DISPATCH_REJECTED_CANCELLED,
|
|
DISPATCH_REJECTED_QUEUE_FULL
|
|
} from './structured-agent-session-dispatch-rejection'
|
|
import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition'
|
|
import {
|
|
createStructuredAgentSessionOutboxEntry,
|
|
reconcileStructuredAgentSessionOutbox,
|
|
type StructuredAgentSessionOutboxEntry
|
|
} from './structured-agent-session-outbox'
|
|
|
|
const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({
|
|
clientMessageId: 'client-1',
|
|
sessionId: 'session-1',
|
|
text: 'hello',
|
|
attachments: [],
|
|
queuedAt: 1
|
|
})
|
|
|
|
function rejectedWith(reason: string | null): AgentSessionMutationResult<AgentSessionSendResult> {
|
|
const submission: AgentJournalSubmission = {
|
|
clientMessageId: 'client-1',
|
|
fence: 1,
|
|
payloadFingerprint: 'fingerprint',
|
|
dispatchState: 'rejected',
|
|
providerItemId: null,
|
|
reason,
|
|
submittedAt: 10,
|
|
resolvedAt: 10
|
|
}
|
|
return {
|
|
ok: true,
|
|
replayed: false,
|
|
fence: 1,
|
|
cursor: { epoch: 'epoch-1', sequence: 10 },
|
|
value: { clientMessageId: 'client-1', submission }
|
|
} as AgentSessionMutationResult<AgentSessionSendResult>
|
|
}
|
|
|
|
function notice(reason: string | null): string | null {
|
|
return disposeStructuredAgentSessionSendResult({
|
|
entries: [entry],
|
|
entry,
|
|
blockedClientMessageId: null,
|
|
result: rejectedWith(reason),
|
|
createOperationId: () => 'unused'
|
|
}).error
|
|
}
|
|
|
|
describe('what a rejection shows the user', () => {
|
|
it('removes a queued message the provider confirms Stop cancelled', () => {
|
|
const result = rejectedWith(DISPATCH_REJECTED_CANCELLED)
|
|
if (!result.ok) {
|
|
throw new Error('expected rejected submission fixture')
|
|
}
|
|
|
|
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
|
|
})
|
|
|
|
it('never puts the transport marker on screen', () => {
|
|
const shown = notice(dispatchWriteFailureReason(new Error('broken pipe')))
|
|
// `provider_write_failed: broken pipe` names nothing a person can act on.
|
|
expect(shown).not.toContain('provider_write_failed')
|
|
expect(shown).not.toContain('broken pipe')
|
|
// And it says the message is safe to send again, which it is: the frame never left.
|
|
expect(shown).toBe(
|
|
"Couldn't reach the agent. Your message was not sent — Retry to send it again."
|
|
)
|
|
})
|
|
|
|
it('shows a content rejection in the provider own words', () => {
|
|
// The provider explaining itself IS the answer; a generic string throws it away.
|
|
expect(notice('Claude messages support at most 20 images')).toBe(
|
|
'Claude messages support at most 20 images'
|
|
)
|
|
})
|
|
|
|
it('claims no cause when the rejection names none', () => {
|
|
expect(notice(null)).toBe('Message was not sent.')
|
|
})
|
|
|
|
it('never puts a local-capacity marker on screen either', () => {
|
|
// Neither the provider's words nor a transport failure: a refusal we minted
|
|
// ourselves. It has no user-facing meaning, so it gets copy rather than the token.
|
|
const shown = notice(DISPATCH_REJECTED_QUEUE_FULL)
|
|
expect(shown).not.toContain('queue is full')
|
|
expect(shown).toBe('Orca could not send your message — Retry to send it again.')
|
|
})
|
|
})
|
|
|
|
describe('ambiguous operation refusals', () => {
|
|
it.each([
|
|
{ ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 },
|
|
{ ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 }
|
|
])('never rotates $state operation after its host tombstone expires', (ambiguous) => {
|
|
const disposition = disposeStructuredAgentSessionSendResult({
|
|
entries: [ambiguous],
|
|
entry: ambiguous,
|
|
blockedClientMessageId: null,
|
|
result: {
|
|
ok: false,
|
|
refusal: {
|
|
code: 'agent_session_operation_expired',
|
|
message: 'Operation expired.'
|
|
}
|
|
},
|
|
createOperationId: () => 'fresh-id'
|
|
})
|
|
|
|
expect(disposition.entries).toMatchObject([
|
|
{ clientMessageId: entry.clientMessageId, state: 'queued' }
|
|
])
|
|
expect(disposition.blockedClientMessageId).toBe(entry.clientMessageId)
|
|
})
|
|
|
|
it('parks a recovered missing submission without polling forever', () => {
|
|
const result = rejectedWith(null)
|
|
if (!result.ok) {
|
|
throw new Error('expected a send result')
|
|
}
|
|
result.value.submission = {
|
|
...result.value.submission,
|
|
dispatchState: 'unknown',
|
|
reason: 'durable_send_submission_missing',
|
|
recovered: true
|
|
}
|
|
|
|
const disposition = disposeStructuredAgentSessionSendResult({
|
|
entries: [entry],
|
|
entry,
|
|
blockedClientMessageId: null,
|
|
result,
|
|
createOperationId: () => 'unused'
|
|
})
|
|
|
|
expect(disposition.entries).toMatchObject([
|
|
{
|
|
clientMessageId: entry.clientMessageId,
|
|
state: 'unconfirmed',
|
|
retryAfterUnknownSubmittedAt: -1
|
|
}
|
|
])
|
|
})
|
|
})
|