Speed up new Pi agent startup (#1727)

* fix(providers): avoid draft feature model guessing

* fix(providers): avoid draft command model guessing
This commit is contained in:
Mohamed Boudra
2026-06-25 22:18:58 +08:00
committed by GitHub
parent f12c9e9cfa
commit bdd9419189
5 changed files with 252 additions and 16 deletions
+1 -1
View File
@@ -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.
@@ -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<boolean> {
this.availabilityCalls += 1;
return true;
}
override async fetchCatalog() {
this.fetchCatalogCalls += 1;
return await super.fetchCatalog();
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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<AgentSlashCommand[]> {
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<AgentSession> {
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<boolean> {
this.availabilityCalls += 1;
return true;
}
override async fetchCatalog() {
this.fetchCatalogCalls += 1;
return await super.fetchCatalog();
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.createSessionCalls += 1;
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
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<AgentSession> {
this.createSessionCalls += 1;
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
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");
@@ -87,6 +87,10 @@ interface PreparedSessionConfig {
launchConfig: AgentSessionConfig;
}
interface NormalizeConfigOptions {
resolveDefaultModel?: boolean;
}
interface TimeoutOptions {
operation: Promise<void>;
timeoutMs: number;
@@ -817,8 +821,11 @@ export class AgentManager {
}
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
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<AgentFeature[]> {
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<AgentSessionConfig> {
private async normalizeConfig(
config: AgentSessionConfig,
options: NormalizeConfigOptions = {},
): Promise<AgentSessionConfig> {
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<string | undefined> {
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,
@@ -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 = [
@@ -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<AgentFeature[]> {
return [];
}
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {