Files
orca/src/shared/structured-agent-session-latest-request.test.ts
T

387 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnLifecycle
} from './agent-session-journal-types'
import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import {
classifyDispatchRejection,
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_CODEX_QUEUE_FULL,
DISPATCH_REJECTED_HOST_RESTARTED,
DISPATCH_REJECTED_NOT_DELIVERED,
DISPATCH_REJECTED_PROVIDER_CLOSED,
DISPATCH_REJECTED_QUEUE_FULL,
DISPATCH_REJECTED_WRITE_FAILED
} from './structured-agent-session-dispatch-rejection'
import {
hasStructuredAgentSessionRequest,
latestStructuredAgentSessionRequest
} from './structured-agent-session-latest-request'
import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection'
import { latestNativeChatTurnSinceRequest } from './native-chat/native-chat-orca-stop-cut'
import { selectStructuredAgentTurnTimings } from './structured-agent-session-turn-timing'
const START_FAILURE = 'Claude is not signed in.'
it('keeps unresolved human input as a latest-request uncertainty boundary without a lifecycle', () => {
const prior = turn('prior', 1, {
state: 'interrupted',
startedAt: 10,
completedAt: 20,
outcome: 'cancellation'
})
const unknown: AgentJournalRenderItem = {
...userEntry('outside', 2),
turnScope: { kind: 'unknown' }
}
const items = [prior, unknown]
expect(latestStructuredAgentSessionRequest([prior], [])?.outcome).toBe('cancellation')
expect(latestStructuredAgentSessionRequest(items, [])).toBeNull()
expect(latestNativeChatTurnSinceRequest(items, [])).toBeNull()
expect(hasStructuredAgentSessionRequest([unknown], [])).toBe(true)
expect(selectStructuredAgentTurnTimings(items)).toEqual(selectStructuredAgentTurnTimings([prior]))
const next = turn('next', 3, { state: 'running', startedAt: 30 })
expect(latestStructuredAgentSessionRequest([...items, next], [])).toMatchObject({
id: 'next',
turnState: 'running',
outcome: null
})
})
/** `intoTurn` names the turn the message's handover delivered it into — a steer. */
function userEntry(
clientMessageId: string,
sequence: number,
intoTurn?: string
): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(clientMessageId),
revision: 0,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] },
turnScope: intoTurn
? { kind: 'turn', turnItemId: `codex:turn:${intoTurn}` }
: { kind: 'thread' }
}
}
function turn(
turnId: string,
sequence: number,
fields: Omit<AgentJournalTurnLifecycle, 'turnId'>
): AgentJournalRenderItem {
return {
itemId: `codex:turn:${turnId}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'turn', turnId, ...fields }
}
}
function sent(
clientMessageId: string,
fields: Partial<AgentJournalSubmission> & Pick<AgentJournalSubmission, 'dispatchState'>
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
providerItemId: null,
reason: null,
submittedAt: 10,
resolvedAt: 20,
handoverRecorded: true,
...fields
}
}
const rejected = (clientMessageId: string, reason: string, handedOverAt?: number) =>
sent(clientMessageId, {
dispatchState: 'rejected',
reason,
...(handedOverAt !== undefined ? { handedOverAt } : {})
})
type Row = {
name: string
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
fence?: number
outcome: 'success' | 'failure' | 'cancellation' | null
listed: boolean
}
const ROWS: Row[] = [
{
name: 'a send the agent start refused',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', START_FAILURE)],
outcome: 'failure',
listed: true
},
{
name: 'a send the user withdrew (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_CANCELLED)],
outcome: null,
listed: false
},
{
name: 'a send still queued when the host restarted (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_HOST_RESTARTED)],
outcome: null,
listed: false
},
{
name: 'a send still queued when the provider closed (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_PROVIDER_CLOSED)],
outcome: null,
listed: false
},
{
name: 'a send crash recovery found the provider never received (no verdict)',
items: [userEntry('m1', 1)],
submissions: [
sent('m1', {
dispatchState: 'rejected',
reason: DISPATCH_REJECTED_NOT_DELIVERED,
recovered: true
})
],
outcome: null,
listed: false
},
{
name: 'a turn that succeeded, then a send crash recovery found the provider never received',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
],
submissions: [
sent('m1', { dispatchState: 'accepted' }),
sent('m2', {
dispatchState: 'rejected',
reason: DISPATCH_REJECTED_NOT_DELIVERED,
recovered: true
})
],
outcome: 'success',
listed: true
},
{
name: 'a send crash recovery left unknown',
items: [userEntry('m1', 1)],
submissions: [sent('m1', { dispatchState: 'unknown', recovered: true })],
outcome: null,
listed: false
},
{
name: 'a send pending at an older fence',
items: [userEntry('m1', 1)],
submissions: [sent('m1', { dispatchState: 'pending', handedOverAt: 11, resolvedAt: null })],
fence: 2,
outcome: null,
listed: false
},
{
name: 'a turn that succeeded, then a send the start refused',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
],
submissions: [sent('m1', { dispatchState: 'accepted' }), rejected('m2', START_FAILURE)],
outcome: 'failure',
listed: true
},
{
name: 'a refused send, then a turn that succeeded',
items: [
userEntry('m1', 1),
userEntry('m2', 2),
turn('t2', 3, { state: 'completed', outcome: 'success', completedAt: 40 })
],
submissions: [rejected('m1', START_FAILURE), sent('m2', { dispatchState: 'accepted' })],
outcome: 'success',
listed: true
},
{
name: 'a steer the provider refused inside a turn that then succeeded',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 50 }),
userEntry('steer', 3)
],
submissions: [
sent('m1', { dispatchState: 'accepted' }),
sent('steer', {
dispatchState: 'rejected',
reason: 'no active turn to steer',
answeredInTurn: { turnItemId: 'codex:turn:t1', via: 'steer' }
})
],
outcome: 'success',
listed: true
},
{
name: 'a user message the provider journaled itself (history, an older host)',
items: [
{
itemId: 'codex:thread-1:turn-1:0',
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from history' }] }
}
],
submissions: [],
outcome: null,
listed: true
},
{
name: 'a goal and nothing else yet',
items: [
{
itemId: 'codex:goal-entry',
revision: 0,
sequence: 1,
observedAt: 1,
body: {
kind: 'message',
role: 'user',
sentAs: 'goal',
blocks: [{ type: 'text', text: 'ship it' }]
}
}
],
submissions: [],
outcome: null,
listed: true
}
]
describe('the latest request and its verdict', () => {
it.each(ROWS)('$name', ({ items, submissions, fence, outcome, listed }) => {
expect(latestStructuredAgentSessionRequest(items, submissions)?.outcome ?? null).toBe(outcome)
const summary = projectStructuredAgentSessionStatusSummary(items, submissions, fence)
expect(summary.status !== null).toBe(listed)
if (listed && summary.status === 'idle') {
expect(summary.turnOutcome ?? null).toBe(outcome)
}
})
it('dates an idle session by the refusal it reports, not by the older turn', () => {
const items = [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
]
const submissions = [sent('m1', { dispatchState: 'accepted' }), rejected('m2', START_FAILURE)]
expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({
status: 'idle',
turnOutcome: 'failure',
statusStartedAt: 20
})
})
})
describe('the sidebar verdict agrees with the rejection classifier', () => {
// A sentence as the reason, so only the typed fact can make a kind read as no verdict.
const typed = AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind).map((kind) => ({
name: `typed ${kind}`,
submission: sent('m1', {
dispatchState: 'rejected',
reason: 'The message was not sent.',
rejection: { kind }
})
}))
const legacy = [
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_QUEUE_FULL,
DISPATCH_REJECTED_CODEX_QUEUE_FULL,
DISPATCH_REJECTED_HOST_RESTARTED,
DISPATCH_REJECTED_PROVIDER_CLOSED,
DISPATCH_REJECTED_NOT_DELIVERED,
DISPATCH_REJECTED_WRITE_FAILED,
`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`
].map((reason) => ({ name: `legacy ${reason}`, submission: rejected('m1', reason) }))
const unknownKind = {
name: 'a kind this build cannot place',
submission: sent('m1', {
dispatchState: 'rejected',
reason: 'A newer host wrote this.',
rejection: { kind: 'someFutureKind' }
})
}
it.each([...typed, ...legacy, unknownKind])('$name', ({ submission }) => {
const items = [userEntry('m1', 1)]
const { verdict } = classifyDispatchRejection(submission)
expect(latestStructuredAgentSessionRequest(items, [submission])?.outcome ?? null).toBe(verdict)
expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure')
})
const failedNobody = new Set([
'typed notDelivered',
'typed cancelled',
'typed hostRestarted',
'typed chatClosed',
`legacy ${DISPATCH_REJECTED_NOT_DELIVERED}`,
`legacy ${DISPATCH_REJECTED_CANCELLED}`,
`legacy ${DISPATCH_REJECTED_HOST_RESTARTED}`,
`legacy ${DISPATCH_REJECTED_PROVIDER_CLOSED}`
])
const failedNobodyCases = [...typed, ...legacy].filter(({ name }) => failedNobody.has(name))
it('covers every send that failed nobody', () => {
expect(failedNobodyCases).toHaveLength(failedNobody.size)
})
it.each(failedNobodyCases)(
'gives no verdict for a send that failed nobody: $name',
({ submission }) => {
expect(latestStructuredAgentSessionRequest([userEntry('m1', 1)], [submission])).toBeNull()
}
)
})
describe('the assistant line plain-text surfaces show', () => {
function message(
itemId: string,
sequence: number,
role: 'user' | 'assistant',
text: string
): AgentJournalRenderItem {
return {
itemId,
revision: 0,
sequence,
observedAt: sequence,
body: { kind: 'message', role, blocks: [{ type: 'text', text }] }
}
}
it('keeps a visual line out of the preview every status reader shows', () => {
const items = [
message('ask', 1, 'user', 'chart it'),
message(
'said',
2,
'assistant',
'p95 is highest in ap-south.\n\n::orca-visual{file="latency.html" title="p95"}'
),
message('only-visual', 3, 'assistant', '::orca-visual{file="table.html"}')
]
// A reply that is nothing but a visual leaves the turn's earlier prose as the preview.
expect(projectStructuredAgentSessionStatusSummary(items).lastAssistantMessage).toBe(
'p95 is highest in ap-south.'
)
})
})