mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-failed-send-returns-to-composer
Main now lets the host's row own a message it recorded and then rejected. The "Sending…" marker follows that: the notices read the outbox through the same reconcile as the transcript, now with the loaded rows, so a message whose row was rejected reads as not sent in the host's words and never as sending. The delivery-notice hook passes the journal's rows whenever the outbox holds a message, so the marker still clears on a pending or accepted row.
This commit is contained in:
@@ -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,7 +158,11 @@ export function useMobileStructuredAgentSession(args: {
|
||||
})
|
||||
|
||||
const messages = useMemo(
|
||||
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
|
||||
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
|
||||
() =>
|
||||
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
|
||||
rejectedInPlace: false
|
||||
}),
|
||||
[state.items, state.submissions]
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
+158
@@ -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)
|
||||
|
||||
+6
-6
@@ -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([])
|
||||
})
|
||||
})
|
||||
+5
-8
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+93
-1
@@ -20,10 +20,14 @@ import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
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()
|
||||
@@ -135,7 +139,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
messages: projectStructuredAgentSessionMessages(items, outbox, submissions),
|
||||
messages: projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS),
|
||||
status: phase === 'done' ? 'ready' : 'working',
|
||||
sessionId: 'session-1',
|
||||
agent: 'claude',
|
||||
@@ -214,3 +218,91 @@ describe.each([
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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',
|
||||
vi.fn(),
|
||||
submissions,
|
||||
[],
|
||||
new Set()
|
||||
)}
|
||||
isWorking={phase !== 'done'}
|
||||
workingStartedAt={phase === 'done' ? null : Date.now() - 1500}
|
||||
settledTurns={settledTurns}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it('draws it where it was sent, with its reason and no Retry, 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')
|
||||
})
|
||||
})
|
||||
|
||||
+109
@@ -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)}
|
||||
/>
|
||||
|
||||
+6
-7
@@ -82,8 +82,8 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi
|
||||
}
|
||||
}
|
||||
|
||||
// The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them.
|
||||
function renderPane(messages: ReturnType<typeof rejected>[]): void {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = messages.map((message) => message.submission)
|
||||
localStorage.setItem(
|
||||
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`,
|
||||
@@ -113,8 +113,9 @@ async function notice(clientMessageId: string): Promise<HTMLElement> {
|
||||
})
|
||||
}
|
||||
|
||||
// The start's own row says why, so its rejected messages say only that they were not sent.
|
||||
it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => {
|
||||
// The start's own row says why, so its rejected messages say only that they were not sent. Sending
|
||||
// one again is a new message, so none offers a Retry.
|
||||
it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => {
|
||||
mocks.journalItems = [startFailureRow(START_FAILED)]
|
||||
|
||||
renderPane([
|
||||
@@ -125,7 +126,7 @@ it("says only 'not sent', with its Retry, on each message the failed start's row
|
||||
for (const id of ['first', 'second']) {
|
||||
const row = await notice(id)
|
||||
expect(within(row).getByText('Your message was not sent.')).toBeTruthy()
|
||||
expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy()
|
||||
expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
}
|
||||
expect(screen.queryByText(/stopped before it finished starting/)).toBeNull()
|
||||
})
|
||||
@@ -157,7 +158,5 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro
|
||||
it("keeps the start failure's own words when its row is not loaded", async () => {
|
||||
renderPane([rejected('first', START_FAILED_REASON, START_FAILED)])
|
||||
|
||||
expect(
|
||||
within(await notice('first')).getByText('Claude stopped before it finished starting.')
|
||||
).toBeTruthy()
|
||||
expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy()
|
||||
})
|
||||
|
||||
+4
-1
@@ -179,6 +179,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,
|
||||
@@ -189,7 +190,9 @@ export function createStructuredSessionMocks() {
|
||||
messages:
|
||||
mocks.messages ??
|
||||
(mocks.mode === 'outbox'
|
||||
? projectStructuredAgentSessionMessages([], outbox.outbox, [])
|
||||
? projectStructuredAgentSessionMessages([], outbox.outbox, [], {
|
||||
rejectedInPlace: true
|
||||
})
|
||||
: [
|
||||
{
|
||||
id: 'message-1',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer'
|
||||
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
@@ -29,11 +29,7 @@ import { useAppStore } from '../../store'
|
||||
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
|
||||
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
|
||||
import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
|
||||
import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
|
||||
const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
|
||||
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
|
||||
|
||||
export function NativeChatStructuredSession(
|
||||
props: Omit<NativeChatStructuredViewProps, 'mode'>
|
||||
@@ -115,42 +111,16 @@ export function NativeChatStructuredSession(
|
||||
}),
|
||||
[controller, historyPhase, props.agent, props.sessionId]
|
||||
)
|
||||
// Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
|
||||
const retryRef = useRef(controller.retry)
|
||||
useEffect(() => {
|
||||
retryRef.current = controller.retry
|
||||
})
|
||||
const retryDelivery = useCallback((clientMessageId: string) => {
|
||||
retryRef.current(clientMessageId)
|
||||
}, [])
|
||||
const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude')
|
||||
// Only a message still in the outbox reads the journal's rows, so a new batch of them re-renders
|
||||
// no row else.
|
||||
const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected')
|
||||
const journalRows = controller.outbox.length > 0 ? controller.submissions : NO_SUBMISSIONS
|
||||
const startFailures = useStructuredAgentSessionStartFailureFacts(
|
||||
controller.journalItems,
|
||||
hasRejected
|
||||
)
|
||||
const deliveryNotices = useMemo(
|
||||
() =>
|
||||
structuredAgentSessionDeliveryNotices(
|
||||
controller.outbox,
|
||||
agentLabel,
|
||||
retryDelivery,
|
||||
journalRows,
|
||||
startFailures,
|
||||
controller.failedHere
|
||||
),
|
||||
[
|
||||
controller.outbox,
|
||||
agentLabel,
|
||||
retryDelivery,
|
||||
journalRows,
|
||||
startFailures,
|
||||
controller.failedHere
|
||||
]
|
||||
)
|
||||
const deliveryNotices = useStructuredAgentSessionDeliveryNotices({
|
||||
outbox: controller.outbox,
|
||||
submissions: controller.submissions,
|
||||
journalItems: controller.journalItems,
|
||||
failedHere: controller.failedHere,
|
||||
queuedMessageIds: controller.queuedMessageIds,
|
||||
retry: controller.retry,
|
||||
agentName: agentLabel
|
||||
})
|
||||
const viewState = selectNativeChatViewState(session, { readRetries: true })
|
||||
// Nothing reads an unread history, so its pane stays blank beside the Retry line.
|
||||
const loadingPane = historyPhase === 'unread' ? null : <NativeChatLoadingCue />
|
||||
|
||||
+50
-23
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
questionCardProps: null as NativeChatQuestionCardProps | null,
|
||||
promptItems: [] as AgentJournalRenderItem[],
|
||||
noJournalItems: Array.of<AgentJournalRenderItem>(),
|
||||
respond: vi.fn(),
|
||||
handlePasteEvent: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
@@ -53,6 +54,7 @@ vi.mock('./use-structured-agent-session', async () => {
|
||||
target: { kind: 'local' } | { kind: 'environment'; environmentId: string }
|
||||
}) => {
|
||||
const outbox = useStructuredAgentSessionOutbox({
|
||||
journalItems: mocks.noJournalItems,
|
||||
sessionId: props.sessionId,
|
||||
target: props.target,
|
||||
fence: 1,
|
||||
@@ -62,7 +64,9 @@ vi.mock('./use-structured-agent-session', async () => {
|
||||
journalItems: [],
|
||||
messages:
|
||||
mocks.mode === 'outbox'
|
||||
? projectStructuredAgentSessionMessages([], outbox.outbox, [])
|
||||
? projectStructuredAgentSessionMessages([], outbox.outbox, [], {
|
||||
rejectedInPlace: true
|
||||
})
|
||||
: [
|
||||
{
|
||||
id: 'message-1',
|
||||
@@ -189,6 +193,8 @@ import {
|
||||
getStructuredAgentSessionOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
|
||||
const REFUSED_RESTART = "The agent couldn't restart. Your message was not sent."
|
||||
|
||||
describe('NativeChatStructuredSession delivery', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -394,10 +400,11 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
})
|
||||
seedOutbox('session-held-rejected', [
|
||||
seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'),
|
||||
// Refused before the host recorded it, so its Retry is the only way it goes again.
|
||||
{
|
||||
...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'),
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
|
||||
}
|
||||
])
|
||||
|
||||
@@ -413,7 +420,7 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
|
||||
expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
|
||||
expect(screen.getByText(REFUSED_RESTART)).toBeTruthy()
|
||||
// One Retry, the stopped message's: it sends only that one.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
@@ -423,27 +430,38 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
|
||||
// The queue moved, so the rejected message offers its own Retry again.
|
||||
await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
|
||||
expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
|
||||
expect(screen.getByText(REFUSED_RESTART)).toBeTruthy()
|
||||
expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1)
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => {
|
||||
// Until the journal carries the row, the message's own copy words it; the host has it, so no Retry.
|
||||
it("words a failed start its reply rejected by the chat's agent, with no Retry", async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = []
|
||||
const startFailed = (clientMessageId: string, text: string) => ({
|
||||
...seededEntry('session-start-failed', clientMessageId, text, 'queued'),
|
||||
state: 'rejected' as const,
|
||||
lastFailure: {
|
||||
kind: 'rejected' as const,
|
||||
reason: 'Codex stopped before it finished starting. Send your message to try again.',
|
||||
rejection: { kind: 'providerStartFailed' as const }
|
||||
}
|
||||
})
|
||||
seedOutbox('session-start-failed', [
|
||||
startFailed('op-first', 'first'),
|
||||
startFailed('op-second', 'second')
|
||||
])
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
clientMessageId: params.envelope.clientOperationId,
|
||||
submission: {
|
||||
clientMessageId: params.envelope.clientOperationId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'Codex stopped before it finished starting. Send your message to try again.',
|
||||
rejection: { kind: 'providerStartFailed' },
|
||||
submittedAt: 1,
|
||||
resolvedAt: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
@@ -456,15 +474,23 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const send = mocks.composerProps?.structuredTransport?.send as
|
||||
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
|
||||
| undefined
|
||||
expect(send?.('first', [])).toBe(true)
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Codex stopped before it finished starting. Send your message to try again.'
|
||||
)
|
||||
).toBeTruthy()
|
||||
)
|
||||
expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2)
|
||||
expect(screen.queryByText(/Send your message to try again/)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("words a rejected message from its loaded journal row, not the message's own copy", async () => {
|
||||
mocks.mode = 'outbox'
|
||||
// A copy an earlier session left in the outbox gives way to the host's row.
|
||||
it("words a rejected message from its journal row, not the message's own copy, with no Retry", async () => {
|
||||
const reason = "Claude couldn't start. Send your message to try again."
|
||||
mocks.submissions = [
|
||||
{
|
||||
@@ -505,6 +531,7 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy()
|
||||
)
|
||||
expect(screen.queryByText(reason)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('names the stuck message behind an admitted head, and its Retry sends that one', async () => {
|
||||
|
||||
@@ -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
|
||||
@@ -93,9 +95,14 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour
|
||||
}
|
||||
|
||||
describe('conversation outline parity with the loaded rail', () => {
|
||||
// Except a rejected message: the desktop draws it in place and ticks it once loaded, while the
|
||||
// host's outline, which older clients read too, leaves it out.
|
||||
it('lists exactly the user messages the transcript gives a rail tick, with the same ids and previews', () => {
|
||||
const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED])
|
||||
const loaded = loadedRailItems(JOURNAL, [REJECTED])
|
||||
const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId)
|
||||
const loadedWithRejected = loadedRailItems(JOURNAL, [REJECTED])
|
||||
expect(loadedWithRejected.filter((item) => item.id === rejectedId)).toHaveLength(1)
|
||||
const loaded = loadedWithRejected.filter((item) => item.id !== rejectedId)
|
||||
|
||||
expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id))
|
||||
expect(
|
||||
|
||||
@@ -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({
|
||||
|
||||
+81
-42
@@ -60,7 +60,8 @@ function texts(
|
||||
}
|
||||
|
||||
describe('the notice on each message that did not go through', () => {
|
||||
it('gives two failed messages each their own reason and their own Retry', () => {
|
||||
// Recorded by the host, so sending one again is a new message: no Retry.
|
||||
it('gives two messages the host rejected each their own reason and no Retry', () => {
|
||||
const retry = vi.fn()
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
@@ -81,7 +82,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
retry,
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
new Set(['first', 'second'])
|
||||
)
|
||||
|
||||
expect([...notices.keys()]).toEqual([
|
||||
@@ -94,8 +95,47 @@ describe('the notice on each message that did not go through', () => {
|
||||
expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe(
|
||||
'Claude never finished starting, so Orca stopped it.'
|
||||
)
|
||||
notices.get(agentJournalSubmissionKey('second'))?.onRetry?.()
|
||||
expect(retry).toHaveBeenCalledExactlyOnceWith('second')
|
||||
expect(notices.get(agentJournalSubmissionKey('first'))?.onRetry).toBeUndefined()
|
||||
expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined()
|
||||
})
|
||||
|
||||
// However this chat learned of it: the host recorded it, so it has no control at all.
|
||||
it('gives a message the host rejected before this chat opened no Retry and no Dismiss', () => {
|
||||
const retry = vi.fn()
|
||||
const notice = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('earlier', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
retry,
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
).get(agentJournalSubmissionKey('earlier'))
|
||||
expect(notice).toEqual({ text: 'Claude messages support at most 20 images' })
|
||||
expect(retry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Refused before the host recorded it: only its Retry sends it, so it keeps one.
|
||||
it('keeps the Retry on a message refused before the host recorded it', () => {
|
||||
const notice = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('refused', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
() => {},
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
).get(agentJournalSubmissionKey('refused'))
|
||||
expect(notice?.onRetry).toBeDefined()
|
||||
expect(notice?.onDismiss).toBeUndefined()
|
||||
})
|
||||
|
||||
it('chooses the words from the saved refusal on a refused message', () => {
|
||||
@@ -235,39 +275,15 @@ describe('the notice on each message that did not go through', () => {
|
||||
expect(retry.mock.calls).toEqual([['refused'], ['rejected'], ['stuck']])
|
||||
})
|
||||
|
||||
// Beside its own Retry the resend step is the button; without one the words keep it.
|
||||
it('leaves out sending again only where the message has its own Retry', () => {
|
||||
const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry =>
|
||||
entry(clientMessageId, {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'Claude stopped before it finished starting. Send your message to try again.',
|
||||
rejection: { kind: 'providerStartFailed' }
|
||||
}
|
||||
})
|
||||
expect(texts([startFailed('first'), startFailed('second')])).toEqual({
|
||||
[agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.',
|
||||
[agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.'
|
||||
})
|
||||
expect(texts([entry('held', { outlivedStop: true }), startFailed('rejected')])).toMatchObject({
|
||||
[agentJournalSubmissionKey('rejected')]:
|
||||
'Claude stopped before it finished starting. Send your message to try again.'
|
||||
})
|
||||
})
|
||||
|
||||
// With no Retry beside it, the words keep the resend step.
|
||||
it.each([
|
||||
[
|
||||
'notDelivered',
|
||||
'This message was not delivered. Send it again to continue.',
|
||||
'This message was not delivered.'
|
||||
'providerStartFailed',
|
||||
'Claude stopped before it finished starting. Send your message to try again.'
|
||||
],
|
||||
[
|
||||
'hostFault',
|
||||
"Orca ran into a problem, so this didn't go through. Try again.",
|
||||
"Orca ran into a problem, so this didn't go through."
|
||||
]
|
||||
] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => {
|
||||
['notDelivered', 'This message was not delivered. Send it again to continue.'],
|
||||
['hostFault', "Orca ran into a problem, so this didn't go through. Try again."]
|
||||
] as const)('keeps the step in the words of a %s message the host rejected', (kind, reason) => {
|
||||
expect(
|
||||
texts([
|
||||
entry('rejected', {
|
||||
@@ -275,7 +291,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
lastFailure: { kind: 'rejected', reason, rejection: { kind } }
|
||||
})
|
||||
])
|
||||
).toEqual({ [agentJournalSubmissionKey('rejected')]: shown })
|
||||
).toEqual({ [agentJournalSubmissionKey('rejected')]: reason })
|
||||
})
|
||||
|
||||
// The journal holds the whole fact; the message's own copy keeps only its kind and attachment.
|
||||
@@ -293,7 +309,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({
|
||||
clientMessageId: id,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
payloadFingerprint: id,
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: "The agent couldn't be started.",
|
||||
@@ -324,7 +340,8 @@ describe('the notice on each message that did not go through', () => {
|
||||
[
|
||||
'resumable',
|
||||
{ kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } },
|
||||
"Claude couldn't start."
|
||||
// No Retry beside it, so the words keep the step.
|
||||
"Claude couldn't start. Send your message to try again."
|
||||
],
|
||||
[
|
||||
'provider',
|
||||
@@ -404,7 +421,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
{
|
||||
clientMessageId: 'recorded',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
payloadFingerprint: 'recorded',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason,
|
||||
@@ -507,7 +524,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Only a row that has the message ends it; one in doubt or rejected leaves it looking sent.
|
||||
// Only a row that has the message ends it; one in doubt leaves it looking sent.
|
||||
describe('a message whose journal row does not have it yet', () => {
|
||||
const row = (
|
||||
clientMessageId: string,
|
||||
@@ -555,14 +572,36 @@ describe('the notice on each message that did not go through', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('says a requeued message whose row was rejected is sending until its replay answers', () => {
|
||||
// The rejection makes it the host's: not sent, in the host's words, whether its outbox copy or
|
||||
// its loaded row draws it.
|
||||
it('says a requeued message whose row was rejected was not sent, never sending', () => {
|
||||
const requeued = entry('q', { state: 'queued', lastAttemptAt: 1 })
|
||||
const rejected = row('q', 'rejected', { reason: 'provider said no', resolvedAt: 8 })
|
||||
expect(texts([requeued], [rejected])).toEqual({ [agentJournalSubmissionKey('q')]: SENDING })
|
||||
const notSent = { [agentJournalSubmissionKey('q')]: 'provider said no' }
|
||||
expect(texts([requeued], [rejected])).toEqual(notSent)
|
||||
expect(texts([stuck, requeued], [rejected])).toEqual({
|
||||
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.',
|
||||
[agentJournalSubmissionKey('q')]: SENDING
|
||||
...notSent
|
||||
})
|
||||
const loadedRow: AgentJournalRenderItem = {
|
||||
itemId: agentJournalSubmissionKey('q'),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 8,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'q' }] }
|
||||
}
|
||||
const loaded = structuredAgentSessionDeliveryNotices(
|
||||
[requeued],
|
||||
'Claude',
|
||||
vi.fn(),
|
||||
[rejected],
|
||||
[],
|
||||
NOT_FAILED_HERE,
|
||||
[],
|
||||
new Set(),
|
||||
[loadedRow]
|
||||
)
|
||||
expect([...loaded]).toEqual([[agentJournalSubmissionKey('q'), { text: 'provider said no' }]])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+57
-27
@@ -1,6 +1,7 @@
|
||||
// Which of the structured chat's own messages say, on their row, that they did not go through, and
|
||||
// which say quietly that they are still sending: every other one, until the host holds a row that
|
||||
// has it (pending or accepted). A row in doubt or rejected does not, so it still reads as sending.
|
||||
// has it (pending or accepted). A row in doubt does not, so a message resent over one still reads
|
||||
// as sending; a rejected row makes it the host's, shown as not sent.
|
||||
//
|
||||
// Derived from the outbox on every render and never stored: each failed or held message carries
|
||||
// its own typed failure, so each row words its own reason. Read through the drain's own rule: while
|
||||
@@ -9,10 +10,11 @@
|
||||
// its own, so it reads as sending; a rejected or refused message holds nothing up, so it keeps its
|
||||
// words and gets its Retry once the queue moves.
|
||||
//
|
||||
// A message the host recorded and then rejected is worded from the journal's own fact, found by id;
|
||||
// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that
|
||||
// is a failed start's, the fact its loaded row states, says only that it was not sent: the row
|
||||
// already says why.
|
||||
// A message the host recorded and then rejected is drawn from the host's history, worded from the
|
||||
// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a
|
||||
// failed start's, the fact its loaded row states, says only that it was not sent: the row already
|
||||
// says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch
|
||||
// or page that loads the row.
|
||||
|
||||
import {
|
||||
readAgentSessionFailureFact,
|
||||
@@ -28,6 +30,7 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-
|
||||
import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key'
|
||||
import {
|
||||
structuredAgentSessionEntryIdExpired,
|
||||
structuredAgentSessionEntryRejectedByHost,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
@@ -37,13 +40,19 @@ import {
|
||||
import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off'
|
||||
import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend'
|
||||
import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import {
|
||||
structuredAgentSessionAttemptFailureParts,
|
||||
structuredAgentSessionRejectionParts
|
||||
} from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
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_COMMANDS: ReadonlySet<string> = new Set()
|
||||
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
|
||||
|
||||
/** The facts the chat's loaded start-failure rows state. */
|
||||
export function structuredAgentSessionStartFailureFacts(
|
||||
@@ -94,8 +103,6 @@ export function agentSessionFailureStatedByStartRow(
|
||||
function deliveryNoticeText(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
context: AgentSessionFailureWordsContext,
|
||||
recorded: AgentJournalSubmission | undefined,
|
||||
startFailures: readonly AgentSessionFailureFact[],
|
||||
failedHere: ReadonlySet<string>
|
||||
): string {
|
||||
// A send attempted before a Stop and then interrupted may already be with the host.
|
||||
@@ -121,17 +128,25 @@ function deliveryNoticeText(
|
||||
if (structuredAgentSessionEntryHeldForRetry(entry) && !failedHere.has(entry.clientMessageId)) {
|
||||
return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
|
||||
}
|
||||
if (
|
||||
entry.state === 'rejected' &&
|
||||
agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures)
|
||||
) {
|
||||
return agentSessionWriteNoticeText(
|
||||
structuredAgentSessionAttemptFailureParts(entry.lastFailure, context)
|
||||
)
|
||||
}
|
||||
|
||||
function hostRejectionNoticeText(
|
||||
submission: AgentJournalSubmission,
|
||||
agentName: string,
|
||||
startFailures: readonly AgentSessionFailureFact[]
|
||||
): string {
|
||||
if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) {
|
||||
return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
|
||||
}
|
||||
return agentSessionWriteNoticeText(
|
||||
structuredAgentSessionAttemptFailureParts(
|
||||
entry.lastFailure,
|
||||
context,
|
||||
readWholeAgentSessionFailureFact(recorded?.rejection)
|
||||
structuredAgentSessionRejectionParts(
|
||||
submission.reason,
|
||||
'send',
|
||||
readWholeAgentSessionFailureFact(submission.rejection),
|
||||
{ agentName }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -148,10 +163,16 @@ export function structuredAgentSessionDeliveryNotices(
|
||||
/** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
|
||||
startFailures: readonly AgentSessionFailureFact[],
|
||||
/** Ids whose send failed or was refused while this chat was open: only they word their cause. */
|
||||
failedHere: ReadonlySet<string>
|
||||
failedHere: ReadonlySet<string>,
|
||||
/** The queue's live cards, which the transcript leaves a rejected message to. */
|
||||
queuedMessageIds: readonly string[] = [],
|
||||
/** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */
|
||||
commandItemIds: ReadonlySet<string> = NO_COMMANDS,
|
||||
/** The loaded rows: a rejected message's outbox copy leaves once its row is here. */
|
||||
journalItems: readonly AgentJournalRenderItem[] = NO_ITEMS
|
||||
): ReadonlyMap<string, NativeChatDeliveryNotice> {
|
||||
// As the transcript reads it, so a row that lands is answered here before the outbox commits it.
|
||||
const entries = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)
|
||||
const entries = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions, journalItems)
|
||||
const admission = admitStructuredAgentSessionOutboxEntry(entries)
|
||||
const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null
|
||||
const stalledFrom = admission.state === 'blocked' ? entries.indexOf(admission.entry) : -1
|
||||
@@ -169,15 +190,12 @@ export function structuredAgentSessionDeliveryNotices(
|
||||
structuredAgentSessionEntryHeldForRetry(entry) ||
|
||||
entry.clientMessageId === held)
|
||||
) {
|
||||
// Its own Retry is the step, so the words leave out sending again.
|
||||
const retryControl = stalledFrom === -1 || index <= stalledFrom
|
||||
const text = deliveryNoticeText(
|
||||
entry,
|
||||
{ agentName, retryControl },
|
||||
rejected.get(entry.clientMessageId),
|
||||
startFailures,
|
||||
failedHere
|
||||
)
|
||||
// Its own Retry is the step, so the words leave out sending again. One the host recorded is
|
||||
// the host's: sending it again is a new message, so it has no Retry.
|
||||
const retryControl =
|
||||
(stalledFrom === -1 || index <= stalledFrom) &&
|
||||
!structuredAgentSessionEntryRejectedByHost(entry)
|
||||
const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere)
|
||||
notices.set(
|
||||
agentJournalSubmissionKey(entry.clientMessageId),
|
||||
retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text }
|
||||
@@ -195,5 +213,17 @@ export function structuredAgentSessionDeliveryNotices(
|
||||
)
|
||||
}
|
||||
}
|
||||
// After the outbox's: in the host's words, whether its row or the outbox's copy draws it.
|
||||
const shown = structuredAgentSessionRejectedShownInPlace(
|
||||
submissions,
|
||||
queuedMessageIds,
|
||||
commandItemIds
|
||||
)
|
||||
for (const submission of rejected.values()) {
|
||||
const id = agentJournalSubmissionKey(submission.clientMessageId)
|
||||
if (shown.has(id)) {
|
||||
notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) })
|
||||
}
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// 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,
|
||||
// with no control, until the page holding the row loads, and then the host's row draws it instead.
|
||||
|
||||
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).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }])
|
||||
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,
|
||||
[],
|
||||
new Set()
|
||||
).get(MESSAGE_ID)
|
||||
expect(notice).toEqual({ 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 }])
|
||||
})
|
||||
+218
@@ -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.
|
||||
|
||||
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,
|
||||
structuredAgentSessionRejectedFailure,
|
||||
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.'
|
||||
|
||||
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 the host's own reply already marked rejected, as the outbox stores it. */
|
||||
const RECORDED_COPY = copy({
|
||||
state: 'rejected',
|
||||
lastFailure: structuredAgentSessionRejectedFailure(REJECTED)
|
||||
})
|
||||
|
||||
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,
|
||||
[],
|
||||
new Set()
|
||||
).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, [])
|
||||
const sent = copy({ state: 'dispatching', lastAttemptAt: 9 })
|
||||
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])).toEqual({
|
||||
kept: [],
|
||||
rows: [{ id: MESSAGE_ID, unsent: true }],
|
||||
notice: { text: WORDS }
|
||||
})
|
||||
})
|
||||
|
||||
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, [RECORDED_COPY])).toEqual({
|
||||
kept: [],
|
||||
rows: [{ id: MESSAGE_ID, unsent: true }],
|
||||
notice: { text: WORDS }
|
||||
})
|
||||
})
|
||||
|
||||
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, [copy({ state: 'dispatching', lastAttemptAt: 9 })])
|
||||
expect(before.kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }])
|
||||
expect(before.rows).toEqual([{ id: MESSAGE_ID, unsent: true }])
|
||||
expect(before.notice).toEqual({ text: WORDS })
|
||||
|
||||
// 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: { text: WORDS }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a stored copy whose record is not held drawn, with no control, and never sends it', () => {
|
||||
const state = opened(WINDOW, [])
|
||||
const after = shown(state, [RECORDED_COPY])
|
||||
|
||||
expect(after.kept).toEqual([RECORDED_COPY])
|
||||
expect(after.rows).toEqual([{ id: MESSAGE_ID, unsent: true }])
|
||||
expect(after.notice).toEqual({ text: WORDS })
|
||||
// The drain passes over it: nothing it holds is ever on its way.
|
||||
expect(admitStructuredAgentSessionOutboxEntry(after.kept)).toEqual({ state: 'idle', entry: null })
|
||||
})
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
// 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,
|
||||
structuredAgentSessionCommandItemIds
|
||||
} 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[] = []
|
||||
|
||||
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 notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
[SEED, restartRejected('lost', 'fix the parser', 3)],
|
||||
[],
|
||||
new Set()
|
||||
)
|
||||
|
||||
const notice = notices.get(agentJournalSubmissionKey('lost'))
|
||||
expect(notice?.text).toBe('Orca restarted before this message was sent.')
|
||||
expect(notice?.onRetry).toBeUndefined()
|
||||
expect([...notices.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' }
|
||||
})
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
[failedStart],
|
||||
[{ kind: 'notSignedIn' }],
|
||||
new Set()
|
||||
)
|
||||
|
||||
expect(notices.get(agentJournalSubmissionKey('first'))).toEqual({
|
||||
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 }
|
||||
])
|
||||
})
|
||||
|
||||
// Its own reply reports the rejection, in the composer, as a command's.
|
||||
it('is not 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 }])
|
||||
// One rule decides for the rows and the notices.
|
||||
expect(
|
||||
structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
submissions,
|
||||
[],
|
||||
new Set(),
|
||||
NO_CARDS,
|
||||
structuredAgentSessionCommandItemIds([...SEED_ROWS, compact])
|
||||
).size
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
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(
|
||||
structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).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)
|
||||
const held = outboxEntry('held', 'outbox copy', {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: DISPATCH_REJECTED_HOST_RESTARTED,
|
||||
rejection: { kind: 'hostRestarted' }
|
||||
}
|
||||
})
|
||||
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 }
|
||||
|
||||
it('the reply first: the outbox draws it, saying why, with no Retry', () => {
|
||||
const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED], NO_CARDS)
|
||||
expect(rows(messages)).toEqual([seedRow, heldRow])
|
||||
const notice = structuredAgentSessionDeliveryNotices(
|
||||
[held],
|
||||
'Claude',
|
||||
() => {},
|
||||
[SEED],
|
||||
[],
|
||||
new Set(['held'])
|
||||
).get(heldRow.id)
|
||||
expect(notice?.text).toBe('Orca restarted before this message was sent.')
|
||||
expect(notice?.onRetry).toBeUndefined()
|
||||
})
|
||||
|
||||
it("the journal first, or next: the host's row replaces the outbox copy at once", () => {
|
||||
const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined }
|
||||
expect(
|
||||
rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED], NO_CARDS))
|
||||
).toEqual([seedRow, { ...heldRow, unsent: false }])
|
||||
for (const entry of [dispatching, held]) {
|
||||
// Before the reconcile drops the entry, the host's row is already the one row.
|
||||
const messages = projectStructuredAgentSessionMessages(
|
||||
items,
|
||||
[entry],
|
||||
[SEED, rejected],
|
||||
NO_CARDS
|
||||
)
|
||||
expect(rows(messages)).toEqual([seedRow, hostRow])
|
||||
expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 })
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[entry],
|
||||
'Claude',
|
||||
() => {},
|
||||
[SEED, rejected],
|
||||
[],
|
||||
new Set()
|
||||
)
|
||||
// In the host's words, with no control.
|
||||
expect([...notices]).toEqual([
|
||||
[hostRow.id, { 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(
|
||||
structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).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(
|
||||
structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [
|
||||
'kept'
|
||||
]).size
|
||||
).toBe(0)
|
||||
expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([
|
||||
seedRow,
|
||||
{ id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the phone', () => {
|
||||
it('still hides every accepted-then-rejected message: it gives the text back to its composer', () => {
|
||||
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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
+18
-17
@@ -7,6 +7,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
const NO_CARDS: readonly string[] = []
|
||||
|
||||
function submission(index: number): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: `client-${index}`,
|
||||
@@ -31,16 +33,8 @@ function item(index: number): AgentJournalRenderItem {
|
||||
}
|
||||
|
||||
describe('structured agent session message projection', () => {
|
||||
it('does not render a rejected host submission as a sent user message', () => {
|
||||
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
|
||||
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
|
||||
const acceptedItem = item(1)
|
||||
expect(
|
||||
projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected])
|
||||
).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }])
|
||||
})
|
||||
|
||||
it('keeps a refused local draft available through its outbox', () => {
|
||||
// The host recorded it, so its row is the message from here; the outbox copy gives way.
|
||||
it("draws a send the host rejected from the host's row, not the outbox copy", () => {
|
||||
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
|
||||
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
|
||||
const draft = createStructuredAgentSessionOutboxEntry({
|
||||
@@ -50,9 +44,15 @@ describe('structured agent session message projection', () => {
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toMatchObject(
|
||||
[{ id: refusedItem.itemId, blocks: [{ text: 'An unsent draft' }] }]
|
||||
)
|
||||
expect(
|
||||
projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: refusedItem.itemId,
|
||||
blocks: [{ type: 'text', text: 'send 0' }],
|
||||
unsent: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => {
|
||||
@@ -68,7 +68,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)
|
||||
@@ -103,8 +104,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])
|
||||
@@ -122,7 +123,7 @@ describe('structured agent session message projection', () => {
|
||||
})
|
||||
]
|
||||
|
||||
expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([
|
||||
expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([
|
||||
{ id: agentJournalSubmissionKey('client-pending'), role: 'user' }
|
||||
])
|
||||
})
|
||||
|
||||
+12
-2
@@ -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 & {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
structuredAgentSessionEntryIdExpired,
|
||||
structuredAgentSessionEntryRejectedByHost,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ export function retryStructuredAgentSessionOutboxEntry(args: {
|
||||
// settled the message already rotated it. An expired id is refused for good; its row told the
|
||||
// user to check the chat first.
|
||||
const recordedRejection =
|
||||
current?.state === 'rejected' && current.lastFailure?.kind === 'rejected'
|
||||
current !== undefined && structuredAgentSessionEntryRejectedByHost(current)
|
||||
if (
|
||||
current &&
|
||||
(recordedRejection ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
parseStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionEntryRejectedByHost,
|
||||
type StructuredAgentSessionAttachment,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
@@ -54,9 +55,14 @@ function publishUndelivered(sessionId: string, undelivered: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any entry still owes a delivery: a copy the host recorded and rejected owes none. */
|
||||
function owesDelivery(entries: readonly StructuredAgentSessionOutboxEntry[]): boolean {
|
||||
return entries.some((entry) => !structuredAgentSessionEntryRejectedByHost(entry))
|
||||
}
|
||||
|
||||
/** Keep the journal subscription alive while this session still owes delivery. */
|
||||
export function hasUndeliveredStructuredAgentSessionOutbox(sessionId: string): boolean {
|
||||
return undeliveredSessions.get(sessionId)?.undelivered ?? readOutbox(sessionId).length > 0
|
||||
return undeliveredSessions.get(sessionId)?.undelivered ?? owesDelivery(readOutbox(sessionId))
|
||||
}
|
||||
|
||||
export function subscribeToUndeliveredStructuredAgentSessionOutbox(
|
||||
@@ -65,7 +71,7 @@ export function subscribeToUndeliveredStructuredAgentSessionOutbox(
|
||||
): () => void {
|
||||
let subscription = undeliveredSessions.get(sessionId)
|
||||
if (!subscription) {
|
||||
subscription = { undelivered: readOutbox(sessionId).length > 0, listeners: new Set() }
|
||||
subscription = { undelivered: owesDelivery(readOutbox(sessionId)), listeners: new Set() }
|
||||
undeliveredSessions.set(sessionId, subscription)
|
||||
}
|
||||
const owned = subscription
|
||||
@@ -92,7 +98,7 @@ export function writeOutbox(
|
||||
} else {
|
||||
localStorage.setItem(storageKey(sessionId), JSON.stringify(entries))
|
||||
}
|
||||
publishUndelivered(sessionId, entries.length > 0)
|
||||
publishUndelivered(sessionId, owesDelivery(entries))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -75,6 +75,15 @@ import {
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
|
||||
/** What these hooks render with: the journal's submissions, and the rows loaded so far. */
|
||||
type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }
|
||||
|
||||
function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps {
|
||||
return { submissions }
|
||||
}
|
||||
|
||||
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
|
||||
|
||||
const SESSION = 'session-1'
|
||||
const PANE = 'tab-1::session-1'
|
||||
const OTHER_PANE = 'tab-2::session-1'
|
||||
@@ -129,15 +138,16 @@ function answerSendsPending(): void {
|
||||
|
||||
function renderOutbox(composerScopeKey: string | null = PANE) {
|
||||
return renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: SESSION,
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions,
|
||||
...(composerScopeKey ? { composerScopeKey } : {})
|
||||
}),
|
||||
{ initialProps: { submissions: NONE } }
|
||||
{ initialProps: outboxProps(NONE) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,7 +269,7 @@ describe('a message the host withdrew at a Stop', () => {
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello')
|
||||
})
|
||||
|
||||
it('keeps a message refused for any other reason on its Retry, and gives nothing back', async () => {
|
||||
it("leaves a message rejected for any other reason to the host's row, and gives nothing back", async () => {
|
||||
answerSendsPending()
|
||||
const { result, rerender } = renderOutbox()
|
||||
const id = await sendToHost(result, 'hello')
|
||||
@@ -267,10 +277,19 @@ describe('a message the host withdrew at a Stop', () => {
|
||||
rerender({
|
||||
submissions: [
|
||||
submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED })
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
itemId: `orca:${id}`,
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// @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 { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const NONE = new Set<string>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
outbox: EMPTY,
|
||||
submissions,
|
||||
journalItems: EMPTY,
|
||||
failedHere: NONE,
|
||||
queuedMessageIds: NO_CARDS,
|
||||
retry: () => {},
|
||||
agentName: 'Claude'
|
||||
}),
|
||||
{ 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({
|
||||
outbox: EMPTY,
|
||||
submissions: current,
|
||||
journalItems: EMPTY,
|
||||
failedHere: NONE,
|
||||
queuedMessageIds: NO_CARDS,
|
||||
retry: () => {},
|
||||
agentName: 'Claude'
|
||||
}),
|
||||
{ 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)
|
||||
})
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { structuredAgentSessionCommandItemIds } from '../../../../shared/structured-agent-session-message-projection'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
|
||||
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
|
||||
|
||||
const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
|
||||
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
|
||||
|
||||
/** The structured chat's delivery notices, by the message id each row renders under. */
|
||||
export function useStructuredAgentSessionDeliveryNotices(args: {
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[]
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
journalItems: readonly AgentJournalRenderItem[]
|
||||
failedHere: ReadonlySet<string>
|
||||
queuedMessageIds: readonly string[]
|
||||
retry: (clientMessageId: string) => void
|
||||
agentName: string
|
||||
}): ReadonlyMap<string, NativeChatDeliveryNotice> {
|
||||
const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args
|
||||
// Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
|
||||
const retryRef = useRef(args.retry)
|
||||
useEffect(() => {
|
||||
retryRef.current = args.retry
|
||||
})
|
||||
const retry = useCallback((clientMessageId: string) => {
|
||||
retryRef.current(clientMessageId)
|
||||
}, [])
|
||||
// Only a message shown as not sent (a withdrawn one draws nothing) or still in the outbox reads
|
||||
// the journal's rows, so in a chat with neither a new batch of them re-renders no row.
|
||||
const hasRejected =
|
||||
outbox.some((entry) => entry.state === 'rejected') ||
|
||||
submissions.some(
|
||||
(submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)
|
||||
)
|
||||
const journalRows = hasRejected || outbox.length > 0 ? 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 = hasRejected && outbox.length > 0 ? args.journalItems : NO_ITEMS
|
||||
const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected)
|
||||
const commandItemIds = useCommandItemIds(args.journalItems, hasRejected)
|
||||
const notices = useMemo(
|
||||
() =>
|
||||
structuredAgentSessionDeliveryNotices(
|
||||
outbox,
|
||||
agentName,
|
||||
retry,
|
||||
journalRows,
|
||||
startFailures,
|
||||
failedHere,
|
||||
queuedMessageIds,
|
||||
commandItemIds,
|
||||
loadedItems
|
||||
),
|
||||
[
|
||||
outbox,
|
||||
agentName,
|
||||
retry,
|
||||
journalRows,
|
||||
startFailures,
|
||||
failedHere,
|
||||
queuedMessageIds,
|
||||
commandItemIds,
|
||||
loadedItems
|
||||
]
|
||||
)
|
||||
// A submission 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
|
||||
}
|
||||
|
||||
const NO_COMMANDS: ReadonlySet<string> = new Set()
|
||||
|
||||
/** The loaded commands, read only while `enabled`, and held while unchanged so a streaming turn
|
||||
* rebuilds no notice. */
|
||||
function useCommandItemIds(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
enabled: boolean
|
||||
): ReadonlySet<string> {
|
||||
const ids = useMemo(
|
||||
() => (enabled ? structuredAgentSessionCommandItemIds(items) : NO_COMMANDS),
|
||||
[enabled, items]
|
||||
)
|
||||
const previousRef = useRef(ids)
|
||||
const previous = previousRef.current
|
||||
const stable =
|
||||
previous.size === ids.size && [...ids].every((id) => previous.has(id)) ? previous : ids
|
||||
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)
|
||||
// Each Retry calls the stable `retry` with its own id, so one under the same key is the same.
|
||||
const same =
|
||||
before !== undefined &&
|
||||
before.text === notice.text &&
|
||||
(before.onRetry === undefined) === (notice.onRetry === undefined) &&
|
||||
(before.onDismiss === undefined) === (notice.onDismiss === undefined)
|
||||
allKept &&= same
|
||||
kept.set(id, same ? before : notice)
|
||||
}
|
||||
return allKept ? previous : kept
|
||||
}
|
||||
+12
-6
@@ -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,
|
||||
|
||||
+9
-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 type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -26,6 +29,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
|
||||
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
|
||||
|
||||
const LOCAL_TARGET = { kind: 'local' } as const
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -93,6 +98,7 @@ function refusedResult(code: AgentSessionWireRefusalCode) {
|
||||
function renderOutbox() {
|
||||
return renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -230,6 +236,7 @@ describe('structured agent session outbox admission', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -311,6 +318,7 @@ describe('structured agent session outbox admission', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
|
||||
+5
@@ -20,6 +20,10 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-sessio
|
||||
import { enqueueStructuredAgentSessionLaunchPrompt } 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[] = []
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -65,6 +69,7 @@ function render(fence: number | null = 1) {
|
||||
return renderHook(
|
||||
(props) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: props.fence,
|
||||
|
||||
+5
@@ -34,6 +34,10 @@ import {
|
||||
import { writeOutbox } 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[] = []
|
||||
|
||||
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.
|
||||
@@ -82,6 +86,7 @@ function mount(fence: number) {
|
||||
return renderHook(
|
||||
({ fence: current }: { fence: number }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: current,
|
||||
|
||||
+258
-64
@@ -2,7 +2,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 { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -16,13 +19,28 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import {
|
||||
hasUndeliveredStructuredAgentSessionOutbox,
|
||||
readOutbox,
|
||||
writeOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
|
||||
/** What these hooks render with: the journal's submissions, and the rows loaded so far. */
|
||||
type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }
|
||||
|
||||
function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps {
|
||||
return { submissions }
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -88,7 +106,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('names the cause on the message and keeps it for Retry', async () => {
|
||||
it('names the cause on the message until the journal row takes it over', async () => {
|
||||
mocks.call.mockImplementationOnce(
|
||||
async (
|
||||
_target: unknown,
|
||||
@@ -98,6 +116,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
@@ -108,7 +127,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
|
||||
await waitFor(() => expect(shownFailure(result.current.outbox[0])).toBe(REASON))
|
||||
// Settled as not delivered: it waits for Retry and holds no later message up.
|
||||
// Settled as not delivered: it holds no later message up.
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
})
|
||||
@@ -126,6 +145,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
@@ -151,7 +171,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a message the host accepted and then could not deliver, with its reason and Retry', async () => {
|
||||
it('leaves a message the host accepted and then could not deliver to its journal row', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
@@ -162,14 +182,15 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
{ initialProps: outboxProps(NO_SUBMISSIONS) }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
@@ -177,22 +198,17 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
rerender({
|
||||
submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }]
|
||||
submissions: [
|
||||
{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }
|
||||
],
|
||||
rows: rowsFor(id)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
// The host's row shows it as not sent, with no Retry: nothing resends it.
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(result.current.error).toBeNull()
|
||||
|
||||
// Retry is a new message with the same text: a fresh id, sent once.
|
||||
act(() => result.current.retry(id))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
const retried: {
|
||||
envelope: { clientOperationId: string }
|
||||
body: { blocks: { text?: string }[] }
|
||||
} = mocks.call.mock.calls[1]![2]
|
||||
expect(retried.envelope.clientOperationId).not.toBe(id)
|
||||
expect(retried.body.blocks[0]?.text).toBe('hello')
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('says nothing when a Stop withdrew the message', async () => {
|
||||
@@ -205,14 +221,15 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
{ initialProps: outboxProps(NO_SUBMISSIONS) }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
@@ -233,7 +250,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a message rejected while the chat was closed as not sent, and sends past it', async () => {
|
||||
it('leaves a message rejected while the chat was closed to its journal row, and sends past it', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
@@ -245,6 +262,7 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
const target = { kind: 'local' } as const
|
||||
const first = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
@@ -260,8 +278,10 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
const rejected = [
|
||||
{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected' as const, reason }
|
||||
]
|
||||
const reopenedRows = rowsFor(id)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: reopenedRows,
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
@@ -269,54 +289,192 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
// After a restart nothing in memory remembers the rejection, and its journal row may be older
|
||||
// than the loaded page: the message's own state is what says a resend needs a new id.
|
||||
it('retries a message rejected before a restart under a new id', async () => {
|
||||
const rejected = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'rejected-before-restart',
|
||||
sessionId: 'session-1',
|
||||
text: 'first',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
// One stored host fact, one rendering: a message the host recorded and rejected, whose record this
|
||||
// chat does not hold, keeps showing why on every mount, with no control, and is never sent again.
|
||||
it('keeps a message the host rejected, with no control, on every mount, and never resends it', async () => {
|
||||
writeOutbox('session-1', [
|
||||
{
|
||||
...rejected,
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'The provider did not accept this message.',
|
||||
rejection: { kind: 'providerRejected' }
|
||||
}
|
||||
...rejectedBeforeRestart(),
|
||||
state: 'dispatching',
|
||||
lastFailure: undefined,
|
||||
lastAttemptAt: 5
|
||||
}
|
||||
])
|
||||
// The host replays its rejection when the reopened chat asks about the send it left in doubt.
|
||||
mocks.call.mockImplementation(async (_target, _method, params) =>
|
||||
acceptedResultFor(String(params.envelope.clientOperationId))
|
||||
rejectedResultFor(String(params.envelope.clientOperationId))
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
const notice = (
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
failedHere: ReadonlySet<string>
|
||||
) =>
|
||||
structuredAgentSessionDeliveryNotices(
|
||||
outbox,
|
||||
'Claude',
|
||||
() => {},
|
||||
NO_SUBMISSIONS,
|
||||
[],
|
||||
failedHere
|
||||
).get(agentJournalSubmissionKey('rejected-before-restart'))
|
||||
const mount = () =>
|
||||
renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: NO_SUBMISSIONS
|
||||
})
|
||||
)
|
||||
|
||||
act(() => result.current.retry('rejected-before-restart'))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId
|
||||
expect(sentId).not.toBe('rejected-before-restart')
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
const first = mount()
|
||||
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('rejected'), {
|
||||
timeout: 4000
|
||||
})
|
||||
const onFirst = notice(first.result.current.outbox, first.result.current.failedHere)
|
||||
first.unmount()
|
||||
const second = mount()
|
||||
const onSecond = notice(second.result.current.outbox, second.result.current.failedHere)
|
||||
|
||||
for (const shown of [onFirst, onSecond]) {
|
||||
expect(shown).toEqual({ text: REASON })
|
||||
}
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 1500)))
|
||||
// Only the first mount's question about the send it left in doubt; nothing resends it.
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(second.result.current.outbox.map((entry) => entry.state)).toEqual(['rejected'])
|
||||
expect(readOutbox('session-1')).toHaveLength(1)
|
||||
expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false)
|
||||
}, 10000)
|
||||
|
||||
// The host's own record is what lets it go, and storage forgets it too: nothing is owed.
|
||||
it('drops a message rejected before a restart once the journal says so, from storage too', async () => {
|
||||
writeOutbox('session-1', [rejectedBeforeRestart()])
|
||||
const { result, rerender } = renderHook(
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: outboxProps(NO_SUBMISSIONS) }
|
||||
)
|
||||
expect(result.current.outbox).toHaveLength(1)
|
||||
|
||||
rerender({
|
||||
submissions: [
|
||||
{
|
||||
...pendingResultFor('rejected-before-restart').value.submission,
|
||||
dispatchState: 'rejected',
|
||||
reason: 'The provider did not accept this message.',
|
||||
resolvedAt: 2
|
||||
}
|
||||
],
|
||||
rows: rowsFor('rejected-before-restart')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readOutbox('session-1')).toEqual([])
|
||||
expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the rejection when the journal settles the message before the send answers', async () => {
|
||||
// An older host leaves a rejected message where it was sent, which may be outside the loaded
|
||||
// window: the entry draws it, with no control, until the row that draws it loads.
|
||||
it('keeps a rejected message whose row is not loaded, then lets it go once the row loads', async () => {
|
||||
writeOutbox('session-1', [
|
||||
{ ...rejectedBeforeRestart(), state: 'dispatching', lastFailure: undefined, lastAttemptAt: 5 }
|
||||
])
|
||||
const rejected = [
|
||||
{
|
||||
...pendingResultFor('rejected-before-restart').value.submission,
|
||||
dispatchState: 'rejected' as const,
|
||||
reason: REASON,
|
||||
resolvedAt: 2
|
||||
}
|
||||
]
|
||||
const { result, rerender } = renderHook(
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: outboxProps(rejected) }
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(REASON)
|
||||
expect(readOutbox('session-1')).toHaveLength(1)
|
||||
|
||||
rerender({ submissions: rejected, rows: rowsFor('rejected-before-restart') })
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readOutbox('session-1')).toEqual([])
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a send its reply rejected without a Retry until the journal row takes it over', async () => {
|
||||
const writeFailed = (clientMessageId: string): AgentJournalSubmission => ({
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'provider_write_failed: broken pipe',
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10
|
||||
})
|
||||
mocks.call.mockImplementationOnce(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: params.envelope.clientOperationId,
|
||||
submission: writeFailed(params.envelope.clientOperationId)
|
||||
}
|
||||
})
|
||||
)
|
||||
const { result, rerender } = renderHook(
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: outboxProps(NO_SUBMISSIONS) }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const firstId = String(mocks.call.mock.calls[0]![2].envelope.clientOperationId)
|
||||
|
||||
// Answered, not doubted: the entry draws the message, saying why, until the journal has it.
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected'))
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
|
||||
rerender({ submissions: [writeFailed(firstId)], rows: rowsFor(firstId) })
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets the journal settle the message when its rejection lands before the send answers', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
let answer: (value: unknown) => void = () => undefined
|
||||
mocks.call.mockImplementationOnce(
|
||||
@@ -327,14 +485,15 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
(props: OutboxProps) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: props.rows ?? NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
{ initialProps: outboxProps(NO_SUBMISSIONS) }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
@@ -343,19 +502,51 @@ describe('a send the host rejected because the agent never started', () => {
|
||||
|
||||
// A start refused at once: the rejection frame lands before the send's own `pending` answer.
|
||||
rerender({
|
||||
submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }]
|
||||
submissions: [
|
||||
{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }
|
||||
],
|
||||
rows: rowsFor(id)
|
||||
})
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
// The late `pending` answer must not bring it back.
|
||||
await act(async () => answer(pendingResultFor(id)))
|
||||
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
expect(result.current.outbox).toEqual([])
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const NO_SUBMISSIONS: AgentJournalSubmission[] = []
|
||||
|
||||
/** The host's rows for these messages, loaded. */
|
||||
function rowsFor(...ids: string[]): AgentJournalRenderItem[] {
|
||||
return ids.map((id, index) => ({
|
||||
itemId: agentJournalSubmissionKey(id),
|
||||
revision: 1,
|
||||
sequence: index + 1,
|
||||
observedAt: index + 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }
|
||||
}))
|
||||
}
|
||||
|
||||
function rejectedBeforeRestart(): StructuredAgentSessionOutboxEntry {
|
||||
return {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'rejected-before-restart',
|
||||
sessionId: 'session-1',
|
||||
text: 'first',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'The provider did not accept this message.',
|
||||
rejection: { kind: 'providerRejected' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pendingResultFor(clientMessageId: string) {
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId,
|
||||
@@ -426,6 +617,7 @@ describe('a send refused while its agent restarted', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence, submissions }: { fence: number; submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
@@ -485,6 +677,7 @@ describe('a send the host refused by throwing', () => {
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
@@ -525,6 +718,7 @@ describe('a send refused on a journal a newer Orca wrote', () => {
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
|
||||
+7
@@ -28,6 +28,10 @@ import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/stru
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
|
||||
const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = []
|
||||
|
||||
const SESSION = 'session-1'
|
||||
// Stable, as the view passes it: a new object each render would re-run the owner-change requeue.
|
||||
const LOCAL_TARGET = { kind: 'local' } as const
|
||||
@@ -96,6 +100,7 @@ function mount(fence = 1) {
|
||||
return renderHook(
|
||||
({ fence: current }: { fence: number }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence: current,
|
||||
@@ -423,6 +428,7 @@ describe('a message whose send could not be saved before it went out', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence }: { fence: number | null }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
@@ -541,6 +547,7 @@ describe('a held message whose id expired', () => {
|
||||
mocks.call.mockResolvedValue(expired())
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
|
||||
+8
-1
@@ -5,7 +5,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,
|
||||
@@ -25,6 +28,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox } from './structured-agent-session-outbox-storage'
|
||||
|
||||
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
|
||||
|
||||
@@ -82,6 +87,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,
|
||||
@@ -147,6 +153,7 @@ describe('a Stop withdrawing what the host does not hold', () => {
|
||||
mocks.call.mockRejectedValue(new Error('the host refused the frame'))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// @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 costs no storage write per streamed delta. And a copy the host recorded and
|
||||
// rejected owes no delivery, so it keeps no hidden pane reading the journal.
|
||||
|
||||
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 {
|
||||
hasUndeliveredStructuredAgentSessionOutbox,
|
||||
writeOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const SESSION = 'session-churn'
|
||||
|
||||
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: { kind: '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()
|
||||
})
|
||||
|
||||
it('counts a copy the host recorded and rejected as owing no delivery', () => {
|
||||
writeOutbox(SESSION, [
|
||||
stored('recorded', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' }
|
||||
})
|
||||
])
|
||||
expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(false)
|
||||
|
||||
writeOutbox(SESSION, [
|
||||
stored('recorded', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' }
|
||||
}),
|
||||
stored('waiting', {})
|
||||
])
|
||||
expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(true)
|
||||
})
|
||||
+7
-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 {
|
||||
@@ -28,6 +31,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
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)
|
||||
|
||||
@@ -77,6 +82,7 @@ function renderOutbox() {
|
||||
return renderHook(
|
||||
(props: Props) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+9
@@ -31,6 +31,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox } from './structured-agent-session-outbox-storage'
|
||||
|
||||
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)
|
||||
|
||||
@@ -53,6 +57,7 @@ function queuedReceipt(clientMessageId: string) {
|
||||
function renderOutbox(queue: boolean) {
|
||||
return renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -154,6 +159,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,
|
||||
@@ -184,6 +190,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,
|
||||
@@ -252,6 +259,7 @@ describe('outbox queue delivery selection', () => {
|
||||
}))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -306,6 +314,7 @@ async function attemptedQueueSend() {
|
||||
const view = renderHook(
|
||||
(props: { capability: StructuredAgentSessionQueueCapability }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// @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 { 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[] = []
|
||||
|
||||
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: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
composerScopeKey: 'pane-1'
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('steer this way')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(readNativeChatDraftCache('pane-1')).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('pane-1')).toBe('')
|
||||
})
|
||||
+7
@@ -25,6 +25,10 @@ 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)
|
||||
|
||||
@@ -66,6 +70,7 @@ describe('a Stop with a queued send in doubt', () => {
|
||||
const view = renderHook(
|
||||
(props: { fence: number | null }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: REMOTE,
|
||||
fence: props.fence,
|
||||
@@ -110,6 +115,7 @@ describe('a Stop with a queued send in doubt', () => {
|
||||
mocks.call.mockImplementationOnce(() => answer.promise)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
@@ -170,6 +176,7 @@ describe('a Stop with a queued send in doubt', () => {
|
||||
])
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
|
||||
+42
-87
@@ -4,7 +4,10 @@ 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 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'
|
||||
|
||||
@@ -19,6 +22,16 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt'
|
||||
|
||||
const hostRow = (id: string): AgentJournalRenderItem => ({
|
||||
itemId: `orca:${id}`,
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -149,6 +162,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
@@ -190,6 +204,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -211,6 +226,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
@@ -245,6 +261,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -277,6 +294,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -316,6 +334,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -346,6 +365,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -375,6 +395,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -400,6 +421,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -431,6 +453,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
})
|
||||
const first = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -445,6 +468,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
|
||||
const restored = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -476,6 +500,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -515,6 +540,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
.mockResolvedValueOnce(acceptedResult(1))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -544,6 +570,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -581,6 +608,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
mocks.call.mockResolvedValue(acceptedResult(1))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -628,6 +656,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -667,12 +696,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
expect(retryParams?.retryUnknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rotates a history-rejected unknown head so the queued tail can advance', async () => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
.mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc')
|
||||
it('lets a history-rejected unknown head go to the journal so the queued tail advances', async () => {
|
||||
mocks.call
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
@@ -684,14 +708,11 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 11)
|
||||
})
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 12)
|
||||
})
|
||||
let rows = NO_JOURNAL_ITEMS
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: rows,
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -704,6 +725,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
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))
|
||||
rows = [hostRow(firstId)]
|
||||
rerender({
|
||||
submissions: [
|
||||
{
|
||||
@@ -719,83 +741,13 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
]
|
||||
})
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3))
|
||||
// The host's row shows the first as not sent; nothing resends it.
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
const retryParams = mocks.call.mock.calls[1]?.[2] as
|
||||
| { envelope: { clientOperationId: string } }
|
||||
| undefined
|
||||
expect(retryParams?.envelope.clientOperationId).not.toBe(firstId)
|
||||
})
|
||||
|
||||
it('rotates the id after a refused write and delivers the message exactly once', async () => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
const writeFailed = (clientMessageId: string) => ({
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected' as const,
|
||||
providerItemId: null,
|
||||
reason: 'provider_write_failed: broken pipe',
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10
|
||||
expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ body: { blocks: [{ text: 'second' }] } })
|
||||
expect(mocks.call.mock.calls[1]?.[2]).not.toMatchObject({
|
||||
envelope: { clientOperationId: firstId }
|
||||
})
|
||||
mocks.call
|
||||
.mockImplementationOnce(async (_target, _method, params) => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId,
|
||||
submission: writeFailed(
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
|
||||
)
|
||||
}
|
||||
}))
|
||||
.mockImplementationOnce(async (_target, _method, params) =>
|
||||
acceptedResultFor(
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId,
|
||||
11
|
||||
)
|
||||
)
|
||||
const { result } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { submissions: [] as readonly AgentJournalSubmission[] } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const firstId = mocks.call.mock.calls[0]![2].envelope.clientOperationId as string
|
||||
|
||||
// A refused write is answered, not doubted: the entry parks with its rejection rather than
|
||||
// under the "delivery is unconfirmed" banner. The disposition tests pin its words.
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected'))
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
|
||||
// Retry immediately, before the journal subscription can publish the rejected row.
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
|
||||
// Exactly one further delivery, under a new id, and with no `retryUnknown`:
|
||||
// this is a first delivery of a new message, so it cannot duplicate.
|
||||
expect(mocks.call).toHaveBeenCalledTimes(2)
|
||||
const retryParams = mocks.call.mock.calls[1]?.[2] as {
|
||||
envelope: { clientOperationId: string }
|
||||
retryUnknown?: true
|
||||
}
|
||||
expect(retryParams.envelope.clientOperationId).not.toBe(firstId)
|
||||
expect(retryParams.retryUnknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads the new session outbox when a pane switches sessions', async () => {
|
||||
@@ -808,6 +760,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ sessionId }: { sessionId: string }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId,
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -840,6 +793,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ sessionId }: { sessionId: string }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId,
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
@@ -869,6 +823,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
} = { current: null }
|
||||
function Probe({ sessionId }: { sessionId: string }): null {
|
||||
controllerRef.current = useStructuredAgentSessionOutbox({
|
||||
journalItems: NO_JOURNAL_ITEMS,
|
||||
sessionId,
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
admitStructuredAgentSessionOutboxEntry,
|
||||
@@ -63,6 +66,8 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
target: RuntimeClientTarget
|
||||
fence: number | null
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
/** The loaded journal rows: a rejected message stays here until the row that draws it loads. */
|
||||
journalItems: readonly AgentJournalRenderItem[]
|
||||
/** The composer that gets back what a Stop withdrew from this client's outbox. */
|
||||
composerScopeKey?: string
|
||||
/** The host's queued-messages capability and the user's setting; a send stamped
|
||||
@@ -76,6 +81,7 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
const {
|
||||
composerScopeKey,
|
||||
fence,
|
||||
journalItems,
|
||||
queueDelivery = NO_QUEUE_DELIVERY,
|
||||
queuedMessageIds,
|
||||
sessionId,
|
||||
@@ -147,15 +153,13 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
.map((submission) => submission.clientMessageId),
|
||||
...handedOffQueuedMessageIds(submissions)
|
||||
])
|
||||
const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions)
|
||||
const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions, journalItems)
|
||||
const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current)
|
||||
if (
|
||||
admittedInFlight ||
|
||||
next.some((entry, index) => entry !== current[index]) ||
|
||||
next.length !== current.length
|
||||
) {
|
||||
// The reconcile returns `current` itself when no entry changed, so a batch that changes
|
||||
// nothing writes nothing.
|
||||
if (admittedInFlight || next !== current) {
|
||||
restoreWithdrawn.byHost(current, submissions)
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
commitStructuredAgentSessionOutbox(sessionId, [...next])
|
||||
}
|
||||
// Keyed on the entry actually in flight, which is no longer always the head: the journal
|
||||
// owning it outranks a send promise that has not settled, so release single-flight and make
|
||||
@@ -174,7 +178,7 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
) {
|
||||
setError(null)
|
||||
}
|
||||
}, [restoreWithdrawn, sessionId, submissions])
|
||||
}, [journalItems, restoreWithdrawn, sessionId, submissions])
|
||||
|
||||
// The one place that owns the refs, the React state and the storage write.
|
||||
const applyDisposition = useCallback(
|
||||
|
||||
+116
@@ -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 }])
|
||||
})
|
||||
@@ -120,6 +120,7 @@ export function useStructuredAgentSession(args: {
|
||||
target,
|
||||
fence: transportState.fence,
|
||||
submissions: transportState.submissions,
|
||||
journalItems: transportState.journalItems,
|
||||
composerScopeKey,
|
||||
queueDelivery: { capability: queueCapability, enabled: queueFollowUps },
|
||||
queuedMessageIds
|
||||
@@ -166,7 +167,8 @@ export function useStructuredAgentSession(args: {
|
||||
const messages = useStructuredAgentSessionMessages(
|
||||
transportState.journalItems,
|
||||
transcriptOutbox,
|
||||
transportState.submissions
|
||||
transportState.submissions,
|
||||
queuedMessageIds
|
||||
)
|
||||
const queuedController = useStructuredAgentSessionQueuedMessages({
|
||||
enabled: queueCapable && transportState.fence !== null,
|
||||
@@ -216,6 +218,8 @@ export function useStructuredAgentSession(args: {
|
||||
failedHere: outboxController.failedHere,
|
||||
/** 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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user