mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* feat(agent-launch): deliver a launch prompt from the host `agent.launch` created the surface and then reported the caller's text as `not-delivered`, always: delivery lived in the renderer, so mobile and any other caller got an agent and no prompt. The host now commits a `submit` prompt to the structured session it just created, through the same send path `agentSession.send` runs, and reports `journaled` with the transcript row's id. Nothing is queued — the durable record that the text is owed is the journal's own submission row, which the send appends before dispatching, so a host-side copy could only disagree with it. The outbox's entry and envelope builders are reused so this send is shaped exactly like a client's, fingerprint included. Everything else under-claims as `not-delivered`: a terminal's paste is observed by whoever owns the pane, a `draft` has no host-side home, and a refused or thrown send commits nothing. There is no fourth "maybe" arm — a caller holding one could neither resend nor drop the text — and dispatch doubt stays on the submission row where it already lives. * fix(agent-launch): recover committed prompt after send errors
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types'
|
|
import { agentSessionRefusalOperationState } from './agent-session-refusal-retry'
|
|
import type {
|
|
AgentSessionMutationEnvelope,
|
|
AgentSessionWireRefusalCode
|
|
} from './agent-session-wire'
|
|
import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation'
|
|
import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection'
|
|
|
|
export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed'
|
|
|
|
export type StructuredAgentSessionOutboxEntry = {
|
|
clientMessageId: string
|
|
sessionId: string
|
|
body: AgentJournalMessageItem
|
|
previewUris: string[]
|
|
state: StructuredAgentSessionOutboxState
|
|
queuedAt: number
|
|
lastAttemptAt: number | null
|
|
retryAfterUnknownSubmittedAt: number | null
|
|
source?: 'launch'
|
|
}
|
|
|
|
export type StructuredAgentSessionAttachment = {
|
|
path: string
|
|
previewUri: string
|
|
}
|
|
|
|
export function structuredAgentSessionSendBody(
|
|
text: string,
|
|
attachments: readonly StructuredAgentSessionAttachment[]
|
|
): AgentJournalMessageItem {
|
|
return {
|
|
kind: 'message',
|
|
role: 'user',
|
|
blocks: [
|
|
...(text.trim().length > 0 ? [{ type: 'text' as const, text: text.trimEnd() }] : []),
|
|
...attachments.map((attachment) => ({ type: 'image-ref' as const, path: attachment.path }))
|
|
]
|
|
}
|
|
}
|
|
|
|
export function createStructuredAgentSessionOutboxEntry(args: {
|
|
clientMessageId: string
|
|
sessionId: string
|
|
text: string
|
|
attachments: readonly StructuredAgentSessionAttachment[]
|
|
queuedAt: number
|
|
}): StructuredAgentSessionOutboxEntry {
|
|
return {
|
|
clientMessageId: args.clientMessageId,
|
|
sessionId: args.sessionId,
|
|
body: structuredAgentSessionSendBody(args.text, args.attachments),
|
|
previewUris: args.attachments.map((attachment) => attachment.previewUri),
|
|
state: 'queued',
|
|
queuedAt: args.queuedAt,
|
|
lastAttemptAt: null,
|
|
retryAfterUnknownSubmittedAt: null
|
|
}
|
|
}
|
|
|
|
export function updateStructuredAgentSessionOutboxEntry(
|
|
entries: readonly StructuredAgentSessionOutboxEntry[],
|
|
id: string,
|
|
update: (entry: StructuredAgentSessionOutboxEntry) => StructuredAgentSessionOutboxEntry | null
|
|
): StructuredAgentSessionOutboxEntry[] {
|
|
return entries.flatMap((entry) => {
|
|
if (entry.clientMessageId !== id) {
|
|
return [entry]
|
|
}
|
|
const next = update(entry)
|
|
return next ? [next] : []
|
|
})
|
|
}
|
|
|
|
export function requeueStructuredAgentSessionSendRefusal(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
code: AgentSessionWireRefusalCode,
|
|
createOperationId: () => string,
|
|
retainOperationId = false
|
|
): StructuredAgentSessionOutboxEntry {
|
|
const refusalState = agentSessionRefusalOperationState('agentSession.send', code)
|
|
if (
|
|
refusalState !== 'settled-rejected' ||
|
|
retainOperationId ||
|
|
entry.state === 'unconfirmed' ||
|
|
entry.retryAfterUnknownSubmittedAt !== null
|
|
) {
|
|
return { ...entry, state: 'queued' }
|
|
}
|
|
return {
|
|
...entry,
|
|
clientMessageId: createOperationId(),
|
|
state: 'queued',
|
|
lastAttemptAt: null,
|
|
retryAfterUnknownSubmittedAt: null
|
|
}
|
|
}
|
|
|
|
export function reconcileStructuredAgentSessionOutbox(
|
|
entries: readonly StructuredAgentSessionOutboxEntry[],
|
|
submissions: readonly AgentJournalSubmission[]
|
|
): StructuredAgentSessionOutboxEntry[] {
|
|
const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry]))
|
|
return entries.flatMap((entry) => {
|
|
const submission = settled.get(entry.clientMessageId)
|
|
if (submission?.dispatchState === 'accepted') {
|
|
return []
|
|
}
|
|
if (
|
|
submission?.dispatchState === 'rejected' &&
|
|
submission.reason === DISPATCH_REJECTED_CANCELLED
|
|
) {
|
|
return []
|
|
}
|
|
if (submission?.dispatchState === 'pending') {
|
|
return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }]
|
|
}
|
|
if (
|
|
submission?.dispatchState === 'unknown' &&
|
|
entry.retryAfterUnknownSubmittedAt !== -1 &&
|
|
entry.retryAfterUnknownSubmittedAt !== submission.submittedAt
|
|
) {
|
|
return [{ ...entry, state: 'unconfirmed' as const }]
|
|
}
|
|
return [entry]
|
|
})
|
|
}
|
|
|
|
export type StructuredAgentSessionOutboxAdmission =
|
|
| { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry }
|
|
| { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry }
|
|
| { state: 'idle'; entry: null }
|
|
|
|
/**
|
|
* What the queue does next. The drain and the Retry affordance both read it, so neither can
|
|
* disagree with the other about which entry is holding the queue.
|
|
*
|
|
* A `dispatching` entry is not a barrier: the host appended its journal row inside the
|
|
* per-session serialize chain before dispatching, so nothing behind it can overtake it, and
|
|
* waiting for its echo costs delivery of everything queued behind it. An `unconfirmed` entry,
|
|
* or one the user must act on, is a barrier — sending past either would reorder around a
|
|
* message that may yet land.
|
|
*/
|
|
export function admitStructuredAgentSessionOutboxEntry(
|
|
entries: readonly StructuredAgentSessionOutboxEntry[],
|
|
blockedClientMessageId: string | null
|
|
): StructuredAgentSessionOutboxAdmission {
|
|
for (const entry of entries) {
|
|
if (entry.state === 'unconfirmed' || entry.clientMessageId === blockedClientMessageId) {
|
|
return { state: 'blocked', entry }
|
|
}
|
|
if (entry.state === 'queued') {
|
|
return { state: 'dispatch', entry }
|
|
}
|
|
}
|
|
return { state: 'idle', entry: null }
|
|
}
|
|
|
|
export function parseStructuredAgentSessionOutboxEntry(
|
|
value: unknown,
|
|
sessionId: string
|
|
): StructuredAgentSessionOutboxEntry | null {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return null
|
|
}
|
|
const entry = value as Partial<StructuredAgentSessionOutboxEntry>
|
|
const body = entry.body
|
|
if (
|
|
entry.sessionId !== sessionId ||
|
|
typeof entry.clientMessageId !== 'string' ||
|
|
typeof entry.queuedAt !== 'number' ||
|
|
!body ||
|
|
body.kind !== 'message' ||
|
|
body.role !== 'user' ||
|
|
!Array.isArray(body.blocks) ||
|
|
!Array.isArray(entry.previewUris) ||
|
|
!entry.previewUris.every((uri) => typeof uri === 'string') ||
|
|
!['queued', 'dispatching', 'unconfirmed'].includes(entry.state ?? '')
|
|
) {
|
|
return null
|
|
}
|
|
return {
|
|
clientMessageId: entry.clientMessageId,
|
|
sessionId,
|
|
body,
|
|
previewUris: entry.previewUris,
|
|
state: entry.state as StructuredAgentSessionOutboxState,
|
|
queuedAt: entry.queuedAt,
|
|
lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null,
|
|
retryAfterUnknownSubmittedAt:
|
|
typeof entry.retryAfterUnknownSubmittedAt === 'number'
|
|
? entry.retryAfterUnknownSubmittedAt
|
|
: null,
|
|
...(entry.source === 'launch' ? { source: 'launch' as const } : {})
|
|
}
|
|
}
|
|
|
|
export type StructuredAgentSessionSendMutation = {
|
|
envelope: AgentSessionMutationEnvelope
|
|
body: AgentJournalMessageItem
|
|
}
|
|
|
|
/** The `agentSession.send` arguments an entry stands for. Typed rather than wire-shaped so a host
|
|
* calling its own send path builds the same envelope a client would, fingerprint included. */
|
|
export function structuredAgentSessionSendMutation(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
expectedRuntimeFence: number
|
|
): StructuredAgentSessionSendMutation {
|
|
const fields = { body: entry.body }
|
|
return {
|
|
envelope: {
|
|
sessionId: entry.sessionId,
|
|
clientOperationId: entry.clientMessageId,
|
|
expectedRuntimeFence,
|
|
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.send',
|
|
sessionId: entry.sessionId,
|
|
fields
|
|
})
|
|
},
|
|
...fields
|
|
}
|
|
}
|
|
|
|
export function structuredAgentSessionSendRequest(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
expectedRuntimeFence: number
|
|
): Record<string, unknown> {
|
|
return structuredAgentSessionSendMutation(entry, expectedRuntimeFence)
|
|
}
|
|
|
|
export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed'
|
|
|
|
export function classifyStructuredAgentSessionSendFailure(
|
|
error: unknown,
|
|
isDeliveryUnknown: (error: unknown) => boolean
|
|
): StructuredAgentSessionSendFailure {
|
|
return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed'
|
|
}
|