fix: preserve session catalog authority and publish live updates

This commit is contained in:
Merge Sim
2026-09-06 16:13:21 -07:00
parent 417692396c
commit 7911ff4cec
24 changed files with 373 additions and 48 deletions
@@ -32,9 +32,11 @@ describe('claude slash command catalog', () => {
it('seeds from the init frame that proved the session', () => {
expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(3)
expect(new ClaudeSlashCommandCatalog().commands).toEqual([])
expect(new ClaudeSlashCommandCatalog().commands).toBeUndefined()
// A frame of the right subtype but without the array is not a catalog.
expect(new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands).toEqual([])
expect(
new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands
).toBeUndefined()
})
it('replaces the catalog on commands_changed and reports only real changes', () => {
@@ -70,3 +72,13 @@ describe('claude slash command catalog', () => {
expect(catalog.commands).toEqual([{ name: 'review', kind: 'skill' }])
})
})
it('distinguishes missing catalogs from an authoritative empty update', () => {
const catalog = new ClaudeSlashCommandCatalog()
expect(catalog.commands).toBeUndefined()
expect(catalog.observe(init({ slash_commands: [] }))).toBe(true)
expect(catalog.commands).toEqual([])
expect(catalog.revision).toBe(1)
expect(catalog.observe(init({ slash_commands: [] }))).toBe(false)
expect(catalog.revision).toBe(1)
})
@@ -46,14 +46,17 @@ export function readClaudeSlashCommands(
/** Per-session `/` catalog, seeded from the init frame that proved the session
* and refreshed by every later init or `commands_changed` frame. */
export class ClaudeSlashCommandCatalog {
private entries: AgentSessionSlashCommand[]
private entries: AgentSessionSlashCommand[] | undefined
revision = 0
constructor(initMessage?: Record<string, unknown>) {
this.entries =
initMessage && carriesCommandCatalog(initMessage) ? readClaudeSlashCommands(initMessage) : []
initMessage && carriesCommandCatalog(initMessage)
? readClaudeSlashCommands(initMessage)
: undefined
}
get commands(): AgentSessionSlashCommand[] {
get commands(): AgentSessionSlashCommand[] | undefined {
return this.entries
}
@@ -64,15 +67,17 @@ export class ClaudeSlashCommandCatalog {
}
const next = readClaudeSlashCommands(message)
if (
this.entries !== undefined &&
next.length === this.entries.length &&
next.every(
(entry, index) =>
entry.name === this.entries[index]?.name && entry.kind === this.entries[index]?.kind
entry.name === this.entries?.[index]?.name && entry.kind === this.entries?.[index]?.kind
)
) {
return false
}
this.entries = next
this.revision += 1
return true
}
}
@@ -195,8 +195,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
: event.type === 'message'
? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false)
: false
if (event.type === 'message') {
session?.commands.observe(event.message)
if (event.type === 'message' && session?.commands.observe(event.message)) {
this.deps.onCommandsChanged?.(event.sessionId)
}
session?.translator?.handle(event)
this.deps.onEvent?.(event)
@@ -264,8 +264,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
const session = this.sessions.get(sessionId)
return session ? backgroundTaskState(session) : undefined
}
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (sessionId) =>
this.sessions.get(sessionId)?.commands.commands
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (sessionId) => {
const catalog = this.sessions.get(sessionId)?.commands
return catalog ? { commands: catalog.commands, revision: catalog.revision } : undefined
}
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
answerClaudePrompt(this.session(input.sessionId), input)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import {
adapterFor,
fakeClaude,
identityFor,
tick,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
describe('session command updates', () => {
it('publishes changed catalogs exactly once while idle', async () => {
const claude = fakeClaude()
const changed = vi.fn()
const adapter = adapterFor(
claude,
{},
[],
[],
undefined,
undefined,
undefined,
undefined,
changed
)
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
expect(adapter.readCommands('session-1')?.commands).toBeUndefined()
const frame = {
type: 'system',
subtype: 'commands_changed',
session_id: PROVIDER_SESSION_ID,
slash_commands: ['plugin:check', 'doctor'],
skills: ['plugin:check'],
terminal_slash_commands: ['doctor']
}
claude.connections[0].handlers.onMessage?.(frame)
await tick()
expect(adapter.readCommands('session-1')).toEqual({
commands: [{ name: 'plugin:check', kind: 'skill' }],
revision: 1
})
expect(changed).toHaveBeenCalledExactlyOnceWith('session-1')
claude.connections[0].handlers.onMessage?.(frame)
await tick()
expect(changed).toHaveBeenCalledTimes(1)
claude.connections[0].handlers.onMessage?.({ ...frame, slash_commands: [] })
await tick()
expect(adapter.readCommands('session-1')).toEqual({ commands: [], revision: 2 })
expect(changed).toHaveBeenCalledTimes(2)
await adapter.closeSession('session-1')
})
})
@@ -57,6 +57,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
identity: AgentSessionJournalIdentity
}) => Promise<ClaudeStructuredLaunch>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
onCommandsChanged?: (sessionId: string) => void
onBackgroundTasksChanged?: (
sessionId: string,
state: AgentSessionBackgroundTaskState | null
@@ -196,7 +196,8 @@ export function adapterFor(
initTimeoutMs?: number,
readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'],
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'],
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged']
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'],
onCommandsChanged?: ClaudeStructuredSessionAdapterDeps['onCommandsChanged']
): ClaudeStructuredSessionAdapter {
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
@@ -210,6 +211,7 @@ export function adapterFor(
...launch
}),
onEvent: (event) => events.push(event),
onCommandsChanged,
openConnection: claude.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => 1_700_000_000_500,
@@ -19,7 +19,7 @@ import type {
import type {
AgentSessionBackgroundTaskState,
AgentSessionOptionsResult,
AgentSessionSlashCommand,
AgentSessionCommandsResult,
AgentSessionWireRefusalCode
} from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
@@ -146,7 +146,7 @@ export type StructuredAgentSessionAdapter = {
backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined
/** The `/` surface the running provider reports for itself. Undefined when the
* provider never reports one, which is what keeps the client on its catalog. */
readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined
readCommands?(sessionId: string): AgentSessionCommandsResult | undefined
/** Fires the provider callback for an approval or a question. The wire calls
* this only after the durable compare-and-set won, so it runs exactly once. */
answerPrompt(input: {
@@ -63,7 +63,8 @@ export class StructuredAgentSessionHost {
now: () => this.now()
})
private readonly subscribers = new AgentSessionSubscribers({
onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal)
onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal),
commandsRevision: (sessionId) => this.deps.adapter.readCommands?.(sessionId)?.revision
})
private readonly tasks = new StructuredAgentSessionTaskQueue()
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
@@ -317,11 +318,9 @@ export class StructuredAgentSessionHost {
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
/** Empty when the provider reports no catalog, which the client reads as
* "keep the curated list" rather than "this session has no commands". */
readCommands = (sessionId: string): SessionWire.AgentSessionCommandsResult => ({
commands: this.deps.adapter.readCommands?.(sessionId) ?? []
})
readCommands = (sessionId: string) => this.deps.adapter.readCommands?.(sessionId) ?? {}
publishCommandsChanged = (sessionId: string): void => this.subscribers.commandsChanged(sessionId)
async handoffStatus(sessionId: string): Promise<SessionWire.AgentSessionHandoffStatus> {
this.requireSession(sessionId)
@@ -72,6 +72,48 @@ describe('AgentSessionSubscribers', () => {
])
})
it('includes catalog revisions on reconnect and sends an idle checkpoint without journal work', async () => {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, 'catalog-journal')
})
let revision = 1
const events: AgentSessionSubscribeEvent[] = []
const subscribers = new AgentSessionSubscribers({ commandsRevision: () => revision })
subscribers.open({
id: 'one',
sessionId: SESSION,
journal,
fence: 7,
emit: (event) => events.push(event)
})
expect(events[0]).toMatchObject({ type: 'snapshot', commandsRevision: 1 })
revision = 2
subscribers.commandsChanged(SESSION)
expect(events[1]).toEqual({
type: 'batch',
sessionId: SESSION,
fence: 7,
commandsRevision: 2,
batch: { cursor: journal.cursor(), items: [], removedItemIds: [], submissions: [] }
})
subscribers.open({
id: 'two',
sessionId: SESSION,
journal,
cursor: journal.cursor(),
fence: 7,
emit: (event) => events.push(event)
})
expect(events[2]).toMatchObject({ type: 'batch', commandsRevision: 2 })
})
it('reports every content publication to the journal hook, subscribed or not', async () => {
const journal = await journals.open({
identity: {
@@ -37,6 +37,7 @@ type Subscriber = {
}
export type AgentSessionSubscribersHooks = {
commandsRevision?: (sessionId: string) => number | undefined
/** Fires after any publication that can change journal content, whether or not anyone
* is subscribed to the transcript: session lists project status from this same edge. */
onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
@@ -196,6 +197,17 @@ export class AgentSessionSubscribers {
}
}
commandsChanged(sessionId: string): void {
for (const subscriber of this.subscribers(sessionId)) {
this.emit(subscriber, {
type: 'batch',
sessionId,
batch: { cursor: subscriber.cursor, items: [], removedItemIds: [], submissions: [] },
fence: subscriber.fence
})
}
}
private subscribers(sessionId: string): Subscriber[] {
return [...(this.bySession.get(sessionId)?.values() ?? [])]
}
@@ -276,7 +288,12 @@ export class AgentSessionSubscribers {
* unknown outcome or poison every later publication. */
private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void {
try {
subscriber.emit(event)
const commandsRevision = this.hooks.commandsRevision?.(subscriber.sessionId)
subscriber.emit(
event.type !== 'end' && commandsRevision !== undefined
? { ...event, commandsRevision }
: event
)
} catch {
this.drop(subscriber)
}
@@ -258,6 +258,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
}
})
},
onCommandsChanged: (sessionId) => host?.publishCommandsChanged(sessionId),
onBackgroundTasksChanged: (sessionId, state) =>
host?.publishBackgroundTaskState(sessionId, state),
...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}),
@@ -30,6 +30,7 @@ export type StructuredClaudeRuntimeAdapterDeps = {
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime']
onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void
onCommandsChanged?: (sessionId: string) => void
onBackgroundTasksChanged?: (
sessionId: string,
state: AgentSessionBackgroundTaskState | null
@@ -97,6 +98,7 @@ export function createStructuredClaudeRuntimeAdapter(
})
}
},
onCommandsChanged: deps.onCommandsChanged,
...(deps.onBackgroundTasksChanged
? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged }
: {}),
@@ -259,7 +259,7 @@ describe('native skill and command picker', () => {
expect(items[1]).toMatchObject({ kind: 'skill', description: null, sources: [] })
})
it('keeps the disk scan when the session reports nothing', () => {
it('does not revive disk skills after an authoritative empty report', () => {
const items = buildNativeChatPickerItems(
[],
[skill({ name: 'ref-oss', skillFilePath: '/home/ref-oss/SKILL.md' })],
@@ -267,7 +267,7 @@ describe('native skill and command picker', () => {
'/',
[]
)
expect(items.map((item) => item.name)).toEqual(['ref-oss'])
expect(items).toEqual([])
})
it('rejects a session-reported name that is not a safe insertion token', () => {
@@ -52,7 +52,7 @@ export function deriveComposerAutocomplete(
profile: NativeChatAgentProfile | null = null,
discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills },
dismissedTriggerKey: string | null = null,
sessionSkillNames: readonly string[] = []
sessionSkillNames?: readonly string[]
): ComposerAutocomplete {
const before = draft.slice(0, caret)
if (before.startsWith('/') && !/\s/.test(before)) {
@@ -101,7 +101,7 @@ function deriveSlashAutocomplete(
profile: NativeChatAgentProfile | null,
discovery: NativeChatSkillDiscoverySnapshot,
dismissedTriggerKey: string | null,
sessionSkillNames: readonly string[]
sessionSkillNames: readonly string[] | undefined
): ComposerAutocomplete {
const triggerKey = '/:0'
if (dismissedTriggerKey === triggerKey) {
@@ -19,8 +19,7 @@ export type NativeChatStructuredComposerTransport = {
optionsSurface: SessionOptionsSurface
optionSnapshot: SessionOptionDescriptor[]
optionPickerRequest?: NativeChatOptionPickerRequest | null
/** The `/` surface the running session reports. Empty keeps the curated
* per-agent catalog, which is what an older host leaves the client with. */
/** Absence keeps the curated catalog; an empty report is authoritative. */
sessionCommands?: readonly AgentSessionSlashCommand[]
worktreeId?: string
onError: (message: string | null) => void
@@ -106,9 +106,8 @@ function mergeNativeChatSkills(
// actually loaded (plugin roots, setting-source filters), and a scanned root
// the session ignored must not be offered. The scan stays the source of
// description and scope for the names both know about.
const names = sessionSkillNames?.length
? sessionSkillNames.filter(isTokenSafe)
: [...discovered.keys()]
const names =
sessionSkillNames !== undefined ? sessionSkillNames.filter(isTokenSafe) : [...discovered.keys()]
return [...new Set(names)]
.map((name) => discovered.get(name) ?? pickerSkill(name, []))
.sort(comparePickerSkills)
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
function transport(sessionCommands?: NativeChatStructuredComposerTransport['sessionCommands']) {
return { sessionCommands } as NativeChatStructuredComposerTransport
}
describe('composer catalog authority', () => {
it('keeps PTY and unsupported structured providers on their original catalogs', () => {
const pty = renderHook(() => useNativeChatComposerCatalog('claude'))
expect(pty.result.current.agentCommands).toEqual(getVerifiedNativeChatCommands('claude'))
expect(pty.result.current.sessionSkillNames).toBeUndefined()
const oldHost = renderHook(() => useNativeChatComposerCatalog('claude', transport()))
expect(oldHost.result.current.agentCommands).toEqual(structuredSlashCommands('claude'))
expect(oldHost.result.current.sessionSkillNames).toBeUndefined()
})
it('respects empty catalogs and command-only catalogs without reviving disk skills', () => {
const { result, rerender } = renderHook(
({ reported }) => useNativeChatComposerCatalog('claude', transport(reported)),
{
initialProps: {
reported: [] as NonNullable<NativeChatStructuredComposerTransport['sessionCommands']>
}
}
)
expect(result.current).toEqual({ agentCommands: [], sessionSkillNames: [] })
rerender({ reported: [{ name: 'custom-command', kind: 'command' }] })
expect(result.current).toEqual({
agentCommands: [{ name: 'custom-command' }],
sessionSkillNames: []
})
})
})
@@ -9,11 +9,9 @@ import {
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
const EMPTY_SKILL_NAMES: readonly string[] = []
export type NativeChatComposerCatalog = {
agentCommands: readonly SlashCommandSuggestion[]
sessionSkillNames: readonly string[]
sessionSkillNames: readonly string[] | undefined
}
/**
@@ -27,18 +25,19 @@ export function useNativeChatComposerCatalog(
agent: AgentType,
structuredTransport?: NativeChatStructuredComposerTransport
): NativeChatComposerCatalog {
const structured = Boolean(structuredTransport)
const reported = structuredTransport?.sessionCommands
const agentCommands = useMemo(
() =>
!structuredTransport
!structured
? getVerifiedNativeChatCommands(agent)
: reported?.length
: reported !== undefined
? sessionSlashCommandSuggestions(agent, reported)
: structuredSlashCommands(agent),
[agent, reported, structuredTransport]
[agent, reported, structured]
)
const sessionSkillNames = useMemo(
() => (reported?.length ? sessionReportedSkillNames(reported) : EMPTY_SKILL_NAMES),
() => (reported !== undefined ? sessionReportedSkillNames(reported) : undefined),
[reported]
)
return { agentCommands, sessionSkillNames }
@@ -28,8 +28,6 @@ import {
emitNativeChatSendClassified
} from '@/lib/native-chat-telemetry'
const EMPTY_SESSION_SKILL_NAMES: readonly string[] = []
export type NativeChatPickerState = {
autocomplete: ComposerAutocomplete
listboxId: string
@@ -48,7 +46,7 @@ export function useNativeChatPickerState(args: {
draft: string
caret: number
agentCommands: readonly SlashCommandSuggestion[]
/** Skill names the running session reports; empty keeps the host disk scan. */
/** Skill names the running session reports; absence keeps the host disk scan. */
sessionSkillNames?: readonly string[]
textareaRef: RefObject<HTMLTextAreaElement | null>
setDraft: (value: string) => void
@@ -62,7 +60,7 @@ export function useNativeChatPickerState(args: {
draft,
caret,
agentCommands,
sessionSkillNames = EMPTY_SESSION_SKILL_NAMES,
sessionSkillNames,
textareaRef,
setDraft,
setCaret,
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn(), operationId: vi.fn() }))
let fence = 3
let commandsRevision: number | undefined
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
@@ -14,6 +15,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
useStructuredAgentSessionRead: () => ({
state: {
fence,
commandsRevision,
items: [],
submissions: [],
status: 'ready',
@@ -333,3 +335,110 @@ describe('useStructuredAgentSession options', () => {
})
})
})
describe('session command catalog reads', () => {
beforeEach(() => {
vi.clearAllMocks()
fence = 3
commandsRevision = 0
})
const args = { sessionId: 'one', target: LOCAL_TARGET, agent: 'claude' as const, isVisible: true }
const commands = [{ name: 'plugin:review', kind: 'skill' as const }]
function respond(read: (params: { sessionId: string }) => Promise<unknown>) {
mocks.call.mockImplementation((_target, method, params) =>
method === 'agentSession.commands'
? read(params)
: Promise.resolve(method === 'agentSession.options' ? OPTIONS : null)
)
}
it('clears previous session data before a new read settles or fails', async () => {
let reject!: (error: Error) => void
respond(({ sessionId }) =>
sessionId === 'one'
? Promise.resolve({ commands })
: new Promise((_resolve, rejectPromise) => {
reject = rejectPromise
})
)
const { result, rerender } = renderHook((props) => useStructuredAgentSession(props), {
initialProps: args
})
await waitFor(() => expect(result.current.sessionCommands).toEqual(commands))
rerender({ ...args, sessionId: 'two' })
expect(result.current.sessionCommands).toBeUndefined()
await act(async () => reject(new Error('method_not_found')))
expect(result.current.sessionCommands).toBeUndefined()
})
it('does not reuse a catalog for the same session id on another paired runtime', async () => {
const first = { kind: 'environment' as const, environmentId: 'first' }
const second = { kind: 'environment' as const, environmentId: 'second' }
mocks.call.mockImplementation((target, method) => {
if (method === 'agentSession.commands') {
return target === first ? Promise.resolve({ commands }) : new Promise(() => {})
}
return Promise.resolve(method === 'agentSession.options' ? OPTIONS : null)
})
const { result, rerender } = renderHook(
(target) => useStructuredAgentSession({ ...args, target }),
{ initialProps: first }
)
await waitFor(() => expect(result.current.sessionCommands).toEqual(commands))
rerender(second)
expect(result.current.sessionCommands).toBeUndefined()
})
it('drops responses from a superseded session and retains authoritative empty responses', async () => {
let resolve!: (value: unknown) => void
respond(({ sessionId }) =>
sessionId === 'one'
? new Promise((resolvePromise) => {
resolve = resolvePromise
})
: Promise.resolve({ commands: [] })
)
const { result, rerender } = renderHook((props) => useStructuredAgentSession(props), {
initialProps: args
})
rerender({ ...args, sessionId: 'two' })
await waitFor(() => expect(result.current.sessionCommands).toEqual([]))
await act(async () => resolve({ commands }))
expect(result.current.sessionCommands).toEqual([])
})
it('refreshes an idle catalog only on revision changes, not transcript renders', async () => {
let current = commands
respond(async () => ({ commands: current }))
const { result, rerender } = renderHook(() => useStructuredAgentSession(args))
await waitFor(() => expect(result.current.sessionCommands).toEqual(commands))
const reads = () =>
mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.commands').length
for (let index = 0; index < 30; index += 1) {
rerender()
}
expect(reads()).toBe(1)
current = []
commandsRevision = 1
rerender()
await waitFor(() => expect(result.current.sessionCommands).toEqual([]))
expect(reads()).toBe(2)
})
it('falls back after a current catalog read failure and fences reacquisition', async () => {
respond(async () => ({ commands }))
const { result, rerender } = renderHook(() => useStructuredAgentSession(args))
await waitFor(() => expect(result.current.sessionCommands).toEqual(commands))
respond(async () => {
throw new Error('unreachable')
})
commandsRevision = 1
rerender()
await waitFor(() => expect(result.current.sessionCommands).toBeUndefined())
respond(() => new Promise(() => {}))
fence = 4
rerender()
expect(result.current.sessionCommands).toBeUndefined()
})
})
@@ -173,7 +173,15 @@ export function useStructuredAgentSession(args: {
}
}, [isVisible, optionCatalog, sessionId, state.fence, target, turnId])
const [sessionCommands, setSessionCommands] = useState<readonly AgentSessionSlashCommand[]>([])
const catalogIdentity = useMemo(
() => ({ sessionId, target, fence: state.fence }),
[sessionId, target, state.fence]
)
const [catalog, setCatalog] = useState<{
identity: typeof catalogIdentity
commands: readonly AgentSessionSlashCommand[] | undefined
}>()
const sessionCommands = catalog?.identity === catalogIdentity ? catalog.commands : undefined
useEffect(() => {
if (!isVisible) {
return
@@ -184,16 +192,18 @@ export function useStructuredAgentSession(args: {
})
.then((result) => {
if (!stale) {
setSessionCommands(result.commands ?? [])
setCatalog({ identity: catalogIdentity, commands: result.commands })
}
})
.catch(() => {
if (!stale) {
setCatalog({ identity: catalogIdentity, commands: undefined })
}
})
// Why: a host that predates this method answers method_not_found, which is
// the same as "no catalog" — the composer keeps its curated list.
.catch(() => {})
return () => {
stale = true
}
}, [isVisible, sessionId, state.fence, target, turnId])
}, [isVisible, catalogIdentity, sessionId, state.commandsRevision, target, turnId])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
+5 -1
View File
@@ -145,6 +145,7 @@ export type AgentSessionSubscribeEvent =
fence: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
commandsRevision?: number
}
| {
type: 'batch'
@@ -154,6 +155,7 @@ export type AgentSessionSubscribeEvent =
fence?: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
commandsRevision?: number
}
| {
type: 'reset'
@@ -163,6 +165,7 @@ export type AgentSessionSubscribeEvent =
fence: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
commandsRevision?: number
}
| { type: 'end' }
@@ -317,7 +320,8 @@ export type AgentSessionSlashCommand = {
* surface: a host that predates it answers `method_not_found`, and the client
* keeps rendering its curated catalog. */
export type AgentSessionCommandsResult = {
commands: AgentSessionSlashCommand[]
commands?: AgentSessionSlashCommand[]
revision?: number
}
/** Provider-reported choices and effective next-turn values. Additive read-only
@@ -409,3 +409,31 @@ describe('structured agent session reducer', () => {
expect(withoutCapability.backgroundTasks).toBeUndefined()
})
})
it('applies catalog-only checkpoints without replacing transcript or submission state', () => {
const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('one', 1)], [submission(1)]),
commandsRevision: 0
}
})
const event = {
type: 'batch' as const,
sessionId: 'session-a',
fence: 1,
commandsRevision: 1,
batch: { cursor: state.cursor!, items: [], removedItemIds: [], submissions: [] }
}
const updated = reduceStructuredAgentSession(state, { type: 'event', event })
expect(updated.commandsRevision).toBe(1)
expect(updated.items).toBe(state.items)
expect(updated.submissions).toBe(state.submissions)
expect(updated.cursor).toBe(state.cursor)
expect(reduceStructuredAgentSession(updated, { type: 'event', event })).toBe(updated)
const { commandsRevision: _revision, ...oldEvent } = event
expect(reduceStructuredAgentSession(updated, { type: 'event', event: oldEvent })).toBe(updated)
})
@@ -13,6 +13,7 @@ import type {
export type StructuredAgentSessionState = {
epoch: string | null
cursor: AgentJournalCursor | null
commandsRevision?: number
fence: number | null
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
@@ -175,6 +176,7 @@ export function reduceStructuredAgentSession(
epoch: action.page.epoch,
cursor: action.page.liveCursor ?? null,
fence: action.page.fence ?? null,
commandsRevision: sameEpoch ? state.commandsRevision : undefined,
items: action.page.items,
submissions: sameEpoch
? mergeSubmissions(state.submissions, action.page.submissions)
@@ -205,7 +207,10 @@ export function reduceStructuredAgentSession(
return state
}
if (event.type === 'snapshot' || event.type === 'reset') {
return replacePage(event.page, event.fence, event.handoff, event.backgroundTasks)
return {
...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks),
commandsRevision: event.commandsRevision
}
}
if (state.epoch !== event.batch.cursor.epoch) {
return state
@@ -224,6 +229,7 @@ export function reduceStructuredAgentSession(
journalUnchanged &&
(event.fence === undefined || event.fence === state.fence) &&
(event.handoff === undefined || event.handoff === state.handoff) &&
(event.commandsRevision === undefined || event.commandsRevision === state.commandsRevision) &&
backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) &&
state.status === 'ready' &&
state.error === undefined
@@ -234,6 +240,7 @@ export function reduceStructuredAgentSession(
...state,
cursor: event.batch.cursor,
fence: event.fence ?? state.fence,
commandsRevision: event.commandsRevision ?? state.commandsRevision,
items: journalUnchanged
? state.items
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds),