diff --git a/docs/providers.md b/docs/providers.md index 8f8ff6d7d..18db4eb7e 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -40,7 +40,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe. -Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. +Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index a8da1c141..02311cdb3 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -29,6 +29,7 @@ import type { AgentRunResult, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, ImportProviderSessionInput, @@ -527,6 +528,205 @@ test("normalizeConfig strips legacy 'default' model id", async () => { expect(snapshot.config.modeId).toBe("auto"); }); +test("listDraftCommands returns no commands without guessing a missing model", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-commands-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + class DraftCommandClient extends TestAgentClient { + fetchCatalogCalls = 0; + createSessionCalls = 0; + availabilityCalls = 0; + + override async isAvailable(): Promise { + this.availabilityCalls += 1; + return true; + } + + override async fetchCatalog() { + this.fetchCatalogCalls += 1; + return await super.fetchCatalog(); + } + + override async createSession(config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return await super.createSession(config); + } + } + const client = new DraftCommandClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + + await expect(manager.listDraftCommands({ provider: "codex", cwd: workdir })).resolves.toEqual([]); + + expect(client.fetchCatalogCalls).toBe(0); + expect(client.createSessionCalls).toBe(0); + expect(client.availabilityCalls).toBe(0); +}); + +test("listDraftCommands uses explicit model config without default model fetching", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-commands-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const draftCommand: AgentSlashCommand = { + name: "review", + description: "Review changes", + argumentHint: "", + kind: "command", + }; + class DraftCommandSession extends TestAgentSession { + override async listCommands(): Promise { + return [draftCommand]; + } + } + class DraftCommandClient extends TestAgentClient { + fetchCatalogCalls = 0; + createSessionCalls = 0; + readonly commandConfigs: AgentSessionConfig[] = []; + + override async fetchCatalog() { + this.fetchCatalogCalls += 1; + return await super.fetchCatalog(); + } + + override async createSession(config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + this.commandConfigs.push(config); + return new DraftCommandSession(config); + } + } + const client = new DraftCommandClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + + const commands = await manager.listDraftCommands({ + provider: "codex", + cwd: workdir, + model: "gpt-5.4", + }); + + expect(commands).toEqual([draftCommand]); + expect(client.fetchCatalogCalls).toBe(0); + expect(client.createSessionCalls).toBe(1); + expect(client.commandConfigs).toEqual([ + { + provider: "codex", + cwd: workdir, + model: "gpt-5.4", + modeId: "auto", + }, + ]); +}); + +test("listDraftFeatures returns no features without guessing a missing model", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + class DraftFeatureClient extends TestAgentClient { + fetchCatalogCalls = 0; + createSessionCalls = 0; + availabilityCalls = 0; + readonly featureConfigs: AgentSessionConfig[] = []; + + override async isAvailable(): Promise { + this.availabilityCalls += 1; + return true; + } + + override async fetchCatalog() { + this.fetchCatalogCalls += 1; + return await super.fetchCatalog(); + } + + override async createSession(config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return await super.createSession(config); + } + + async listFeatures(config: AgentSessionConfig): Promise { + this.featureConfigs.push(config); + return [createFeature({ id: "fast_mode", label: "Fast mode", value: false })]; + } + } + const client = new DraftFeatureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + + await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]); + + expect(client.fetchCatalogCalls).toBe(0); + expect(client.createSessionCalls).toBe(0); + expect(client.availabilityCalls).toBe(0); + expect(client.featureConfigs).toEqual([]); +}); + +test("listDraftFeatures uses explicit model config without default model fetching", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false }); + class DraftFeatureClient extends TestAgentClient { + fetchCatalogCalls = 0; + createSessionCalls = 0; + readonly featureConfigs: AgentSessionConfig[] = []; + + override async fetchCatalog() { + this.fetchCatalogCalls += 1; + return await super.fetchCatalog(); + } + + override async createSession(config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return await super.createSession(config); + } + + async listFeatures(config: AgentSessionConfig): Promise { + this.featureConfigs.push(config); + return [draftFeature]; + } + } + const client = new DraftFeatureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + + const features = await manager.listDraftFeatures({ + provider: "codex", + cwd: workdir, + model: "gpt-5.4", + }); + + expect(features).toEqual([draftFeature]); + expect(client.fetchCatalogCalls).toBe(0); + expect(client.createSessionCalls).toBe(0); + expect(client.featureConfigs).toEqual([ + { + provider: "codex", + cwd: workdir, + model: "gpt-5.4", + modeId: "auto", + }, + ]); +}); + test("createAgent injects daemon append system prompt at runtime only", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fcb9bd099..7d6b6016b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -87,6 +87,10 @@ interface PreparedSessionConfig { launchConfig: AgentSessionConfig; } +interface NormalizeConfigOptions { + resolveDefaultModel?: boolean; +} + interface TimeoutOptions { operation: Promise; timeoutMs: number; @@ -817,8 +821,11 @@ export class AgentManager { } async listDraftCommands(config: AgentSessionConfig): Promise { - const normalizedConfig = await this.normalizeConfig(config); + const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false }); const client = this.requireClient(normalizedConfig.provider); + if (!normalizedConfig.model) { + return []; + } const available = await client.isAvailable(); if (!available) { throw new Error( @@ -851,8 +858,11 @@ export class AgentManager { } async listDraftFeatures(config: AgentSessionConfig): Promise { - const normalizedConfig = await this.normalizeConfig(config); + const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false }); const client = this.requireClient(normalizedConfig.provider); + if (!normalizedConfig.model) { + return []; + } const available = await client.isAvailable(); if (!available) { throw new Error( @@ -3632,7 +3642,10 @@ export class AgentManager { } } - private async normalizeConfig(config: AgentSessionConfig): Promise { + private async normalizeConfig( + config: AgentSessionConfig, + options: NormalizeConfigOptions = {}, + ): Promise { const normalized: AgentSessionConfig = { ...config }; // Always resolve cwd to absolute path for consistent history file lookup @@ -3663,18 +3676,11 @@ export class AgentManager { normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined; } - if (!normalized.model) { - const client = this.clients.get(normalized.provider); - if (client) { - try { - const catalog = await client.fetchCatalog({ cwd: normalized.cwd, force: false }); - const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0]; - if (defaultModel) { - normalized.model = defaultModel.id; - } - } catch { - // Provider may not support model listing — leave model undefined - } + const shouldResolveDefaultModel = options.resolveDefaultModel ?? true; + if (shouldResolveDefaultModel && !normalized.model) { + const defaultModelId = await this.resolveDefaultModelId(normalized); + if (defaultModelId) { + normalized.model = defaultModelId; } } @@ -3690,6 +3696,20 @@ export class AgentManager { return normalized; } + private async resolveDefaultModelId(config: AgentSessionConfig): Promise { + const client = this.clients.get(config.provider); + if (!client) { + return undefined; + } + try { + const catalog = await client.fetchCatalog({ cwd: config.cwd, force: false }); + return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id; + } catch { + // Provider may not support model listing — leave model undefined. + return undefined; + } + } + private async prepareSessionConfig( config: AgentSessionConfig, agentId: string, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 942bf1d14..e24bf2901 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -885,6 +885,17 @@ describe("PiRpcAgentClient", () => { expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" }); }); + test("lists no draft features without starting a Pi session", async () => { + const pi = new FakePi(); + const client = createClient(pi); + + await expect( + client.listFeatures(createConfig({ model: "openrouter/test/model" })), + ).resolves.toEqual([]); + + expect(pi.recordedLaunches).toHaveLength(0); + }); + test("maps extension, prompt, and skill commands to Paseo slash commands", async () => { const { pi, session } = await createSession(); pi.latestSession().commands = [ diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 07d83d9dd..381c49fb3 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { type AgentCapabilityFlags, type AgentClient, + type AgentFeature, type AgentLaunchContext, type AgentMetadata, type AgentMode, @@ -1981,6 +1982,10 @@ export class PiRpcAgentClient implements AgentClient { } } + async listFeatures(_config: AgentSessionConfig): Promise { + return []; + } + async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise {