diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 9b05015e9..4ee09301c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -158,6 +158,95 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("wait:true returns a warm provider without refreshing it", async () => { + const cwd = "/tmp/project"; + const isAvailable = vi.fn(async () => true); + const listModels = vi.fn(async () => [ + { + provider: "codex", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + }, + ]); + const listModes = vi.fn(async () => [] as AgentMode[]); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { isAvailable, listModels, listModes }), + }, + }); + const listener = vi.fn(); + manager.on("change", listener); + try { + const [first] = await manager.listProviders({ cwd, providers: ["codex"], wait: true }); + expect(first).toMatchObject({ provider: "codex", status: "ready" }); + expect(isAvailable).toHaveBeenCalledTimes(1); + expect(listModels).toHaveBeenCalledTimes(1); + expect(listModes).toHaveBeenCalledTimes(1); + + listener.mockClear(); + const [second] = await manager.listProviders({ cwd, providers: ["codex"], wait: true }); + + expect(second).toEqual(first); + expect(isAvailable).toHaveBeenCalledTimes(1); + expect(listModels).toHaveBeenCalledTimes(1); + expect(listModes).toHaveBeenCalledTimes(1); + expect(listener).not.toHaveBeenCalled(); + } finally { + manager.destroy(); + } + }); + + test("explicit refresh re-probes only the requested warm provider", async () => { + const cwd = "/tmp/project"; + const isAvailableCodex = vi.fn(async () => true); + const listCodexModels = vi.fn(async () => [ + { + provider: "codex", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + }, + ]); + const listCodexModes = vi.fn(async () => [] as AgentMode[]); + const isAvailableClaude = vi.fn(async () => true); + const listClaudeModels = vi.fn(async () => [ + { + provider: "claude", + id: "claude-opus-4.5", + label: "Claude Opus 4.5", + }, + ]); + const listClaudeModes = vi.fn(async () => [] as AgentMode[]); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: isAvailableCodex, + listModels: listCodexModels, + listModes: listCodexModes, + }), + claude: createExtraClient("claude", { + isAvailable: isAvailableClaude, + listModels: listClaudeModels, + listModes: listClaudeModes, + }), + }, + }); + try { + await manager.listProviders({ cwd, providers: ["codex", "claude"], wait: true }); + await manager.refreshSnapshotForCwd({ cwd, providers: ["codex"] }); + + expect(isAvailableCodex).toHaveBeenCalledTimes(2); + expect(listCodexModels).toHaveBeenCalledTimes(2); + expect(listCodexModes).toHaveBeenCalledTimes(2); + expect(isAvailableClaude).toHaveBeenCalledTimes(1); + expect(listClaudeModels).toHaveBeenCalledTimes(1); + expect(listClaudeModes).toHaveBeenCalledTimes(1); + } finally { + manager.destroy(); + } + }); + test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { // never-resolving isAvailable forces the timeout path const isAvailable = vi.fn(() => new Promise(() => {})); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 0acbc3fe8..b273f38d9 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -150,31 +150,11 @@ export class ProviderSnapshotManager { getSnapshot(cwd?: string): ProviderSnapshotEntry[] { const resolvedCwd = resolveSnapshotCwd(cwd); - const entries = this.snapshots.get(resolvedCwd); - if (!entries) { - const loadingEntries = this.resetSnapshotToLoading(resolvedCwd); - void this.warmUp(resolvedCwd); - return entriesToArray(loadingEntries); + const providersToWarm = this.resolveProvidersToWarm(resolvedCwd); + if (providersToWarm.length > 0) { + void this.warmUp(resolvedCwd, providersToWarm); } - const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider)); - if (missingProviders.length > 0) { - const loadingEntries = this.createLoadingEntries(); - for (const provider of missingProviders) { - const loadingEntry = loadingEntries.get(provider); - if (loadingEntry) { - entries.set(provider, loadingEntry); - } - } - void this.warmUp(resolvedCwd, missingProviders); - } - const providerLoads = this.providerLoads.get(resolvedCwd); - const loadingProviders = Array.from(entries.values()) - .filter((entry) => entry.status === "loading" && !providerLoads?.has(entry.provider)) - .map((entry) => entry.provider); - if (loadingProviders.length > 0) { - void this.warmUp(resolvedCwd, loadingProviders); - } - return entriesToArray(entries); + return entriesToArray(this.getOrCreateSnapshot(resolvedCwd)); } async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { @@ -205,17 +185,11 @@ export class ProviderSnapshotManager { return; } - const snapshot = this.snapshots.get(snapshotCwd); - if (!snapshot) { - this.resetSnapshotToLoading(snapshotCwd, providers); - } else if (providers) { - const missingProviders = providers.filter((provider) => !snapshot.has(provider)); - if (missingProviders.length > 0) { - this.resetSnapshotToLoading(snapshotCwd, missingProviders); - } + const providersToWarm = this.resolveProvidersToWarm(snapshotCwd, providers); + if (providersToWarm.length === 0) { + return; } - - await this.warmUp(snapshotCwd, providers); + await this.warmUp(snapshotCwd, providersToWarm); } async refresh(options: ProviderSnapshotRefreshOptions): Promise { @@ -520,6 +494,22 @@ export class ProviderSnapshotManager { await this.loadProviders({ cwd, providers, force: true }); } + private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { + const providersToInspect = providers ?? this.getProviderIds(); + const snapshot = this.snapshots.get(cwd); + if (!snapshot) { + this.resetSnapshotToLoading(cwd, providers); + return providersToInspect; + } + + const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider)); + if (missingProviders.length > 0) { + this.resetSnapshotToLoading(cwd, missingProviders); + } + + return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading"); + } + private clearCachedProviders(providers?: AgentProvider[]): void { const providerSet = providers ? new Set(providers) : null; const loadingEntries = this.createLoadingEntries(); @@ -577,6 +567,10 @@ export class ProviderSnapshotManager { if (existingLoad && !options.force) { return existingLoad.promise; } + const existingEntry = this.snapshots.get(options.cwd)?.get(options.provider); + if (existingEntry && existingEntry.status !== "loading" && !options.force) { + return Promise.resolve(); + } const load: ProviderLoad = { promise: Promise.resolve(), diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 1b5c5f1c6..39bf3bbc1 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -18,10 +18,13 @@ interface TestPaseoDaemonOptions { corsAllowedOrigins?: string[]; listen?: string; logger?: Parameters[1]; + mcpEnabled?: boolean; mcpDebug?: boolean; + isDev?: boolean; relayEnabled?: boolean; relayEndpoint?: string; agentClients?: Partial>; + providerOverrides?: PaseoDaemonConfig["providerOverrides"]; paseoHomeRoot?: string; staticDir?: string; cleanup?: boolean; @@ -152,10 +155,12 @@ async function prepareTestDaemonConfig( paseoHome, corsAllowedOrigins: options.corsAllowedOrigins ?? [], hostnames: true, - mcpEnabled: true, + mcpEnabled: options.mcpEnabled ?? true, staticDir, mcpDebug: options.mcpDebug ?? false, + isDev: options.isDev, agentClients: options.agentClients ?? createTestAgentClients(), + providerOverrides: options.providerOverrides, agentStoragePath: path.join(paseoHome, "agents"), relayEnabled: options.relayEnabled ?? false, relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 320e5904c..a790ec216 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -9,6 +9,16 @@ import { createTestLogger } from "../test-utils/test-logger.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentMode, + AgentModelDefinition, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + ListModelsOptions, +} from "./agent/agent-sdk-types.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -18,6 +28,126 @@ const WORKSPACE_A = "wks_same_cwd_a"; const WORKSPACE_B = "wks_same_cwd_b"; const LEGACY_OWNER_WORKSPACE = "wks_legacy_owner"; const PERMISSION_WAIT_MS = 15_000; +const SNAPSHOT_STORM_PROVIDER_COUNT = 18; +const SNAPSHOT_STORM_MODELS_PER_PROVIDER = 150; +const SNAPSHOT_STORM_DESCRIPTION = "x".repeat(120); + +const SNAPSHOT_STORM_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; + +class SnapshotStormProviderClient implements AgentClient { + readonly capabilities = SNAPSHOT_STORM_CAPABILITIES; + private readonly models: AgentModelDefinition[]; + + constructor( + readonly provider: string, + private readonly delayMs: number, + ) { + this.models = Array.from({ length: SNAPSHOT_STORM_MODELS_PER_PROVIDER }, (_, index) => ({ + provider, + id: `${provider}-model-${index.toString().padStart(3, "0")}`, + label: `${provider} model ${index.toString().padStart(3, "0")}`, + description: SNAPSHOT_STORM_DESCRIPTION, + isDefault: index === 0, + metadata: { + providerId: provider, + modelId: `model-${index.toString().padStart(3, "0")}`, + }, + })); + } + + async createSession(_config: AgentSessionConfig): Promise { + throw new Error(`${this.provider} is only used for provider snapshot tests`); + } + + async resumeSession(_handle: AgentPersistenceHandle): Promise { + throw new Error(`${this.provider} is only used for provider snapshot tests`); + } + + async listModels(_options: ListModelsOptions): Promise { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + return this.models; + } + + async listModes(): Promise { + return []; + } + + async isAvailable(): Promise { + return true; + } +} + +class MetadataMockLoadTestAgentClient extends MockLoadTestAgentClient { + override async listModels(_options: ListModelsOptions): Promise { + return [ + { + provider: "mock", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + isDefault: true, + }, + ]; + } +} + +function createSnapshotStormClients(): SnapshotStormProviderClient[] { + return Array.from( + { length: SNAPSHOT_STORM_PROVIDER_COUNT }, + (_, index) => + new SnapshotStormProviderClient(`snapshot-storm-${index.toString().padStart(2, "0")}`, index), + ); +} + +async function createSnapshotStormDaemon(clients: SnapshotStormProviderClient[]) { + return createTestPaseoDaemon({ + mcpEnabled: false, + isDev: true, + agentClients: { + mock: new MetadataMockLoadTestAgentClient(), + ...Object.fromEntries(clients.map((client) => [client.provider, client])), + }, + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + "mock-slow": { enabled: false }, + ...Object.fromEntries( + clients.map((client) => [ + client.provider, + { + extends: "mock", + label: client.provider, + enabled: true, + }, + ]), + ), + }, + }); +} + +function collectProviderSnapshotUpdateBytes(client: DaemonClient): { + sizes: number[]; + unsubscribe: () => void; +} { + const sizes: number[] = []; + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type !== "providers_snapshot_update") { + return; + } + sizes.push(JSON.stringify({ type: "session", message }).length); + }); + return { sizes, unsubscribe }; +} // Seed two active workspaces that share one cwd, so we can prove agent // ownership and status stay workspaceId-scoped — a sibling that owns nothing @@ -234,6 +364,54 @@ test("workspace.create directory source with firstAgentContext generates a daemo } }, 20_000); +test("local workspace auto-title does not broadcast provider snapshot warm-up to clients", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-title-snapshot-storm-")); + const stormClients = createSnapshotStormClients(); + const daemon = await createSnapshotStormDaemon(stormClients); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + const snapshotUpdates = collectProviderSnapshotUpdateBytes(client); + + try { + await client.connect(); + await client.getProvidersSnapshot({ cwd }); + await expect + .poll( + async () => { + const snapshot = await client.getProvidersSnapshot({ cwd }); + return snapshot.entries.filter((entry) => entry.status === "loading").length; + }, + { timeout: 10_000 }, + ) + .toBe(0); + snapshotUpdates.sizes.length = 0; + + const created = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + firstAgentContext: { + prompt: "hello", + attachments: [], + }, + }); + const workspaceId = created.workspace?.id; + if (!workspaceId) { + throw new Error(created.error ?? "Expected workspace to be created"); + } + + await expect.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 }).toBe("hello"); + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(snapshotUpdates.sizes).toEqual([]); + } finally { + snapshotUpdates.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 20_000); + test("create_agent_request with initialPrompt generates a daemon-visible workspace title", async () => { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-submit-title-")); const daemon = await createTestPaseoDaemon({