fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)

* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
This commit is contained in:
Xisheng Parker Zhao
2026-06-30 15:36:48 +08:00
committed by GitHub
parent 7c6152663e
commit 4e28b0941b
5 changed files with 288 additions and 19 deletions

View File

@@ -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;
}

View File

@@ -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<typeof createTestLogger> = 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<ACPSessionInternals>(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 <name>" },
},
],
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 <name>",
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<ACPSessionInternals>(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<ACPSessionInternals>(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 } }> = [];

View File

@@ -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<string, unknown>,
) => AgentSlashCommand[] | null;
interface ACPAgentClientOptions {
provider: string;
logger: Logger;
@@ -356,6 +367,7 @@ interface ACPAgentClientOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
terminateProcess?: ProcessTerminator;
@@ -383,6 +395,7 @@ interface ACPAgentSessionOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
@@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient {
) => Promise<void>;
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 }> {

View File

@@ -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;

View File

@@ -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<string, string>;
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<string, unknown> {
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<string, unknown>): AgentSlashCommand[] {
const result: AgentSlashCommand[] = [];
const seen = new Set<string>();
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,
});
}
}