mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
committed by
GitHub
parent
7c6152663e
commit
4e28b0941b
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } }> = [];
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
101
packages/server/src/server/agent/providers/kiro-acp-agent.ts
Normal file
101
packages/server/src/server/agent/providers/kiro-acp-agent.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user