fix(acp): speed up acp-next warm switches (#29713)
Some checks are pending
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run

This commit is contained in:
Shoubhit Dash 2026-05-28 15:10:17 +05:30 committed by GitHub
parent 2449b50585
commit 9031ce7b51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 175 additions and 25 deletions

View file

@ -83,6 +83,7 @@ export function make(input: {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
@ -149,6 +150,14 @@ export function make(input: {
return snapshot
})
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
sessionSnapshots.set(state.id, loaded)
return loaded
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd)
@ -180,6 +189,7 @@ export function make(input: {
variant,
modeId,
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@ -220,6 +230,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@ -290,6 +301,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@ -307,6 +319,7 @@ export function make(input: {
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
if (!removed) return {}
yield* request(
@ -350,6 +363,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@ -369,7 +383,7 @@ export function make(input: {
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
@ -424,7 +438,7 @@ export function make(input: {
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
@ -434,7 +448,7 @@ export function make(input: {
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(

View file

@ -707,24 +707,35 @@ describe("ACP next service sessions", () => {
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
})
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
let providersCalls = 0
let commandCalls = 0
it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
mcpAdds: 0,
}
const sdk = {
config: {
providers: () => {
providersCalls++
calls.providers++
return 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: [] }),
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
commandCalls++
calls.commands++
return Promise.resolve({ data: [] })
},
},
@ -733,14 +744,16 @@ describe("ACP next service sessions", () => {
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
add: () => {
calls.mcpAdds++
return Promise.resolve({ data: {} })
},
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
await Effect.runPromise(
service.setSessionConfigOption({
@ -750,8 +763,135 @@ describe("ACP next service sessions", () => {
}),
)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
})
it("switches model against the warm provider snapshot without refetching", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
})
it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
const calls = {
providers: 0,
config: 0,
agents: 0,
commands: 0,
skills: 0,
sessionList: 0,
messages: 0,
creates: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => {
calls.config++
return Promise.resolve({ data: {} })
},
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => {
calls.creates++
return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
},
list: () => {
calls.sessionList++
return Promise.resolve({ data: [] })
},
messages: () => {
calls.messages++
return Promise.resolve({ data: [] })
},
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.sessionId).toBe("ses_warm_1")
expect(second.sessionId).toBe("ses_warm_2")
expect(calls).toEqual({
providers: 1,
config: 1,
agents: 1,
commands: 1,
skills: 1,
sessionList: 0,
messages: 0,
creates: 2,
})
})
it("normal text prompt sends model variant mode and converted parts", async () => {

View file

@ -42,7 +42,7 @@ describe("opencode acp-next verifier timing diagnostics", () => {
)
cliIt.live(
"warm new session timing diagnostic stays below generous threshold",
"warm new session stays below verifier threshold",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
@ -57,15 +57,13 @@ describe("opencode acp-next verifier timing diagnostics", () => {
const durationMs = Math.round(performance.now() - started)
expect(session.sessionId).toBeTruthy()
// TODO: replace this diagnostic assertion with finalFastPathThresholdMs.
expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs)
expect(finalFastPathThresholdMs).toBe(100)
expect(durationMs).toBeLessThan(finalFastPathThresholdMs)
}),
60_000,
)
cliIt.live(
"model switch timing diagnostic updates currentValue below generous threshold",
"model switch updates currentValue below verifier threshold",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
@ -89,14 +87,13 @@ describe("opencode acp-next verifier timing diagnostics", () => {
const durationMs = Math.round(performance.now() - started)
expect(expectSelectOption(updated.configOptions, "model").currentValue).toBe(nextModel)
// TODO: replace this diagnostic assertion with finalFastPathThresholdMs.
expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs)
expect(durationMs).toBeLessThan(finalFastPathThresholdMs)
}),
60_000,
)
cliIt.live(
"effort switch timing diagnostic updates currentValue below generous threshold",
"effort switch updates currentValue below verifier threshold",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
@ -118,8 +115,7 @@ describe("opencode acp-next verifier timing diagnostics", () => {
const durationMs = Math.round(performance.now() - started)
expect(expectSelectOption(updated.configOptions, "effort").currentValue).toBe(nextEffort)
// TODO: replace this diagnostic assertion with finalFastPathThresholdMs.
expect(durationMs).toBeLessThan(diagnosticFastPathThresholdMs)
expect(durationMs).toBeLessThan(finalFastPathThresholdMs)
}),
60_000,
)