فهرست منبع

fix(acp): improve acp-next first-session startup (#29709)

Shoubhit Dash 3 ماه پیش
والد
کامیت
2449b50585

+ 42 - 0
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<string, string | number | boolean | undefined>) {
+  if (!enabled) return
+  write(`${name}.mark`, performance.now() - started, fields)
+}
+
+export function duration(
+  name: string,
+  startedAt: number,
+  fields?: Record<string, string | number | boolean | undefined>,
+) {
+  if (!enabled) return
+  write(name, performance.now() - startedAt, fields)
+}
+
+export async function measure<T>(
+  name: string,
+  fn: () => Promise<T>,
+  fields?: Record<string, string | number | boolean | undefined>,
+) {
+  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<string, string | number | boolean | undefined>) {
+  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"

+ 92 - 82
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<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
   })
 }
 
+function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
+  return request(() => ACPNextProfile.measure(name, fn), service)
+}
+
 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[]
-
-  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 } : {}),
+  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<ProviderID, Provider.Info>,
-): Promise<Directory.DefaultModel | undefined> {
-  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<ProviderID, Provider.Info>,
-): Promise<Directory.DefaultModel | undefined> {
-  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<string>()
   registered.set(sessionId, current)
   const pending = new Set<string>()
@@ -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<typeof mcpConfig>) {

+ 6 - 1
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)
 

+ 40 - 0
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: [] }))

+ 1 - 1
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.