mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
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:
@@ -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' }])
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user