From 2449b505856d9af090564419106ed0e3dc76fde5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 14:53:47 +0530 Subject: [PATCH] fix(acp): improve acp-next first-session startup (#29709) --- packages/opencode/src/acp-next/profile.ts | 42 +++++ packages/opencode/src/acp-next/service.ts | 174 +++++++++--------- packages/opencode/src/cli/cmd/acp.ts | 7 +- .../test/acp-next/service-session.test.ts | 40 ++++ .../opencode/test/cli/acp-next/helpers.ts | 2 +- 5 files changed, 181 insertions(+), 84 deletions(-) create mode 100644 packages/opencode/src/acp-next/profile.ts diff --git a/packages/opencode/src/acp-next/profile.ts b/packages/opencode/src/acp-next/profile.ts new file mode 100644 index 0000000000..4b79d6e903 --- /dev/null +++ b/packages/opencode/src/acp-next/profile.ts @@ -0,0 +1,42 @@ +const enabled = process.env.OPENCODE_ACP_PROFILE === "1" +const started = performance.now() + +export function mark(name: string, fields?: Record) { + if (!enabled) return + write(`${name}.mark`, performance.now() - started, fields) +} + +export function duration( + name: string, + startedAt: number, + fields?: Record, +) { + if (!enabled) return + write(name, performance.now() - startedAt, fields) +} + +export async function measure( + name: string, + fn: () => Promise, + fields?: Record, +) { + if (!enabled) return fn() + const start = performance.now() + try { + return await fn() + } finally { + write(name, performance.now() - start, fields) + } +} + +function write(name: string, durationMs: number, fields?: Record) { + const extra = fields + ? Object.entries(fields) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + : "" + console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`) +} + +export * as ACPNextProfile from "./profile" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 04cf71fff8..0e0d0f8352 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -40,6 +40,7 @@ import { Directory } from "./directory" import { ACPNextEvent } from "./event" import { ACPNextSession } from "./session" import { UsageService } from "./usage" +import { ACPNextProfile } from "./profile" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -88,6 +89,7 @@ export function make(input: { if (events) input.eventSubscription?.(events) const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { + const started = performance.now() const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -104,7 +106,7 @@ export function make(input: { } } - return { + const response = { protocolVersion: 1, agentCapabilities: { loadSession: true, @@ -129,6 +131,8 @@ export function make(input: { version: InstallationVersion, }, } + ACPNextProfile.duration("acp.initialize", started) + return response }) const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) { @@ -139,15 +143,20 @@ export function make(input: { }) const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { - return yield* directoryService.get(cwd) + const started = performance.now() + const snapshot = yield* directoryService.get(cwd) + ACPNextProfile.duration("acp.directory.snapshot", started) + return snapshot }) const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { + const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) const selected = selectDefaultModel(snapshot) const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined - const created = yield* request( + const created = yield* profiledRequest( + "acp.newSession.session.create", () => input.sdk.session.create( { @@ -175,7 +184,7 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) - return { + const response = { sessionId: state.id, configOptions: configOptions(snapshot, { model: state.model ?? selected, @@ -183,6 +192,8 @@ export function make(input: { modeId: state.modeId, }), } + ACPNextProfile.duration("acp.newSession", started) + return response }) const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) { @@ -684,66 +695,79 @@ function request(fn: () => Promise>, service?: string) { }) } -async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { - const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([ - sdk.config.providers({ directory }, { throwOnError: true }), - sdk.app.agents({ directory }, { throwOnError: true }), - sdk.command.list({ directory }, { throwOnError: true }), - sdk.app.skills({ directory }, { throwOnError: true }), - ]) - const providersData = providersResponse.data! - const agents = agentsResponse.data! - const commandsData = commandsResponse.data! - const skills = skillsResponse.data! - const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< - ProviderID, - Provider.Info - > - const defaultModel = await defaultModelFromSdk(sdk, directory, providers) - const modes = agents - .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) - .map((agent) => ({ - id: agent.name, - name: agent.name, - ...(agent.description ? { description: agent.description } : {}), - })) - const commands = [ - ...commandsData, - ...skills - .filter((skill) => !commandsData.some((command) => command.name === skill.name)) - .map((skill) => ({ - name: skill.name, - description: skill.description, - source: "skill" as const, - template: skill.content, - hints: [], - })), - ] as Command.Info[] +function profiledRequest(name: string, fn: () => Promise>, service?: string) { + return request(() => ACPNextProfile.measure(name, fn), service) +} - return Directory.build({ - directory, - providers, - modes, - defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", - commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), - ...(defaultModel ? { defaultModel } : {}), +async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { + return ACPNextProfile.measure("acp.directory.load", async () => { + const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ + ACPNextProfile.measure("acp.directory.provider.list", () => + sdk.config.providers({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () => + sdk.app.agents({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.command.list", () => + sdk.command.list({ directory }, { throwOnError: true }), + ), + ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPNextProfile.measure("acp.directory.defaultModel.config", () => + sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), + ), + ]) + const providersData = providersResponse.data! + const agents = agentsResponse.data! + const commandsData = commandsResponse.data! + const skills = skillsResponse.data! + const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< + ProviderID, + Provider.Info + > + const defaultModelStarted = performance.now() + const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) + ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + const modes = agents + .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })) + const commands = [ + ...commandsData, + ...skills + .filter((skill) => !commandsData.some((command) => command.name === skill.name)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + source: "skill" as const, + template: skill.content, + hints: [], + })), + ] as Command.Info[] + + return Directory.build({ + directory, + providers, + modes, + defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel ? { defaultModel } : {}), + }) }) } -async function defaultModelFromSdk( - sdk: OpencodeClient, - directory: string, +function defaultModelFromConfig( + configuredModel: string | undefined, providers: Record, -): Promise { - const configured = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined)) - .catch(() => undefined) +): Directory.DefaultModel | undefined { + const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - + // First-session ACP startup must not scan historical sessions just to infer + // a default. Configured model, opencode provider, then sorted best model keep + // the protocol response deterministic without extra session/message reads. const opencodeProvider = providers[ProviderID.make("opencode")] const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } @@ -753,30 +777,6 @@ async function defaultModelFromSdk( if (configured) return configured } -async function lastUsedModel( - sdk: OpencodeClient, - directory: string, - providers: Record, -): Promise { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((response) => response.data?.[0]) - .catch(() => undefined) - if (!session) return - - const lastUser = await sdk.session - .messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true }) - .then((response) => response.data?.findLast((message) => message.info.role === "user")?.info) - .catch(() => undefined) - if (lastUser?.role !== "user") return - if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return - - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel const model = snapshot.modelOptions[0] @@ -891,6 +891,7 @@ function registerMcpServers( sessionId: string, servers: readonly McpServer[], ) { + const started = performance.now() const current = registered.get(sessionId) ?? new Set() registered.set(sessionId, current) const pending = new Set() @@ -922,7 +923,16 @@ function registerMcpServers( ), ), { concurrency: "unbounded" }, - ).pipe(Effect.asVoid) + ).pipe( + Effect.tap(() => + Effect.sync(() => + ACPNextProfile.duration("acp.mcp.register", started, { + count: pending.size, + }), + ), + ), + Effect.asVoid, + ) } function mcpRegistrationKey(name: string, config: ReturnType) { diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index b113a278f9..35abfec7e5 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ACPNextProfile } from "@/acp-next/profile" const log = Log.create({ service: "acp-command" }) @@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({ }) }, handler: Effect.fn("Cli.acp")(function* (args) { + ACPNextProfile.mark("cli.acp.handler") process.env.OPENCODE_CLIENT = "acp" const flags = yield* RuntimeFlags.Service const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => Server.listen(opts)) + const server = yield* Effect.promise(() => + ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)), + ) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, @@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({ const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk }) new AgentSideConnection((conn) => { + ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext }) return agent.create(conn, { sdk }) }, stream) diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 4c0563b63e..2ea31a96c8 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -602,6 +602,46 @@ describe("ACP next service sessions", () => { expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model") }) + it("does not scan last-used sessions when resolving the new session default", async () => { + const historyCalls: string[] = [] + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }), + list: () => { + historyCalls.push("list") + return Promise.resolve({ data: [{ id: "ses_recent" }] }) + }, + messages: () => { + historyCalls.push("messages") + return Promise.resolve({ + data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }], + }) + }, + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(result.sessionId).toBe("test-model") + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model") + expect(historyCalls).toEqual([]) + }) + it("switches model and returns updated model and effort options", async () => { const { service } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp-next/helpers.ts index 4bd1bb9e97..9de5f37129 100644 --- a/packages/opencode/test/cli/acp-next/helpers.ts +++ b/packages/opencode/test/cli/acp-next/helpers.ts @@ -11,7 +11,7 @@ import { type AcpClient, } from "../acp/acp-test-client" -export const diagnosticFirstSessionThresholdMs = 15_000 +export const diagnosticFirstSessionThresholdMs = 5_000 export const diagnosticFastPathThresholdMs = 15_000 // TODO: tighten to the public verifier target of 500ms once acp-next startup is optimized.