Skip to content

Commit 6820420

Browse files
authored
test(core): guard boot work counts for catalog lookups, Azure clones and git spawns (#58892)
1 parent e12fad9 commit 6820420

1 file changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Counts the work a cold Location boot and one session turn do, so a speedup can't silently regress:
2+
// catalog lookups per step (#58601), Azure model edits per model rebuild (#58600) and git spawns (#58602).
3+
// These are exact counts, not timings. Changing one is fine when the change is intended; update the expected
4+
// number here and say why in the PR.
5+
import { describe, expect } from "bun:test"
6+
import path from "path"
7+
import whichPkg from "which"
8+
import { Effect, Layer } from "effect"
9+
import { ChildProcessSpawner } from "effect/process"
10+
import { LanguageModel } from "@opencode/ai/effect"
11+
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
12+
import { TestLLM } from "@opencode/ai/testing"
13+
import { Agent } from "@opencode/core/agent"
14+
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
15+
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
16+
import { Watcher } from "@opencode/core/filesystem/watcher"
17+
import { LocationServiceMap } from "@opencode/core/location-service-map"
18+
import { Model } from "@opencode/core/model"
19+
import { Plugin } from "@opencode/core/plugin"
20+
import { Provider } from "@opencode/core/provider"
21+
import { AbsolutePath } from "@opencode/core/schema"
22+
import { Session } from "@opencode/core/session"
23+
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
24+
import { gitExecutable } from "@opencode/core/util/git-executable"
25+
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
26+
import { LayerNode } from "@opencode/util/effect/layer-node"
27+
import { Global } from "@opencode/util/global"
28+
import { tempGlobalLayer } from "./fixture/global"
29+
import { initRepo } from "./fixture/git"
30+
import { offlineModels } from "./fixture/models"
31+
import { tmpdirScoped } from "./fixture/tmpdir"
32+
import { testEffect } from "./lib/effect"
33+
34+
const counters = { catalogLookups: 0, azureModelEdits: 0, gitSpawns: [] as string[], gitLookups: 0 }
35+
36+
const isGit = (command: string) => path.basename(command).replace(/\.exe$/i, "") === "git"
37+
38+
const countingSpawner = CrossSpawnSpawner.node.replace(
39+
CrossSpawnSpawner.node.mapLayer((base) =>
40+
Layer.effect(
41+
ChildProcessSpawner.ChildProcessSpawner,
42+
Effect.map(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
43+
ChildProcessSpawner.make((command) => {
44+
if (command._tag === "StandardCommand" && isGit(command.command))
45+
counters.gitSpawns.push([command.command, ...command.args].join(" "))
46+
return spawner.spawn(command)
47+
}),
48+
),
49+
).pipe(Layer.provide(base)),
50+
),
51+
)
52+
53+
const isAzure = (provider: Provider.Info) =>
54+
provider.id === Provider.ID.azure || provider.package.startsWith("@opencode/ai/providers/azure/")
55+
56+
// `ctx.model.list()` reads `Model.available()`. Every editor list/get/update clones the models it touches.
57+
const countingModels = Model.node.replace(
58+
Model.node.mapLayer((base) =>
59+
Layer.effect(
60+
Model.Service,
61+
Effect.map(Model.Service, (models) =>
62+
Model.Service.of({
63+
...models,
64+
available: () =>
65+
Effect.suspend(() => {
66+
counters.catalogLookups++
67+
return models.available()
68+
}),
69+
transform: (update) =>
70+
models.transform((editor) => {
71+
const azure = new Set(
72+
editor.provider
73+
.list()
74+
.filter((item) => isAzure(item.provider))
75+
.map((item) => item.provider.id),
76+
)
77+
update({
78+
...editor,
79+
list: (providerID) => {
80+
const result = editor.list(providerID)
81+
counters.azureModelEdits += result.filter((model) => azure.has(model.providerID)).length
82+
return result
83+
},
84+
get: (providerID, modelID) => {
85+
if (azure.has(providerID)) counters.azureModelEdits++
86+
return editor.get(providerID, modelID)
87+
},
88+
update: (providerID, modelID, edit) => {
89+
if (azure.has(providerID)) counters.azureModelEdits++
90+
editor.update(providerID, modelID, edit)
91+
},
92+
})
93+
}),
94+
}),
95+
),
96+
).pipe(Layer.provide(base)),
97+
),
98+
)
99+
100+
// Counts PATH lookups of git: resolving the git executable (macOS and Windows) and finding Git Bash (Windows).
101+
const countingGitLookups = Effect.acquireRelease(
102+
Effect.sync(() => {
103+
const sync = whichPkg.sync
104+
whichPkg.sync = Object.assign((cmd: string, options?: Parameters<typeof sync>[1]) => {
105+
if (cmd === "git") counters.gitLookups++
106+
return sync(cmd, options as never)
107+
}, sync) as typeof sync
108+
return sync
109+
}),
110+
(sync) => Effect.sync(() => void (whichPkg.sync = sync)),
111+
)
112+
113+
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Done", "done") })
114+
const it = testEffect(
115+
Layer.merge(
116+
llmLayer,
117+
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
118+
Global.node.replace(tempGlobalLayer),
119+
offlineModels,
120+
Watcher.node.replace(Watcher.configured({ enabled: false })),
121+
LayerNodePlatform.llmClient.replace(llmLayer),
122+
SessionRunnerModel.node.replace(
123+
Layer.succeed(SessionRunnerModel.Service, {
124+
resolve: () =>
125+
Effect.succeed(
126+
SessionRunnerModel.resolved(
127+
LanguageModel.make({ id: "boot", provider: "test", route: OpenAIChat.route }),
128+
{
129+
capabilities: { tools: true, input: ["text"], output: ["text"] },
130+
cost: [],
131+
limit: { context: 200_000, output: 32_000 },
132+
},
133+
),
134+
),
135+
}),
136+
),
137+
countingSpawner,
138+
countingModels,
139+
]),
140+
),
141+
)
142+
143+
const expected = {
144+
// Folder startup: project identity, VCS branch/remote reads and the Location's repository probe.
145+
"boot: git spawns": 8,
146+
// The step's snapshot: repository probe, snapshot init, diff, untracked files, ignore check, index and tree.
147+
"turn: git spawns": 9,
148+
// Git is resolved once per process, before this test, and every spawn uses that resolution. On Windows the shell
149+
// tool's ShellSelect.gitbash() looks up git once to find Git Bash (cached per process), so one lookup is allowed.
150+
"boot + turn: git lookups beyond one": 0,
151+
"boot + turn: git spawns not using the resolved executable": 0,
152+
// One step resolves the prompt hook model once, shared by the context, compaction and generate hooks.
153+
"turn: catalog lookups per step": 1,
154+
// Azure is enabled without a resource name, so its model transform has nothing to rewrite.
155+
"model rebuild: Azure model edits": 0,
156+
}
157+
158+
describe("boot work", () => {
159+
it.live("counts catalog lookups, Azure model edits and git spawns over a cold boot and one turn", () =>
160+
Effect.gen(function* () {
161+
const tmp = yield* tmpdirScoped()
162+
yield* Effect.promise(() => initRepo(tmp.path))
163+
yield* Effect.promise(() =>
164+
Bun.write(path.join(tmp.path, "opencode.json"), JSON.stringify({ providers: { azure: {} } })),
165+
)
166+
const executable = gitExecutable()
167+
counters.gitSpawns = []
168+
counters.gitLookups = 0
169+
yield* countingGitLookups
170+
171+
const sessions = yield* Session.Service
172+
const locations = yield* LocationServiceMap.Service
173+
const session = yield* sessions.create({
174+
location: { directory: AbsolutePath.make(tmp.path) },
175+
agent: Agent.ID.make("build"),
176+
model: Model.Ref.make({ id: Model.ID.make("boot"), providerID: Provider.ID.make("test") }),
177+
})
178+
const location = locations.get(session.location)
179+
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(location))
180+
const boot = counters.gitSpawns.length
181+
182+
counters.catalogLookups = 0
183+
yield* sessions.prompt({ sessionID: session.id, text: "hello" })
184+
yield* sessions.wait(session.id)
185+
const steps = (yield* sessions.context(session.id)).filter((message) => message.type === "assistant").length
186+
const lookups = counters.catalogLookups
187+
const spawns = [...counters.gitSpawns]
188+
189+
counters.azureModelEdits = 0
190+
yield* Model.Service.use((models) => models.reload().pipe(Effect.andThen(models.available()))).pipe(
191+
Effect.provide(location),
192+
)
193+
194+
const measured: Record<keyof typeof expected, number> = {
195+
"boot: git spawns": boot,
196+
"turn: git spawns": spawns.length - boot,
197+
"boot + turn: git lookups beyond one": Math.max(0, counters.gitLookups - 1),
198+
"boot + turn: git spawns not using the resolved executable": spawns.filter(
199+
(spawn) => !spawn.startsWith(`${executable} `),
200+
).length,
201+
"turn: catalog lookups per step": steps === 0 ? lookups : lookups / steps,
202+
"model rebuild: Azure model edits": counters.azureModelEdits,
203+
}
204+
const drift = Object.entries(expected).flatMap(([name, want]) => {
205+
const got = measured[name as keyof typeof expected]
206+
if (got === want) return []
207+
return [`${name}: expected ${want}, got ${got} (${got > want ? "+" : ""}${got - want})`]
208+
})
209+
const report = drift.length === 0 ? "" : [...drift, "", "git spawns, boot then turn:", ...spawns].join("\n")
210+
expect(steps).toBe(1)
211+
expect(report).toBe("")
212+
}),
213+
)
214+
})

0 commit comments

Comments
 (0)