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:
+1
-1
@@ -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.
|
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.
|
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,
|
AgentRunResult,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
|
AgentSlashCommand,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
ImportProviderSessionInput,
|
ImportProviderSessionInput,
|
||||||
@@ -527,6 +528,205 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
|
|||||||
expect(snapshot.config.modeId).toBe("auto");
|
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 () => {
|
test("createAgent injects daemon append system prompt at runtime only", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ interface PreparedSessionConfig {
|
|||||||
launchConfig: AgentSessionConfig;
|
launchConfig: AgentSessionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizeConfigOptions {
|
||||||
|
resolveDefaultModel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimeoutOptions {
|
interface TimeoutOptions {
|
||||||
operation: Promise<void>;
|
operation: Promise<void>;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
@@ -817,8 +821,11 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
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);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
if (!normalizedConfig.model) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -851,8 +858,11 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
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);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
if (!normalizedConfig.model) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
throw new Error(
|
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 };
|
const normalized: AgentSessionConfig = { ...config };
|
||||||
|
|
||||||
// Always resolve cwd to absolute path for consistent history file lookup
|
// 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;
|
normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized.model) {
|
const shouldResolveDefaultModel = options.resolveDefaultModel ?? true;
|
||||||
const client = this.clients.get(normalized.provider);
|
if (shouldResolveDefaultModel && !normalized.model) {
|
||||||
if (client) {
|
const defaultModelId = await this.resolveDefaultModelId(normalized);
|
||||||
try {
|
if (defaultModelId) {
|
||||||
const catalog = await client.fetchCatalog({ cwd: normalized.cwd, force: false });
|
normalized.model = defaultModelId;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3690,6 +3696,20 @@ export class AgentManager {
|
|||||||
return normalized;
|
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(
|
private async prepareSessionConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -885,6 +885,17 @@ describe("PiRpcAgentClient", () => {
|
|||||||
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
|
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 () => {
|
test("maps extension, prompt, and skill commands to Paseo slash commands", async () => {
|
||||||
const { pi, session } = await createSession();
|
const { pi, session } = await createSession();
|
||||||
pi.latestSession().commands = [
|
pi.latestSession().commands = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
|
type AgentFeature,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -1981,6 +1982,10 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async listImportableSessions(
|
async listImportableSessions(
|
||||||
options?: ListImportableSessionsOptions,
|
options?: ListImportableSessionsOptions,
|
||||||
): Promise<ImportableProviderSession[]> {
|
): Promise<ImportableProviderSession[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user