mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
The auth mode probes every protected route with valid credentials to prove the route accepts them. Two routes intentionally block after a valid request: tui.control.next waits for queued TUI input, and instance.reload can outlive the one second probe. The probe won by timeout, marked the scenario passed, and left the request alive inside the cached web handler. Final disposeApps() then waited for those requests forever, so any run that rebuilt the app (cold turbo cache) never exited and CI cancelled the job at 15 minutes. Add a scenario flag that keeps the missing-credentials check (the route must still return 401) but skips the valid-credential probe for routes whose valid requests intentionally block, and mark tui.control.next and instance.reload with it.
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { Cause, Duration, Effect, Layer, Scope } from "effect"
|
|
import { TestLLMServer } from "../../lib/llm-server"
|
|
import type { Config } from "../../../src/config/config"
|
|
|
|
import type { MessageV2 } from "../../../src/session/message-v2"
|
|
import { MessageID, PartID } from "../../../src/session/schema"
|
|
import { call, callAuthProbe, disposeApps } from "./backend"
|
|
import { original } from "./environment"
|
|
import { runtime } from "./runtime"
|
|
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
|
|
export function runScenario(options: Options) {
|
|
return (scenario: Scenario) => {
|
|
if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
|
|
return runActive(options, scenario).pipe(
|
|
Effect.timeoutOrElse({
|
|
duration: options.scenarioTimeout,
|
|
orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)),
|
|
}),
|
|
Effect.as({ status: "pass", scenario } as Result),
|
|
Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
|
|
Effect.scoped,
|
|
)
|
|
}
|
|
}
|
|
|
|
function runActive(options: Options, scenario: ActiveScenario) {
|
|
if (options.mode === "auth") return runAuth(scenario)
|
|
|
|
return withContext(options, scenario, "shared", (ctx) =>
|
|
Effect.gen(function* () {
|
|
yield* trace(options, scenario, "request start")
|
|
const result = yield* call(scenario, ctx)
|
|
yield* trace(options, scenario, `response ${result.status}`)
|
|
yield* trace(options, scenario, "expect start")
|
|
yield* scenario.expect(ctx, ctx.state, result)
|
|
yield* trace(options, scenario, "expect done")
|
|
}),
|
|
)
|
|
}
|
|
|
|
function runAuth(scenario: ActiveScenario) {
|
|
return Effect.gen(function* () {
|
|
const result = yield* callAuthProbe(scenario, "missing")
|
|
if (scenario.auth === "protected") {
|
|
if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`)
|
|
if (!scenario.validAuthProbe) return // kilocode_change - blocking routes skip the valid probe; a leaked valid request hangs final app disposal
|
|
const authed = yield* callAuthProbe(scenario, "valid")
|
|
if (authed.status === 401) throw new Error("auth rejected valid credentials")
|
|
return
|
|
}
|
|
|
|
if (result.status === 401) throw new Error("auth expected public access, got 401")
|
|
if (result.timedOut) throw new Error("auth expected public access, probe timed out")
|
|
})
|
|
}
|
|
|
|
function withContext<A, E>(
|
|
options: Options,
|
|
scenario: ActiveScenario,
|
|
label: string,
|
|
use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>,
|
|
) {
|
|
return Effect.acquireRelease(
|
|
Effect.gen(function* () {
|
|
yield* trace(options, scenario, `${label} context acquire start`)
|
|
const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
|
|
const project = scenario.project
|
|
const dir = project
|
|
? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
|
|
: undefined
|
|
yield* trace(options, scenario, `${label} context acquire done`)
|
|
return { dir, llm }
|
|
}),
|
|
(ctx) =>
|
|
Effect.gen(function* () {
|
|
yield* trace(options, scenario, `${label} tmpdir cleanup start`)
|
|
yield* Effect.promise(async () => {
|
|
await ctx.dir?.[Symbol.asyncDispose]()
|
|
}).pipe(Effect.ignore)
|
|
yield* trace(options, scenario, `${label} tmpdir cleanup done`)
|
|
}),
|
|
).pipe(
|
|
Effect.flatMap((context) =>
|
|
Effect.gen(function* () {
|
|
yield* trace(options, scenario, `${label} runtime start`)
|
|
const modules = yield* Effect.promise(() => runtime())
|
|
const scope = yield* Scope.Scope
|
|
const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
|
|
yield* trace(options, scenario, `${label} runtime done`)
|
|
const path = context.dir?.path
|
|
const instance = path
|
|
? yield* trace(options, scenario, `${label} instance load start`).pipe(
|
|
Effect.andThen(
|
|
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
|
Effect.provide(app),
|
|
Effect.catchCause((cause) =>
|
|
Effect.sleep("100 millis").pipe(
|
|
Effect.andThen(
|
|
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
|
Effect.provide(app),
|
|
),
|
|
),
|
|
Effect.catchCause(() => Effect.failCause(cause)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Effect.tap(() => trace(options, scenario, `${label} instance load done`)),
|
|
)
|
|
: undefined
|
|
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
|
|
const directory = () => {
|
|
if (!context.dir?.path) throw new Error("scenario needs a project directory")
|
|
return context.dir.path
|
|
}
|
|
const llm = () => {
|
|
if (!context.llm) throw new Error("scenario needs fake LLM")
|
|
return context.llm
|
|
}
|
|
const base: ScenarioContext = {
|
|
directory: context.dir?.path,
|
|
headers: (extra) => ({
|
|
...(context.dir?.path ? { "x-kilo-directory": context.dir.path } : {}),
|
|
...extra,
|
|
}),
|
|
file: (name, content) =>
|
|
Effect.promise(() => {
|
|
return Bun.write(`${directory()}/${name}`, content)
|
|
}).pipe(Effect.asVoid),
|
|
session: (input) =>
|
|
run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))),
|
|
sessionGet: (sessionID) =>
|
|
run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe(
|
|
Effect.catchCause(() => Effect.succeed(undefined)),
|
|
),
|
|
project: () =>
|
|
Effect.sync(() => {
|
|
if (!instance) throw new Error("scenario needs a project directory")
|
|
return instance.project
|
|
}),
|
|
message: (sessionID, input) =>
|
|
Effect.gen(function* () {
|
|
const info: SessionV1.User = {
|
|
id: MessageID.ascending(),
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: "build",
|
|
model: {
|
|
providerID: ProviderV2.ID.opencode,
|
|
modelID: ModelV2.ID.make("test"),
|
|
},
|
|
}
|
|
const part: SessionV1.TextPart = {
|
|
id: PartID.ascending(),
|
|
sessionID,
|
|
messageID: info.id,
|
|
type: "text",
|
|
text: input?.text ?? "hello",
|
|
}
|
|
yield* run(
|
|
modules.Session.Service.use((svc) =>
|
|
Effect.gen(function* () {
|
|
yield* svc.updateMessage(info)
|
|
yield* svc.updatePart(part)
|
|
}),
|
|
),
|
|
)
|
|
return { info, part }
|
|
}),
|
|
messages: (sessionID) =>
|
|
run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
|
|
todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
|
|
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))),
|
|
worktreeRemove: (directory) =>
|
|
run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
|
|
llmText: (value) => Effect.suspend(() => llm().text(value)),
|
|
llmWait: (count) => Effect.suspend(() => llm().wait(count)),
|
|
tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
|
|
}
|
|
yield* trace(options, scenario, `${label} seed start`)
|
|
const state = yield* scenario.seed(base)
|
|
yield* trace(options, scenario, `${label} seed done`)
|
|
yield* trace(options, scenario, `${label} use start`)
|
|
const result = yield* use({ ...base, state })
|
|
yield* trace(options, scenario, `${label} use done`)
|
|
return result
|
|
}).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
|
|
),
|
|
Effect.ensuring(scenario.reset ? resetState : Effect.void),
|
|
)
|
|
}
|
|
|
|
function trace(options: Options, scenario: ActiveScenario, phase: string) {
|
|
return Effect.sync(() => {
|
|
if (!options.trace) return
|
|
console.log(`[trace] ${scenario.name}: ${phase}`)
|
|
})
|
|
}
|
|
|
|
function projectOptions(
|
|
project: ProjectOptions,
|
|
llmUrl: string | undefined,
|
|
): { git?: boolean; config?: Partial<ConfigV1.Info>; init?: (directory: string) => Promise<void> } {
|
|
if (!project.llm || !llmUrl) return { git: project.git, config: project.config, init: project.init }
|
|
const fake = fakeLlmConfig(llmUrl)
|
|
return {
|
|
git: project.git,
|
|
init: project.init,
|
|
config: {
|
|
...fake,
|
|
...project.config,
|
|
provider: {
|
|
...fake.provider,
|
|
...project.config?.provider,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function fakeLlmConfig(url: string): Partial<ConfigV1.Info> {
|
|
return {
|
|
model: "test/test-model",
|
|
small_model: "test/test-model",
|
|
provider: {
|
|
test: {
|
|
name: "Test",
|
|
id: "test",
|
|
env: [],
|
|
npm: "@ai-sdk/openai-compatible",
|
|
models: {
|
|
"test-model": {
|
|
id: "test-model",
|
|
name: "Test Model",
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: false,
|
|
tool_call: true,
|
|
release_date: "2025-01-01",
|
|
limit: { context: 100000, output: 10000 },
|
|
cost: { input: 0, output: 0 },
|
|
options: {},
|
|
},
|
|
},
|
|
options: {
|
|
apiKey: "test-key",
|
|
baseURL: url,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const resetState = Effect.promise(async () => {
|
|
const modules = await runtime()
|
|
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
|
|
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
|
|
await disposeApps()
|
|
await modules.disposeAllInstances()
|
|
// kilocode_change - each exerciser process already owns an isolated DB; unlinking it between scenarios races async Kilo callbacks
|
|
await Bun.sleep(25)
|
|
})
|