mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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.
212 lines
8.5 KiB
TypeScript
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 ''
|
|
}
|