Merge #24606's head (main with #24710) into C2: one decider for a rejected message

Main's #24710 draws a message Orca recorded and then rejected where the host
placed it, as "Not sent", from the host's own history. The host side (the
rejection moves the message to its place; a failed start writes its rejections
and its row in one transaction) is taken as written.

On the client, C2's settlement stays the only place an outbox entry ends:
- #24710's reconcile module is folded into C2's: an entry whose rejected row is
  not loaded yet stays, never sent again, and draws the message until that row
  loads (or the host's window for its id ends). Nothing new is stored; the held
  submission says it was rejected. A batch that settles nothing writes nothing.
- One "not sent" surface: main's rule hides a rejected message a live card
  holds or a later copy of the same text superseded, in the rows and the
  notices alike, on the desktop and the phone. The notice stays C2's muted one,
  and notices are kept as the same objects across unchanged batches.
- No Retry: a message refused before it was recorded still goes back to the
  conversation's draft with the reason, as before.
- A rejected /compact stays in the chat (#24918's rule), since the command's
  own reply says nothing once the journal shows it.
- The phone and the desktop both draw these rows; only the host's outline,
  which older clients read, leaves them out.
This commit is contained in:
Brennan Benson
2026-10-04 16:19:52 -07:00
119 changed files with 5179 additions and 337 deletions
+8
View File
@@ -15,6 +15,7 @@ export type PickerOption<T extends string = string> = {
type Props<T extends string = string> = {
visible: boolean
title: string
subtitle?: string
options: PickerOption<T>[]
selected: T
onSelect: (value: T) => void
@@ -32,6 +33,7 @@ type PickerModalContentProps<T extends string = string> = Pick<
export function PickerModal<T extends string = string>({
visible,
title,
subtitle,
options,
selected,
onSelect,
@@ -44,6 +46,7 @@ export function PickerModal<T extends string = string>({
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
<PickerModalContent
@@ -130,6 +133,11 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: colors.textMuted
},
subtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
@@ -12,7 +12,8 @@ import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
WORKSPACE_VIEW_SHARED_NOTE
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
@@ -37,6 +38,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<PickerModal
visible={state.showSortPicker}
title="Sort By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={SORT_OPTIONS}
selected={state.sortMode}
onSelect={settings.handleSortChange}
@@ -46,6 +48,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<PickerModal
visible={state.showGroupPicker}
title="Group By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={GROUP_OPTIONS}
selected={state.groupMode}
onSelect={settings.handleGroupChange}
@@ -54,7 +57,10 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
<View style={styles.filterModalHeading}>
<Text style={styles.filterModalTitle}>Filter</Text>
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
</View>
{settings.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
@@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalHeading: {
flexShrink: 1
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
filterModalSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
@@ -158,8 +158,13 @@ export function useMobileStructuredAgentSession(args: {
})
const messages = useMemo(
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
[state.items, state.submissions]
// A message the host recorded and then rejected stays in place, said once on its row.
() =>
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
rejectedInPlace: true,
queuedMessageIds: (queuedMessages ?? []).map((draft) => draft.messageId)
}),
[queuedMessages, state.items, state.submissions]
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
@@ -1,6 +1,9 @@
import type { PickerOption } from '../components/PickerModal'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
// Why: the host may be headless, so the note can't promise a desktop sidebar.
export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices'
export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
// Why: desktop and persisted state keep the `smart` key, while mobile shows the product label.
{
@@ -11,7 +14,7 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
{ value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' },
{ value: 'manual', label: 'Manual', subtitle: 'Server order' }
{ value: 'manual', label: 'Manual', subtitle: 'Desktop drag order' }
]
export const WORKSPACE_GROUP_OPTIONS: PickerOption<MobileGroupMode>[] = [
@@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/typ
import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
// Desktop sort adds 'manual'; mobile orders it by the desktop's drag ranks.
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
// Desktop PersistedUIState fields this screen syncs (a structural subset).
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import type { SessionFileDiscovery } from './session-scanner-types'
import type { TranscriptReadOutcome } from './session-transcript-consumers'
@@ -24,6 +27,7 @@ import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests
} from './session-transcript-consumers'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
const file = {
path: '/fixture/opencode.db#session',
@@ -40,6 +44,7 @@ beforeEach(() => {
resetSessionParseCacheForTests()
})
afterEach(() => {
vi.useRealTimers()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
@@ -48,7 +53,11 @@ function configure(agent: 'opencode' | 'opencode2') {
readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }])
const accumulator = createAccumulator({ agent, file, sessionId: 'session' })
accumulator.title = 'SQLite session'
return finalizeSession(accumulator, 'linux')
const session = finalizeSession(accumulator, 'linux')
if (!session) {
throw new Error('Configured SQLite session was empty')
}
return session
}
function untilAborted(signal: AbortSignal | undefined): Promise<never> {
@@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise<never> {
}
describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => {
it('reports a deadline while retaining completed sessions and other agents, then retries', async () => {
vi.useFakeTimers()
const root = mkdtempSync(join(tmpdir(), 'orca-scan-deadline-'))
try {
const session = configure(agent)
const blocked = { ...file, path: '/fixture/opencode.db#blocked' }
const claudePath = join(root, 'claude.jsonl')
writeFileSync(
claudePath,
`${JSON.stringify({
type: 'user',
sessionId: 'retained-claude',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: root,
message: { role: 'user', content: 'Retain this other-agent session' }
})}\n`
)
readers.discover.mockResolvedValue([
{ agent, rootDir: '/fixture', files: [file, blocked] },
{ agent: 'claude', rootDir: root, files: [{ ...file, path: claudePath }] }
])
readers.parse.mockImplementation(({ sessionId, signal }) =>
sessionId === 'blocked'
? runOpenCodeSqliteScanRequest(signal, untilAborted)
: Promise.resolve(session)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.parse).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions.map((row) => row.sessionId)).toEqual(
expect.arrayContaining(['session', 'retained-claude'])
)
expect(result.sessions).toHaveLength(2)
expect(result.issues).toEqual([
expect.objectContaining({
agent,
path: blocked.path,
message: expect.stringContaining('45s work budget')
})
])
readers.parse.mockResolvedValue({ ...session, sessionId: 'blocked', filePath: blocked.path })
const recovered = await scanAiVaultSessions({ platform: 'linux' })
expect(recovered.sessions).toHaveLength(3)
expect(
readers.parse.mock.calls.filter(([args]) => args.sessionId === 'blocked')
).toHaveLength(2)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('marks a deadline capture incomplete instead of caching a failed history', async () => {
vi.useFakeTimers()
const session = configure(agent)
const outcomes: TranscriptReadOutcome[] = []
registerTranscriptConsumer({
beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) })
})
readers.capture.mockImplementationOnce(({ signal }) =>
runOpenCodeSqliteScanRequest(signal, untilAborted)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.capture).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({ message: expect.stringContaining('45s work budget') })
])
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
readers.capture.mockResolvedValue({ session, messages })
expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1)
expect(readers.capture).toHaveBeenCalledTimes(2)
expect(outcomes.at(-1)?.incomplete).toBe(false)
})
it.each(['parse', 'capture'] as const)(
'cancels an active %s and retries the uncached read',
async (mode) => {
@@ -0,0 +1,138 @@
import { afterEach, expect, it, vi } from 'vitest'
import {
OPENCODE_SQLITE_SCAN_BUDGET_MS,
runOpenCodeSqliteScanRequest,
withOpenCodeSqliteScanScope
} from './session-scanner-opencode-sqlite-scan-scope'
afterEach(() => vi.useRealTimers())
function waitForAbort(signal: AbortSignal | undefined): Promise<never> {
if (!signal) {
throw new Error('Missing scoped request signal')
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
it('spends the budget while admission is pending and refuses later work in that scan', async () => {
vi.useFakeTimers()
const admitted = vi.fn()
const outcome = withOpenCodeSqliteScanScope(async () => {
const error = await runOpenCodeSqliteScanRequest(undefined, waitForAbort).catch((err) => err)
expect(error).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await expect(runOpenCodeSqliteScanRequest(undefined, admitted)).rejects.toBe(error)
})
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS)
await outcome
expect(admitted).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('banks only outstanding work across legs and does not spend other-agent time', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(async () => {
await runOpenCodeSqliteScanRequest(undefined, () => wait(20_000))
await wait(70_000)
return runOpenCodeSqliteScanRequest(undefined, waitForAbort)
}).catch((error) => error)
await vi.advanceTimersByTimeAsync(90_000)
let completed = false
void outcome.then(() => {
completed = true
})
await vi.advanceTimersByTimeAsync(24_999)
expect(completed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('counts overlapping preparation and worker waits once', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, () =>
Promise.all([
runOpenCodeSqliteScanRequest(undefined, waitForAbort),
runOpenCodeSqliteScanRequest(undefined, waitForAbort)
])
)
).catch((error) => error)
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS - 1)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('keeps concurrent scans independent and gives the next scan a fresh owner and budget', async () => {
vi.useFakeTimers()
const owners: unknown[] = []
const first = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(30_000)
const second = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(15_000)
expect(await first).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(await second).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, async (_signal, owner) => {
owners.push(owner)
})
)
expect(new Set(owners).size).toBe(3)
expect(vi.getTimerCount()).toBe(0)
})
it('preserves the caller cancellation reason and disposes its timer', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const reason = new Error('caller cancelled')
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(controller.signal, waitForAbort)
).catch((error) => error)
controller.abort(reason)
expect(await outcome).toBe(reason)
expect(vi.getTimerCount()).toBe(0)
})
it('leaves unrelated native-chat and Zcode calls unscoped and retires the scan signal', async () => {
vi.useFakeTimers()
let scopedSignal: AbortSignal | undefined
const caller = new AbortController()
await withOpenCodeSqliteScanScope(async () => {
for (const agent of ['zcode', 'native-chat'] as const) {
await runOpenCodeSqliteScanRequest(
caller.signal,
async (signal, owner) => {
expect(signal).toBe(caller.signal)
expect(owner).toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
},
agent
)
}
await runOpenCodeSqliteScanRequest(undefined, async (signal) => {
scopedSignal = signal
})
})
expect(scopedSignal?.aborted).toBe(true)
expect(caller.signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,75 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import type { WorkerThreadRequestOwner } from '../worker-thread-request-queue'
export const OPENCODE_SQLITE_SCAN_BUDGET_MS = 45_000
class OpenCodeSqliteScanScope implements WorkerThreadRequestOwner {
private readonly controller = new AbortController()
readonly signal = this.controller.signal
private remainingMs = OPENCODE_SQLITE_SCAN_BUDGET_MS
private outstanding = 0
private armedAt = 0
private timer: NodeJS.Timeout | undefined
async run<T>(
callerSignal: AbortSignal | undefined,
fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise<T>
): Promise<T> {
throwIfSignalAborted(callerSignal)
throwIfSignalAborted(this.signal)
if (this.outstanding++ === 0) {
this.armedAt = Date.now()
this.timer = setTimeout(() => {
const error = new Error(
`OpenCode SQLite scan exceeded its ${OPENCODE_SQLITE_SCAN_BUDGET_MS / 1000}s work budget`
)
error.name = 'OpenCodeSqliteScanDeadlineError'
this.controller.abort(error)
}, this.remainingMs)
this.timer.unref?.()
}
const signal = callerSignal ? AbortSignal.any([callerSignal, this.signal]) : this.signal
try {
return await fn(signal, this)
} finally {
if (--this.outstanding === 0) {
this.pause()
}
}
}
dispose(): void {
this.pause()
this.controller.abort(new Error('OpenCode SQLite scan ended'))
}
private pause(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.armedAt))
}
}
}
const scanScope = new AsyncLocalStorage<OpenCodeSqliteScanScope>()
export async function withOpenCodeSqliteScanScope<T>(fn: () => Promise<T>): Promise<T> {
const scope = new OpenCodeSqliteScanScope()
try {
return await scanScope.run(scope, fn)
} finally {
scope.dispose()
}
}
// The clock covers outstanding SQLite work, including admission and WSL preparation.
export function runOpenCodeSqliteScanRequest<T>(
signal: AbortSignal | undefined,
fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise<T>,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<T> {
const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore()
return scope ? scope.run(signal, fn) : fn(signal)
}
@@ -12,6 +12,7 @@ import type {
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
// A worker_threads stand-in the tests drive directly: it records posted requests
// and lets a test emit message/error/exit without a built worker bundle.
@@ -75,6 +76,63 @@ function makeFactory(workers: FakeWorker[]): () => Worker {
}
describe('OpenCodeSqliteWorkerClient', () => {
it('expires a scan in the FIFO without cancelling ordinary reads or a later scan', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
const issues: AiVaultScanIssue[] = []
try {
const ordinary = [1, 2].map((id) =>
client.list({
dbPaths: [`/ordinary-${id}.db`],
limit: 1,
issues: []
})
)
const scan = withOpenCodeSqliteScanScope(() =>
client.list({
dbPaths: ['/scan.db'],
limit: 1,
issues
})
)
await vi.advanceTimersByTimeAsync(25_000)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await vi.advanceTimersByTimeAsync(20_000)
await expect(scan).resolves.toEqual([])
expect(issues).toEqual([
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
expect(workers[0].postedRequests).toHaveLength(2)
expect(workers[0].terminated).toBe(false)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(Promise.all(ordinary)).resolves.toEqual([[], []])
const next = withOpenCodeSqliteScanScope(() =>
client.list({ dbPaths: ['/next.db'], limit: 1, issues: [] })
)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(next).resolves.toEqual([])
} finally {
client.dispose()
vi.useRealTimers()
}
})
it('correlates responses by id and ignores stale ids', async () => {
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
@@ -188,7 +246,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues })
).resolves.toEqual([])
expect(
listIssues.some((issue) => /background scanner could not start/.test(issue.message))
listIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' })
@@ -264,7 +322,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues })
expect(first).toEqual([])
expect(
firstIssues.some((issue) => /background scanner could not start/.test(issue.message))
firstIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' })
@@ -12,6 +12,7 @@ import type {
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
// the main-process event loop. This module owns only the OpenCode legs; the
@@ -117,7 +118,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
LIST_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)) as OpenCodeSqliteListValue
args.issues.push(...value.issues)
return value.candidates
@@ -178,7 +180,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
PARSE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
return value as AiVaultSession | null
@@ -217,7 +220,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
CAPTURE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
return parseOpenCodeSqliteCaptureValue(value)
} catch (err) {
@@ -229,7 +233,12 @@ export class OpenCodeSqliteWorkerClient {
args: Omit<OpenCodeNativeChatReadRequest, 'id'>,
signal?: AbortSignal
): Promise<OpenCodeNativeChatReadValue> {
const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal)
const value = await this.dispatch(
(id) => ({ ...args, id }),
PARSE_TIMEOUT_MS,
signal,
'native-chat'
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames.
return value as OpenCodeNativeChatReadValue
}
@@ -241,13 +250,20 @@ export class OpenCodeSqliteWorkerClient {
private async dispatch(
buildRequest: (id: number) => OpenCodeSqliteWorkerRequest,
timeoutMs: number,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<unknown> {
const deadline = this.requestTimeoutMs ?? timeoutMs
const response = await this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
signal
const response = await runOpenCodeSqliteScanRequest(
signal,
(requestSignal, owner) =>
this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
requestSignal,
owner
),
agent
)
if (!response.ok) {
throw new Error(response.error)
@@ -16,6 +16,7 @@ import {
openCodeWslPath
} from './session-scanner-opencode-wsl-client'
import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why: resolve the built worker entry + own the process-wide shared client so
// the client class stays free of Electron (require'd lazily here) and the
@@ -179,8 +180,14 @@ async function listForHost(
const first = paths.values().next().value!
const issues: AiVaultScanIssue[] = []
try {
const client = await openCodeWslClient(distro, first, args.signal)
const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues })
const result = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(distro, first, signal)
return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues })
},
args.agent
)
return result.flatMap((candidate) => {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent)
const original = parsed && paths.get(parsed.dbPath)
@@ -223,8 +230,14 @@ async function parseForHost(
if (!wsl) {
return getSharedClient().parse(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const session = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return mapOpenCodeWslSession(session, args.dbPath)
}
@@ -235,8 +248,14 @@ async function captureForHost(
if (!wsl) {
return getSharedClient().capture(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const capture = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
@@ -3,6 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
import type { SessionFileCandidate } from './session-scanner-types'
import type * as wslClientModule from './session-scanner-opencode-wsl-client'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const mocks = vi.hoisted(() => ({
native: {
@@ -59,9 +60,53 @@ beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
})
afterEach(() => vi.restoreAllMocks())
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('OpenCode SQLite execution-host routes', () => {
it('budgets WSL preparation while retaining a native source that already answered', async () => {
vi.useFakeTimers()
mocks.native.list.mockResolvedValueOnce([row(native)])
mocks.guest.mockImplementationOnce((_distro, _path, signal: AbortSignal | undefined) => {
if (!signal) {
throw new Error('Missing scoped preparation signal')
}
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
)
})
const issues: AiVaultScanIssue[] = []
const result = withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [native, ubuntu],
limit: 2,
issues
})
)
await vi.advanceTimersByTimeAsync(45_000)
expect((await result).map((entry) => entry.file.path)).toEqual([`${native}#same-session`])
expect(issues).toEqual([
expect.objectContaining({
path: ubuntu,
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
mocks.guest.mockResolvedValueOnce({ list: vi.fn(async () => [row(guest)]) })
const recoveredIssues: AiVaultScanIssue[] = []
const recovered = await withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [ubuntu],
limit: 2,
issues: recoveredIssues
})
)
expect(recovered).toHaveLength(1)
expect(recoveredIssues).toEqual([])
})
it('separates native and distro databases and preserves equal IDs in different distros', async () => {
const list = vi.fn(async (args) => [row(args.dbPaths[0])])
mocks.guest.mockResolvedValue({ list })
+5
View File
@@ -45,6 +45,7 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const SESSION_PARSE_CONCURRENCY = 8
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
@@ -61,6 +62,10 @@ const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {
return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options))
}
async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise<AiVaultListResult> {
// The span makes scan cost visible in the local trace file: STA-1278-style
// "one core pegged" reports need to show whether transcript scanning is the
// subsystem burning CPU, and how much of each scan the cache absorbed.
@@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture'
/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */
async function newestRunDelegation(frames: Frame[]): Promise<NativeChatSubagentDelegation | null> {
const { conversation } = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [])
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], {
rejectedInPlace: true
})
)
const runs = conversation.filter((message: NativeChatMessage) =>
message.blocks.some((block) => block.type === 'tool-call')
@@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): {
})
})
const transcript = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds)
projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map(
withoutCallIds
)
)
// The conversation's runs, then each helper's section's.
const runs = [
@@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] {
client.items,
[],
client.submissions,
{ rejectedInPlace: true },
projectStructuredQuestionMessages
)
)
@@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => {
])
// Mobile draws the shared projection in journal order, one row per question.
expect(
projectStructuredAgentSessionMessages(client.items, [], client.submissions).map(
({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)
)
projectStructuredAgentSessionMessages(client.items, [], client.submissions, {
rejectedInPlace: false
}).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null))
).toEqual(ASKED.map(({ question }) => question))
})
@@ -5,7 +5,11 @@ import {
agentModelCatalogFingerprintForRecord
} from './agent-model-catalog-fingerprint'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
import {
AGENT_MODEL_CATALOG_FRESH_MS,
AgentModelCatalogStore,
type AgentModelCatalogSuccess
} from './agent-model-catalog-store'
function record(accountHomePath: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
@@ -55,7 +59,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
// A second read while the probe is in flight must not start another, and a
// record-scoped read probes the RECORD's pinned home, not the selection.
@@ -81,7 +86,10 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
expect(probe).toHaveBeenCalledWith('/homes/new')
await vi.waitFor(async () => {
const result = await service.read({ agent: 'codex' })
@@ -139,7 +147,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
// Still a clean unknown — and the failure TTL suppresses a probe storm.
@@ -164,6 +173,94 @@ describe('agent model catalog service', () => {
expect(probe).not.toHaveBeenCalled()
})
describe('a read that waits for the first listing', () => {
function deferredListing() {
let resolve!: (success: AgentModelCatalogSuccess) => void
let reject!: (error: Error) => void
const promise = new Promise<AgentModelCatalogSuccess>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function coldService(probe: (home: string) => Promise<AgentModelCatalogSuccess>) {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
return { store, service }
}
it('joins the listing the first read started and answers with it', async () => {
const pending = deferredListing()
const probe = vi.fn(() => pending.promise)
const { service } = coldService(probe)
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.resolve(listing('gpt-listed'))
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed')
expect(probe).toHaveBeenCalledTimes(1)
})
it('answers a plain unknown when the listing fails', async () => {
const pending = deferredListing()
const { service } = coldService(() => pending.promise)
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.reject(new Error('spawn failed'))
expect(await waited).toEqual({ origin: 'unknown' })
})
it('does not wait or report a listing while a failure is inside its TTL', async () => {
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
throw new Error('spawn failed')
})
const { store, service } = coldService(probe)
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('reports no listing where the host has no lister for the account', async () => {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected')
})
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
})
it('serves an aged entry at once and refreshes it behind the answer', async () => {
let now = 0
const store = new AgentModelCatalogStore({ now: () => now })
store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old'))
now = AGENT_MODEL_CATALOG_FRESH_MS
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
const result = await service.read({ agent: 'codex', waitForListing: true })
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old')
expect(probe).toHaveBeenCalledTimes(1)
})
})
describe('a read for the workspace a new chat runs in', () => {
function serviceWith(mayOverride: boolean) {
const store = new AgentModelCatalogStore()
@@ -35,6 +35,8 @@ export type AgentModelCatalogService = {
sessionId?: string
/** Where a new chat would run; null when one was named but is not a local directory. */
workspacePath?: string | null
/** With no entry yet, answer from the listing this read starts or joins instead of `unknown`. */
waitForListing?: boolean
}) => Promise<AgentSessionModelCatalogResult>
}
@@ -79,8 +81,9 @@ async function workspaceKeepsListedDefault(
* launch); without one, the key is the account a launch would pin right now —
* never "whichever account listed last". `unknown` tells the client to keep
* its static seed, and a missing or aged entry kicks one joined background
* probe so the next read is warm. Failures are the store's 30s TTL, never an
* answer — a picker is a user surface and must not block.
* probe so the next read is warm. With no entry, the answer says that listing
* is running, and only a read that asks waits for it. Failures are the store's
* 30s TTL, never an answer: inside it a read answers `unknown` at once.
*/
export function createAgentModelCatalogService(
deps: AgentModelCatalogServiceDeps
@@ -110,14 +113,27 @@ export function createAgentModelCatalogService(
})
accountHomePath = resolved.path
}
const entry = deps.store.get(fingerprint)
let entry = deps.store.get(fingerprint)
const probe = deps.probes?.[params.agent]
if (probe && accountHomePath && deps.store.shouldRefresh(fingerprint)) {
const home = accountHomePath
void deps.store.refresh(fingerprint, params.agent, () => probe(home))
}
const home = accountHomePath
// Without an entry, join a running listing too: that is the one a waiting read answers from.
const listing =
probe &&
home &&
(entry ? deps.store.shouldRefresh(fingerprint) : !deps.store.hasActiveFailure(fingerprint))
? deps.store.refresh(fingerprint, params.agent, () => probe(home))
: null
if (!entry) {
return { origin: 'unknown' }
if (!listing) {
return { origin: 'unknown' }
}
if (!params.waitForListing) {
return { origin: 'unknown', listingInProgress: true }
}
entry = await listing
if (!entry) {
return { origin: 'unknown' }
}
}
return resultFromEntry(
entry,
@@ -8,7 +8,11 @@ import {
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
import type { JournalReducerState } from './journal-reducer'
import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold'
import {
notePersonTurnAccepted,
placeHandedOverMessage,
placeRejectedMessage
} from './journal-submission-fold'
import type { JournalRow } from './journal-row-schema'
export function applyJournalDispatchRow(
@@ -34,6 +38,8 @@ export function applyJournalDispatchRow(
if (row.state === 'pending') {
submission.handedOverAt = row.ts
placeHandedOverMessage(state, submission, row)
} else if (row.state === 'rejected') {
placeRejectedMessage(state, submission, row)
}
if (row.recovered) {
submission.recovered = row.recovered
@@ -3,6 +3,7 @@ import type { JournalReducerState } from './journal-reducer'
import { journalLifecycleBatchRowBuilder } from './journal-row-builders'
import type { JournalLifecycleBatchInput } from './journal-store-contracts'
import type { JournalRow } from './journal-row-schema'
import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery'
const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied')
@@ -12,10 +13,38 @@ export class JournalLifecycleBatchAppender {
state: () => JournalReducerState
cursor: () => AgentJournalCursor
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
enqueueRows: (
plan: () => readonly ((seq: number, ts: number) => JournalRow)[]
) => Promise<JournalRow[]>
}
) {}
append(input: JournalLifecycleBatchInput): Promise<AgentJournalCursor> {
const { rejectsQueued } = input
if (rejectsQueued) {
// Planned on the lane: the sends queued then, and this batch unless it already landed. With
// none left (a Stop withdrew them first) it failed no one, so nothing is written.
return this.deps
.enqueueRows(() => {
const rejections = journalQueuedRejectionRowBuilders(
this.deps.state,
input.fence,
rejectsQueued
)
return rejections.length === 0 || this.wasApplied(input.settlementId)
? rejections
: [
...rejections,
journalLifecycleBatchRowBuilder(
this.deps.state,
input.settlementId,
input.mutations,
input
)
]
})
.then(() => this.deps.cursor())
}
if (this.wasApplied(input.settlementId)) {
return Promise.resolve(this.deps.cursor())
}
@@ -2,6 +2,9 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons'
import type { JournalReducerState } from './journal-reducer'
import { journalDispatchRowBuilder } from './journal-row-builders'
import type { JournalRow } from './journal-row-schema'
import type { AgentSessionJournal } from './journal-store'
/** Settles every submission a process fact left unanswerable. Doubt is never
@@ -88,3 +91,21 @@ export async function rejectJournalQueuedSubmissions(
)
return queued.map((entry) => entry.clientMessageId)
}
/** Rows rejecting every submission still queued, read from `state` when called: for an append
* that must carry them with what follows, in one transaction. */
export function journalQueuedRejectionRowBuilders(
state: () => JournalReducerState,
fence: number,
rejection: AgentJournalDispatchRejection
): ((seq: number, ts: number) => JournalRow)[] {
return [...state().submissions.values()].filter(isQueuedAgentJournalSubmission).map((entry) =>
journalDispatchRowBuilder(state, {
clientMessageId: entry.clientMessageId,
state: 'rejected',
...rejection,
fence,
recovered: true
})
)
}
@@ -0,0 +1,129 @@
// A failed start's row and the queued messages it failed land in ONE append, the messages first:
// no reader meets one without the other, and the messages sit above the row that says why.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from './journal-store'
import {
closeTestJournalHostDatabases,
createTrackedJournalOpener
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-start',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'claude',
providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null }
}
const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), {
surface: 'rejection'
})
const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const
let root: string
let clock = 1_000
const journals = createTrackedJournalOpener()
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-'))
})
afterEach(async () => {
await closeTestJournalHostDatabases()
await rm(root, { recursive: true, force: true })
})
async function openWithQueued(...ids: string[]): Promise<AgentSessionJournal> {
const journal = await journals.open({
identity: IDENTITY,
stateDirectory: root,
now: () => (clock += 1),
mintEpoch: () => 'epoch-1'
})
for (const id of ids) {
await journal.appendSubmission({
clientMessageId: id,
payloadFingerprint: `fp-${id}`,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] },
fence: 0,
handoverRecorded: true
})
}
return journal
}
function startFailureBatch(mutations = 1) {
return {
settlementId: 'start-failure:gen-1',
fence: 0,
recovered: true as const,
mutations: Array.from({ length: mutations }, () => ({
kind: 'item' as const,
identity: ERROR_ROW,
body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' },
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})),
rejectsQueued: START_FAILED
}
}
it('writes the rejections first and the row after them, and draws the messages above it', async () => {
const journal = await openWithQueued('first', 'second')
const before = journal.cursor().sequence
await journal.appendLifecycleBatch(startFailureBatch())
expect(journal.cursor().sequence).toBe(before + 3)
expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([
'rejected',
'rejected'
])
const order = journal.snapshot().items.map((item) => item.itemId)
expect(order).toEqual([
agentJournalSubmissionKey('first'),
agentJournalSubmissionKey('second'),
'orca:start-failure%3Agen-1'
])
})
it('writes neither when the row cannot be written', async () => {
const journal = await openWithQueued('first')
const before = journal.cursor().sequence
// An empty batch breaks the row's bound, so the transaction rolls back as a whole.
await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow(
'journal_lifecycle_batch_mutation_bound_exceeded'
)
expect(journal.cursor().sequence).toBe(before)
expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending'])
})
// A Stop that reaches the lane first takes the message back; the failed start then failed no one.
it('writes nothing when a Stop withdrew every queued message first', async () => {
const journal = await openWithQueued('first')
const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), {
surface: 'rejection'
})
await Promise.all([
journal.rejectQueuedSubmissions(0, withdrawal),
journal.appendLifecycleBatch(startFailureBatch())
])
expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' })
expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([
agentJournalSubmissionKey('first')
])
})
@@ -551,7 +551,7 @@ describe('submission and dispatch state machine', () => {
expect(state.receipts.get('cm_1')).toBeTruthy()
})
it('keeps a refused write rejected and leaves its bubble where it was', () => {
it('keeps a refused write rejected, at its rejection, whatever comes after', () => {
const state = fold([
submission,
{
@@ -579,7 +579,8 @@ describe('submission and dispatch state machine', () => {
submittedAt: submission.ts,
reason: 'provider_write_failed: closed before enqueue'
})
expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq)
// It sits where it was rejected; the late `pending` moves nothing.
expect(renderJournalState(state).items[0]?.sequence).toBe(2)
})
it('ignores a dispatch for a submission this epoch never saw', () => {
@@ -6,9 +6,9 @@
// dropped rather than resurrecting stale content, and ordering is by the
// position (sequence, then place in the row) of the write that CREATED an item
// (a later revision updates the body, it does not move the bubble) — except a
// queued message, which sits where its handover put it. Producer linkage is
// likewise the creating write's: a revision naming no producer keeps it, one
// naming any replaces it.
// queued message, which sits where its handover put it, and a rejected one, which
// sits where it was rejected. Producer linkage is likewise the creating write's: a
// revision naming no producer keeps it, one naming any replaces it.
import type {
AgentJournalAcceptanceReceipt,
@@ -0,0 +1,158 @@
// A rejected message sits where it was rejected, in no turn: queued, handed over or sent directly.
// One in doubt stays where it was: it may have reached the agent.
import { describe, expect, it } from 'vitest'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalTurnScope
} from '../../../shared/agent-session-journal-types'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection'
import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection'
import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch'
import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons'
import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer'
import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders'
import type { JournalRow } from './journal-row-schema'
function journal() {
const state = createJournalReducerState('session-1', 'epoch-1')
let seq = 0
const push = (next: JournalRow): JournalRow => {
applyJournalRow(state, next)
return next
}
return {
state,
submission(clientMessageId: string, handoverRecorded = true) {
seq += 1
return push(
buildJournalSubmissionRow({
state,
clientMessageId,
payloadFingerprint: `fp-${clientMessageId}`,
providerHandle: { kind: 'codex', threadId: 'thread-1' },
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: clientMessageId }]
},
seq,
fence: 1,
ts: 1_000 + seq,
...(handoverRecorded ? { handoverRecorded: true } : {})
})
)
},
dispatch(
clientMessageId: string,
state_: 'pending' | 'rejected' | 'unknown',
turnScope: AgentJournalTurnScope = AGENT_JOURNAL_THREAD_SCOPE
) {
seq += 1
return push({
kind: 'dispatch',
clientMessageId,
state: state_,
providerItemId: null,
reason:
state_ === 'rejected'
? DISPATCH_REJECTED_HOST_RESTARTED
: state_ === 'unknown'
? DISPATCH_DOUBT_HOST_RESTARTED
: null,
...(state_ === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}),
...journalRowBase(state.epoch, seq, 1, 1_000 + seq),
turnScope
})
},
/** Other work between the send and its settlement, as a turn running ahead of it would write. */
advance(rows: number) {
seq += rows
state.lastSequence = seq
}
}
}
function placed(state: ReturnType<typeof journal>['state'], clientMessageId: string) {
const item = state.items.get(agentJournalSubmissionKey(clientMessageId))
return item && { sequence: item.sequence, observedAt: item.observedAt, scope: item.turnScope }
}
const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'orca:turn-1' }
describe('a rejected message', () => {
it('sits at the rejection, in no turn, when it was queued', () => {
const { state, submission, dispatch, advance } = journal()
submission('waiting')
advance(300)
dispatch('waiting', 'rejected')
expect(placed(state, 'waiting')).toEqual({
sequence: 302,
observedAt: 1_302,
scope: AGENT_JOURNAL_THREAD_SCOPE
})
})
it('reaches a subscriber at the tail, so the newest page holds it', () => {
const { state, submission, dispatch, advance } = journal()
submission('waiting')
advance(300)
const rejection = dispatch('waiting', 'rejected')
const projected = projectJournalBatch({
rows: [rejection],
snapshot: renderJournalState(state),
afterSequence: 301
})
expect(projected.ok && projected.batch.items.map((item) => item.sequence)).toEqual([302])
expect(renderJournalState(state).items.at(-1)?.itemId).toBe(
agentJournalSubmissionKey('waiting')
)
})
it('sits at the rejection, out of the turn it was handed into, when it was a steer', () => {
const { state, submission, dispatch, advance } = journal()
submission('steer')
advance(10)
dispatch('steer', 'pending', IN_TURN)
expect(placed(state, 'steer')?.scope).toEqual(IN_TURN)
advance(10)
dispatch('steer', 'rejected')
expect(placed(state, 'steer')).toEqual({
sequence: 23,
observedAt: 1_023,
scope: AGENT_JOURNAL_THREAD_SCOPE
})
})
it('sits at the rejection when it was sent directly', () => {
const { state, submission, dispatch, advance } = journal()
submission('direct', false)
advance(10)
dispatch('direct', 'rejected')
expect(placed(state, 'direct')?.sequence).toBe(12)
})
})
describe('a message in doubt', () => {
it('stays where it was handed over, a plain bubble in its turn: it may have reached the agent', () => {
const { state, submission, dispatch, advance } = journal()
submission('steer')
advance(10)
dispatch('steer', 'pending', IN_TURN)
advance(10)
dispatch('steer', 'unknown')
expect(placed(state, 'steer')).toEqual({ sequence: 12, observedAt: 1_012, scope: IN_TURN })
const { items, submissions } = renderJournalState(state)
const drawn = projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: true
}).find((message) => message.id === agentJournalSubmissionKey('steer'))
expect(drawn).toBeDefined()
expect(drawn?.unsent).toBeUndefined()
})
})
@@ -93,4 +93,17 @@ describe('journal row writer', () => {
kind: 'item'
})
})
it('writes several rows as one: a failure on the last leaves none', async () => {
const { writer, committedRows } = writerHarness()
// Sequence 2 is taken, so the second of the two rows violates the primary key.
insertTestJournalRow(database.db, SESSION_ID, row(2, 1))
await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow()
expect(committedRows).toHaveLength(0)
expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual(
[2]
)
})
})
@@ -71,6 +71,40 @@ export class JournalRowWriter {
})
}
/** Several rows in ONE transaction, in order, planned once the lane is this append's: none is
* durable unless all are, so no reader ever meets some without the rest. */
enqueueRows(
plan: () => readonly ((seq: number, ts: number) => JournalRow)[]
): Promise<JournalRow[]> {
return this.deps.serialize(() => {
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
const first = this.deps.nextSequence()
const ts = this.deps.now()
const rows = plan().map((build, index) => build(first + index, ts))
if (rows.length === 0) {
return rows
}
for (const row of rows) {
assertJournalFence(row.fence, this.deps.highestFence())
}
try {
this.deps.database().transaction((db) => {
for (const row of rows) {
insertJournalRow(db, this.deps.sessionId, row)
this.runBookkeeping(db, row)
}
})
} catch (error) {
this.deps.rolledBack?.()
throw error
}
for (const row of rows) {
this.deps.commit(row)
}
return rows
})
}
/** Assign the next sequence, make the row durable, and fold it through the SAME reducer
* replay uses — all inside one serialized step — answering where the row landed. */
append(
@@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence),
committed: host.notifyCommitted
})
const rowWriter = new JournalRowWriter({
sessionId: host.identity.sessionId,
now: host.now,
serialize: host.serialize,
database: host.database,
readOnly: host.readOnly,
highestFence: () => host.state().highestFence,
nextSequence: () => host.state().lastSequence + 1,
commit: host.commit,
// Every rejection is a dispatch row through this one writer; the draft
// returned-transition rides it so no path can bypass the hook.
inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row),
rolledBack: () => queuedMessages.invalidate()
})
return {
epochController,
queuedMessages,
@@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
restoreJournalStore(host, { epochController }).then(() =>
queuedMessages.repairAndPruneAtOpen()
),
rowWriter: new JournalRowWriter({
sessionId: host.identity.sessionId,
now: host.now,
serialize: host.serialize,
database: host.database,
readOnly: host.readOnly,
highestFence: () => host.state().highestFence,
nextSequence: () => host.state().lastSequence + 1,
commit: host.commit,
// Every rejection is a dispatch row through this one writer; the draft
// returned-transition rides it so no path can bypass the hook.
inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row),
rolledBack: () => queuedMessages.invalidate()
}),
rowWriter,
itemAppender: new JournalItemAppender({
state: host.state,
enqueue: host.enqueue
@@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
lifecycleBatchAppender: new JournalLifecycleBatchAppender({
state: host.state,
cursor: host.cursor,
enqueue: host.enqueue
enqueue: host.enqueue,
enqueueRows: (plan) => rowWriter.enqueueRows(plan)
})
}
}
@@ -70,6 +70,10 @@ export type JournalLifecycleBatchInput = {
mutations: readonly JournalLifecycleMutationInput[]
fence: number
recovered?: true
/** Rejects the sends still queued with this first, in the same append: a failed start's row
* follows the messages it failed, and no reader meets one without the other. With none still
* queued, the batch is not written either. */
rejectsQueued?: AgentJournalDispatchRejection
}
export type JournalSubmissionInput = {
@@ -69,6 +69,28 @@ export function placeHandedOverMessage(
})
}
/** A rejected message — queued, handed over, or sent directly — joins the conversation where it was
* rejected, in no turn: what happened before the rejection happened before it, and the newest page
* holds a recent one. Only a rejection: one in doubt may have reached the agent, so it stays. */
export function placeRejectedMessage(
state: JournalReducerState,
submission: AgentJournalSubmission,
row: Extract<JournalRow, { kind: 'dispatch' }>
): void {
const itemId = agentJournalSubmissionKey(submission.clientMessageId)
const item = state.items.get(itemId)
if (row.state !== 'rejected' || !item) {
return
}
const { sequenceIndex: _placed, ...rest } = item
state.items.set(itemId, {
...rest,
sequence: row.seq,
observedAt: row.ts,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
export function acceptSubmissionFromProviderItem(
state: JournalReducerState,
providerItemId: string,
@@ -163,7 +163,9 @@ describe('stated turn scope', () => {
const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')]
const onPhone = () => {
const { items, submissions } = renderJournalState(state)
return projectStructuredAgentSessionMessages(items, [], submissions)
return projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: false
})
}
// Still waiting: drawn after everything the agent did, the command's result included.
expect(drawn(onPhone())).toEqual(expected)
@@ -241,16 +241,16 @@ describe('a start that never finishes (P2-15)', () => {
providerChildPhase: 'starting' as const
}))
Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise })
const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions
vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function (
// The loop rejects the queued messages in the same append as its row.
const append = AgentSessionJournal.prototype.appendLifecycleBatch
vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function (
this: AgentSessionJournal,
...args
) {
// Not the open's sweep of an earlier process's leftovers.
if (args[1].rejection.kind !== 'hostRestarted') {
order.push(`rejected: ${args[1].reason}`)
if (args[0].rejectsQueued) {
order.push(`rejected: ${args[0].rejectsQueued.reason}`)
}
return reject.apply(this, args)
return append.apply(this, args)
})
const reader = collectSubscriber()
const attached = await rig.host.attach(CALLER, hostTestAttachParams(null))
@@ -84,6 +84,8 @@ export function hostTestDrawnRowIds(
state: 'dispatching' as const
}))
return projectNativeChatTranscriptMessages(
projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions)
projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions, {
rejectedInPlace: true
})
).map(({ id }) => id)
}
@@ -0,0 +1,48 @@
// A chat's options at rest come from the host catalog without waiting on a listing.
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service'
import {
AgentModelCatalogStore,
type AgentModelCatalogSuccess
} from '../agent-model-catalog/agent-model-catalog-store'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read'
const SESSION = 'session-1'
function restingRecord(): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
return {
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/homes/a' },
location: { wslDistro: null },
options: {}
} as unknown as AgentSessionRecord
}
describe('options at rest', () => {
it('answers while the first catalog listing is still running', async () => {
const record = restingRecord()
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const modelCatalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getRecord: () => record,
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
probes: { codex: probe }
})
const resting = { child: null, params: { provider: 'codex' } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
const context = {
deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog },
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
openConversation: async () => resting,
conversation: async () => resting
} as unknown as StructuredAgentSessionMutationContext
const result = await readStructuredAgentSessionOptions(context, SESSION)
expect(probe).toHaveBeenCalledTimes(1)
expect(result.models).toEqual([])
})
})
@@ -42,9 +42,9 @@ export function hasStructuredAgentSessionStartFailureRow(
}
/**
* A start the delivery loop needed and did not get: the start's row, and every queued message
* rejected with the same words. Writes nothing when nothing is still queued: a start whose
* messages Stop withdrew did not fail anyone.
* A start the delivery loop needed and did not get: every queued message rejected with the same
* words, then the start's row. Writes nothing when nothing is still queued: a start whose messages
* Stop withdrew did not fail anyone.
*/
export async function recordStructuredAgentSessionStartFailure(
session: Pick<StructuredAgentSessionHostSession, 'journal'> & { fence: number },
@@ -59,11 +59,8 @@ export async function recordStructuredAgentSessionStartFailure(
settlementId: `start-failure:${startKey}`,
fence: session.fence,
recovered: true,
mutations: [structuredAgentSessionStartFailureRow(startKey, failure)]
})
await session.journal.rejectQueuedSubmissions(session.fence, {
reason: failure.reason,
rejection: failure.rejection
mutations: [structuredAgentSessionStartFailureRow(startKey, failure)],
rejectsQueued: { reason: failure.reason, rejection: failure.rejection }
})
}
@@ -33,60 +33,64 @@ describe('Pi Windows foreground recognition', () => {
}
})
it('recognizes the npm entrypoint within the active ConPTY', async () => {
const rows = [
{
pid: 100,
ppid: 99,
name: 'bash.exe',
commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine:
'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js'
}
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'node.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
it.each(['cli.js', String.raw`bundle\cli.js`])(
'recognizes the npm entrypoint dist/%s within the active ConPTY',
async (entrypoint) => {
const rows = [
{
pid: 100,
ppid: 99,
name: 'bash.exe',
commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: `node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\${entrypoint}`
}
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
).resolves.toEqual({ available: true, processName: 'pi', processId: 101 })
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
})
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101]))
it('anchors a collapsed omp name to the omp pid, not the embedded pi leaf', async () => {
// Pi restarts under a live OMP; an anchor on pi's pid would read that as
// OMP's exit and fire a false "agent done" when the next snapshot degrades.
const rows = [
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp' },
{
pid: 102,
ppid: 101,
name: 'node.exe',
commandLine:
'node.exe C:\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js'
}
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 102]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'node.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'pi', processId: 101 })
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
}
)
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
it.each(['cli.js', String.raw`bundle\cli.js`])(
'anchors a collapsed omp name to the omp pid, not the embedded pi leaf at dist/%s',
async (entrypoint) => {
// Pi restarts under a live OMP; an anchor on pi's pid would read that as
// OMP's exit and fire a false "agent done" when the next snapshot degrades.
const rows = [
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp' },
{
pid: 102,
ppid: 101,
name: 'node.exe',
commandLine: `node.exe C:\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\${entrypoint}`
}
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
).resolves.toEqual({ available: true, processName: 'omp', processId: 101 })
})
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 102]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'omp', processId: 101 })
}
)
})
@@ -78,6 +78,7 @@ describe('fetchCursorRateLimits', () => {
await fetchCursorRateLimits({ authReadResult: session() })
const [url, init] = netFetchMock.mock.calls[0] ?? []
expect(url).toBe('https://cursor.com/api/usage-summary')
expect(init?.credentials).toBe('omit')
expect(init?.headers).toMatchObject({
Origin: 'https://cursor.com',
Referer: 'https://cursor.com/dashboard',
+2
View File
@@ -84,6 +84,8 @@ async function fetchDashboardJson(
const res = await net.fetch(url, {
// Keep dashboard redirects visible without forwarding session credentials.
redirect: 'manual',
// The selected account's Cookie must not be replaced by Electron's session jar.
credentials: 'omit',
headers: requestHeaders(session),
signal: requestSignal
})
@@ -56,4 +56,66 @@ describe('agentSession.modelCatalog', () => {
await call('agentSession.modelCatalog', { agent: 'claude' }, STRUCTURED_CLIENT)
expect(read).toHaveBeenCalledWith({ agent: 'claude' })
})
it('passes a wait for the listing through to the catalog', async () => {
await call(
'agentSession.modelCatalog',
{ agent: 'codex', sessionId: SESSION, waitForListing: true },
STRUCTURED_CLIENT
)
expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true })
})
})
describe('agentSession.modelCatalog before anything built the host', () => {
const read = vi.fn(async () => ({
origin: 'probe' as const,
models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }],
fetchedAt: 1
}))
const installHost = vi.fn(async () => {
setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } }))
})
beforeEach(() => {
read.mockClear()
installHost.mockClear()
clearStructuredHostStub()
})
// A new chat's picker reads before its create lands; on a host with no saved chats nothing else
// has built the host yet, and a refusal here left the picker on the client's built-in list.
it('builds the host for a structured chat and answers from its catalog', async () => {
const reply = await call(
'agentSession.modelCatalog',
{ agent: 'codex', sessionId: SESSION },
STRUCTURED_CLIENT,
{ ensureStructuredAgentSessionHost: installHost }
)
expect(installHost).toHaveBeenCalledTimes(1)
expect(reply).toMatchObject({ ok: true, result: { origin: 'probe' } })
expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION })
})
// Terminal-backed chat reads with no session: a host that runs no structured chat keeps its
// journal closed, and the read falls back to the CLI listing.
it('does not build the host for a read that names no session', async () => {
const reply = await call('agentSession.modelCatalog', { agent: 'codex' }, STRUCTURED_CLIENT, {
ensureStructuredAgentSessionHost: installHost
})
expect(installHost).not.toHaveBeenCalled()
expect(reply).toMatchObject({ ok: false })
expect(read).not.toHaveBeenCalled()
})
it('does not build the host for a client that cannot read structured sessions', async () => {
const reply = await call(
'agentSession.modelCatalog',
{ agent: 'codex', sessionId: SESSION },
{ clientKind: 'runtime', clientCapabilities: [] },
{ ensureStructuredAgentSessionHost: installHost }
)
expect(installHost).not.toHaveBeenCalled()
expect(reply).toMatchObject({ ok: false })
})
})
@@ -11,7 +11,7 @@
import { defineMethod } from '../core'
import {
requireInstalledStructuredHost,
requireStructuredHost as requireHost
requireStructuredHost
} from './structured-agent-session-gate'
import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas'
@@ -25,8 +25,14 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
defineMethod({
name: 'agentSession.modelCatalog',
params: ModelCatalogParams,
// A structured chat's read names its session and builds the host, since it may come first;
// terminal-backed chat's session-less read must not open the journal where none runs.
handler: async ({ worktree, ...params }, ctx) => {
const catalog = requireHost(ctx).deps.modelCatalog
const host =
params.sessionId === undefined
? requireStructuredHost(ctx)
: await requireInstalledStructuredHost(ctx)
const catalog = host.deps.modelCatalog
if (!catalog) {
return { origin: 'unknown' as const }
}
+158 -2
View File
@@ -92,9 +92,10 @@ function makeQueue(
function send(
queue: WorkerThreadRequestQueue<Request, Response>,
label: string
label: string,
owner?: { readonly signal: AbortSignal }
): Promise<Response> {
return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS)
return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS, undefined, owner)
}
/** Resolve to the response or to the rejection, so a test can assert on either. */
@@ -328,6 +329,161 @@ describe('WorkerThreadRequestQueue', () => {
expect(await behind).toMatchObject({ label: 'd' })
})
it('keeps one owner fault budget across idle batches and refuses a fourth worker', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const owner = { signal: new AbortController().signal }
try {
for (let fault = 0; fault < 3; fault++) {
const call = settle(send(queue, `owned-${fault}`, owner))
workers.at(-1)?.emit('error', new Error(`fault-${fault}`))
expect(await call).toMatchObject({ message: `fault-${fault}` })
}
const refused = settle(send(queue, 'fourth', owner))
expect(workers).toHaveLength(3)
await expect(refused).resolves.toMatchObject({ message: 'crashed repeatedly (fault-2)' })
const nextScan = send(queue, 'new-scan', { signal: new AbortController().signal })
expect(workers).toHaveLength(4)
workers[3].respond()
await expect(nextScan).resolves.toMatchObject({ label: 'new-scan' })
} finally {
queue.dispose()
}
})
it('resets only the successful owner and does not count idle worker exits', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const a = { signal: new AbortController().signal }
const b = { signal: new AbortController().signal }
try {
const initial = send(queue, 'initial', a)
workers[0].respond()
await initial
workers[0].emit('exit', 17)
for (let fault = 0; fault < 2; fault++) {
const call = settle(send(queue, `a-${fault}`, a))
workers.at(-1)?.emit('error', new Error(`a-fault-${fault}`))
await call
}
const peer = send(queue, 'b-success', b)
workers.at(-1)?.respond()
await peer
const third = settle(send(queue, 'a-third', a))
workers.at(-1)?.emit('error', new Error('a-third-fault'))
await third
const refused = settle(send(queue, 'a-fourth', a))
expect(workers).toHaveLength(4)
await expect(refused).resolves.toMatchObject({
message: 'crashed repeatedly (a-third-fault)'
})
const healthy = send(queue, 'b-still-healthy', b)
workers.at(-1)?.respond()
await expect(healthy).resolves.toMatchObject({ label: 'b-still-healthy' })
} finally {
queue.dispose()
}
})
it('clears a successful owner budget while preserving another owner failure count', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const a = { signal: new AbortController().signal }
const b = { signal: new AbortController().signal }
try {
for (const owner of [a, b]) {
for (let fault = 0; fault < 2; fault++) {
const call = settle(send(queue, 'failure', owner))
workers.at(-1)?.emit('error', new Error('failure'))
await call
}
}
const successful = send(queue, 'a-success', a)
workers.at(-1)?.respond()
await successful
const thirdB = settle(send(queue, 'b-third', b))
workers.at(-1)?.emit('error', new Error('b-third-fault'))
await thirdB
for (let fault = 0; fault < 2; fault++) {
const call = settle(send(queue, 'a-new-failure', a))
workers.at(-1)?.emit('error', new Error('a-new-failure'))
await call
}
const stillAllowed = send(queue, 'a-allowed', a)
expect(workers).toHaveLength(8)
workers.at(-1)?.respond()
await expect(stillAllowed).resolves.toMatchObject({ label: 'a-allowed' })
await expect(settle(send(queue, 'b-refused', b))).resolves.toMatchObject({
message: 'crashed repeatedly (b-third-fault)'
})
expect(workers).toHaveLength(8)
} finally {
queue.dispose()
}
})
it('drains only the failing owner while queued peers keep their FIFO order', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const a = { signal: new AbortController().signal }
const b = { signal: new AbortController().signal }
try {
const failed = ['a1', 'a2', 'a3', 'a4'].map((label) => settle(send(queue, label, a)))
const peer = settle(send(queue, 'peer', b))
const ordinary = settle(send(queue, 'ordinary'))
for (let fault = 0; fault < 3; fault++) {
workers.at(-1)?.emit('error', new Error(`fault-${fault}`))
}
expect(workers).toHaveLength(4)
expect(labels(workers[3])).toEqual(['peer'])
expect((await Promise.all(failed)).at(-1)).toMatchObject({
message: 'crashed repeatedly (fault-2)'
})
workers[3].respond()
await expect(peer).resolves.toMatchObject({ label: 'peer' })
expect(labels(workers[3])).toEqual(['peer', 'ordinary'])
workers[3].respond()
await expect(ordinary).resolves.toMatchObject({ label: 'ordinary' })
} finally {
queue.dispose()
}
})
it('keeps retirement refusals out of the owner failure budget', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
let finish: (code: number) => void = () => {}
const first = new FakeWorker()
first.exit = new Promise((resolve) => {
finish = resolve
})
const queue = makeQueue(workers, {
awaitRetirement: true,
makeWorker: () => (workers.length === 0 ? first : new FakeWorker())
})
const owner = { signal: new AbortController().signal }
try {
const failed = settle(send(queue, 'first', owner))
first.emit('error', new Error('first-fault'))
await failed
for (let attempt = 0; attempt < 4; attempt++) {
await expect(settle(send(queue, 'not-executed', owner))).resolves.toMatchObject({
message: 'unavailable: previous worker still exiting'
})
}
expect(workers).toHaveLength(1)
finish(1)
await vi.advanceTimersByTimeAsync(0)
const recovered = send(queue, 'recovered', owner)
expect(workers).toHaveLength(2)
workers[1].respond()
await expect(recovered).resolves.toMatchObject({ label: 'recovered' })
} finally {
finish(1)
queue.dispose()
}
})
describe('awaitRetirement', () => {
function stalledExit(): { worker: FakeWorker; finish: (code: number) => void } {
const worker = new FakeWorker()
+47 -12
View File
@@ -42,6 +42,10 @@ export type WorkerThreadRequestQueueOptions<TRequest> = {
awaitRetirement?: boolean
}
export type WorkerThreadRequestOwner = { readonly signal: AbortSignal }
type OwnerFailures = { consecutiveDeaths: number; refused: Error | null }
type PendingCall<TRequest, TResponse> = {
request: TRequest
timeoutMs: number
@@ -49,6 +53,7 @@ type PendingCall<TRequest, TResponse> = {
reject: (error: Error) => void
timer: NodeJS.Timeout | null
signal?: AbortSignal
owner?: WorkerThreadRequestOwner
cleanupAbort: () => void
}
@@ -59,6 +64,7 @@ export class WorkerThreadRequestQueue<
private active: PendingCall<TRequest, TResponse> | null = null
private queue: PendingCall<TRequest, TResponse>[] = []
private consecutiveDeaths = 0
private readonly ownerFailures = new WeakMap<WorkerThreadRequestOwner, OwnerFailures>()
private nextId = 1
private disposed = false
private readonly host: LazyWorkerThreadHost<TResponse>
@@ -85,7 +91,8 @@ export class WorkerThreadRequestQueue<
dispatch(
buildRequest: (id: number) => TRequest,
timeoutMs: number,
signal?: AbortSignal
signal?: AbortSignal,
owner?: WorkerThreadRequestOwner
): Promise<TResponse> {
return new Promise((resolve, reject) => {
if (this.disposed) {
@@ -96,6 +103,11 @@ export class WorkerThreadRequestQueue<
reject(signal.reason ?? new Error('Worker request aborted'))
return
}
const refused = owner && this.ownerFailures.get(owner)?.refused
if (refused) {
reject(refused)
return
}
// Built before the cap check so a rejection can name the dropped work;
// the id it burns is only a correlation token, so a gap costs nothing.
const request = buildRequest(this.nextId++)
@@ -106,7 +118,7 @@ export class WorkerThreadRequestQueue<
}
// A fresh burst from full idle starts new work: clear any death count
// carried from a prior burst so the respawn cap can't drain it early.
if (!this.active && this.queue.length === 0) {
if (!owner && !this.active && this.queue.length === 0) {
this.consecutiveDeaths = 0
}
const call: PendingCall<TRequest, TResponse> = {
@@ -116,6 +128,7 @@ export class WorkerThreadRequestQueue<
reject,
timer: null,
signal,
owner,
cleanupAbort: () => signal?.removeEventListener('abort', abort)
}
const abort = (): void => {
@@ -201,7 +214,11 @@ export class WorkerThreadRequestQueue<
this.armDeadline(call)
return
}
this.consecutiveDeaths = 0
if (call.owner) {
this.failuresFor(call.owner).consecutiveDeaths = 0
} else {
this.consecutiveDeaths = 0
}
this.settle(call, () => call.resolve(response))
this.afterSettle()
}
@@ -226,12 +243,16 @@ export class WorkerThreadRequestQueue<
private onWorkerFault(error: Error): void {
const failed = this.active
this.host.destroy()
this.consecutiveDeaths++
if (failed) {
this.settle(failed, () => failed.reject(error))
if (!failed) {
this.pump()
return
}
if (this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) {
this.drainQueueAfterCrashLoop(error)
const deaths = failed.owner
? ++this.failuresFor(failed.owner).consecutiveDeaths
: ++this.consecutiveDeaths
this.settle(failed, () => failed.reject(error))
if (deaths >= this.options.maxConsecutiveDeaths) {
this.drainQueueAfterCrashLoop(error, failed.owner)
return
}
if (this.queue.length > 0) {
@@ -239,14 +260,28 @@ export class WorkerThreadRequestQueue<
}
}
private drainQueueAfterCrashLoop(error: Error): void {
const pending = this.queue
this.queue = []
this.consecutiveDeaths = 0
private drainQueueAfterCrashLoop(error: Error, owner?: WorkerThreadRequestOwner): void {
const pending = this.queue.filter((call) => call.owner === owner)
this.queue = this.queue.filter((call) => call.owner !== owner)
const drainError = new Error(this.options.describeCrashLoop(error.message))
if (owner) {
this.failuresFor(owner).refused = drainError
} else {
this.consecutiveDeaths = 0
}
for (const call of pending) {
this.settle(call, () => call.reject(drainError))
}
this.afterSettle()
}
private failuresFor(owner: WorkerThreadRequestOwner): OwnerFailures {
let failures = this.ownerFailures.get(owner)
if (!failures) {
failures = { consecutiveDeaths: 0, refused: null }
this.ownerFailures.set(owner, failures)
}
return failures
}
private failQueuedAsUnavailable(): void {
@@ -22,7 +22,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
const ANDROID_COPY: InstallCopy = {
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk'
}
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
@@ -10,6 +10,8 @@ import { NativeChatMessageList } from './NativeChatMessageList'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
@@ -42,7 +44,7 @@ function transcript(items: AgentJournalRenderItem[]) {
return (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], []),
messages: projectStructuredAgentSessionMessages(items, [], [], NO_CARDS),
status: 'ready',
sessionId: 'live-codex',
agent: 'codex',
@@ -0,0 +1,204 @@
// @vitest-environment happy-dom
// A message the host recorded and then rejected is drawn from the host's own row where the journal
// placed it, marked not sent and in no turn: the newer turn's "Working for" clock and "Worked for"
// bar stay with that turn, never under it.
import '@testing-library/jest-dom/vitest'
import { cleanup, render } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnScope
} from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import { NativeChatMessageList } from './NativeChatMessageList'
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
type Phase = 'in flight' | 'running' | 'done'
const SEED = agentJournalSubmissionKey('seed')
const NEW = agentJournalSubmissionKey('new')
function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody {
return { kind: 'message', role, blocks: [{ type: 'text', text }] }
}
function journal(phase: Phase, scoped: boolean): AgentJournalRenderItem[] {
const thread: AgentJournalTurnScope = { kind: 'thread' }
const row = (
itemId: string,
body: AgentJournalItemBody,
turnScope: AgentJournalTurnScope
): AgentJournalRenderItem => ({
itemId,
revision: 0,
sequence: rows.length + 1,
observedAt: 1000 + rows.length,
body,
...(scoped ? { turnScope } : {})
})
const rows: AgentJournalRenderItem[] = []
rows.push(row(SEED, said('user', 'SEED PROMPT'), thread))
rows.push(
row(
'turn-seed',
{ kind: 'turn', turnId: 'turn-seed', state: 'completed', userItemId: SEED },
thread
)
)
rows.push(
row('seed-answer', said('assistant', 'SEED OK'), { kind: 'turn', turnItemId: 'turn-seed' })
)
rows.push(row(NEW, said('user', 'NEW PROMPT'), thread))
if (phase !== 'in flight') {
rows.push(
row(
'turn-new',
{
kind: 'turn',
turnId: 'turn-new',
state: phase === 'done' ? 'completed' : 'running',
userItemId: NEW
},
thread
)
)
}
if (phase === 'done') {
rows.push(
row('new-answer', said('assistant', 'NEW OK'), { kind: 'turn', turnItemId: 'turn-new' })
)
}
return rows
}
function submission(
id: string,
dispatchState: AgentJournalSubmission['dispatchState']
): AgentJournalSubmission {
return {
clientMessageId: id,
fence: 1,
payloadFingerprint: id,
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
/** The drawn sequence of the prompts, bars and live activity line, top to bottom. */
function drawn(container: HTMLElement): string[] {
const out: string[] = []
for (const element of container.querySelectorAll('*')) {
if (element.hasAttribute('data-native-chat-turn-activity')) {
out.push('ACTIVITY')
} else if (element.hasAttribute('data-native-chat-turn-status')) {
out.push(
element.getAttribute('data-native-chat-turn-status') === 'active' ? 'WORKING' : 'WORKED'
)
} else if (element.childElementCount === 0 && (element.textContent ?? '').endsWith('PROMPT')) {
out.push(element.textContent ?? '')
}
}
return out
}
describe('a message the host rejected after a crash, with no outbox entry left', () => {
const LOST = agentJournalSubmissionKey('lost')
function hostList(phase: Phase) {
const items = journal(phase, true)
const newIndex = items.findIndex((item) => item.itemId === NEW)
// Recorded between the seed's answer and the newer prompt; the next open rejected it.
items.splice(newIndex, 0, {
itemId: LOST,
revision: 0,
sequence: items[newIndex - 1]!.sequence,
sequenceIndex: 1,
observedAt: 1002.5,
body: said('user', 'LOST PROMPT'),
turnScope: { kind: 'thread' }
})
const submissions: AgentJournalSubmission[] = [
submission('seed', 'accepted'),
{
...submission('lost', 'rejected'),
reason: DISPATCH_REJECTED_HOST_RESTARTED,
rejection: { kind: 'hostRestarted' }
},
submission('new', phase === 'done' ? 'accepted' : 'pending')
]
const settledTurns: NativeChatSettledTurns = new Map([
[SEED, { startedAt: 1, workedSeconds: 3 }],
...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : [])
])
return (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS),
status: phase === 'done' ? 'ready' : 'working',
sessionId: 'session-1',
agent: 'claude',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
deliveryNotices={structuredAgentSessionDeliveryNotices([], 'Claude', submissions, [])}
isWorking={phase !== 'done'}
workingStartedAt={phase === 'done' ? null : Date.now() - 1500}
settledTurns={settledTurns}
expandSignal={false}
fontScale={1}
/>
)
}
it('draws it where it was recorded, muted with its reason and no control, outside the newer turn', () => {
const { container, rerender } = render(hostList('running'))
expect(drawn(container)).toEqual([
'SEED PROMPT',
'WORKED',
'LOST PROMPT',
'NEW PROMPT',
'WORKING',
'ACTIVITY'
])
expect(container.textContent).toContain('Orca restarted before this message was sent.')
expect(container.querySelector('button[aria-label="Retry"]')).toBeNull()
expect(
[...container.querySelectorAll('button')].map((button) => button.textContent)
).not.toContain('Retry')
rerender(hostList('done'))
expect(drawn(container)).toEqual([
'SEED PROMPT',
'WORKED',
'LOST PROMPT',
'NEW PROMPT',
'WORKED'
])
expect(container.textContent).toContain('Worked for 5s')
})
})
@@ -143,7 +143,9 @@ describe('native chat transcript re-render cost during a streaming turn', () =>
}
const view = (items: AgentJournalRenderItem[]) => (
<NativeChatMessageList
session={sessionWith(projectStructuredAgentSessionMessages(items, [], []))}
session={sessionWith(
projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true })
)}
journalItems={items}
isWorking={false}
expandSignal={false}
@@ -32,7 +32,7 @@ afterEach(cleanup)
const EMPTY: never[] = []
const loadEarlier = () => Promise.resolve('exhausted' as const)
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY)
const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY)
const session: NativeChatLiveSession = {
messages,
status: 'working',
@@ -88,7 +88,9 @@ function journalList(
return (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], submissions),
messages: projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: true
}),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
// Against the real menu: a `/model` request opens the menu once, and a period while the host still
// lists models neither opens it later nor reopens one the user closed.
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
SessionOptionDescriptor,
SessionOptionsSurface
} from '../../../../shared/native-chat-session-options'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
values
? Object.entries(values).reduce(
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
fallback
)
: fallback
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
function model(pending: boolean): SessionOptionDescriptor {
return {
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'opus',
choices: [
{ value: 'opus', label: 'Opus 4.8' },
{ value: 'sonnet', label: 'Sonnet 5' }
]
},
valueSource: 'applied',
transport: 'agent-session',
settable: !pending,
...(pending ? { choicesPending: true as const } : {})
}
}
const surface: SessionOptionsSurface = {
getSnapshot: () => [],
setOption: vi.fn(async () => ({ snapshot: [] })),
invokeAction: vi.fn(async () => ({ snapshot: [] })),
subscribe: () => () => {}
}
function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element {
return (
<TooltipProvider>
<textarea data-testid="composer" />
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(pending)]}
isWorking={false}
pickerRequest={request}
/>
</TooltipProvider>
)
}
const settle = (): Promise<void> =>
act(async () => new Promise((resolve) => setTimeout(resolve, 30)))
afterEach(() => cleanup())
describe('model menu requests while the host lists models', () => {
it('spends a request made while pending without opening the menu once the list lands', async () => {
const request = { id: 'model', sequence: 1 }
const { rerender } = render(view(true, request))
const composer = screen.getByTestId('composer')
composer.focus()
await settle()
expect(screen.queryByRole('menu')).toBeNull()
rerender(view(false, request))
await settle()
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(composer)
// A new request after the list landed still opens it.
rerender(view(false, { id: 'model', sequence: 2 }))
await settle()
expect(screen.queryByRole('menu')).not.toBeNull()
})
it('does not reopen a menu the user closed when a pending period ends', async () => {
const request = { id: 'model', sequence: 1 }
const { rerender } = render(view(false, request))
await settle()
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
await settle()
expect(screen.queryByRole('menu')).toBeNull()
rerender(view(true, request))
await settle()
rerender(view(false, request))
await settle()
expect(screen.queryByRole('menu')).toBeNull()
})
})
@@ -341,6 +341,44 @@ describe('NativeChatSessionOptionPickers', () => {
).toBe('true')
})
it('keeps the model pill shut, label intact, while its choices are still listed', async () => {
const pending = model({ settable: false, choicesPending: true })
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[pending, effort]}
isWorking={false}
pickerRequest={{ id: 'model', sequence: 1 }}
/>
)
const trigger = screen.getByRole('button', { name: 'Model Opus 4.8' })
expect(trigger.parentElement?.getAttribute('data-disabled')).toBe('true')
expect(trigger.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).toBe(
'false'
)
expect(
screen
.getByRole('button', { name: 'Effort High' })
.parentElement?.getAttribute('data-disabled')
).toBeNull()
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={{ id: 'model', sequence: 2 }}
/>
)
const listed = screen.getByRole('button', { name: 'Model Opus 4.8' })
expect(listed.parentElement?.getAttribute('data-disabled')).toBeNull()
await waitFor(() =>
expect(listed.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).toBe(
'true'
)
)
})
it('does not duplicate titles for unknown values or misname generic controls', () => {
const { rerender } = render(
<NativeChatSessionOptionPickers
@@ -226,6 +226,8 @@ function NativeChatSessionOptionPickersInner({
if (!surface || !model) {
return null
}
// Still listed by the host: the pill shows its value and does not open, even on request.
const modelChoicesPending = model.choicesPending === true
const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null
const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id)
? (pickerRequest?.sequence ?? null)
@@ -250,12 +252,13 @@ function NativeChatSessionOptionPickersInner({
<div className="flex min-w-0 items-center gap-0.5">
<DropdownMenu
key={`model:${requestedModelSequence ?? 'idle'}`}
defaultOpen={requestedModelSequence !== null}
// Read only when a request remounts the menu: one made while pending is spent shut.
defaultOpen={requestedModelSequence !== null && !modelChoicesPending}
>
<PickerTrigger
label={nativeChatModelPillLabel(model)}
tooltipLabel={modelTooltip}
disabled={isWorking || pendingId !== null}
disabled={isWorking || pendingId !== null || modelChoicesPending}
disabledReason={modelReason}
dispatched={sessionOptionDispatchUnconfirmed(model)}
/>
@@ -94,7 +94,9 @@ function renderPane(messages: ReturnType<typeof rejected>[]): void {
]
const submissions = messages.map((message) => message.submission)
mocks.submissions = submissions
mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions)
mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions, {
rejectedInPlace: true
})
render(
<NativeChatStructuredSession
isVisible
@@ -192,7 +194,9 @@ it('words a recorded rejection this window holds no outbox entry for, with no Re
).submission
]
mocks.submissions = submissions
mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions)
mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions, {
rejectedInPlace: true
})
render(
<NativeChatStructuredSession
isVisible
@@ -171,6 +171,7 @@ export function createStructuredSessionMocks() {
}) => {
mocks.controllerProps = props
const outbox = useStructuredAgentSessionOutbox({
journalItems: mocks.journalItems,
sessionId: props.sessionId,
target: props.target,
fence: props.transportEnabled === false ? null : 1,
@@ -181,7 +182,9 @@ export function createStructuredSessionMocks() {
messages:
mocks.messages ??
(mocks.mode === 'outbox'
? projectStructuredAgentSessionMessages([], outbox.outbox, [])
? projectStructuredAgentSessionMessages([], outbox.outbox, [], {
rejectedInPlace: true
})
: [
{
id: 'message-1',
@@ -117,6 +117,7 @@ export function NativeChatStructuredSession(
journalItems: controller.journalItems,
submissions: controller.submissions,
outbox: controller.outbox,
queuedMessageIds: controller.queuedMessageIds,
agentLabel,
// A lost outcome may still resolve while the agent works or starts.
agentActive:
@@ -62,7 +62,9 @@ vi.mock('./use-structured-agent-session', async () => {
journalItems: [],
messages:
mocks.mode === 'outbox'
? projectStructuredAgentSessionMessages([], outbox.outbox, [])
? projectStructuredAgentSessionMessages([], outbox.outbox, [], {
rejectedInPlace: true
})
: [
{
id: 'message-1',
@@ -0,0 +1,62 @@
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
// One waiting catalog read per chat, shared by every mount and effect run of that chat's picker:
// a remote read cannot be withdrawn once sent, so a re-run joins the one in flight instead of
// sending another. An entry lives exactly as long as its read.
/** Null when the read failed or timed out. */
export type HostModelListingJoiner = (catalog: AgentSessionModelCatalogResult | null) => void
const waits = new Map<string, Set<HostModelListingJoiner>>()
const listeners = new Set<() => void>()
function notify(): void {
for (const listener of listeners) {
listener()
}
}
/** Joins the chat's waiting read, started by `read` only when none is in flight. Returns `leave`. */
export function joinHostModelListingWait(
key: string,
read: () => Promise<AgentSessionModelCatalogResult>,
onSettled: HostModelListingJoiner
): () => void {
let joiners = waits.get(key)
if (!joiners) {
const started = new Set<HostModelListingJoiner>()
joiners = started
waits.set(key, started)
const settle = (catalog: AgentSessionModelCatalogResult | null): void => {
try {
// Every joiner applies before the hold lifts, so the answer and the release commit together.
for (const joiner of started) {
joiner(catalog)
}
} finally {
waits.delete(key)
notify()
}
}
new Promise<AgentSessionModelCatalogResult>((resolve) => resolve(read())).then(settle, () =>
settle(null)
)
notify()
}
joiners.add(onSettled)
const joined = joiners
return () => {
joined.delete(onSettled)
}
}
export function isHostModelListingWaitInFlight(key: string): boolean {
return waits.has(key)
}
export function subscribeHostModelListingWaits(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
@@ -19,6 +19,8 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) {
return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body }
}
@@ -68,7 +70,7 @@ const JOURNAL: AgentJournalRenderItem[] = [
/** The renderer's own path from journal items to rail items, as the list runs it. */
function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) {
const projected = createNativeChatMessageListProjection()(
projectStructuredAgentSessionMessages(items, [], submissions)
projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS)
).conversation
const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected))
let turn: string | undefined
@@ -10,6 +10,7 @@ import {
LOCAL_COMMIT_MESSAGE_HOST_KEY
} from '../../../../shared/commit-message-host-key'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { hasExplicitTuiLaunchCommand } from '../../../../shared/tui-agent-launch-command-override'
import { getConnectionIdFromState } from '@/lib/connection-context'
import {
getLocalProjectExecutionRuntimeContext,
@@ -126,7 +127,12 @@ export async function discoverNativeChatCatalogModels(
const hostCatalogAgent =
agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null
// Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees.
if (hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY) {
// A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it.
if (
hostCatalogAgent &&
hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY &&
!hasExplicitTuiLaunchCommand(context.settings, hostCatalogAgent)
) {
const fromHost = await readLocalHostCatalogModels(hostCatalogAgent)
if (fromHost) {
return fromHost
@@ -59,6 +59,24 @@ describe('native chat session option enrichment', () => {
expect(paired?.map(({ id }) => id)).toContain('gpt-cli')
})
it('lists through the CLI when this machine runs a custom launch command', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'probe',
models: [{ id: 'gpt-custom', label: 'GPT Custom' }]
})
const context = {
settings: { agentCmdOverrides: { codex: 'codex-nightly' } },
worktreeId: 'repo::/worktree',
worktreePath: '/worktree'
}
// The structured catalog lists the built-in binary, which this terminal does not run.
const models = await discoverNativeChatCatalogModels('codex', context, 'local')
expect(mocks.callStructuredAgentSession).not.toHaveBeenCalled()
expect(models?.map(({ id }) => id)).toContain('gpt-custom')
})
it('bounds settled host enrichment entries', async () => {
for (let index = 0; index < NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES + 4; index += 1) {
ensureNativeChatModelEnrichment({
@@ -41,7 +41,8 @@ function row(
return {
clientMessageId,
fence: 1,
payloadFingerprint: 'fp',
// Each its own body: a later copy of the same body would supersede a rejected one.
payloadFingerprint: `fp-${clientMessageId}`,
dispatchState,
providerItemId: null,
reason: null,
@@ -3,17 +3,22 @@
// - one in this client's outbox reads quietly as sending until the host holds a row for it
// (pending or accepted): nothing in the outbox has failed, since a send that ends leaves it;
// - one the host recorded and then did not deliver says so, muted, on every client, worded from
// the journal's own fact. A rejection a loaded host row already states (a failed start's, or a
// command's result row) says only that it was not sent: the row already says why;
// the journal's own fact, wherever the chat draws it (its row, or this client's copy until that
// row loads), and only where it does (structuredAgentSessionRejectedShownInPlace). A rejection a
// loaded host row already states (a failed start's, or a command's result row) says only that it
// was not sent: the row already says why;
// - one whose outcome the host lost when the process sending it went away says it is not
// confirmed, once nothing is running that could still deliver it.
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off'
import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection'
import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
import { isRecoveredStructuredAgentSessionSubmission } from '../../../../shared/structured-agent-session-unanswered-dispatch'
import { translate } from '@/i18n/i18n'
@@ -22,12 +27,13 @@ import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
/** One shared value, so a rebuilt map re-renders no row still sending. */
const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { sending: true }
const NO_CARDS: readonly string[] = []
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
/** The host holds a row that is not in doubt: pending, accepted, or rejected. */
function hostHoldsIt(submissions: readonly AgentJournalSubmission[], id: string): boolean {
return submissions.some(
(submission) =>
submission.clientMessageId === id &&
(submission.dispatchState === 'pending' || submission.dispatchState === 'accepted')
(submission) => submission.clientMessageId === id && submission.dispatchState !== 'unknown'
)
}
@@ -36,19 +42,24 @@ function hostHoldsIt(submissions: readonly AgentJournalSubmission[], id: string)
export function structuredAgentSessionDeliveryNotices(
outbox: readonly StructuredAgentSessionOutboxEntry[],
agentName: string,
/** The journal's rows: rejected ones carry their whole fact, and a message with no pending or
* accepted one is still sending. */
/** The journal's rows: rejected ones carry their whole fact, and a message the host holds no
* settled or pending row for is still sending. */
submissions: readonly AgentJournalSubmission[],
/** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
startFailures: readonly AgentSessionFailureFact[],
/** Commands whose loaded result row says how they ended, from `structuredAgentSessionCommandResultRows`. */
commandResults?: ReadonlySet<string>,
/** Whether the agent is working or starting, so a lost outcome may still resolve. */
agentActive = false
agentActive = false,
/** The queue's live cards: a rejected message one holds is drawn as that card, not as a row. */
queuedMessageIds: readonly string[] = NO_CARDS,
/** The loaded rows: a rejected message's outbox copy leaves once its row is here. */
journalItems: readonly AgentJournalRenderItem[] = NO_ITEMS
): ReadonlyMap<string, NativeChatDeliveryNotice> {
const notices = new Map<string, NativeChatDeliveryNotice>()
// As the transcript reads it, so a row that lands is answered here before the outbox commits it.
for (const entry of reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)) {
const entries = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions, journalItems)
for (const entry of entries) {
if (!hostHoldsIt(submissions, entry.clientMessageId)) {
notices.set(
agentJournalSubmissionKey(entry.clientMessageId),
@@ -56,12 +67,15 @@ export function structuredAgentSessionDeliveryNotices(
)
}
}
const shown = submissions.some((submission) => submission.dispatchState === 'rejected')
? structuredAgentSessionRejectedShownInPlace(submissions, queuedMessageIds)
: new Set<string>()
for (const submission of submissions) {
const id = agentJournalSubmissionKey(submission.clientMessageId)
if (notices.has(id)) {
continue
}
if (submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)) {
if (shown.has(id)) {
notices.set(id, {
muted: true,
text: agentSessionWriteNoticeText(
@@ -0,0 +1,163 @@
// An older host leaves a rejected message where it was sent. When that is older than the loaded
// window, the chat holds its rejected submission but not its row: the outbox copy keeps drawing it,
// muted, with no control, until the page holding the row loads, and then the host's row draws it.
import { expect, it } from 'vitest'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
reduceStructuredAgentSession
} from '../../../../shared/structured-agent-session-reducer'
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
const MESSAGE_ID = agentJournalSubmissionKey('m')
function answer(sequence: number): AgentJournalRenderItem {
return {
itemId: `a-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] }
}
}
/** Where the older host left it: at its submission, far behind the loaded window. */
const SENT: AgentJournalRenderItem = {
itemId: MESSAGE_ID,
revision: 1,
sequence: 10,
observedAt: 10,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued msg' }] }
}
const REJECTED: AgentJournalSubmission = {
clientMessageId: 'm',
fence: 1,
payloadFingerprint: 'x',
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_HOST_RESTARTED,
rejection: { kind: 'hostRestarted' },
submittedAt: 10,
resolvedAt: 2000
}
function page(
items: AgentJournalRenderItem[],
submissions: AgentJournalSubmission[],
hasOlder: boolean
): AgentSessionHistoryPage {
const oldest = items[0]?.sequence ?? 0
const newest = items.at(-1)?.sequence ?? 0
return {
sessionId: 's',
epoch: 'e',
direction: 'tail',
items,
removedItemIds: [],
submissions,
window: {
oldest: { epoch: 'e', sequence: oldest },
newest: { epoch: 'e', sequence: newest },
nextCursor: { epoch: 'e', sequence: oldest }
},
liveCursor: { epoch: 'e', sequence: 1099 },
hasOlder,
hasNewer: false
}
}
it("keeps the outbox copy of a rejected message whose row is outside the window, then the host's row takes over", () => {
let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 's',
fence: 1,
page: page(
Array.from({ length: 100 }, (_, index) => answer(1000 + index)),
[],
true
)
}
})
// The rejection touches the row, which the older host left at its submission.
state = reduceStructuredAgentSession(state, {
type: 'event',
event: {
type: 'batch',
sessionId: 's',
batch: {
cursor: { epoch: 'e', sequence: 1100 },
items: [SENT],
removedItemIds: [],
submissions: [REJECTED]
}
}
})
expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(false)
expect(state.submissions.map((submission) => submission.clientMessageId)).toEqual(['m'])
const sent = {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: 'm',
sessionId: 's',
text: 'queued msg',
attachments: [],
queuedAt: 10
}),
state: 'dispatching' as const
}
const kept = reconcileStructuredAgentSessionOutboxWithQueue(
[sent],
state.submissions,
state.items
)
expect(kept).toEqual([sent])
const drawn = (outbox: typeof kept) =>
projectStructuredAgentSessionMessages(state.items, outbox, state.submissions, NO_CARDS)
.filter((message) => message.role === 'user')
.map(({ id, unsent }) => ({ id, unsent }))
expect(drawn(kept)).toEqual([{ id: MESSAGE_ID, unsent: true }])
const notice = structuredAgentSessionDeliveryNotices(
kept,
'Claude',
state.submissions,
[],
undefined,
false,
NO_CARDS,
state.items
).get(MESSAGE_ID)
expect(notice).toEqual({ muted: true, text: 'Orca restarted before this message was sent.' })
// Paging back loads the row: the outbox lets go, and the host's row is the one drawn.
state = reduceStructuredAgentSession(state, {
type: 'older-page',
requestedCursor: { epoch: 'e', sequence: 1000 },
page: page(
[SENT, ...Array.from({ length: 10 }, (_, index) => answer(990 + index))],
[REJECTED],
false
)
})
expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(true)
const released = reconcileStructuredAgentSessionOutboxWithQueue(
kept,
state.submissions,
state.items
)
expect(released).toEqual([])
expect(drawn(released)).toEqual([{ id: MESSAGE_ID, unsent: true }])
})
@@ -0,0 +1,218 @@
// An outbox copy of a message the host recorded and then rejected draws it only while the host's row
// is not loaded, and leaves, with no user action, on the batch or page that loads that row. It never
// offers a control: sending it again is a new message. Nothing is stored to say it was rejected:
// the held submission says so.
import { expect, it } from 'vitest'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off'
import {
createStructuredAgentSessionOutboxEntry,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
reduceStructuredAgentSession,
type StructuredAgentSessionState
} from '../../../../shared/structured-agent-session-reducer'
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
const MESSAGE_ID = agentJournalSubmissionKey('m')
const WORDS = 'Orca restarted before this message was sent.'
const NOT_SENT = { muted: true, text: WORDS }
function answer(sequence: number): AgentJournalRenderItem {
return {
itemId: `a-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] }
}
}
function hostRow(sequence: number): AgentJournalRenderItem {
return {
itemId: MESSAGE_ID,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'sent text' }] }
}
}
const REJECTED: AgentJournalSubmission = {
clientMessageId: 'm',
fence: 1,
payloadFingerprint: 'm',
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_HOST_RESTARTED,
rejection: { kind: 'hostRestarted' },
submittedAt: 10,
resolvedAt: 1100
}
function copy(patch: Partial<StructuredAgentSessionOutboxEntry> = {}) {
return {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: 'm',
sessionId: 's',
text: 'sent text',
attachments: [],
queuedAt: 10
}),
...patch
}
}
/** A copy whose send went out, as the outbox stores it however the host answered. */
const SENT_COPY = copy({ state: 'dispatching', lastAttemptAt: 9 })
function page(
items: AgentJournalRenderItem[],
submissions: AgentJournalSubmission[]
): AgentSessionHistoryPage {
const oldest = items[0]?.sequence ?? 0
return {
sessionId: 's',
epoch: 'e',
direction: 'tail',
items,
removedItemIds: [],
submissions,
window: {
oldest: { epoch: 'e', sequence: oldest },
newest: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 },
nextCursor: { epoch: 'e', sequence: oldest }
},
liveCursor: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 },
hasOlder: true,
hasNewer: false
}
}
function opened(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) {
return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: { type: 'snapshot', sessionId: 's', fence: 1, page: page(items, submissions) }
})
}
const WINDOW = Array.from({ length: 100 }, (_, index) => answer(1000 + index))
/** What the chat draws for this message, and the notice its row carries. */
function shown(
state: StructuredAgentSessionState,
outbox: readonly StructuredAgentSessionOutboxEntry[]
) {
const kept = reconcileStructuredAgentSessionOutboxWithQueue(
outbox,
state.submissions,
state.items
)
const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions, NO_CARDS)
.filter((message) => message.role === 'user')
.map(({ id, unsent }) => ({ id, unsent }))
const notice = structuredAgentSessionDeliveryNotices(
kept,
'Claude',
state.submissions,
[],
undefined,
false,
NO_CARDS,
state.items
).get(MESSAGE_ID)
return { kept, rows, notice }
}
it('leaves on the live batch that rejects it on a host that places the row at the rejection', () => {
let state = opened(WINDOW, [])
state = reduceStructuredAgentSession(state, {
type: 'event',
event: {
type: 'batch',
sessionId: 's',
batch: {
cursor: { epoch: 'e', sequence: 1100 },
items: [hostRow(1100)],
removedItemIds: [],
submissions: [REJECTED]
}
}
})
expect(shown(state, [SENT_COPY])).toEqual({
kept: [],
rows: [{ id: MESSAGE_ID, unsent: true }],
notice: NOT_SENT
})
})
it('leaves on the page that opens the chat when that page holds the row', () => {
const state = opened([...WINDOW, hostRow(1100)], [REJECTED])
expect(shown(state, [SENT_COPY])).toEqual({
kept: [],
rows: [{ id: MESSAGE_ID, unsent: true }],
notice: NOT_SENT
})
})
it('draws once, with no control, while its row is outside the window, and leaves when that page loads', () => {
// An older host leaves the row where it was sent; the live batch brings only its record.
let state = reduceStructuredAgentSession(opened(WINDOW, []), {
type: 'event',
event: {
type: 'batch',
sessionId: 's',
batch: {
cursor: { epoch: 'e', sequence: 1100 },
items: [hostRow(10)],
removedItemIds: [],
submissions: [REJECTED]
}
}
})
const before = shown(state, [SENT_COPY])
expect(before.kept).toEqual([SENT_COPY])
expect(before.rows).toEqual([{ id: MESSAGE_ID, unsent: true }])
expect(before.notice).toEqual(NOT_SENT)
// Scrolling back loads the page; no control on the copy is involved.
state = reduceStructuredAgentSession(state, {
type: 'older-page',
requestedCursor: { epoch: 'e', sequence: 1000 },
page: page(
[hostRow(10), ...Array.from({ length: 5 }, (_, index) => answer(995 + index))],
[REJECTED]
)
})
expect(shown(state, before.kept)).toEqual({
kept: [],
rows: [{ id: MESSAGE_ID, unsent: true }],
notice: NOT_SENT
})
})
// Until the journal holds the rejection, nothing says the host has it: the copy reads as sending,
// and goes again only under its own id, which the host records at most once.
it('reads a stored copy whose record is not held yet as sending, and the drain passes over it', () => {
const state = opened(WINDOW, [])
const after = shown(state, [SENT_COPY])
expect(after.kept).toEqual([SENT_COPY])
expect(after.rows).toEqual([{ id: MESSAGE_ID, unsent: undefined }])
expect(after.notice).toEqual({ sending: true })
expect(admitStructuredAgentSessionOutboxEntry(after.kept)).toEqual({ state: 'idle', entry: null })
})
@@ -0,0 +1,454 @@
// A message the host accepted and then rejected stays in the desktop's chat where it was sent,
// marked not sent, from the host's own history: a crash can lose the outbox, never the host's row.
import { describe, expect, it } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_HOST_RESTARTED
} from '../../../../shared/structured-agent-session-dispatch-rejection'
import { projectStructuredAgentSessionMessages as projectShared } from '../../../../shared/structured-agent-session-message-projection'
import {
createStructuredAgentSessionOutboxEntry,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
/** The delivery notices, with the arguments these cases vary. */
function notices(
outbox: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[],
options: {
startFailures?: Parameters<typeof structuredAgentSessionDeliveryNotices>[3]
cards?: readonly string[]
items?: readonly AgentJournalRenderItem[]
} = {}
) {
return structuredAgentSessionDeliveryNotices(
outbox,
'Claude',
submissions,
options.startFailures ?? [],
undefined,
false,
options.cards ?? NO_CARDS,
options.items ?? []
)
}
const SESSION = 'session-1'
function body(text: string) {
return {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text }]
}
}
// Same text, same fingerprint, as the host's body-only hash gives.
function fingerprint(text: string): string {
return `body:${text}`
}
function userItem(id: string, sequence: number, text: string): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(id),
revision: 1,
sequence,
observedAt: sequence,
body: body(text)
}
}
function answer(sequence: number): AgentJournalRenderItem {
return {
itemId: `answer-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] }
}
}
function submission(
id: string,
text: string,
submittedAt: number,
patch: Partial<AgentJournalSubmission> = {}
): AgentJournalSubmission {
return {
clientMessageId: id,
fence: 1,
payloadFingerprint: fingerprint(text),
dispatchState: 'accepted',
providerItemId: `provider-${id}`,
reason: null,
submittedAt,
resolvedAt: submittedAt,
...patch
}
}
/** Rejected on the next open after a crash, before the agent ever got it. */
function restartRejected(id: string, text: string, submittedAt: number): AgentJournalSubmission {
return submission(id, text, submittedAt, {
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_HOST_RESTARTED,
rejection: { kind: 'hostRestarted' }
})
}
function withdrawn(id: string, text: string, submittedAt: number): AgentJournalSubmission {
return submission(id, text, submittedAt, {
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_CANCELLED,
rejection: { kind: 'cancelled' }
})
}
function outboxEntry(
id: string,
text: string,
patch: Partial<StructuredAgentSessionOutboxEntry> = {}
): StructuredAgentSessionOutboxEntry {
return {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: id,
sessionId: SESSION,
text,
attachments: [],
queuedAt: 50
}),
...patch
}
}
function rows(messages: ReturnType<typeof projectStructuredAgentSessionMessages>) {
return messages
.filter((message) => message.role === 'user')
.map((message) => ({
id: message.id,
text: message.blocks[0]?.type === 'text' ? message.blocks[0].text : null,
unsent: message.unsent ?? false
}))
}
const SEED = submission('seed', 'seed', 1)
const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)]
describe('a message the host accepted and then rejected, on the desktop', () => {
it('stays where it was sent, as not sent, with no outbox entry left after a crash', () => {
const items = [...SEED_ROWS, userItem('lost', 3, 'fix the parser')]
const messages = projectStructuredAgentSessionMessages(
items,
[],
[SEED, restartRejected('lost', 'fix the parser', 3)],
NO_CARDS
)
expect(rows(messages)).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true }
])
// Its place is the host's: the row keeps the journal position it was recorded at.
expect(
messages.find((message) => message.id === agentJournalSubmissionKey('lost'))
).toMatchObject({ journalPosition: { sequence: 3, index: 0 }, source: 'transcript' })
})
// An older host never moves it to its rejection: it stays at its submission, still drawn.
it('keeps the position an older host gave it, however far back', () => {
const later = Array.from({ length: 300 }, (_, index) => answer(4 + index))
const items = [...SEED_ROWS, userItem('waited', 3, 'fix the parser'), ...later]
const messages = projectStructuredAgentSessionMessages(
items,
[],
[SEED, restartRejected('waited', 'fix the parser', 3)],
NO_CARDS
)
expect(
messages.find((message) => message.id === agentJournalSubmissionKey('waited'))
).toMatchObject({
unsent: true,
journalPosition: { sequence: 3, index: 0 }
})
})
it('says why from the host fact, with no Retry', () => {
const shown = notices([], [SEED, restartRejected('lost', 'fix the parser', 3)])
expect(shown.get(agentJournalSubmissionKey('lost'))).toEqual({
muted: true,
text: 'Orca restarted before this message was sent.'
})
expect([...shown.keys()]).toEqual([agentJournalSubmissionKey('lost')])
})
it("says only that it was not sent when the failed start's row already says why", () => {
const failedStart = submission('first', 'hello', 3, {
dispatchState: 'rejected',
providerItemId: null,
reason: 'Claude is not signed in.',
rejection: { kind: 'notSignedIn' }
})
expect(
notices([], [failedStart], { startFailures: [{ kind: 'notSignedIn' }] }).get(
agentJournalSubmissionKey('first')
)
).toEqual({ muted: true, text: 'Your message was not sent.' })
})
it('is hidden by a copy of the same body sent once its rejection was known', () => {
// An earlier build's Retry resent it under a new id, and that copy was delivered.
const items = [...SEED_ROWS, userItem('old', 3, 'retry me'), userItem('resent', 4, 'retry me')]
const submissions = [
SEED,
restartRejected('old', 'retry me', 3),
submission('resent', 'retry me', 4)
]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false }
])
})
it('stays when the same text was sent again before it was rejected', () => {
// "continue", sent twice on purpose; a restart rejected the first only after the second went.
const items = [...SEED_ROWS, userItem('first', 3, 'continue'), userItem('again', 4, 'continue')]
const submissions = [
SEED,
{ ...restartRejected('first', 'continue', 3), resolvedAt: 5 },
submission('again', 'continue', 4)
]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false },
// Listed after the delivered rows; its journal position keeps its place.
{ id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true }
])
})
// The host re-delivers its own message under new ids; however close their times, one row stays.
it('keeps the last of several copies rejected in the same instant', () => {
const items = [...SEED_ROWS, userItem('a', 3, 'pointer'), userItem('b', 4, 'pointer')]
const submissions = [
SEED,
restartRejected('a', 'pointer', 5),
restartRejected('b', 'pointer', 5)
]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true }
])
})
it('stays when the same body was only sent before it, or by a copy a Stop withdrew', () => {
const items = [
...SEED_ROWS,
userItem('first', 3, 'again'),
userItem('failed', 4, 'again'),
userItem('stopped', 5, 'again')
]
const submissions = [
SEED,
submission('first', 'again', 3),
restartRejected('failed', 'again', 4),
withdrawn('stopped', 'again', 5)
]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('first'), text: 'again', unsent: false },
{ id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true }
])
})
// A command the host recorded stays like any message, so no path loses it; its own reply says
// nothing more once the journal shows it (structuredAgentSessionJournalShowsSubmission).
it('is drawn when it was a command such as /compact', () => {
const compact = {
...userItem('compact', 3, '/compact'),
body: { ...body('/compact'), command: { name: 'compact' } }
}
const submissions = [SEED, restartRejected('compact', '/compact', 3)]
expect(
rows(
projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS)
)
).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('compact'), text: '/compact', unsent: true }
])
expect([...notices([], submissions).keys()]).toEqual([agentJournalSubmissionKey('compact')])
})
it('keeps a message a Stop withdrew hidden: it went back to its sender', () => {
const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')]
const submissions = [SEED, withdrawn('stopped', 'never mind', 3)]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }
])
expect(notices([], submissions).size).toBe(0)
})
})
describe("one row per rejected message, the host's once it records the rejection", () => {
const items = [...SEED_ROWS, userItem('held', 3, 'host copy')]
// Fingerprinted from the host's own copy, so only the shared id ties the two rows together.
const rejected = restartRejected('held', 'host copy', 3)
// Out, however the host answered: the outbox stores nothing about the answer.
const held = outboxEntry('held', 'outbox copy', { state: 'dispatching', lastAttemptAt: 50 })
const heldRow = { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }
const hostRow = { id: agentJournalSubmissionKey('held'), text: 'host copy', unsent: true }
const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }
// Nothing stored says the reply rejected it, so until the journal holds the rejection the copy
// reads as sending; it never goes out again on its own while dispatching.
it('the reply first: the outbox draws it as sending until the journal holds the rejection', () => {
const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED], NO_CARDS)
expect(rows(messages)).toEqual([seedRow, { ...heldRow, unsent: false }])
expect(notices([held], [SEED]).get(heldRow.id)).toEqual({ sending: true })
})
it("the journal first, or next: the host's row replaces the outbox copy at once", () => {
// Before the settlement drops the entry, the host's row is already the one row.
const messages = projectStructuredAgentSessionMessages(
items,
[held],
[SEED, rejected],
NO_CARDS
)
expect(rows(messages)).toEqual([seedRow, hostRow])
expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 })
// In the host's words, with no control.
expect([...notices([held], [SEED, rejected], { items })]).toEqual([
[hostRow.id, { muted: true, text: 'Orca restarted before this message was sent.' }]
])
})
it('the journal holds the rejection before its row loads: the copy draws it, saying why', () => {
const messages = projectStructuredAgentSessionMessages(
SEED_ROWS,
[held],
[SEED, rejected],
NO_CARDS
)
expect(rows(messages)).toEqual([seedRow, heldRow])
expect([...notices([held], [SEED, rejected], { items: SEED_ROWS })]).toEqual([
[heldRow.id, { muted: true, text: 'Orca restarted before this message was sent.' }]
])
})
it("keeps the old row beside an earlier build's resend until the host records the resend", () => {
const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')]
const resent = restartRejected('held', 'outbox copy', 3)
const resend = outboxEntry('resend', 'outbox copy')
expect(
rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent], NO_CARDS))
).toEqual([
seedRow,
{ id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true },
{ id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false }
])
const recorded = submission('resend', 'outbox copy', 60, {
dispatchState: 'pending',
providerItemId: null,
resolvedAt: null
})
expect(
rows(
projectStructuredAgentSessionMessages(
[...hostItems, userItem('resend', 4, 'outbox copy')],
[resend],
[SEED, resent, recorded],
NO_CARDS
)
)
).toEqual([
seedRow,
{ id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false }
])
})
})
describe('a rejected message the queue holds', () => {
const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }
// A hand-off the host rejected sends its draft back to the card list, which shows the text.
it('is not drawn when it was a queued draft handed off', () => {
const items = [...SEED_ROWS, userItem('handoff', 3, 'queued text')]
const submissions = [
SEED,
{ ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' }
]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([
seedRow
])
expect(notices([], submissions).size).toBe(0)
})
// A send kept across a restart comes back as a paused card under its own id.
it('is not drawn while a card holds it under its id, and is drawn once that card is gone', () => {
const items = [...SEED_ROWS, userItem('kept', 3, 'kept text')]
const submissions = [SEED, restartRejected('kept', 'kept text', 3)]
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, ['kept']))).toEqual([
seedRow
])
expect(notices([], submissions, { cards: ['kept'] }).size).toBe(0)
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([
seedRow,
{ id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true }
])
})
})
describe("the host's outline", () => {
// Older clients read it too, and they hide these rows.
it('leaves every accepted-then-rejected message out', () => {
const items = [
...SEED_ROWS,
userItem('lost', 3, 'fix the parser'),
userItem('stopped', 4, 'never mind')
]
const submissions = [
SEED,
restartRejected('lost', 'fix the parser', 3),
withdrawn('stopped', 'never mind', 4)
]
expect(rows(projectShared(items, [], submissions, { rejectedInPlace: false }))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }
])
})
})
describe('the phone', () => {
it('draws them in place, as the desktop does', () => {
const items = [...SEED_ROWS, userItem('lost', 3, 'fix the parser')]
const submissions = [SEED, restartRejected('lost', 'fix the parser', 3)]
expect(rows(projectShared(items, [], submissions, { rejectedInPlace: true }))).toEqual([
{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false },
{ id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true }
])
})
})
@@ -21,6 +21,9 @@ import { projectStructuredAgentSessionMessages as projectForPhone } from '../../
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
const PHONE = { rejectedInPlace: true }
function submission(index: number): AgentJournalSubmission {
return {
clientMessageId: `client-${index}`,
@@ -49,11 +52,12 @@ describe('structured agent session message projection', () => {
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
const acceptedItem = item(1)
// In no turn, after the conversation; its journal position keeps its place when drawn.
expect(
projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected])
projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected], NO_CARDS)
).toMatchObject([
{ id: refusedItem.itemId, role: 'user', unsent: true },
{ id: acceptedItem.itemId, role: 'user' }
{ id: acceptedItem.itemId, role: 'user' },
{ id: refusedItem.itemId, role: 'user', unsent: true, journalPosition: expect.anything() }
])
})
@@ -70,7 +74,12 @@ describe('structured agent session message projection', () => {
...item(0),
itemId: agentJournalSubmissionKey(notDelivered.clientMessageId)
}
const messages = projectStructuredAgentSessionMessages([recordedItem], [], [notDelivered])
const messages = projectStructuredAgentSessionMessages(
[recordedItem],
[],
[notDelivered],
NO_CARDS
)
expect(messages).toMatchObject([
{
id: recordedItem.itemId,
@@ -92,7 +101,9 @@ describe('structured agent session message projection', () => {
...item(0),
itemId: agentJournalSubmissionKey(withdrawn.clientMessageId)
}
expect(projectStructuredAgentSessionMessages([withdrawnItem], [], [withdrawn])).toEqual([])
expect(
projectStructuredAgentSessionMessages([withdrawnItem], [], [withdrawn], NO_CARDS)
).toEqual([])
})
it("shows the sender's recorded rejection once, from the journal, even before its entry leaves", () => {
@@ -108,9 +119,9 @@ describe('structured agent session message projection', () => {
}),
state: 'dispatching' as const
}
expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([
expect.objectContaining({ id: refusedItem.itemId, unsent: true })
])
expect(
projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS)
).toEqual([expect.objectContaining({ id: refusedItem.itemId, unsent: true })])
})
it('keeps the not-sent original when the same text is sent again as a new message', () => {
@@ -123,7 +134,9 @@ describe('structured agent session message projection', () => {
attachments: [],
queuedAt: 2
})
expect(projectStructuredAgentSessionMessages([refusedItem], [resend], [rejected])).toEqual([
expect(
projectStructuredAgentSessionMessages([refusedItem], [resend], [rejected], NO_CARDS)
).toEqual([
expect.objectContaining({ id: refusedItem.itemId, unsent: true }),
expect.objectContaining({ id: agentJournalSubmissionKey('rotated-id') })
])
@@ -230,8 +243,8 @@ describe('structured agent session message projection', () => {
: [])
]
for (const messages of [
projectStructuredAgentSessionMessages(items, [], [compact]),
projectForPhone(items, [], [compact])
projectStructuredAgentSessionMessages(items, [], [compact], NO_CARDS),
projectForPhone(items, [], [compact], PHONE)
]) {
expect(messages.filter((message) => message.unsent === true).map((m) => m.id)).toEqual([
userItemId
@@ -271,8 +284,8 @@ describe('structured agent session message projection', () => {
const items = [handOffRow(1), handOffRow(2)]
const submissions = [handOff(1, 'rejected'), handOff(2, 'accepted')]
for (const messages of [
projectStructuredAgentSessionMessages(items, [], submissions),
projectForPhone(items, [], submissions)
projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS),
projectForPhone(items, [], submissions, PHONE)
]) {
expect(messages).toEqual([
expect.objectContaining({ id: agentJournalSubmissionKey('handoff-2') })
@@ -294,7 +307,8 @@ describe('structured agent session message projection', () => {
const messages = projectStructuredAgentSessionMessages(
Array.from({ length: sendCount }, (_, index) => item(index)),
outbox,
Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1))
Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)),
NO_CARDS
)
expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount)
@@ -329,8 +343,8 @@ describe('structured agent session message projection', () => {
resolvedAt: null
}
const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending])
const optimistic = projectStructuredAgentSessionMessages([], outbox, [])
const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending], NO_CARDS)
const optimistic = projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)
expect(messages.filter((message) => message.role === 'user')).toHaveLength(1)
expect(messages.map((message) => message.id)).toEqual([walItem.itemId])
@@ -348,7 +362,7 @@ describe('structured agent session message projection', () => {
})
]
expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([
expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([
{ id: agentJournalSubmissionKey('client-pending'), role: 'user' }
])
})
@@ -6,12 +6,22 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc
import { projectStructuredAgentSessionMessages as projectMessages } from '../../../../shared/structured-agent-session-message-projection'
import { projectStructuredQuestionMessages } from './structured-agent-question-projection'
/** The desktop's transcript: a message the host accepted and then rejected stays where it was
* sent, as not sent, unless a queued card holds it. */
export function projectStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
outbox: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
submissions: readonly AgentJournalSubmission[],
/** The queue's live cards; required, since a rejected message a card holds must not draw twice. */
queuedMessageIds: readonly string[]
) {
return projectMessages(items, outbox, submissions, projectStructuredQuestionMessages)
return projectMessages(
items,
outbox,
submissions,
{ rejectedInPlace: true, queuedMessageIds },
projectStructuredQuestionMessages
)
}
export type StructuredPromptItem = AgentJournalRenderItem & {
@@ -239,6 +239,8 @@ export async function sendStructuredAgentSessionOutboxEntry(args: {
beforeSettle?: () => void
/** Whether the loaded journal holds a row for the id; absent where no journal is loaded. */
journalHasRow?: (clientMessageId: string) => boolean
/** Whether the loaded journal pages hold the message's own row; absent where none is loaded. */
rowLoaded?: (clientMessageId: string) => boolean
}): Promise<StructuredAgentSessionOutboxSettlement | null> {
const { next } = args
const sessionId = next.sessionId
@@ -281,6 +283,7 @@ export async function sendStructuredAgentSessionOutboxEntry(args: {
firstAttempt: onlyAttempt,
answersProve,
journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false,
rowLoaded: args.rowLoaded?.(next.clientMessageId) ?? false,
outlivedHostWindow: structuredAgentSessionEntryOutlivedHostWindow(next, Date.now())
})
settle(settlement)
@@ -299,6 +302,7 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: {
dispatchGenerationRef: MutableRef<number>
inFlightIdRef: MutableRef<string | null>
journalHasRow: (clientMessageId: string) => boolean
rowLoaded: (clientMessageId: string) => boolean
}): { promise: Promise<StructuredAgentSessionOutboxSettlement | null>; started: boolean } {
const start = async (): Promise<StructuredAgentSessionOutboxSettlement | null> => {
args.inFlightIdRef.current = args.next.clientMessageId
@@ -314,6 +318,7 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: {
target: args.target,
fence: args.fence,
journalHasRow: args.journalHasRow,
rowLoaded: args.rowLoaded,
isCurrent,
// Held through the capability probe, so nothing overtakes it; released before the settling
// write, which is what runs the drain again.
@@ -13,6 +13,8 @@ import {
import { createNativeChatMessageListProjection } from './native-chat-message-list-projection'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const NO_CARDS: readonly string[] = []
function journalItem(
itemId: string,
sequence: number,
@@ -43,7 +45,7 @@ function drawn(
submissions: AgentJournalSubmission[] = []
): string[] {
return createNativeChatMessageListProjection()(
projectStructuredAgentSessionMessages(items, outbox, submissions)
projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS)
).conversation.map(({ id }) => id)
}
@@ -0,0 +1,357 @@
// @vitest-environment happy-dom
import { act, render, renderHook } from '@testing-library/react'
import { useLayoutEffect } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
vi.mock('./native-chat-session-option-settings-write', () => ({
enqueueSessionOptionSettingsWrite: vi.fn()
}))
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
holdStructuredAgentSessionLaunchOption: vi.fn(() => Promise.resolve({ kind: 'held' })),
getStructuredAgentSessionLaunchSelection: () => null
}))
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const
const UNKNOWN = { origin: 'unknown' }
const LISTING = { origin: 'unknown', listingInProgress: true }
const HOST_CATALOG = {
origin: 'probe',
models: [{ id: 'gpt-hosted', label: 'GPT Hosted', isDefault: true, efforts: [] }],
fetchedAt: 1_000
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: no test here sends a pick over a fence, so mutate is never called.
const mutate = vi.fn(async () => null) as unknown as StructuredAgentSessionMutate
type Props = { hidden?: boolean; attached?: boolean; sessionId?: string }
// A chat's waiting read outlives its mounts, so each test gets its own chat.
let sessionId = ''
let sessionCount = 0
function renderOptions(initial: Props = {}) {
return renderHook(
(props: Props) =>
useStructuredAgentSessionOptions({
agent: 'codex',
sessionId: props.sessionId ?? sessionId,
target: PAIRED_TARGET,
transportEnabled: props.attached === true,
isVisible: !props.hidden,
providerVisible: props.attached === true && !props.hidden,
fence: props.attached ? 1 : null,
turnId: null,
unloadedTurnRevisions: undefined,
mutate,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
}),
{ initialProps: initial }
)
}
type Deferred = {
promise: Promise<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
function deferred(): Deferred {
let resolve!: (value: unknown) => void
let reject!: (error: unknown) => void
const promise = new Promise<unknown>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** Each catalog read takes the next answer; `options` reads never settle unless given. */
function answerCatalog(answers: (() => Promise<unknown>)[]): void {
let index = 0
mocks.call.mockImplementation((_target: unknown, method: string) => {
if (method === 'agentSession.modelCatalog') {
const next = answers[index]
index += 1
return next ? next() : new Promise(() => {})
}
return new Promise(() => {})
})
}
function catalogReads(): unknown[] {
return mocks.call.mock.calls
.filter(([, method]) => method === 'agentSession.modelCatalog')
.map(([, , params]) => params)
}
function model(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor {
return snapshot.find((entry) => entry.id === 'model')!
}
function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] {
const descriptor = model(snapshot)
return descriptor.kind.type === 'select' ? descriptor.kind.choices.map((c) => c.value) : []
}
const flush = (): Promise<void> => act(async () => {})
describe('host model catalog read', () => {
beforeEach(() => {
mocks.call.mockReset()
sessionCount += 1
sessionId = `session-${sessionCount}`
})
it('reads a warm catalog once and never holds the picker', async () => {
const first = deferred()
answerCatalog([() => first.promise])
const { result, unmount } = renderOptions()
await flush()
// A read in flight is not a reason to hold the picker.
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
first.resolve(HOST_CATALOG)
await flush()
expect(catalogReads()).toEqual([{ agent: 'codex', sessionId }])
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
it('waits once for a listing the host reports, holding the picker until it lands', async () => {
const waited = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
const { result, unmount } = renderOptions()
await flush()
expect(catalogReads()).toEqual([
{ agent: 'codex', sessionId },
{ agent: 'codex', sessionId, waitForListing: true }
])
const held = model(result.current.optionSnapshot)
expect(held).toMatchObject({ choicesPending: true, settable: false })
// The label stays: the pill still names the launch's model.
expect(held.kind.type === 'select' ? held.kind.currentValue : null).toBe('gpt-5.5')
await act(async () => {
expect(await result.current.setStructuredOption('model', 'gpt-5.5')).toBe(false)
})
waited.resolve(HOST_CATALOG)
await flush()
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
expect(catalogReads()).toHaveLength(2)
unmount()
})
it('releases the picker on the seed when the waiting read fails or times out', async () => {
for (const failure of [
() => Promise.resolve(UNKNOWN),
() => Promise.reject(Object.assign(new Error('timed out'), { code: 'runtime_timeout' })),
() => Promise.reject(Object.assign(new Error('gone'), { code: 'not_connected' }))
]) {
mocks.call.mockReset()
answerCatalog([() => Promise.resolve(LISTING), failure])
const { result, unmount } = renderOptions()
await flush()
expect(catalogReads()).toHaveLength(2)
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
expect(model(result.current.optionSnapshot).settable).toBe(true)
expect(modelChoices(result.current.optionSnapshot)).not.toContain('gpt-hosted')
unmount()
}
})
it('asks a host that reports no listing nothing more', async () => {
answerCatalog([() => Promise.resolve(UNKNOWN)])
const { result, unmount } = renderOptions()
await flush()
expect(catalogReads()).toHaveLength(1)
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
it('releases the picker when the running provider reports its own list first', async () => {
const live = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => new Promise(() => {})])
const catalogAnswers = mocks.call.getMockImplementation()!
mocks.call.mockImplementation((target: unknown, method: string, params: unknown) =>
method === 'agentSession.options' ? live.promise : catalogAnswers(target, method, params)
)
const { result, unmount } = renderOptions({ attached: true })
await flush()
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
live.resolve({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live', confirmed: ['model'] }
})
await flush()
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-live')
unmount()
})
it('keeps the hold through attach and joins the wait already in flight', async () => {
const waited = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
const { result, rerender, unmount } = renderOptions()
await flush()
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
rerender({ attached: true })
// No frame on the stand-in list between the old fence and the new one.
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
await flush()
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
expect(catalogReads()).toHaveLength(2)
waited.resolve(HOST_CATALOG)
await flush()
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
it('joins the wait already in flight when the pane hides and shows again', async () => {
const waited = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
const { result, rerender, unmount } = renderOptions()
await flush()
rerender({ hidden: true })
await flush()
rerender({})
await flush()
expect(catalogReads()).toHaveLength(2)
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
waited.resolve(HOST_CATALOG)
await flush()
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
it('joins the wait already in flight when the chat mounts again', async () => {
const waited = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
const first = renderOptions()
await flush()
first.unmount()
const { result, unmount } = renderOptions()
await flush()
expect(catalogReads()).toHaveLength(2)
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
waited.resolve(HOST_CATALOG)
await flush()
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
it('holds and answers only the chat whose host is listing', async () => {
const waited = deferred()
answerCatalog([
() => Promise.resolve(LISTING),
() => waited.promise,
() => Promise.resolve(UNKNOWN)
])
const { result, rerender, unmount } = renderOptions()
await flush()
rerender({ sessionId: `${sessionId}-other` })
await flush()
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
waited.resolve(HOST_CATALOG)
await flush()
expect(modelChoices(result.current.optionSnapshot)).not.toContain('gpt-hosted')
expect(catalogReads()).toHaveLength(3)
unmount()
})
it('applies nothing and keeps nothing when the answer lands after the chat closed', async () => {
const waited = deferred()
answerCatalog([
() => Promise.resolve(LISTING),
() => waited.promise,
() => Promise.resolve(UNKNOWN)
])
const first = renderOptions()
await flush()
first.unmount()
waited.resolve(HOST_CATALOG)
await flush()
// A later open of the same chat asks afresh instead of joining a finished wait.
const { result, unmount } = renderOptions()
await flush()
expect(catalogReads()).toHaveLength(3)
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
unmount()
})
})
function CommitRecorder(props: { sessionId: string; attached: boolean; commits: string[] }) {
const { optionSnapshot } = useStructuredAgentSessionOptions({
agent: 'codex',
sessionId: props.sessionId,
target: PAIRED_TARGET,
transportEnabled: props.attached,
isVisible: true,
providerVisible: false,
fence: props.attached ? 1 : null,
turnId: null,
unloadedTurnRevisions: undefined,
mutate,
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
})
const descriptor = model(optionSnapshot)
const state = `held=${descriptor.choicesPending === true} hosted=${modelChoices(optionSnapshot).includes('gpt-hosted')}`
// No deps: one entry per commit, which act() would otherwise batch out of sight.
useLayoutEffect(() => {
props.commits.push(state)
})
return null
}
describe('the end of a host listing wait', () => {
beforeEach(() => {
mocks.call.mockReset()
sessionCount += 1
sessionId = `session-${sessionCount}`
})
for (const attachedFirst of [false, true]) {
it(`never commits the built-in list unheld before the host list (${attachedFirst ? 'joined after attach' : 'started here'})`, async () => {
const waited = deferred()
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
const commits: string[] = []
const view = render(
<CommitRecorder sessionId={sessionId} attached={false} commits={commits} />
)
await flush()
if (attachedFirst) {
view.rerender(<CommitRecorder sessionId={sessionId} attached commits={commits} />)
await flush()
}
expect(commits.at(-1)).toBe('held=true hosted=false')
const settledFrom = commits.length
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', false)
try {
waited.resolve(HOST_CATALOG)
await vi.waitFor(() => expect(commits.at(-1)).toBe('held=false hosted=true'))
await new Promise((resolve) => setTimeout(resolve, 20))
} finally {
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
}
expect(commits.slice(settledFrom)).not.toContain('held=false hosted=false')
view.unmount()
})
}
})
@@ -1,4 +1,4 @@
import { useEffect, type MutableRefObject } from 'react'
import { useEffect, useSyncExternalStore, type MutableRefObject } from 'react'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
@@ -8,7 +8,13 @@ import {
} from '../../../../shared/structured-agent-session-options'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state'
import {
isHostModelListingWaitInFlight,
joinHostModelListingWait,
subscribeHostModelListingWaits
} from './host-model-listing-waits'
/**
* Upgrades the static seed with the host's stored catalog without waiting on
@@ -16,6 +22,10 @@ import type { NativeChatSessionOptionRecord } from '../../../../shared/native-ch
* would pin, so the picker warms during create. An older host answers
* `forbidden` or `method_not_found` — both mean "no such surface", so the seed
* stands until the live read lands.
*
* When the host says its first listing for the account is running, one more
* read waits for it — one per chat, joined by every later run and remount.
* Returns true while that read is in flight.
*/
export function useHostModelCatalogUpgrade(args: {
agent: AgentType
@@ -33,7 +43,7 @@ export function useHostModelCatalogUpgrade(args: {
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
) => void
}): void {
}): boolean {
const {
activeOptionRecordRef,
agent,
@@ -46,30 +56,58 @@ export function useHostModelCatalogUpgrade(args: {
updateOptionState,
worktree
} = args
const waitKey = `${structuredAgentSessionHostKey(target)}\u0000${agent}\u0000${sessionId}`
const awaitingListing = useSyncExternalStore(subscribeHostModelListingWaits, () =>
isHostModelListingWaitInFlight(waitKey)
)
useEffect(() => {
if (!enabled || !optionCatalog || (agent !== 'claude' && agent !== 'codex')) {
return
}
let stale = false
void callStructuredAgentSession<AgentSessionModelCatalogResult>(
target,
'agentSession.modelCatalog',
{ agent, sessionId, ...(namesDefault && worktree ? { worktree } : {}) }
)
.then((catalog) => {
if (!stale) {
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, {
namesDefault
})
: current
)
const params = { agent, sessionId, ...(namesDefault && worktree ? { worktree } : {}) }
const read = (waitForListing: boolean): Promise<AgentSessionModelCatalogResult> =>
callStructuredAgentSession<AgentSessionModelCatalogResult>(
target,
'agentSession.modelCatalog',
waitForListing ? { ...params, waitForListing } : params
)
const apply = (catalog: AgentSessionModelCatalogResult): void =>
updateOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, {
namesDefault
})
: current
)
let leave: (() => void) | null = null
const waitForListing = (): void => {
leave = joinHostModelListingWait(waitKey, () => read(true), (catalog) => {
if (catalog) {
apply(catalog)
}
})
.catch(() => {})
}
if (isHostModelListingWaitInFlight(waitKey)) {
waitForListing()
} else {
void read(false)
.then((catalog) => {
if (stale) {
return
}
// Only a host that reports the listing knows the wait param; an older one refuses it.
if (catalog.origin === 'unknown' && catalog.listingInProgress === true) {
waitForListing()
} else {
apply(catalog)
}
})
.catch(() => {})
}
return () => {
stale = true
leave?.()
}
}, [
activeOptionRecordRef,
@@ -81,6 +119,8 @@ export function useHostModelCatalogUpgrade(args: {
sessionId,
target,
updateOptionState,
waitKey,
worktree
])
return awaitingListing
}
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { structuredAgentSessionRecordStaysInChat } from '../../../../shared/structured-agent-session-message-projection'
import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
afterEach(cleanup)
const NO_CARDS: readonly string[] = []
const EMPTY: never[] = []
function rejected(
clientMessageId: string,
kind: 'hostRestarted' | 'notDelivered'
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'rejected',
providerItemId: null,
reason: null,
rejection: { kind },
submittedAt: 1,
resolvedAt: 2
}
}
/** The rows the transcript draws as not sent: each rejection a Stop did not withdraw. */
function drawn(submissions: readonly AgentJournalSubmission[]): NativeChatMessage[] {
return submissions
.filter(
(submission) =>
submission.dispatchState === 'rejected' &&
structuredAgentSessionRecordStaysInChat(submission)
)
.map((submission) => ({
id: `orca:${submission.clientMessageId}`,
role: 'user',
source: 'transcript',
timestamp: submission.submittedAt,
blocks: [],
unsent: true
}))
}
function accepted(clientMessageId: string): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'accepted',
providerItemId: `provider-${clientMessageId}`,
reason: null,
submittedAt: 3,
resolvedAt: 4
}
}
function withdrawn(clientMessageId: string): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_CANCELLED,
rejection: { kind: 'cancelled' },
submittedAt: 1,
resolvedAt: 2
}
}
// A Stop's withdrawn message draws no row, so a chat that has one rebuilds no notice per batch.
it('keeps the same notices across batches in a chat whose only rejection a Stop withdrew', () => {
const { result, rerender } = renderHook(
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
useStructuredAgentSessionDeliveryNotices({
messages: drawn(submissions),
outbox: EMPTY,
submissions,
journalItems: EMPTY,
queuedMessageIds: NO_CARDS,
agentLabel: 'Claude',
agentActive: false
}),
{ initialProps: { submissions: [withdrawn('stopped')] } }
)
const first = result.current
rerender({ submissions: [withdrawn('stopped')] })
expect(result.current).toBe(first)
expect(result.current.size).toBe(0)
})
function renderNotices(submissions: readonly AgentJournalSubmission[]) {
return renderHook(
({ submissions: current }: { submissions: readonly AgentJournalSubmission[] }) =>
useStructuredAgentSessionDeliveryNotices({
messages: drawn(current),
outbox: EMPTY,
submissions: current,
journalItems: EMPTY,
queuedMessageIds: NO_CARDS,
agentLabel: 'Claude',
agentActive: false
}),
{ initialProps: { submissions } }
)
}
// A batch for another message rebuilds the journal's rows; the rows already marked not sent keep
// the same notices, so no row wrapper re-renders.
it('keeps the same notices when a batch leaves every not-sent message as it was', () => {
const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')])
const first = result.current
expect(first.size).toBe(1)
rerender({ submissions: [rejected('lost', 'hostRestarted'), accepted('next')] })
expect(result.current).toBe(first)
})
it('keeps the notice of a row that did not change when another one does', () => {
const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')])
const lost = result.current.get('orca:lost')
rerender({
submissions: [rejected('lost', 'hostRestarted'), rejected('other', 'notDelivered')]
})
expect(result.current.size).toBe(2)
expect(result.current.get('orca:lost')).toBe(lost)
})
@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
@@ -12,6 +12,7 @@ import { useStructuredAgentSessionCommandResultRows } from './use-structured-age
import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
/** The structured chat's delivery notices, keyed by the message id each row renders under. */
export function useStructuredAgentSessionDeliveryNotices(input: {
@@ -19,11 +20,13 @@ export function useStructuredAgentSessionDeliveryNotices(input: {
journalItems: readonly AgentJournalRenderItem[]
submissions: readonly AgentJournalSubmission[]
outbox: readonly StructuredAgentSessionOutboxEntry[]
/** The queue's live cards, which hold their own rejected messages. */
queuedMessageIds: readonly string[]
agentLabel: string
/** The agent is working or starting, so a lost outcome may still resolve. */
agentActive: boolean
}): ReadonlyMap<string, NativeChatDeliveryNotice> {
const { agentActive, agentLabel, outbox } = input
const { agentActive, agentLabel, outbox, queuedMessageIds } = input
// Only a message shown as not sent or not confirmed, or one still in this window's outbox,
// reads the journal's rows, so a new batch of them re-renders no row else. Read from the
// transcript and the journal, not this window's outbox: the host's record alone shows one.
@@ -31,9 +34,12 @@ export function useStructuredAgentSessionDeliveryNotices(input: {
input.messages.some((message) => message.unsent === true) ||
input.submissions.some(isRecoveredStructuredAgentSessionSubmission)
const journalRows = hasNotSent || outbox.length > 0 ? input.submissions : NO_SUBMISSIONS
// Only an outbox copy of a rejected message reads the loaded rows, so a streaming turn rebuilds
// no notice otherwise.
const loadedItems = hasNotSent && outbox.length > 0 ? input.journalItems : NO_ITEMS
const startFailures = useStructuredAgentSessionStartFailureFacts(input.journalItems, hasNotSent)
const commandResults = useStructuredAgentSessionCommandResultRows(input.journalItems, hasNotSent)
return useMemo(
const notices = useMemo(
() =>
structuredAgentSessionDeliveryNotices(
outbox,
@@ -41,8 +47,50 @@ export function useStructuredAgentSessionDeliveryNotices(input: {
journalRows,
startFailures,
commandResults,
agentActive
agentActive,
queuedMessageIds,
loadedItems
),
[outbox, agentLabel, journalRows, startFailures, commandResults, agentActive]
[
outbox,
agentLabel,
journalRows,
startFailures,
commandResults,
agentActive,
queuedMessageIds,
loadedItems
]
)
// A batch rebuilds the map; one that says the same keeps the old, so no row re-renders.
const previousRef = useRef(notices)
const stable = sameNoticesKept(previousRef.current, notices)
useEffect(() => {
previousRef.current = stable
}, [stable])
return stable
}
/** `next`, reusing each notice `previous` words the same way, and `previous` itself when all are. */
function sameNoticesKept(
previous: ReadonlyMap<string, NativeChatDeliveryNotice>,
next: ReadonlyMap<string, NativeChatDeliveryNotice>
): ReadonlyMap<string, NativeChatDeliveryNotice> {
if (previous === next) {
return next
}
let allKept = previous.size === next.size
const kept = new Map<string, NativeChatDeliveryNotice>()
for (const [id, notice] of next) {
const before = previous.get(id)
const same =
before !== undefined &&
before.sending === notice.sending &&
before.text === notice.text &&
before.muted === notice.muted &&
(before.onDismiss === undefined) === (notice.onDismiss === undefined)
allKept &&= same
kept.set(id, same ? before : notice)
}
return allKept ? previous : kept
}
@@ -0,0 +1,30 @@
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
/** Which sent messages' own rows the loaded journal pages hold: a rejected message's outbox entry
* draws it until its row is here. `loadedItemIds` stays the same set while the user rows don't
* change, so a streaming turn re-runs nothing; `rowLoaded` reads the latest at call time. */
export function useStructuredAgentSessionLoadedUserRows(items: readonly AgentJournalRenderItem[]): {
loadedItemIds: ReadonlySet<string>
rowLoaded: (clientMessageId: string) => boolean
} {
const key = useMemo(
() =>
items
.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
.map((item) => item.itemId)
.join('\0'),
[items]
)
const loadedItemIds = useMemo(() => new Set(key === '' ? [] : key.split('\0')), [key])
const loadedRef = useRef(loadedItemIds)
useLayoutEffect(() => {
loadedRef.current = loadedItemIds
}, [loadedItemIds])
const rowLoaded = useCallback(
(clientMessageId: string) => loadedRef.current.has(agentJournalSubmissionKey(clientMessageId)),
[]
)
return { loadedItemIds, rowLoaded }
}
@@ -11,6 +11,7 @@ import { useStructuredAgentSessionMessages } from './use-structured-agent-sessio
afterEach(cleanup)
const EMPTY: never[] = []
const NO_CARDS: readonly string[] = []
function tool(id: string, sequence: number): AgentJournalRenderItem {
return {
itemId: id,
@@ -25,7 +26,8 @@ it('retains only unchanged item projections across updates, reorder, deletion, a
const first = tool('first', 1)
const second = tool('second', 2)
const { result, rerender } = renderHook(
(items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY),
(items: AgentJournalRenderItem[]) =>
useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS),
{ initialProps: [first, second] }
)
const initial = result.current
@@ -50,7 +52,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a
[structuredClone(completed)]
]) {
rerender(items)
expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY))
expect(result.current).toEqual(
projectStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS)
)
expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1])
}
const replacement = {
@@ -62,7 +66,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a
}
}
rerender([replacement])
expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY))
expect(result.current).toEqual(
projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY, NO_CARDS)
)
expect(result.current[0]).not.toBe(initial[0])
})
@@ -91,14 +97,14 @@ it('keeps optimistic sends and their settlement identical to uncached projection
}: {
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
}) => useStructuredAgentSessionMessages(items, [entry], submissions),
}) => useStructuredAgentSessionMessages(items, [entry], submissions, NO_CARDS),
{ initialProps: { items: [tool('tool', 1)], submissions: [submission] } }
)
for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) {
const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] }
rerender(props)
expect(result.current).toEqual(
projectStructuredAgentSessionMessages(props.items, [entry], props.submissions)
projectStructuredAgentSessionMessages(props.items, [entry], props.submissions, NO_CARDS)
)
}
})
@@ -106,7 +112,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection
it('does no transcript projection work on a status-only render', () => {
const items = [tool('tool', 1)]
const { result, rerender } = renderHook(() =>
useStructuredAgentSessionMessages(items, EMPTY, EMPTY)
useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS)
)
const initial = result.current
rerender()
@@ -9,10 +9,11 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio
export function useStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
outbox: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
submissions: readonly AgentJournalSubmission[],
queuedMessageIds: readonly string[]
) {
return useMemo(
() => projectStructuredAgentSessionMessages(items, outbox, submissions),
[items, outbox, submissions]
() => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds),
[items, outbox, submissions, queuedMessageIds]
)
}
@@ -12,6 +12,7 @@ import {
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
lockedStructuredAgentSessionOptionSnapshot,
pendingModelListStructuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionPicks,
structuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionView,
@@ -89,7 +90,7 @@ export function useStructuredAgentSessionOptions(args: {
unloadedTurnRevisions: args.unloadedTurnRevisions
})
useHostModelCatalogUpgrade({
const awaitingHostModelList = useHostModelCatalogUpgrade({
agent,
sessionId,
target,
@@ -103,6 +104,8 @@ export function useStructuredAgentSessionOptions(args: {
activeOptionRecordRef,
updateOptionState
})
// The running provider's own list ends the wait for the host's.
const modelListPending = awaitingHostModelList && optionState.catalogSource !== 'live'
// What a settled pick must remember so the next launch starts where the user left off.
const rememberOptionPicks = useCallback(
@@ -205,8 +208,9 @@ export function useStructuredAgentSessionOptions(args: {
const snapshot = structuredAgentSessionOptionSnapshot(
structuredAgentSessionOptionView(optionState, launchSeedOptions, held)
)
return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot)
}, [acceptsPicks, held, launchSeedOptions, optionState])
const shown = acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot)
return modelListPending ? pendingModelListStructuredAgentSessionOptionSnapshot(shown) : shown
}, [acceptsPicks, held, launchSeedOptions, modelListPending, optionState])
const setStructuredOption = useCallback(
async (id: string, value: string | boolean): Promise<boolean> => {
const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held)
@@ -214,6 +218,8 @@ export function useStructuredAgentSessionOptions(args: {
if (
!optionCatalog ||
encoded === null ||
// A typed `/model` reaches here without the picker.
optionSnapshot.some((descriptor) => descriptor.id === id && descriptor.choicesPending) ||
!canSetStructuredAgentSessionOption(view, id, value)
) {
return false
@@ -234,6 +240,7 @@ export function useStructuredAgentSessionOptions(args: {
held,
launchSeedOptions,
optionCatalog,
optionSnapshot,
optionStateRef,
pendingOptionRef,
sendStructuredOption,
@@ -22,6 +22,10 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import { readNativeChatDraftCache } from './native-chat-draft-cache'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
afterEach(cleanup)
@@ -67,6 +71,7 @@ function render(fence: number | null = 1) {
return renderHook(
(props) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: props.fence,
@@ -31,6 +31,10 @@ import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-sessi
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
type SendRequest = { envelope?: { clientOperationId?: string; expectedRuntimeFence?: number } }
// Stable, as the view passes it: a new object each render would re-run the owner-change requeue.
@@ -84,6 +88,7 @@ function mount(fence: number) {
return renderHook(
({ fence: current }: { fence: number }) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: TARGET,
fence: current,
@@ -6,7 +6,10 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import {
hasUnsentStructuredAgentSessionOutboxEntry,
@@ -31,6 +34,8 @@ import {
readNativeChatDraftCache
} from './native-chat-draft-cache'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
// One object: a target rebuilt each render reads as a new owner, which re-sends what is on its way.
const TARGET = { kind: 'local' } as const
@@ -88,6 +93,7 @@ describe('a Stop withdrawing what the host does not hold', () => {
)
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: TARGET,
fence: 1,
@@ -153,6 +159,7 @@ describe('a Stop withdrawing what the host does not hold', () => {
mocks.call.mockRejectedValue(new Error('socket closed'))
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: TARGET,
fence: 1,
@@ -0,0 +1,136 @@
// @vitest-environment happy-dom
// The outbox re-reads the journal on every batch; a batch that settles nothing writes nothing, so a
// message left in doubt, or one the host rejected whose row is not loaded, costs no storage write
// per streamed delta.
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(() => new Promise(() => {}))
}))
import { writeOutbox } from './structured-agent-session-outbox-storage'
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const SESSION = 'session-churn'
// One target across renders: a new one reads as an owner change, which sends again.
const LOCAL = { kind: 'local' as const }
function stored(
id: string,
patch: Partial<ReturnType<typeof createStructuredAgentSessionOutboxEntry>>
) {
return {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: id,
sessionId: SESSION,
text: id,
attachments: [],
queuedAt: 1
}),
...patch
}
}
function inDoubt(clientMessageId: string): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'unknown',
providerItemId: null,
reason: 'in doubt',
submittedAt: 5,
resolvedAt: 6
}
}
function streamed(sequence: number): AgentJournalRenderItem[] {
return [
{
itemId: `answer-${sequence}`,
revision: sequence,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'streaming' }] }
}
]
}
it('writes nothing to storage across 50 batches while a message waits in doubt', async () => {
writeOutbox(SESSION, [stored('doubt', { state: 'dispatching', lastAttemptAt: 2 })])
const { rerender } = renderHook(
(props: { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] }) =>
useStructuredAgentSessionOutbox({
sessionId: SESSION,
target: LOCAL,
fence: 1,
submissions: props.submissions,
journalItems: props.items
}),
{ initialProps: { items: streamed(1), submissions: [inDoubt('doubt')] } }
)
await act(async () => {})
const writes = vi.spyOn(localStorage, 'setItem')
for (let sequence = 2; sequence <= 51; sequence += 1) {
// Each batch is a new journal array and a new submissions array with the same content.
rerender({ items: streamed(sequence), submissions: [inDoubt('doubt')] })
}
await act(async () => {})
expect(writes).not.toHaveBeenCalled()
writes.mockRestore()
})
function rejected(clientMessageId: string): AgentJournalSubmission {
return {
...inDoubt(clientMessageId),
dispatchState: 'rejected',
reason: 'Orca restarted before this message was sent.',
rejection: { kind: 'hostRestarted' }
}
}
it("writes nothing across 50 batches while a rejected message's row is not loaded", async () => {
writeOutbox(SESSION, [stored('rejected', { state: 'unconfirmed', lastAttemptAt: 2 })])
const { result, rerender } = renderHook(
(props: { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] }) =>
useStructuredAgentSessionOutbox({
sessionId: SESSION,
target: LOCAL,
fence: 1,
submissions: props.submissions,
journalItems: props.items
}),
{ initialProps: { items: streamed(1), submissions: [rejected('rejected')] } }
)
await act(async () => {})
// Settled once: no longer sent again, and it draws the message until its row loads.
expect(result.current.outbox).toMatchObject([
{ clientMessageId: 'rejected', state: 'dispatching' }
])
const writes = vi.spyOn(localStorage, 'setItem')
for (let sequence = 2; sequence <= 51; sequence += 1) {
rerender({ items: streamed(sequence), submissions: [rejected('rejected')] })
}
await act(async () => {})
expect(writes).not.toHaveBeenCalled()
writes.mockRestore()
expect(result.current.outbox).toHaveLength(1)
})
@@ -7,7 +7,10 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import {
@@ -29,6 +32,8 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import { writeOutbox } from './structured-agent-session-outbox-storage'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
afterEach(cleanup)
@@ -80,6 +85,7 @@ function renderOutbox() {
return renderHook(
(props: Props) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: TARGET,
fence: 1,
@@ -15,6 +15,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage'
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
afterEach(cleanup)
beforeEach(() => {
@@ -39,6 +43,7 @@ it('delivers a message queued from outside the chat through the open outbox', as
})
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: { kind: 'local' },
fence: 1,
@@ -32,6 +32,7 @@ import {
import { resetStructuredAgentSessionChatLinesForTests } from './structured-agent-session-returned-send'
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
const DESKTOP = { rejectedInPlace: true }
const ID = 'legacy'
type Props = { submissions: AgentJournalSubmission[]; journalCursor: AgentJournalCursor | null }
@@ -105,7 +106,9 @@ describe('a message an older build saved behind its Retry', () => {
const outbox = view.result.current.outbox
expect(outbox).toMatchObject([{ clientMessageId: ID, legacyUnsettled: true }])
expect(projectStructuredAgentSessionMessages([], outbox, [])).toEqual([])
expect(
projectStructuredAgentSessionMessages([], outbox, [], { rejectedInPlace: true })
).toEqual([])
expect(structuredAgentSessionDeliveryNotices(outbox, 'Claude', [], [])).toEqual(new Map())
})
@@ -121,7 +124,7 @@ describe('a message an older build saved behind its Retry', () => {
const before = view.result.current.outbox
// Even drawn against the row before the outbox drops it, the row is the only bubble.
expect(
projectStructuredAgentSessionMessages([ROW_ITEM], before, [row()]).map(
projectStructuredAgentSessionMessages([ROW_ITEM], before, [row()], DESKTOP).map(
(message) => message.id
)
).toEqual([agentJournalSubmissionKey(ID)])
@@ -131,3 +134,45 @@ describe('a message an older build saved behind its Retry', () => {
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
})
// The build before this one kept a message the host recorded and then rejected as its own copy
// until the row loaded: the same rejected state, with the host's rejection as its failure.
describe('a message an older build kept as the host rejected it', () => {
beforeEach(() => {
localStorage.clear()
writeOutbox('session-1', [
{
...createStructuredAgentSessionOutboxEntry({
clientMessageId: ID,
sessionId: 'session-1',
text: 'rejected by the host',
attachments: [],
queuedAt: 1
}),
lastAttemptAt: 5,
state: 'rejected',
lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' }
}
])
})
it("leaves for the host's row, even one not loaded, without a resend or a hand-back", () => {
const view = mount({ submissions: [], journalCursor: null })
expect(view.result.current.outbox).toMatchObject([
{ clientMessageId: ID, legacyUnsettled: true }
])
expect(
projectStructuredAgentSessionMessages([], view.result.current.outbox, [], DESKTOP)
).toEqual([])
view.rerender({
submissions: [
{ ...row(), dispatchState: 'rejected', reason: 'host_restarted_before_delivery' }
],
journalCursor: { epoch: 'e', sequence: 2 }
})
expect(view.result.current.outbox).toEqual([])
expect(readNativeChatDraftCache(SCOPE)).toBe('')
expect(mocks.call).not.toHaveBeenCalled()
})
})
@@ -32,6 +32,10 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-
import { readOutbox } from './structured-agent-session-outbox-storage'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
afterEach(cleanup)
@@ -54,6 +58,7 @@ function queuedReceipt(clientMessageId: string) {
function renderOutbox(queue: boolean) {
return renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
@@ -155,6 +160,7 @@ describe('outbox queue delivery selection', () => {
const first = renderHook(
(props: { queuedMessageIds: string[] }) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
@@ -185,6 +191,7 @@ describe('outbox queue delivery selection', () => {
const view = renderHook(
(props: { queuedMessageIds: string[] }) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
@@ -250,6 +257,7 @@ describe('outbox queue delivery selection', () => {
}))
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
@@ -302,6 +310,7 @@ async function attemptedQueueSend() {
const view = renderHook(
(props: { capability: StructuredAgentSessionQueueCapability }) =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
// A send whose own reply says the host recorded it and then rejected it is shown once, in the chat
// where it was sent; its text never also goes back to the composer.
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../../shared/structured-agent-session-dispatch-rejection'
type SendParams = { envelope: { clientOperationId: string; payloadFingerprint: string } }
const mocks = vi.hoisted(() => ({
call: vi.fn<(target: unknown, method: string, params: SendParams) => Promise<unknown>>()
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache
} from './native-chat-draft-cache'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
const NO_CARDS: readonly string[] = []
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
// One target across renders: a new one reads as an owner change, which sends again.
const LOCAL = { kind: 'local' as const }
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
clearNativeChatDraftCacheForTests()
})
it('draws a send its reply rejected in place once, and leaves the composer empty', async () => {
const reply: { submission: AgentJournalSubmission | null } = { submission: null }
mocks.call.mockImplementationOnce(async (_target, _method, { envelope }) => {
const rejected: AgentJournalSubmission = {
clientMessageId: envelope.clientOperationId,
fence: 1,
payloadFingerprint: envelope.payloadFingerprint,
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_NOT_DELIVERED,
rejection: { kind: 'notDelivered' },
submittedAt: 10,
resolvedAt: 11
}
reply.submission = rejected
return {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 10 },
value: { clientMessageId: envelope.clientOperationId, submission: rejected }
}
})
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
journalItems: NO_JOURNAL_ITEMS,
sessionId: 'session-1',
target: LOCAL,
fence: 1,
submissions: []
})
)
act(() => expect(result.current.send('steer this way')).toBe(true))
// The host has it: the entry stays only to draw it until the journal's row loads, and never
// goes out again.
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
expect(readNativeChatDraftCache(SCOPE)).toBe('')
const submission = reply.submission
if (!submission) {
throw new Error('the send was never answered')
}
const hostItem: AgentJournalRenderItem = {
itemId: agentJournalSubmissionKey(submission.clientMessageId),
revision: 1,
sequence: 10,
observedAt: 10,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'steer this way' }] }
}
const users = (outbox: typeof result.current.outbox) =>
projectStructuredAgentSessionMessages([hostItem], outbox, [submission], NO_CARDS)
.filter((message) => message.role === 'user')
.map((message) => ({ id: message.id, unsent: message.unsent }))
expect(users(result.current.outbox)).toEqual([{ id: hostItem.itemId, unsent: true }])
// After a crash takes the outbox, the host's row is still the one row.
expect(users([])).toEqual([{ id: hostItem.itemId, unsent: true }])
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
@@ -7,7 +7,11 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
type SentParams = { envelope: { clientOperationId: string }; body: { blocks: { text?: string }[] } }
@@ -96,18 +100,29 @@ function transportError(code: string, message: string): RuntimeRpcCallError {
function mount(submissions: AgentJournalSubmission[] = []) {
return renderHook(
(props: { submissions: AgentJournalSubmission[] }) =>
(props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: TARGET,
fence: 1,
submissions: props.submissions,
journalItems: props.rows,
journalCursor: { epoch: 'epoch-1', sequence: 1 }
}),
{ initialProps: { submissions } }
)
}
function userRow(clientMessageId: string): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(clientMessageId),
revision: 1,
sequence: 2,
observedAt: 2,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
}
}
describe('case 3: no answer yet', () => {
it('a lost answer goes again under the same id, and the host replay settles it: one message', async () => {
let calls = 0
@@ -323,4 +338,32 @@ describe('case 1: the host has a record', () => {
await waitFor(() => expect(view.result.current.outbox).toEqual([]))
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it("the journal's rejection before the send's own answer settles it, and the late answer brings nothing back", async () => {
let reply: (value: unknown) => void = () => undefined
mocks.call.mockImplementationOnce(
() =>
new Promise((resolve) => {
reply = resolve
})
)
const view = mount()
act(() => expect(view.result.current.send('hello')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const id = sentIds()[0]!
const params = mocks.call.mock.calls[0]![2]
// A start refused at once: the rejection lands before the send's own pending answer.
view.rerender({
submissions: [submission(id, 'rejected', { reason: "Codex couldn't restart." })],
rows: [userRow(id)]
})
await waitFor(() => expect(view.result.current.outbox).toEqual([]))
await act(async () => reply(answer(params, 'pending')))
expect(view.result.current.outbox).toEqual([])
expect(mocks.call).toHaveBeenCalledOnce()
expect(view.result.current.error).toBeNull()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
})
@@ -4,7 +4,11 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { useLayoutEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire'
import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
@@ -146,6 +150,19 @@ function sentOperationId(params: unknown): string | undefined {
: undefined
}
const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
const NO_ROWS: readonly AgentJournalRenderItem[] = []
function userRow(clientMessageId: string): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(clientMessageId),
revision: 1,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }
}
}
describe('useStructuredAgentSessionOutbox', () => {
let randomUuidSequence = 0
@@ -564,7 +581,7 @@ describe('useStructuredAgentSessionOutbox', () => {
})
})
it('lets a head the journal rejects leave without a resend, so the queued tail advances', async () => {
it('keeps a head the journal rejects until its row loads, never resending it, so the queued tail advances', async () => {
mocks.call
.mockImplementationOnce(async () => {
throw new Error('socket closed')
@@ -576,35 +593,48 @@ describe('useStructuredAgentSessionOutbox', () => {
)
)
const { result, rerender } = renderHook(
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
({
submissions,
journalItems
}: {
submissions: readonly AgentJournalSubmission[]
journalItems: readonly AgentJournalRenderItem[]
}) =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions
submissions,
journalItems
}),
{ initialProps: { submissions: [] as readonly AgentJournalSubmission[] } }
{ initialProps: { submissions: NO_SUBMISSIONS, journalItems: NO_ROWS } }
)
act(() => expect(result.current.send('first')).toBe(true))
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
const firstId = result.current.outbox[0]!.clientMessageId
act(() => expect(result.current.send('second')).toBe(true))
rerender({
submissions: [
{
clientMessageId: firstId,
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: 'not_delivered',
submittedAt: 10,
resolvedAt: 10
}
]
})
const submissions: AgentJournalSubmission[] = [
{
clientMessageId: firstId,
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: 'not_delivered',
submittedAt: 10,
resolvedAt: 10
}
]
rerender({ submissions, journalItems: [] })
// Its row is on a page not loaded: the entry draws it meanwhile, and nothing sends it again.
await waitFor(() =>
expect(result.current.outbox.map((entry) => [entry.clientMessageId, entry.state])).toEqual([
[firstId, 'dispatching']
])
)
rerender({ submissions, journalItems: [userRow(firstId)] })
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
const ids = mocks.call.mock.calls.map((call) => sentOperationId(call[2]))
// The first went once; its row says it was not sent, and its text is not handed back.
@@ -635,19 +665,33 @@ describe('useStructuredAgentSessionOutbox', () => {
value: { clientMessageId, submission: writeFailed(clientMessageId) }
}
})
const { result } = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
const { result, rerender } = renderHook(
({
submissions,
journalItems
}: {
submissions: readonly AgentJournalSubmission[]
journalItems: readonly AgentJournalRenderItem[]
}) =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions,
journalItems
}),
{ initialProps: { submissions: NO_SUBMISSIONS, journalItems: NO_ROWS } }
)
act(() => expect(result.current.send('first')).toBe(true))
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
// The reply came first: the entry draws it until the journal's row loads.
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
expect(mocks.call).toHaveBeenCalledOnce()
const id = result.current.outbox[0]!.clientMessageId
rerender({ submissions: [writeFailed(id)], journalItems: [userRow(id)] })
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
expect(mocks.call).toHaveBeenCalledOnce()
expect(result.current.error).toBeNull()
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
})
@@ -8,6 +8,7 @@ import {
} from 'react'
import type {
AgentJournalCursor,
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
@@ -37,6 +38,7 @@ import {
import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-launch-prompt-in-flight-dispatches'
import { useStructuredAgentSessionOutboxOwnerChange } from '@/runtime/structured-agent-session-accepted-send-capability'
import { useStructuredAgentSessionOutboxUnconfirmedProbe } from './use-structured-agent-session-outbox-unconfirmed-probe'
import { useStructuredAgentSessionLoadedUserRows } from './use-structured-agent-session-loaded-user-rows'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
clearStructuredAgentSessionChatLineHeldBy,
@@ -52,6 +54,8 @@ import {
/** setTimeout's longest delay; a longer wait fires early and is simply armed again. */
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
const NO_QUEUE_DELIVERY: StructuredAgentSessionQueueDelivery = {
capability: 'unsupported',
enabled: false
@@ -73,6 +77,8 @@ export function useStructuredAgentSessionOutbox(args: {
target: RuntimeClientTarget
fence: number | null
submissions: readonly AgentJournalSubmission[]
/** The loaded journal rows: a rejected message's entry draws it until its row is here. */
journalItems?: readonly AgentJournalRenderItem[]
/** How far the journal has been read; null until a page has loaded. */
journalCursor?: AgentJournalCursor | null
/** The host's queued-messages capability and the user's setting; a send stamped
@@ -85,6 +91,7 @@ export function useStructuredAgentSessionOutbox(args: {
const {
fence,
journalCursor = null,
journalItems = NO_ITEMS,
queueDelivery = NO_QUEUE_DELIVERY,
queuedMessageIds,
sessionId,
@@ -118,6 +125,7 @@ export function useStructuredAgentSessionOutbox(args: {
useLayoutEffect(() => {
submissionsRef.current = submissions
}, [submissions])
const { loadedItemIds, rowLoaded } = useStructuredAgentSessionLoadedUserRows(journalItems)
const error = useStructuredAgentSessionChatLine(sessionId)
useLayoutEffect(() => {
@@ -153,6 +161,7 @@ export function useStructuredAgentSessionOutbox(args: {
cursor: journalCursor,
inFlightClientMessageId: inFlightIdRef.current,
queuedMessageIds: queuedMessageIds ?? null,
loadedItemIds,
now
}
const entries = settleStructuredAgentSessionOutboxFromJournal(sessionId, reading)
@@ -187,6 +196,7 @@ export function useStructuredAgentSessionOutbox(args: {
drainAgain,
hostWindowsClosed,
journalCursor,
loadedItemIds,
outbox,
queuedMessageIds,
sessionId,
@@ -241,7 +251,8 @@ export function useStructuredAgentSessionOutbox(args: {
dispatchGeneration: dispatchGenerationRef.current,
dispatchGenerationRef,
inFlightIdRef,
journalHasRow
journalHasRow,
rowLoaded
})
// Whatever settles it writes the outbox, which runs this again; an unsaved stage or a voided
// answer writes nothing, so drain again when it ends either way.
@@ -253,6 +264,7 @@ export function useStructuredAgentSessionOutbox(args: {
journalHasRow,
outbox,
queueCapability,
rowLoaded,
queueEnabled,
sessionId,
target
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
// The session controller hands the queue's live cards to the transcript: a rejected message one of
// them holds under its own id is drawn as that card, never also as a not-sent row.
import { renderHook } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type {
AgentJournalMessageItem,
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection'
let queuedMessages: AgentSessionQueuedMessage[] = []
const KEPT_BODY: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'kept text' }]
}
const KEPT_ITEM: AgentJournalRenderItem = {
itemId: agentJournalSubmissionKey('kept'),
revision: 1,
sequence: 1,
observedAt: 1,
body: KEPT_BODY
}
const KEPT_SUBMISSION: AgentJournalSubmission = {
clientMessageId: 'kept',
fence: 3,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: DISPATCH_REJECTED_HOST_RESTARTED,
rejection: { kind: 'hostRestarted' },
submittedAt: 1,
resolvedAt: 2
}
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(async () => null),
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
}))
vi.mock('./use-structured-agent-session-read', () => ({
useStructuredAgentSessionRead: () => ({
state: {
fence: 3,
items: [KEPT_ITEM],
submissions: [KEPT_SUBMISSION],
status: 'ready',
error: null,
hasOlder: false,
queuedMessages
},
loadingOlder: false,
loadOlder: vi.fn()
})
}))
vi.mock('./use-structured-agent-session-outbox', () => ({
structuredSessionOperationId: () => 'operation-1',
useStructuredAgentSessionOutbox: () => ({
outbox: [],
error: null,
send: vi.fn(),
retry: vi.fn(),
withdrawUnsent: vi.fn()
})
}))
import { useStructuredAgentSession } from './use-structured-agent-session'
function card(messageId: string): AgentSessionQueuedMessage {
return {
messageId,
position: 1,
body: KEPT_BODY,
state: 'waiting'
}
}
function keptRows(): { id: string; unsent?: true }[] {
const { result, unmount } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
agent: 'claude',
target: { kind: 'local' },
isVisible: true,
composerScopeKey: 'scope-1'
})
)
const rows = result.current.messages
.filter((message) => message.id === KEPT_ITEM.itemId)
.map(({ id, unsent }) => ({ id, ...(unsent ? { unsent } : {}) }))
unmount()
return rows
}
beforeEach(() => {
queuedMessages = []
})
it('draws no row for a rejected message while a card holds it under its id', () => {
queuedMessages = [card('kept')]
expect(keptRows()).toEqual([])
})
it('draws it as not sent once no card holds it', () => {
expect(keptRows()).toEqual([{ id: KEPT_ITEM.itemId, unsent: true }])
})
@@ -18,6 +18,7 @@ import {
useStructuredAgentSessionHostStopsConversation
} from '@/runtime/structured-agent-session-host-capability'
import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal'
import { structuredAgentSessionOutboxOwesDelivery } from '../../../../shared/structured-agent-session-outbox'
import { useStructuredAgentSessionConversationStop } from './use-structured-agent-session-conversation-stop'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
import {
@@ -126,6 +127,7 @@ export function useStructuredAgentSession(args: {
// Only a live journal may decide a message alone; a stale one kept through an outage says
// nothing about what the host holds now.
journalCursor: journalLive ? state.cursor : null,
journalItems: transportState.journalItems,
queueDelivery: { capability: queueCapability, enabled: queueFollowUps },
// Absent until the host publishes a list: only a list says it holds no draft under an id.
queuedMessageIds:
@@ -181,7 +183,8 @@ export function useStructuredAgentSession(args: {
const messages = useStructuredAgentSessionMessages(
transportState.journalItems,
transcriptOutbox,
transportState.submissions
transportState.submissions,
queuedMessageIds
)
const queuedController = useStructuredAgentSessionQueuedMessages({
enabled: queueCapable && transportState.fence !== null,
@@ -203,7 +206,7 @@ export function useStructuredAgentSession(args: {
transportState.turnId ||
prompts.length ||
transportState.backgroundTasks.isMonitoring ||
outbox.length
structuredAgentSessionOutboxOwesDelivery(outbox, transportState.submissions)
),
startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items),
recorded: (clientMessageId) =>
@@ -235,6 +238,8 @@ export function useStructuredAgentSession(args: {
outbox,
/** The journal's rows for sent messages, which carry a rejected message's whole fact. */
submissions: transportState.submissions,
/** The host's queued cards, which hold their own rejected hand-offs. */
queuedMessageIds,
// A message typed during a command queues behind it on the host.
send: (...input: Parameters<typeof outboxController.send>) =>
// Legacy: an older host refuses sends while a command runs; removable once those hosts age out.
@@ -13,7 +13,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
@@ -48,6 +48,7 @@ export function buildVisibleWorktreeOptionsFromState(
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
defaultHostId: getSettingsFocusedExecutionHostId(state.settings),
worktreeLineageById: state.worktreeLineageById
worktreeLineageById: state.worktreeLineageById,
preserveLineageParentOrder: state.sortBy === 'manual'
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import { worktree, repoMap } from './worktree-list-groups-test-fixtures'
import { computeVisibleWorktrees, type VisibleWorktreeOptions } from './visible-worktrees'
import { buildRows } from './worktree-list/grouping/build-rows'
function scenario(hostId: ExecutionHostId) {
const parent = { ...worktree, id: 'parent', instanceId: 'parent-instance', hostId }
const child = { ...worktree, id: 'child', instanceId: 'child-instance', hostId }
const sibling = { ...worktree, id: 'sibling', instanceId: 'sibling-instance', hostId }
const lineage: WorktreeLineage = {
worktreeId: child.id,
worktreeInstanceId: child.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
const options: VisibleWorktreeOptions = {
filterRepoIds: [],
showSleepingWorkspaces: true,
tabsByWorktree: {},
ptyIdsByTabId: {},
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
hideWorkspacesFromOtherDevices: false,
pairedDeviceIdsByEnvironment: new Map(),
repoMap,
workspaceHostScope: 'all',
defaultHostId: 'local',
worktreeLineageById: { [child.id]: lineage }
}
const worktreesByRepo = { [worktree.repoId]: [parent, child, sibling] }
const sortedIds = [child.id, sibling.id, parent.id]
function renderedIds(overrides: Partial<VisibleWorktreeOptions> = {}): string[] {
const visible = computeVisibleWorktrees(worktreesByRepo, sortedIds, {
...options,
...overrides
})
return buildRows(
'none',
visible,
repoMap,
{},
new Set(),
new Map(),
[],
undefined,
options.worktreeLineageById,
new Map([parent, child, sibling].map((row) => [row.id, row])),
true
).flatMap((row) => (row.type === 'item' ? [row.worktree.id] : []))
}
return { renderedIds }
}
describe.each(['local', 'ssh:remote'] as const)('manual lineage order on %s', (hostId) => {
it('keeps a parent below its neighbor despite higher-ranked children', () => {
expect(scenario(hostId).renderedIds({ preserveLineageParentOrder: true })).toEqual([
'sibling',
'parent',
'child'
])
})
it('keeps a filtered structural parent in its manual position', () => {
expect(
scenario(hostId).renderedIds({
preserveLineageParentOrder: true,
showSleepingWorkspaces: false,
worktreeIdsWithLiveAgent: new Set(['child', 'sibling'])
})
).toEqual(['sibling', 'parent', 'child'])
})
it('continues promoting a parent with its children for automatic sorts', () => {
expect(scenario(hostId).renderedIds()).toEqual(['parent', 'child', 'sibling'])
})
})
@@ -85,6 +85,7 @@ export type VisibleWorktreeOptions = {
defaultHostId: ExecutionHostId
worktreeLineageById: Record<string, WorktreeLineage>
injectLineageAncestors?: boolean
preserveLineageParentOrder?: boolean
forcedVisibleWorktreeIds?: readonly string[]
}
@@ -175,6 +176,10 @@ export function computeVisibleWorktrees(
// Apply cached sort order. Items not yet in the cache (e.g. brand-new
// worktrees before the next sortEpoch bump) are appended at the end.
// Manual placement belongs to the parent, even when a hidden child has a higher rank.
if (opts.injectLineageAncestors !== false && opts.preserveLineageParentOrder) {
all = addVisibleLineageAncestors(all, lineageAncestorById, opts.worktreeLineageById)
}
const orderIndex = getSortedWorktreeRankIndex(sortedIds)
all.sort((a, b) => {
const ai = orderIndex.get(a.id) ?? Infinity
@@ -182,7 +187,7 @@ export function computeVisibleWorktrees(
return ai - bi
})
return opts.injectLineageAncestors === false
return opts.injectLineageAncestors === false || opts.preserveLineageParentOrder
? all
: addVisibleLineageAncestors(all, lineageAncestorById, opts.worktreeLineageById)
}
@@ -106,6 +106,7 @@ export function useVisibleSidebarWorktrees(args: {
visibleWorkspaceHostIds,
defaultHostId,
worktreeLineageById,
preserveLineageParentOrder: sortBy === 'manual',
forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId
? [args.agentSendTargetWorktreeId]
: undefined
@@ -130,6 +131,7 @@ export function useVisibleSidebarWorktrees(args: {
ptyIdsByTabId,
browserTabsByWorktree,
sortedIds,
sortBy,
worktreeLineageById,
worktreesByRepo,
pairedDeviceIdsByEnvironment,
@@ -123,6 +123,30 @@ describe('callStructuredAgentSession rewind capability', () => {
})
})
describe('callStructuredAgentSession timeouts', () => {
const target = { kind: 'environment', environmentId: 'env-1' } as const
beforeEach(() => {
vi.resetAllMocks()
mocks.call.mockResolvedValue({ origin: 'unknown' })
})
// A waiting read lasts as long as the host's listing, which outlasts the default 15 s.
it('gives a model catalog read longer than the slowest host listing', async () => {
const params = { agent: 'claude', sessionId: 'session-1', waitForListing: true }
await callStructuredAgentSession(target, 'agentSession.modelCatalog', params)
const [, , , options] = mocks.call.mock.calls[0]!
expect(options.timeoutMs).toBeGreaterThan(80_000)
})
it('leaves other reads on the default timeout', async () => {
await callStructuredAgentSession(target, 'agentSession.options', { sessionId: 'session-1' })
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.options', {
sessionId: 'session-1'
})
})
})
describe('subscribeStructuredAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -85,6 +85,13 @@ export async function readStructuredAgentSessionConversationOutline(
)
}
const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, number> = new Map([
['agentSession.conversationCommand', 195_000],
// A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s
// for an account switch to settle and 5 s of login-shell environment.
['agentSession.modelCatalog', 90_000]
])
export async function callStructuredAgentSession<TResult>(
target: RuntimeClientTarget,
method: string,
@@ -100,9 +107,10 @@ export async function callStructuredAgentSession<TResult>(
) {
throw new Error('Rewinding requires a newer Orca server. Update the server and try again.')
}
return method === 'agentSession.conversationCommand'
? callRuntimeRpc<TResult>(target, method, params, { timeoutMs: 195_000 })
: callRuntimeRpc<TResult>(target, method, params)
const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method)
return timeoutMs === undefined
? callRuntimeRpc<TResult>(target, method, params)
: callRuntimeRpc<TResult>(target, method, params, { timeoutMs })
}
async function subscribeStructuredAgentSessionMethod<TEvent>(
@@ -17,7 +17,7 @@ export const EXACT_NODE_ENTRYPOINT_IDENTITIES: readonly {
// Why: Pi's npm shim launches a generic cli.js; only the exact package path is authoritative.
{
pattern:
/(?:^|\/)node_modules\/@(?:earendil-works|mariozechner)\/pi-coding-agent\/dist\/cli\.js$/,
/(?:^|\/)node_modules\/@(?:earendil-works|mariozechner)\/pi-coding-agent\/dist\/(?:bundle\/)?cli\.js$/,
agent: 'pi',
processName: 'pi'
},
+32 -6
View File
@@ -405,12 +405,38 @@ describe('agent process recognition', () => {
).toEqual({ agent: 'gemini', processName: 'gemini' })
})
it.each(['earendil-works', 'mariozechner'])('recognizes the @%s Pi npm entrypoint', (scope) => {
expect(
recognizeAgentProcessFromCommandLine(
String.raw`node.exe C:\Users\dev\AppData\Roaming\npm\node_modules\@${scope}\pi-coding-agent\dist\cli.js`
)
).toEqual({ agent: 'pi', processName: 'pi' })
it.each([
['earendil-works', 'cli.js'],
['earendil-works', 'bundle/cli.js'],
['mariozechner', 'cli.js'],
['mariozechner', 'bundle/cli.js']
])('recognizes the @%s Pi npm entrypoint at dist/%s', (scope, entrypoint) => {
const windowsEntrypoint = entrypoint.replace('/', '\\')
const windowsPath = `C:\\Users\\Dev User\\AppData\\Roaming\\npm\\node_modules\\@${scope}\\pi-coding-agent\\dist\\${windowsEntrypoint}`
for (const command of [
`node /usr/local/lib/node_modules/@${scope}/pi-coding-agent/dist/${entrypoint}`,
`node.exe ${windowsPath.replace('Dev User', 'dev')}`,
String.raw`"C:\Program Files\nodejs\node.exe" "${windowsPath}"`
]) {
expect(recognizeAgentProcessFromCommandLine(command)).toEqual({
agent: 'pi',
processName: 'pi'
})
}
})
it.each([
'node /tmp/dist/bundle/cli.js',
'node /tmp/node_modules/@other/pi-coding-agent/dist/bundle/cli.js',
'node /tmp/node_modules/@earendil-works/not-pi-coding-agent/dist/bundle/cli.js',
'node /tmp/notnode_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js',
'node /tmp/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js.extra',
'node /tmp/node_modules/@earendil-works/pi-coding-agent/dist/bundle/nested/cli.js',
'node /tmp/server.js /tmp/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js',
'node --require /tmp/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js /tmp/server.js',
'node --eval "console.log(1)" /tmp/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js'
])('does not recognize a Pi entrypoint lookalike or argument: %s', (command) => {
expect(recognizeAgentProcessFromCommandLine(command)).toBeNull()
})
it('recognizes Prime Agent by its binary and npm entrypoint', () => {

Some files were not shown because too many files have changed in this diff Show More