diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index ff767b94ac9..52d498e4aa5 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -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) +}) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index 980fe42f48f..5fd6c2c617d 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -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) { 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 } } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 0bdbfeaf9d7..bd8e3974651 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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 = (sessionId) => - this.sessions.get(sessionId)?.commands.commands + readCommands: NonNullable = (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) => diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts new file mode 100644 index 00000000000..4ebd57c1a6d --- /dev/null +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -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') + }) +}) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index bd7bb9d9661..92cbfd43e42 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -57,6 +57,7 @@ export type ClaudeStructuredSessionAdapterDeps = { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + onCommandsChanged?: (sessionId: string) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 903cafae416..6c0fea9b4c9 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 4872426d009..f678dca38ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 60ef98c1900..3191a4039ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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 => 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 { this.requireSession(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 81bcfa82b40..1131466ced5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -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: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 29dffa6a687..7e7d66b929c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -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) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d670c16f47d..2d3faa0be4a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -258,6 +258,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishCommandsChanged(sessionId), onBackgroundTasksChanged: (sessionId, state) => host?.publishBackgroundTaskState(sessionId, state), ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 95151f1dae9..4ef32220372 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index cd8644f0491..be1858209b8 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 6a10978a6c6..3535a90c04b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -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) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 9a9af79e536..9035770919a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -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 diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 7a3f8083332..5f78061efb6 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -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) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx new file mode 100644 index 00000000000..4f4c58ffdcf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -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 + } + } + ) + expect(result.current).toEqual({ agentCommands: [], sessionSkillNames: [] }) + rerender({ reported: [{ name: 'custom-command', kind: 'command' }] }) + expect(result.current).toEqual({ + agentCommands: [{ name: 'custom-command' }], + sessionSkillNames: [] + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index 2e70e542aa3..e9a7b84e09a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -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 } diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 1e735900259..2072872a81a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -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 setDraft: (value: string) => void @@ -62,7 +60,7 @@ export function useNativeChatPickerState(args: { draft, caret, agentCommands, - sessionSkillNames = EMPTY_SESSION_SKILL_NAMES, + sessionSkillNames, textareaRef, setDraft, setCaret, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 9a42ccb6da6..1d43b4bd1f5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -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) { + 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() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 0b3e7051f71..73b102e237b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -173,7 +173,15 @@ export function useStructuredAgentSession(args: { } }, [isVisible, optionCatalog, sessionId, state.fence, target, turnId]) - const [sessionCommands, setSessionCommands] = useState([]) + 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), diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index f52bc24b850..d30758785a8 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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 diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index d36b6717758..8c903748918 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -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) +}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 88d41b2f8e5..f364a5d6cc1 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -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),