mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* feat(native-chat): visual directive grammar and host read for a chat's visuals folder
A shared grammar for the ::orca-visual{file="..." title="..."} reply line,
the per-chat visuals folder location on the owning host, and the
agentSession.readVisual runtime method that reads one visual with lexical and
canonical containment, a 512 KiB bounded read and UTF-8 refusal.
* feat(native-chat): render chat visuals inline and in the right sidebar
Native-chat assistant replies render a ::orca-visual{...} line as the chat's
HTML visual in an opaque, scripts-only sandboxed frame: CSP first, the host
frame navigation guard registered before content runs, live theme without a
reload, fitted height, links opened in the viewer's browser only from a real
gesture, lazy mount, and one muted line when the visual cannot be shown.
Open in sidebar shows the same frame in the right sidebar, widened while it
is open and restored after.
* fix(native-chat): visual CI fixes, shared height governor, live-turn streaming hold
Registers agentSession.readVisual from the methods index so the structured
method file stays under its line budget, replaces reflective reads with checked
narrowing, moves the pure height governor to src/shared for mobile, and holds a
half-written directive tail while the turn works (structured text rows carry no
running state).
* fix(native-chat): harden the visual read and link opening
Re-checks after the open that the chat's visuals folder is still the real
directory at Orca's path, reports unexpected filesystem faults by code without
host paths, and lets one click in a visual open at most one page.
* fix(native-chat): keep visual lines out of plain-text reply surfaces; review fixes
One shared helper drops visual lines (outside fenced code) from reply text where
it becomes plain text: the structured status summary that feeds the sidebar row,
dashboard, notifications, phone rows and handoffs, and AI Vault reply previews.
Review fixes: height also counts a pinned body's overflow, only the live
frontier row holds a half-written visual line, the runaway-height stop needs the
same step repeated, and any host refusal evicts the cached revision.
* fix(native-chat): resolve the visuals folder without the removed journal-paths helper
Main removed the per-chat journal paths and the journal database's state
directory; the visuals folder keeps the same sha256 layout on its own and the
read method uses the profile state directory the chat host is opened in.
* fix(native-chat): review round 2 fixes; copy a reply without its visual lines
Reply previews in Agent Session History drop visual lines per text part before
lines are folded; the frame adds a body's overflow only when the body really
overflows; fence tracking follows CommonMark closers and openers; the copy
button copies a reply without visual lines; a coded read fault keeps its cause.
* fix(native-chat): update the frame's theme ref after render; read the visuals folder pair at once
* test(native-chat): declare agentSession.readVisual on the cross-version agent-session surface
* fix(native-chat): copying a reply keeps its code blocks and indentation
Removing visual lines now closes only the gap each removal leaves, instead of
collapsing blank lines across the whole reply and trimming its indentation; the
visuals folder is checked parent first again so a broken path answers the same
way every time.
356 lines
11 KiB
TypeScript
356 lines
11 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'
|
|
|
|
const START_FAILURE = 'Claude is not signed in.'
|
|
|
|
/** `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, 't1')
|
|
],
|
|
submissions: [
|
|
sent('m1', { dispatchState: 'accepted' }),
|
|
rejected('steer', 'no active turn to steer', 30)
|
|
],
|
|
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.'
|
|
)
|
|
})
|
|
})
|