From 040e262215d3856b028fdb70c0552706dae5787f Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Tue, 21 Jul 2026 12:18:03 +0000 Subject: [PATCH 1/2] feat(agent-adapter): complete slash command support Amp-Thread-ID: https://ampcode.com/threads/T-019f846a-8c38-7578-808f-91c71b38bea3 --- .../integration/dev-mock-transport.test.ts | 3 +- .../schema/src/model/agent/input.ts | 2 +- packages/host/agent-adapter/AGENTS.md | 10 +-- packages/host/agent-adapter/package.json | 2 +- .../agent-adapter/src/__tests__/base.test.ts | 2 +- .../src/__tests__/opencode.test.ts | 4 +- .../agent-adapter/src/__tests__/pi.test.ts | 76 ++++++++++++++++++- .../agent-adapter/src/native/pi/adapter.ts | 50 +++++++++++- .../__tests__/new-session-surface.test.tsx | 8 +- pnpm-lock.yaml | 10 +-- 10 files changed, 140 insertions(+), 27 deletions(-) diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index d874cb7de..87c968ecc 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -238,10 +238,11 @@ describe('dev mock transport', () => { let mark = piEvents.length; client.attachSession(piSessionId); - await eventually(() => piEvents.length === mark + 2); + await eventually(() => piEvents.length === mark + 3); expect(piEvents.slice(mark).map((event) => event.type)).toEqual([ 'status', 'capabilities-update', + 'available-commands-update', ]); const opencodeSessionId = await client.startSession({ diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 50a9c0da5..9734facae 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -117,7 +117,7 @@ export const AGENT_INPUT_CAPABILITIES = { 'claude-code': { slashCommands: true, shellCommand: false }, codex: { slashCommands: true, shellCommand: true }, opencode: { slashCommands: true, shellCommand: true }, - pi: { slashCommands: false, shellCommand: false }, + pi: { slashCommands: true, shellCommand: false }, 'grok-build': { slashCommands: false, shellCommand: false }, } as const satisfies Readonly>; diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 12a4b4b19..440f6cb5c 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -22,7 +22,7 @@ Pins as of 2026-07 (package.json ranges are caret; the lockfile is the real pin) | --- | --- | --- | | claude-code | `@anthropic-ai/claude-agent-sdk` | 0.3.206 | | codex | `@openai/codex` (CLI carrier — no JS SDK) | 0.144.1 | -| opencode | `@opencode-ai/sdk` | 1.17.18 | +| opencode | `@opencode-ai/sdk` | 1.18.3 | | pi | `@earendil-works/pi-coding-agent` | 0.80.6 | | grok-build | none (user-installed `grok` CLI; headless `-p`, not ACP) | detect `grok --version` (verified 0.2.102) | @@ -45,8 +45,8 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they | --- | --- | --- | --- | | claude-code | ✓ | `Query#supportedCommands()` at Query creation + `commands_changed` full-replace push | ✗ (`!` bash mode is a TUI input-box feature with no verified programmatic entry) | | codex | ✓ (`/compact` + enabled skills) | `skills/list` at start + `skills/changed` invalidation | ✓ `thread/shellCommand` | -| opencode | ✓ | `command.list({directory})` once at start (no change events exist — snapshot only) | ✓ `session.shell` | -| pi | ✗ (the SDK's own `session.prompt` still expands `/` text it receives — untouched pass-through) | — | ✗ | +| opencode | ✓ (commands + skills) | `command.list({directory})` once at start (no change events exist — snapshot only); binary ≥1.18 includes skills as `source:'skill'` entries | ✓ `session.shell` | +| pi | ✓ (prompt templates + skills as `skill:`) | `DefaultResourceLoader.getPrompts()` + `getSkills()` once at start (snapshot only) | ✗ | - **claude-code**: invocation = pushing `/name args` as a plain user message — the vendored CLI parses a leading `/` on every user message even in SDK streaming-input mode (verified in the binary), so `onCommand` rides `onPrompt` and the normal result-frame settle. Live-verified on the pinned 0.3.206×2.1.206 pair: a local command run through that path returns a synthetic assistant message + a zero-cost `result` — status does not hang. `SlashCommand.aliases` ride the normalized `AgentCommand.aliases` field (`agentCommandMatches` accepts them in composer/engine; menus display only the canonical name; invocation pushes the typed alias, which the CLI resolves itself). `system/local_command_output` (never observed live, handled per SDK types) renders as assistant text bracketed by `freshSegment()`. - **claude-code `/usage` is the one intercepted command (CODE-213)**: mirroring the CLI's own TUI (a dialog, never transcript text), `onCommand` matching the catalog's `usage` entry (canonical or aliases `/cost`/`/stats`; literal name only until the async catalog lands) never forwards a prompt — `reportUsage()` serves it from the Query's get-usage control request and emits a structured `usage-report` event; failures emit a recoverable `error`, never a text fallback and never a silent no-op. No `result` frame follows, so `reportUsage` brackets itself with status `running`→`idle` per the base.ts turn contract — the control request is network-bound and can span a process respawn (crash / effort-max rebuild), and without the bracket the engine's input gate rejects concurrent input while the composer looks idle. The SDK method is EXPERIMENTAL (`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` — the name itself warns it will be renamed); it is feature-detected and confined to `reportUsage()` + `mapClaudeUsageReport()` (which zod-parses the reply at the trust boundary), verified against SDK 0.3.206 (live: the pinned pair advertises `aliases:['cost','stats']`). @@ -88,7 +88,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). - **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source). - **opencode history** (CODE-171, live-verified on 1.17.11): `list`/`read` are served by a daemon-shared, lazily-spawned, idle-reaped `opencode serve` (`opencode/history-server.ts`) — NOT a per-session server: HistoryService calls history on never-started factory instances. The manager self-spawns with a **neutral cwd** (`~/.linkcode/opencode-history`) because opencode indexes its cwd as the default workspace and the SDK's `createOpencode()` cannot set cwd (a daemon launched from `$HOME` would index the whole home tree); `--port=0` does NOT auto-allocate (falls back to 4096) so the free port is found up front; shutdown escalates SIGTERM→SIGKILL (the SDK's `close()` is a bare SIGTERM). The neutral-cwd instance lists/reads sessions across every project with no `directory` scoping. `readHistory` full-fetches then slices at event level — the messages RPC's `limit` returns the LAST n messages, so it cannot page forward — and truncates at `Session.revert.messageID` (partial `partID` reverts keep everything rather than over-cut). Replay reuses the live part ids (`streamDelta`'s message keys, `toolCallFromPart` snapshots) so live and cold cards converge by id. `resumeHistory` is a native continue: adopt the existing session id (no `session.create`) and scope every call by the session's OWN directory, not the resume cwd. Fresh sessions defer `session-ref` until the first on-stream busy acknowledgement — announcing at create would seed the client against an empty transcript and the `uptoSeq` cut would swallow the first prompt (same deferral as codex). -- **pi** — pure JS in-process (`createAgentSession()`), no binary spawn, unaffected by asar-spawn, not staged. The SDK import resolves store-first (CODE-219): `agentRuntimeProber.resolveEntry('pi')` — the managed npm-closure entry installed by `@linkcode/assets` — then the bare `import()` (dev/standalone). The SDK is deliberately a devDependency: `--prod` deploys (desktop staging, standalone daemon) carry no closure, so packaged hosts have only the store. The prober reports pi three-state: managed entry → `source:'managed'`, node_modules → `source:'sdk'`, neither → `missing` (onboarding offers the download). auth via `authStorage.setRuntimeApiKey(provider, apiKey)` overriding `~/.pi/agent/auth.json` + env; model `provider/rest`, falls back to `modelRegistry.getAvailable()[0]`, and the SDK-selected session model is reflected as `provider/id` after creation. `agent_end {willRetry:true}` is intermediate; only `agent_settled` finalizes the turn, sweeps unfinished tools, and maps the final assistant's success/error/aborted outcome. Pi has no approval callback: it advertises one fixed `bypassPermissions` policy so the UI exposes that all tools run without prompts, while policy changes still reject. +- **pi** — pure JS in-process (`createAgentSession()`), no binary spawn, unaffected by asar-spawn, not staged. The SDK import resolves store-first (CODE-219): `agentRuntimeProber.resolveEntry('pi')` — the managed npm-closure entry installed by `@linkcode/assets` — then the bare `import()` (dev/standalone). The SDK is deliberately a devDependency: `--prod` deploys (desktop staging, standalone daemon) carry no closure, so packaged hosts have only the store. The prober reports pi three-state: managed entry → `source:'managed'`, node_modules → `source:'sdk'`, neither → `missing` (onboarding offers the download). auth via `authStorage.setRuntimeApiKey(provider, apiKey)` overriding `~/.pi/agent/auth.json` + env; model `provider/rest`, falls back to `modelRegistry.getAvailable()[0]`, and the SDK-selected session model is reflected as `provider/id` after creation. `agent_end {willRetry:true}` is intermediate; only `agent_settled` finalizes the turn, sweeps unfinished tools, and maps the final assistant's success/error/aborted outcome. Slash commands (CODE-313) snapshot prompt templates and skills from the session's resource loader; invocation sends `/name args` through the same lifecycle-safe prompt path so the SDK performs expansion. ## Capability, auth & cancel matrices @@ -99,7 +99,7 @@ Product code must branch on `historyCapabilities` — never assume an op is supp | claude-code | ✓ | ✓ (live) | ✓ (live) | ✓ | ✓ | ✗ | detected user install / managed dir | | codex | ✓ | ✓ (next turn) | ✓ (next turn, low–xhigh) | ✓ (3 tiers) | ✓ | ✓ | detected user install / managed dir | | opencode | ✓ | ✓ (next turn, same provider) | ✗ | ✓ (agent axis: build/plan/custom; hidden if discovery fails) | ✓ | ✓ | self-spawns server via PATH (CODE-76) | -| pi | ✗ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | +| pi | ✗ | ✗ | ✗ | fixed bypass | ✓ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | | grok-build | ✗ | ✓ (next turn) | ✓ (next turn, low–high) | fixed bypass | ✗ | ✗ | detected user install | `StartOptions.effort` enters through the same `onSetEffort` hook before `onStart`, so startup-only diff --git a/packages/host/agent-adapter/package.json b/packages/host/agent-adapter/package.json index 37509e1b9..a1ccda5df 100644 --- a/packages/host/agent-adapter/package.json +++ b/packages/host/agent-adapter/package.json @@ -18,7 +18,7 @@ "@linkcode/schema": "workspace:*", "@linkcode/transport": "workspace:*", "@openai/codex": "^0.144.1", - "@opencode-ai/sdk": "^1.17.18", + "@opencode-ai/sdk": "^1.18.3", "cross-spawn": "^7.0.6", "foxts": "^5.8.0", "smol-toml": "^1.7.0", diff --git a/packages/host/agent-adapter/src/__tests__/base.test.ts b/packages/host/agent-adapter/src/__tests__/base.test.ts index b2f06e0f6..8a79d1788 100644 --- a/packages/host/agent-adapter/src/__tests__/base.test.ts +++ b/packages/host/agent-adapter/src/__tests__/base.test.ts @@ -211,7 +211,7 @@ describe('BaseAgentAdapter command/shell defaults', () => { await a.start({ kind: 'pi', cwd: '/repo' }); expect(a.seen).toContainEqual({ type: 'capabilities-update', - capabilities: { slashCommands: false, shellCommand: false }, + capabilities: { slashCommands: true, shellCommand: false }, }); }); diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index cec855b04..962780ec5 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -1042,7 +1042,7 @@ describe('OpenCodeAdapter command catalog', () => { client.command.list.mockReturnValueOnce({ data: [ { name: 'review', description: 'Review code', template: 't', hints: [''] }, - { name: 'noop', template: 't', hints: [] }, + { name: 'karpathy-guidelines', source: 'skill', template: 'skill body', hints: [] }, ], }); return Promise.resolve({ client, server: { url: 'http://fake', close: closeServer } }); @@ -1056,7 +1056,7 @@ describe('OpenCodeAdapter command catalog', () => { expect(catalog).toHaveLength(1); expect(catalog[0].commands).toEqual([ { name: 'review', description: 'Review code', argumentHint: '' }, - { name: 'noop', description: undefined, argumentHint: undefined }, + { name: 'karpathy-guidelines', description: undefined, argumentHint: undefined }, ]); }); diff --git a/packages/host/agent-adapter/src/__tests__/pi.test.ts b/packages/host/agent-adapter/src/__tests__/pi.test.ts index 0eae0870f..cda672736 100644 --- a/packages/host/agent-adapter/src/__tests__/pi.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi.test.ts @@ -6,6 +6,11 @@ import { agentRuntimeProber } from '../probe'; let listener: ((event: AgentSessionEvent) => void) | undefined; const prompt = vi.fn(); +const resources = { + fail: false, + prompts: [] as Array<{ name: string; description: string; argumentHint?: string }>, + skills: [] as Array<{ name: string; description: string }>, +}; const session = { abort: vi.fn(), @@ -25,6 +30,14 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({ AuthStorage: { create: () => ({ setRuntimeApiKey: vi.fn() }) }, DefaultResourceLoader: class { reload = vi.fn(); + getPrompts() { + if (resources.fail) throw new Error('prompt discovery failed'); + return { prompts: resources.prompts, diagnostics: [] }; + } + getSkills() { + if (resources.fail) throw new Error('skill discovery failed'); + return { skills: resources.skills, diagnostics: [] }; + } }, ModelRegistry: { create: () => ({ @@ -42,14 +55,20 @@ function record(adapter: PiAdapter): AgentEvent[] { return events; } -async function startedAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> { +async function startAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> { const adapter = new PiAdapter(); const events = record(adapter); await adapter.start({ kind: 'pi', cwd: '/tmp/repo' }); - events.length = 0; return { adapter, events }; } +async function startedAdapter(): Promise<{ adapter: PiAdapter; events: AgentEvent[] }> { + const result = await startAdapter(); + const { events } = result; + events.length = 0; + return result; +} + function assistant(stopReason: 'aborted' | 'error' | 'stop', errorMessage?: string): object { return { role: 'assistant', stopReason, errorMessage }; } @@ -62,9 +81,62 @@ describe('PiAdapter lifecycle', () => { beforeEach(() => { listener = undefined; prompt.mockReset(); + resources.fail = false; + resources.prompts = []; + resources.skills = []; vi.spyOn(agentRuntimeProber, 'resolveEntry').mockReturnValue(undefined); }); + it('advertises prompt templates and skills in its slash-command catalog', async () => { + resources.prompts = [ + { name: 'review', description: 'Review changes', argumentHint: '' }, + { name: 'bare', description: '' }, + ]; + resources.skills = [ + { name: 'pdf', description: 'PDF tools' }, + { name: 'user-only', description: '' }, + ]; + + const { adapter, events } = await startAdapter(); + + expect(adapter.capabilities.slashCommands).toBe(true); + expect(events).toContainEqual({ + type: 'available-commands-update', + commands: [ + { name: 'review', description: 'Review changes', argumentHint: '' }, + { name: 'bare', description: undefined, argumentHint: undefined }, + { name: 'skill:pdf', description: 'PDF tools' }, + { name: 'skill:user-only', description: undefined }, + ], + }); + }); + + it('publishes an empty catalog without failing the session when discovery fails', async () => { + resources.fail = true; + + const { events } = await startAdapter(); + + expect(events).toContainEqual({ type: 'available-commands-update', commands: [] }); + expect(events.at(-1)).toEqual({ type: 'status', status: 'idle' }); + }); + + it('runs slash commands through the prompt lifecycle', async () => { + const { adapter, events } = await startedAdapter(); + + await adapter.send({ type: 'command', name: 'skill:pdf', arguments: 'extract a.pdf' }); + expect(prompt).toHaveBeenCalledWith('/skill:pdf extract a.pdf', undefined); + expect(events).toEqual([{ type: 'status', status: 'running' }]); + + emit({ type: 'agent_end', messages: [assistant('stop')], willRetry: false }); + emit({ type: 'agent_settled' }); + + expect(events).toEqual([ + { type: 'status', status: 'running' }, + { type: 'stop', stopReason: 'end_turn' }, + { type: 'status', status: 'idle' }, + ]); + }); + it('unwinds a prompt rejected after announcing running', async () => { prompt.mockImplementationOnce(() => { emit({ diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 6c4654b5f..2df628100 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -5,11 +5,13 @@ import type { CreateAgentSessionOptions, ExtensionAPI, PromptOptions, + ResourceLoader, SessionManager, ToolCallEvent, ToolCallEventResult, } from '@earendil-works/pi-coding-agent'; import type { + AgentCommand, AgentHistoryCapabilities, AgentHistoryId, AgentHistoryListOptions, @@ -93,6 +95,30 @@ function modelOptions(models: PiModel[]) { })); } +/** Commands Pi's prompt expansion accepts. Extension commands are excluded because they require + * ExtensionCommandContext execution rather than a prompt containing `/name`. */ +function piCommandCatalog( + loader: Pick, +): AgentCommand[] { + const { prompts } = loader.getPrompts(); + const { skills } = loader.getSkills(); + return [ + ...prompts.map( + (prompt): AgentCommand => ({ + name: prompt.name, + description: prompt.description || undefined, + argumentHint: prompt.argumentHint, + }), + ), + ...skills.map( + (skill): AgentCommand => ({ + name: `skill:${skill.name}`, + description: skill.description || undefined, + }), + ), + ]; +} + function createConfiguredRegistry( pi: PiSdk, opts: Pick, @@ -279,11 +305,16 @@ export class PiAdapter extends BaseAgentAdapter { } }, }); + // Pi has no resource change event in headless mode, so this is a full snapshot. Catalog + // discovery is optional session metadata: a failure must hide the menu, not fail the session. + try { + this.emitCommands(piCommandCatalog(resourceLoader)); + } catch { + this.emitCommands([]); + } } protected async onPrompt(content: ContentBlock[]): Promise { - invariant(this.session, 'pi: session not started'); - const text = contentToText(content); const images = imageBlocksFrom(content); const imageOptions: Pick | undefined = images.length === 0 @@ -295,6 +326,17 @@ export class PiAdapter extends BaseAgentAdapter { mimeType: image.mimeType, })), }; + await this.runPrompt(contentToText(content), imageOptions); + } + + /** Pi expands `/skill:name` and prompt-template commands inside `session.prompt`, so command + * dispatch shares the normal turn lifecycle and must not re-emit the user's invocation. */ + protected override async onCommand(name: string, args?: string): Promise { + await this.runPrompt(`/${name}${args ? ` ${args}` : ''}`); + } + + private async runPrompt(text: string, options?: Pick): Promise { + invariant(this.session, 'pi: session not started'); this.turnActive = true; this.promptInFlight = true; this.settlementPending = false; @@ -302,8 +344,8 @@ export class PiAdapter extends BaseAgentAdapter { this.emitStatus('running'); try { if (this.session.isStreaming) { - await this.session.prompt(text, { ...imageOptions, streamingBehavior: 'followUp' }); - } else await this.session.prompt(text, imageOptions); + await this.session.prompt(text, { ...options, streamingBehavior: 'followUp' }); + } else await this.session.prompt(text, options); this.promptInFlight = false; if (this.settlementPending) this.settleTurn(); } catch (error) { diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index fb1a49a15..036c1bd56 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -43,6 +43,7 @@ describe('NewSessionSurface', () => { 'claude-code', 'codex', 'opencode', + 'pi', ] as const)('submits a leading slash command for %s', async (provider) => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( @@ -298,15 +299,12 @@ describe('NewSessionSurface', () => { expect(screen.queryByRole('img', { name: 'accepted.png' })).toBeNull(); }); - it.each([ - 'pi', - 'grok-build', - ] as const)('blocks unsupported slash commands for %s', async (provider) => { + it('blocks unsupported slash commands for grok-build', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( Date: Wed, 22 Jul 2026 05:39:29 +0000 Subject: [PATCH 2/2] fix(agent-adapter): preserve partial Pi command discovery Amp-Thread-ID: https://ampcode.com/threads/T-019f846a-8c38-7578-808f-91c71b38bea3 --- .../agent-adapter/src/__tests__/pi.test.ts | 42 ++++++++++++++++--- .../agent-adapter/src/native/pi/adapter.ts | 39 ++++++++--------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/pi.test.ts b/packages/host/agent-adapter/src/__tests__/pi.test.ts index cda672736..570fcf876 100644 --- a/packages/host/agent-adapter/src/__tests__/pi.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi.test.ts @@ -7,7 +7,8 @@ import { agentRuntimeProber } from '../probe'; let listener: ((event: AgentSessionEvent) => void) | undefined; const prompt = vi.fn(); const resources = { - fail: false, + promptFail: false, + skillFail: false, prompts: [] as Array<{ name: string; description: string; argumentHint?: string }>, skills: [] as Array<{ name: string; description: string }>, }; @@ -31,11 +32,11 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({ DefaultResourceLoader: class { reload = vi.fn(); getPrompts() { - if (resources.fail) throw new Error('prompt discovery failed'); + if (resources.promptFail) throw new Error('prompt discovery failed'); return { prompts: resources.prompts, diagnostics: [] }; } getSkills() { - if (resources.fail) throw new Error('skill discovery failed'); + if (resources.skillFail) throw new Error('skill discovery failed'); return { skills: resources.skills, diagnostics: [] }; } }, @@ -81,7 +82,8 @@ describe('PiAdapter lifecycle', () => { beforeEach(() => { listener = undefined; prompt.mockReset(); - resources.fail = false; + resources.promptFail = false; + resources.skillFail = false; resources.prompts = []; resources.skills = []; vi.spyOn(agentRuntimeProber, 'resolveEntry').mockReturnValue(undefined); @@ -112,7 +114,8 @@ describe('PiAdapter lifecycle', () => { }); it('publishes an empty catalog without failing the session when discovery fails', async () => { - resources.fail = true; + resources.promptFail = true; + resources.skillFail = true; const { events } = await startAdapter(); @@ -120,6 +123,35 @@ describe('PiAdapter lifecycle', () => { expect(events.at(-1)).toEqual({ type: 'status', status: 'idle' }); }); + it.each([ + { + failed: 'prompt templates', + configure() { + resources.promptFail = true; + resources.skills = [{ name: 'pdf', description: 'PDF tools' }]; + }, + commands: [{ name: 'skill:pdf', description: 'PDF tools' }], + }, + { + failed: 'skills', + configure() { + resources.skillFail = true; + resources.prompts = [{ name: 'review', description: 'Review changes' }]; + }, + commands: [{ name: 'review', description: 'Review changes', argumentHint: undefined }], + }, + ])('preserves valid commands when $failed discovery fails', async (testCase) => { + testCase.configure(); + + const { events } = await startAdapter(); + + expect(events).toContainEqual({ + type: 'available-commands-update', + commands: testCase.commands, + }); + expect(events.at(-1)).toEqual({ type: 'status', status: 'idle' }); + }); + it('runs slash commands through the prompt lifecycle', async () => { const { adapter, events } = await startedAdapter(); diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 2df628100..698c1d67e 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -23,6 +23,7 @@ import type { StartOptions, ToolKind, } from '@linkcode/schema'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { invariant } from 'foxts/guard'; import type { AgentStartCatalogOptions } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; @@ -100,23 +101,27 @@ function modelOptions(models: PiModel[]) { function piCommandCatalog( loader: Pick, ): AgentCommand[] { - const { prompts } = loader.getPrompts(); - const { skills } = loader.getSkills(); - return [ - ...prompts.map( - (prompt): AgentCommand => ({ + const commands: AgentCommand[] = []; + try { + appendArrayInPlace( + commands, + loader.getPrompts().prompts.map((prompt) => ({ name: prompt.name, description: prompt.description || undefined, argumentHint: prompt.argumentHint, - }), - ), - ...skills.map( - (skill): AgentCommand => ({ + })), + ); + } catch {} + try { + appendArrayInPlace( + commands, + loader.getSkills().skills.map((skill) => ({ name: `skill:${skill.name}`, description: skill.description || undefined, - }), - ), - ]; + })), + ); + } catch {} + return commands; } function createConfiguredRegistry( @@ -305,13 +310,9 @@ export class PiAdapter extends BaseAgentAdapter { } }, }); - // Pi has no resource change event in headless mode, so this is a full snapshot. Catalog - // discovery is optional session metadata: a failure must hide the menu, not fail the session. - try { - this.emitCommands(piCommandCatalog(resourceLoader)); - } catch { - this.emitCommands([]); - } + // Pi has no resource change event in headless mode, so this is a full snapshot. Each resource + // category is optional session metadata: discovery failure hides only that category. + this.emitCommands(piCommandCatalog(resourceLoader)); } protected async onPrompt(content: ContentBlock[]): Promise {