mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
List a generic ACP agent's slash commands in a new agent's / menu (#5411)
* List a generic ACP agent's slash commands in a new agent's / menu Generic ACP agents advertise slash commands with available_commands_update after session/new. The new-agent / menu opens a short-lived session and asks for its commands right after session/new, so it always got an empty list, which the app keeps for the draft. Wait for the first batch, bounded by the existing initial-commands timeout, as the Cursor, Kiro, and TRAE clients do. Closes #4759 * Keep the generic ACP command test independent of scheduling * Wait for agent state writes before removing MCP test temp dirs The blocking send_agent_prompt timeout test removed its temp dir while AgentManager background tasks and AgentStorage writes were still in flight, which intermittently failed cleanup with ENOTEMPTY on Linux CI. Flush both before removing the dir in the tests that use real storage. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync, rmSync } from "node:fs";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -192,6 +192,16 @@ async function removeTempDir(path: string): Promise<void> {
|
||||
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
async function removeAgentStateDir(
|
||||
agentManager: AgentManager,
|
||||
storage: AgentStorage,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await agentManager.flush();
|
||||
await storage.flush();
|
||||
await removeTempDir(path);
|
||||
}
|
||||
|
||||
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
|
||||
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
|
||||
|
||||
@@ -3420,7 +3430,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(storedChild?.workspaceId).toBe("wks_parent");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4012,7 +4022,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
it("notifies the caller once when it prompts a created child that is still running", async () => {
|
||||
@@ -4072,7 +4082,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(finishNotifications()).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient slash commands", () => {
|
||||
test("lists commands an agent advertises right after session/new", async () => {
|
||||
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
// Long enough that the update always lands inside the wait, however slowly
|
||||
// the fake agent is scheduled.
|
||||
initialCommandsWaitTimeoutMs: 60_000,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([
|
||||
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("answers with no commands for an agent that never advertises any", async () => {
|
||||
await withFakeACPAgent("silent", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
initialCommandsWaitTimeoutMs: 50,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withFakeACPAgent(
|
||||
mode: "commands-after-session-new" | "silent",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-commands-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run([process.execPath, scriptPath, mode], testDir);
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
|
||||
const mode = process.argv[2];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
write({
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||
agentCapabilities: { sessionCapabilities: { close: {} } },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "session/new") {
|
||||
write({ id: message.id, result: { sessionId: "session-1" } });
|
||||
if (mode === "commands-after-session-new") {
|
||||
setTimeout(() => {
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "Review the diff" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
write({ id: message.id, result: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -62,6 +62,7 @@ describe("GenericACPAgentClient", () => {
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
waitForInitialCommands: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,9 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
},
|
||||
defaultCommand: options.command,
|
||||
capabilities: buildGenericACPCapabilities(providerParams),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
// ACP agents advertise slash commands with available_commands_update after
|
||||
// session/new, so the first listCommands() waits for that batch.
|
||||
waitForInitialCommands: options.waitForInitialCommands ?? true,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
clientCapabilities: providerParams.clientCapabilities,
|
||||
clientCapabilityMeta: options.clientCapabilityMeta,
|
||||
|
||||
Reference in New Issue
Block a user