diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 671aa8a83..42382cb0c 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -33,6 +33,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; +import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; @@ -644,24 +645,23 @@ function addDerivedProviders( enabled: override.enabled !== false, derivedFromProviderId: null, providerParams: override.params, - createBaseClient: (logger) => - providerId === "cursor" - ? new CursorACPAgentClient({ - logger, - command, - env: override.env, - providerId, - label: override.label ?? providerId, - providerParams: override.params, - }) - : new GenericACPAgentClient({ - logger, - command, - env: override.env, - providerId, - label: override.label ?? providerId, - providerParams: override.params, - }), + createBaseClient: (logger) => { + const acpOptions = { + logger, + command, + env: override.env, + providerId, + label: override.label ?? providerId, + providerParams: override.params, + }; + if (providerId === "cursor") { + return new CursorACPAgentClient(acpOptions); + } + if (providerId === "kiro") { + return new KiroACPAgentClient(acpOptions); + } + return new GenericACPAgentClient(acpOptions); + }, }); continue; } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 56db2ec15..86fd1a485 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -41,6 +41,7 @@ import { writeCopilotProviderMode, } from "./copilot-acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { parseKiroExtensionCommands } from "./kiro-acp-agent.js"; import { transformPiModels } from "./pi/agent.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js"; @@ -165,6 +166,35 @@ function createSessionWithConfig( ); } +function createKiroSession( + options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {}, + logger: ReturnType = createTestLogger(), +): ACPAgentSession { + return new ACPAgentSession( + { + provider: "kiro", + cwd: "/tmp/paseo-acp-test", + }, + { + provider: "kiro", + logger, + defaultCommand: ["kiro-cli", "acp"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + extensionCommandsParser: parseKiroExtensionCommands, + waitForInitialCommands: options.waitForInitialCommands ?? false, + initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, + }, + ); +} + function createTerminalChildStub(): ChildProcess { const child = new EventEmitter() as ChildProcess; child.stdout = new EventEmitter() as ChildProcess["stdout"]; @@ -1902,6 +1932,86 @@ describe("ACPAgentSession", () => { ); }); + test("maps the Kiro _kiro.dev/commands/available notification into slash commands and skills", async () => { + const session = createKiroSession({ + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 1500, + }); + asInternals(session).sessionId = "session-1"; + + const listCommandsPromise = session.listCommands(); + + await session.extNotification("_kiro.dev/commands/available", { + sessionId: "session-1", + commands: [ + { + name: "/agent", + description: "Select or list available agents", + meta: { inputType: "selection", hint: "swap " }, + }, + ], + prompts: [ + { + name: "agent-sync-doctor", + description: "Hand off Claude or Codex state across Macs", + arguments: [], + serverName: "skill:config", + }, + ], + // Tools are not slash commands and must be ignored. + tools: [{ name: "code", description: "Code intelligence", source: "built-in" }], + }); + + expect(await listCommandsPromise).toEqual([ + { + name: "agent", + description: "Select or list available agents", + argumentHint: "swap ", + kind: "command", + }, + { + name: "agent-sync-doctor", + description: "Hand off Claude or Codex state across Macs", + argumentHint: "", + kind: "skill", + }, + ]); + }); + + test("ignores Kiro _kiro.dev/commands/available for a different session", async () => { + const session = createKiroSession(); + asInternals(session).sessionId = "session-1"; + + await session.extNotification("_kiro.dev/commands/available", { + sessionId: "other-session", + commands: [{ name: "/agent", description: "Select or list available agents" }], + prompts: [], + }); + + expect(await session.listCommands()).toEqual([]); + }); + + test("settles listCommands() immediately on an empty Kiro commands batch", async () => { + // A long timeout means a resolution can only come from settleCommandsReady() + // firing — not from the wait timer — so this test would hang if the empty + // batch failed to unblock listCommands() (the P1 regression). + const session = createKiroSession({ + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 60_000, + }); + asInternals(session).sessionId = "session-1"; + + const listCommandsPromise = session.listCommands(); + + await session.extNotification("_kiro.dev/commands/available", { + sessionId: "session-1", + commands: [], + prompts: [], + }); + + expect(await listCommandsPromise).toEqual([]); + }); + test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { const session = createSession(); const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index d2fe5aadb..a5848de5c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -334,6 +334,17 @@ export function createLoggedNdJsonStream( return { readable, writable }; } +// Lets a provider that publishes its slash commands through a vendor-specific +// ACP extension notification (rather than the standard +// `available_commands_update` session update) translate that payload into Paseo +// slash commands, without the generic ACP session/client carrying any vendor +// knowledge. Return the parsed commands (possibly empty) for a notification this +// provider owns, or null to ignore notifications it does not handle. +export type ACPExtensionCommandsParser = ( + method: string, + params: Record, +) => AgentSlashCommand[] | null; + interface ACPAgentClientOptions { provider: string; logger: Logger; @@ -356,6 +367,7 @@ interface ACPAgentClientOptions { thinkingOptionId: string, ) => Promise; capabilities?: AgentCapabilityFlags; + extensionCommandsParser?: ACPExtensionCommandsParser; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; terminateProcess?: ProcessTerminator; @@ -383,6 +395,7 @@ interface ACPAgentSessionOptions { thinkingOptionId: string, ) => Promise; capabilities: AgentCapabilityFlags; + extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; agentId?: string; launchEnv?: Record; @@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient { ) => Promise; private readonly waitForInitialCommands: boolean; private readonly initialCommandsWaitTimeoutMs: number; + private readonly extensionCommandsParser?: ACPExtensionCommandsParser; protected readonly terminateProcess: ProcessTerminator; constructor(options: ACPAgentClientOptions) { @@ -716,6 +730,7 @@ export class ACPAgentClient implements AgentClient { this.thinkingOptionWriter = options.thinkingOptionWriter; this.waitForInitialCommands = options.waitForInitialCommands ?? false; this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; + this.extensionCommandsParser = options.extensionCommandsParser; } async createSession( @@ -743,6 +758,7 @@ export class ACPAgentClient implements AgentClient { capabilities: this.capabilities, agentId: launchContext?.agentId, launchEnv: launchContext?.env, + extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }, @@ -791,6 +807,7 @@ export class ACPAgentClient implements AgentClient { handle, agentId: launchContext?.agentId, launchEnv: launchContext?.env, + extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }); @@ -1273,6 +1290,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private commandsReadySettled = false; private waitForInitialCommands: boolean; private initialCommandsWaitTimeoutMs: number; + private readonly extensionCommandsParser?: ACPExtensionCommandsParser; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; private closed = false; @@ -1309,6 +1327,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.currentTitle = config.title ?? null; this.waitForInitialCommands = options.waitForInitialCommands ?? false; this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; + this.extensionCommandsParser = options.extensionCommandsParser; } get id(): string | null { @@ -2082,6 +2101,39 @@ export class ACPAgentSession implements AgentSession, ACPClient { }, "provider.acp.extension_notification", ); + + const parsedCommands = this.extensionCommandsParser?.(method, params); + if (parsedCommands) { + this.applyResolvedCommands(parsedCommands, { + sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined, + }); + } + } + + // Cache an asynchronously-delivered slash-command batch and unblock any + // listCommands() call that is waiting on the initial batch. Used when a + // provider supplies an extensionCommandsParser whose result arrives after + // session/new (e.g. via a vendor extension notification). The ready gate is + // always settled — even for an empty batch — so a provider that legitimately + // reports no commands does not leave listCommands() blocked for the full + // initial-commands timeout. An optional sessionId scopes the batch to this + // session; notifications addressed to a different session are ignored. + private applyResolvedCommands( + commands: AgentSlashCommand[], + options?: { sessionId?: string }, + ): void { + if ( + options?.sessionId !== undefined && + this.sessionId !== null && + options.sessionId !== this.sessionId + ) { + return; + } + + if (commands.length > 0) { + this.cachedCommands = commands; + } + this.settleCommandsReady(); } async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 745b990c2..c1da53e96 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -3,7 +3,11 @@ import { z } from "zod"; import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; -import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js"; +import { + ACPAgentClient, + DEFAULT_ACP_CAPABILITIES, + type ACPExtensionCommandsParser, +} from "./acp-agent.js"; import { buildBinaryDiagnosticRows, formatProviderDiagnostic, @@ -29,6 +33,7 @@ interface GenericACPAgentClientOptions { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number; + extensionCommandsParser?: ACPExtensionCommandsParser; } export class GenericACPAgentClient extends ACPAgentClient { @@ -48,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient { capabilities: buildGenericACPCapabilities(options), waitForInitialCommands: options.waitForInitialCommands, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, + extensionCommandsParser: options.extensionCommandsParser, }); this.command = options.command; diff --git a/packages/server/src/server/agent/providers/kiro-acp-agent.ts b/packages/server/src/server/agent/providers/kiro-acp-agent.ts new file mode 100644 index 000000000..53fd7f240 --- /dev/null +++ b/packages/server/src/server/agent/providers/kiro-acp-agent.ts @@ -0,0 +1,101 @@ +import type { Logger } from "pino"; + +import type { ACPExtensionCommandsParser } from "./acp-agent.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import type { AgentSlashCommand, AgentSlashCommandKind } from "../agent-sdk-types.js"; + +interface KiroACPAgentClientOptions { + logger: Logger; + command: [string, ...string[]]; + env?: Record; + providerId?: string; + label?: string; + providerParams?: unknown; +} + +// Kiro CLI publishes its slash commands and skills asynchronously through the +// `_kiro.dev/commands/available` extension notification shortly after +// `session/new` resolves. Wait for that first batch so listCommands() doesn't +// resolve to an empty list before Kiro has reported its commands. +const KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; + +// ACP extension method (per the `_`-prefixed vendor namespace convention) that +// Kiro CLI uses to publish its slash commands and skills after session/new. +const KIRO_COMMANDS_AVAILABLE_METHOD = "_kiro.dev/commands/available"; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +// Maps a `_kiro.dev/commands/available` payload onto Paseo slash commands. +// Kiro reports built-in slash commands under `commands` (names arrive with a +// leading "/", e.g. "/agent") and skills/prompts under `prompts` (names without +// a slash, tagged with a `skill:` serverName). Paseo stores command names +// without the leading slash — the composer prepends it on insertion. +function mapKiroAvailableCommands(params: Record): AgentSlashCommand[] { + const result: AgentSlashCommand[] = []; + const seen = new Set(); + + const pushEntry = (entry: unknown): void => { + if (!isRecord(entry)) { + return; + } + const rawName = typeof entry.name === "string" ? entry.name.trim() : ""; + const name = rawName.replace(/^\/+/, ""); + if (!name || seen.has(name)) { + return; + } + seen.add(name); + + const description = typeof entry.description === "string" ? entry.description : ""; + const meta = isRecord(entry.meta) ? entry.meta : null; + const argumentHint = meta && typeof meta.hint === "string" ? meta.hint : ""; + const serverName = typeof entry.serverName === "string" ? entry.serverName : ""; + const kind: AgentSlashCommandKind = serverName.startsWith("skill:") ? "skill" : "command"; + + result.push({ name, description, argumentHint, kind }); + }; + + if (Array.isArray(params.commands)) { + for (const entry of params.commands) { + pushEntry(entry); + } + } + if (Array.isArray(params.prompts)) { + for (const entry of params.prompts) { + pushEntry(entry); + } + } + + return result; +} + +// Provider-specific parser injected into the generic ACP session via the +// `extensionCommandsParser` option (mirrors how Cursor/Copilot inject their +// behavior through constructor options). Kiro advertises its slash commands and +// skills through the `_kiro.dev/commands/available` extension notification +// instead of the standard `available_commands_update` session update; this +// recognizes that one method and returns the parsed commands (possibly empty), +// or null for any other notification so the base session ignores it. +export const parseKiroExtensionCommands: ACPExtensionCommandsParser = (method, params) => { + if (method !== KIRO_COMMANDS_AVAILABLE_METHOD) { + return null; + } + return mapKiroAvailableCommands(params); +}; + +export class KiroACPAgentClient extends GenericACPAgentClient { + constructor(options: KiroACPAgentClientOptions) { + super({ + logger: options.logger, + command: options.command, + env: options.env, + providerId: options.providerId, + label: options.label, + providerParams: options.providerParams, + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, + extensionCommandsParser: parseKiroExtensionCommands, + }); + } +}