feat(native-chat): describe slash commands from the provider's own report

The Claude session reports a description and argument hint for every
command it can run, but the catalog kept only the name, so the `/` picker
described the handful of commands our curated map covers and left the rest
— `/goal` included — with a blank row.

Carry `description`/`argumentHint` through the catalog and the session wire
(both optional, so mixed-version hosts are unaffected), and let a reported
description win over the curated one, which stays as the fallback for the
name-only report shape. The curated maps are untouched, so structured
dispatch still claims exactly the commands it claimed before.
This commit is contained in:
Merge Sim
2026-09-10 14:00:22 -07:00
parent 721a269289
commit ea7c2bf443
6 changed files with 218 additions and 22 deletions
@@ -86,8 +86,8 @@ it('accepts descriptor reloads, removing old skills while retaining terminal fil
}
expect(catalog.observe(reload)).toBe(true)
expect(catalog.commands).toEqual([
{ name: 'clear', kind: 'command' },
{ name: 'new-skill', kind: 'skill' }
{ name: 'clear', kind: 'command', description: 'Clear' },
{ name: 'new-skill', kind: 'skill', description: 'New' }
])
expect(catalog.observe(reload)).toBe(false)
expect(catalog.observe({ ...reload, commands: [] })).toBe(true)
@@ -130,3 +130,105 @@ it('publishes classification becoming authoritative even when the name and kind
expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true)
expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
})
it('keeps the description and argument hint a descriptor report authored', () => {
const catalog = new ClaudeSlashCommandCatalog(undefined, {
commands: [
{ name: 'goal', description: 'Set or view the goal', argumentHint: '<goal>' },
{ name: 'quiet', description: '', argumentHint: '' }
]
})
expect(catalog.commands).toEqual([
{
name: 'goal',
kind: 'command',
kindUnspecified: true,
description: 'Set or view the goal',
argumentHint: '<goal>'
},
{ name: 'quiet', kind: 'command', kindUnspecified: true }
])
})
it('bounds the row text a provider can put in the picker', () => {
const catalog = new ClaudeSlashCommandCatalog(undefined, {
commands: [
{ name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) },
{ name: 'wrong-type', description: 42, argumentHint: { text: 'no' } },
{ name: 'blank', description: ' ' },
{ name: 'wrapped', description: 'first line\n second line' }
]
})
expect(catalog.commands).toEqual([
{ name: 'long', kind: 'command', kindUnspecified: true },
{ name: 'wrong-type', kind: 'command', kindUnspecified: true },
{ name: 'blank', kind: 'command', kindUnspecified: true },
{
name: 'wrapped',
kind: 'command',
kindUnspecified: true,
description: 'first line second line'
}
])
})
it('carries descriptor text across the name-only stream init that classifies it', () => {
const catalog = new ClaudeSlashCommandCatalog(undefined, {
commands: [
{ name: 'clear', description: 'Clear conversation' },
{ name: 'ref-oss', description: 'A skill' }
]
})
expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe(
true
)
expect(catalog.commands).toEqual([
{ name: 'clear', kind: 'command', description: 'Clear conversation' },
{ name: 'ref-oss', kind: 'skill', description: 'A skill' }
])
})
it('reports a description-only change and lets a later report drop the text', () => {
const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] }))
expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
const changed = {
type: 'system',
subtype: 'commands_changed',
commands: [{ name: 'clear', description: 'Clear conversation history' }]
}
expect(catalog.observe(changed)).toBe(true)
expect(catalog.commands).toEqual([
{ name: 'clear', kind: 'command', description: 'Clear conversation history' }
])
expect(catalog.observe(changed)).toBe(false)
expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true)
expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
})
it('describes nothing when the session reported names only', () => {
expect(readClaudeSlashCommands(init())).toEqual([
{ name: 'clear', kind: 'command' },
{ name: 'ref-oss', kind: 'skill' },
{ name: 'opsx:apply', kind: 'command' }
])
expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([
{ name: 'clear', kind: 'command' },
{ name: 'ref-oss', kind: 'skill' },
{ name: 'opsx:apply', kind: 'command' }
])
})
it('still hides terminal-only names however well the provider describes them', () => {
const catalog = new ClaudeSlashCommandCatalog(init())
expect(
catalog.observe({
type: 'system',
subtype: 'commands_changed',
commands: [
{ name: 'doctor', description: 'Diagnose the CLI install' },
{ name: 'ref-oss', description: 'A skill' }
]
})
).toBe(true)
expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }])
})
+67 -14
View File
@@ -3,6 +3,11 @@ import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire'
// Stream init carries name arrays; control initialization and reloads carry descriptors.
const MAX_COMMANDS = 512
const MAX_NAME_LENGTH = 200
const MAX_DESCRIPTION_LENGTH = 200
const MAX_ARGUMENT_HINT_LENGTH = 100
/** The provider's own row text for one command, absent when it reported none. */
type CommandDetail = Pick<AgentSessionSlashCommand, 'description' | 'argumentHint'>
function names(value: unknown): string[] {
if (!Array.isArray(value)) {
@@ -29,6 +34,40 @@ function descriptorNames(value: unknown): string[] {
)
}
/** A single picker row's worth of provider text: unusable values are dropped, not truncated. */
function rowText(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const collapsed = value.replace(/\s+/gu, ' ').trim()
return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined
}
function descriptorDetails(value: unknown): Map<string, CommandDetail> {
const details = new Map<string, CommandDetail>()
if (!Array.isArray(value)) {
return details
}
for (const entry of value) {
if (details.size >= MAX_COMMANDS) {
break
}
if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string') {
continue
}
const description = rowText(entry.description, MAX_DESCRIPTION_LENGTH)
const argumentHint = rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH)
if (description === undefined && argumentHint === undefined) {
continue
}
details.set(entry.name.trim(), {
...(description === undefined ? {} : { description }),
...(argumentHint === undefined ? {} : { argumentHint })
})
}
return details
}
function carriesCommandCatalog(message: Record<string, unknown>): boolean {
return (
message.type === 'system' &&
@@ -56,6 +95,7 @@ export class ClaudeSlashCommandCatalog {
private hasSkillClassification = false
private hidden = new Set<string>()
private commandNames = new Set<string>()
private details = new Map<string, CommandDetail>()
constructor(initMessage?: Record<string, unknown>, initialization?: unknown) {
// SessionStart can prove acquisition before the first stream init exists.
@@ -65,11 +105,14 @@ export class ClaudeSlashCommandCatalog {
'commands' in initialization &&
Array.isArray(initialization.commands)
) {
this.entries = descriptorNames(initialization.commands).map((name) => ({
name,
kind: 'command',
kindUnspecified: true
}))
this.details = descriptorDetails(initialization.commands)
this.entries = this.describe(
descriptorNames(initialization.commands).map((name) => ({
name,
kind: 'command',
kindUnspecified: true
}))
)
}
if (initMessage) {
this.observe(initMessage)
@@ -80,13 +123,18 @@ export class ClaudeSlashCommandCatalog {
return this.entries
}
/** Provider row text, carried across the name-only frames that never restate it. */
private describe(entries: AgentSessionSlashCommand[]): AgentSessionSlashCommand[] {
return entries.map((entry) => ({ ...entry, ...this.details.get(entry.name) }))
}
/** True when this frame replaced the catalog with a different one. */
observe(message: Record<string, unknown>): boolean {
let next: AgentSessionSlashCommand[]
if (carriesCommandCatalog(message)) {
this.hasSkillClassification = true
this.hidden = new Set(names(message.terminal_slash_commands))
next = readClaudeSlashCommands(message)
next = this.describe(readClaudeSlashCommands(message))
this.commandNames = new Set(
next.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
)
@@ -95,13 +143,16 @@ export class ClaudeSlashCommandCatalog {
message.subtype === 'commands_changed' &&
Array.isArray(message.commands)
) {
next = descriptorNames(message.commands)
.filter((name) => !this.hidden.has(name))
.map((name) =>
this.hasSkillClassification
? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
: { name, kind: 'command', kindUnspecified: true }
)
this.details = descriptorDetails(message.commands)
next = this.describe(
descriptorNames(message.commands)
.filter((name) => !this.hidden.has(name))
.map((name) =>
this.hasSkillClassification
? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
: { name, kind: 'command', kindUnspecified: true }
)
)
} else {
return false
}
@@ -112,7 +163,9 @@ export class ClaudeSlashCommandCatalog {
(entry, index) =>
entry.name === this.entries?.[index]?.name &&
entry.kind === this.entries?.[index]?.kind &&
entry.kindUnspecified === this.entries?.[index]?.kindUnspecified
entry.kindUnspecified === this.entries?.[index]?.kindUnspecified &&
entry.description === this.entries?.[index]?.description &&
entry.argumentHint === this.entries?.[index]?.argumentHint
)
) {
return false
@@ -55,8 +55,14 @@ it.each([
const adapter = adapterFor(claude)
try {
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
const described = commands[0]?.description
expect(adapter.readCommands('session-1')).toEqual(
commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true }))
commands.map(({ name, description }) => ({
name,
kind: 'command',
kindUnspecified: true,
...(description ? { description } : {})
}))
)
expect(claude.connections[0].sent).toEqual([])
expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([
@@ -70,7 +76,10 @@ it.each([
slash_commands: ['project:check'],
skills: ['project:check']
})
expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }])
// The stream init classifies the name; the control seed's text survives it.
expect(adapter.readCommands('session-1')).toEqual([
{ name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) }
])
} finally {
await adapter.closeSession('session-1')
}
+4
View File
@@ -343,6 +343,10 @@ export type AgentSessionSlashCommand = {
kind: 'command' | 'skill'
/** Membership is authoritative, but this provider report did not classify the name. */
kindUnspecified?: true
/** Provider-authored row text; absent when the report carried names only. */
description?: string
/** Provider-authored argument sketch, e.g. `<issue-url>`. */
argumentHint?: string
}
/** The provider's own command surface, read per session. Additive read-only
@@ -89,4 +89,31 @@ describe('a session that reports its own command surface', () => {
it('splits skills out for the picker to group on its own', () => {
expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss'])
})
it('prefers the description the session reported over the curated one', () => {
expect(
sessionSlashCommandSuggestions('claude', [
{ name: 'clear', kind: 'command', description: 'Wipe the transcript' },
{ name: 'goal', kind: 'command', description: 'Set or view the goal' },
{ name: 'compact', kind: 'command' }
])
).toEqual([
{ name: 'clear', description: 'Wipe the transcript' },
{ name: 'goal', description: 'Set or view the goal' },
{ name: 'compact', description: 'Summarize and compact the conversation' }
])
})
it('keeps a reported description on a command the curated catalog never claims', () => {
expect(
sessionSlashCommandSuggestions('codex', [
{
name: 'opsx:apply',
kind: 'command',
description: 'Apply the plan',
kindUnspecified: true
}
])
).toEqual([{ name: 'opsx:apply', description: 'Apply the plan', kindUnspecified: true }])
})
})
+5 -4
View File
@@ -93,9 +93,10 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu
}
/** 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. */
* is the authority on WHICH commands exist and, when it carries one, on how a
* command is described; the curated catalog above only covers the names whose
* report is text-free. Skills are excluded — they render in the picker's own
* skills group. */
export function sessionSlashCommandSuggestions(
agent: AgentType,
reported: readonly AgentSessionSlashCommand[]
@@ -106,7 +107,7 @@ export function sessionSlashCommandSuggestions(
return reported
.filter((entry) => entry.kind === 'command')
.map((entry) => {
const description = described.get(entry.name)
const description = entry.description ?? described.get(entry.name)
return {
name: entry.name,
...(description ? { description } : {}),