diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index f4363b62b3..450ec4b969 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; @@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => { ]); expect(queryMock.return).toHaveBeenCalledTimes(1); }); + + test("keeps the Claude control-plane query open until supportedModels resolves", async () => { + const queryMock = createSupportedModelsQueryMock([ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + }, + ] satisfies ModelInfo[]); + let promptIterator: AsyncIterator | null = null; + let promptNextPromise: Promise> | null = null; + let promptClosedBeforeModelsResolved = false; + + queryMock.supportedModels = vi.fn(async () => { + promptNextPromise = promptIterator?.next() ?? null; + if (!promptNextPromise) { + throw new Error("Prompt iterator not captured"); + } + promptNextPromise.then(() => { + promptClosedBeforeModelsResolved = true; + }); + await Promise.resolve(); + expect(promptClosedBeforeModelsResolved).toBe(false); + return [ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + }, + ] satisfies ModelInfo[]; + }); + + const queryFactory = vi.fn(({ prompt }) => { + promptIterator = prompt[Symbol.asyncIterator](); + return queryMock; + }); + const client = new ClaudeAgentClient({ + logger, + queryFactory: queryFactory as never, + }); + + const models = await client.listModels({ cwd: process.cwd() }); + + expect(models).toHaveLength(1); + expect(promptNextPromise).not.toBeNull(); + await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined }); + expect(queryMock.return).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e38b2379a2..6a13d5bf67 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -225,10 +225,6 @@ function applyRuntimeSettingsToClaudeOptions( }; } -function createEmptyClaudePrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort { return value === "low" || value === "medium" || value === "high" || value === "max"; } @@ -1032,8 +1028,9 @@ export class ClaudeAgentClient implements AgentClient { } async listModels(options?: ListModelsOptions): Promise { + const input = createAsyncMessageInput(); const claudeQuery = this.queryFactory({ - prompt: createEmptyClaudePrompt(), + prompt: input.iterable, options: applyRuntimeSettingsToClaudeOptions( { cwd: options?.cwd ?? process.cwd(), @@ -1052,13 +1049,13 @@ export class ClaudeAgentClient implements AgentClient { this.logger.warn({ err: error }, "Failed to query Claude supportedModels()"); throw error; } finally { + input.end(); try { await claudeQuery.return?.(); } catch { // ignore control-plane shutdown errors } } - } async listPersistedAgents(