mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
878 lines
30 KiB
TypeScript
878 lines
30 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
|
||
import { agentSessionFailureWords } from './agent-session-failure-words'
|
||
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||
import { AgentJournalRenderItemSchema } from './agent-session-journal-schemas'
|
||
import { parsePaneKey } from './stable-pane-id'
|
||
import {
|
||
activeStructuredAgentSessionTurnId,
|
||
hasUnansweredStructuredAgentSessionDispatch,
|
||
projectStructuredItemToNativeChat,
|
||
projectStructuredItemsToNativeChat,
|
||
projectStructuredAgentSessionStatus,
|
||
projectStructuredAgentSessionStatusState,
|
||
projectStructuredAgentSessionStatusSummary,
|
||
structuredAgentSessionPaneKey
|
||
} from './structured-agent-session-projection'
|
||
import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn'
|
||
import { latestStructuredAgentSessionAssistantMessage } from './structured-agent-session-latest-request'
|
||
|
||
function item(
|
||
itemId: string,
|
||
sequence: number,
|
||
body: AgentJournalRenderItem['body']
|
||
): AgentJournalRenderItem {
|
||
return { itemId, sequence, revision: 1, observedAt: sequence, body }
|
||
}
|
||
|
||
function submission(
|
||
clientMessageId: string,
|
||
dispatchState: AgentJournalSubmission['dispatchState']
|
||
): AgentJournalSubmission {
|
||
return {
|
||
clientMessageId,
|
||
fence: 1,
|
||
payloadFingerprint: clientMessageId,
|
||
dispatchState,
|
||
providerItemId: null,
|
||
reason: null,
|
||
submittedAt: 1,
|
||
resolvedAt: dispatchState === 'pending' ? null : 2
|
||
}
|
||
}
|
||
|
||
describe('structured agent session status projection', () => {
|
||
it('reuses immutable item projections and refreshes revisions and resolved prompts', () => {
|
||
const original = item('diff', 1, {
|
||
kind: 'diff',
|
||
path: 'a.ts',
|
||
patch: {
|
||
head: '@@\n+first',
|
||
digest: 'one',
|
||
byteLength: 10,
|
||
truncated: false
|
||
}
|
||
})
|
||
const first = projectStructuredItemToNativeChat(original)
|
||
expect(projectStructuredItemToNativeChat(original)).toBe(first)
|
||
const revised = {
|
||
...original,
|
||
revision: 2,
|
||
observedAt: 2000,
|
||
body: {
|
||
kind: 'diff' as const,
|
||
path: 'a.ts',
|
||
patch: {
|
||
head: '@@\n+second',
|
||
digest: 'two',
|
||
byteLength: 11,
|
||
truncated: false
|
||
}
|
||
}
|
||
}
|
||
const second = projectStructuredItemToNativeChat(revised)
|
||
expect(second).not.toBe(first)
|
||
expect(second).toMatchObject({
|
||
timestamp: 2000,
|
||
blocks: [{ type: 'tool-call' }, { type: 'tool-result', output: '@@\n+second' }]
|
||
})
|
||
expect(second?.blocks).toEqual([
|
||
{ type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, callId: 'diff' },
|
||
{ type: 'tool-result', output: '@@\n+second', callId: 'diff' }
|
||
])
|
||
const pending = item('approval', 2, {
|
||
kind: 'approval',
|
||
title: 'Allow?',
|
||
detail: null,
|
||
options: [],
|
||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||
})
|
||
expect(projectStructuredItemToNativeChat(pending)).toBeNull()
|
||
if (pending.body.kind !== 'approval') {
|
||
throw new Error('fixture')
|
||
}
|
||
const resolved = {
|
||
...pending,
|
||
revision: 2,
|
||
body: {
|
||
...pending.body,
|
||
resolution: { ...pending.body.resolution, state: 'resolved' as const }
|
||
}
|
||
}
|
||
expect(projectStructuredItemToNativeChat(resolved)).toMatchObject({
|
||
id: 'approval',
|
||
role: 'system'
|
||
})
|
||
})
|
||
|
||
it("forwards a status row's failure fact and drops one this build cannot place", () => {
|
||
const words = agentSessionFailureWords(
|
||
{
|
||
kind: 'providerExited',
|
||
detail: { text: 'stderr tail', audience: 'log' }
|
||
},
|
||
{ surface: 'row' }
|
||
)
|
||
expect(
|
||
projectStructuredItemToNativeChat(item('exit', 1, { kind: 'status', ...words }))?.blocks[0]
|
||
).toEqual({ type: 'text', ...words })
|
||
const future = item('future', 2, { kind: 'status', text: 'Stopped.' })
|
||
// A newer host's kind reads as no fact, so the row keeps its text and nothing else.
|
||
Object.assign(future.body, { failure: { kind: 'futureKind' } })
|
||
expect(projectStructuredItemToNativeChat(future)?.blocks[0]).toEqual({
|
||
type: 'text',
|
||
text: 'Stopped.'
|
||
})
|
||
})
|
||
|
||
it('names the call a tool row output answers, so a run pairs it by id', () => {
|
||
const projected = projectStructuredItemToNativeChat(
|
||
item('wait', 1, {
|
||
kind: 'tool-call',
|
||
name: 'wait_agent',
|
||
callId: 'call-wait',
|
||
input: null,
|
||
state: 'completed',
|
||
output: { head: 'CHILD_REPLY', digest: 'd', byteLength: 11, truncated: false }
|
||
})
|
||
)
|
||
expect(projected?.blocks).toEqual([
|
||
expect.objectContaining({ type: 'tool-call', callId: 'call-wait' }),
|
||
{ type: 'tool-result', output: 'CHILD_REPLY', isError: false, callId: 'call-wait' }
|
||
])
|
||
})
|
||
|
||
it('carries a cut-short call through the schema, and its partial output is not an error', () => {
|
||
const body = {
|
||
kind: 'tool-call' as const,
|
||
name: 'shell',
|
||
callId: 'call-sleep',
|
||
input: { command: 'sleep 20' },
|
||
state: 'failed' as const,
|
||
endedAs: 'interrupted' as const,
|
||
output: { head: 'partial', digest: 'd', byteLength: 7, truncated: false }
|
||
}
|
||
const parsed = AgentJournalRenderItemSchema.parse(item('sleep', 1, body))
|
||
expect(parsed.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' })
|
||
expect(projectStructuredItemToNativeChat(item('sleep', 1, body))?.blocks).toEqual([
|
||
expect.objectContaining({ type: 'tool-call', state: 'failed', endedAs: 'interrupted' }),
|
||
{ type: 'tool-result', output: 'partial', isError: false, callId: 'call-sleep' }
|
||
])
|
||
})
|
||
|
||
it('projects running, attention, and completed lifecycle states', () => {
|
||
const running = item('running', 1, {
|
||
kind: 'status',
|
||
text: 'Working',
|
||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||
})
|
||
const prompt = item('prompt', 2, {
|
||
kind: 'approval',
|
||
title: 'Run command?',
|
||
detail: null,
|
||
options: [{ id: 'yes', label: 'Allow' }],
|
||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||
})
|
||
const completed = item('completed', 3, {
|
||
kind: 'status',
|
||
text: 'Done',
|
||
turnLifecycle: { turnId: 'turn-1', state: 'completed' }
|
||
})
|
||
|
||
expect(activeStructuredAgentSessionTurnId([running])).toBe('turn-1')
|
||
expect(projectStructuredAgentSessionStatus([running])).toBe('working')
|
||
expect(projectStructuredAgentSessionStatus([running, prompt])).toBe('attention')
|
||
expect(activeStructuredAgentSessionTurnId([running, completed])).toBeNull()
|
||
expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle')
|
||
})
|
||
|
||
it('summarizes status with the newest user prompt, and null before any request', () => {
|
||
const running = item('running', 3, {
|
||
kind: 'status',
|
||
text: 'Working',
|
||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||
})
|
||
const first = item('first', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'first' }]
|
||
})
|
||
const second = item('second', 2, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [
|
||
{ type: 'text', text: 'second' },
|
||
{ type: 'text', text: 'line' }
|
||
]
|
||
})
|
||
|
||
expect(projectStructuredAgentSessionStatusSummary([])).toEqual({
|
||
status: null,
|
||
latestPrompt: ''
|
||
})
|
||
// A turn the provider opened on its own is a request, with no prompt to quote.
|
||
expect(projectStructuredAgentSessionStatusSummary([running])).toEqual({
|
||
status: 'working',
|
||
latestPrompt: '',
|
||
statusStartedAt: 3
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([first, second, running])).toEqual({
|
||
status: 'working',
|
||
latestPrompt: 'second line',
|
||
statusStartedAt: 3
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([first, second])).toEqual({
|
||
status: 'idle',
|
||
latestPrompt: 'second line'
|
||
})
|
||
})
|
||
|
||
it('reads a session as working while a dispatch is unanswered, before any lifecycle row', () => {
|
||
const asked = item('asked', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const pending = [submission('m1', 'pending')]
|
||
|
||
expect(hasUnansweredStructuredAgentSessionDispatch(pending)).toBe(true)
|
||
expect(projectStructuredAgentSessionStatus([asked], pending)).toBe('working')
|
||
// The first send has no journalled message until the provider replays it.
|
||
expect(projectStructuredAgentSessionStatusSummary([], pending)).toEqual({
|
||
status: 'working',
|
||
latestPrompt: '',
|
||
statusStartedAt: 1
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([asked], pending)).toEqual({
|
||
status: 'working',
|
||
latestPrompt: 'go',
|
||
statusStartedAt: 1
|
||
})
|
||
})
|
||
|
||
it('does not resurrect old-host unknown work after its execution fence advances', () => {
|
||
const oldHostSubmission = { ...submission('m1', 'unknown'), fence: 2 }
|
||
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 2)).toBe(true)
|
||
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 3)).toBe(false)
|
||
})
|
||
|
||
it('recognizes recovery from an older host without the optional marker', () => {
|
||
expect(
|
||
hasUnansweredStructuredAgentSessionDispatch([
|
||
{ ...submission('m1', 'unknown'), reason: 'host_restarted_before_acknowledgement' }
|
||
])
|
||
).toBe(false)
|
||
})
|
||
|
||
it('stops reading a resolved dispatch as work, and lets a pending prompt outrank it', () => {
|
||
const asked = item('asked', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const prompt = item('prompt', 2, {
|
||
kind: 'approval',
|
||
title: 'Run command?',
|
||
detail: null,
|
||
options: [{ id: 'yes', label: 'Allow' }],
|
||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||
})
|
||
|
||
for (const state of ['accepted', 'rejected'] as const) {
|
||
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', state)])).toBe(false)
|
||
expect(projectStructuredAgentSessionStatus([asked], [submission('m1', state)])).toBe('idle')
|
||
}
|
||
// The ack budget elapsing is a delivery answer, not an answer about the turn.
|
||
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', 'unknown')])).toBe(true)
|
||
expect(
|
||
hasUnansweredStructuredAgentSessionDispatch([
|
||
{ ...submission('m1', 'unknown'), recovered: true }
|
||
])
|
||
).toBe(false)
|
||
expect(
|
||
projectStructuredAgentSessionStatus([asked, prompt], [submission('m1', 'pending')])
|
||
).toBe('attention')
|
||
expect(projectStructuredAgentSessionStatusSummary([], [])).toEqual({
|
||
status: null,
|
||
latestPrompt: ''
|
||
})
|
||
})
|
||
|
||
it('still reports owed work beneath a pending prompt, which the attention status hides', () => {
|
||
const asked = item('asked', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const prompt = item('prompt', 3, {
|
||
kind: 'approval',
|
||
title: 'Run command?',
|
||
detail: null,
|
||
options: [{ id: 'yes', label: 'Allow' }],
|
||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||
})
|
||
const running = item('turn', 2, { kind: 'turn', turnId: 't1', state: 'running' })
|
||
const settled = item('turn', 2, {
|
||
kind: 'turn',
|
||
turnId: 't1',
|
||
state: 'completed',
|
||
outcome: 'success'
|
||
})
|
||
const accepted = [submission('m1', 'accepted')]
|
||
const owes = (items: AgentJournalRenderItem[], submissions = accepted) => {
|
||
const state = projectStructuredAgentSessionStatusState(items, submissions)
|
||
return [state.summary.status, state.owesWork]
|
||
}
|
||
|
||
expect(owes([asked, running, prompt])).toEqual(['attention', true])
|
||
expect(owes([asked, settled, prompt])).toEqual(['attention', false])
|
||
expect(owes([asked, settled, prompt], [...accepted, submission('m2', 'pending')])).toEqual([
|
||
'attention',
|
||
true
|
||
])
|
||
expect(owes([asked, running])).toEqual(['working', true])
|
||
expect(owes([asked, settled])).toEqual(['idle', false])
|
||
})
|
||
|
||
it('carries the running tool and the newest assistant prose the sidebar row shows', () => {
|
||
const ask = item('ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'look at the sidebar' }]
|
||
})
|
||
const running = item('running', 2, {
|
||
kind: 'status',
|
||
text: 'Working',
|
||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||
})
|
||
const said = item('said', 3, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'Reading the card first.' }]
|
||
})
|
||
const tool = item('tool', 4, {
|
||
kind: 'tool-call',
|
||
name: 'Read',
|
||
input: { file_path: '/repo/src/WorktreeCard.tsx' },
|
||
state: 'running'
|
||
})
|
||
|
||
expect(projectStructuredAgentSessionStatusSummary([ask, running, said, tool])).toEqual({
|
||
status: 'working',
|
||
latestPrompt: 'look at the sidebar',
|
||
toolName: 'Read',
|
||
toolInput: '/repo/src/WorktreeCard.tsx',
|
||
lastAssistantMessage: 'Reading the card first.',
|
||
statusStartedAt: 2
|
||
})
|
||
})
|
||
|
||
it('clears the previous answer as soon as the next prompt is persisted', () => {
|
||
const firstAsk = item('first-ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'first task' }]
|
||
})
|
||
const previousAnswer = item('previous-answer', 2, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'The first task is done.' }]
|
||
})
|
||
const nextAsk = item('next-ask', 3, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'second task' }]
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([firstAsk, previousAnswer, nextAsk])).toEqual(
|
||
{
|
||
status: 'idle',
|
||
latestPrompt: 'second task'
|
||
}
|
||
)
|
||
})
|
||
|
||
it('reports no tool line once the turn settles, even with an abandoned running call', () => {
|
||
const ask = item('ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const abandoned = item('abandoned', 2, {
|
||
kind: 'tool-call',
|
||
name: 'Bash',
|
||
input: { command: 'sleep 600' },
|
||
state: 'running'
|
||
})
|
||
|
||
expect(projectStructuredAgentSessionStatusSummary([ask, abandoned])).toEqual({
|
||
status: 'idle',
|
||
latestPrompt: 'go'
|
||
})
|
||
})
|
||
|
||
it('never adopts a running call from a turn older than the live one', () => {
|
||
const ask = item('ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const abandoned = item('abandoned', 2, {
|
||
kind: 'tool-call',
|
||
name: 'Bash',
|
||
input: { command: 'sleep 600' },
|
||
state: 'running'
|
||
})
|
||
const running = item('running', 3, {
|
||
kind: 'status',
|
||
text: 'Working',
|
||
turnLifecycle: { turnId: 'turn-2', state: 'running' }
|
||
})
|
||
|
||
expect(projectStructuredAgentSessionStatusSummary([ask, abandoned, running])).toEqual({
|
||
status: 'working',
|
||
latestPrompt: 'go',
|
||
statusStartedAt: 3
|
||
})
|
||
})
|
||
|
||
it('skips a tool-only assistant item to reach the newest prose', () => {
|
||
const ask = item('ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const said = item('said', 2, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'Done — the card now aligns.' }]
|
||
})
|
||
const wordless = item('wordless', 3, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
|
||
})
|
||
|
||
expect(
|
||
projectStructuredAgentSessionStatusSummary([ask, said, wordless]).lastAssistantMessage
|
||
).toBe('Done — the card now aligns.')
|
||
})
|
||
|
||
it('bounds the assistant preview at the shared agent-status preview cap', () => {
|
||
const ask = item('ask', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
const rambled = item('rambled', 2, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'y'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }]
|
||
})
|
||
|
||
expect(
|
||
projectStructuredAgentSessionStatusSummary([ask, rambled]).lastAssistantMessage
|
||
).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH)
|
||
})
|
||
|
||
it('bounds the wire prompt at the shared agent-status preview cap', () => {
|
||
const pasted = item('pasted', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }]
|
||
})
|
||
|
||
expect(projectStructuredAgentSessionStatusSummary([pasted]).latestPrompt).toHaveLength(
|
||
AGENT_STATUS_MAX_FIELD_LENGTH
|
||
)
|
||
})
|
||
|
||
it('creates a deterministic pane identity for status stores', () => {
|
||
const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')
|
||
|
||
expect(structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')).toBe(paneKey)
|
||
expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' })
|
||
})
|
||
|
||
it('preserves provider-frame detail on the backward-compatible status line', () => {
|
||
const projected = projectStructuredItemToNativeChat(
|
||
item('frame', 1, {
|
||
kind: 'status',
|
||
text: 'codex · notification:new/event',
|
||
providerFrame: {
|
||
provider: 'codex',
|
||
kind: 'notification:new/event',
|
||
payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false }
|
||
}
|
||
})
|
||
)
|
||
|
||
expect(projected?.blocks).toEqual([
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
text: 'codex · notification:new/event',
|
||
providerFrame: expect.objectContaining({ kind: 'notification:new/event' })
|
||
})
|
||
])
|
||
})
|
||
|
||
it('preserves structured tool lifecycle state for the live renderer', () => {
|
||
const projected = projectStructuredItemToNativeChat(
|
||
item('ls', 1, {
|
||
kind: 'tool-call',
|
||
name: 'shell',
|
||
input: { command: 'ls' },
|
||
state: 'running'
|
||
})
|
||
)
|
||
|
||
expect(projected?.blocks).toEqual([
|
||
{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running', callId: 'ls' }
|
||
])
|
||
})
|
||
|
||
it('projects a turn record to no message', () => {
|
||
const turn = item('turn', 1, {
|
||
kind: 'turn',
|
||
turnId: 't1',
|
||
state: 'completed',
|
||
startedAt: 1_000,
|
||
completedAt: 4_000
|
||
})
|
||
|
||
expect(projectStructuredItemToNativeChat(turn)).toBeNull()
|
||
})
|
||
|
||
it('projects an item kind this build does not know to no message, never a text bubble', () => {
|
||
const unknown = item('future', 1, {
|
||
kind: 'future-kind',
|
||
text: 'a newer host wrote this'
|
||
} as unknown as AgentJournalRenderItem['body'])
|
||
|
||
expect(projectStructuredItemToNativeChat(unknown)).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('notice projection for desktop and mobile consumers', () => {
|
||
it.each([
|
||
{ presentation: 'compaction' },
|
||
{ presentation: 'compaction-skipped', tone: 'warning' },
|
||
{ presentation: 'plan-document' },
|
||
{ tone: 'warning' },
|
||
{ tone: 'error' },
|
||
{ tone: 'notice' },
|
||
{ presentation: 'future-presentation', tone: 'future-tone' }
|
||
])('preserves readable text alongside optional metadata: %j', (metadata) => {
|
||
const projected = projectStructuredItemToNativeChat(
|
||
item('notice', 1, {
|
||
kind: 'status',
|
||
text: 'A readable document or notice',
|
||
...metadata
|
||
})
|
||
)
|
||
expect(projected).toMatchObject({
|
||
role: 'system',
|
||
blocks: [{ type: 'text', text: 'A readable document or notice', ...metadata }]
|
||
})
|
||
})
|
||
})
|
||
|
||
it('preserves optional tool annotations for desktop and mobile projection', () => {
|
||
const metadata = {
|
||
callId: 'call-1',
|
||
exitCode: 127,
|
||
durationMs: 400,
|
||
webSearchResults: [{ title: 'Docs', url: 'https://example.com' }]
|
||
}
|
||
const projected = projectStructuredItemToNativeChat(
|
||
item('annotated', 1, {
|
||
kind: 'tool-call',
|
||
name: 'shell',
|
||
input: null,
|
||
state: 'failed',
|
||
...metadata
|
||
})
|
||
)
|
||
expect(projected?.blocks[0]).toEqual({
|
||
type: 'tool-call',
|
||
name: 'shell',
|
||
input: null,
|
||
state: 'failed',
|
||
...metadata
|
||
})
|
||
})
|
||
|
||
it('preserves confirmed MCP identity and the raw name through projection', () => {
|
||
const body = {
|
||
kind: 'tool-call' as const,
|
||
name: 'my_server/ns.tool',
|
||
input: null,
|
||
state: 'running' as const,
|
||
mcpIdentity: { server: 'my_server', tool: 'ns.tool' }
|
||
}
|
||
const projected = projectStructuredItemToNativeChat(item('mcp', 1, body))
|
||
expect(projected?.blocks[0]).toMatchObject({
|
||
name: body.name,
|
||
mcpIdentity: body.mcpIdentity,
|
||
type: 'tool-call'
|
||
})
|
||
})
|
||
|
||
describe("producer linkage — a subagent's output never speaks for the parent", () => {
|
||
/** A row a subagent produced. Same journal, same session; only linkage differs. */
|
||
function childItem(
|
||
itemId: string,
|
||
sequence: number,
|
||
body: AgentJournalRenderItem['body'],
|
||
agentId = 'task-1'
|
||
): AgentJournalRenderItem {
|
||
return { ...item(itemId, sequence, body), agentId, producerKind: 'agent' }
|
||
}
|
||
|
||
const userAsk = item('user-1', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'summarise the repo' }]
|
||
})
|
||
const turnRunning = item('turn-1', 2, { kind: 'turn', turnId: 'turn-1', state: 'running' })
|
||
const parentProse = item('root-prose', 3, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'delegating' }]
|
||
})
|
||
const spawnCall = item('root-task', 4, {
|
||
kind: 'tool-call',
|
||
name: 'Task',
|
||
input: { description: 'explore the lane' },
|
||
state: 'running'
|
||
})
|
||
const childProse = childItem('child-prose', 5, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'looking' }]
|
||
})
|
||
const childCall = childItem('child-grep', 6, {
|
||
kind: 'tool-call',
|
||
name: 'Grep',
|
||
input: { pattern: 'x' },
|
||
state: 'running'
|
||
})
|
||
const items = [userAsk, turnRunning, parentProse, spawnCall, childProse, childCall]
|
||
|
||
it("shows the parent's own prose and its own running call, not the child's newer ones", () => {
|
||
expect(latestStructuredAgentSessionAssistantMessage(items)).toBe('delegating')
|
||
expect(statusStructuredAgentSessionToolCall(items)?.name).toBe('Task')
|
||
})
|
||
|
||
it("publishes the parent's own line and call on the summary the sidebar reads", () => {
|
||
const summary = projectStructuredAgentSessionStatusSummary(items)
|
||
expect(summary.status).toBe('working')
|
||
expect(summary.lastAssistantMessage).toBe('delegating')
|
||
expect(summary.toolName).toBe('Task')
|
||
// Keep the parent’s spawn-call preview while its child runs.
|
||
expect(summary.toolInput).toBeTruthy()
|
||
})
|
||
|
||
it("names the parent's own settled call, not a child's newer settled one", () => {
|
||
const parentRead = item('root-read', 4, {
|
||
kind: 'tool-call',
|
||
name: 'Read',
|
||
input: { file_path: '/repo/a.ts' },
|
||
state: 'completed'
|
||
})
|
||
const childFailed = childItem('child-grep', 6, {
|
||
kind: 'tool-call',
|
||
name: 'Grep',
|
||
input: { pattern: 'x' },
|
||
state: 'failed'
|
||
})
|
||
expect(
|
||
projectStructuredAgentSessionStatusSummary([userAsk, turnRunning, parentRead, childFailed])
|
||
.toolName
|
||
).toBe('Read')
|
||
})
|
||
|
||
it("still renders the child's output in the transcript", () => {
|
||
// Status scoping must still preserve all child output in the transcript.
|
||
const prose = projectStructuredItemsToNativeChat(items).flatMap((message) =>
|
||
message.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : []))
|
||
)
|
||
expect(prose).toContain('looking')
|
||
expect(prose).toContain('delegating')
|
||
})
|
||
|
||
it("keeps the child's output as the child's: the transcript message names its producer", () => {
|
||
// Keep producer identity or child output would appear to be the parent speaking.
|
||
const messages = projectStructuredItemsToNativeChat(items)
|
||
const byId = new Map(messages.map((message) => [message.id, message]))
|
||
expect(byId.get('child-prose')).toMatchObject({ agentId: 'task-1', producerKind: 'agent' })
|
||
expect(byId.get('child-grep')).toMatchObject({ agentId: 'task-1' })
|
||
// The session's own rows name no producer: absence is the claim that they are its own.
|
||
expect(byId.get('root-prose')).not.toHaveProperty('agentId')
|
||
expect(byId.get('root-task')).not.toHaveProperty('agentId')
|
||
})
|
||
|
||
it("falls back to nothing rather than a child's line when the parent said nothing", () => {
|
||
const summary = projectStructuredAgentSessionStatusSummary([
|
||
userAsk,
|
||
turnRunning,
|
||
spawnCall,
|
||
childProse,
|
||
childCall
|
||
])
|
||
expect(summary.lastAssistantMessage).toBeUndefined()
|
||
expect(summary.toolName).toBe('Task')
|
||
})
|
||
|
||
it('attributes rows without reading their neighbours', () => {
|
||
// Attribution must survive a window without spawn/turn rows; derive it from each row’s own linkage.
|
||
const windowed = [childProse, childCall]
|
||
expect(latestStructuredAgentSessionAssistantMessage(windowed)).toBe('')
|
||
expect(statusStructuredAgentSessionToolCall(windowed)).toBeNull()
|
||
})
|
||
|
||
it("does not quote a subagent's own user-role prompt as the session's", () => {
|
||
const childPrompt = childItem('child-prompt', 5, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'explore the lane' }]
|
||
})
|
||
expect(
|
||
projectStructuredAgentSessionStatusSummary([userAsk, turnRunning, spawnCall, childPrompt])
|
||
.latestPrompt
|
||
).toBe('summarise the repo')
|
||
})
|
||
|
||
it('keeps a nested child off the parent, read through the projection', () => {
|
||
// A grandchild: its own agent id, and a parent that is not the session root.
|
||
const grandchild: AgentJournalRenderItem = {
|
||
...item('grandchild-prose', 7, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'deeper' }]
|
||
}),
|
||
agentId: 'task-2',
|
||
parentAgentId: 'task-1',
|
||
producerKind: 'agent'
|
||
}
|
||
const nested = [...items, grandchild]
|
||
expect(latestStructuredAgentSessionAssistantMessage(nested)).toBe('delegating')
|
||
// A named parent does not make child prose part of the root summary.
|
||
expect(projectStructuredAgentSessionStatusSummary(nested).lastAssistantMessage).toBe(
|
||
'delegating'
|
||
)
|
||
expect(
|
||
projectStructuredItemsToNativeChat(nested).some((block) =>
|
||
JSON.stringify(block).includes('deeper')
|
||
)
|
||
).toBe(true)
|
||
})
|
||
|
||
it('treats an agent id that failed to resolve as a child rather than as the parent', () => {
|
||
const unresolved = childItem(
|
||
'child-unresolved',
|
||
5,
|
||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'looking' }] },
|
||
''
|
||
)
|
||
expect(
|
||
latestStructuredAgentSessionAssistantMessage([userAsk, turnRunning, parentProse, unresolved])
|
||
).toBe('delegating')
|
||
})
|
||
|
||
it("reads a row written before linkage existed as the parent's own", () => {
|
||
// Legacy unmarked child rows retain historical root attribution across upgrades; absence is never unknown.
|
||
const legacyChildProse = item('legacy-child', 5, {
|
||
kind: 'message',
|
||
role: 'assistant',
|
||
blocks: [{ type: 'text', text: 'legacy child line' }]
|
||
})
|
||
expect(
|
||
latestStructuredAgentSessionAssistantMessage([
|
||
userAsk,
|
||
turnRunning,
|
||
parentProse,
|
||
spawnCall,
|
||
legacyChildProse,
|
||
childProse
|
||
])
|
||
).toBe('legacy child line')
|
||
})
|
||
})
|
||
|
||
describe('the turn verdict on the status summary', () => {
|
||
const user = item('u1', 1, {
|
||
kind: 'message',
|
||
role: 'user',
|
||
blocks: [{ type: 'text', text: 'go' }]
|
||
})
|
||
|
||
it('carries the newest settled turn verdict only while the session is idle', () => {
|
||
const running = item('turn-running', 2, {
|
||
kind: 'turn',
|
||
turnId: 'turn-1',
|
||
state: 'running'
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([user, running])).not.toHaveProperty(
|
||
'turnOutcome'
|
||
)
|
||
const cancelled = item('turn-cancelled', 3, {
|
||
kind: 'turn',
|
||
turnId: 'turn-1',
|
||
state: 'interrupted',
|
||
outcome: 'cancellation'
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([user, cancelled])).toMatchObject({
|
||
status: 'idle',
|
||
turnOutcome: 'cancellation'
|
||
})
|
||
})
|
||
|
||
it('reports no verdict for a completed turn the provider never judged', () => {
|
||
const completed = item('turn-completed', 2, {
|
||
kind: 'turn',
|
||
turnId: 'turn-1',
|
||
state: 'completed'
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([user, completed])).not.toHaveProperty(
|
||
'turnOutcome'
|
||
)
|
||
})
|
||
|
||
// With no verdict from the provider, the lifecycle the host settled is what the row reports.
|
||
it.each([
|
||
['interrupted', 'interruption'],
|
||
['unverifiable', 'unconfirmed']
|
||
] as const)(
|
||
'reads an %s turn the provider never judged as its host-observed end',
|
||
(state, outcome) => {
|
||
const ended = item('turn-ended', 2, { kind: 'turn', turnId: 'turn-1', state })
|
||
expect(projectStructuredAgentSessionStatusSummary([user, ended])).toMatchObject({
|
||
status: 'idle',
|
||
turnOutcome: outcome
|
||
})
|
||
}
|
||
)
|
||
|
||
it("keeps the provider's own verdict over what the host observed of the end", () => {
|
||
for (const outcome of ['cancellation', 'failure', 'success'] as const) {
|
||
const judged = item('turn-judged', 2, {
|
||
kind: 'turn',
|
||
turnId: 'turn-1',
|
||
state: 'interrupted',
|
||
outcome
|
||
})
|
||
expect(projectStructuredAgentSessionStatusSummary([user, judged])).toMatchObject({
|
||
turnOutcome: outcome
|
||
})
|
||
}
|
||
})
|
||
|
||
it('keeps the observed end off the request the completion feed announces', () => {
|
||
const ended = item('turn-ended', 2, { kind: 'turn', turnId: 'turn-1', state: 'interrupted' })
|
||
expect(projectStructuredAgentSessionStatusState([user, ended]).latestRequest).toMatchObject({
|
||
turnState: 'interrupted',
|
||
outcome: null
|
||
})
|
||
})
|
||
})
|