mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix: preserve session catalog authority and publish live updates
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user