mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix(native-chat): list the slash commands and skills a structured Claude session actually loaded
The chat composer's `/` menu was built from a curated five-command catalog plus a host disk scan of skill roots. Neither is what the running session can do: the session reports its own `/` surface, which carries this repo's `.claude/commands`, the skills that only reach it through plugin roots, and a hide-list of commands that mean nothing outside a terminal UI. On one local session the menu offered 6 commands and 17 skills where the session reported 62 commands and 33 skills. Read that surface per session and let it drive the picker: - A per-session catalog seeded from the frame that proves the session and kept current by every later report, exposed over a new `agentSession.commands` read. - The report is the authority on WHICH skills exist; the disk scan stays the source of scope and description for the names both know about, so a skill the session never loaded is no longer offered and one it loaded from a root the scan cannot see now is. - A host that predates the read answers `method_not_found` and the composer keeps its curated catalog, so mixed versions and the PTY lane are unchanged.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ClaudeSlashCommandCatalog, readClaudeSlashCommands } from './claude-slash-command-catalog'
|
||||
|
||||
function init(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: 'provider-1',
|
||||
slash_commands: ['clear', 'ref-oss', 'doctor', 'opsx:apply'],
|
||||
terminal_slash_commands: ['doctor'],
|
||||
skills: ['ref-oss', 'doctor'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('claude slash command catalog', () => {
|
||||
it('tags reported skills and drops the commands reserved for a terminal UI', () => {
|
||||
expect(readClaudeSlashCommands(init())).toEqual([
|
||||
{ name: 'clear', kind: 'command' },
|
||||
{ name: 'ref-oss', kind: 'skill' },
|
||||
{ name: 'opsx:apply', kind: 'command' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects blank, whitespace-carrying and duplicate names', () => {
|
||||
expect(
|
||||
readClaudeSlashCommands(
|
||||
init({ slash_commands: ['clear', ' ', 'two words', 'clear'], skills: [] })
|
||||
)
|
||||
).toEqual([{ name: 'clear', kind: 'command' }])
|
||||
})
|
||||
|
||||
it('seeds from the init frame that proved the session', () => {
|
||||
expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(3)
|
||||
expect(new ClaudeSlashCommandCatalog().commands).toEqual([])
|
||||
// A frame of the right subtype but without the array is not a catalog.
|
||||
expect(new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands).toEqual([])
|
||||
})
|
||||
|
||||
it('replaces the catalog on commands_changed and reports only real changes', () => {
|
||||
const catalog = new ClaudeSlashCommandCatalog(init())
|
||||
expect(catalog.observe(init())).toBe(false)
|
||||
expect(catalog.observe({ type: 'assistant', slash_commands: ['other'] })).toBe(false)
|
||||
expect(
|
||||
catalog.observe({
|
||||
type: 'system',
|
||||
subtype: 'commands_changed',
|
||||
slash_commands: ['clear', 'brand-new'],
|
||||
skills: ['brand-new']
|
||||
})
|
||||
).toBe(true)
|
||||
expect(catalog.commands).toEqual([
|
||||
{ name: 'clear', kind: 'command' },
|
||||
{ name: 'brand-new', kind: 'skill' }
|
||||
])
|
||||
})
|
||||
|
||||
it('notices a name that only changed kind', () => {
|
||||
const catalog = new ClaudeSlashCommandCatalog(
|
||||
init({ slash_commands: ['review'], skills: [], terminal_slash_commands: [] })
|
||||
)
|
||||
expect(
|
||||
catalog.observe({
|
||||
type: 'system',
|
||||
subtype: 'commands_changed',
|
||||
slash_commands: ['review'],
|
||||
skills: ['review']
|
||||
})
|
||||
).toBe(true)
|
||||
expect(catalog.commands).toEqual([{ name: 'review', kind: 'skill' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire'
|
||||
|
||||
// The CLI reports its whole `/` surface on `system/init` and republishes it on
|
||||
// `system/commands_changed`; both frames carry the same three arrays.
|
||||
const MAX_COMMANDS = 512
|
||||
const MAX_NAME_LENGTH = 200
|
||||
|
||||
function names(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const entry of value) {
|
||||
if (seen.size >= MAX_COMMANDS) {
|
||||
break
|
||||
}
|
||||
const name = typeof entry === 'string' ? entry.trim() : ''
|
||||
if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) {
|
||||
seen.add(name)
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
function carriesCommandCatalog(message: Record<string, unknown>): boolean {
|
||||
return (
|
||||
message.type === 'system' &&
|
||||
(message.subtype === 'init' || message.subtype === 'commands_changed') &&
|
||||
Array.isArray(message.slash_commands)
|
||||
)
|
||||
}
|
||||
|
||||
/** What the session reports it can run, minus what it reserves for a terminal UI. */
|
||||
export function readClaudeSlashCommands(
|
||||
message: Record<string, unknown>
|
||||
): AgentSessionSlashCommand[] {
|
||||
// Why: the hide-list exists so a non-terminal UI like chat does not offer a
|
||||
// command that only means something inside the CLI's own TUI.
|
||||
const hidden = new Set(names(message.terminal_slash_commands))
|
||||
const skills = new Set(names(message.skills))
|
||||
return names(message.slash_commands)
|
||||
.filter((name) => !hidden.has(name))
|
||||
.map((name) => ({ name, kind: skills.has(name) ? ('skill' as const) : ('command' as const) }))
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
|
||||
constructor(initMessage?: Record<string, unknown>) {
|
||||
this.entries =
|
||||
initMessage && carriesCommandCatalog(initMessage) ? readClaudeSlashCommands(initMessage) : []
|
||||
}
|
||||
|
||||
get commands(): AgentSessionSlashCommand[] {
|
||||
return this.entries
|
||||
}
|
||||
|
||||
/** True when this frame replaced the catalog with a different one. */
|
||||
observe(message: Record<string, unknown>): boolean {
|
||||
if (!carriesCommandCatalog(message)) {
|
||||
return false
|
||||
}
|
||||
const next = readClaudeSlashCommands(message)
|
||||
if (
|
||||
next.length === this.entries.length &&
|
||||
next.every(
|
||||
(entry, index) =>
|
||||
entry.name === this.entries[index]?.name && entry.kind === this.entries[index]?.kind
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.entries = next
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structur
|
||||
import { readClaudeImage } from './claude-structured-dispatch-content'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
|
||||
|
||||
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
|
||||
return {
|
||||
@@ -21,6 +22,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
commands: new ClaudeSlashCommandCatalog(),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
|
||||
|
||||
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
|
||||
return {
|
||||
@@ -16,6 +17,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
commands: new ClaudeSlashCommandCatalog(),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
|
||||
@@ -195,6 +195,9 @@ 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)
|
||||
}
|
||||
session?.translator?.handle(event)
|
||||
this.deps.onEvent?.(event)
|
||||
if (backgroundTasksChanged) {
|
||||
@@ -261,6 +264,8 @@ 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
|
||||
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
|
||||
answerClaudePrompt(this.session(input.sessionId), input)
|
||||
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
|
||||
|
||||
export function createClaudeSessionPublication(input: {
|
||||
connection: ClaudeSession['connection']
|
||||
@@ -52,6 +53,7 @@ export function createClaudeSessionPublication(input: {
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
commands: new ClaudeSlashCommandCatalog(input.init.message),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(input.options),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cancelProcessAcquisition } from '../../shared/child-process/cancel-proc
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
|
||||
|
||||
export type ClaudeAuthDiagnostic = {
|
||||
apiKeySourceConfigured: boolean
|
||||
@@ -126,6 +127,9 @@ export type ClaudeSession = {
|
||||
/** Provider uuid of the most recently admitted turn, if one is active. */
|
||||
activeTurnId?: string
|
||||
backgroundTasks: ClaudeBackgroundTaskTracker
|
||||
/** The `/` surface the CLI reports for itself; seeded from init, kept current
|
||||
* by later init and `commands_changed` frames. */
|
||||
commands: ClaudeSlashCommandCatalog
|
||||
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
|
||||
dispatchSequence: number
|
||||
/** Dispatch sequence that admitted activeTurnId. */
|
||||
|
||||
@@ -60,6 +60,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
sessionId
|
||||
) => this.owners.get(sessionId)?.backgroundTaskState?.(sessionId)
|
||||
|
||||
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (sessionId) =>
|
||||
this.owners.get(sessionId)?.readCommands?.(sessionId)
|
||||
|
||||
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
|
||||
this.owner(input.sessionId).answerPrompt(input)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import type {
|
||||
AgentSessionBackgroundTaskState,
|
||||
AgentSessionOptionsResult,
|
||||
AgentSessionSlashCommand,
|
||||
AgentSessionWireRefusalCode
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
@@ -143,6 +144,9 @@ export type StructuredAgentSessionAdapter = {
|
||||
taskId?: string
|
||||
}): Promise<{ cancelled: boolean }>
|
||||
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
|
||||
/** 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: {
|
||||
|
||||
@@ -317,6 +317,12 @@ 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) ?? []
|
||||
})
|
||||
|
||||
async handoffStatus(sessionId: string): Promise<SessionWire.AgentSessionHandoffStatus> {
|
||||
this.requireSession(sessionId)
|
||||
return this.serialize(sessionId, () =>
|
||||
|
||||
@@ -224,6 +224,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
params: OptionsParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.commands',
|
||||
params: OptionsParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).readCommands(params.sessionId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.history',
|
||||
params: HistoryParams,
|
||||
|
||||
@@ -214,6 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'agentSession.setOption',
|
||||
'agentSession.handoffStatus',
|
||||
'agentSession.options',
|
||||
'agentSession.commands',
|
||||
'agentSession.history',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
|
||||
import {
|
||||
applyMentionSuggestion,
|
||||
EMPTY_HISTORY,
|
||||
@@ -22,6 +20,7 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options'
|
||||
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
|
||||
import { useNativeChatDictationActions } from './use-native-chat-dictation-actions'
|
||||
import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command'
|
||||
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
|
||||
import { useNativeChatPickerState } from './use-native-chat-picker-state'
|
||||
import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch'
|
||||
import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion'
|
||||
@@ -109,10 +108,9 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
dictationState === 'listening' ||
|
||||
dictationState === 'stopping'
|
||||
|
||||
const agentCommands = useMemo(
|
||||
() =>
|
||||
structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent),
|
||||
[agent, structuredTransport]
|
||||
const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog(
|
||||
agent,
|
||||
structuredTransport
|
||||
)
|
||||
const picker = useNativeChatPickerState({
|
||||
agent,
|
||||
@@ -121,6 +119,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
draft,
|
||||
caret,
|
||||
agentCommands,
|
||||
sessionSkillNames,
|
||||
textareaRef,
|
||||
setDraft,
|
||||
setCaret,
|
||||
|
||||
@@ -140,6 +140,7 @@ export function NativeChatStructuredSession(
|
||||
optionsSurface: controller.optionSurface,
|
||||
optionSnapshot: controller.optionSnapshot,
|
||||
optionPickerRequest,
|
||||
sessionCommands: controller.sessionCommands,
|
||||
worktreeId: fileLinkContext?.worktreeId,
|
||||
onError: setComposerError,
|
||||
runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote'
|
||||
|
||||
@@ -236,6 +236,45 @@ describe('native skill and command picker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a session report replace the disk scan and enrich the names it knows', () => {
|
||||
const items = buildNativeChatPickerItems(
|
||||
[],
|
||||
[
|
||||
skill({
|
||||
name: 'ref-oss',
|
||||
description: 'On disk',
|
||||
skillFilePath: '/home/ref-oss/SKILL.md',
|
||||
sourceKind: 'home'
|
||||
}),
|
||||
skill({ name: 'stale-on-disk', skillFilePath: '/home/stale/SKILL.md', sourceKind: 'home' })
|
||||
],
|
||||
'',
|
||||
'/',
|
||||
['dataviz', 'ref-oss']
|
||||
)
|
||||
// The scanned-but-unreported skill is gone; the reported-but-unscanned one is
|
||||
// offered without a scope, and sorts after the one the scan located.
|
||||
expect(items.map((item) => item.name)).toEqual(['ref-oss', 'dataviz'])
|
||||
expect(items[0]).toMatchObject({ kind: 'skill', description: 'On disk' })
|
||||
expect(items[1]).toMatchObject({ kind: 'skill', description: null, sources: [] })
|
||||
})
|
||||
|
||||
it('keeps the disk scan when the session reports nothing', () => {
|
||||
const items = buildNativeChatPickerItems(
|
||||
[],
|
||||
[skill({ name: 'ref-oss', skillFilePath: '/home/ref-oss/SKILL.md' })],
|
||||
'',
|
||||
'/',
|
||||
[]
|
||||
)
|
||||
expect(items.map((item) => item.name)).toEqual(['ref-oss'])
|
||||
})
|
||||
|
||||
it('rejects a session-reported name that is not a safe insertion token', () => {
|
||||
const items = buildNativeChatPickerItems([], [], '', '/', ['ok', 'two words', 'cle\u200bar'])
|
||||
expect(items.map((item) => item.name)).toEqual(['ok'])
|
||||
})
|
||||
|
||||
it('ranks exact, prefix, fuzzy, then description matches within a group', () => {
|
||||
const items = buildNativeChatPickerItems(
|
||||
[],
|
||||
|
||||
@@ -51,11 +51,19 @@ export function deriveComposerAutocomplete(
|
||||
skills: readonly DiscoveredSkill[] = [],
|
||||
profile: NativeChatAgentProfile | null = null,
|
||||
discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills },
|
||||
dismissedTriggerKey: string | null = null
|
||||
dismissedTriggerKey: string | null = null,
|
||||
sessionSkillNames: readonly string[] = []
|
||||
): ComposerAutocomplete {
|
||||
const before = draft.slice(0, caret)
|
||||
if (before.startsWith('/') && !/\s/.test(before)) {
|
||||
return deriveSlashAutocomplete(before, agentCommands, profile, discovery, dismissedTriggerKey)
|
||||
return deriveSlashAutocomplete(
|
||||
before,
|
||||
agentCommands,
|
||||
profile,
|
||||
discovery,
|
||||
dismissedTriggerKey,
|
||||
sessionSkillNames
|
||||
)
|
||||
}
|
||||
const mentionMatch = before.match(/(?:^|\s)@(\S*)$/)
|
||||
if (mentionMatch) {
|
||||
@@ -81,7 +89,7 @@ export function deriveComposerAutocomplete(
|
||||
grouped: false,
|
||||
commandsEnabled: false,
|
||||
skillsEnabled: true,
|
||||
items: buildNativeChatPickerItems([], discovery.skills, query, '$'),
|
||||
items: buildNativeChatPickerItems([], discovery.skills, query, '$', sessionSkillNames),
|
||||
skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status,
|
||||
...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {})
|
||||
}
|
||||
@@ -92,7 +100,8 @@ function deriveSlashAutocomplete(
|
||||
agentCommands: readonly SlashCommandSuggestion[],
|
||||
profile: NativeChatAgentProfile | null,
|
||||
discovery: NativeChatSkillDiscoverySnapshot,
|
||||
dismissedTriggerKey: string | null
|
||||
dismissedTriggerKey: string | null,
|
||||
sessionSkillNames: readonly string[]
|
||||
): ComposerAutocomplete {
|
||||
const triggerKey = '/:0'
|
||||
if (dismissedTriggerKey === triggerKey) {
|
||||
@@ -106,7 +115,8 @@ function deriveSlashAutocomplete(
|
||||
agentCommands,
|
||||
hasSlashSkills ? discovery.skills : [],
|
||||
query,
|
||||
'/'
|
||||
'/',
|
||||
hasSlashSkills ? sessionSkillNames : []
|
||||
)
|
||||
return {
|
||||
mode: 'slash',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer'
|
||||
import type {
|
||||
@@ -18,6 +19,9 @@ 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. */
|
||||
sessionCommands?: readonly AgentSessionSlashCommand[]
|
||||
worktreeId?: string
|
||||
onError: (message: string | null) => void
|
||||
runtime: 'local' | 'remote'
|
||||
|
||||
@@ -47,9 +47,10 @@ export function buildNativeChatPickerItems(
|
||||
commands: readonly SlashCommandSuggestion[],
|
||||
skills: readonly DiscoveredSkill[],
|
||||
query: string,
|
||||
prefix: '/' | '$'
|
||||
prefix: '/' | '$',
|
||||
sessionSkillNames?: readonly string[]
|
||||
): NativeChatPickerItem[] {
|
||||
const mergedSkills = mergeNativeChatSkills(skills)
|
||||
const mergedSkills = mergeNativeChatSkills(skills, sessionSkillNames)
|
||||
const skillNames = new Set(mergedSkills.map((skill) => skill.name))
|
||||
const commandNames = new Set(commands.map((command) => command.name))
|
||||
const commandItems = rankItems(
|
||||
@@ -80,7 +81,8 @@ export function buildNativeChatPickerItems(
|
||||
}
|
||||
|
||||
function mergeNativeChatSkills(
|
||||
skills: readonly DiscoveredSkill[]
|
||||
skills: readonly DiscoveredSkill[],
|
||||
sessionSkillNames?: readonly string[]
|
||||
): Extract<NativeChatPickerItem, { kind: 'skill' }>[] {
|
||||
const exactPaths = new Map<string, DiscoveredSkill>()
|
||||
for (const skill of skills) {
|
||||
@@ -96,23 +98,39 @@ function mergeNativeChatSkills(
|
||||
}
|
||||
byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }])
|
||||
}
|
||||
return [...byName.entries()]
|
||||
.map(([name, namedSkills]) => {
|
||||
const sorted = [...namedSkills].sort(compareDiscoveredSkills)
|
||||
return {
|
||||
kind: 'skill' as const,
|
||||
id: `skill:${name}`,
|
||||
name,
|
||||
description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null,
|
||||
sources: sorted.map((skill) => ({
|
||||
sourceKind: skill.sourceKind,
|
||||
skillFilePath: skill.skillFilePath
|
||||
}))
|
||||
}
|
||||
})
|
||||
const discovered = new Map(
|
||||
[...byName.entries()].map(([name, namedSkills]) => [name, pickerSkill(name, namedSkills)])
|
||||
)
|
||||
// Why: when the running session reports its own skills, that report is the
|
||||
// authority on which ones exist — a disk scan cannot see what the session
|
||||
// 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()]
|
||||
return [...new Set(names)]
|
||||
.map((name) => discovered.get(name) ?? pickerSkill(name, []))
|
||||
.sort(comparePickerSkills)
|
||||
}
|
||||
|
||||
function pickerSkill(
|
||||
name: string,
|
||||
namedSkills: readonly DiscoveredSkill[]
|
||||
): Extract<NativeChatPickerItem, { kind: 'skill' }> {
|
||||
const sorted = [...namedSkills].sort(compareDiscoveredSkills)
|
||||
return {
|
||||
kind: 'skill' as const,
|
||||
id: `skill:${name}`,
|
||||
name,
|
||||
description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null,
|
||||
sources: sorted.map((skill) => ({
|
||||
sourceKind: skill.sourceKind,
|
||||
skillFilePath: skill.skillFilePath
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function rankItems<T extends NativeChatPickerItem>(
|
||||
entries: { item: T; stableOrder: number }[],
|
||||
query: string
|
||||
@@ -197,12 +215,21 @@ function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number
|
||||
)
|
||||
}
|
||||
|
||||
// A session-reported skill this host could not locate on disk sorts last: it is
|
||||
// real and invocable, but carries no scope or description to rank on.
|
||||
const UNLOCATED_SCOPE_PRIORITY = Object.keys(SCOPE_PRIORITY).length
|
||||
|
||||
function skillScopePriority(item: Extract<NativeChatPickerItem, { kind: 'skill' }>): number {
|
||||
const sourceKind = item.sources[0]?.sourceKind
|
||||
return sourceKind === undefined ? UNLOCATED_SCOPE_PRIORITY : SCOPE_PRIORITY[sourceKind]
|
||||
}
|
||||
|
||||
function comparePickerSkills(
|
||||
a: Extract<NativeChatPickerItem, { kind: 'skill' }>,
|
||||
b: Extract<NativeChatPickerItem, { kind: 'skill' }>
|
||||
): number {
|
||||
return (
|
||||
SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] ||
|
||||
skillScopePriority(a) - skillScopePriority(b) ||
|
||||
compareBaseSensitivityLocaleText(a.name, b.name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import {
|
||||
sessionReportedSkillNames,
|
||||
sessionSlashCommandSuggestions,
|
||||
type SlashCommandSuggestion
|
||||
} from '../../../../shared/native-chat-slash-commands'
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* What the `/` menu offers. A structured session reports the surface it actually
|
||||
* loaded — the only list that includes this repo's own commands and the skills
|
||||
* that reach the session through plugin roots — so it wins whenever it is
|
||||
* present. The curated per-agent catalog remains the answer for the PTY lane and
|
||||
* for a host that predates the report.
|
||||
*/
|
||||
export function useNativeChatComposerCatalog(
|
||||
agent: AgentType,
|
||||
structuredTransport?: NativeChatStructuredComposerTransport
|
||||
): NativeChatComposerCatalog {
|
||||
const reported = structuredTransport?.sessionCommands
|
||||
const agentCommands = useMemo(
|
||||
() =>
|
||||
!structuredTransport
|
||||
? getVerifiedNativeChatCommands(agent)
|
||||
: reported?.length
|
||||
? sessionSlashCommandSuggestions(agent, reported)
|
||||
: structuredSlashCommands(agent),
|
||||
[agent, reported, structuredTransport]
|
||||
)
|
||||
const sessionSkillNames = useMemo(
|
||||
() => (reported?.length ? sessionReportedSkillNames(reported) : EMPTY_SKILL_NAMES),
|
||||
[reported]
|
||||
)
|
||||
return { agentCommands, sessionSkillNames }
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
emitNativeChatSendClassified
|
||||
} from '@/lib/native-chat-telemetry'
|
||||
|
||||
const EMPTY_SESSION_SKILL_NAMES: readonly string[] = []
|
||||
|
||||
export type NativeChatPickerState = {
|
||||
autocomplete: ComposerAutocomplete
|
||||
listboxId: string
|
||||
@@ -46,6 +48,8 @@ export function useNativeChatPickerState(args: {
|
||||
draft: string
|
||||
caret: number
|
||||
agentCommands: readonly SlashCommandSuggestion[]
|
||||
/** Skill names the running session reports; empty keeps the host disk scan. */
|
||||
sessionSkillNames?: readonly string[]
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
setDraft: (value: string) => void
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
@@ -58,6 +62,7 @@ export function useNativeChatPickerState(args: {
|
||||
draft,
|
||||
caret,
|
||||
agentCommands,
|
||||
sessionSkillNames = EMPTY_SESSION_SKILL_NAMES,
|
||||
textareaRef,
|
||||
setDraft,
|
||||
setCaret,
|
||||
@@ -86,9 +91,19 @@ export function useNativeChatPickerState(args: {
|
||||
discovery.skills,
|
||||
profile,
|
||||
discovery,
|
||||
dismissed?.context === dismissalContext ? dismissed.triggerKey : null
|
||||
dismissed?.context === dismissalContext ? dismissed.triggerKey : null,
|
||||
sessionSkillNames
|
||||
),
|
||||
[agentCommands, caret, dismissalContext, dismissed, discovery, draft, profile]
|
||||
[
|
||||
agentCommands,
|
||||
caret,
|
||||
dismissalContext,
|
||||
dismissed,
|
||||
discovery,
|
||||
draft,
|
||||
profile,
|
||||
sessionSkillNames
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,7 +167,9 @@ export function useNativeChatPickerState(args: {
|
||||
agentCommands,
|
||||
discovery.skills,
|
||||
profile,
|
||||
discovery
|
||||
discovery,
|
||||
null,
|
||||
sessionSkillNames
|
||||
)
|
||||
if (
|
||||
(next.mode !== 'slash' && next.mode !== 'skill') ||
|
||||
@@ -161,7 +178,7 @@ export function useNativeChatPickerState(args: {
|
||||
setDismissed(null)
|
||||
}
|
||||
},
|
||||
[agentCommands, dismissalContext, dismissed, discovery, draft, profile]
|
||||
[agentCommands, dismissalContext, dismissed, discovery, draft, profile, sessionSkillNames]
|
||||
)
|
||||
|
||||
const classifySend = useCallback(
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
AgentSessionCommandsResult,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionOptionResult,
|
||||
AgentSessionOptionsResult,
|
||||
AgentSessionPromptResult
|
||||
AgentSessionPromptResult,
|
||||
AgentSessionSlashCommand
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
|
||||
import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options'
|
||||
@@ -171,6 +173,28 @@ export function useStructuredAgentSession(args: {
|
||||
}
|
||||
}, [isVisible, optionCatalog, sessionId, state.fence, target, turnId])
|
||||
|
||||
const [sessionCommands, setSessionCommands] = useState<readonly AgentSessionSlashCommand[]>([])
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void callStructuredAgentSession<AgentSessionCommandsResult>(target, 'agentSession.commands', {
|
||||
sessionId
|
||||
})
|
||||
.then((result) => {
|
||||
if (!stale) {
|
||||
setSessionCommands(result.commands ?? [])
|
||||
}
|
||||
})
|
||||
// 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])
|
||||
|
||||
const optionSnapshot = useMemo(
|
||||
() => structuredAgentSessionOptionSnapshot(optionState),
|
||||
[optionState]
|
||||
@@ -270,6 +294,7 @@ export function useStructuredAgentSession(args: {
|
||||
),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
sessionCommands,
|
||||
setStructuredOption
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,21 @@ export type AgentSessionModelOption = {
|
||||
efforts: AgentSessionOptionChoice[]
|
||||
}
|
||||
|
||||
/** One entry of the `/` menu the running provider reports for itself. `skill`
|
||||
* marks a name the session loaded as a skill rather than a built-in command;
|
||||
* commands the provider reserves for a terminal UI are already removed. */
|
||||
export type AgentSessionSlashCommand = {
|
||||
name: string
|
||||
kind: 'command' | 'skill'
|
||||
}
|
||||
|
||||
/** The provider's own command surface, read per session. Additive read-only
|
||||
* surface: a host that predates it answers `method_not_found`, and the client
|
||||
* keeps rendering its curated catalog. */
|
||||
export type AgentSessionCommandsResult = {
|
||||
commands: AgentSessionSlashCommand[]
|
||||
}
|
||||
|
||||
/** Provider-reported choices and effective next-turn values. Additive read-only
|
||||
* surface so older hosts can reject it without changing structured v1 writes. */
|
||||
export type AgentSessionOptionsResult = {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
filterSlashCommands,
|
||||
getAgentSlashCommands,
|
||||
isSlashCommandDraft,
|
||||
sessionReportedSkillNames,
|
||||
sessionSlashCommandSuggestions,
|
||||
slashCommandDispatchText
|
||||
} from './native-chat-slash-commands'
|
||||
|
||||
@@ -64,3 +66,27 @@ describe('dispatch vs completion text', () => {
|
||||
expect(applySlashSuggestion({ name: 'model' })).toBe('/model ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session that reports its own command surface', () => {
|
||||
const reported = [
|
||||
{ name: 'clear', kind: 'command' as const },
|
||||
{ name: 'opsx:apply', kind: 'command' as const },
|
||||
{ name: 'ref-oss', kind: 'skill' as const }
|
||||
]
|
||||
|
||||
it('offers exactly the reported commands, described from the curated catalog', () => {
|
||||
expect(sessionSlashCommandSuggestions('claude', reported)).toEqual([
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{ name: 'opsx:apply' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not resurrect a curated command the session never reported', () => {
|
||||
const names = sessionSlashCommandSuggestions('claude', reported).map((c) => c.name)
|
||||
expect(names).not.toContain('compact')
|
||||
})
|
||||
|
||||
it('splits skills out for the picker to group on its own', () => {
|
||||
expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// mirrored copy to drift, unlike the agent-specific parsers in src/shared that
|
||||
// Metro forces us to duplicate.
|
||||
|
||||
import type { AgentSessionSlashCommand } from './agent-session-wire'
|
||||
import type { AgentType } from './agent-status-types'
|
||||
|
||||
export type SlashCommandSuggestion = {
|
||||
@@ -90,6 +91,32 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu
|
||||
return COMMANDS_BY_AGENT[agent] ?? COMMON_COMMANDS
|
||||
}
|
||||
|
||||
/** The command rows for a session that reports its own `/` surface. The report
|
||||
* is the authority on WHICH commands exist; the curated catalog above is kept
|
||||
* only as the description source for the names both know about. Skills are
|
||||
* excluded — they render in the picker's own skills group. */
|
||||
export function sessionSlashCommandSuggestions(
|
||||
agent: AgentType,
|
||||
reported: readonly AgentSessionSlashCommand[]
|
||||
): readonly SlashCommandSuggestion[] {
|
||||
const described = new Map(
|
||||
getAgentSlashCommands(agent).map((command) => [command.name, command.description])
|
||||
)
|
||||
return reported
|
||||
.filter((entry) => entry.kind === 'command')
|
||||
.map((entry) => {
|
||||
const description = described.get(entry.name)
|
||||
return { name: entry.name, ...(description ? { description } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Names the session reported as skills, in the order it reported them. */
|
||||
export function sessionReportedSkillNames(
|
||||
reported: readonly AgentSessionSlashCommand[]
|
||||
): readonly string[] {
|
||||
return reported.filter((entry) => entry.kind === 'skill').map((entry) => entry.name)
|
||||
}
|
||||
|
||||
/** Whether the draft is a slash command (leading `/`, ignoring leading space).
|
||||
* Slash drafts dispatch to the agent's own TUI and must NOT render an optimistic
|
||||
* user bubble — they are control actions, not chat turns. */
|
||||
|
||||
Reference in New Issue
Block a user