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 b686497b5..a5bad22f5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -415,10 +415,39 @@ describe("transformPiModels", () => { }); describe("ACPAgentSession slash commands", () => { - test("caches ACP available commands for listCommands", async () => { + test("returns immediately for ACP sessions that do not wait for async command discovery", async () => { const session = createSession(); - expect(await session.listCommands()).toEqual([]); + await expect(session.listCommands()).resolves.toEqual([]); + }); + + test("waits for async available_commands_update when enabled", async () => { + const session = new ACPAgentSession( + { + provider: "pi", + cwd: "/tmp/paseo-acp-test", + }, + { + provider: "pi", + logger: createTestLogger(), + defaultCommand: ["pi-acp"], + defaultModes: [], + modelTransformer: transformPiModels, + sessionResponseTransformer: transformPiSessionResponse, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 1500, + }, + ); + + const listCommandsPromise = session.listCommands(); (session as any).translateSessionUpdate({ sessionUpdate: "available_commands_update", @@ -434,6 +463,19 @@ describe("ACPAgentSession slash commands", () => { ], }); + expect(await listCommandsPromise).toEqual([ + { + name: "research_codebase", + description: "Search the workspace for relevant files", + argumentHint: "", + }, + { + name: "create_plan", + description: "Draft a plan for the requested work", + argumentHint: "", + }, + ]); + expect(await session.listCommands()).toEqual([ { name: "research_codebase", diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index f0a90b0ac..83802b2b3 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -125,6 +125,8 @@ type ACPAgentClientOptions = { thinkingOptionId: string, ) => Promise; capabilities?: AgentCapabilityFlags; + waitForInitialCommands?: boolean; + initialCommandsWaitTimeoutMs?: number; }; type ACPAgentSessionOptions = { @@ -144,6 +146,8 @@ type ACPAgentSessionOptions = { capabilities: AgentCapabilityFlags; handle?: AgentPersistenceHandle; launchEnv?: Record; + waitForInitialCommands?: boolean; + initialCommandsWaitTimeoutMs?: number; }; type SpawnedACPProcess = { @@ -302,6 +306,8 @@ export class ACPAgentClient implements AgentClient { sessionId: string, thinkingOptionId: string, ) => Promise; + private readonly waitForInitialCommands: boolean; + private readonly initialCommandsWaitTimeoutMs: number; constructor(options: ACPAgentClientOptions) { this.provider = options.provider; @@ -314,6 +320,8 @@ export class ACPAgentClient implements AgentClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.thinkingOptionWriter = options.thinkingOptionWriter; + this.waitForInitialCommands = options.waitForInitialCommands ?? false; + this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; } async createSession( @@ -335,6 +343,8 @@ export class ACPAgentClient implements AgentClient { thinkingOptionWriter: this.thinkingOptionWriter, capabilities: this.capabilities, launchEnv: launchContext?.env, + waitForInitialCommands: this.waitForInitialCommands, + initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }, ); await session.initializeNewSession(); @@ -375,6 +385,8 @@ export class ACPAgentClient implements AgentClient { capabilities: this.capabilities, handle, launchEnv: launchContext?.env, + waitForInitialCommands: this.waitForInitialCommands, + initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, }); await session.initializeResumedSession(); return session; @@ -617,6 +629,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; private cachedCommands: AgentSlashCommand[] = []; + private commandsReadyDeferred: { promise: Promise; resolve: () => void } | null = null; + private commandsReadySettled = false; + private waitForInitialCommands: boolean; + private initialCommandsWaitTimeoutMs: number; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; private closed = false; @@ -645,6 +661,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.currentModel = config.model ?? null; this.thinkingOptionId = config.thinkingOptionId ?? null; this.currentTitle = config.title ?? null; + this.waitForInitialCommands = options.waitForInitialCommands ?? false; + this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; } get id(): string | null { @@ -876,7 +894,59 @@ export class ACPAgentSession implements AgentSession, ACPClient { return this.currentMode; } + private ensureCommandsReadyDeferred(): void { + if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) { + return; + } + + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + this.commandsReadyDeferred = { promise, resolve }; + } + + private settleCommandsReady(): void { + if (this.commandsReadySettled) { + return; + } + this.commandsReadySettled = true; + this.commandsReadyDeferred?.resolve(); + this.commandsReadyDeferred = null; + } + + private async waitForCommandsReady(): Promise { + const deferred = this.commandsReadyDeferred; + if (!deferred) { + return; + } + + let timer: ReturnType | null = null; + try { + await Promise.race([ + deferred.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + async listCommands(): Promise { + if (this.cachedCommands.length > 0) { + return this.cachedCommands; + } + if (!this.waitForInitialCommands || this.closed) { + return this.cachedCommands; + } + + this.ensureCommandsReadyDeferred(); + await this.waitForCommandsReady(); + this.settleCommandsReady(); return this.cachedCommands; } @@ -1046,6 +1116,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { } this.closed = true; + this.settleCommandsReady(); + for (const pending of this.pendingPermissions.values()) { pending.resolve({ outcome: { outcome: "cancelled" } }); } @@ -1392,6 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { description: command.description, argumentHint: "", })); + this.settleCommandsReady(); return []; default: return []; diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index 94d24f4d2..7d3d9588d 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -298,6 +298,8 @@ export class PiACPAgentClient extends ACPAgentClient { await connection.setSessionMode({ sessionId, modeId: thinkingOptionId }); }, capabilities: PI_CAPABILITIES, + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: 1500, }); }