Files
orca/src/shared/structured-agent-session-latest-request.ts
T
Brennan Benson 1c411db2a5 fix(native-chat): a refused steer leaves its turn as the chat's latest request (#29907)
A steer Codex answered into a turn that then ended without taking it is
rejected and moved to the thread, so the sidebar verdict and the
completion feed treated it as a refused request of its own and reported
it in place of the turn it joined.
2026-10-17 13:12:26 -07:00

212 lines
8.5 KiB
TypeScript

// The session's latest request and what became of it: the verdict a sidebar row reports once
// the session is idle, whether there is a request to list at all, and the prompt and answer its row
// quotes.
//
// A request is either a turn, whose record carries the provider's verdict, or a send that never
// became one because the agent or its start refused it. A send its handover placed inside a
// running turn (a steer) is not a request of its own: the turn it joined answers for it. Nor is a
// conversation command.
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnLifecycle,
AgentJournalTurnLifecycleState,
AgentJournalTurnOutcome
} from './agent-session-journal-types'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
import { withoutNativeChatVisualDirectiveLines } from './native-chat-visual-directive'
import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch'
import {
isStructuredAgentSessionCommandEntry,
isStructuredAgentSessionCommandRow,
structuredAgentSessionCommandTurnItemIds
} from './structured-agent-session-command-entry'
import type { NativeChatBlock } from './native-chat-types'
export type StructuredAgentSessionLatestRequest = {
kind: 'turn' | 'refused-send'
/** The turn's id, or the refused send's journal item key. Unique only within its kind. */
id: string
/** The turn's lifecycle state: what the host observed of it. Null for a refused send. */
turnState: AgentJournalTurnLifecycleState | null
/** The provider's verdict. Null while the turn runs, and for a turn whose end carried none. */
outcome: AgentJournalTurnOutcome | null
/** When it settled: the turn's end, or the refusal. Undefined while it runs. */
settledAt: number | undefined
}
/** A root row that is no request of its own: a conversation command or a row its turn produced, or
* a send its handover placed inside a running turn (a steer), which that turn answers for. */
export function isStructuredAgentSessionNonRequestRow(
item: Pick<AgentJournalRenderItem, 'itemId' | 'body' | 'turnScope'>,
commandTurnItemIds: ReadonlySet<string>
): boolean {
return (
commandTurnItemIds.has(item.itemId) ||
isStructuredAgentSessionCommandRow(item, commandTurnItemIds) ||
(item.body.kind === 'message' && item.body.role === 'user' && item.turnScope?.kind === 'turn')
)
}
/** Null when the journal holds no request with a verdict to give. Accepted and unanswered sends
* are passed over — the session is working until their turn records — and so are sends that
* failed nobody (withdrawn, or left undelivered by a restart or a close). */
export function latestStructuredAgentSessionRequest(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[]
): StructuredAgentSessionLatestRequest | null {
const rejected = rejectedSubmissionsByItem(submissions)
const commandTurns = structuredAgentSessionCommandTurnItemIds(items)
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
// A conversation command is not a request: the verdict stays the last real request's.
if (
!item ||
!isRootAgentJournalItem(item) ||
isStructuredAgentSessionNonRequestRow(item, commandTurns)
) {
continue
}
const turn = readAgentJournalTurn(item.body)
if (turn) {
return {
kind: 'turn',
id: turn.turnId,
turnState: turn.state,
outcome: readAgentJournalTurnOutcome(turn),
settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn)
}
}
const submission = rejected.get(item.itemId)
// Rejection relocates a refused steer to the thread, but its turn remains the request.
if (
submission &&
submission.answeredInTurn?.via !== 'steer' &&
classifyDispatchRejection(submission).verdict === 'failure'
) {
return {
kind: 'refused-send',
id: item.itemId,
turnState: null,
outcome: 'failure',
settledAt: submission.resolvedAt ?? undefined
}
}
}
return null
}
/** Whether the session has a request to list. A send that failed nobody and never became a turn
* leaves nothing to report, so a session holding only those is not listed; a user message the
* provider journaled itself (history, an older host) still is. Deliberately NOT scoped by
* producer: a session whose only content came from a subagent still has content. */
export function hasStructuredAgentSessionRequest(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[],
currentFence?: number | null
): boolean {
const sent = new Set(
submissions.map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
)
return (
items.some(
(item) =>
readAgentJournalTurn(item.body) !== null ||
(item.body.kind === 'message' &&
(item.body.role === 'assistant' || (item.body.role === 'user' && !sent.has(item.itemId))))
) ||
submissions.some(
(submission) =>
submission.dispatchState === 'accepted' ||
isUnansweredStructuredAgentSessionDispatch(submission, currentFence) ||
(submission.dispatchState === 'rejected' &&
classifyDispatchRejection(submission).verdict === 'failure')
)
)
}
function rejectedSubmissionsByItem(
submissions: readonly AgentJournalSubmission[]
): Map<string, AgentJournalSubmission> {
const rejected = new Map<string, AgentJournalSubmission>()
for (const submission of submissions) {
if (submission.dispatchState === 'rejected') {
rejected.set(agentJournalSubmissionKey(submission.clientMessageId), submission)
}
}
return rejected
}
/** A turn recovery settled ended when that settle was written: when the user learns it stopped. */
function turnEndedAt(
item: AgentJournalRenderItem,
turn: AgentJournalTurnLifecycle
): number | undefined {
return item.recoveredAt ?? turn.completedAt
}
function messageProse(blocks: readonly NativeChatBlock[]): string {
return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
}
/** The newest prompt the session's own user turn carries, as the sidebar quotes
* it. Scoped to root rows for the same reason the assistant line is: a provider
* that journals a subagent's own prompt would otherwise requote it as the
* session's. */
export function latestStructuredAgentSessionPrompt(
items: readonly AgentJournalRenderItem[]
): string {
const body = latestStructuredAgentSessionUserItem(items)?.body
return body?.kind === 'message' ? messageProse(body.blocks) : ''
}
export function latestStructuredAgentSessionUserItem(
items: readonly AgentJournalRenderItem[]
): AgentJournalRenderItem | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
if (
item?.body.kind === 'message' &&
item.body.role === 'user' &&
isRootAgentJournalItem(item) &&
!isStructuredAgentSessionCommandEntry(item.body)
) {
return item
}
}
return null
}
/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a
* subagent's, whose rows share this journal and are usually the newer ones while
* a child runs. Tool-only assistant items are skipped; the user boundary clears
* prose from the preceding turn. */
export function latestStructuredAgentSessionAssistantMessage(
items: readonly AgentJournalRenderItem[]
): string {
const commandTurns = structuredAgentSessionCommandTurnItemIds(items)
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
const body = item?.body
// A command and what its turn produced are not the conversation's latest answer.
if (!isRootAgentJournalItem(item) || isStructuredAgentSessionCommandRow(item, commandTurns)) {
continue
}
if (body?.kind === 'message' && body.role === 'user') {
return ''
}
if (body?.kind === 'message' && body.role === 'assistant') {
// A visual line shows only in the transcript; every plain-text reader of this line drops it.
const prose = withoutNativeChatVisualDirectiveLines(messageProse(body.blocks))
if (prose.trim()) {
return prose
}
}
}
return ''
}