mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
fix(native-chat): keep Claude's terminal-only commands in the chat / menu (#29784)
A Claude chat's / menu listed /color, /doctor and /reload-plugins until the first message ran, then dropped them: the CLI names its terminal-only commands only in the init frame a turn emits, and the catalog treated that list as a hide-list. Chat runs all three (/color and /reload-plugins answer locally, /doctor is an ordinary turn), so the catalog now keeps every command Claude reports and the menu no longer changes on the first message.
This commit is contained in:
@@ -14,10 +14,11 @@ function init(overrides: Record<string, unknown> = {}): Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('claude slash command catalog', () => {
|
||||
it('tags reported skills and drops the commands reserved for a terminal UI', () => {
|
||||
it('tags reported skills and keeps the commands Claude marks terminal-only', () => {
|
||||
expect(readClaudeSlashCommands(init())).toEqual([
|
||||
{ name: 'clear', kind: 'command' },
|
||||
{ name: 'ref-oss', kind: 'skill' },
|
||||
{ name: 'doctor', kind: 'skill' },
|
||||
{ name: 'opsx:apply', kind: 'command' }
|
||||
])
|
||||
})
|
||||
@@ -31,7 +32,7 @@ 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(init()).commands).toHaveLength(4)
|
||||
expect(new ClaudeSlashCommandCatalog().commands).toBeUndefined()
|
||||
// A frame of the right subtype but without the array is not a catalog.
|
||||
expect(
|
||||
@@ -92,7 +93,7 @@ describe('claude slash command catalog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts descriptor reloads, removing old skills while retaining terminal filtering', () => {
|
||||
it('accepts descriptor reloads, removing old skills and keeping terminal-only commands', () => {
|
||||
const catalog = new ClaudeSlashCommandCatalog(init())
|
||||
const reload = {
|
||||
type: 'system',
|
||||
@@ -106,7 +107,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', description: 'Clear' },
|
||||
{ name: 'new-skill', kind: 'skill', description: 'New' }
|
||||
{ name: 'new-skill', kind: 'skill', description: 'New' },
|
||||
{ name: 'doctor', kind: 'skill', description: 'Terminal' }
|
||||
])
|
||||
expect(catalog.observe(reload)).toBe(false)
|
||||
expect(catalog.observe({ ...reload, commands: [] })).toBe(true)
|
||||
|
||||
@@ -88,17 +88,16 @@ function carriesCommandCatalog(message: Record<string, unknown>): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** What the session reports it can run, minus what it reserves for a terminal UI. */
|
||||
/** What the session reports it can run, terminal-only commands included: chat runs them too, and
|
||||
* that list arrives only with a turn, so hiding it made the menu change mid-chat. */
|
||||
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) }))
|
||||
return names(message.slash_commands).map((name) => ({
|
||||
name,
|
||||
kind: skills.has(name) ? ('skill' as const) : ('command' as const)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Per-session catalog seeded during acquisition and refreshed by provider frames. */
|
||||
@@ -107,7 +106,6 @@ export class ClaudeSlashCommandCatalog {
|
||||
private hasSkillClassification = false
|
||||
/** The folder the first catalog frame reported: the one the session's own `/` list was loaded for. */
|
||||
private launchCwd: string | undefined
|
||||
private hidden = new Set<string>()
|
||||
private commandNames = new Set<string>()
|
||||
private details = new Map<string, CommandDetail>()
|
||||
|
||||
@@ -165,7 +163,6 @@ export class ClaudeSlashCommandCatalog {
|
||||
let next: AgentSessionSlashCommand[]
|
||||
if (carriesCommandCatalog(message)) {
|
||||
this.hasSkillClassification = true
|
||||
this.hidden = new Set(names(message.terminal_slash_commands))
|
||||
next = this.describe(this.classify(message))
|
||||
this.commandNames = new Set(
|
||||
next.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
|
||||
@@ -178,13 +175,11 @@ export class ClaudeSlashCommandCatalog {
|
||||
const catalog = descriptorCatalog(message.commands)
|
||||
this.details = catalog.details
|
||||
next = this.describe(
|
||||
catalog.names
|
||||
.filter((name) => !this.hidden.has(name))
|
||||
.map((name) =>
|
||||
this.hasSkillClassification
|
||||
? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
|
||||
: { name, kind: 'command', kindUnspecified: true }
|
||||
)
|
||||
catalog.names.map((name) =>
|
||||
this.hasSkillClassification
|
||||
? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
|
||||
: { name, kind: 'command', kindUnspecified: true }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
return false
|
||||
|
||||
@@ -34,7 +34,10 @@ describe('session command updates', () => {
|
||||
}
|
||||
claude.connections[0].handlers.onMessage?.(frame)
|
||||
await tick()
|
||||
expect(adapter.readCommands('session-1')).toEqual([{ name: 'plugin:check', kind: 'skill' }])
|
||||
expect(adapter.readCommands('session-1')).toEqual([
|
||||
{ name: 'plugin:check', kind: 'skill' },
|
||||
{ name: 'doctor', kind: 'command' }
|
||||
])
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
claude.connections[0].handlers.onMessage?.(frame)
|
||||
await tick()
|
||||
|
||||
Reference in New Issue
Block a user