mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
387 lines
12 KiB
TypeScript
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.'
|
|
)
|
|
})
|
|
})
|